diff --git a/.github/scripts/validate-branch-policy.sh b/.github/scripts/validate-branch-policy.sh new file mode 100755 index 00000000..0fb7ee27 --- /dev/null +++ b/.github/scripts/validate-branch-policy.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +failed=0 + +error() { + echo "::error::$*" + failed=1 +} + +warning() { + echo "::warning::$*" +} + +active_branch_file="support/ci/ACTIVE_DEV_BRANCH" +if [[ ! -f "${active_branch_file}" ]]; then + error "${active_branch_file} is required." + active_branch="" +else + active_branch="$(tr -d '[:space:]' < "${active_branch_file}")" +fi + +if [[ -z "${active_branch}" ]]; then + error "${active_branch_file} must not be empty." +elif [[ "${active_branch}" == "main" ]]; then + error "${active_branch_file} must point to a dev branch, not main." +elif [[ "${active_branch}" != *-dev ]]; then + warning "${active_branch_file} should normally point to a -dev branch; got ${active_branch}." +fi + +release_branch="${active_branch%-dev}" +default_branch="${GITHUB_DEFAULT_BRANCH:-}" +event_name="${GITHUB_EVENT_NAME:-local}" +base_ref="${GITHUB_BASE_REF:-}" +head_ref="${GITHUB_HEAD_REF:-}" +ref_name="${GITHUB_REF_NAME:-}" +actor="${GITHUB_ACTOR:-}" + +if [[ -n "${default_branch}" && "${default_branch}" != "main" ]]; then + warning "Repository default branch should be main after branch-policy rollout; currently ${default_branch}." +fi + +if [[ -n "${base_ref}" && "${base_ref}" == "main" ]]; then + warning "PR targets main; retarget-main-prs should move it to ${active_branch}." +fi + +if [[ -n "${base_ref}" && -n "${active_branch}" ]]; then + if [[ "${base_ref}" == "${release_branch}" && "${head_ref}" != "${active_branch}" && "${ALLOW_DIRECT_RELEASE_PR:-false}" != "true" ]]; then + error "PRs into ${release_branch} must come from ${active_branch}. Merge feature work into ${active_branch}, then promote ${active_branch} -> ${release_branch}." + fi +fi + +if [[ "${event_name}" == "push" && "${ref_name}" == "main" ]]; then + case "${actor}" in + github-actions[bot]|ci-core-e2e-runner[bot]) + ;; + *) + error "main should only move by automation from ${active_branch}; direct push actor was ${actor:-unknown}." + ;; + esac +fi + +if [[ ! -f ".github/workflows/fast-forward-main.yaml" ]]; then + error ".github/workflows/fast-forward-main.yaml is required." +fi + +if [[ ! -f ".github/workflows/retarget-main-prs.yaml" ]]; then + error ".github/workflows/retarget-main-prs.yaml is required." +fi + +if [[ -f ".github/workflows/release-from-main.yml" ]]; then + error ".github/workflows/release-from-main.yml is forbidden. Releases must be tag/version-branch driven." +fi + +if [[ -f "release.config.js" ]]; then + error "release.config.js is forbidden in versioned tooling branches; semantic-release-on-main must not be restored." +fi + +if [[ -f ".github/workflows/release-from-tag.yml" ]]; then + if ! grep -Fq 'v*.*.*' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must trigger only from version tags matching v*.*.*." + fi + if ! grep -Fq 'refs/remotes/origin/${version_branch}' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'tag_commit' .github/workflows/release-from-tag.yml || \ + ! grep -Fq 'branch_head' .github/workflows/release-from-tag.yml; then + error "release-from-tag.yml must verify the tag commit is the current matching version branch head." + fi +fi + +if [[ -f ".github/workflows/manual-docker-release.yml" ]]; then + if ! grep -Fq 'expected_branch=' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must derive and enforce the expected version branch from the tag." + fi + if ! grep -Fq './.github/workflows/release-from-tag.yml' .github/workflows/manual-docker-release.yml; then + error "manual-docker-release.yml must delegate image promotion to release-from-tag.yml." + fi +fi + +if [[ "${failed}" -ne 0 ]]; then + exit 1 +fi + +if [[ -n "${base_ref}" ]]; then + echo "Branch policy ok for PR ${head_ref} -> ${base_ref}; active dev branch is ${active_branch}." +else + echo "Branch policy ok for ${event_name} on ${ref_name:-detached ref}; active dev branch is ${active_branch}." +fi diff --git a/.github/workflows/branch-policy.yml b/.github/workflows/branch-policy.yml new file mode 100644 index 00000000..7759870f --- /dev/null +++ b/.github/workflows/branch-policy.yml @@ -0,0 +1,24 @@ +name: Branch Policy + +on: + pull_request: + types: [opened, synchronize, reopened, edited, ready_for_review] + push: + branches: + - "**" + workflow_dispatch: + +permissions: + contents: read + +jobs: + branch-policy: + name: Validate branch policy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate branch policy + env: + GITHUB_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + run: ./.github/scripts/validate-branch-policy.sh diff --git a/.github/workflows/e2e-wallet.yml b/.github/workflows/e2e-wallet.yml new file mode 100644 index 00000000..0af08e2f --- /dev/null +++ b/.github/workflows/e2e-wallet.yml @@ -0,0 +1,63 @@ +# Tier-2 browser-wallet signing e2e (Playwright + mock provider on anvil). +# +# INFORMATIONAL / NOT A REQUIRED GATE (initially). This job drives a headless +# chromium against the real bridge page with a viem-backed mock window.ethereum +# that signs+broadcasts to an ephemeral anvil, exercising the full +# connect -> sign -> receipt loop with zero MetaMask. Keep it OFF the required +# status checks in branch protection until it has proven stable across a few +# weeks of PRs, then promote it to required. +# +# Distinct filename from the synced cross-repo e2e.yml (the /run-e2e cucumber +# pipeline) so the two never collide. +name: E2E Wallet (Tier 2) + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: + - v0.40 + - v0.40-dev + +jobs: + e2e-wallet: + name: Browser-wallet e2e (anvil lanes) + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install libsecret runtime + run: sudo apt-get update && sudo apt-get install -y libsecret-1-0 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - name: Install dependencies + run: npm ci + + - name: Build the project + run: npm run build + + - name: Install Foundry (anvil) + uses: foundry-rs/foundry-toolchain@v1 + + - name: Install Playwright chromium + run: npx playwright install --with-deps chromium + + - name: Run Tier-2 e2e (anvil lanes) + run: npm run test:e2e + + - name: Upload Playwright report + if: always() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/fast-forward-main.yaml b/.github/workflows/fast-forward-main.yaml new file mode 100644 index 00000000..688e997a --- /dev/null +++ b/.github/workflows/fast-forward-main.yaml @@ -0,0 +1,57 @@ +name: Fast-forward main + +# main is the static/default branch for GitHub UX and tools that assume a +# stable default branch. It is not the integration target. On each push to the +# configured active dev branch, fast-forward main to that commit. + +on: + push: + branches: ["**"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: fast-forward-main-${{ github.repository }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +jobs: + fast-forward: + if: github.ref_type == 'branch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fast-forward main to active dev branch + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + if [[ "${GITHUB_REF_NAME}" != "${active_branch}" ]]; then + echo "Push was to ${GITHUB_REF_NAME}; active dev branch is ${active_branch}. Nothing to do." + exit 0 + fi + + if git ls-remote --exit-code --heads origin main >/dev/null 2>&1; then + git fetch origin main + if ! git merge-base --is-ancestor origin/main HEAD; then + echo "::error::main has diverged from ${active_branch}; refusing non-fast-forward update" + exit 1 + fi + else + echo "main does not exist yet; creating it at ${GITHUB_SHA}." + fi + + git push origin "HEAD:refs/heads/main" diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a8c7dea2..9364859a 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-clarke.1 publish to a matching +# channel dist-tag (clarke) 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,14 +52,37 @@ 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 + PRERELEASE_SUFFIX="${TAG_VERSION#*-}" + PRERELEASE_SUFFIX="${PRERELEASE_SUFFIX%%+*}" + NPM_DIST_TAG="${PRERELEASE_SUFFIX%%.*}" + + 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 }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN || secrets.NPM_TOKEN }} - name: Create GitHub Release env: @@ -62,6 +96,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/retarget-main-prs.yaml b/.github/workflows/retarget-main-prs.yaml new file mode 100644 index 00000000..37a066f7 --- /dev/null +++ b/.github/workflows/retarget-main-prs.yaml @@ -0,0 +1,53 @@ +name: Retarget main PRs + +# main is a static/default alias of the active dev branch. Contributions should +# target the active dev branch directly; PRs opened against main are retargeted +# automatically so required checks and release-train rules run in the right +# branch context. +# +# pull_request_target is used for the write-scoped token. This workflow never +# checks out or executes PR head code; it reads only trusted base-branch files. + +on: + pull_request_target: + types: [opened, reopened, synchronize, edited, ready_for_review] + +permissions: + contents: read + pull-requests: write + issues: write + +defaults: + run: + shell: bash + +jobs: + retarget: + if: github.event.pull_request.base.ref == 'main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.base.ref }} + + - name: Retarget PR to active dev branch + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + active_branch="$(tr -d '[:space:]' < support/ci/ACTIVE_DEV_BRANCH)" + if [[ -z "${active_branch}" || "${active_branch}" == "main" ]]; then + echo "::error::support/ci/ACTIVE_DEV_BRANCH must name a non-main dev branch" + exit 1 + fi + + gh pr edit "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --base "${active_branch}" + + gh pr comment "${PR_NUMBER}" --repo "${GITHUB_REPOSITORY}" --body "$(cat < 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 cbf1655b..0b4707de 100644 --- a/.github/workflows/validate-code.yml +++ b/.github/workflows/validate-code.yml @@ -9,6 +9,8 @@ on: push: branches: - v0.39 + - v0.40 + - v0.40-dev jobs: build-and-test: @@ -47,5 +49,8 @@ jobs: with: verbose: true token: ${{ secrets.CODECOV_TOKEN }} - fail_ci_if_error: true + # Codecov OIDC upload has been failing on ubuntu/windows since + # before this PR (#345 merged with the same failures) — keep the + # upload best-effort so coverage flakes don't block test-green PRs. + fail_ci_if_error: false directory: coverage diff --git a/.gitignore b/.gitignore index bbf43fff..d3c698e5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,9 @@ node_modules dist .idea coverage -.ollama \ No newline at end of file +.ollama + +# Playwright (Tier-2 e2e) artifacts +test-results +playwright-report +.playwright \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index c207c6db..0e005f65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,71 @@ # Changelog +## [0.40.0-clarke.4](https://github.com/genlayerlabs/genlayer-cli/compare/v0.40.0-clarke.3...v0.40.0-clarke.4) (2026-07-10) + +### Features + +* **network:** --explorer override for custom networks; stop inheriting the base explorer ([#393](https://github.com/genlayerlabs/genlayer-cli/issues/393)) ([015e6d4](https://github.com/genlayerlabs/genlayer-cli/commit/015e6d46d4223a569207c12a2436fbce0dea7c6f)) +* **staking:** validator eligibility warnings + clean validator-info view ([#394](https://github.com/genlayerlabs/genlayer-cli/issues/394)) ([dd77d5b](https://github.com/genlayerlabs/genlayer-cli/commit/dd77d5bab743a03880e82d5bd4de7cfd2efad760)) +* **wallet:** EIP-6963 multi-wallet discovery in the browser bridge ([#392](https://github.com/genlayerlabs/genlayer-cli/issues/392)) ([74952bd](https://github.com/genlayerlabs/genlayer-cli/commit/74952bd8fb42499564fc9096f2609449a7185fbb)) + +## [0.40.0-clarke.3](https://github.com/genlayerlabs/genlayer-cli/compare/v0.40.0-clarke.2...v0.40.0-clarke.3) (2026-07-10) + +### Features + +* **staking:** non-interactive wizard mode ([#384](https://github.com/genlayerlabs/genlayer-cli/issues/384)) ([ef47935](https://github.com/genlayerlabs/genlayer-cli/commit/ef47935f81f17119caafb4d3121874a27308a40c)) +* **staking:** wizard guides identity on the vesting funding path ([#386](https://github.com/genlayerlabs/genlayer-cli/issues/386)) ([6249e26](https://github.com/genlayerlabs/genlayer-cli/commit/6249e26d68ce12236e4fda6512480778d2990621)) + +### Bug Fixes + +* **account:** clear error for password prompts with no TTY ([#390](https://github.com/genlayerlabs/genlayer-cli/issues/390)) ([0ce6b1b](https://github.com/genlayerlabs/genlayer-cli/commit/0ce6b1b19d4d6fb17ce52569a74574212c8d0d53)) +* **balances:** degrade gracefully when staking is unavailable ([#389](https://github.com/genlayerlabs/genlayer-cli/issues/389)) ([8cdcb8a](https://github.com/genlayerlabs/genlayer-cli/commit/8cdcb8aea4c17fcb4f671f5d03bf7decf784618b)), closes [#2](https://github.com/genlayerlabs/genlayer-cli/issues/2) +* **balances:** wallet-only view when consensus infra is not deployed ([#391](https://github.com/genlayerlabs/genlayer-cli/issues/391)) ([f2d9c98](https://github.com/genlayerlabs/genlayer-cli/commit/f2d9c9837f88be42e53e0a4dcc94322e1d15b5b6)), closes [#389](https://github.com/genlayerlabs/genlayer-cli/issues/389) +* **cli:** read commands honor a live wallet session (connect-once identity) ([#385](https://github.com/genlayerlabs/genlayer-cli/issues/385)) ([923475d](https://github.com/genlayerlabs/genlayer-cli/commit/923475d93e36dbb8146f2dcb9ff5d80799af99a4)) +* **staking:** route set-operator/validator-claim/set-identity/trace through the SDK ([#387](https://github.com/genlayerlabs/genlayer-cli/issues/387)) ([e70b31b](https://github.com/genlayerlabs/genlayer-cli/commit/e70b31b039bbb67b568628adc7219909e9d0d1af)) +* **staking:** validator-deposit/exit work through the CLI ([#382](https://github.com/genlayerlabs/genlayer-cli/issues/382)) ([3de9a6a](https://github.com/genlayerlabs/genlayer-cli/commit/3de9a6a2ee07bd596d3706228d9584f529ce1e16)) +* **wallet:** harden bridge (verify signer, Host check, constant-time token, 0700, url scrub, body cap) ([#383](https://github.com/genlayerlabs/genlayer-cli/issues/383)) ([e55f917](https://github.com/genlayerlabs/genlayer-cli/commit/e55f917f269ece99c98dee6337695c7e5c459b95)) + +## [0.40.0-clarke.2](https://github.com/genlayerlabs/genlayer-cli/compare/v0.40.0-clarke.1...v0.40.0-clarke.2) (2026-07-09) + +### Features + +* **staking:** browser-wallet signing for validator-join and wizard ([#367](https://github.com/genlayerlabs/genlayer-cli/issues/367)) ([ab128c0](https://github.com/genlayerlabs/genlayer-cli/commit/ab128c0b76b4f9d97ed660d91a556a5f769cef6f)) + +## [0.40.0-clarke.1](https://github.com/genlayerlabs/genlayer-cli/compare/v0.40.0-rc2...v0.40.0-clarke.1) (2026-07-08) + +## [0.40.0-rc2](https://github.com/genlayerlabs/genlayer-cli/compare/v0.40.0-rc1...v0.40.0-rc2) (2026-07-08) + +### Bug Fixes + +* avoid git dependency in npm prerelease install ([#368](https://github.com/genlayerlabs/genlayer-cli/issues/368)) ([3cd1cac](https://github.com/genlayerlabs/genlayer-cli/commit/3cd1cac564932d67e5b1200d4396ca24556d2e46)) + +## [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/CLAUDE.md b/CLAUDE.md index 0666f390..aeb18adc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,9 @@ npx vitest -t "test name pattern" # Run specific test by name ### Entry Point & Command Structure - `src/index.ts` - Main entry, initializes Commander program and registers all command groups - Commands organized in `src/commands//index.ts` - each exports `initialize*Commands(program)` function -- Command domains: general (init/up/stop), account, contracts, config, localnet, update, scaffold, network, transactions, staking +- Command domains: general (init/up/stop), account, contracts, config, localnet, update, scaffold, network, transactions, staking, vesting, wallet, balances +- Custom networks: `network add --base ` registers a custom network profile (RPC/contract-address overrides); resolved by alias everywhere via `--network` +- Browser-wallet signing: write commands (staking/vesting/deploy/write) accept `--wallet browser` to sign in MetaMask through a local bridge (`addWalletModeOption` in `src/lib/wallet/walletOption.ts`); `wallet connect` starts a persistent session so the key never leaves MetaMask ### Core Classes - `BaseAction` (`src/lib/actions/BaseAction.ts`) - Base class for all CLI actions. Provides: 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..585d80a2 100644 --- a/README.md +++ b/README.md @@ -151,15 +151,34 @@ USAGE: genlayer network set [network] Set the network to use genlayer network info Show current network configuration and contract addresses genlayer network list List available networks + genlayer network add Add a custom network profile + genlayer network remove Remove a custom network profile + +OPTIONS (add): + --base Built-in base network to derive from (required) + --rpc Node RPC URL override + --chain-id Chain ID override + --consensus-main ConsensusMain contract address override + --consensus-data ConsensusData contract address override + --staking Staking contract address override + --fee-manager FeeManager contract address override + --deployment Consensus deployments JSON file to read addresses from EXAMPLES: genlayer network set - genlayer network set testnet - genlayer network set mainnet + genlayer network set testnet-bradbury genlayer network info genlayer network list + + # Register a custom network on top of a built-in base, then use it by alias + genlayer network add my-devnet --base testnet-bradbury --rpc https://rpc.example.com --chain-id 4221 + genlayer network set my-devnet + genlayer deploy --network my-devnet ``` +Custom networks are stored by alias and can be selected anywhere a built-in +network can, via `--network ` (or `genlayer network set `). + #### Deploy and Call Intelligent Contracts Deploy and interact with intelligent contracts. @@ -176,6 +195,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 +209,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 +219,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 +233,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 +249,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 +306,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 @@ -441,6 +497,7 @@ COMMON OPTIONS (all commands): OPTIONS (validator-join): --amount Amount to stake (in wei or with 'gen' suffix) --operator
Operator address (defaults to signer) + --wallet 'keystore' (default) or 'browser' (sign in MetaMask) OPTIONS (delegator-join): --validator
Validator address to delegate to @@ -460,6 +517,14 @@ EXAMPLES: # Join as validator with 42000 GEN genlayer staking validator-join --amount 42000gen + # Sign the validator-join with a browser wallet (MetaMask) instead of a keystore. + # The CLI serves a page on 127.0.0.1 and opens it; connect + confirm in the wallet. + # Remote/SSH: forward the printed port first (ssh -L :127.0.0.1: ...). + genlayer staking validator-join --amount 42000gen --wallet browser --network testnet-bradbury + + # The wizard can also use a browser wallet as the owner (operator keystore is still generated locally): + genlayer staking wizard --wallet browser + # Join as delegator with 42 GEN genlayer staking delegator-join --validator 0x... --amount 42gen @@ -539,6 +604,80 @@ EXAMPLES: genlayer staking prime-all ``` +#### Browser Wallet Signing + +By default, write commands sign with your encrypted keystore. Any write command +(`deploy`, `write`, and the `staking`/`vesting` commands) also accepts +`--wallet browser` to sign in MetaMask instead: the CLI serves a small page on +`127.0.0.1`, opens it, and you connect and confirm in the wallet. Your private +key never leaves MetaMask. + +To avoid reconnecting for every command, open a persistent session once with +`wallet connect` and reuse it across subsequent `--wallet browser` commands. + +```bash +USAGE: + genlayer wallet connect [options] Start a persistent browser-wallet session (connect once) + genlayer wallet status Show the current session (address, network, heartbeat) + genlayer wallet disconnect End the active session + +EXAMPLES: + # Connect once, then reuse across commands + genlayer wallet connect --network testnet-bradbury + genlayer deploy --wallet browser + genlayer write 0x123...abc updateValue --args 42 --wallet browser + genlayer wallet status + genlayer wallet disconnect +``` + +For remote/SSH hosts, forward the printed port first +(`ssh -L :127.0.0.1: ...`). The default signing mode can be set via +the `walletMode` config value. + +#### Balances + +Show wallet and vesting balances plus committed stake for an address +(read-only, no keystore unlock). + +```bash +USAGE: + genlayer balances [options] + +OPTIONS: + --beneficiary
Address to inspect (defaults to the active account) + --account Account whose address to use (no unlock) + --network Built-in or custom network alias + --rpc RPC URL for the network + +EXAMPLES: + genlayer balances + genlayer balances --beneficiary 0x123...abc --network testnet-bradbury +``` + +#### Vesting + +Manage vesting contracts and vesting-backed validators. Beneficiaries can list +their vesting contracts, delegate/withdraw vested tokens, and run validators +whose stake is committed from a vesting contract rather than their wallet. + +```bash +USAGE: + genlayer vesting list List beneficiary vesting contracts and state + genlayer vesting delegate Delegate vesting-held tokens to a validator + genlayer vesting withdraw --amount Withdraw vested tokens to the beneficiary + genlayer vesting validator create [operator] --amount Create a vesting-backed validator + genlayer vesting validator list List validator wallets owned by a vesting contract +``` + +The `staking wizard` can fund a validator from either your wallet or a vesting +contract, and sign with a keystore key or a browser wallet — the recommended +path for creating a vesting-backed validator interactively: + +```bash +genlayer staking wizard +genlayer staking wizard --wallet browser +``` + ### Running the CLI from the repository First, install the dependencies and start the build process 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..eba5ac45 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -1,9 +1,16 @@ { + "index": "Overview", "environment": "Environment", "configuration": "Configuration", "contracts": "Contracts", "transactions": "Transactions", "accounts": "Accounts", "staking": "Staking", - "localnet": "Localnet" + "localnet": "Localnet", + "balances": "balances", + "estimate-fees": "estimate-fees", + "finalize": "finalize", + "finalize-batch": "finalize-batch", + "vesting": "vesting", + "wallet": "wallet" } \ 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/accounts/account/show.mdx b/docs/api-references/accounts/account/show.mdx index 189f3899..3c5db44b 100644 --- a/docs/api-references/accounts/account/show.mdx +++ b/docs/api-references/accounts/account/show.mdx @@ -13,5 +13,6 @@ Show account details (address, balance) | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --account <name> | Account to show | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/balances.mdx b/docs/api-references/balances.mdx new file mode 100644 index 00000000..16c32c02 --- /dev/null +++ b/docs/api-references/balances.mdx @@ -0,0 +1,19 @@ +--- +title: balances +--- + +Show wallet + vesting balances and committed stake (read-only) + +### Usage + +`$ genlayer balances [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --beneficiary <address> | Address to inspect (defaults to the active account, no unlock) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --account <name> | Account whose address to use (no unlock) | 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..1fc24809 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -14,4 +14,12 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..a6542d4a 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -18,4 +18,12 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..37fa3895 --- /dev/null +++ b/docs/api-references/finalize-batch.mdx @@ -0,0 +1,21 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..74ad96ea --- /dev/null +++ b/docs/api-references/finalize.mdx @@ -0,0 +1,21 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index 49295cfd..6caf0ea5 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.40.0-clarke.2` ### 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,12 @@ 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 +- `genlayer wallet` — Manage the persistent browser-wallet (MetaMask) signing session +- `genlayer balances` — Show wallet + vesting balances and committed stake (read-only) --- 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..1845fe47 100644 --- a/docs/api-references/staking/staking.mdx +++ b/docs/api-references/staking/staking.mdx @@ -20,7 +20,7 @@ Staking operations for validators and delegators ### Subcommands -- `genlayer wizard` — Interactive wizard to become a validator +- `genlayer wizard` — Interactive wizard to become a validator: funds the stake from your wallet or a vesting contract, and signs with a keystore key or a browser wallet (--wallet browser) - `genlayer validator-join` — Join as a validator by staking tokens - `genlayer validator-deposit` — Make an additional deposit to a validator wallet - `genlayer validator-exit` — Exit as a validator by withdrawing shares @@ -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..9f8ea959 100644 --- a/docs/api-references/staking/staking/delegator-claim.mdx +++ b/docs/api-references/staking/staking/delegator-claim.mdx @@ -20,7 +20,8 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..31e233ec 100644 --- a/docs/api-references/staking/staking/delegator-exit.mdx +++ b/docs/api-references/staking/staking/delegator-exit.mdx @@ -20,7 +20,8 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..2efe155c 100644 --- a/docs/api-references/staking/staking/delegator-join.mdx +++ b/docs/api-references/staking/staking/delegator-join.mdx @@ -20,7 +20,8 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..96a929ef 100644 --- a/docs/api-references/staking/staking/prime-all.mdx +++ b/docs/api-references/staking/staking/prime-all.mdx @@ -14,7 +14,8 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..66fea2cd 100644 --- a/docs/api-references/staking/staking/set-identity.mdx +++ b/docs/api-references/staking/staking/set-identity.mdx @@ -28,6 +28,7 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..bde668f7 100644 --- a/docs/api-references/staking/staking/set-operator.mdx +++ b/docs/api-references/staking/staking/set-operator.mdx @@ -21,6 +21,7 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..64ca0a66 100644 --- a/docs/api-references/staking/staking/validator-claim.mdx +++ b/docs/api-references/staking/staking/validator-claim.mdx @@ -19,6 +19,7 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..029c7071 100644 --- a/docs/api-references/staking/staking/validator-deposit.mdx +++ b/docs/api-references/staking/staking/validator-deposit.mdx @@ -20,6 +20,7 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..383aa2f3 100644 --- a/docs/api-references/staking/staking/validator-exit.mdx +++ b/docs/api-references/staking/staking/validator-exit.mdx @@ -20,6 +20,7 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..09a65a35 100644 --- a/docs/api-references/staking/staking/validator-join.mdx +++ b/docs/api-references/staking/staking/validator-join.mdx @@ -16,7 +16,8 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..0ced6924 100644 --- a/docs/api-references/staking/staking/validator-prime.mdx +++ b/docs/api-references/staking/staking/validator-prime.mdx @@ -19,7 +19,8 @@ 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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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/staking/staking/wizard.mdx b/docs/api-references/staking/staking/wizard.mdx index 60733ceb..1da536cc 100644 --- a/docs/api-references/staking/staking/wizard.mdx +++ b/docs/api-references/staking/staking/wizard.mdx @@ -2,7 +2,9 @@ title: staking wizard --- -Interactive wizard to become a validator +Interactive wizard to become a validator: funds the stake from your wallet or a +vesting contract, and signs with a keystore key or a browser wallet (--wallet +browser) ### Usage @@ -17,4 +19,5 @@ Interactive wizard to become a validator | | --skip-identity | Skip identity setup step | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/transactions/appeal.mdx b/docs/api-references/transactions/appeal.mdx index 606c70c1..271a6a93 100644 --- a/docs/api-references/transactions/appeal.mdx +++ b/docs/api-references/transactions/appeal.mdx @@ -18,4 +18,5 @@ Appeal a transaction by its hash | --- | --- | --- | :---: | --- | | | --bond <amount> | Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..0d068d80 --- /dev/null +++ b/docs/api-references/vesting/claim.mdx @@ -0,0 +1,28 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..3b984e6f --- /dev/null +++ b/docs/api-references/vesting/delegate.mdx @@ -0,0 +1,29 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..c1c602c7 --- /dev/null +++ b/docs/api-references/vesting/undelegate.mdx @@ -0,0 +1,28 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..8b359a80 --- /dev/null +++ b/docs/api-references/vesting/validator/claim.mdx @@ -0,0 +1,28 @@ +--- +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 | +| --- | --- | --- | :---: | --- | +| | --validator-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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..f6b28c43 --- /dev/null +++ b/docs/api-references/vesting/validator/create.mdx @@ -0,0 +1,29 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..fcf8099b --- /dev/null +++ b/docs/api-references/vesting/validator/deposit.mdx @@ -0,0 +1,29 @@ +--- +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 | | +| | --validator-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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..a44f12cf --- /dev/null +++ b/docs/api-references/vesting/validator/exit.mdx @@ -0,0 +1,29 @@ +--- +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 | | +| | --validator-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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..35a4a9f1 --- /dev/null +++ b/docs/api-references/vesting/validator/join.mdx @@ -0,0 +1,29 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..504afe5d --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx @@ -0,0 +1,28 @@ +--- +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 | +| --- | --- | --- | :---: | --- | +| | --validator-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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..20af8759 --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/complete.mdx @@ -0,0 +1,28 @@ +--- +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 | +| --- | --- | --- | :---: | --- | +| | --validator-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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..473d51fc --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx @@ -0,0 +1,30 @@ +--- +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 | | +| | --validator-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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..2379836d --- /dev/null +++ b/docs/api-references/vesting/validator/set-identity.mdx @@ -0,0 +1,37 @@ +--- +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 | | +| | --validator-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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | 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..a065322e --- /dev/null +++ b/docs/api-references/vesting/withdraw.mdx @@ -0,0 +1,24 @@ +--- +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 | | +| | --wallet <mode> | Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>). Defaults to the 'walletMode' config value, else 'keystore'. | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/wallet.mdx b/docs/api-references/wallet.mdx new file mode 100644 index 00000000..6a6eeb4d --- /dev/null +++ b/docs/api-references/wallet.mdx @@ -0,0 +1,25 @@ +--- +title: wallet +--- + +Manage the persistent browser-wallet (MetaMask) signing session + +### Usage + +`$ genlayer wallet [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer connect` — Start a persistent browser-wallet session (connect once, +- `genlayer status` — Show the current browser-wallet session (address, network, +- `genlayer disconnect` — End the active browser-wallet session diff --git a/docs/api-references/wallet/connect.mdx b/docs/api-references/wallet/connect.mdx new file mode 100644 index 00000000..a5da010f --- /dev/null +++ b/docs/api-references/wallet/connect.mdx @@ -0,0 +1,17 @@ +--- +title: wallet connect +--- + +Start a persistent browser-wallet session (connect once, reuse across commands) + +### Usage + +`$ genlayer wallet connect [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --network <network> | Network alias to connect on (defaults to config network) | No | | +| | --rpc <rpc> | Override the RPC URL | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/wallet/disconnect.mdx b/docs/api-references/wallet/disconnect.mdx new file mode 100644 index 00000000..8c2554ec --- /dev/null +++ b/docs/api-references/wallet/disconnect.mdx @@ -0,0 +1,15 @@ +--- +title: wallet disconnect +--- + +End the active browser-wallet session + +### Usage + +`$ genlayer wallet disconnect [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/wallet/status.mdx b/docs/api-references/wallet/status.mdx new file mode 100644 index 00000000..00bdea34 --- /dev/null +++ b/docs/api-references/wallet/status.mdx @@ -0,0 +1,15 @@ +--- +title: wallet status +--- + +Show the current browser-wallet session (address, network, heartbeat, queue) + +### Usage + +`$ genlayer wallet status [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | diff --git a/e2e/config-default.e2e.ts b/e2e/config-default.e2e.ts new file mode 100644 index 00000000..5a2674c0 --- /dev/null +++ b/e2e/config-default.e2e.ts @@ -0,0 +1,69 @@ +import {test, expect, type Browser} from "@playwright/test"; +import {startAnvil, readStubCallCount, type AnvilHandle} from "./fixtures/chain"; +import {makeScratchEnv, runCli, readDescriptor, isPidAlive, type ScratchEnv} from "./fixtures/cli"; +import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/bridgePage"; + +/** + * S5 — config default `walletMode=browser`. With the config set and a live + * session, a bare `validator-join` (no --wallet) signs via the browser session; + * an explicit `--wallet keystore` overrides it and takes the keystore path + * (which errors here because no keystore exists — proving it did NOT enqueue). + */ +const CHAIN_ID = 61343; +const HASH_RE = /0x[0-9a-fA-F]{64}/; + +test.describe.serial("S5 config default walletMode=browser", () => { + let anvil: AnvilHandle; + let browser: Browser; + let driver: BridgeDriver; + let scratch: ScratchEnv; + + test.beforeAll(async () => { + anvil = await startAnvil({chainId: CHAIN_ID}); + browser = await launchBrowser(); + scratch = makeScratchEnv({ + chainId: CHAIN_ID, + rpcUrl: anvil.rpcUrl, + stubAddress: anvil.stubAddress, + walletMode: "browser", + timing: {longPollMs: 1000}, + }); + ({driver} = await establishSession(browser, anvil, scratch)); + }); + + test.afterAll(async () => { + await driver?.close().catch(() => {}); + const d = readDescriptor(scratch); + if (d && isPidAlive(d.pid)) { + try { + process.kill(d.pid, "SIGKILL"); + } catch { + /* gone */ + } + } + await anvil?.stop(); + }); + + test("bare validator-join signs via session (no --wallet flag)", async () => { + const before = await readStubCallCount(anvil); + const res = await runCli(["staking", "validator-join", "--force", "--amount", "1"], scratch); + expect(res.all).toContain("Validator created successfully!"); + expect(res.all.match(HASH_RE)?.[0]).toBeTruthy(); + expect(await readStubCallCount(anvil)).toBe(before + 1); + }); + + test("--wallet keystore overrides config, takes keystore path (no enqueue)", async () => { + const before = await readStubCallCount(anvil); + const res = await runCli( + ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "keystore"], + scratch, + ); + // Keystore path selected: it fails on the missing account rather than + // signing via the browser session. + expect(res.all).not.toContain("Validator created successfully!"); + expect(res.all.toLowerCase()).toContain("not found"); + expect(res.exitCode).not.toBe(0); + // The browser session was never used → no new recorded call on the stub. + expect(await readStubCallCount(anvil)).toBe(before); + }); +}); diff --git a/e2e/errors.e2e.ts b/e2e/errors.e2e.ts new file mode 100644 index 00000000..4cf6cab2 --- /dev/null +++ b/e2e/errors.e2e.ts @@ -0,0 +1,128 @@ +import {test, expect, type Browser} from "@playwright/test"; +import {startAnvil, type AnvilHandle} from "./fixtures/chain"; +import { + makeScratchEnv, + runCli, + readDescriptor, + daemonGet, + isPidAlive, + waitUntil, + type ScratchEnv, +} from "./fixtures/cli"; +import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/bridgePage"; + +/** + * S6 (subset) — deterministic error lanes with short timing overrides so they + * resolve in seconds: + * - user reject (4001): CLI surfaces "Transaction rejected in wallet", exits + * non-zero, and the session stays usable (daemon still pinging). + * - tab closed: page.close() → heartbeat goes stale → the next command fails + * fast with "tab appears to be closed", never hanging on a dead tab. + */ + +test.describe.serial("S6a user reject (4001)", () => { + const CHAIN_ID = 61344; + let anvil: AnvilHandle; + let browser: Browser; + let driver: BridgeDriver; + let scratch: ScratchEnv; + + test.beforeAll(async () => { + anvil = await startAnvil({chainId: CHAIN_ID}); + browser = await launchBrowser(); + scratch = makeScratchEnv({ + chainId: CHAIN_ID, + rpcUrl: anvil.rpcUrl, + stubAddress: anvil.stubAddress, + timing: {longPollMs: 1000}, + }); + ({driver} = await establishSession(browser, anvil, scratch, {behavior: "reject"})); + }); + + test.afterAll(async () => { + await driver?.close().catch(() => {}); + const d = readDescriptor(scratch); + if (d && isPidAlive(d.pid)) { + try { + process.kill(d.pid, "SIGKILL"); + } catch { + /* gone */ + } + } + await anvil?.stop(); + }); + + test("reject surfaces the message, exits non-zero, session survives", async () => { + const res = await runCli( + ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"], + scratch, + ); + expect(res.all).toContain("Transaction rejected in wallet"); + expect(res.exitCode).not.toBe(0); + + // Session stays usable: descriptor present, daemon still answers /api/ping. + const d = readDescriptor(scratch); + expect(d).not.toBeNull(); + expect(isPidAlive(d!.pid)).toBe(true); + const ping = await daemonGet(d!, "/api/ping"); + expect(ping.status).toBe(200); + }); +}); + +test.describe.serial("S6b tab closed", () => { + const CHAIN_ID = 61345; + const HEARTBEAT_DEAD_MS = 2000; + let anvil: AnvilHandle; + let browser: Browser; + let driver: BridgeDriver; + let scratch: ScratchEnv; + + test.beforeAll(async () => { + anvil = await startAnvil({chainId: CHAIN_ID}); + browser = await launchBrowser(); + scratch = makeScratchEnv({ + chainId: CHAIN_ID, + rpcUrl: anvil.rpcUrl, + stubAddress: anvil.stubAddress, + timing: {longPollMs: 500, heartbeatDeadMs: HEARTBEAT_DEAD_MS}, + }); + ({driver} = await establishSession(browser, anvil, scratch)); + }); + + test.afterAll(async () => { + await driver?.close().catch(() => {}); + const d = readDescriptor(scratch); + if (d && isPidAlive(d.pid)) { + try { + process.kill(d.pid, "SIGKILL"); + } catch { + /* gone */ + } + } + await anvil?.stop(); + }); + + test("closed tab → fail-fast 'tab appears to be closed'", async () => { + const d = readDescriptor(scratch)!; + await driver.closePage(); + + // Wait for the page heartbeat to go stale (no more polls after close). + const stale = await waitUntil( + async () => { + const {body} = await daemonGet(d, "/api/state"); + const last = (body as {lastPagePollAt?: number}).lastPagePollAt ?? 0; + return last > 0 && Date.now() - last > HEARTBEAT_DEAD_MS; + }, + {timeoutMs: 10_000, intervalMs: 200}, + ); + expect(stale, "heartbeat should go stale after tab close").toBe(true); + + const res = await runCli( + ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"], + scratch, + {timeoutMs: 20_000}, + ); + expect(res.all.toLowerCase()).toContain("tab appears to be closed"); + expect(res.exitCode).not.toBe(0); + }); +}); diff --git a/e2e/fixtures/StakingStub.json b/e2e/fixtures/StakingStub.json new file mode 100644 index 00000000..1cd8d180 --- /dev/null +++ b/e2e/fixtures/StakingStub.json @@ -0,0 +1,114 @@ +{ + "abi": [ + { + "type": "function", + "name": "callCount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastAmount", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastOperator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "lastValidator", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "validatorJoin", + "inputs": [ + { + "name": "_operator", + "type": "address", + "internalType": "address" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "payable" + }, + { + "type": "function", + "name": "validatorJoin", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "payable" + }, + { + "type": "event", + "name": "ValidatorJoin", + "inputs": [ + { + "name": "operator", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "validator", + "type": "address", + "indexed": false, + "internalType": "address" + }, + { + "name": "amount", + "type": "uint256", + "indexed": false, + "internalType": "uint256" + } + ], + "anonymous": false + } + ], + "bytecode": "0x6080604052348015600e575f5ffd5b5061050e8061001c5f395ff3fe608060405260043610610054575f3560e01c806301fe0a781461005857806330e7349f1461008857806330ebe468146100b25780634b28f9a2146100d05780636eb3cd49146100fa578063829a86d914610124575b5f5ffd5b610072600480360381019061006d919061032f565b61014e565b60405161007f9190610369565b60405180910390f35b348015610093575f5ffd5b5061009c61015f565b6040516100a99190610369565b60405180910390f35b6100ba610184565b6040516100c79190610369565b60405180910390f35b3480156100db575f5ffd5b506100e4610193565b6040516100f1919061039a565b60405180910390f35b348015610105575f5ffd5b5061010e610198565b60405161011b9190610369565b60405180910390f35b34801561012f575f5ffd5b506101386101bd565b604051610145919061039a565b60405180910390f35b5f610158826101c3565b9050919050565b60025f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b5f61018e336101c3565b905090565b5f5481565b60015f9054906101000a900473ffffffffffffffffffffffffffffffffffffffff1681565b60035481565b5f60015f5f8282546101d591906103e0565b92505081905550335f546040516020016101f0929190610478565b604051602081830303815290604052805190602001205f1c90508160015f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055508060025f6101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff160217905550346003819055507f2b7297b31452d0a13e57c605870ec3c9b4ac6ef33e5cbae7a0f00bc2a3e967828282346040516102c4939291906104a3565b60405180910390a1919050565b5f5ffd5b5f73ffffffffffffffffffffffffffffffffffffffff82169050919050565b5f6102fe826102d5565b9050919050565b61030e816102f4565b8114610318575f5ffd5b50565b5f8135905061032981610305565b92915050565b5f60208284031215610344576103436102d1565b5b5f6103518482850161031b565b91505092915050565b610363816102f4565b82525050565b5f60208201905061037c5f83018461035a565b92915050565b5f819050919050565b61039481610382565b82525050565b5f6020820190506103ad5f83018461038b565b92915050565b7f4e487b71000000000000000000000000000000000000000000000000000000005f52601160045260245ffd5b5f6103ea82610382565b91506103f583610382565b925082820190508082111561040d5761040c6103b3565b5b92915050565b5f8160601b9050919050565b5f61042982610413565b9050919050565b5f61043a8261041f565b9050919050565b61045261044d826102f4565b610430565b82525050565b5f819050919050565b61047261046d82610382565b610458565b82525050565b5f6104838285610441565b6014820191506104938284610461565b6020820191508190509392505050565b5f6060820190506104b65f83018661035a565b6104c3602083018561035a565b6104d0604083018461038b565b94935050505056fea26469706673582212202ea3a8dac16f254ee61dc37f81eb395845a459b3af57d33fab7a95c141e032b664736f6c63430008210033" +} diff --git a/e2e/fixtures/StakingStub.sol b/e2e/fixtures/StakingStub.sol new file mode 100644 index 00000000..758043ee --- /dev/null +++ b/e2e/fixtures/StakingStub.sol @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.13; + +/// Minimal recording stub for the Tier-2 browser-wallet e2e harness. +/// Mimics the Staking `validatorJoin` selectors and emits the ValidatorJoin +/// event (address operator, address validator, uint256 amount) the CLI decodes. +/// It stands up NO consensus; it only records the call so the sign->broadcast +/// ->receipt loop can be asserted end to end. +contract StakingStub { + event ValidatorJoin(address operator, address validator, uint256 amount); + + uint256 public callCount; + address public lastOperator; + address public lastValidator; + uint256 public lastAmount; + + function validatorJoin() external payable returns (address) { + return _join(msg.sender); + } + + function validatorJoin(address _operator) external payable returns (address) { + return _join(_operator); + } + + function _join(address operator) internal returns (address validator) { + callCount += 1; + validator = address(uint160(uint256(keccak256(abi.encodePacked(msg.sender, callCount))))); + lastOperator = operator; + lastValidator = validator; + lastAmount = msg.value; + emit ValidatorJoin(operator, validator, msg.value); + } +} diff --git a/e2e/fixtures/chain.ts b/e2e/fixtures/chain.ts new file mode 100644 index 00000000..940ce78d --- /dev/null +++ b/e2e/fixtures/chain.ts @@ -0,0 +1,141 @@ +/** + * Ephemeral anvil fixture for the Tier-2 anvil lanes. + * + * Boots a private `anvil` on a random port, deploys the recording StakingStub + * (e2e/fixtures/StakingStub.sol) with anvil dev key #0, and exposes the RPC + + * signer + stub address. Deterministic: anvil auto-mines instantly and the key + * is the well-known anvil account #0, so there is no live-network flakiness. + */ +import {spawn, type ChildProcess} from "node:child_process"; +import {createWalletClient, createPublicClient, http, type Hex} from "viem"; +import {privateKeyToAccount} from "viem/accounts"; +import {readFileSync} from "node:fs"; +import {fileURLToPath} from "node:url"; +import {dirname, resolve} from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** anvil dev account #0 (public, well-known test key — never used off-anvil). */ +export const ANVIL_KEY_0 = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as const; +export const ANVIL_ADDRESS_0 = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" as const; + +const STUB_ARTIFACT = JSON.parse( + readFileSync(resolve(__dirname, "StakingStub.json"), "utf-8"), +) as {abi: unknown[]; bytecode: Hex}; + +export interface AnvilHandle { + rpcUrl: string; + port: number; + chainId: number; + account: {address: `0x${string}`; privateKey: `0x${string}`}; + /** Deployed StakingStub address (the validator-join target). */ + stubAddress: `0x${string}`; + stop: () => Promise; +} + +function makeChain(chainId: number, rpcUrl: string) { + return { + id: chainId, + name: `anvil-e2e-${chainId}`, + nativeCurrency: {name: "Ether", symbol: "ETH", decimals: 18}, + rpcUrls: {default: {http: [rpcUrl]}}, + } as const; +} + +/** Boot anvil, wait until it is listening, deploy the stub, return the handle. */ +export async function startAnvil(opts: {chainId: number}): Promise { + const child: ChildProcess = spawn( + "anvil", + ["--port", "0", "--chain-id", String(opts.chainId), "--accounts", "3"], + {stdio: ["ignore", "pipe", "pipe"]}, + ); + + const port = await new Promise((resolvePort, reject) => { + const timer = setTimeout(() => reject(new Error("anvil did not report a port within 15s")), 15_000); + let buf = ""; + const onData = (chunk: Buffer) => { + buf += chunk.toString(); + const m = buf.match(/Listening on 127\.0\.0\.1:(\d+)/); + if (m) { + clearTimeout(timer); + child.stdout?.off("data", onData); + resolvePort(Number(m[1])); + } + }; + child.stdout?.on("data", onData); + child.once("error", err => { + clearTimeout(timer); + reject(err); + }); + child.once("exit", code => { + clearTimeout(timer); + reject(new Error(`anvil exited early (code ${code})`)); + }); + }); + + const rpcUrl = `http://127.0.0.1:${port}`; + const account = privateKeyToAccount(ANVIL_KEY_0); + const chain = makeChain(opts.chainId, rpcUrl); + + const walletClient = createWalletClient({account, chain, transport: http(rpcUrl)}); + const publicClient = createPublicClient({chain, transport: http(rpcUrl)}); + + // Deploy the recording stub. + const deployHash = await walletClient.deployContract({ + abi: STUB_ARTIFACT.abi as never, + bytecode: STUB_ARTIFACT.bytecode, + account, + chain, + }); + const deployReceipt = await publicClient.waitForTransactionReceipt({hash: deployHash}); + const stubAddress = deployReceipt.contractAddress; + if (!stubAddress) throw new Error("StakingStub deployment produced no contract address"); + + const stop = async (): Promise => { + await new Promise(res => { + if (child.exitCode !== null || child.signalCode) return res(); + child.once("exit", () => res()); + child.kill("SIGKILL"); + // Safety net if the exit event never fires. + setTimeout(() => res(), 2000); + }); + }; + + return { + rpcUrl, + port, + chainId: opts.chainId, + account: {address: ANVIL_ADDRESS_0, privateKey: ANVIL_KEY_0}, + stubAddress: stubAddress as `0x${string}`, + stop, + }; +} + +const STUB_READ_ABI = [ + {name: "callCount", type: "function", stateMutability: "view", inputs: [], outputs: [{type: "uint256"}]}, +] as const; + +/** Read the StakingStub's recorded validator-join count. */ +export async function readStubCallCount(anvil: AnvilHandle): Promise { + const client = createPublicClient({ + chain: makeChain(anvil.chainId, anvil.rpcUrl), + transport: http(anvil.rpcUrl), + }); + const count = await client.readContract({ + address: anvil.stubAddress, + abi: STUB_READ_ABI, + functionName: "callCount", + }); + return Number(count); +} + +/** Assert a tx mined successfully on the anvil chain. */ +export async function receiptSucceeded(anvil: AnvilHandle, hash: `0x${string}`): Promise { + const client = createPublicClient({ + chain: makeChain(anvil.chainId, anvil.rpcUrl), + transport: http(anvil.rpcUrl), + }); + const receipt = await client.getTransactionReceipt({hash}); + return receipt.status === "success"; +} diff --git a/e2e/fixtures/cli.ts b/e2e/fixtures/cli.ts new file mode 100644 index 00000000..11f3a917 --- /dev/null +++ b/e2e/fixtures/cli.ts @@ -0,0 +1,257 @@ +/** + * Hermetic CLI-process fixture for the Tier-2 e2e lanes. + * + * Every command runs the built `dist/index.js` in a child process with a scratch + * HOME, so the session descriptor + config file live under a throwaway + * `/.genlayer` and never touch the developer's real `~/.genlayer` or the + * live network. The scratch config is seeded with a custom network whose + * chain-id / rpc / staking address point at the ephemeral anvil, so + * `ensureChain()` matches and `validator-join` targets the recording stub. + */ +import {spawn, type ChildProcess} from "node:child_process"; +import {mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, statSync} from "node:fs"; +import {tmpdir} from "node:os"; +import {join, resolve} from "node:path"; +import {fileURLToPath} from "node:url"; +import {dirname} from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "..", ".."); +const CLI_ENTRY = join(REPO_ROOT, "dist", "index.js"); + +export const NETWORK_ALIAS = "anvil-e2e"; + +/** Short timing budgets so the error/tab-closed lanes resolve in seconds. */ +export interface TimingOverrides { + longPollMs?: number; + heartbeatDeadMs?: number; + connectTimeoutMs?: number; +} + +export interface ScratchEnv { + home: string; + env: NodeJS.ProcessEnv; + descriptorPath: string; +} + +/** + * Create a scratch HOME with a seeded config pointing at the given anvil chain. + * The returned `env` is passed to every runCli/spawnConnect in the same test. + */ +export function makeScratchEnv(opts: { + chainId: number; + rpcUrl: string; + stubAddress: `0x${string}`; + walletMode?: "browser" | "keystore"; + timing?: TimingOverrides; +}): ScratchEnv { + const home = mkdtempSync(join(tmpdir(), "gl-e2e-")); + const genlayerDir = join(home, ".genlayer"); + mkdirSync(genlayerDir, {recursive: true}); + + const config: Record = { + network: NETWORK_ALIAS, + customNetworks: { + [NETWORK_ALIAS]: { + base: "localnet", + overrides: { + rpcUrl: opts.rpcUrl, + chainId: opts.chainId, + staking: opts.stubAddress, + }, + }, + }, + }; + if (opts.walletMode) config.walletMode = opts.walletMode; + writeFileSync(join(genlayerDir, "genlayer-config.json"), JSON.stringify(config, null, 2)); + + const timing = opts.timing ?? {}; + const env: NodeJS.ProcessEnv = { + ...process.env, + HOME: home, + // Keep chalk/ora output plain so stdout scraping is reliable. + NO_COLOR: "1", + FORCE_COLOR: "0", + // Never auto-open a real browser: the harness drives its own chromium. + GENLAYER_E2E_NO_OPEN: "1", + }; + if (timing.longPollMs) env.GENLAYER_E2E_LONG_POLL_MS = String(timing.longPollMs); + if (timing.heartbeatDeadMs) env.GENLAYER_E2E_HEARTBEAT_DEAD_MS = String(timing.heartbeatDeadMs); + if (timing.connectTimeoutMs) env.GENLAYER_E2E_CONNECT_TIMEOUT_MS = String(timing.connectTimeoutMs); + + return {home, env, descriptorPath: join(genlayerDir, "wallet-session.json")}; +} + +export interface CliResult { + exitCode: number; + stdout: string; + stderr: string; + all: string; +} + +/** Run a CLI command to completion and capture its output + exit code. */ +export function runCli( + args: string[], + scratch: ScratchEnv, + opts: {timeoutMs?: number} = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 60_000; + return new Promise((resolvePromise, reject) => { + const child = spawn(process.execPath, [CLI_ENTRY, ...args], {env: scratch.env}); + let stdout = ""; + let stderr = ""; + let all = ""; + child.stdout.on("data", d => { + stdout += d; + all += d; + }); + child.stderr.on("data", d => { + stderr += d; + all += d; + }); + const timer = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`runCli timed out after ${timeoutMs}ms: ${args.join(" ")}\n${all}`)); + }, timeoutMs); + child.once("error", err => { + clearTimeout(timer); + reject(err); + }); + child.once("exit", code => { + clearTimeout(timer); + resolvePromise({exitCode: code ?? 0, stdout, stderr, all}); + }); + }); +} + +export interface ConnectHandle { + child: ChildProcess; + /** Resolves with the bridge session URL scraped from stdout. */ + waitForUrl(timeoutMs?: number): Promise; + /** Resolves when the connect command reports a successful connection. */ + waitForConnected(timeoutMs?: number): Promise; + kill(): void; +} + +const URL_RE = /http:\/\/127\.0\.0\.1:\d+\/#s=[0-9a-fA-F-]+/; + +/** + * Spawn `wallet connect` (which blocks until the browser connects) and expose + * hooks to scrape the printed session URL and await the "Connected as ..." line. + */ +export function spawnConnect(args: string[], scratch: ScratchEnv): ConnectHandle { + const child = spawn(process.execPath, [CLI_ENTRY, "wallet", "connect", ...args], {env: scratch.env}); + let buf = ""; + const listeners: Array<(chunk: string) => void> = []; + const onData = (d: Buffer) => { + const s = d.toString(); + buf += s; + for (const l of listeners) l(buf); + }; + child.stdout.on("data", onData); + child.stderr.on("data", onData); + + const waitFor = (re: RegExp, label: string, timeoutMs: number): Promise => + new Promise((resolvePromise, reject) => { + const check = (text: string) => { + const m = text.match(re); + if (m) { + const idx = listeners.indexOf(check as never); + if (idx >= 0) listeners.splice(idx, 1); + clearTimeout(timer); + resolvePromise(m[0]); + return true; + } + return false; + }; + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for ${label}. Output so far:\n${buf}`)), + timeoutMs, + ); + child.once("exit", () => { + if (!check(buf)) reject(new Error(`connect exited before ${label}. Output:\n${buf}`)); + }); + if (check(buf)) return; + listeners.push(check as never); + }); + + return { + child, + waitForUrl: (timeoutMs = 30_000) => waitFor(URL_RE, "session URL", timeoutMs), + waitForConnected: (timeoutMs = 30_000) => + waitFor(/Connected as (0x[0-9a-fA-F]{40})/, "connection", timeoutMs), + kill: () => { + try { + child.kill("SIGKILL"); + } catch { + // already gone + } + }, + }; +} + +// --- Descriptor / daemon inspection helpers --------------------------------- + +export interface Descriptor { + version: number; + pid: number; + port: number; + token: string; + address: string | null; + chainId: number; + network: string; + rpcUrl: string; + createdAt: number; + lastUsed: number; +} + +export function readDescriptor(scratch: ScratchEnv): Descriptor | null { + if (!existsSync(scratch.descriptorPath)) return null; + try { + return JSON.parse(readFileSync(scratch.descriptorPath, "utf-8")) as Descriptor; + } catch { + return null; + } +} + +/** Octal file mode (e.g. 0o600 → 384) of the descriptor, or null if absent. */ +export function descriptorMode(scratch: ScratchEnv): number | null { + if (!existsSync(scratch.descriptorPath)) return null; + return statSync(scratch.descriptorPath).mode & 0o777; +} + +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e: unknown) { + return (e as {code?: string})?.code === "EPERM"; + } +} + +/** Authenticated GET against the running daemon (127.0.0.1:). */ +export async function daemonGet( + descriptor: Descriptor, + path: string, +): Promise<{status: number; body: unknown}> { + const res = await fetch(`http://127.0.0.1:${descriptor.port}${path}`, { + headers: {"X-Bridge-Token": descriptor.token}, + }); + const body = await res.json().catch(() => ({})); + return {status: res.status, body}; +} + +/** Poll until a predicate holds or the deadline passes. */ +export async function waitUntil( + predicate: () => boolean | Promise, + opts: {timeoutMs?: number; intervalMs?: number} = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? 10_000; + const intervalMs = opts.intervalMs ?? 100; + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await predicate()) return true; + if (Date.now() > deadline) return false; + await new Promise(r => setTimeout(r, intervalMs)); + } +} diff --git a/e2e/fixtures/mockProvider.ts b/e2e/fixtures/mockProvider.ts new file mode 100644 index 00000000..d90a85dd --- /dev/null +++ b/e2e/fixtures/mockProvider.ts @@ -0,0 +1,80 @@ +/** + * Injected `window.ethereum` for the Tier-2 browser-signing e2e lanes. + * + * `installMockProvider` returns a self-invoking script string that Playwright + * installs via `page.addInitScript(...)` BEFORE `bridgePage.ts` runs, so the + * mock always wins the injection race (design §4/§9). The mock is a thin + * EIP-1193 shim: account/chain queries are answered locally, and the one + * privileged operation — `eth_sendTransaction` — is delegated to Node via + * `window.__glSign` (wired in helpers/bridgePage.ts), where a real viem local + * account signs and broadcasts to a real anvil and returns a real tx hash. + * + * No key or RPC ever lives in page JS. `behavior` gives deterministic error + * lanes without any human/extension: + * - "approve" — sign+broadcast for real (happy path) + * - "reject" — throw 4001 (user rejected) on eth_sendTransaction + * - "wrong-network" — throw 4901 on wallet_switchEthereumChain + */ +export interface MockProviderOptions { + address: `0x${string}`; + /** Must match GenLayerChain.id targeted by the bridge's ensureChain(). */ + chainIdHex: string; + behavior?: "approve" | "reject" | "wrong-network"; +} + +export const installMockProvider = (opts: MockProviderOptions): string => { + const behavior = opts.behavior ?? "approve"; + return ` + (() => { + let currentChain = ${JSON.stringify(opts.chainIdHex)}; + const ADDR = ${JSON.stringify(opts.address)}; + const provider = { + isMetaMask: true, + _l: {}, + on(ev, cb) { (this._l[ev] = this._l[ev] || []).push(cb); }, + removeListener() {}, + async request({ method, params }) { + switch (method) { + case "eth_requestAccounts": + case "eth_accounts": + return [ADDR]; + case "eth_chainId": + return currentChain; + case "wallet_switchEthereumChain": + ${ + behavior === "wrong-network" + ? 'throw { code: 4901, message: "Wallet is disconnected from the requested chain." };' + : "currentChain = params[0].chainId; return null;" + } + case "wallet_addEthereumChain": + return null; + case "eth_sendTransaction": + ${ + behavior === "reject" + ? 'throw { code: 4001, message: "User rejected the request." };' + : "return await window.__glSign(params[0]);" + } + default: + throw { code: 4200, message: "unsupported " + method }; + } + }, + }; + // Legacy injected global (fallback path when a wallet doesn't speak 6963). + window.ethereum = provider; + // EIP-6963: announce this provider so the bridge's discovery finds it. + // The bridge registers its announceProvider listener then dispatches + // requestProvider; addInitScript runs this before the page, so our + // requestProvider listener is in place when that dispatch happens. + const info = Object.freeze({ + uuid: "00000000-0000-4000-8000-000000000001", + name: "Mock Wallet", + rdns: "io.genlayer.mockwallet", + icon: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiI+PHJlY3Qgd2lkdGg9IjE2IiBoZWlnaHQ9IjE2IiBmaWxsPSIjNGY3Y2ZmIi8+PC9zdmc+", + }); + const announce = () => + window.dispatchEvent(new CustomEvent("eip6963:announceProvider", { detail: Object.freeze({ info, provider }) })); + window.addEventListener("eip6963:requestProvider", announce); + announce(); + })(); + `; +}; diff --git a/e2e/helpers/bridgePage.ts b/e2e/helpers/bridgePage.ts new file mode 100644 index 00000000..cbe1b8ce --- /dev/null +++ b/e2e/helpers/bridgePage.ts @@ -0,0 +1,116 @@ +/** + * Playwright driver for the real bridge page (src/lib/wallet/bridgePage.ts). + * + * Launches a headless chromium, injects the mock `window.ethereum` BEFORE the + * page loads, and wires the page's `window.__glSign` hook to a Node-side viem + * local account that actually signs + broadcasts `eth_sendTransaction` to the + * ephemeral anvil and returns a real tx hash. The full loop therefore runs for + * real — bridge + served page JS + session daemon + chain — with zero human and + * zero extension. + */ +import {chromium, type Browser, type Page} from "@playwright/test"; +import {createWalletClient, http, type Hex} from "viem"; +import {privateKeyToAccount} from "viem/accounts"; +import {installMockProvider, type MockProviderOptions} from "../fixtures/mockProvider"; +import {spawnConnect, type ScratchEnv} from "../fixtures/cli"; +import type {AnvilHandle} from "../fixtures/chain"; + +export interface DriverOptions { + rpcUrl: string; + chainId: number; + privateKey: `0x${string}`; + behavior?: MockProviderOptions["behavior"]; +} + +export interface BridgeDriver { + page: Page; + /** Load the URL with the mock installed and click "Connect wallet". */ + connect(sessionUrl: string): Promise; + /** Close just the tab (simulates the user closing the wallet tab). */ + closePage(): Promise; + /** Tear down the whole browser. */ + close(): Promise; +} + +/** Launch one headless chromium; callers open a driver per session. */ +export async function launchBrowser(): Promise { + return chromium.launch({headless: true}); +} + +export async function openDriver(browser: Browser, opts: DriverOptions): Promise { + const page = await browser.newPage(); + + const account = privateKeyToAccount(opts.privateKey); + const chain = { + id: opts.chainId, + name: `anvil-e2e-${opts.chainId}`, + nativeCurrency: {name: "Ether", symbol: "ETH", decimals: 18}, + rpcUrls: {default: {http: [opts.rpcUrl]}}, + } as const; + const wallet = createWalletClient({account, chain, transport: http(opts.rpcUrl)}); + + // Real signing happens in Node, returning a real on-chain hash. + await page.exposeFunction( + "__glSign", + async (tx: {to: Hex; data?: Hex; value?: string; gas?: string}): Promise => { + const hash = await wallet.sendTransaction({ + account, + chain, + to: tx.to, + data: (tx.data ?? "0x") as Hex, + value: tx.value ? BigInt(tx.value) : undefined, + gas: tx.gas ? BigInt(tx.gas) : undefined, + }); + return hash; + }, + ); + + await page.addInitScript( + installMockProvider({ + address: account.address, + chainIdHex: `0x${opts.chainId.toString(16)}`, + behavior: opts.behavior, + }), + ); + + const connect = async (sessionUrl: string): Promise => { + await page.goto(sessionUrl); + // The page shows the "Connect wallet" button once /api/session resolves. + await page.waitForSelector("#action:not([style*='display: none'])", {timeout: 15_000}); + await page.click("#action"); + }; + + return { + page, + connect, + closePage: () => page.close(), + close: () => browser.close(), + }; +} + +/** + * Full connect dance shared by the lane specs: spawn `wallet connect`, scrape + * its URL, drive the mock page to approve, and wait for "Connected as ...". + * Returns the live driver (keep it open so the page keeps its heartbeat) and + * the connected signer address. + */ +export async function establishSession( + browser: Browser, + anvil: AnvilHandle, + scratch: ScratchEnv, + opts: {behavior?: MockProviderOptions["behavior"]} = {}, +): Promise<{driver: BridgeDriver; address: string}> { + const connect = spawnConnect([], scratch); + const url = await connect.waitForUrl(); + const driver = await openDriver(browser, { + rpcUrl: anvil.rpcUrl, + chainId: anvil.chainId, + privateKey: anvil.account.privateKey, + behavior: opts.behavior, + }); + await driver.connect(url); + const line = await connect.waitForConnected(); + await new Promise(res => connect.child.once("exit", () => res())); + const address = line.match(/0x[0-9a-fA-F]{40}/)?.[0] ?? ""; + return {driver, address}; +} diff --git a/e2e/lane-a-staking.e2e.ts b/e2e/lane-a-staking.e2e.ts new file mode 100644 index 00000000..7b913e28 --- /dev/null +++ b/e2e/lane-a-staking.e2e.ts @@ -0,0 +1,89 @@ +import {test, expect, type Browser} from "@playwright/test"; +import {startAnvil, readStubCallCount, receiptSucceeded, type AnvilHandle} from "./fixtures/chain"; +import {makeScratchEnv, runCli, readDescriptor, isPidAlive, type ScratchEnv} from "./fixtures/cli"; +import {launchBrowser, establishSession, type BridgeDriver} from "./helpers/bridgePage"; + +/** + * Lane A — real sign -> broadcast -> receipt loop against a recording + * StakingStub on anvil. Proves the browser wallet actually signs and the CLI + * observes a real receipt; it does NOT stand up consensus. + * + * S2: one `validator-join --wallet browser` signs and succeeds. + * S4: session reuse — two more sequential joins over the same daemon/tab. + */ +const CHAIN_ID = 61342; +const HASH_RE = /0x[0-9a-fA-F]{64}/; + +test.describe.serial("Lane A staking (validator-join)", () => { + let anvil: AnvilHandle; + let browser: Browser; + let driver: BridgeDriver; + let scratch: ScratchEnv; + + test.beforeAll(async () => { + anvil = await startAnvil({chainId: CHAIN_ID}); + browser = await launchBrowser(); + scratch = makeScratchEnv({ + chainId: CHAIN_ID, + rpcUrl: anvil.rpcUrl, + stubAddress: anvil.stubAddress, + timing: {longPollMs: 1000}, + }); + ({driver} = await establishSession(browser, anvil, scratch)); + }); + + test.afterAll(async () => { + await driver?.close().catch(() => {}); + const d = readDescriptor(scratch); + if (d && isPidAlive(d.pid)) { + try { + process.kill(d.pid, "SIGKILL"); + } catch { + /* gone */ + } + } + await anvil?.stop(); + }); + + test("S2: validator-join --wallet browser signs and mines", async () => { + const before = await readStubCallCount(anvil); + const res = await runCli( + ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"], + scratch, + ); + + expect(res.all).toContain("Validator created successfully!"); + const hash = res.all.match(HASH_RE)?.[0] as `0x${string}` | undefined; + expect(hash, "a tx hash should be printed").toBeTruthy(); + expect(await receiptSucceeded(anvil, hash!)).toBe(true); + expect(await readStubCallCount(anvil)).toBe(before + 1); + }); + + test("S4: session reuse — two sequential joins, one tab, distinct hashes", async () => { + const d0 = readDescriptor(scratch); + expect(d0).not.toBeNull(); + const before = await readStubCallCount(anvil); + + const first = await runCli( + ["staking", "validator-join", "--force", "--amount", "1", "--wallet", "browser"], + scratch, + ); + const second = await runCli( + ["staking", "validator-join", "--force", "--amount", "2", "--wallet", "browser"], + scratch, + ); + + const h1 = first.all.match(HASH_RE)?.[0] as `0x${string}` | undefined; + const h2 = second.all.match(HASH_RE)?.[0] as `0x${string}` | undefined; + expect(h1).toBeTruthy(); + expect(h2).toBeTruthy(); + expect(h1).not.toBe(h2); + expect(await receiptSucceeded(anvil, h1!)).toBe(true); + expect(await receiptSucceeded(anvil, h2!)).toBe(true); + + // Same daemon reused: pid unchanged, single descriptor, both calls recorded. + const d1 = readDescriptor(scratch); + expect(d1!.pid).toBe(d0!.pid); + expect(await readStubCallCount(anvil)).toBe(before + 2); + }); +}); diff --git a/e2e/lane-b-deploy.e2e.ts b/e2e/lane-b-deploy.e2e.ts new file mode 100644 index 00000000..053448eb --- /dev/null +++ b/e2e/lane-b-deploy.e2e.ts @@ -0,0 +1,25 @@ +import {test} from "@playwright/test"; + +/** + * S3 — Lane B: intelligent-contract deploy/write over the browser session. + * + * DEFERRED (nightly / Docker follow-up per design §3, §6). IC deploy goes + * through genlayer-js against ConsensusMain, which only exists on the Docker + * `localnet` studio node (id 61127, RPC :4000/api) — a bare anvil has no + * GenVM/consensus, so this lane cannot run on the per-PR anvil harness. + * + * When implemented, this describe block boots `genlayer localnet up`, connects + * the session against localnet, deploys a trivial `.py` IC fixture through the + * session's eip1193Provider, and asserts the returned contract address + a + * read-back. It is guarded behind GENLAYER_E2E_LOCALNET so it never runs (and + * never fails) on the standard anvil CI job. + */ +const LOCALNET_ENABLED = process.env.GENLAYER_E2E_LOCALNET === "1"; + +test.describe.skip("S3 Lane B IC deploy on Docker localnet (nightly)", () => { + test("deploy .py IC over the browser session", async () => { + // Intentionally unimplemented; see file header. Enable with + // GENLAYER_E2E_LOCALNET=1 and a running Docker localnet. + void LOCALNET_ENABLED; + }); +}); diff --git a/e2e/wallet-session.e2e.ts b/e2e/wallet-session.e2e.ts new file mode 100644 index 00000000..75e35358 --- /dev/null +++ b/e2e/wallet-session.e2e.ts @@ -0,0 +1,105 @@ +import {test, expect, type Browser} from "@playwright/test"; +import {startAnvil, type AnvilHandle} from "./fixtures/chain"; +import { + makeScratchEnv, + spawnConnect, + runCli, + readDescriptor, + descriptorMode, + daemonGet, + isPidAlive, + waitUntil, + type ScratchEnv, +} from "./fixtures/cli"; +import {launchBrowser, openDriver, type BridgeDriver} from "./helpers/bridgePage"; + +/** + * S1 — connect / status / disconnect on anvil. One serial session: connect + * once, inspect it, then tear it down. Assertions target the descriptor file, + * the daemon HTTP surface, and the CLI stdout — not brittle DOM text. + */ +const CHAIN_ID = 61341; + +test.describe.serial("S1 wallet session lifecycle", () => { + let anvil: AnvilHandle; + let browser: Browser; + let driver: BridgeDriver; + let scratch: ScratchEnv; + let daemonPid: number; + + test.beforeAll(async () => { + anvil = await startAnvil({chainId: CHAIN_ID}); + browser = await launchBrowser(); + scratch = makeScratchEnv({ + chainId: CHAIN_ID, + rpcUrl: anvil.rpcUrl, + stubAddress: anvil.stubAddress, + timing: {longPollMs: 1000}, + }); + }); + + test.afterAll(async () => { + await driver?.close().catch(() => {}); + if (daemonPid && isPidAlive(daemonPid)) { + try { + process.kill(daemonPid, "SIGKILL"); + } catch { + /* gone */ + } + } + await anvil?.stop(); + }); + + test("connect: descriptor 0600, daemon reachable, Connected as ...", async () => { + const connect = spawnConnect([], scratch); + const url = await connect.waitForUrl(); + expect(url).toMatch(/#s=/); + + driver = await openDriver(browser, { + rpcUrl: anvil.rpcUrl, + chainId: CHAIN_ID, + privateKey: anvil.account.privateKey, + }); + await driver.connect(url); + + const line = await connect.waitForConnected(); + expect(line.toLowerCase()).toContain(anvil.account.address.toLowerCase()); + await new Promise(res => connect.child.once("exit", () => res())); + + const d = readDescriptor(scratch); + expect(d, "descriptor should exist after connect").not.toBeNull(); + daemonPid = d!.pid; + expect(isPidAlive(d!.pid)).toBe(true); + expect(d!.port).toBeGreaterThan(0); + expect(d!.token).toBeTruthy(); + expect(d!.chainId).toBe(CHAIN_ID); + expect((d!.address ?? "").toLowerCase()).toBe(anvil.account.address.toLowerCase()); + expect(descriptorMode(scratch)).toBe(0o600); + + const ping = await daemonGet(d!, "/api/ping"); + expect(ping.status).toBe(200); + expect((ping.body as {status?: string}).status).toBe("ok"); + }); + + test("status: reports address, connected, empty queue", async () => { + const res = await runCli(["wallet", "status"], scratch); + expect(res.exitCode).toBe(0); + expect(res.all.toLowerCase()).toContain(anvil.account.address.toLowerCase()); + expect(res.all).toContain("connected"); + expect(res.all).toMatch(/queuedTransactions|queued/i); + }); + + test("disconnect: descriptor removed, daemon gone, status reports none", async () => { + const res = await runCli(["wallet", "disconnect"], scratch); + expect(res.all).toContain("Disconnected"); + + const gone = await waitUntil(() => readDescriptor(scratch) === null && !isPidAlive(daemonPid), { + timeoutMs: 8000, + }); + expect(gone, "descriptor removed and daemon pid gone").toBe(true); + + const status = await runCli(["wallet", "status"], scratch); + expect(status.all).toContain("No active wallet session"); + expect(status.exitCode).toBe(1); + }); +}); diff --git a/package-lock.json b/package-lock.json index 02b49aa6..f6db0176 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-clarke.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-clarke.4", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -17,9 +17,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.1.8", "inquirer": "^12.0.0", - "keytar": "^7.9.0", "node-fetch": "^3.0.0", "open": "^10.1.0", "ora": "^8.2.0", @@ -32,6 +30,7 @@ "genlayer": "dist/index.js" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@release-it/conventional-changelog": "^10.0.1", "@types/dockerode": "^3.3.31", "@types/fs-extra": "^11.0.4", @@ -42,17 +41,20 @@ "@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", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", + "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" + }, + "optionalDependencies": { + "keytar": "^7.9.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -325,6 +327,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -348,6 +351,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -806,6 +810,7 @@ "version": "4.8.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.8.0.tgz", "integrity": "sha512-MJQFqrZgcW0UNYLGOuQpey/oTN59vyWwplvCGZztn1cKz9agZPPYpJB7h2OMmuu7VLqkvEjN8feFZJmxNF9D+Q==", + "dev": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -824,6 +829,7 @@ "version": "4.12.1", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -833,6 +839,7 @@ "version": "0.21.0", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.6", @@ -847,6 +854,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -857,6 +865,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -869,6 +878,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -878,6 +888,7 @@ "version": "0.15.2", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -890,6 +901,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", @@ -913,6 +925,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -923,6 +936,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -932,6 +946,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -944,6 +959,7 @@ "version": "9.34.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -956,6 +972,7 @@ "version": "2.1.6", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -965,6 +982,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.15.2", @@ -1009,6 +1027,7 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -1018,6 +1037,7 @@ "version": "0.16.6", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", @@ -1031,6 +1051,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -1044,6 +1065,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -1057,6 +1079,7 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -1646,6 +1669,7 @@ "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -1855,6 +1879,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1943,6 +1983,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, "license": "MIT" }, "node_modules/@scure/base": { @@ -2136,12 +2177,14 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/jsonfile": { @@ -2159,6 +2202,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.0.tgz", "integrity": "sha512-m5ObIqwsUp6BZzyiy4RdZpzWGub9bqLJMvZDD0QMXhxjqMHMENlj+SqF5QxoUwaQNFe+8kz8XM8ZQhqkQPTgMQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2281,6 +2325,7 @@ "integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.42.0", "@typescript-eslint/types": "8.42.0", @@ -2916,7 +2961,9 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2928,6 +2975,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -2973,6 +3021,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -3038,12 +3087,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -3067,6 +3118,7 @@ "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -3089,6 +3141,7 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -3110,6 +3163,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -3128,6 +3182,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -3146,6 +3201,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", @@ -3210,6 +3266,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3229,6 +3286,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -3244,6 +3302,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, "license": "MIT" }, "node_modules/base64-js": { @@ -3436,6 +3495,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -3454,6 +3514,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3467,6 +3528,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3483,6 +3545,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -3785,6 +3848,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -4060,29 +4124,11 @@ "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", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -4134,6 +4180,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -4151,6 +4198,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -4168,6 +4216,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -4210,6 +4259,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" }, @@ -4242,6 +4292,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, "license": "MIT" }, "node_modules/default-browser": { @@ -4276,6 +4327,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -4305,6 +4357,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -4352,6 +4405,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" } @@ -4416,6 +4470,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -4453,6 +4508,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -4502,6 +4558,7 @@ "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -4570,6 +4627,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4579,6 +4637,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -4594,6 +4653,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -4606,6 +4666,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -4621,6 +4682,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -4633,6 +4695,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7", @@ -4700,6 +4763,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -4734,7 +4798,9 @@ "version": "9.34.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -4835,6 +4901,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7", @@ -4846,6 +4913,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.1" @@ -4890,6 +4958,7 @@ "version": "2.12.1", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, "license": "MIT", "dependencies": { "debug": "^3.2.7" @@ -4907,6 +4976,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.1" @@ -4916,7 +4986,9 @@ "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4949,6 +5021,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -4959,6 +5032,7 @@ "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.1" @@ -4968,6 +5042,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -4980,6 +5055,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -4989,6 +5065,7 @@ "version": "8.4.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -5005,6 +5082,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -5017,6 +5095,7 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5027,6 +5106,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -5043,6 +5123,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5055,6 +5136,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" @@ -5064,6 +5146,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -5076,6 +5159,7 @@ "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.15.0", @@ -5093,6 +5177,7 @@ "version": "4.2.1", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5119,6 +5204,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -5131,6 +5217,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -5143,6 +5230,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -5161,6 +5249,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -5273,6 +5362,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" } @@ -5314,6 +5404,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5350,12 +5441,14 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, "license": "MIT" }, "node_modules/fastq": { @@ -5395,6 +5488,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" @@ -5420,6 +5514,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -5449,6 +5544,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", @@ -5462,12 +5558,14 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, "license": "ISC" }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -5546,6 +5644,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5555,6 +5654,7 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -5575,6 +5675,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -5582,8 +5683,8 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "https://registry.npmjs.org/genlayer-js/-/genlayer-js-1.1.8.tgz", - "integrity": "sha512-qlqh8oqR9Ad7FVbIdqIrHfsMPLLJ24ZRHUZ2LGMpw6DX5ySjrEWdV1X93bVIHO44cu9CLGdx8m2ubkPv78/RLg==", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#bf42f13a66a2bb762e5ef1065eb89789b9c45d4a", + "dev": true, "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -5616,6 +5717,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -5640,6 +5742,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -5666,6 +5769,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -5794,7 +5898,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", @@ -5821,6 +5926,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -5833,6 +5939,7 @@ "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -5845,6 +5952,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -5861,6 +5969,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5908,6 +6017,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5920,6 +6030,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -5929,6 +6040,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -5941,6 +6053,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" @@ -5956,6 +6069,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5968,6 +6082,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -5983,6 +6098,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -6108,6 +6224,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -6124,6 +6241,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -6184,6 +6302,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -6208,6 +6327,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -6225,6 +6345,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, "license": "MIT", "dependencies": { "async-function": "^1.0.0", @@ -6244,6 +6365,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" @@ -6259,6 +6381,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6285,6 +6408,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6297,6 +6421,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -6312,6 +6437,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6329,6 +6455,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6360,6 +6487,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6369,6 +6497,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6393,6 +6522,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.0.tgz", "integrity": "sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6411,6 +6541,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6453,6 +6584,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6465,6 +6597,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6487,6 +6620,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6520,6 +6654,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6538,6 +6673,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6550,6 +6686,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6588,6 +6725,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6604,6 +6742,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -6621,6 +6760,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -6648,6 +6788,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6660,6 +6801,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -6675,6 +6817,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -6706,12 +6849,14 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, "license": "MIT" }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, "license": "ISC" }, "node_modules/isows": { @@ -6822,6 +6967,7 @@ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -6836,6 +6982,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -6850,6 +6997,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -6910,24 +7058,28 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.0" @@ -6954,6 +7106,7 @@ "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "node-addon-api": "^4.3.0", "prebuild-install": "^7.0.1" @@ -6963,6 +7116,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -6972,6 +7126,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -6985,6 +7140,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -7041,6 +7197,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, "license": "MIT" }, "node_modules/lodash.uniqby": { @@ -7158,6 +7315,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7260,6 +7418,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" }, @@ -7352,7 +7511,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", @@ -7374,6 +7534,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, "license": "MIT" }, "node_modules/neo-async": { @@ -7427,6 +7588,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" }, @@ -7438,7 +7600,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", @@ -7560,6 +7723,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7572,6 +7736,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -7581,6 +7746,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -7601,6 +7767,7 @@ "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -7619,6 +7786,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -7633,6 +7801,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -7700,6 +7869,7 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -7757,6 +7927,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", @@ -7837,6 +8008,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -7852,6 +8024,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -7908,6 +8081,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -7988,6 +8162,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -7997,6 +8172,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8006,6 +8182,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { @@ -8078,10 +8255,58 @@ "pathe": "^2.0.3" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8120,6 +8345,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", @@ -8145,6 +8371,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8.0" @@ -8248,6 +8475,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -8405,6 +8633,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8427,6 +8656,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8481,6 +8711,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@nodeutils/defaults-deep": "1.1.0", "@octokit/rest": "21.1.1", @@ -8553,6 +8784,7 @@ "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.0", @@ -8573,6 +8805,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -8710,6 +8943,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -8749,6 +8983,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8765,6 +9000,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8801,6 +9037,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" @@ -8813,6 +9050,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -8830,6 +9068,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -8845,6 +9084,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -8859,6 +9099,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -8871,6 +9112,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -8880,6 +9122,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8899,6 +9142,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -8915,6 +9159,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8933,6 +9178,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -8984,7 +9230,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -9005,6 +9252,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -9168,6 +9416,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9253,6 +9502,7 @@ "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9274,6 +9524,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9292,6 +9543,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -9348,6 +9600,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -9370,6 +9623,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9394,6 +9648,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" @@ -9406,6 +9661,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -9515,6 +9771,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9669,6 +9926,7 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", @@ -9688,6 +9946,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" }, @@ -9705,6 +9964,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -9729,6 +9989,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -9743,6 +10004,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -9762,6 +10024,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -9783,6 +10046,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -9812,6 +10076,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9824,6 +10089,7 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/typescript-parsec/-/typescript-parsec-0.3.4.tgz", "integrity": "sha512-6RD4xOxp26BTZLopNbqT2iErqNhQZZWb5m5F07/UwGhldGvOAKOl41pZ3fxsFp04bNL+PbgMjNfb6IvJAC/uYQ==", + "dev": true, "license": "MIT" }, "node_modules/uglify-js": { @@ -9844,6 +10110,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -9910,6 +10177,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -9952,6 +10220,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -9974,9 +10243,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" @@ -10087,6 +10356,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.4.tgz", "integrity": "sha512-X5QFK4SGynAeeIt+A7ZWnApdUyHYm+pzv/8/A57LqSGcI88U6R6ipOs3uCesdc6yl7nl+zNO0t8LmqAdXcQihw==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -10200,6 +10470,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10370,6 +10641,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -10385,6 +10657,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", @@ -10404,6 +10677,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -10431,6 +10705,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, "license": "MIT", "dependencies": { "is-map": "^2.0.3", @@ -10449,6 +10724,7 @@ "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -10509,6 +10785,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10651,6 +10928,7 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=10.0.0" }, @@ -10790,6 +11068,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 34276046..badba938 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-clarke.4", "description": "GenLayer Command Line Tool", "main": "src/index.ts", "type": "module", @@ -12,8 +12,13 @@ "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", + "test:e2e": "playwright test -c playwright.config.ts", + "test:e2e:lane-a": "playwright test -c playwright.config.ts e2e/lane-a-staking.e2e.ts", + "test:e2e:full": "GENLAYER_E2E_LOCALNET=1 playwright test -c playwright.config.ts", + "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" @@ -36,6 +41,7 @@ }, "homepage": "https://github.com/yeagerai/genlayer-cli#readme", "devDependencies": { + "@playwright/test": "^1.61.1", "@release-it/conventional-changelog": "^10.0.1", "@types/dockerode": "^3.3.31", "@types/fs-extra": "^11.0.4", @@ -46,12 +52,12 @@ "@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", "eslint-import-resolver-typescript": "^4.0.0", "eslint-plugin-import": "^2.29.1", + "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "jsdom": "^26.0.0", "prettier": "^3.2.5", "release-it": "^19.0.0", @@ -66,9 +72,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.1.8", "inquirer": "^12.0.0", - "keytar": "^7.9.0", "node-fetch": "^3.0.0", "open": "^10.1.0", "ora": "^8.2.0", @@ -77,6 +81,9 @@ "viem": "^2.21.54", "vitest": "^3.0.0" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "overrides": { "vite": { "rollup": "npm:@rollup/wasm-node" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..3233f4a2 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,18 @@ +import {defineConfig} from "@playwright/test"; + +/** + * Tier-2 browser-wallet signing e2e (anvil lanes). Each spec boots an ephemeral + * anvil + spawns the real CLI daemon + drives a headless chromium against the + * real bridge page, so the runs must be serial: the session descriptor, the + * detached daemon, and the loopback bridge port must never race between specs. + */ +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.e2e.ts", + fullyParallel: false, + workers: 1, + timeout: 60_000, + retries: 0, + reporter: [["list"]], + // No webServer: the fixtures own chain + daemon lifecycle. +}); 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/export.ts b/src/commands/account/export.ts index bed07a48..38b59869 100644 --- a/src/commands/account/export.ts +++ b/src/commands/account/export.ts @@ -43,7 +43,10 @@ export class ExportAccountAction extends BaseAction { if (options.password) { password = options.password; } else { - password = await this.promptPassword("Enter password for exported keystore (minimum 8 characters):"); + password = await this.promptPassword( + "Enter password for exported keystore (minimum 8 characters):", + "Pass --password to set the exported keystore password non-interactively.", + ); const confirmPassword = await this.promptPassword("Confirm password:"); if (password !== confirmPassword) { this.failSpinner("Passwords do not match"); @@ -89,7 +92,12 @@ export class ExportAccountAction extends BaseAction { const encryptedJson = parsed.encrypted || fileContent; - const password = sourcePassword || await this.promptPassword(`Enter password to unlock '${accountName}':`); + const password = + sourcePassword || + (await this.promptPassword( + `Enter password to unlock '${accountName}':`, + "Pass --source-password to unlock the account non-interactively.", + )); this.startSpinner("Decrypting keystore..."); diff --git a/src/commands/account/import.ts b/src/commands/account/import.ts index a7ec0518..357751fa 100644 --- a/src/commands/account/import.ts +++ b/src/commands/account/import.ts @@ -102,7 +102,12 @@ export class ImportAccountAction extends BaseAction { this.failSpinner("Invalid keystore file. Could not parse JSON."); } - const password = sourcePassword || await this.promptPassword("Enter password to decrypt keystore:"); + const password = + sourcePassword || + (await this.promptPassword( + "Enter password to decrypt keystore:", + "Pass --source-password to decrypt the source keystore non-interactively.", + )); this.startSpinner("Decrypting keystore..."); diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts index 3fa23f15..219471ac 100644 --- a/src/commands/account/index.ts +++ b/src/commands/account/index.ts @@ -32,6 +32,7 @@ export function initializeAccountCommands(program: Command) { .command("show") .description("Show account details (address, balance)") .option("--rpc ", "RPC URL for the network") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--account ", "Account to show") .action(async (options: ShowAccountOptions) => { const showAction = new ShowAccountAction(); @@ -98,7 +99,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..2c539f6b 100644 --- a/src/commands/account/show.ts +++ b/src/commands/account/show.ts @@ -6,6 +6,7 @@ import {readFileSync, existsSync} from "fs"; export interface ShowAccountOptions { rpc?: string; + network?: string; account?: string; } @@ -14,8 +15,12 @@ export class ShowAccountAction extends BaseAction { super(); } - private getNetwork(): GenLayerChain { - return resolveNetwork(this.getConfig().network); + private getNetwork(networkOption?: string): GenLayerChain { + // Priority: --network option > global config network > localnet default. + if (networkOption) { + return resolveNetwork(networkOption, this.getCustomNetworks()); + } + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } async execute(options?: ShowAccountOptions): Promise { @@ -30,7 +35,9 @@ export class ShowAccountAction extends BaseAction { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { - this.failSpinner(`Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`); + this.failSpinner( + `Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`, + ); return; } @@ -43,7 +50,11 @@ export class ShowAccountAction extends BaseAction { const rawAddr = keystoreData.address; const address = (rawAddr.startsWith("0x") ? rawAddr : `0x${rawAddr}`) as Address; - const network = this.getNetwork(); + const network = this.getNetwork(options?.network); + // Label with the ACTIVE network alias (what the user set via `network set` + // or --network), not chain.name: a custom network inherits its base + // chain's name ("Genlayer Localnet"), which would mislabel the account. + const networkAlias = options?.network || this.getConfig().network || "localnet"; const client = createClient({ chain: network, @@ -61,7 +72,8 @@ export class ShowAccountAction extends BaseAction { name: accountName, address, balance: `${formattedBalance} GEN`, - network: network.name || "localnet", + network: networkAlias, + chainId: network.id, status: isUnlocked ? "unlocked" : "locked", active: isActive, }; diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts new file mode 100644 index 00000000..fbb3af26 --- /dev/null +++ b/src/commands/balances/BalancesAction.ts @@ -0,0 +1,307 @@ +import {VestingAction, VestingConfig} from "../vesting/VestingAction"; +import {resolveNetwork} from "../../lib/actions/BaseAction"; +import type {Address} from "genlayer-js/types"; +import type {VestingClient} from "../vesting/vestingTypes"; +import {vestingAvailableToStake} from "../../lib/vesting/availableToStake"; +import {formatEther} from "viem"; +import Table from "cli-table3"; +import chalk from "chalk"; + +export interface BalancesOptions extends VestingConfig { + beneficiary?: string; +} + +/** Per-vesting-contract holdings, all amounts kept as raw wei bigints. */ +interface VestingBalanceSummary { + vesting: Address; + name: string; + totalRaw: bigint; + vestedRaw: bigint; + /** Unvested (still locked by schedule). */ + lockedRaw: bigint; + withdrawableRaw: bigint; + totalWithdrawnRaw: bigint; + /** Revoked contracts can no longer stake, so their available-to-stake is 0. */ + revoked: boolean; + /** Self-stake committed principal (cost basis, summed over its validator wallets). */ + selfStakeRaw: bigint; + /** Delegated committed principal (cost basis, summed over the validator set). */ + delegatedRaw: bigint; + /** Committed principal (self-stake + delegated); informational only. */ + committedRaw: bigint; + /** AUTHORITATIVE on-chain figure: the contract's live native balance (0 if revoked). */ + availableToStakeRaw: bigint; +} + +interface BalancesSummary { + network: string; + chainId: number; + address: Address; + walletBalanceRaw: bigint; + vestings: VestingBalanceSummary[]; + /** False when the network has no consensus contracts deployed (e.g. studio): vesting + staking data is unavailable and only the wallet balance is shown. */ + consensusAvailable: boolean; +} + +/** + * Read-only "what do I hold" view: wallet GEN + per-vesting totals, committed + * principal, and the authoritative available-to-stake (the contract's live + * on-chain balance). Never signs, never writes, and never unlocks the keystore + * — a read only needs the address. + */ +export class BalancesAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: BalancesOptions): Promise { + this.startSpinner("Fetching balances..."); + + try { + const client = await this.getReadOnlyVestingClient(options); + const address = await this.resolveAddress(options); + + // Label with the ACTIVE network alias + real chainId, mirroring `account + // show`. chain.name is the BASE chain's name for custom networks, so it + // would mislabel — use the alias the user set and network.id instead. + const networkKey = options.network || this.getConfig().network; + const networkAlias = networkKey || "localnet"; + const chain = resolveNetwork(networkKey, this.getCustomNetworks()); + + this.setSpinnerText(`Fetching wallet balance for ${address}...`); + const walletBalanceRaw = await client.getBalance({address}); + + // Vesting + staking are consensus-layer features: the vesting-factory + // lookup resolves through ConsensusMain → AddressManager, and the + // validator scan reads the Staking contract. On a network without that + // infrastructure deployed (e.g. studio, or a custom profile pointed at a + // bare RPC) those reads either revert or decode garbage. Probe once, up + // front, whether the consensus contracts are actually deployed on this + // RPC and skip the whole consensus-dependent section if not — the wallet + // balance is a plain native read and always works. This keeps `balances` + // useful (wallet-only) instead of crashing on a missing-infra network. + const consensusAvailable = await this.isConsensusInfraDeployed(client, chain); + + const vestings: VestingBalanceSummary[] = []; + if (consensusAvailable) { + this.setSpinnerText(`Looking up vesting contracts for ${address}...`); + const vestingAddresses = await client.getBeneficiaryVestings( + address, + this.getFactoryLookupOptions(options), + ); + if (vestingAddresses.length > 0) { + // The validator set is global; fetch it once and reuse across every + // vesting. Committed-delegation lookup is O(#vestings × #validators). + // A vesting can hold committed principal against validators that later + // left the active set (quarantined/banned) — scanning only the active + // set would under-count committed and thus mis-state + // available-to-stake, so union active + quarantined + banned. A + // network can have consensus (vesting factory) but no staking + // contract, so still gate the scan on staking availability. + const validatorSet = this.isStakingAvailable(chain) + ? await this.getKnownValidatorSet(client) + : []; + + for (let i = 0; i < vestingAddresses.length; i++) { + this.setSpinnerText( + `Computing balances for vesting ${i + 1}/${vestingAddresses.length} ` + + `(scanning ${validatorSet.length} validator${validatorSet.length === 1 ? "" : "s"})...`, + ); + vestings.push(await this.computeVestingSummary(client, vestingAddresses[i], validatorSet)); + } + } + } + + this.stopSpinner(); + + this.renderSummary({ + network: networkAlias, + chainId: chain.id, + address, + walletBalanceRaw, + vestings, + consensusAvailable, + }); + } catch (error: any) { + this.failSpinner("Failed to fetch balances", error.message || error); + } + } + + /** + * Resolve the address to inspect without ever unlocking a keystore, via the + * shared connect-once resolver. `--beneficiary` is the explicit override; a + * live browser session otherwise wins over the keystore default. + */ + private async resolveAddress(options: BalancesOptions): Promise
{ + return this.resolveActiveIdentity(options, options.beneficiary); + } + + /** + * Whether the resolved network has a usable staking contract. Mirrors the + * SDK's own guard (missing or zero address ⇒ unsupported), so studio-based + * networks — which carry no staking contract — are detected up front and the + * validator-set scan is skipped rather than left to throw mid-read. + */ + private isStakingAvailable(chain: {stakingContract?: {address?: string} | null}): boolean { + const address = chain.stakingContract?.address; + return !!address && address !== "0x0000000000000000000000000000000000000000"; + } + + /** + * Whether the consensus infrastructure (ConsensusMain, which the vesting + * factory + staking lookups resolve through) is actually DEPLOYED on the + * active RPC — a runtime capability probe, not a static config check. + * + * Custom networks inherit the base chain's ConsensusMain address even when + * `--consensus-main` isn't overridden, so a studio profile carries a non-null + * (localnet) address that simply isn't deployed on the studio RPC. A static + * check can't tell them apart; `eth_getCode` can — an undeployed address + * returns `0x`. When absent, `balances` skips the consensus-dependent + * sections and reports the wallet balance only. + */ + private async isConsensusInfraDeployed(client: VestingClient, chain: {consensusMainContract?: {address?: string} | null}): Promise { + const address = chain.consensusMainContract?.address; + if (!address || address === "0x0000000000000000000000000000000000000000") return false; + const code = await client.getCode({address: address as Address}); + return !!code && code !== "0x"; + } + + /** + * The full set of validators a vesting could have committed principal to: + * active + quarantined + banned, de-duplicated (case-insensitively, keeping + * the first-seen casing). Committed principal survives a validator leaving the + * active set, so an active-only scan would under-count it. + */ + private async getKnownValidatorSet(client: VestingClient): Promise { + this.setSpinnerText("Enumerating validator set..."); + const [active, quarantined, banned] = await Promise.all([ + client.getActiveValidators(), + client.getQuarantinedValidatorsDetailed(), + client.getBannedValidators(), + ]); + + const seen = new Map(); + const add = (addr: Address) => { + const key = addr.toLowerCase(); + if (!seen.has(key)) seen.set(key, addr); + }; + active.forEach(add); + quarantined.forEach(v => add(v.validator)); + banned.forEach(v => add(v.validator)); + return Array.from(seen.values()); + } + + private async computeVestingSummary( + client: VestingClient, + vesting: Address, + knownValidators: Address[], + ): Promise { + const state = await client.getVestingState(vesting); + + // Self-stake committed principal: sum the cost-basis deposits across this + // vesting's own validator wallets (see vesting validatorList). + const wallets = await client.getValidatorWallets(vesting); + let selfStakeRaw = 0n; + for (const wallet of wallets) { + const deposited = await client.validatorDeposited(vesting, wallet); + selfStakeRaw += typeof deposited === "bigint" ? deposited : this.parseAmount(String(deposited)); + } + + // Delegated committed principal: sum the cost-basis the vesting deposited + // delegating to each known validator (see execute() for the one-shot set, + // which unions active + quarantined + banned). This on-chain principal + // getter is consistent with the balance identity used below + // (balance = deposits − withdrawals + rewards − losses). + let delegatedRaw = 0n; + for (const validator of knownValidators) { + delegatedRaw += await client.vestingDepositedPerValidator(vesting, validator); + } + + const committedRaw = selfStakeRaw + delegatedRaw; + + // AUTHORITATIVE available-to-stake: the contract's live native balance (0 + // once revoked). Vesting.sol enforces every stake path against + // address(this).balance, so this — not vested/withdrawn/committed math — is + // the real cap; it already nets withdrawals + committed principal and + // includes still-locked tokens. The committed figures above are purely + // informational now. + const availableToStakeRaw = await vestingAvailableToStake(client, vesting, state.revoked); + + return { + vesting, + name: state.name || "", + totalRaw: state.totalAmountRaw, + vestedRaw: state.vestedAmountRaw, + lockedRaw: state.unvestedAmountRaw, + withdrawableRaw: state.withdrawableAmountRaw, + totalWithdrawnRaw: state.totalWithdrawnRaw, + revoked: state.revoked, + selfStakeRaw, + delegatedRaw, + committedRaw, + availableToStakeRaw, + }; + } + + private renderSummary(summary: BalancesSummary): void { + const fmt = (raw: bigint) => `${formatEther(raw)} GEN`; + + console.log(""); + console.log(chalk.bold("GenLayer balances")); + console.log(chalk.gray(`Network: ${summary.network} (chainId ${summary.chainId})`)); + console.log(chalk.gray(`Address: ${summary.address}`)); + console.log(""); + console.log(`${chalk.cyan("Wallet")}: ${fmt(summary.walletBalanceRaw)}`); + console.log(""); + + if (!summary.consensusAvailable) { + console.log( + chalk.yellow("Vesting & staking data is unavailable on this network (no consensus contracts deployed)."), + ); + console.log(""); + return; + } + + if (summary.vestings.length === 0) { + console.log(chalk.yellow(`No vesting contracts found for ${summary.address}`)); + console.log(""); + return; + } + + summary.vestings.forEach(v => { + const table = new Table({ + head: [chalk.cyan("Metric"), chalk.cyan("Amount")], + style: {head: [], border: []}, + wordWrap: true, + }); + const availableValue = v.revoked + ? `${fmt(v.availableToStakeRaw)} ${chalk.gray("(revoked — staking disabled)")}` + : fmt(v.availableToStakeRaw); + table.push( + ["Total", fmt(v.totalRaw)], + ["Vested", fmt(v.vestedRaw)], + ["Locked (unvested)", fmt(v.lockedRaw)], + ["Withdrawable", fmt(v.withdrawableRaw)], + ["Total withdrawn", fmt(v.totalWithdrawnRaw)], + ["Committed principal (staked)", fmt(v.committedRaw)], + [chalk.gray(" self-stake"), chalk.gray(fmt(v.selfStakeRaw))], + [chalk.gray(" delegated"), chalk.gray(fmt(v.delegatedRaw))], + [chalk.bold("Available to stake"), chalk.bold(availableValue)], + ); + console.log(chalk.bold(`Vesting ${v.vesting}`) + (v.name ? ` ${chalk.gray(v.name)}` : "")); + console.log(table.toString()); + console.log(""); + }); + + console.log( + chalk.gray("Available to stake is the vesting contract's live on-chain balance (0 once revoked)."), + ); + console.log( + chalk.gray("Committed principal is the staked cost basis (self-stake + delegated), shown for reference."), + ); + console.log( + chalk.gray(`Total: ${summary.vestings.length} vesting contract${summary.vestings.length === 1 ? "" : "s"}`), + ); + console.log(""); + } +} diff --git a/src/commands/balances/index.ts b/src/commands/balances/index.ts new file mode 100644 index 00000000..3577d0d5 --- /dev/null +++ b/src/commands/balances/index.ts @@ -0,0 +1,18 @@ +import {Command} from "commander"; +import {BalancesAction, BalancesOptions} from "./BalancesAction"; + +export function initializeBalancesCommands(program: Command) { + program + .command("balances") + .description("Show wallet + vesting balances and committed stake (read-only)") + .option("--beneficiary
", "Address to inspect (defaults to the active account, no unlock)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--account ", "Account whose address to use (no unlock)") + .action(async (options: BalancesOptions) => { + const action = new BalancesAction(); + await action.execute(options); + }); + + return program; +} diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 198f9c84..9abc875d 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -1,19 +1,23 @@ import fs from "fs"; +import os from "os"; import path from "path"; import {BaseAction} from "../../lib/actions/BaseAction"; import {pathToFileURL} from "url"; -import {TransactionStatus} from "genlayer-js/types"; +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 { contract?: string; args?: any[]; rpc?: string; + wallet?: "keystore" | "browser"; } export interface DeployScriptsOptions { rpc?: string; + wallet?: "keystore" | "browser"; } export class DeployAction extends BaseAction { @@ -31,9 +35,14 @@ export class DeployAction extends BaseAction { } private async executeTsScript(filePath: string, rpcUrl?: string): Promise { - const outFile = filePath.replace(/\.ts$/, ".compiled.js"); + let tempDir: string | undefined; this.startSpinner(`Transpiling TypeScript file: ${filePath}`); try { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-deploy-")); + const outFile = path.join( + tempDir, + path.basename(filePath).replace(/\.ts$/, ".compiled.js"), + ); buildSync({ entryPoints: [filePath], outfile: outFile, @@ -47,7 +56,9 @@ export class DeployAction extends BaseAction { } catch (error) { this.failSpinner(`Error executing: ${filePath}`, error); } finally { - fs.unlinkSync(outFile); + if (tempDir) { + fs.rmSync(tempDir, {recursive: true, force: true}); + } } } @@ -72,6 +83,7 @@ export class DeployAction extends BaseAction { } async deployScripts(options?: DeployScriptsOptions) { + if (this.isBrowserWallet({wallet: options?.wallet})) this.walletModeOverride = "browser"; this.startSpinner("Searching for deploy scripts..."); if (!fs.existsSync(this.deployDir)) { this.failSpinner("No deploy folder found."); @@ -97,24 +109,33 @@ export class DeployAction extends BaseAction { this.setSpinnerText(`Found ${files.length} deploy scripts. Executing...`); - for (const file of files) { - const filePath = path.resolve(this.deployDir, file); - this.setSpinnerText(`Executing script: ${filePath}`); - try { - if (file.endsWith(".ts")) { - await this.executeTsScript(filePath, options?.rpc); - } else { - await this.executeJsScript(filePath, undefined, options?.rpc); + try { + for (const file of files) { + const filePath = path.resolve(this.deployDir, file); + this.setSpinnerText(`Executing script: ${filePath}`); + try { + if (file.endsWith(".ts")) { + await this.executeTsScript(filePath, options?.rpc); + } else { + await this.executeJsScript(filePath, undefined, options?.rpc); + } + } catch (error) { + this.failSpinner(`Error executing script: ${filePath}`, error); } - } catch (error) { - this.failSpinner(`Error executing script: ${filePath}`, error); } + } finally { + // One browser session drives every script in the folder; close it here. + await this.closeBrowserSession(); } } async deploy(options: DeployOptions): Promise { + if (this.isBrowserWallet({wallet: options.wallet})) this.walletModeOverride = "browser"; try { const client = await this.getClient(options.rpc); + this.browserSession?.setNextLabel( + options.contract ? `Deploy ${path.basename(options.contract)}` : "Deploy contract", + ); this.startSpinner("Setting up the deployment environment..."); await client.initializeConsensusSmartContract(); @@ -132,12 +153,19 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; - const fees = parseTransactionFees(options); + 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; this.setSpinnerText("Starting contract deployment..."); + if (fees?.feeValue !== undefined) { + const feeValue = BigInt(fees.feeValue); + this.log(`Fee deposit: ${feeValue.toString()} wei (~${formatStakingAmount(feeValue)})`); + } this.log("Deployment Parameters:", deployParams); const hash = (await client.deployContract(deployParams)) as any; @@ -146,21 +174,29 @@ export class DeployAction extends BaseAction { hash, retries: 50, interval: 5000, - status: TransactionStatus.ACCEPTED, + waitUntil: "decided", + fullTransaction: true, }); + assertSuccessfulExecution("Deployment", hash, result); this.log("Deployment Receipt:", result); + 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, "Contract Address": contractAddress, + "Consensus Status": transactionConsensusStatus(result), }); } catch (error) { this.failSpinner("Error deploying contract", error); + } finally { + await this.closeBrowserSession(); } } } 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/execution.ts b/src/commands/contracts/execution.ts new file mode 100644 index 00000000..69d45a75 --- /dev/null +++ b/src/commands/contracts/execution.ts @@ -0,0 +1,145 @@ +import {isSuccessful} from "genlayer-js"; +import {ExecutionResult, TransactionStatus, transactionsStatusNumberToName} from "genlayer-js/types"; + +function directField(value: unknown, names: string[]): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const record = value as Record; + for (const name of names) { + if (Object.prototype.hasOwnProperty.call(record, name)) return record[name]; + } + return undefined; +} + +function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { + if (value == null) return undefined; + if (typeof value === "string") { + const normalized = value.toUpperCase(); + if (normalized === ExecutionResult.FINISHED_WITH_RETURN) return ExecutionResult.FINISHED_WITH_RETURN; + if (normalized === ExecutionResult.FINISHED_WITH_ERROR) return ExecutionResult.FINISHED_WITH_ERROR; + if (normalized === ExecutionResult.NOT_VOTED) return ExecutionResult.NOT_VOTED; + if (normalized === ExecutionResult.TIMEOUT) return ExecutionResult.TIMEOUT; + if (normalized === ExecutionResult.NONDET_DISAGREE) return ExecutionResult.NONDET_DISAGREE; + if (normalized === "NONDET_DISAGREE" || normalized === "NONDET_DISAGREEMENT") return ExecutionResult.NONDET_DISAGREE; + if (normalized === "SUCCESS") return ExecutionResult.FINISHED_WITH_RETURN; + if (normalized === "ERROR" || normalized === "FAILURE") return ExecutionResult.FINISHED_WITH_ERROR; + if (/^\d+$/.test(normalized)) return normalizeExecutionResult(Number(normalized)); + } + if (typeof value === "number" || typeof value === "bigint") { + const numeric = Number(value); + if (numeric === 0) return ExecutionResult.NOT_VOTED; + if (numeric === 1) return ExecutionResult.FINISHED_WITH_RETURN; + if (numeric === 2) return ExecutionResult.FINISHED_WITH_ERROR; + if (numeric === 3) return ExecutionResult.TIMEOUT; + if (numeric === 4) return ExecutionResult.NONDET_DISAGREE; + } + return undefined; +} + +function normalizeConsensusStatus(value: unknown): TransactionStatus | undefined { + if (value == null) return undefined; + if (typeof value === "string") { + const normalized = value.toUpperCase(); + if (normalized in TransactionStatus) return normalized as TransactionStatus; + if (/^\d+$/.test(normalized)) return normalizeConsensusStatus(Number(normalized)); + } + if (typeof value === "number" || typeof value === "bigint") { + return transactionsStatusNumberToName[String(value) as keyof typeof transactionsStatusNumberToName]; + } + return undefined; +} + +function receiptData(receipt: unknown): { + record: Record; + data: unknown; + firstLeaderReceipt: unknown; + genvmResult: unknown; +} { + const record = receipt && typeof receipt === "object" && !Array.isArray(receipt) + ? receipt as Record + : {}; + const data = directField(record, ["data"]); + const consensusData = + directField(data, ["consensus_data", "consensusData"]) ?? + directField(record, ["consensus_data", "consensusData"]); + const leaderReceipt = directField(consensusData, ["leader_receipt", "leaderReceipt"]); + const firstLeaderReceipt = Array.isArray(leaderReceipt) ? leaderReceipt[0] : leaderReceipt; + const genvmResult = directField(firstLeaderReceipt, ["genvm_result", "genvmResult"]); + return {record, data, firstLeaderReceipt, genvmResult}; +} + +export function transactionConsensusStatus(receipt: unknown): TransactionStatus | undefined { + const {record, data} = receiptData(receipt); + const candidates = [ + directField(record, ["statusName", "status_name"]), + directField(record, ["status"]), + directField(data, ["statusName", "status_name"]), + directField(data, ["status"]), + ]; + for (const candidate of candidates) { + const status = normalizeConsensusStatus(candidate); + if (status !== undefined) return status; + } + return undefined; +} + +function transactionExecutionResult(receipt: unknown): ExecutionResult | undefined { + const {record, data, firstLeaderReceipt, genvmResult} = receiptData(receipt); + const candidates = [ + directField(record, ["txExecutionResultName", "tx_execution_result_name"]), + directField(record, ["txExecutionResult", "tx_execution_result"]), + directField(data, ["txExecutionResultName", "tx_execution_result_name"]), + directField(data, ["txExecutionResult", "tx_execution_result"]), + directField(firstLeaderReceipt, ["execution_result", "executionResult"]), + directField(genvmResult, ["execution_result", "executionResult"]), + directField(data, ["execution_result", "executionResult"]), + ]; + for (const candidate of candidates) { + const result = normalizeExecutionResult(candidate); + if (result !== undefined) return result; + } + return undefined; +} + +function consensusDiagnosis(status: TransactionStatus | undefined): string { + if (status === TransactionStatus.UNDETERMINED) return "UNDETERMINED (no validator majority)"; + if (status === TransactionStatus.CANCELED) return "CANCELED before execution"; + if (status === TransactionStatus.LEADER_TIMEOUT) return "LEADER_TIMEOUT"; + if (status === TransactionStatus.VALIDATORS_TIMEOUT) return "VALIDATORS_TIMEOUT"; + return status ?? "UNKNOWN"; +} + +function executionDiagnosis(result: ExecutionResult | undefined): string { + if (result === ExecutionResult.TIMEOUT) return "TIMEOUT (leader timed out during execution)"; + if (result === ExecutionResult.NONDET_DISAGREE) { + return "NONDET_DISAGREE (validators disagreed on non-deterministic output)"; + } + return result ?? "UNKNOWN"; +} + +export function assertSuccessfulExecution( + operation: string, + hash: unknown, + receipt: unknown, +): void { + const status = transactionConsensusStatus(receipt); + const result = transactionExecutionResult(receipt); + if (isSuccessful(receipt as any) || isSuccessful({ + ...(receipt && typeof receipt === "object" && !Array.isArray(receipt) ? receipt as Record : {}), + statusName: status, + txExecutionResultName: result, + } as any)) { + return; + } + + const decidedAs = consensusDiagnosis(status); + + if (result === undefined) { + throw new Error( + `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: UNKNOWN.`, + ); + } + + throw new Error( + `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: ${executionDiagnosis(result)}.`, + ); +} diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts index 4e2a839e..f276e96d 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -1,11 +1,40 @@ -import {hexToBytes, keccak256, toHex, type Hex} from "viem"; +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 { @@ -48,32 +77,21 @@ const parseBigNumberishOption = (value: string | undefined, optionName: string): return trimmed; }; -const CALL_KEY_UNNAMED = "0x0000000000000000000000000000000000000000000000000000000000000000" as const; - -const bytesToPaddedCallKey = (bytes: Uint8Array): Hex => { - if (bytes.length > 32) { - throw new Error("call key source bytes must be 32 bytes or fewer."); +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 `0x${toHex(bytes).slice(2).padEnd(64, "0")}` as Hex; + return Number(parsed); }; -const deriveInternalMessageCallKey = (methodName = ""): Hex => { - const methodBytes = new TextEncoder().encode(methodName); - if (methodBytes.length < 32) { - return bytesToPaddedCallKey(methodBytes); +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."); } - - const hashed = keccak256(methodBytes); - const lastByte = Number.parseInt(hashed.slice(-2), 16) | 1; - return `${hashed.slice(0, -2)}${lastByte.toString(16).padStart(2, "0")}` as Hex; -}; - -const deriveExternalMessageCallKey = (selectorOrCalldata: Hex): Hex => { - const bytes = hexToBytes(selectorOrCalldata); - if (bytes.length < 4) { - return CALL_KEY_UNNAMED; - } - return bytesToPaddedCallKey(bytes.slice(0, 4)); + return appealRounds; }; const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | undefined => { @@ -85,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") { @@ -127,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; @@ -156,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)); @@ -164,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); @@ -178,7 +195,7 @@ const normalizeMessageAllocationCallKey = ( return normalized; }; -const normalizeMessageTypes = (fees: Record): Record => { +const normalizeMessageTypes = (fees: Record, deployTargeted = false): Record => { if (!Array.isArray(fees.messageAllocations)) { return fees; } @@ -191,15 +208,122 @@ const normalizeMessageTypes = (fees: Record): Record = } const messageType = normalizeMessageType(allocation.messageType, index); - return 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; + } + return normalized; }), }; }; -export const parseTransactionFees = (options: ContractFeeCliOptions): Record | undefined => { +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: FeeParseConfig = {}, +): Record | undefined => { const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); let fees = options.fees ? parseJsonObject(options.fees, "--fees") : undefined; @@ -207,28 +331,58 @@ export const parseTransactionFees = (options: ContractFeeCliOptions): Record): 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..f4909eb3 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -6,6 +6,7 @@ import {WriteAction, WriteOptions} from "./write"; import {SchemaAction, SchemaOptions} from "./schema"; import {CodeAction, CodeOptions} from "./code"; import {EstimateFeesAction, EstimateFeesOptions} from "./estimateFees"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/; const ADDR_PREFIX_RE = /^addr#([0-9a-fA-F]{40})$/; @@ -78,7 +79,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,17 +101,27 @@ 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") - .description("Deploy intelligent contracts") - .option("--contract ", "Path to the smart contract to deploy") - .option("--rpc ", "RPC URL for the network") - .option("--fees ", FEES_HELP) - .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, []) - .action(async (options: DeployOptions) => { + addWalletModeOption( + program + .command("deploy") + .description("Deploy intelligent contracts") + .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, []), + ).action(async (options: DeployOptions) => { const deployer = new DeployAction(); if (options.contract) { await deployer.deploy(options); @@ -124,31 +135,25 @@ 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}); }); - program - .command("write ") - .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-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, - [], - ) - .action(async (contractAddress: string, method: string, options: WriteOptions) => { + addWalletModeOption( + program + .command("write ") + .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, []), + ).action(async (contractAddress: string, method: string, options: WriteOptions) => { const writeAction = new WriteAction(); await writeAction.write({contractAddress, method, ...options}); }); @@ -158,18 +163,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 3187098d..882e3c3b 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -1,11 +1,14 @@ // import {simulator} from "genlayer-js/chains"; // 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 { args: any[]; rpc?: string; + wallet?: "keystore" | "browser"; } export class WriteAction extends BaseAction { @@ -18,20 +21,21 @@ export class WriteAction extends BaseAction { method, args, rpc, + wallet, fees, + feeProfile, + feePreset, + appealRounds, feeValue, validUntil, - }: { + }: WriteOptions & { contractAddress: string; method: string; - args: any[]; - rpc?: string; - fees?: string; - feeValue?: string; - validUntil?: string; }): Promise { + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); + this.browserSession?.setNextLabel(`${method} on ${contractAddress}`); this.startSpinner(`Calling write method ${method} on contract at ${contractAddress}...`); try { @@ -41,10 +45,25 @@ 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) { + const parsedFeeValue = BigInt(parsedFees.feeValue); + this.log(`Fee deposit: ${parsedFeeValue.toString()} wei (~${formatStakingAmount(parsedFeeValue)})`); + } const hash = await client.writeContract(writeParams); this.log("Write Transaction Hash:", hash); @@ -53,10 +72,18 @@ export class WriteAction extends BaseAction { hash, retries: 100, interval: 5000, + waitUntil: "decided", + fullTransaction: true, + }); + assertSuccessfulExecution("Write", hash, result); + this.succeedSpinner("Write operation successfully executed", { + ...result, + consensusStatus: transactionConsensusStatus(result), }); - this.succeedSpinner("Write operation successfully executed", result); } catch (error) { this.failSpinner("Error during write operation", error); + } finally { + await this.closeBrowserSession(); } } } diff --git a/src/commands/network/index.ts b/src/commands/network/index.ts index a5f7ee27..dda8f74c 100644 --- a/src/commands/network/index.ts +++ b/src/commands/network/index.ts @@ -6,6 +6,25 @@ 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") + .option("--explorer ", "Block explorer URL for this custom network (custom networks do NOT inherit the base's explorer, to avoid misleading links)") + .action((alias: string, options) => networkActions.addNetwork(alias, options)); + // genlayer network set [name] network .command("set") @@ -25,5 +44,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..3f141e93 100644 --- a/src/commands/network/setNetwork.ts +++ b/src/commands/network/setNetwork.ts @@ -1,61 +1,114 @@ 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; + explorer?: 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 +117,181 @@ 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 || + options.explorer || + CONTRACT_FLAG_OPTIONS.some(option => Boolean(options[option.optionKey])), + ); + if (!hasOverrideInput) { + throw new Error("Provide at least one override: --deployment, --rpc, --chain-id, --explorer, 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; + } + + if (options.explorer) { + const url = options.explorer.trim(); + if (!/^https?:\/\//i.test(url)) { + throw new Error(`Invalid --explorer URL (must start with http:// or https://): ${options.explorer}`); + } + overrides.explorer = url; + } + + 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..62c762eb 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -3,21 +3,9 @@ import {createClient, createAccount, formatStakingAmount, parseStakingAmount, ab import type {GenLayerClient, GenLayerChain, Address} from "genlayer-js/types"; import {readFileSync, existsSync} from "fs"; import {ethers, ZeroAddress} from "ethers"; -import {createPublicClient, createWalletClient, http, type PublicClient, type WalletClient, type Chain, type Account, type HttpTransportConfig} from "viem"; -import {privateKeyToAccount} from "viem/accounts"; - -// GenLayer RPC rejects JSON-RPC requests with id=0 (treats 0 as missing). -// Viem starts its id counter at 0, so we ensure non-zero ids. -const glHttpConfig: HttpTransportConfig = { - async fetchFn(url, init) { - if (init?.body) { - const body = JSON.parse(init.body as string); - if (body.id === 0) body.id = 1; - init = {...init, body: JSON.stringify(body)}; - } - return fetch(url, init); - }, -}; +import {createPublicClient, http} from "viem"; +import {glHttpConfig, type BrowserSession} from "../../lib/wallet/browserSend"; +import {resolveBrowserWalletSession} from "../../lib/wallet/sessionResolver"; // Extended ABI for tree traversal (not in SDK) const STAKING_TREE_ABI = [ @@ -39,8 +27,12 @@ export interface StakingConfig { network?: string; account?: string; password?: string; + wallet?: "keystore" | "browser"; } +/** Staking-scoped session: the shared BrowserSession plus a resolved staking address. */ +export type BrowserWalletSession = BrowserSession & {stakingAddress: string}; + export class StakingAction extends BaseAction { private _stakingClient: GenLayerClient | null = null; private _passwordOverride: string | undefined; @@ -52,14 +44,93 @@ 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 {...resolveNetwork(config.network, this.getCustomNetworks())}; + } + + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + } + + /** + * Validate flag combinations for browser-wallet mode. Delegates value/password + * checks to the shared BaseAction.assertWalletFlags, then applies the + * staking-specific `--account` wording (wizard: "the browser wallet is the + * owner"). Preserves the #367 call-site/test messages. + */ + protected assertBrowserWalletFlags(config: StakingConfig, context: "validator-join" | "wizard"): void { + // Shared checks: invalid --wallet value + --password conflict. + this.assertWalletFlags(config, {accountFlagExists: false, context}); + if (!this.isBrowserWallet(config)) return; + if (config.account !== undefined) { + if (context === "validator-join") { + throw new Error("--account selects a keystore; not applicable with --wallet browser"); } - return {...network}; + throw new Error("--account cannot be used with --wallet browser (the browser wallet is the owner)"); } + } + + /** + * Build a staking browser-wallet signing session: resolves the staking + * address, then delegates to the shared bridge session. Never touches + * keystore/keychain/password code paths. + */ + protected async getBrowserWalletSession( + config: StakingConfig, + context: "validator-join" | "wizard", + ): Promise { + this.assertBrowserWalletFlags(config, context); + + const chain = this.getNetwork(config); + const rpcUrl = config.rpc || chain.rpcUrls.default.http[0]; + const stakingAddress = config.stakingAddress || chain.stakingContract?.address; - return resolveNetwork(this.getConfig().network); + if (!stakingAddress) { + throw new Error( + "Staking contract address not configured. Pass --staking-address or use a network with one.", + ); + } + + // The wizard is a self-contained interactive flow: keep its own single-use + // bridge (own-bridge) rather than leaving a surprise daemon behind. + // validator-join reuses / auto-starts the persistent session like other writes. + const session = await resolveBrowserWalletSession({ + chain, + rpcUrl, + networkAlias: config.network ?? this.getConfig().network, + configManager: this, + fallback: context === "wizard" ? "own-bridge" : "auto-start", + log: (msg: string) => this.log(msg), + logInfo: (msg: string) => this.logInfo(msg), + logWarning: (msg: string) => this.logWarning(msg), + }); + this.browserSession = session; + + return {...session, stakingAddress}; + } + + /** + * Build a staking client bound to a browser-wallet session: an Address-only + * account plus the session's EIP-1193 provider. The SDK's `executeWrite` + * branches on `account.type` — for an Address it routes `eth_sendTransaction` + * through the provider (the bridge signs), so browser writes run the exact + * same `client.(...)` calls as the keystore lane. No keystore / + * keychain / password code path is ever touched. Callers should + * `session.setNextLabel(...)` before each write so the bridge shows a + * human-readable label instead of a generic one. + */ + protected getBrowserStakingClient( + config: StakingConfig, + session: BrowserSession, + ): GenLayerClient { + const network = this.getNetwork(config); + if (config.stakingAddress) { + network.stakingContract = {address: config.stakingAddress as Address, abi: abi.STAKING_ABI}; + } + return createClient({ + chain: network, + endpoint: config.rpc, + account: session.signerAddress, + provider: session.eip1193Provider, + } as Parameters[0]); } protected async getStakingClient(config: StakingConfig): Promise> { @@ -113,7 +184,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")); @@ -132,7 +208,9 @@ 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.`); + throw new Error( + `Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`, + ); } const keystoreJson = readFileSync(keystorePath, "utf-8"); @@ -147,7 +225,7 @@ export class StakingAction extends BaseAction { // Verify cached key matches keystore address - safety check const tempAccount = createAccount(cachedKey as `0x${string}`); const cachedAddress = tempAccount.address.toLowerCase(); - const keystoreAddress = `0x${keystoreData.address.toLowerCase().replace(/^0x/, '')}`; + const keystoreAddress = `0x${keystoreData.address.toLowerCase().replace(/^0x/, "")}`; if (cachedAddress !== keystoreAddress) { // Cached key doesn't match keystore - invalidate it @@ -190,46 +268,6 @@ export class StakingAction extends BaseAction { return (addr.startsWith("0x") ? addr : `0x${addr}`) as Address; } - /** - * Get viem clients for direct contract interactions (e.g., ValidatorWallet calls) - * Future: can be extended to support hardware wallets - */ - protected async getViemClients(config: StakingConfig): Promise<{ - walletClient: WalletClient; - publicClient: PublicClient; - signerAddress: Address; - }> { - if (config.account) { - this.accountOverride = config.account; - } - if (config.password) { - this._passwordOverride = config.password; - } - - const network = this.getNetwork(config); - const rpcUrl = config.rpc || network.rpcUrls.default.http[0]; - - const privateKey = await this.getPrivateKeyForStaking(); - const account = privateKeyToAccount(privateKey as `0x${string}`); - - const publicClient = createPublicClient({ - chain: network, - transport: http(rpcUrl, glHttpConfig), - }); - - const walletClient = createWalletClient({ - chain: network, - transport: http(rpcUrl, glHttpConfig), - account, - }); - - return { - walletClient, - publicClient, - signerAddress: account.address as Address, - }; - } - /** * Get all validators by traversing the validator tree. * This finds ALL validators including those not yet active/primed. @@ -272,12 +310,12 @@ export class StakingAction extends BaseAction { validators.push(addr as Address); - const info = await publicClient.readContract({ + const info = (await publicClient.readContract({ address: stakingAddress as `0x${string}`, abi: abi.STAKING_ABI, functionName: "validatorView", args: [addr as `0x${string}`], - }) as {left: string; right: string}; + })) as {left: string; right: string}; if (info.left !== ZeroAddress) { stack.push(info.left); diff --git a/src/commands/staking/delegatorClaim.ts b/src/commands/staking/delegatorClaim.ts index 6f1b2b56..dcda92ee 100644 --- a/src/commands/staking/delegatorClaim.ts +++ b/src/commands/staking/delegatorClaim.ts @@ -12,6 +12,10 @@ export class DelegatorClaimAction extends StakingAction { } async execute(options: DelegatorClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming delegator withdrawals..."); try { @@ -38,4 +42,39 @@ export class DelegatorClaimAction extends StakingAction { this.failSpinner("Failed to claim", error.message || error); } } + + private async executeWithBrowserWallet(options: DelegatorClaimOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const delegatorAddress = options.delegator || session.signerAddress; + const client = this.getBrowserStakingClient(options, session); + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Claim delegator withdrawals`); + const result = await client.delegatorClaim({ + validator: options.validator as Address, + delegator: delegatorAddress as Address, + }); + + this.succeedSpinner("Claim successful!", { + transactionHash: result.transactionHash, + delegator: delegatorAddress, + validator: options.validator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/delegatorExit.ts b/src/commands/staking/delegatorExit.ts index 66e32996..fdd42be5 100644 --- a/src/commands/staking/delegatorExit.ts +++ b/src/commands/staking/delegatorExit.ts @@ -12,6 +12,10 @@ export class DelegatorExitAction extends StakingAction { } async execute(options: DelegatorExitOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating delegator exit..."); try { @@ -53,4 +57,54 @@ export class DelegatorExitAction extends StakingAction { this.failSpinner("Failed to exit", error.message || error); } } + + private async executeWithBrowserWallet(options: DelegatorExitOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + 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 = this.getBrowserStakingClient(options, session); + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Exit ${shares} shares from validator`); + const result = await client.delegatorExit({ + validator: options.validator as Address, + shares, + }); + + // Check epoch to determine note + const epochInfo = await client.getEpochInfo(); + const isEpochZero = epochInfo.currentEpoch === 0n; + + this.succeedSpinner("Exit initiated successfully!", { + transactionHash: result.transactionHash, + validator: options.validator, + sharesWithdrawn: shares.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: isEpochZero + ? "Epoch 0: Withdrawal claimable immediately" + : "Withdrawal will be claimable after the unbonding period", + }); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/delegatorJoin.ts b/src/commands/staking/delegatorJoin.ts index c9649bcf..ac8eedf4 100644 --- a/src/commands/staking/delegatorJoin.ts +++ b/src/commands/staking/delegatorJoin.ts @@ -13,6 +13,10 @@ export class DelegatorJoinAction extends StakingAction { } async execute(options: DelegatorJoinOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Joining as delegator..."); try { @@ -41,4 +45,41 @@ export class DelegatorJoinAction extends StakingAction { this.failSpinner("Failed to join as delegator", error.message || error); } } + + private async executeWithBrowserWallet(options: DelegatorJoinOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to join as delegator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const amount = this.parseAmount(options.amount); + const client = this.getBrowserStakingClient(options, session); + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Delegate ${this.formatAmount(amount)} to validator`); + const result = await client.delegatorJoin({ + validator: options.validator as Address, + amount, + }); + + this.succeedSpinner("Successfully joined as delegator!", { + transactionHash: result.transactionHash, + validator: result.validator, + amount: result.amount, + delegator: result.delegator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + console.log(chalk.dim(`\nTo view your delegation: genlayer staking delegation-info --validator ${options.validator}`)); + } catch (error: any) { + this.failSpinner("Failed to join as delegator", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index 41a1d535..f1cc249b 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -1,4 +1,6 @@ import {Command} from "commander"; +import type {StakingConfig} from "./StakingAction"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; import {ValidatorJoinAction, ValidatorJoinOptions} from "./validatorJoin"; import {ValidatorDepositAction, ValidatorDepositOptions} from "./validatorDeposit"; import {ValidatorExitAction, ValidatorExitOptions} from "./validatorExit"; @@ -11,51 +13,80 @@ 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) { const staking = program.command("staking").description("Staking operations for validators and delegators"); // Wizard command (main entry point for new validators) - staking - .command("wizard") - .description("Interactive wizard to become a validator") - .option("--account ", "Account to use (skip selection)") - .option("--network ", "Network to use (skip selection)") - .option("--skip-identity", "Skip identity setup step") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: WizardOptions) => { - const wizard = new ValidatorWizardAction(); - await wizard.execute(options); - }); + addWalletModeOption( + staking + .command("wizard") + .description("Interactive wizard to become a validator: funds the stake from your wallet or a vesting contract, and signs with a keystore key or a browser wallet (--wallet browser). Every prompt can be supplied by a flag; pass --non-interactive to run scripted with zero prompts") + .option("--account ", "Account to use (skip selection)") + .option("--network ", "Network to use (skip selection)") + .option("--skip-identity", "Skip identity setup step") + .option("--rpc ", "RPC URL for the network") + .option("--staking-address
", "Staking contract address (overrides chain config)") + // Non-interactive / scriptable mode + .option("--non-interactive", "Run end-to-end with no prompts; every choice must come from a flag") + .option("--yes", "Alias for --non-interactive (assume yes to confirmations)") + .option("--funding-source ", "Where the self-stake is funded from: 'wallet' (default) or 'vesting'") + .option("--vesting-contract
", "Vesting contract to fund from (with --funding-source vesting)") + .option("--operator
", "External operator address (0x...)") + .option("--create-operator ", "Create a new operator account and export its keystore") + .option("--operator-same", "Use the owner address as the operator") + .option("--operator-password ", "Password for the exported operator keystore (with --create-operator)") + .option("--operator-keystore-out ", "Output filename for the exported operator keystore") + .option("--amount ", "Self-stake amount (GEN, e.g. '42' or '42gen')") + // Identity metadata (mirrors `staking set-identity`); --moniker enables the identity step + .option("--moniker ", "Validator display name (enables the identity step)") + .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"), + ).action(async (options: WizardOptions) => { + const wizard = new ValidatorWizardAction(); + await wizard.execute(options); + }); // Validator commands - staking - .command("validator-join") - .description("Join as a validator by staking tokens") - .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen')") - .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("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: ValidatorJoinOptions) => { - const action = new ValidatorJoinAction(); - await action.execute(options); - }); + addWalletModeOption( + staking + .command("validator-join") + .description("Join as a validator by staking tokens") + .requiredOption( + "--amount ", + "Amount to stake (in wei or with 'eth'/'gen' suffix, e.g., '42000gen')", + ) + .option("--operator
", "Operator address (defaults to signer)") + .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("--force", "Proceed even if self-stake is below the minimum required to become active") + .option("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (options: ValidatorJoinOptions) => { + const action = new ValidatorJoinAction(); + await action.execute(options); + }); - staking - .command("validator-deposit [validator]") - .description("Make an additional deposit to a validator wallet") - .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") - .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("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { + addWalletModeOption( + staking + .command("validator-deposit [validator]") + .description("Make an additional deposit to a validator wallet") + .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") + .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 ", "built-in or custom network alias (see: genlayer network list)") + .option("--rpc ", "RPC URL for the network") + .option("--force", "Proceed even if self-stake is below the minimum required to become active"), + ).action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -65,16 +96,17 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("validator-exit [validator]") - .description("Exit as a validator by withdrawing shares") - .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") - .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("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: ValidatorExitOptions) => { + addWalletModeOption( + staking + .command("validator-exit [validator]") + .description("Exit as a validator by withdrawing shares") + .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") + .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 ", "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; if (!validator) { console.error("Error: validator address is required"); @@ -84,15 +116,16 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("validator-claim [validator]") - .description("Claim validator withdrawals after unbonding period") - .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("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: ValidatorClaimOptions) => { + addWalletModeOption( + staking + .command("validator-claim [validator]") + .description("Claim validator withdrawals after unbonding period") + .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 ", "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; if (!validator) { console.error("Error: validator address is required"); @@ -102,16 +135,17 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("validator-prime [validator]") - .description("Prime a validator to prepare their stake record for the next epoch") - .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("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: ValidatorPrimeOptions) => { + addWalletModeOption( + staking + .command("validator-prime [validator]") + .description("Prime a validator to prepare their stake record for the next epoch") + .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 ", "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) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -121,57 +155,66 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("prime-all") - .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("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: StakingConfig) => { + addWalletModeOption( + staking + .command("prime-all") + .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 ", "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) => { const action = new ValidatorPrimeAction(); await action.primeAll(options); }); - staking - .command("set-operator [validator] [operator]") - .description("Change the operator address for a validator wallet") - .option("--validator
", "Validator wallet address (deprecated, use positional arg)") - .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("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, operatorArg: string | undefined, options: SetOperatorOptions) => { - const validator = validatorArg || options.validator; - const operator = operatorArg || options.operator; - if (!validator || !operator) { - console.error("Error: validator and operator addresses are required"); - process.exit(1); - } - const action = new SetOperatorAction(); - await action.execute({...options, validator, operator}); - }); + addWalletModeOption( + staking + .command("set-operator [validator] [operator]") + .description("Change the operator address for a validator wallet") + .option("--validator
", "Validator wallet address (deprecated, use positional arg)") + .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 ", "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; + const operator = operatorArg || options.operator; + if (!validator || !operator) { + console.error("Error: validator and operator addresses are required"); + process.exit(1); + } + const action = new SetOperatorAction(); + await action.execute({...options, validator, operator}); + }, + ); - staking - .command("set-identity [validator]") - .description("Set validator identity metadata (moniker, website, socials, etc.)") - .option("--validator
", "Validator wallet address (deprecated, use positional arg)") - .requiredOption("--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...)") - .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("--rpc ", "RPC URL for the network") - .action(async (validatorArg: string | undefined, options: SetIdentityOptions) => { + addWalletModeOption( + staking + .command("set-identity [validator]") + .description("Set validator identity metadata (moniker, website, socials, etc.)") + .option("--validator
", "Validator wallet address (deprecated, use positional arg)") + .requiredOption("--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...)") + .option("--account ", "Account to use (must be validator operator)") + .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"), + ).action(async (validatorArg: string | undefined, options: SetIdentityOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -182,17 +225,18 @@ export function initializeStakingCommands(program: Command) { }); // Delegator commands - staking - .command("delegator-join [validator]") - .description("Join as a delegator by staking with a validator") - .option("--validator
", "Validator address to delegate to (deprecated, use positional arg)") - .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("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: DelegatorJoinOptions) => { + addWalletModeOption( + staking + .command("delegator-join [validator]") + .description("Join as a delegator by staking with a validator") + .option("--validator
", "Validator address to delegate to (deprecated, use positional arg)") + .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 ", "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) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -202,17 +246,18 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("delegator-exit [validator]") - .description("Exit as a delegator by withdrawing shares from a validator") - .option("--validator
", "Validator address to exit from (deprecated, use positional arg)") - .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("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: DelegatorExitOptions) => { + addWalletModeOption( + staking + .command("delegator-exit [validator]") + .description("Exit as a delegator by withdrawing shares from a validator") + .option("--validator
", "Validator address to exit from (deprecated, use positional arg)") + .requiredOption("--shares ", "Number of shares to withdraw") + .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("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (validatorArg: string | undefined, options: DelegatorExitOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -222,17 +267,18 @@ export function initializeStakingCommands(program: Command) { await action.execute({...options, validator}); }); - staking - .command("delegator-claim [validator]") - .description("Claim delegator withdrawals after unbonding period") - .option("--validator
", "Validator address (deprecated, use positional arg)") - .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("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (validatorArg: string | undefined, options: DelegatorClaimOptions) => { + addWalletModeOption( + staking + .command("delegator-claim [validator]") + .description("Claim delegator withdrawals after unbonding period") + .option("--validator
", "Validator address (deprecated, use positional arg)") + .option("--delegator
", "Delegator address (defaults to signer)") + .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("--staking-address
", "Staking contract address (overrides chain config)"), + ).action(async (validatorArg: string | undefined, options: DelegatorClaimOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -248,9 +294,10 @@ 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("--json", "Output raw validator info as machine-readable JSON") .option("--debug", "Show raw unfiltered pending deposits/withdrawals") .action(async (validatorArg: string | undefined, options: StakingInfoOptions) => { const validator = validatorArg || options.validator; @@ -264,7 +311,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 +328,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 +339,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 +350,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 +361,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 +371,20 @@ 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 +397,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/setIdentity.ts b/src/commands/staking/setIdentity.ts index eb20acdb..9b35a064 100644 --- a/src/commands/staking/setIdentity.ts +++ b/src/commands/staking/setIdentity.ts @@ -1,7 +1,11 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; -import {abi} from "genlayer-js"; -import {toHex} from "viem"; +import type { + Address, + GenLayerClient, + GenLayerChain, + SetIdentityOptions as SdkSetIdentityOptions, + StakingTransactionResult, +} from "genlayer-js/types"; export interface SetIdentityOptions extends StakingConfig { validator: string; @@ -22,42 +26,106 @@ export class SetIdentityAction extends StakingAction { } async execute(options: SetIdentityOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting validator identity..."); try { const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + + // Route through the SDK staking client rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). The SDK owns the extraCid + // encoding (hex passthrough vs UTF-8 -> hex). `setIdentity` exists on the + // client at runtime but is missing from the installed genlayer-js + // StakingActions .d.ts — cast to bridge that type gap. + const client = (await this.getStakingClient(options)) as GenLayerClient & { + setIdentity(o: SdkSetIdentityOptions): Promise; + }; this.setSpinnerText(`Setting identity for ${validatorWallet}...`); - // Convert extraCid string to bytes (hex) - const extraCidBytes = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x"; - - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "setIdentity", - args: [ - options.moniker, - options.logoUri || "", - options.website || "", - options.description || "", - options.email || "", - options.twitter || "", - options.telegram || "", - options.github || "", - extraCidBytes, - ], + const result = await client.setIdentity({ + validator: validatorWallet, + 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: options.extraCid, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); + const output: Record = { + transactionHash: result.transactionHash, + validator: validatorWallet, + moniker: options.moniker, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + // Add optional fields that were set + 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("Validator identity set!", output); + } catch (error: any) { + this.failSpinner("Failed to set identity", error.message || error); + } + } + + private async executeWithBrowserWallet(options: SetIdentityOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to set identity", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const validatorWallet = options.validator as Address; + // `setIdentity` exists at runtime but is missing from the installed + // genlayer-js StakingActions .d.ts — cast to bridge that type gap. The + // SDK owns the extraCid encoding (hex passthrough vs UTF-8 -> hex). + const client = this.getBrowserStakingClient(options, session) as GenLayerClient & { + setIdentity(o: SdkSetIdentityOptions): Promise; + }; + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Set identity (${options.moniker})`); + const result = await client.setIdentity({ + validator: validatorWallet, + 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: options.extraCid, + }); const output: Record = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, moniker: options.moniker, - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; // Add optional fields that were set @@ -73,6 +141,8 @@ export class SetIdentityAction extends StakingAction { this.succeedSpinner("Validator identity set!", output); } catch (error: any) { this.failSpinner("Failed to set identity", error.message || error); + } finally { + await session.close(); } } } diff --git a/src/commands/staking/setOperator.ts b/src/commands/staking/setOperator.ts index 446cef56..e4221514 100644 --- a/src/commands/staking/setOperator.ts +++ b/src/commands/staking/setOperator.ts @@ -1,6 +1,11 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; -import {abi} from "genlayer-js"; +import type { + Address, + GenLayerClient, + GenLayerChain, + SetOperatorOptions as SdkSetOperatorOptions, + StakingTransactionResult, +} from "genlayer-js/types"; export interface SetOperatorOptions extends StakingConfig { validator: string; @@ -13,29 +18,39 @@ export class SetOperatorAction extends StakingAction { } async execute(options: SetOperatorOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting operator..."); try { const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + + // Route through the SDK staking client rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). `setOperator` exists on the + // client at runtime but is missing from the installed genlayer-js + // StakingActions .d.ts — cast to bridge that type gap. + const client = (await this.getStakingClient(options)) as GenLayerClient & { + setOperator(o: SdkSetOperatorOptions): Promise; + }; this.setSpinnerText(`Setting operator to ${options.operator}...`); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "setOperator", - args: [options.operator as Address], + const result = await client.setOperator({ + validator: validatorWallet, + operator: options.operator as Address, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); - const output = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, newOperator: options.operator, - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; this.succeedSpinner("Operator updated!", output); @@ -43,4 +58,43 @@ export class SetOperatorAction extends StakingAction { this.failSpinner("Failed to set operator", error.message || error); } } + + private async executeWithBrowserWallet(options: SetOperatorOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to set operator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const validatorWallet = options.validator as Address; + // `setOperator` exists at runtime but is missing from the installed + // genlayer-js StakingActions .d.ts — cast to bridge that type gap. + const client = this.getBrowserStakingClient(options, session) as GenLayerClient & { + setOperator(o: SdkSetOperatorOptions): Promise; + }; + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Set operator to ${options.operator}`); + const result = await client.setOperator({ + validator: validatorWallet, + operator: options.operator as Address, + }); + + this.succeedSpinner("Operator updated!", { + transactionHash: result.transactionHash, + validator: validatorWallet, + newOperator: options.operator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to set operator", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts index 2c580483..4f4d5a80 100644 --- a/src/commands/staking/stakingInfo.ts +++ b/src/commands/staking/stakingInfo.ts @@ -10,6 +10,7 @@ const UNBONDING_PERIOD_EPOCHS = 7n; export interface StakingInfoOptions extends StakingConfig { validator?: string; debug?: boolean; + json?: boolean; } export class StakingInfoAction extends StakingAction { @@ -22,7 +23,7 @@ export class StakingInfoAction extends StakingAction { try { const client = await this.getReadOnlyStakingClient(options); - const validatorAddress = options.validator || (await this.getSignerAddress()); + const validatorAddress = await this.resolveActiveIdentity(options, options.validator); const isValidator = await client.isValidator(validatorAddress as Address); @@ -110,18 +111,189 @@ export class StakingInfoAction extends StakingAction { }; } - this.succeedSpinner("Validator info retrieved", result); + // --json: emit the exact object the code builds today, machine-readable. + // No spinner decoration so the output is a clean JSON document. + if (options.json) { + this.stopSpinner(); + console.log(JSON.stringify(result, null, 2)); + return; + } + + const source = await this.detectSelfStakeSource(client, info.owner as Address); + + this.succeedSpinner("Validator info retrieved"); + this.renderValidatorInfo(info, result, epochInfo, source); } catch (error: any) { this.failSpinner("Failed to get validator info", error.message || error); } } + /** + * Best-effort liquid-vs-vesting label. A liquid validator's wallet is owned by + * the signer EOA; a vesting-funded one is owned by the vesting contract. The + * cheap distinguishing signal is whether the owner address has code. If the + * probe isn't available/fails, degrade to "unknown" — the owner address is + * shown regardless, so nothing is lost. + */ + private async detectSelfStakeSource(client: any, owner: Address): Promise { + try { + const getCode = client.getCode ?? client.getBytecode; + if (typeof getCode !== "function") return "unknown"; + const code = await getCode.call(client, {address: owner}); + return code && code !== "0x" ? "vesting (owner is a contract)" : "liquid (wallet-owned)"; + } catch { + return "unknown"; + } + } + + /** + * Clean grouped validator view. Every VALUE from the raw output is preserved + * verbatim as a plain substring (addresses, " GEN" amounts, moniker, + * "Not banned", epoch numbers, the `live` boolean) so downstream greps keep + * matching — only the layout changes. `live` is shown as an opaque contract + * flag, never relabelled "active". + */ + private renderValidatorInfo( + info: ValidatorInfo, + result: Record, + epochInfo: {currentEpoch: bigint; validatorMinStake: string; validatorMinStakeRaw: bigint}, + source: string, + ): void { + const currentEpoch = epochInfo.currentEpoch; + const label = (s: string) => chalk.gray(s); + + console.log(""); + + // Identity (only when set) + if (info.identity?.moniker) { + console.log(chalk.bold("Identity")); + console.log(` ${label("Moniker:")} ${info.identity.moniker}`); + if (info.identity.website) console.log(` ${label("Website:")} ${info.identity.website}`); + if (info.identity.description) console.log(` ${label("Description:")} ${info.identity.description}`); + if (info.identity.twitter) console.log(` ${label("Twitter:")} ${info.identity.twitter}`); + if (info.identity.telegram) console.log(` ${label("Telegram:")} ${info.identity.telegram}`); + if (info.identity.github) console.log(` ${label("GitHub:")} ${info.identity.github}`); + if (info.identity.email) console.log(` ${label("Email:")} ${info.identity.email}`); + if (info.identity.logoUri) console.log(` ${label("Logo URI:")} ${info.identity.logoUri}`); + console.log(""); + } + + // Addresses + console.log(chalk.bold("Addresses")); + console.log(` ${label("Validator:")} ${info.address}`); + console.log(` ${label("Owner:")} ${info.owner}`); + console.log(` ${label("Operator:")} ${info.operator}`); + console.log(` ${label("Source:")} ${source}`); + console.log(""); + + // Stake + console.log(chalk.bold("Stake")); + console.log(` ${label("Self (vStake):")} ${info.vStake} ${chalk.gray("(counts toward eligibility)")}`); + console.log(` ${label("Delegated (dStake):")} ${info.dStake} ${chalk.gray("(does NOT count toward eligibility)")}`); + console.log(` ${label("Deposit (vDeposit):")} ${info.vDeposit}`); + console.log(` ${label("Withdrawal (vWithdrawal):")} ${info.vWithdrawal}`); + console.log(""); + + // Status. `live` printed as the raw boolean, an opaque contract flag. + console.log(chalk.bold("Status")); + console.log(` ${label("live:")} ${info.live} ${chalk.gray("(contract flag: primed & not exited — not an eligibility signal)")}`); + console.log(` ${label("needsPriming:")} ${info.needsPriming}`); + console.log(` ${label("Banned:")} ${result.banned}`); + console.log(` ${label("Primed epoch (ePrimed):")} ${info.ePrimed}`); + console.log(` ${label("Current epoch:")} ${currentEpoch}`); + console.log(""); + + // Pending self-stake deposits + console.log(chalk.bold("Pending self-stake deposits")); + const deposits = result.selfStakePendingDeposits; + if (Array.isArray(deposits) && deposits.length > 0) { + for (const d of deposits) { + console.log( + ` ${label("epoch")} ${d.epoch}: ${d.stake} ` + + `${chalk.gray(`(activatesAtEpoch ${d.activatesAtEpoch}, ${d.epochsRemaining ?? d.status} epochs remaining)`)}`, + ); + } + } else { + console.log(` ${typeof deposits === "string" ? deposits : "None"}`); + } + console.log(""); + + // Pending self-stake withdrawals + console.log(chalk.bold("Pending self-stake withdrawals")); + const withdrawals = result.selfStakePendingWithdrawals; + if (Array.isArray(withdrawals) && withdrawals.length > 0) { + for (const w of withdrawals) { + console.log( + ` ${label("epoch")} ${w.epoch}: ${w.stake} ` + + `${chalk.gray(`(claimableAtEpoch ${w.claimableAtEpoch}, ${w.status})`)}`, + ); + } + } else { + console.log(` ${typeof withdrawals === "string" ? withdrawals : "None"}`); + } + console.log(""); + + // Eligibility warning (display-only — never blocks a read command). + this.renderEligibility(info, epochInfo); + } + + /** + * Display-only self-stake eligibility note (decision 2). Counts still-pending + * self-stake deposits toward the effective self-stake: + * effectiveSelfStakeRaw = vStakeRaw + sum(pendingDeposits[].stakeRaw) + * Epoch-0 aware. Reads the minimum from epochInfo — never hardcoded. + */ + private renderEligibility( + info: ValidatorInfo, + epochInfo: {currentEpoch: bigint; validatorMinStake: string; validatorMinStakeRaw: bigint}, + ): void { + const minRaw = epochInfo.validatorMinStakeRaw; + const minFmt = epochInfo.validatorMinStake; + const pendingSum = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + const effectiveSelfStakeRaw = info.vStakeRaw + pendingSum; + + if (epochInfo.currentEpoch === 0n) { + this.logInfo( + `Epoch 0: minimum self-stake not enforced yet. This validator won't become active until its ` + + `self-stake reaches ${minFmt}.`, + ); + return; + } + + if (effectiveSelfStakeRaw < minRaw) { + this.logWarning( + `Self-stake below minimum: ${info.vStake} self-staked vs ${minFmt} required. This validator ` + + `won't become active until its self-stake reaches the minimum. Only self-stake counts toward ` + + `eligibility (delegated stake does not).`, + ); + return; + } + + if (info.vStakeRaw < minRaw) { + // Active stake alone is below min, but pending deposits will cross it. + let running = info.vStakeRaw; + let activatesAtEpoch = "?"; + const sorted = [...info.pendingDeposits].sort((a, b) => (a.epoch < b.epoch ? -1 : a.epoch > b.epoch ? 1 : 0)); + for (const d of sorted) { + running += d.stakeRaw; + if (running >= minRaw) { + activatesAtEpoch = (d.epoch + ACTIVATION_DELAY_EPOCHS).toString(); + break; + } + } + this.logInfo( + `Self-stake meets the ${minFmt} minimum once the pending deposit activates at epoch ` + + `${activatesAtEpoch}. Currently ${info.vStake} is active.`, + ); + } + } + async getStakeInfo(options: StakingInfoOptions & {delegator?: string}): Promise { this.startSpinner("Fetching stake info..."); try { const client = await this.getReadOnlyStakingClient(options); - const delegatorAddress = options.delegator || (await this.getSignerAddress()); + const delegatorAddress = await this.resolveActiveIdentity(options, options.delegator); const isOwnDelegation = !options.delegator; this.setSpinnerText(`Fetching delegation info for ${delegatorAddress}...`); @@ -361,12 +533,13 @@ export class StakingInfoAction extends StakingAction { try { const client = await this.getReadOnlyStakingClient(options); - // Get current user's address to mark "mine" + // Get current user's address to mark "mine" — honor a live wallet session + // so "mine" tracks the connected identity, not just the keystore default. let myAddress: Address | null = null; try { - myAddress = await this.getSignerAddress(); + myAddress = await this.resolveActiveIdentity(options); } catch { - // No account configured, that's fine + // No account or session configured, that's fine } // Use tree traversal to get ALL validators (including not-yet-primed) diff --git a/src/commands/staking/validatorClaim.ts b/src/commands/staking/validatorClaim.ts index 319ffbbd..4a39d50f 100644 --- a/src/commands/staking/validatorClaim.ts +++ b/src/commands/staking/validatorClaim.ts @@ -1,6 +1,5 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; -import {abi} from "genlayer-js"; export interface ValidatorClaimOptions extends StakingConfig { validator: string; @@ -12,32 +11,77 @@ export class ValidatorClaimAction extends StakingAction { } async execute(options: ValidatorClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming validator withdrawals..."); try { const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + + // Route through the SDK staking client rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). + const client = await this.getStakingClient(options); this.setSpinnerText(`Claiming for validator ${validatorWallet}...`); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "validatorClaim", - }); + const result = await client.validatorClaim({validator: validatorWallet}); + + const output: Record = { + transactionHash: result.transactionHash, + validator: validatorWallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + if (result.claimedAmount !== undefined) { + output.claimedAmount = this.formatAmount(result.claimedAmount); + } + + this.succeedSpinner("Claim successful!", output); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + } + } + + private async executeWithBrowserWallet(options: ValidatorClaimOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to claim", error.message || error); + return; + } - const receipt = await publicClient.waitForTransactionReceipt({hash}); + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const validatorWallet = options.validator as Address; + const client = this.getBrowserStakingClient(options, session); - const output = { - transactionHash: receipt.transactionHash, + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Claim validator withdrawals`); + const result = await client.validatorClaim({validator: validatorWallet}); + + const output: Record = { + transactionHash: result.transactionHash, validator: validatorWallet, - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; + if (result.claimedAmount !== undefined) { + output.claimedAmount = this.formatAmount(result.claimedAmount); + } + this.succeedSpinner("Claim successful!", output); } catch (error: any) { this.failSpinner("Failed to claim", error.message || error); + } finally { + await session.close(); } } } diff --git a/src/commands/staking/validatorDeposit.ts b/src/commands/staking/validatorDeposit.ts index e295fdf6..69831849 100644 --- a/src/commands/staking/validatorDeposit.ts +++ b/src/commands/staking/validatorDeposit.ts @@ -1,10 +1,10 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; -import {abi} from "genlayer-js"; +import type {Address, GenLayerClient, GenLayerChain} from "genlayer-js/types"; export interface ValidatorDepositOptions extends StakingConfig { amount: string; validator: string; + force?: boolean; } export class ValidatorDepositAction extends StakingAction { @@ -12,32 +12,91 @@ export class ValidatorDepositAction extends StakingAction { super(); } + /** + * Pre-submit checks for a liquid (wallet-funded) top-up deposit: + * 1. Mixing hard-guard: the target wallet must be owned by the signing EOA. + * A vesting-funded validator's wallet is owned by the vesting contract, + * so a liquid deposit would revert on-chain (OwnableUnauthorizedAccount) + * — fail fast with actionable copy instead. No `--force` override. + * 2. Self-stake minimum: the resulting self-stake is the current committed + * self-stake plus still-pending self-stake deposits plus the new amount. + * Block unless `--force`, epoch-0 aware. + */ + private async preflight( + client: GenLayerClient, + validatorWallet: Address, + signerAddress: Address, + amount: bigint, + force?: boolean, + ): Promise { + const info = await client.getValidatorInfo(validatorWallet); + + // Mixing guard — a liquid deposit into a vesting-owned wallet reverts + // on-chain; fail fast with guidance. This is a hard guard, always enforced. + if (info.owner.toLowerCase() !== signerAddress.toLowerCase()) { + throw new Error( + "This validator wallet is owned by a vesting contract (vesting-funded self-stake). " + + "Self-stake source is fixed at creation — you can't add liquid (wallet) tokens. " + + "Use `genlayer vesting validator-deposit` instead.", + ); + } + + // The self-stake minimum is advisory. If the chain can't report it, skip + // the check rather than blocking the deposit. + let epochInfo; + try { + epochInfo = await client.getEpochInfo(); + } catch { + return; + } + const pendingSelfStakeRaw = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + const resultingSelfStakeRaw = info.vStakeRaw + pendingSelfStakeRaw + amount; + this.assertOrWarnSelfStakeMinimum({ + currentEpoch: epochInfo.currentEpoch, + minStakeRaw: epochInfo.validatorMinStakeRaw, + minStakeFormatted: epochInfo.validatorMinStake, + resultingSelfStakeRaw, + resultingSelfStakeFormatted: this.formatAmount(resultingSelfStakeRaw), + force, + }); + } + async execute(options: ValidatorDepositOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Making validator deposit..."); try { const amount = this.parseAmount(options.amount); const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + // Route through the SDK's staking action rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). The action forwards to the + // ValidatorWallet's own `validatorDeposit`, preserving msg.sender == + // ValidatorWallet when it re-enters Staking. + const client = await this.getStakingClient(options); + const signerAddress = await this.getSignerAddress(); + + await this.preflight(client, validatorWallet, signerAddress, amount, options.force); this.setSpinnerText(`Depositing ${this.formatAmount(amount)} to validator ${validatorWallet}...`); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "validatorDeposit", - value: amount, + const result = await client.validatorDeposit({ + validator: validatorWallet, + amount, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); - const output = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, amount: this.formatAmount(amount), - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), }; this.succeedSpinner("Deposit successful!", output); @@ -45,4 +104,42 @@ export class ValidatorDepositAction extends StakingAction { this.failSpinner("Failed to make deposit", error.message || error); } } + + private async executeWithBrowserWallet(options: ValidatorDepositOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to make deposit", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const amount = this.parseAmount(options.amount); + const validatorWallet = options.validator as Address; + const client = this.getBrowserStakingClient(options, session); + + await this.preflight(client, validatorWallet, session.signerAddress as Address, amount, options.force); + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Deposit ${this.formatAmount(amount)} to validator`); + const result = await client.validatorDeposit({ + validator: validatorWallet, + amount, + }); + + this.succeedSpinner("Deposit successful!", { + transactionHash: result.transactionHash, + validator: validatorWallet, + amount: this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to make deposit", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validatorExit.ts b/src/commands/staking/validatorExit.ts index 5a038c5e..3f4ced2e 100644 --- a/src/commands/staking/validatorExit.ts +++ b/src/commands/staking/validatorExit.ts @@ -1,6 +1,5 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; -import {abi} from "genlayer-js"; export interface ValidatorExitOptions extends StakingConfig { validator: string; @@ -13,6 +12,10 @@ export class ValidatorExitAction extends StakingAction { } async execute(options: ValidatorExitOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating validator exit..."); try { @@ -26,30 +29,33 @@ export class ValidatorExitAction extends StakingAction { } const validatorWallet = options.validator as Address; - const {walletClient, publicClient} = await this.getViemClients(options); + + // Route through the SDK's staking action rather than a raw viem + // writeContract. The SDK's executeWrite pins `type: "legacy"` and does + // manual nonce/gas + sign + sendRawTransaction, which the GenLayer + // consensus RPC requires (it has no EIP-1559 fee support, so viem's + // default fee/tx-type negotiation fails). The action forwards to the + // ValidatorWallet's own `validatorExit`, preserving msg.sender == + // ValidatorWallet when it re-enters Staking. + const client = await this.getStakingClient(options); this.setSpinnerText(`Exiting validator ${validatorWallet} with ${shares} shares...`); - const hash = await walletClient.writeContract({ - address: validatorWallet, - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "validatorExit", - args: [shares], + const result = await client.validatorExit({ + validator: validatorWallet, + shares, }); - const receipt = await publicClient.waitForTransactionReceipt({hash}); - // Check epoch to determine note - const readClient = await this.getReadOnlyStakingClient(options); - const epochInfo = await readClient.getEpochInfo(); + const epochInfo = await client.getEpochInfo(); const isEpochZero = epochInfo.currentEpoch === 0n; const output = { - transactionHash: receipt.transactionHash, + transactionHash: result.transactionHash, validator: validatorWallet, sharesWithdrawn: shares.toString(), - blockNumber: receipt.blockNumber.toString(), - gasUsed: receipt.gasUsed.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), note: isEpochZero ? "Epoch 0: Withdrawal claimable immediately" : "Withdrawal will be claimable after the unbonding period", @@ -60,4 +66,55 @@ export class ValidatorExitAction extends StakingAction { this.failSpinner("Failed to exit", error.message || error); } } + + private async executeWithBrowserWallet(options: ValidatorExitOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + 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 validatorWallet = options.validator as Address; + const client = this.getBrowserStakingClient(options, session); + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Exit validator (${shares} shares)`); + const result = await client.validatorExit({ + validator: validatorWallet, + shares, + }); + + // Check epoch to determine note + const epochInfo = await client.getEpochInfo(); + const isEpochZero = epochInfo.currentEpoch === 0n; + + this.succeedSpinner("Exit initiated successfully!", { + transactionHash: result.transactionHash, + validator: validatorWallet, + sharesWithdrawn: shares.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: isEpochZero + ? "Epoch 0: Withdrawal claimable immediately" + : "Withdrawal will be claimable after the unbonding period", + }); + } catch (error: any) { + this.failSpinner("Failed to exit", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validatorHistory.ts b/src/commands/staking/validatorHistory.ts index 071ad84c..e0273cac 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 { @@ -91,7 +85,7 @@ export class ValidatorHistoryAction extends StakingAction { } const client = await this.getReadOnlyStakingClient(options); - const validatorAddress = options.validator || (await this.getSignerAddress()); + const validatorAddress = await this.resolveActiveIdentity(options, options.validator); // Verify it's a validator const isValidator = await client.isValidator(validatorAddress as Address); @@ -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/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 8185fd91..8318dab4 100644 --- a/src/commands/staking/validatorJoin.ts +++ b/src/commands/staking/validatorJoin.ts @@ -1,9 +1,10 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; +import type {Address, GenLayerClient, GenLayerChain} from "genlayer-js/types"; export interface ValidatorJoinOptions extends StakingConfig { amount: string; operator?: string; + force?: boolean; } export class ValidatorJoinAction extends StakingAction { @@ -11,7 +12,45 @@ export class ValidatorJoinAction extends StakingAction { super(); } + /** + * A fresh join always creates a NEW liquid (wallet-funded) validator wallet, + * so the self-stake source is fixed at creation and the resulting self-stake + * is exactly the join amount. Warn/block if that is below the on-chain + * minimum, and surface the source note. + */ + private async preflight( + client: GenLayerClient, + amount: bigint, + force?: boolean, + ): Promise { + this.logInfo( + "Creating a liquid (wallet-funded) validator. Self-stake source is fixed at creation — " + + "you won't be able to add vesting tokens later.", + ); + // The self-stake minimum is advisory. If the chain can't report it + // (a minimal/stub staking contract without epoch/minStake reads, or a + // transient read failure), skip the check rather than blocking the join. + let epochInfo; + try { + epochInfo = await client.getEpochInfo(); + } catch { + return; + } + this.assertOrWarnSelfStakeMinimum({ + currentEpoch: epochInfo.currentEpoch, + minStakeRaw: epochInfo.validatorMinStakeRaw, + minStakeFormatted: epochInfo.validatorMinStake, + resultingSelfStakeRaw: amount, + resultingSelfStakeFormatted: this.formatAmount(amount), + force, + }); + } + async execute(options: ValidatorJoinOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Creating a new validator..."); try { @@ -19,6 +58,8 @@ export class ValidatorJoinAction extends StakingAction { const amount = this.parseAmount(options.amount); const signerAddress = await this.getSignerAddress(); + await this.preflight(client, amount, options.force); + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} stake...`); this.log(` From: ${signerAddress}`); if (options.operator) { @@ -44,4 +85,50 @@ export class ValidatorJoinAction extends StakingAction { this.failSpinner("Failed to create validator", error.message || error); } } + + private async executeWithBrowserWallet(options: ValidatorJoinOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to create validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const amount = this.parseAmount(options.amount); + const client = this.getBrowserStakingClient(options, session); + + await this.preflight(client, amount, options.force); + + this.log(` From (browser wallet): ${session.signerAddress}`); + if (options.operator) { + this.log(` Operator: ${options.operator}`); + } + + // Same SDK call as the keystore lane; the SDK decodes the ValidatorJoin + // event and returns validatorWallet for both lanes. + session.setNextLabel(`Join as validator (${this.formatAmount(amount)})`); + const result = await client.validatorJoin({ + amount, + operator: options.operator as Address | undefined, + }); + + this.succeedSpinner("Validator created successfully!", { + transactionHash: result.transactionHash, + validatorWallet: result.validatorWallet, + amount: result.amount, + operator: result.operator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to create validator", error.message || error); + } finally { + // session.close() is a no-op for a remote (daemon) session and a full + // close for an own bridge — so a shared daemon survives the command. + await session.close(); + } + } } diff --git a/src/commands/staking/validatorPrime.ts b/src/commands/staking/validatorPrime.ts index bf7d4294..7cb96c0a 100644 --- a/src/commands/staking/validatorPrime.ts +++ b/src/commands/staking/validatorPrime.ts @@ -1,7 +1,15 @@ import {StakingAction, StakingConfig} from "./StakingAction"; -import type {Address} from "genlayer-js/types"; +import type {Address, GenLayerClient, GenLayerChain, StakingTransactionResult} from "genlayer-js/types"; import chalk from "chalk"; +/** + * `validatorPrime` exists on the client at runtime but is missing from the + * installed genlayer-js StakingActions .d.ts — this alias bridges that type gap. + */ +type ClientWithPrime = GenLayerClient & { + validatorPrime(o: {validator: Address}): Promise; +}; + export interface ValidatorPrimeOptions extends StakingConfig { validator: string; } @@ -12,6 +20,10 @@ export class ValidatorPrimeAction extends StakingAction { } async execute(options: ValidatorPrimeOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Priming validator..."); try { @@ -21,6 +33,8 @@ export class ValidatorPrimeAction extends StakingAction { const result = await client.validatorPrime({validator: options.validator as Address}); + await this.maybeNotePrimedBelowMinimum(client, options.validator as Address); + const output = { transactionHash: result.transactionHash, validator: options.validator, @@ -34,7 +48,69 @@ export class ValidatorPrimeAction extends StakingAction { } } + private async executeWithBrowserWallet(options: ValidatorPrimeOptions): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to prime validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const client = this.getBrowserStakingClient(options, session) as ClientWithPrime; + + this.log(` From (browser wallet): ${session.signerAddress}`); + session.setNextLabel(`Prime ${options.validator}`); + const result = await client.validatorPrime({validator: options.validator as Address}); + + this.succeedSpinner("Validator primed for next epoch!", { + transactionHash: result.transactionHash, + validator: options.validator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to prime validator", error.message || error); + } finally { + await session.close(); + } + } + + /** + * Best-effort one-line note: priming makes a validator's stake record ready + * for the next epoch, but it still won't become active while self-stake is + * below the on-chain minimum. Purely informational; never fails the prime. + */ + private async maybeNotePrimedBelowMinimum( + client: GenLayerClient, + validator: Address, + ): Promise { + try { + const [info, epochInfo] = await Promise.all([ + client.getValidatorInfo(validator), + client.getEpochInfo(), + ]); + if (epochInfo.currentEpoch === 0n) return; + const effectiveSelfStakeRaw = + info.vStakeRaw + info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + if (effectiveSelfStakeRaw < epochInfo.validatorMinStakeRaw) { + this.logInfo( + `Primed, but this validator won't become active until its self-stake reaches ` + + `${epochInfo.validatorMinStake} (currently ${info.vStake}).`, + ); + } + } catch { + // Informational only — never block or fail the prime on this note. + } + } + async primeAll(options: StakingConfig): Promise { + if (this.isBrowserWallet(options)) { + return this.primeAllWithBrowserWallet(options); + } + this.startSpinner("Fetching validators..."); try { @@ -70,4 +146,48 @@ export class ValidatorPrimeAction extends StakingAction { this.failSpinner("Failed to prime validators", error.message || error); } } + + private async primeAllWithBrowserWallet(options: StakingConfig): Promise { + let session; + try { + session = await this.getBrowserWalletSession(options, "validator-join"); + } catch (error: any) { + this.failSpinner("Failed to prime validators", error.message || error); + return; + } + + try { + this.startSpinner("Fetching validators..."); + const allValidators = await this.getAllValidatorsFromTree(options); + const client = this.getBrowserStakingClient(options, session) as ClientWithPrime; + + this.stopSpinner(); + console.log(`\nPriming ${allValidators.length} validators:\n`); + + let succeeded = 0; + let skipped = 0; + + for (const addr of allValidators) { + process.stdout.write(` ${addr} ... `); + + try { + session.setNextLabel(`Prime ${addr}`); + const result = await client.validatorPrime({validator: addr}); + console.log(chalk.green(`primed ${result.transactionHash}`)); + succeeded++; + } catch (error: any) { + const msg = error.message || String(error); + const shortErr = msg.length > 60 ? msg.slice(0, 57) + "..." : msg; + console.log(chalk.gray(`skipped: ${shortErr}`)); + skipped++; + } + } + + console.log(`\n${chalk.green(`${succeeded} primed`)}, ${chalk.gray(`${skipped} skipped`)}\n`); + } catch (error: any) { + this.failSpinner("Failed to prime validators", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts new file mode 100644 index 00000000..8399f539 --- /dev/null +++ b/src/commands/staking/validators.ts @@ -0,0 +1,621 @@ +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); + + // Honor a live wallet session so "mine" tracks the connected identity, not + // just the keystore default. Listing still works with neither configured. + let myAddress: Address | null = null; + try { + myAddress = await this.resolveActiveIdentity(options); + } catch { + // Listing validators should not require a local account or session. + } + + 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..290f59df 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -1,21 +1,66 @@ -import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import {StakingAction, StakingConfig, BUILT_IN_NETWORKS, type BrowserWalletSession} from "./StakingAction"; +import {resolveNetwork} from "../../lib/actions/BaseAction"; import {CreateAccountAction} from "../account/create"; import {ExportAccountAction} from "../account/export"; import inquirer from "inquirer"; -import type {Address} from "genlayer-js/types"; +import type { + Address, + GenLayerClient, + GenLayerChain, + StakingTransactionResult, + SetIdentityOptions as SdkSetIdentityOptions, +} from "genlayer-js/types"; import {formatEther, parseEther} from "viem"; import {createClient} from "genlayer-js"; import {readFileSync, existsSync} from "fs"; import path from "path"; +import type {VestingClient, VestingValidatorJoinResult} from "../vesting/vestingTypes"; +import type {BrowserSession} from "../../lib/wallet/browserSend"; +import {vestingAvailableToStake} from "../../lib/vesting/availableToStake"; + +const BROWSER_WALLET_CHOICE = "__browser_wallet__"; export interface WizardOptions extends StakingConfig { skipIdentity?: boolean; + /** Run end-to-end with zero prompts; every choice must come from a flag. */ + nonInteractive?: boolean; + /** Alias for --non-interactive (also doubles as "assume yes" to confirmations). */ + yes?: boolean; + /** Funding source: "wallet" (default) or "vesting". */ + fundingSource?: string; + /** Vesting contract address to fund from (when fundingSource === "vesting"). */ + vestingContract?: string; + /** External operator address (0x...). */ + operator?: string; + /** Name of a new operator account to create (exported keystore). */ + createOperator?: string; + /** Reuse the owner address as the operator. */ + operatorSame?: boolean; + /** Password for the exported operator keystore (required with --create-operator). */ + operatorPassword?: string; + /** Output filename for the exported operator keystore. */ + operatorKeystoreOut?: string; + /** Self-stake amount (GEN number, e.g. "42" or "42gen"). */ + amount?: string; + // Identity fields (mirror `staking set-identity`). + moniker?: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; } interface WizardState { accountName: string; accountAddress: string; networkAlias: string; + /** Where the self-stake comes from. "wallet" (default) keeps the original flow. */ + stakeSource: "wallet" | "vesting"; + /** Chosen vesting contract address when stakeSource === "vesting". */ + vestingContract?: string; balance: bigint; minStake: bigint; operatorAddress?: string; @@ -23,6 +68,7 @@ interface WizardState { operatorKeystorePath?: string; stakeAmount: string; validatorWalletAddress?: string; // the validator contract address returned from validatorJoin + ownerIsBrowserWallet?: boolean; identity?: { moniker: string; logoUri?: string; @@ -42,15 +88,36 @@ function ensureHexPrefix(address: string): string { } export class ValidatorWizardAction extends StakingAction { + /** Non-interactive mode: no prompts, every choice comes from a flag. */ + private ni = false; + constructor() { super(); } + /** True when the wizard must run with zero prompts (--non-interactive / --yes). */ + private isNonInteractive(options: WizardOptions): boolean { + return Boolean(options.nonInteractive || options.yes); + } + + /** Fail with a clear "you forgot flag X" message for non-interactive mode. */ + private missingFlag(flag: string, why?: string): never { + throw new Error( + `Non-interactive mode requires ${flag}${why ? ` (${why})` : ""}. ` + + `Pass it, or drop --non-interactive/--yes to be prompted.`, + ); + } + async execute(options: WizardOptions): Promise { console.log("\n========================================"); console.log(" GenLayer Validator Setup Wizard"); console.log("========================================\n"); + this.ni = this.isNonInteractive(options); + + // Validate flag combinations up-front (throws on --account/--password + browser). + this.assertBrowserWalletFlags(options, "wizard"); + const state: Partial = {}; try { @@ -60,14 +127,18 @@ export class ValidatorWizardAction extends StakingAction { // Step 2: Network Selection await this.stepNetworkSelection(state, options); - // Step 3: Balance Check + // Step 2b: Funding source (wallet vs vesting contract). Default "wallet" + // keeps the original flow untouched. + await this.stepStakeSource(state, options); + + // Step 3: Balance Check (lazily starts the browser session if owner is browser wallet) await this.stepBalanceCheck(state, options); // Step 4: Operator Setup - await this.stepOperatorSetup(state); + await this.stepOperatorSetup(state, options); // Step 5: Stake Amount - await this.stepStakeAmount(state); + await this.stepStakeAmount(state, options); // Step 6: Join as Validator await this.stepJoinValidator(state, options); @@ -85,13 +156,57 @@ export class ValidatorWizardAction extends StakingAction { return; } this.failSpinner("Wizard failed", error.message || error); + } finally { + const session = this._wizardSession; + this._wizardSession = null; + this.browserSession = null; + // session.close() is a no-op for a remote (daemon) session and a full + // close for an own bridge — so a shared daemon survives the wizard. + if (session) await session.close(); } } + /** + * Lazily start the browser-wallet bridge for the owner. Deferred until after + * network selection (step 2) so the connect prompt carries the right chain. + * Idempotent — reuses the same page session across steps 3/6/7. + */ + private async ensureBrowserSession( + state: Partial, + options: WizardOptions, + ): Promise { + if (this._wizardSession) return this._wizardSession; + + // getBrowserWalletSession sets this.browserSession (base field) internally. + this._wizardSession = await this.getBrowserWalletSession( + {...options, network: state.networkAlias}, + "wizard", + ); + state.accountAddress = this._wizardSession.signerAddress; + if (!state.accountName) state.accountName = "browser wallet"; + return this._wizardSession; + } + + /** Staking-scoped session cache (carries stakingAddress on top of the base session). */ + private _wizardSession: BrowserWalletSession | null = null; + private async stepAccountSetup(state: Partial, options: WizardOptions): Promise { console.log("Step 1: Account Setup"); console.log("---------------------\n"); + // Browser-wallet owner. Auto-selected when the effective wallet mode is + // browser: explicit --wallet browser, walletMode=browser config, OR a live + // wallet session (connect-once) — consistent with every other command. The + // actual bridge start is deferred until after network selection (step 2) so + // the connect prompt carries the right chain; here we only record the choice. + if (this.resolveWalletMode(options.wallet) === "browser") { + state.ownerIsBrowserWallet = true; + state.accountName = "browser wallet"; + console.log("Owner account: browser wallet (MetaMask) — the cold key stays in your wallet."); + console.log("You will connect and sign in your browser after selecting the network.\n"); + return; + } + // Check if account override provided if (options.account) { const keystorePath = this.getKeystorePath(options.account); @@ -106,6 +221,12 @@ export class ValidatorWizardAction extends StakingAction { return; } + // Non-interactive: the owner must be resolvable from flags. Browser and + // --account both returned above, so reaching here means neither was given. + if (this.ni) { + this.missingFlag("--account", "to select the owner keystore, or --wallet browser"); + } + const accounts = this.listAccounts(); if (accounts.length === 0) { @@ -131,6 +252,10 @@ export class ValidatorWizardAction extends StakingAction { } else { // Accounts exist, choose or create const choices = [ + { + name: "Connect browser wallet (MetaMask) — cold key stays in your wallet", + value: BROWSER_WALLET_CHOICE, + }, ...accounts.map(a => ({ name: `${a.name} (${a.address})`, value: a.name, @@ -147,7 +272,12 @@ export class ValidatorWizardAction extends StakingAction { }, ]); - if (selectedAccount === "__create_new__") { + if (selectedAccount === BROWSER_WALLET_CHOICE) { + state.ownerIsBrowserWallet = true; + state.accountName = "browser wallet"; + console.log("\nOwner account: browser wallet (MetaMask)."); + console.log("You will connect and sign in your browser after selecting the network."); + } else if (selectedAccount === "__create_new__") { const {accountName} = await inquirer.prompt([ { type: "input", @@ -187,16 +317,17 @@ 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`); return; } + if (this.ni) { + this.missingFlag("--network"); + } + const currentNetwork = this.getConfigByKey("network"); // Exclude studionet - not compatible with staking const excludedNetworks = ["studionet"]; @@ -207,28 +338,236 @@ export class ValidatorWizardAction extends StakingAction { value: alias, })); + // Also offer any custom networks the user configured (`genlayer network add`). + const customNetworks = this.getCustomNetworks(); + const customChoices = Object.entries(customNetworks).map(([alias, profile]) => ({ + name: `${resolveNetwork(alias, customNetworks).name} (custom, base: ${profile.base})`, + value: alias, + })); + const {selectedNetwork} = await inquirer.prompt([ { type: "list", name: "selectedNetwork", message: "Select network:", - choices: networks, + choices: [...networks, ...customChoices], default: currentNetwork || "testnet-asimov", }, ]); state.networkAlias = selectedNetwork; this.writeConfig("network", selectedNetwork); - console.log(`\nNetwork set to: ${BUILT_IN_NETWORKS[selectedNetwork].name}\n`); + // Resolve through both built-in and custom maps so a custom alias doesn't crash. + console.log(`\nNetwork set to: ${resolveNetwork(selectedNetwork, this.getCustomNetworks()).name}\n`); + } + + /** + * Account-less (read-only) client typed for the vesting lookups the wizard + * needs (getBeneficiaryVestings / getVestingState / getValidatorWallets). The + * genlayer-js client exposes the vesting actions at runtime; the cast bridges + * the CLI-facing type shim (same pattern as VestingAction.getReadOnlyVestingClient). + */ + private getWizardVestingReadClient(state: Partial, options: WizardOptions): VestingClient { + const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); + return createClient({ + chain: network, + account: state.accountAddress as Address, + endpoint: options.rpc, + }) as unknown as VestingClient; + } + + /** + * Vesting client bound to the browser session (Address-only account + the + * session's EIP-1193 provider). The SDK routes writes through the provider, + * so vesting writes run the same client.(...) calls as the keystore + * path; the same client serves the getValidatorWallets read. + */ + private getWizardVestingBrowserClient( + state: Partial, + options: WizardOptions, + session: BrowserSession, + ): VestingClient { + const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); + return createClient({ + chain: network, + account: session.signerAddress, + endpoint: options.rpc, + provider: session.eip1193Provider, + } as Parameters[0]) as unknown as VestingClient; + } + + /** + * Choose where the self-stake is funded from: the owner's wallet (default, + * original flow) or one of the owner's vesting contracts. When vesting is + * chosen we resolve the concrete contract up-front (none → loop back with a + * clear message; one → use it; many → pick), so the balance/join steps have a + * concrete `state.vestingContract` to work with. + */ + private async stepStakeSource(state: Partial, options: WizardOptions): Promise { + console.log("Step: Funding Source"); + console.log("--------------------\n"); + + if (this.ni) { + return this.stepStakeSourceNonInteractive(state, options); + } + + // Loop so a "no vesting contracts" answer can bounce the user straight back + // to the source choice instead of crashing or dead-ending. + for (;;) { + const {stakeSource} = await inquirer.prompt([ + { + type: "list", + name: "stakeSource", + message: "Fund this validator from:", + choices: [ + {name: "Your wallet", value: "wallet"}, + {name: "A vesting contract", value: "vesting"}, + ], + default: "wallet", + }, + ]); + + if (stakeSource === "wallet") { + state.stakeSource = "wallet"; + console.log(""); + return; + } + + // Vesting: the beneficiary is the owner. For a browser owner not yet + // connected, start the shared session now (network is already selected) so + // we can read the connected address — the same session the join reuses. + let beneficiary = state.accountAddress; + if (!beneficiary && state.ownerIsBrowserWallet) { + console.log("Connect your browser wallet to continue..."); + const session = await this.ensureBrowserSession(state, options); + beneficiary = session.signerAddress; + } + + this.startSpinner("Looking up vesting contracts..."); + let vestings: Address[] = []; + try { + const readClient = this.getWizardVestingReadClient(state, options); + vestings = await readClient.getBeneficiaryVestings(beneficiary as Address); + } catch (error: any) { + this.stopSpinner(); + this.logWarning(`Could not look up vesting contracts: ${error.message || error}`); + continue; + } + this.stopSpinner(); + + if (!vestings || vestings.length === 0) { + this.logWarning( + `No vesting contracts found for ${beneficiary}. ` + + `Choose 'Your wallet' or fund a vesting contract first.`, + ); + continue; + } + + if (vestings.length === 1) { + state.vestingContract = ensureHexPrefix(vestings[0]); + } else { + const {selectedVesting} = await inquirer.prompt([ + { + type: "list", + name: "selectedVesting", + message: "Select the vesting contract to fund from:", + choices: vestings.map(v => ({name: v, value: v})), + }, + ]); + state.vestingContract = ensureHexPrefix(selectedVesting); + } + + state.stakeSource = "vesting"; + console.log(`\nFunding from vesting contract: ${state.vestingContract}\n`); + return; + } + } + + /** + * Non-interactive funding source. Defaults to "wallet" (the original flow) when + * --funding-source is omitted. For "vesting" the concrete contract is taken from + * --vesting-contract, or auto-resolved when the owner has exactly one; zero or + * many (without --vesting-contract) is a hard error naming the flag to pass. + */ + private async stepStakeSourceNonInteractive( + state: Partial, + options: WizardOptions, + ): Promise { + const source = options.fundingSource ?? "wallet"; + if (source !== "wallet" && source !== "vesting") { + throw new Error(`Invalid --funding-source '${source}'. Use 'wallet' or 'vesting'.`); + } + + if (source === "wallet") { + state.stakeSource = "wallet"; + console.log("Funding source: your wallet\n"); + return; + } + + // Vesting: the beneficiary is the owner. For a browser owner not yet + // connected, start the shared session now so we can read the address. + let beneficiary = state.accountAddress; + if (!beneficiary && state.ownerIsBrowserWallet) { + const session = await this.ensureBrowserSession(state, options); + beneficiary = session.signerAddress; + } + + if (options.vestingContract) { + state.vestingContract = ensureHexPrefix(options.vestingContract); + } else { + this.startSpinner("Looking up vesting contracts..."); + let vestings: Address[] = []; + try { + const readClient = this.getWizardVestingReadClient(state, options); + vestings = await readClient.getBeneficiaryVestings(beneficiary as Address); + } catch (error: any) { + this.stopSpinner(); + throw new Error(`Could not look up vesting contracts: ${error.message || error}`); + } + this.stopSpinner(); + + if (!vestings || vestings.length === 0) { + throw new Error( + `No vesting contracts found for ${beneficiary}. ` + + `Fund a vesting contract first, or use --funding-source wallet.`, + ); + } + if (vestings.length > 1) { + this.missingFlag( + "--vesting-contract", + `${vestings.length} vesting contracts found for ${beneficiary}; pick one`, + ); + } + state.vestingContract = ensureHexPrefix(vestings[0]); + } + + state.stakeSource = "vesting"; + console.log(`Funding from vesting contract: ${state.vestingContract}\n`); } private async stepBalanceCheck(state: Partial, options: WizardOptions): Promise { console.log("Step 3: Balance Check"); console.log("---------------------\n"); + // For a browser-wallet owner, start the bridge now (network is known) and + // obtain the owner address from the wallet connect handshake. The session may + // already be up if a vesting source was chosen in the funding step. + if (state.ownerIsBrowserWallet) { + if (!this._wizardSession) console.log("Connect your browser wallet to continue..."); + const session = await this.ensureBrowserSession(state, options); + state.accountAddress = session.signerAddress; + console.log(`Connected owner: ${session.signerAddress}\n`); + } + + // A vesting-funded validator checks the vesting contract's balance, not the + // wallet's (gas is still paid from the wallet — see the sanity check inside). + if (state.stakeSource === "vesting") { + return this.stepBalanceCheckVesting(state, options); + } + 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, @@ -265,7 +604,7 @@ export class ValidatorWizardAction extends StakingAction { const minFormatted = currentEpoch === 0n ? "0.01 GEN (for gas)" : `${minStakeFormatted} + gas`; this.failSpinner( `Insufficient balance. You need at least ${minFormatted} to become a validator.\n` + - `Fund your account (${state.accountAddress}) and run the wizard again.` + `Fund your account (${state.accountAddress}) and run the wizard again.`, ); } @@ -275,10 +614,102 @@ export class ValidatorWizardAction extends StakingAction { console.log("Balance sufficient!\n"); } - private async stepOperatorSetup(state: Partial): Promise { + /** + * Balance check for a vesting-funded validator. The stake is committed from + * the vesting contract, so we validate the contract's available-to-stake + * against the minimum. "Available" is the contract's LIVE ON-CHAIN BALANCE + * (0 once revoked): Vesting.sol enforces staking against address(this).balance + * — reverting InsufficientContractBalance when the amount exceeds it — so the + * balance is the true cap (shared with `genlayer balances`). It already + * includes still-locked (unvested) tokens, which vesting-backed staking may + * commit (they return to the contract on exit). Gas is still paid from the + * wallet, so we keep a non-blocking low-wallet-balance warning. + */ + private async stepBalanceCheckVesting(state: Partial, options: WizardOptions): Promise { + this.startSpinner("Checking vesting balance and staking requirements..."); + + const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); + const walletClient = createClient({ + chain: network, + account: state.accountAddress as Address, + endpoint: options.rpc, + }); + const vestingClient = this.getWizardVestingReadClient(state, options); + + const [walletBalance, epochInfo, vestingState] = await Promise.all([ + walletClient.getBalance({address: state.accountAddress as Address}), + walletClient.getEpochInfo(), + vestingClient.getVestingState(state.vestingContract as Address), + ]); + + this.stopSpinner(); + + const minStakeRaw = epochInfo.validatorMinStakeRaw; + const minStakeFormatted = epochInfo.validatorMinStake; + const currentEpoch = epochInfo.currentEpoch; + + // A revoked contract can never stake again (Vesting.sol blocks every stake + // path once revoked), so its available-to-stake is 0 regardless of balance. + // Bail out cleanly — like the no-contracts path — rather than presenting a + // 0 cap the user can't act on. + if (vestingState.revoked) { + this.logError( + `This vesting contract has been revoked; it can no longer stake.\n` + + `Re-run the wizard and choose 'Your wallet'.`, + ); + throw new Error("WIZARD_ABORTED"); + } + + // Authoritative cap: the vesting contract's live native balance (not + // total − withdrawn), shared with `genlayer balances`. + const available = await vestingAvailableToStake( + vestingClient, + state.vestingContract as Address, + vestingState.revoked, + ); + + console.log(`Vesting contract: ${state.vestingContract}`); + console.log(`Available to stake: ${this.formatAmount(available)}`); + console.log(`Minimum stake required: ${minStakeFormatted}`); + if (currentEpoch === 0n) { + console.log("(Epoch 0: minimum stake not enforced)"); + console.log(`Note: Validator won't become active until self-stake reaches ${minStakeFormatted}`); + } + + const minRequired = currentEpoch === 0n ? 0n : minStakeRaw; + if (available < minRequired) { + console.log(""); + this.failSpinner( + `Insufficient vesting balance. The vesting contract has ${this.formatAmount(available)} available, ` + + `but at least ${minStakeFormatted} is required to become a validator.\n` + + `Fund the vesting contract or re-run the wizard and choose 'Your wallet'.`, + ); + } + + // Gas for the create tx is paid from the wallet, not the vesting contract. + const MIN_GAS_BUFFER = parseEther("0.01"); + if (walletBalance < MIN_GAS_BUFFER) { + this.logWarning( + `Your wallet balance (${formatEther(walletBalance)} GEN) is low. Gas for the create ` + + `transaction is paid from your wallet (${state.accountAddress}), not the vesting contract.`, + ); + } + + // stepStakeAmount reuses state.balance as the max and state.minStake as the floor. + state.balance = available; + state.minStake = currentEpoch === 0n ? 0n : minStakeRaw; + + console.log("Vesting balance sufficient!\n"); + } + + private async stepOperatorSetup(state: Partial, options: WizardOptions): Promise { console.log("Step 4: Operator Setup"); console.log("----------------------\n"); + if (this.ni) { + return this.stepOperatorSetupNonInteractive(state, options); + } + console.log("Using a separate operator address is recommended for security:"); console.log("- Owner account: holds staked funds (keep secure)"); console.log("- Operator account: signs blocks (hot wallet on validator server)\n"); @@ -536,7 +967,84 @@ export class ValidatorWizardAction extends StakingAction { console.log("========================================\n"); } - private async stepStakeAmount(state: Partial): Promise { + /** + * Non-interactive operator setup. Exactly one of the operator flags must be + * given: --operator-same (reuse owner), --operator (external), or + * --create-operator (mint + export a new keystore, needs + * --operator-password). Anything else is a hard error naming the choices. + */ + private async stepOperatorSetupNonInteractive( + state: Partial, + options: WizardOptions, + ): Promise { + if (options.operatorSame) { + state.operatorAddress = ensureHexPrefix(state.accountAddress!); + state.operatorAccountName = state.accountName; + console.log("Operator will be the same as owner address.\n"); + return; + } + + if (options.operator) { + if (!options.operator.match(/^0x[a-fA-F0-9]{40}$/)) { + throw new Error( + `Invalid --operator '${options.operator}'. Expected 0x followed by 40 hex characters.`, + ); + } + state.operatorAddress = ensureHexPrefix(options.operator); + console.log(`Operator: ${state.operatorAddress}\n`); + return; + } + + if (options.createOperator) { + const operatorName = options.createOperator; + if (this.listAccounts().find(a => a.name === operatorName)) { + throw new Error(`Account '${operatorName}' already exists. Choose another --create-operator name.`); + } + if (!options.operatorPassword) { + this.missingFlag("--operator-password", "to encrypt the exported operator keystore"); + } + if (options.operatorPassword.length < 8) { + throw new Error("--operator-password must be at least 8 characters."); + } + + const createAction = new CreateAccountAction(); + await createAction.execute({name: operatorName, overwrite: false, setActive: false}); + + const operatorKeystorePath = this.getKeystorePath(operatorName); + const operatorData = JSON.parse(readFileSync(operatorKeystorePath, "utf-8")); + state.operatorAddress = ensureHexPrefix(operatorData.address); + state.operatorAccountName = operatorName; + + const outputFilename = options.operatorKeystoreOut || `${operatorName}-keystore.json`; + const outputPath = path.resolve(`./${outputFilename}`); + + const exportAction = new ExportAccountAction(); + await exportAction.execute({ + account: operatorName, + output: outputPath, + password: options.operatorPassword, + overwrite: true, + }); + + state.operatorKeystorePath = outputPath; + + console.log("\n========================================"); + console.log(" IMPORTANT: Transfer operator keystore"); + console.log("========================================"); + console.log(`File: ${outputPath}`); + console.log("Transfer this file to your validator server and import it"); + console.log("into your validator node software."); + console.log("========================================\n"); + return; + } + + this.missingFlag( + "an operator choice", + "one of --operator-same, --operator , or --create-operator ", + ); + } + + private async stepStakeAmount(state: Partial, options: WizardOptions): Promise { console.log("Step 5: Stake Amount"); console.log("--------------------\n"); @@ -544,6 +1052,27 @@ export class ValidatorWizardAction extends StakingAction { const minStakeGEN = formatEther(state.minStake!); const hasMinStake = state.minStake! > 0n; + if (this.ni) { + if (!options.amount) { + this.missingFlag("--amount"); + } + const cleaned = options.amount.toLowerCase().replace("gen", "").trim(); + const num = parseFloat(cleaned); + if (isNaN(num) || num <= 0) { + throw new Error(`Invalid --amount '${options.amount}'. Enter a positive GEN amount.`); + } + const amountWei = BigInt(Math.floor(num * 1e18)); + if (hasMinStake && amountWei < state.minStake!) { + throw new Error(`--amount is below the minimum stake of ${minStakeGEN} GEN.`); + } + if (amountWei > state.balance!) { + throw new Error(`--amount exceeds the available balance (${balanceGEN} GEN).`); + } + state.stakeAmount = options.amount.toLowerCase().endsWith("gen") ? options.amount : `${options.amount}gen`; + console.log(`Staking ${state.stakeAmount}\n`); + return; + } + const {stakeAmount} = await inquirer.prompt([ { type: "input", @@ -594,6 +1123,19 @@ export class ValidatorWizardAction extends StakingAction { console.log("Step 6: Join as Validator"); console.log("-------------------------\n"); + // Vesting-funded: build+send the vesting validator-create tx (funds come from + // the vesting contract, not the wallet). Operator is still required + passed. + if (state.stakeSource === "vesting") { + if (state.ownerIsBrowserWallet) { + return this.stepJoinValidatorVestingBrowser(state, options); + } + return this.stepJoinValidatorVestingKeystore(state, options); + } + + if (state.ownerIsBrowserWallet) { + return this.stepJoinValidatorBrowser(state, options); + } + this.startSpinner("Creating validator..."); try { @@ -629,10 +1171,196 @@ export class ValidatorWizardAction extends StakingAction { } } + private async stepJoinValidatorBrowser(state: Partial, options: WizardOptions): Promise { + const session = await this.ensureBrowserSession(state, options); + const amount = this.parseAmount(state.stakeAmount!); + const client = this.getBrowserStakingClient({...options, network: state.networkAlias}, session); + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + // Same SDK call as the keystore lane; the SDK decodes the ValidatorJoin + // event and returns validatorWallet for both lanes. + session.setNextLabel(`Join as validator (${this.formatAmount(amount)})`); + const result = await client.validatorJoin({ + amount, + operator: state.operatorAddress as Address, + }); + + state.validatorWalletAddress = ensureHexPrefix(result.validatorWallet); + + this.succeedSpinner("Validator created successfully!", { + transactionHash: result.transactionHash, + validatorWallet: state.validatorWalletAddress, + amount: result.amount, + operator: result.operator, + blockNumber: result.blockNumber.toString(), + }); + + console.log(""); + } catch (error: any) { + // Abort the wizard on a failed/rejected join (nothing downstream can proceed). + this.failSpinner("Failed to create validator", error.message || error); + throw new Error("WIZARD_ABORTED"); + } + } + + /** + * Keystore owner + vesting source: create the validator via the vesting client's + * vestingValidatorJoin (mirrors `vesting validator create`). The genlayer-js + * client the keystore path builds exposes the vesting actions at runtime; the + * cast bridges the CLI-facing type shim. + */ + private async stepJoinValidatorVestingKeystore( + state: Partial, + options: WizardOptions, + ): Promise { + this.startSpinner("Creating vesting-backed validator..."); + + try { + const client = (await this.getStakingClient({ + ...options, + account: state.accountName, + network: state.networkAlias, + })) as unknown as VestingClient; + + const amount = this.parseAmount(state.stakeAmount!); + const vesting = state.vestingContract as Address; + + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} from vesting ${vesting}...`); + + // Pin the CLI-facing result shape: the SDK's GenLayerClient typing omits the + // optional validatorWallet/wallet fields, so read them off the local shim. + const result: VestingValidatorJoinResult = await client.vestingValidatorJoin({ + vesting, + operator: state.operatorAddress 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 just created. + let validatorWallet = result.validatorWallet || result.wallet; + if (!validatorWallet) { + try { + const wallets = await client.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + // Leave undefined; the summary falls back to the owner address. + } + } + if (validatorWallet) state.validatorWalletAddress = ensureHexPrefix(validatorWallet); + + this.succeedSpinner("Vesting-backed validator created successfully!", { + transactionHash: result.transactionHash, + vesting, + validatorWallet: state.validatorWalletAddress, + amount: result.amount || this.formatAmount(amount), + operator: result.operator || state.operatorAddress, + blockNumber: result.blockNumber.toString(), + }); + + console.log(""); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + } + } + + /** + * Browser owner + vesting source: send the vestingValidatorJoin tx through the + * shared browser session (reusing the same tx-builder as `vesting validator + * create --wallet browser`). Funds come from the vesting contract, so there is + * no msg.value on this tx. + */ + private async stepJoinValidatorVestingBrowser( + state: Partial, + options: WizardOptions, + ): Promise { + const session = await this.ensureBrowserSession(state, options); + const amount = this.parseAmount(state.stakeAmount!); + const vesting = state.vestingContract as Address; + const client = this.getWizardVestingBrowserClient(state, options, session); + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + session.setNextLabel(`Create vesting validator (${this.formatAmount(amount)})`); + const result = await client.vestingValidatorJoin({ + vesting, + operator: state.operatorAddress as Address, + amount, + }); + + // The join result does not carry the wallet address; the vesting contract + // tracks its wallets, so the newest entry is the one just created. + let validatorWallet: Address | undefined; + try { + const wallets = await client.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + // Leave undefined; the summary falls back to the owner address. + } + if (validatorWallet) state.validatorWalletAddress = ensureHexPrefix(validatorWallet); + + this.succeedSpinner("Vesting-backed validator created successfully!", { + transactionHash: result.transactionHash, + vesting, + validatorWallet: state.validatorWalletAddress, + amount: this.formatAmount(amount), + operator: state.operatorAddress, + blockNumber: result.blockNumber.toString(), + }); + + console.log(""); + } catch (error: any) { + // Abort the wizard on a failed/rejected join (nothing downstream can proceed). + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + throw new Error("WIZARD_ABORTED"); + } + } + + /** + * How the user can set identity later — differs by funding source. A + * vesting-backed validator's identity is set through the vesting contract + * (`vesting validator set-identity`), a wallet-backed one through staking. + */ + private identitySetLaterHint(state: Partial): string { + if (state.stakeSource === "vesting") { + const walletHint = state.validatorWalletAddress || ""; + return `genlayer vesting validator set-identity ${walletHint} --vesting ${state.vestingContract}`; + } + return "genlayer staking set-identity"; + } + private async stepIdentitySetup(state: Partial, options: WizardOptions): Promise { console.log("Step 7: Identity Setup"); console.log("----------------------\n"); + // Both funding sources run the same guided identity step: identical prompts + // and fields. Only the commit target differs — a vesting-backed validator's + // identity is set through the vesting contract (see commitIdentity). + + if (this.ni) { + // Identity is optional non-interactively: driven by --moniker. No moniker + // means "skip identity" (same as the interactive "no" answer). + if (!options.moniker) { + console.log("\nNo --moniker given; skipping identity setup."); + console.log(`You can set it later with: ${this.identitySetLaterHint(state)}\n`); + return; + } + state.identity = { + moniker: options.moniker, + logoUri: options.logoUri || undefined, + website: options.website || undefined, + description: options.description || undefined, + email: options.email || undefined, + twitter: options.twitter || undefined, + telegram: options.telegram || undefined, + github: options.github || undefined, + }; + await this.commitIdentity(state, options); + return; + } + const {setupIdentity} = await inquirer.prompt([ { type: "confirm", @@ -643,7 +1371,7 @@ export class ValidatorWizardAction extends StakingAction { ]); if (!setupIdentity) { - console.log("\nYou can set up identity later with: genlayer staking set-identity\n"); + console.log(`\nYou can set up identity later with: ${this.identitySetLaterHint(state)}\n`); return; } @@ -724,39 +1452,152 @@ export class ValidatorWizardAction extends StakingAction { github: github || undefined, }; + await this.commitIdentity(state, options); + } + + /** + * Send the set-identity transaction from `state.identity`. Routing: + * - vesting funding → the vesting contract's vestingValidatorSetIdentity + * (keystore via the SDK client, browser via the shared bridge); + * - wallet funding → staking's setIdentity, through the SDK client for both + * the keystore and the browser (provider) lanes. + * Shared by the interactive and non-interactive identity steps so both behave + * identically. A revert (e.g. consensus gaps) is caught: the wizard warns and + * continues to the summary rather than crashing or losing the created validator. + */ + private async commitIdentity(state: Partial, options: WizardOptions): Promise { + const identity = state.identity!; + + // Use the validator wallet address (contract), not the owner address. + const validatorAddress = ensureHexPrefix(state.validatorWalletAddress || state.accountAddress!); + + // Vesting identity must target the just-created vesting validator wallet. If + // we never learned that address (owner ≠ wallet), don't send a doomed tx — + // point the user at the standalone command instead. + if (state.stakeSource === "vesting" && !state.validatorWalletAddress) { + this.logWarning("Could not determine the vesting validator wallet address; skipping identity."); + console.log(`You can set it later with: ${this.identitySetLaterHint(state)}\n`); + return; + } + this.startSpinner("Setting validator identity..."); try { - const client = await this.getStakingClient({ - ...options, - account: state.accountName, - network: state.networkAlias, - }); - - // Use the validator wallet address (contract), not owner address - const validatorAddress = state.validatorWalletAddress || state.accountAddress; - - await client.setIdentity({ - validator: ensureHexPrefix(validatorAddress) as Address, - moniker, - logoUri: logoUri || undefined, - website: website || undefined, - description: description || undefined, - email: email || undefined, - twitter: twitter || undefined, - telegram: telegram || undefined, - github: github || undefined, - }); + if (state.stakeSource === "vesting") { + await this.commitVestingIdentity(state, options, validatorAddress); + } else if (state.ownerIsBrowserWallet) { + const session = await this.ensureBrowserSession(state, options); + this.setSpinnerText("Confirm the identity transaction in your browser wallet..."); + // `setIdentity` exists at runtime but is missing from the installed + // genlayer-js StakingActions .d.ts — cast bridges that type gap. The + // SDK owns the extraCid encoding. + const client = this.getBrowserStakingClient( + {...options, network: state.networkAlias}, + session, + ) as GenLayerClient & { + setIdentity(o: SdkSetIdentityOptions): Promise; + }; + session.setNextLabel(`Set validator identity (${identity.moniker})`); + await client.setIdentity({ + validator: validatorAddress as Address, + moniker: identity.moniker, + logoUri: identity.logoUri, + website: identity.website, + description: identity.description, + email: identity.email, + twitter: identity.twitter, + telegram: identity.telegram, + github: identity.github, + }); + } else { + const client = await this.getStakingClient({ + ...options, + account: state.accountName, + network: state.networkAlias, + }); + + await client.setIdentity({ + validator: validatorAddress as Address, + moniker: identity.moniker, + logoUri: identity.logoUri, + website: identity.website, + description: identity.description, + email: identity.email, + twitter: identity.twitter, + telegram: identity.telegram, + github: identity.github, + }); + } this.succeedSpinner("Validator identity set!"); console.log(""); } catch (error: any) { this.stopSpinner(); this.logWarning(`Failed to set identity: ${error.message || error}`); - console.log("You can try again later with: genlayer staking set-identity\n"); + console.log(`You can try again later with: ${this.identitySetLaterHint(state)}\n`); } } + /** + * Set the identity of a vesting-backed validator wallet through the vesting + * contract. Keystore owner → the SDK's vestingValidatorSetIdentity (same method + * `vesting validator set-identity` calls); browser owner → the same calldata + * builder used by that command, sent through the shared wizard session. The + * wizard has no extraCid field, so it is sent empty ("0x"). + */ + private async commitVestingIdentity( + state: Partial, + options: WizardOptions, + validatorAddress: string, + ): Promise { + const identity = state.identity!; + const vesting = state.vestingContract as Address; + + if (state.ownerIsBrowserWallet) { + const session = await this.ensureBrowserSession(state, options); + this.setSpinnerText("Confirm the identity transaction in your browser wallet..."); + const client = this.getWizardVestingBrowserClient(state, options, session); + session.setNextLabel(`Set validator identity (${identity.moniker})`); + await client.vestingValidatorSetIdentity({ + vesting, + wallet: validatorAddress as Address, + moniker: identity.moniker, + logoUri: identity.logoUri || "", + website: identity.website || "", + description: identity.description || "", + email: identity.email || "", + twitter: identity.twitter || "", + telegram: identity.telegram || "", + github: identity.github || "", + extraCid: "0x", + }); + return; + } + + // The genlayer-js client the keystore path builds exposes the vesting actions + // at runtime; the cast bridges the CLI-facing type shim (same pattern as the + // vesting join step). + const client = (await this.getStakingClient({ + ...options, + account: state.accountName, + network: state.networkAlias, + })) as unknown as VestingClient; + + await client.vestingValidatorSetIdentity({ + vesting, + wallet: validatorAddress as Address, + moniker: identity.moniker, + logoUri: identity.logoUri || "", + website: identity.website || "", + description: identity.description || "", + email: identity.email || "", + twitter: identity.twitter || "", + telegram: identity.telegram || "", + github: identity.github || "", + extraCid: "0x", + }); + } + private showSummary(state: WizardState): void { console.log("\n========================================"); console.log(" Validator Setup Complete!"); @@ -772,6 +1613,13 @@ export class ValidatorWizardAction extends StakingAction { console.log(` Validator Wallet: ${validatorWallet}`); console.log(` Owner: ${ownerAddress} (${state.accountName})`); + if (state.stakeSource === "vesting") { + console.log(` Funding Source: Vesting contract ${ensureHexPrefix(state.vestingContract || "")}`); + console.log(` (staked funds return to the vesting contract on exit/claim)`); + } else { + console.log(` Funding Source: Your wallet (${ownerAddress})`); + } + // Operator - show account name if it's a CLI account if (state.operatorAccountName) { console.log(` Operator: ${operatorAddress} (${state.operatorAccountName})`); @@ -780,7 +1628,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:`); @@ -803,7 +1651,9 @@ export class ValidatorWizardAction extends StakingAction { } console.log(` ${step++}. Monitor your validator:`); console.log(` genlayer staking validator-info --validator ${validatorWallet}`); - console.log(` ${step++}. Lock your account when done: genlayer account lock`); + if (!state.ownerIsBrowserWallet) { + console.log(` ${step++}. Lock your account when done: genlayer account lock`); + } console.log("\n========================================\n"); } } diff --git a/src/commands/transactions/appeal.ts b/src/commands/transactions/appeal.ts index 0c4d9082..f5f80866 100644 --- a/src/commands/transactions/appeal.ts +++ b/src/commands/transactions/appeal.ts @@ -5,6 +5,7 @@ import {BaseAction} from "../../lib/actions/BaseAction"; export interface AppealOptions { rpc?: string; bond?: string; + wallet?: "keystore" | "browser"; } export interface AppealBondOptions { @@ -20,12 +21,16 @@ export class AppealAction extends BaseAction { txId, rpc, bond, + wallet, }: { txId: TransactionHash; rpc?: string; bond?: string; + wallet?: "keystore" | "browser"; }): Promise { + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); + this.browserSession?.setNextLabel(`Appeal ${txId}`); try { let value: bigint | undefined; @@ -60,6 +65,8 @@ export class AppealAction extends BaseAction { this.succeedSpinner("Appeal successfully executed", result); } catch (error) { this.failSpinner("Error during appeal operation", error); + } finally { + await this.closeBrowserSession(); } } diff --git a/src/commands/transactions/finalize.ts b/src/commands/transactions/finalize.ts index cd780f26..af271387 100644 --- a/src/commands/transactions/finalize.ts +++ b/src/commands/transactions/finalize.ts @@ -3,6 +3,7 @@ import {BaseAction} from "../../lib/actions/BaseAction"; export interface FinalizeOptions { rpc?: string; + wallet?: "keystore" | "browser"; } export class FinalizeAction extends BaseAction { @@ -10,8 +11,18 @@ export class FinalizeAction extends BaseAction { super(); } - async finalize({txId, rpc}: {txId: TransactionHash; rpc?: string}): Promise { + async finalize({ + txId, + rpc, + wallet, + }: { + txId: TransactionHash; + rpc?: string; + wallet?: "keystore" | "browser"; + }): Promise { + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); + this.browserSession?.setNextLabel(`Finalize ${txId}`); this.startSpinner(`Finalizing transaction ${txId}...`); try { @@ -19,16 +30,28 @@ export class FinalizeAction extends BaseAction { this.succeedSpinner("Transaction finalized", {txId, evmTransactionHash: evmHash}); } catch (error) { this.failSpinner("Error finalizing transaction", error); + } finally { + await this.closeBrowserSession(); } } - async finalizeBatch({txIds, rpc}: {txIds: TransactionHash[]; rpc?: string}): Promise { + async finalizeBatch({ + txIds, + rpc, + wallet, + }: { + txIds: TransactionHash[]; + rpc?: string; + wallet?: "keystore" | "browser"; + }): Promise { if (txIds.length === 0) { this.failSpinner("At least one txId is required."); return; } + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); + this.browserSession?.setNextLabel(`Finalize ${txIds.length} idle transaction(s)`); this.startSpinner(`Finalizing ${txIds.length} idle transaction(s)...`); try { @@ -40,6 +63,8 @@ export class FinalizeAction extends BaseAction { }); } catch (error) { this.failSpinner("Error finalizing idle transactions", error); + } finally { + await this.closeBrowserSession(); } } } diff --git a/src/commands/transactions/index.ts b/src/commands/transactions/index.ts index 58269fa4..ef08d3de 100644 --- a/src/commands/transactions/index.ts +++ b/src/commands/transactions/index.ts @@ -4,6 +4,7 @@ import {ReceiptAction, ReceiptOptions} from "./receipt"; import {AppealAction, AppealOptions, AppealBondOptions} from "./appeal"; import {TraceAction, TraceOptions} from "./trace"; import {FinalizeAction, FinalizeOptions} from "./finalize"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; function parseIntOption(value: string, fallback: number): number { const parsed = parseInt(value, 10); @@ -28,15 +29,16 @@ export function initializeTransactionsCommands(program: Command) { await receiptAction.receipt({txId, ...options}); }) - program - .command("appeal ") - .description("Appeal a transaction by its hash") - .option("--bond ", "Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted") - .option("--rpc ", "RPC URL for the network") - .action(async (txId: TransactionHash, options: AppealOptions) => { - const appealAction = new AppealAction(); - await appealAction.appeal({txId, ...options}); - }); + addWalletModeOption( + program + .command("appeal ") + .description("Appeal a transaction by its hash") + .option("--bond ", "Appeal bond amount (e.g. 500gen, 0.5gen). Auto-calculated if omitted") + .option("--rpc ", "RPC URL for the network"), + ).action(async (txId: TransactionHash, options: AppealOptions) => { + const appealAction = new AppealAction(); + await appealAction.appeal({txId, ...options}); + }); program .command("appeal-bond ") @@ -57,23 +59,25 @@ export function initializeTransactionsCommands(program: Command) { await traceAction.trace({txId, ...options}); }); - program - .command("finalize ") - .description("Finalize a transaction that is ready to be finalized (public call)") - .option("--rpc ", "RPC URL for the network") - .action(async (txId: TransactionHash, options: FinalizeOptions) => { - const finalizeAction = new FinalizeAction(); - await finalizeAction.finalize({txId, ...options}); - }); + addWalletModeOption( + program + .command("finalize ") + .description("Finalize a transaction that is ready to be finalized (public call)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (txId: TransactionHash, options: FinalizeOptions) => { + const finalizeAction = new FinalizeAction(); + await finalizeAction.finalize({txId, ...options}); + }); - program - .command("finalize-batch ") - .description("Finalize a batch of idle transactions in a single call (public call)") - .option("--rpc ", "RPC URL for the network") - .action(async (txIds: TransactionHash[], options: FinalizeOptions) => { - const finalizeAction = new FinalizeAction(); - await finalizeAction.finalizeBatch({txIds, ...options}); - }); + addWalletModeOption( + program + .command("finalize-batch ") + .description("Finalize a batch of idle transactions in a single call (public call)") + .option("--rpc ", "RPC URL for the network"), + ).action(async (txIds: TransactionHash[], options: FinalizeOptions) => { + const finalizeAction = new FinalizeAction(); + await finalizeAction.finalizeBatch({txIds, ...options}); + }); return program; } diff --git a/src/commands/transactions/trace.ts b/src/commands/transactions/trace.ts index 13db39e9..6125bafa 100644 --- a/src/commands/transactions/trace.ts +++ b/src/commands/transactions/trace.ts @@ -23,12 +23,7 @@ export class TraceAction extends BaseAction { this.startSpinner(`Fetching execution trace for ${txId} (round: ${round})...`); try { - const result = await client.request({ - method: "gen_dbg_traceTransaction" as any, - params: [{txID: txId, round}], - }); - - const trace = (result as any); + const trace = await client.debugTraceTransaction({hash: txId, round}); if (!trace) { this.failSpinner("No trace found", `No execution trace found for transaction ${txId}`); return; diff --git a/src/commands/vesting/VestingAction.ts b/src/commands/vesting/VestingAction.ts new file mode 100644 index 00000000..08800330 --- /dev/null +++ b/src/commands/vesting/VestingAction.ts @@ -0,0 +1,193 @@ +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"; +import type {BrowserSession} from "../../lib/wallet/browserSend"; + +export {BUILT_IN_NETWORKS}; + +export interface VestingConfig { + rpc?: string; + network?: string; + account?: string; + password?: string; + vesting?: string; + factory?: string; + addressManager?: string; + /** Signing mode (from --wallet). "browser" routes through the MetaMask bridge. */ + wallet?: "keystore" | "browser"; + /** Deprecated alias for the validator-wallet positional arg (from --validator-wallet). */ + validatorWallet?: 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; + } + + /** + * Open (or reuse) a vesting browser-wallet session. Delegates to the shared + * BaseAction seam; validates flags (--password conflict; --account conflicts). + * The beneficiary is the connected wallet address (see resolveBeneficiaryVesting). + */ + protected async getVestingBrowserSession(options: VestingConfig) { + this.assertWalletFlags(options, {accountFlagExists: true, context: "vesting"}); + return this.getBrowserSession({network: options.network, rpc: options.rpc}); + } + + /** + * Build a vesting client bound to a browser-wallet session: an Address-only + * account plus the session's EIP-1193 provider. The SDK's `executeWrite` + * routes `eth_sendTransaction` through the provider for an Address account, + * so browser writes run the exact same `client.(...)` calls as the + * keystore lane — and the same client serves the read helpers + * (resolveBeneficiaryVesting / getStakeInfo / getValidatorWallets). Callers + * should `session.setNextLabel(...)` before each write. + */ + protected getBrowserVestingClient(options: VestingConfig, session: BrowserSession): VestingClient { + const network = this.getNetwork(options); + return createClient({ + chain: network, + endpoint: options.rpc, + account: session.signerAddress, + provider: session.eip1193Provider, + } as Parameters[0]) as VestingClient; + } + + protected async resolveBeneficiaryVesting(client: VestingClient, options: VestingConfig): Promise
{ + if (options.vesting) { + return options.vesting as Address; + } + + // In browser mode the beneficiary is the connected wallet address; the + // lookup itself runs on the account-less read-only client. + const beneficiary = this.browserSession + ? this.browserSession.signerAddress + : 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..6fdc89f4 --- /dev/null +++ b/src/commands/vesting/claim.ts @@ -0,0 +1,79 @@ +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 { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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); + } + } + + private async executeWithBrowserWallet(options: VestingClaimOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to claim vesting withdrawal", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + session.setNextLabel("Claim vesting delegation withdrawal"); + const result = await client.vestingDelegatorClaim({ + vesting, + validator: options.validator as Address, + }); + + this.succeedSpinner("Vesting claim successful!", { + transactionHash: result.transactionHash, + vesting, + validator: options.validator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to claim vesting withdrawal", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/delegate.ts b/src/commands/vesting/delegate.ts new file mode 100644 index 00000000..fdcfb122 --- /dev/null +++ b/src/commands/vesting/delegate.ts @@ -0,0 +1,88 @@ +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 { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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); + } + } + + private async executeWithBrowserWallet(options: VestingDelegateOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to delegate vesting tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + const amount = this.parseAmount(options.amount); + + session.setNextLabel(`Delegate ${this.formatAmount(amount)} to validator`); + const result = await client.vestingDelegatorJoin({ + vesting, + validator: options.validator as Address, + amount, + }); + + this.succeedSpinner("Vesting delegation successful!", { + transactionHash: result.transactionHash, + vesting: result.vesting, + validator: result.validator, + beneficiary: result.beneficiary, + amount: result.amount, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to delegate vesting tokens", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/index.ts b/src/commands/vesting/index.ts new file mode 100644 index 00000000..73b67a31 --- /dev/null +++ b/src/commands/vesting/index.ts @@ -0,0 +1,288 @@ +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"; +import {addWalletModeOption} from "../../lib/wallet/walletOption"; + +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 addWalletModeOption( + 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 addValidatorWalletOption(command: Command): Command { + return command.option( + "--validator-wallet
", + "Validator wallet address (deprecated, use positional arg)", + ); +} + +function requireWallet(walletArg: string | undefined, options: {validatorWallet?: string}): string { + const wallet = walletArg || options.validatorWallet; + 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)") + .option("--force", "Proceed even if self-stake is below the minimum required to become active"), + ).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( + addValidatorWalletOption( + 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)") + .option("--force", "Proceed even if self-stake is below the minimum required to become active"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorDepositOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorDepositAction(); + await action.execute({...options, walletAddress: wallet}); + }); + + addWriteOptions( + addValidatorWalletOption( + 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, walletAddress: wallet}); + }); + + addWriteOptions( + addValidatorWalletOption( + 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, walletAddress: wallet}); + }); + + const operatorTransfer = validator.command("operator-transfer").description("Manage vesting validator operator transfers"); + + addWriteOptions( + addValidatorWalletOption( + 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, walletAddress: wallet, newOperator}); + }); + + addWriteOptions( + addValidatorWalletOption( + 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, walletAddress: wallet}); + }); + + addWriteOptions( + addValidatorWalletOption( + 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, walletAddress: wallet}); + }); + + addWriteOptions( + addValidatorWalletOption( + 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, walletAddress: 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..516f672b --- /dev/null +++ b/src/commands/vesting/list.ts @@ -0,0 +1,156 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +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 = await this.resolveActiveIdentity(options, options.beneficiary); + + 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..89549539 --- /dev/null +++ b/src/commands/vesting/undelegate.ts @@ -0,0 +1,104 @@ +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 { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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); + } + } + + private async executeWithBrowserWallet(options: VestingUndelegateOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to undelegate vesting tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + 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; + } + + session.setNextLabel(`Undelegate ${shares.toString()} shares from validator`); + const result = await client.vestingDelegatorExit({ + vesting, + validator: options.validator as Address, + shares, + }); + + this.succeedSpinner("Vesting undelegation initiated!", { + 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", + }); + } catch (error: any) { + this.failSpinner("Failed to undelegate vesting tokens", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/validatorClaim.ts b/src/commands/vesting/validatorClaim.ts new file mode 100644 index 00000000..67050139 --- /dev/null +++ b/src/commands/vesting/validatorClaim.ts @@ -0,0 +1,79 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorClaimOptions extends VestingConfig { + walletAddress: string; +} + +export class VestingValidatorClaimAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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.walletAddress}...`); + + const result = await client.vestingValidatorClaim({ + vesting, + wallet: options.walletAddress as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorClaimOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to claim vesting validator withdrawal", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + session.setNextLabel("Claim vesting validator withdrawal"); + const result = await client.vestingValidatorClaim({ + vesting, + wallet: options.walletAddress as Address, + }); + + this.succeedSpinner("Vesting validator claim successful!", { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to claim vesting validator withdrawal", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts new file mode 100644 index 00000000..a614914e --- /dev/null +++ b/src/commands/vesting/validatorCreate.ts @@ -0,0 +1,145 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import type {VestingClient} from "./vestingTypes"; + +export interface VestingValidatorCreateOptions extends VestingConfig { + operator: string; + amount: string; + force?: boolean; +} + +export class VestingValidatorCreateAction extends VestingAction { + constructor() { + super(); + } + + /** + * A vesting-backed create always spins up a NEW wallet owned by the vesting + * contract, so the self-stake source is fixed and the resulting self-stake is + * exactly the create amount. Warn/block if that is below the on-chain + * minimum, and surface the source note. + */ + private async preflight(client: VestingClient, amount: bigint, force?: boolean): Promise { + this.logInfo( + "Creating a vesting-funded validator. Self-stake source is fixed — you won't be able to add " + + "liquid self-stake later.", + ); + // Advisory min check — skip if the chain can't report the minimum. + let epochInfo; + try { + epochInfo = await client.getEpochInfo(); + } catch { + return; + } + this.assertOrWarnSelfStakeMinimum({ + currentEpoch: epochInfo.currentEpoch, + minStakeRaw: epochInfo.validatorMinStakeRaw, + minStakeFormatted: epochInfo.validatorMinStake, + resultingSelfStakeRaw: amount, + resultingSelfStakeFormatted: this.formatAmount(amount), + force, + }); + } + + async execute(options: VestingValidatorCreateOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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); + + await this.preflight(client, amount, options.force); + + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorCreateOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + const amount = this.parseAmount(options.amount); + + await this.preflight(client, amount, options.force); + + session.setNextLabel("Create vesting validator"); + const result = await client.vestingValidatorJoin({ + vesting, + operator: options.operator as Address, + amount, + }); + + // The vestingValidatorJoin result does not carry the wallet address; the + // vesting contract tracks its wallets, so the newest entry is the one + // just created. + let validatorWallet: string; + try { + const wallets = await client.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + validatorWallet = "(read getValidatorWallets to inspect)"; + } + + this.succeedSpinner("Vesting-backed validator created!", { + transactionHash: result.transactionHash, + vesting, + validatorWallet, + operator: options.operator, + amount: this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/validatorDeposit.ts b/src/commands/vesting/validatorDeposit.ts new file mode 100644 index 00000000..37e6594a --- /dev/null +++ b/src/commands/vesting/validatorDeposit.ts @@ -0,0 +1,142 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import type {VestingClient} from "./vestingTypes"; + +export interface VestingValidatorDepositOptions extends VestingConfig { + walletAddress: string; + amount: string; + force?: boolean; +} + +export class VestingValidatorDepositAction extends VestingAction { + constructor() { + super(); + } + + /** + * Pre-submit checks for a vesting-funded top-up deposit: + * 1. Mixing hard-guard: the target wallet must have been created by THIS + * vesting contract (`isValidatorWallet`). A liquid, wallet-funded + * validator's wallet is not owned by the vesting contract, so depositing + * vesting tokens would revert on-chain — fail fast with actionable copy. + * No `--force` override. + * 2. Self-stake minimum: resulting self-stake is the current committed + * self-stake plus still-pending self-stake deposits plus the new amount. + * Block unless `--force`, epoch-0 aware. + */ + private async preflight( + client: VestingClient, + vesting: Address, + wallet: Address, + amount: bigint, + force?: boolean, + ): Promise { + const isOwned = await client.isValidatorWallet(vesting, wallet); + if (!isOwned) { + throw new Error( + "This wallet was not created by this vesting contract (it's a liquid, wallet-funded " + + "validator). You can't add vesting tokens. Use `genlayer staking validator-deposit` from " + + "the owner wallet instead.", + ); + } + + // Advisory min check (the mixing guard above is already enforced) — skip + // if the chain can't report validator/epoch state. + let info, epochInfo; + try { + [info, epochInfo] = await Promise.all([ + client.getValidatorInfo(wallet), + client.getEpochInfo(), + ]); + } catch { + return; + } + const pendingSelfStakeRaw = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + const resultingSelfStakeRaw = info.vStakeRaw + pendingSelfStakeRaw + amount; + this.assertOrWarnSelfStakeMinimum({ + currentEpoch: epochInfo.currentEpoch, + minStakeRaw: epochInfo.validatorMinStakeRaw, + minStakeFormatted: epochInfo.validatorMinStake, + resultingSelfStakeRaw, + resultingSelfStakeFormatted: this.formatAmount(resultingSelfStakeRaw), + force, + }); + } + + async execute(options: VestingValidatorDepositOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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); + + await this.preflight(client, vesting, options.walletAddress as Address, amount, options.force); + + this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.walletAddress}...`); + + const result = await client.vestingValidatorDeposit({ + vesting, + wallet: options.walletAddress as Address, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorDepositOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to deposit vesting validator tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + const amount = this.parseAmount(options.amount); + + await this.preflight(client, vesting, options.walletAddress as Address, amount, options.force); + + session.setNextLabel(`Deposit ${this.formatAmount(amount)} to validator wallet`); + const result = await client.vestingValidatorDeposit({ + vesting, + wallet: options.walletAddress as Address, + amount, + }); + + this.succeedSpinner("Vesting validator deposit successful!", { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + amount: this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to deposit vesting validator tokens", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/validatorExit.ts b/src/commands/vesting/validatorExit.ts new file mode 100644 index 00000000..d882d30b --- /dev/null +++ b/src/commands/vesting/validatorExit.ts @@ -0,0 +1,104 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorExitOptions extends VestingConfig { + walletAddress: string; + shares: string; +} + +export class VestingValidatorExitAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorExitOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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.walletAddress}...`); + + const result = await client.vestingValidatorExit({ + vesting, + wallet: options.walletAddress as Address, + shares, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorExitOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to exit vesting validator", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + 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 = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + session.setNextLabel(`Exit ${shares.toString()} validator shares`); + const result = await client.vestingValidatorExit({ + vesting, + wallet: options.walletAddress as Address, + shares, + }); + + this.succeedSpinner("Vesting validator exit initiated!", { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + 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", + }); + } catch (error: any) { + this.failSpinner("Failed to exit vesting validator", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/validatorList.ts b/src/commands/vesting/validatorList.ts new file mode 100644 index 00000000..612b2e70 --- /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 = await this.resolveActiveIdentity(options, options.beneficiary); + 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..c0ca265f --- /dev/null +++ b/src/commands/vesting/validatorOperatorTransfer.ts @@ -0,0 +1,240 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorOperatorTransferOptions extends VestingConfig { + walletAddress: string; + newOperator?: string; +} + +export class VestingValidatorInitiateOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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.walletAddress} to ${options.newOperator}...`); + + const result = await client.vestingValidatorInitiateOperatorTransfer({ + vesting, + wallet: options.walletAddress as Address, + newOperator: options.newOperator as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorOperatorTransferOptions): Promise { + if (!options.newOperator) { + this.failSpinner("New operator address is required."); + return; + } + + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to initiate vesting validator operator transfer", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + session.setNextLabel("Initiate validator operator transfer"); + const result = await client.vestingValidatorInitiateOperatorTransfer({ + vesting, + wallet: options.walletAddress as Address, + newOperator: options.newOperator as Address, + }); + + this.succeedSpinner("Vesting validator operator transfer initiated!", { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + newOperator: options.newOperator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to initiate vesting validator operator transfer", error.message || error); + } finally { + await session.close(); + } + } +} + +export class VestingValidatorCompleteOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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.walletAddress}...`); + + const result = await client.vestingValidatorCompleteOperatorTransfer({ + vesting, + wallet: options.walletAddress as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorOperatorTransferOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to complete vesting validator operator transfer", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + session.setNextLabel("Complete validator operator transfer"); + const result = await client.vestingValidatorCompleteOperatorTransfer({ + vesting, + wallet: options.walletAddress as Address, + }); + + this.succeedSpinner("Vesting validator operator transfer completed!", { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to complete vesting validator operator transfer", error.message || error); + } finally { + await session.close(); + } + } +} + +export class VestingValidatorCancelOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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.walletAddress}...`); + + const result = await client.vestingValidatorCancelOperatorTransfer({ + vesting, + wallet: options.walletAddress as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorOperatorTransferOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to cancel vesting validator operator transfer", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + session.setNextLabel("Cancel validator operator transfer"); + const result = await client.vestingValidatorCancelOperatorTransfer({ + vesting, + wallet: options.walletAddress as Address, + }); + + this.succeedSpinner("Vesting validator operator transfer cancelled!", { + transactionHash: result.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to cancel vesting validator operator transfer", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/validatorSetIdentity.ts b/src/commands/vesting/validatorSetIdentity.ts new file mode 100644 index 00000000..11a75d81 --- /dev/null +++ b/src/commands/vesting/validatorSetIdentity.ts @@ -0,0 +1,131 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import {toHex} from "viem"; + +export interface VestingValidatorSetIdentityOptions extends VestingConfig { + walletAddress: 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 { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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.walletAddress}...`); + + const result = await client.vestingValidatorSetIdentity({ + vesting, + wallet: options.walletAddress 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.walletAddress, + 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); + } + } + + private async executeWithBrowserWallet(options: VestingValidatorSetIdentityOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to set vesting validator identity", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + const extraCid = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x"; + + session.setNextLabel("Set vesting validator identity"); + const result = await client.vestingValidatorSetIdentity({ + vesting, + wallet: options.walletAddress 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.walletAddress, + 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); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/vesting/vestingTypes.ts b/src/commands/vesting/vestingTypes.ts new file mode 100644 index 00000000..667b1915 --- /dev/null +++ b/src/commands/vesting/vestingTypes.ts @@ -0,0 +1,164 @@ +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; + getActiveValidators: () => Promise; + getQuarantinedValidatorsDetailed: () => Promise< + Array<{validator: Address; untilEpoch: bigint; permanentlyBanned: boolean}> + >; + getBannedValidators: () => Promise< + Array<{validator: Address; untilEpoch: bigint; permanentlyBanned: boolean}> + >; + vestingDepositedPerValidator: (vesting: Address, validator: Address) => Promise; +}; diff --git a/src/commands/vesting/withdraw.ts b/src/commands/vesting/withdraw.ts new file mode 100644 index 00000000..527346d2 --- /dev/null +++ b/src/commands/vesting/withdraw.ts @@ -0,0 +1,82 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; + +export interface VestingWithdrawOptions extends VestingConfig { + amount: string; +} + +export class VestingWithdrawAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingWithdrawOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + + 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); + } + } + + private async executeWithBrowserWallet(options: VestingWithdrawOptions): Promise { + let session; + try { + session = await this.getVestingBrowserSession(options); + } catch (error: any) { + this.failSpinner("Failed to withdraw vested tokens", error.message || error); + return; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const client = this.getBrowserVestingClient(options, session); + const vesting = await this.resolveBeneficiaryVesting(client, options); + const amount = this.parseAmount(options.amount); + + session.setNextLabel(`Withdraw ${this.formatAmount(amount)} from vesting`); + const result = await client.vestingWithdraw({ + vesting, + amount, + }); + + this.succeedSpinner("Vesting withdrawal successful!", { + transactionHash: result.transactionHash, + vesting: result.vesting, + beneficiary: result.beneficiary, + amount: result.amount, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to withdraw vested tokens", error.message || error); + } finally { + await session.close(); + } + } +} diff --git a/src/commands/wallet/WalletAction.ts b/src/commands/wallet/WalletAction.ts new file mode 100644 index 00000000..edc5c5a5 --- /dev/null +++ b/src/commands/wallet/WalletAction.ts @@ -0,0 +1,193 @@ +import {BaseAction, resolveNetwork} from "../../lib/actions/BaseAction"; +import type {GenLayerChain} from "genlayer-js/types"; +import {WalletSessionClient} from "../../lib/wallet/sessionClient"; +import { + descriptorPath, + readDescriptor, + removeDescriptor, + isPidAlive, +} from "../../lib/wallet/sessionDescriptor"; +import {spawnWalletDaemon, waitForDaemonReady} from "../../lib/wallet/spawnDaemon"; +import {runWalletSessionDaemon} from "../../lib/wallet/sessionDaemon"; +import {DAEMON_LOG_FILENAME, HEARTBEAT_DEAD_MS, CONNECT_TIMEOUT_MS} from "../../lib/wallet/sessionConstants"; + +export interface WalletConnectOptions { + network?: string; + rpc?: string; +} + +export class WalletAction extends BaseAction { + private resolveChain(network?: string): GenLayerChain { + return network + ? {...resolveNetwork(network, this.getCustomNetworks())} + : resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + } + + private networkAlias(network?: string): string { + return network ?? this.getConfig().network ?? "localnet"; + } + + /** `genlayer wallet connect` — start (or reuse) the persistent session. */ + async connect(options: WalletConnectOptions): Promise { + const chain = this.resolveChain(options.network); + const alias = this.networkAlias(options.network); + const dpath = descriptorPath(this); + + const existing = readDescriptor(dpath); + if (existing && isPidAlive(existing.pid)) { + const client = new WalletSessionClient(existing); + if (await client.ping()) { + const state = await client.state().catch(() => null); + if (state && state.chainId === chain.id) { + const tabDead = + state.lastPagePollAt > 0 && Date.now() - state.lastPagePollAt > HEARTBEAT_DEAD_MS; + if (tabDead) { + // Daemon is alive and pinging, but its browser tab is gone (stale + // page heartbeat). Reporting "Already connected" here would strand + // the user — the next sign fails on the dead tab. Tear the stale + // daemon down and start a fresh session so connect can recover. + this.logInfo("Previous wallet tab was closed; starting a fresh session."); + await client.shutdown(); + await this.waitForDescriptorGone(dpath, 5000); + } else { + if (state.connected && state.address) { + this.logSuccess(`Already connected as ${state.address} on ${existing.network}.`); + } else { + this.logInfo( + `A session is starting on ${existing.network}. Approve the connection in your browser.`, + ); + } + return; + } + } else { + // Different chain → explicit switch: shut the old one down first. + this.logInfo( + `Switching wallet session from ${existing.network} (chain ${state?.chainId}) to ${alias} (chain ${chain.id}).`, + ); + await client.shutdown(); + await this.waitForDescriptorGone(dpath, 5000); + } + } else { + removeDescriptor(dpath); + } + } else if (existing) { + removeDescriptor(dpath); + } + + const logPath = this.getFilePath(DAEMON_LOG_FILENAME); + this.startSpinner("Starting wallet session..."); + spawnWalletDaemon({network: options.network, rpc: options.rpc, logPath}); + const ready = await waitForDaemonReady(dpath, {logPath}); + this.stopSpinner(); + + const client = new WalletSessionClient(ready); + const state = await client.state(); + this.logInfo(`Open this URL in a browser with your wallet to connect:\n ${state.url}`); + this.logInfo( + "(Remote/SSH? Forward the port first: ssh -L :127.0.0.1: ...; " + + "do not use -g, GatewayPorts yes, or bind the local side to a public interface.)", + ); + + this.startSpinner("Waiting for wallet connection..."); + try { + const address = await client.waitForConnection(CONNECT_TIMEOUT_MS); + this.succeedSpinner( + `Connected as ${address} on ${alias}. This session stays active; run 'genlayer wallet disconnect' to end it.`, + ); + } catch (err) { + this.failSpinner((err as Error)?.message || "Wallet did not connect."); + } + } + + /** `genlayer wallet status` — report the current session. */ + async status(): Promise { + const dpath = descriptorPath(this); + const d = readDescriptor(dpath); + if (!d) { + this.logInfo("No active wallet session."); + process.exitCode = 1; + return; + } + const client = new WalletSessionClient(d); + const alive = isPidAlive(d.pid) && (await client.ping()); + if (!alive) { + this.logWarning("Wallet session descriptor is stale (daemon not reachable). Cleaning it up."); + removeDescriptor(dpath); + process.exitCode = 1; + return; + } + const state = await client.state(); + const now = Date.now(); + const ageMin = Math.round((now - state.createdAt) / 60_000); + const idleMin = Math.round((now - d.lastUsed) / 60_000); + const heartbeatFresh = state.lastPagePollAt > 0 ? now - state.lastPagePollAt <= HEARTBEAT_DEAD_MS : true; + + this.log("Wallet session:", { + status: state.connected ? "connected" : "connecting", + address: state.address ?? "(not connected)", + network: d.network, + chainId: state.chainId, + port: d.port, + url: state.url, + ageMinutes: ageMin, + idleMinutes: idleMin, + tabHeartbeat: heartbeatFresh ? "fresh" : "stale (tab may be closed)", + queuedTransactions: state.queuedCount, + }); + process.exitCode = state.connected ? 0 : 1; + } + + /** `genlayer wallet disconnect` — shut the session down. */ + async disconnect(): Promise { + const dpath = descriptorPath(this); + const d = readDescriptor(dpath); + if (!d) { + this.logInfo("No active wallet session."); + return; + } + const client = new WalletSessionClient(d); + await client.shutdown(); + + // Wait briefly for the daemon to exit; otherwise SIGTERM it. + const gone = await this.waitForPidGone(d.pid, 5000); + if (!gone && isPidAlive(d.pid)) { + try { + process.kill(d.pid, "SIGTERM"); + } catch { + // Already gone / not ours. + } + } + removeDescriptor(dpath); + this.logSuccess("Disconnected."); + } + + /** Hidden entry point for the detached daemon process. */ + async daemon(options: WalletConnectOptions): Promise { + await runWalletSessionDaemon({ + network: options.network, + rpc: options.rpc, + configManager: this, + log: (msg: string) => { + // Daemon logs go to the redirected stdout (wallet-daemon.log). + console.log(`[${new Date().toISOString()}] ${msg}`); + }, + }); + // runWalletSessionDaemon installs its own timers/handlers and exits via + // process.exit on its shutdown paths; nothing more to do here. + } + + private async waitForDescriptorGone(dpath: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (readDescriptor(dpath) && Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)); + } + } + + private async waitForPidGone(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (isPidAlive(pid) && Date.now() < deadline) { + await new Promise(r => setTimeout(r, 100)); + } + return !isPidAlive(pid); + } +} diff --git a/src/commands/wallet/index.ts b/src/commands/wallet/index.ts new file mode 100644 index 00000000..8af603d3 --- /dev/null +++ b/src/commands/wallet/index.ts @@ -0,0 +1,36 @@ +import {Command} from "commander"; +import {WalletAction, type WalletConnectOptions} from "./WalletAction"; + +export function initializeWalletCommands(program: Command) { + const wallet = new WalletAction(); + + const walletCommand = program + .command("wallet") + .description("Manage the persistent browser-wallet (MetaMask) signing session"); + + walletCommand + .command("connect") + .description("Start a persistent browser-wallet session (connect once, reuse across commands)") + .option("--network ", "Network alias to connect on (defaults to config network)") + .option("--rpc ", "Override the RPC URL") + .action((options: WalletConnectOptions) => wallet.connect(options)); + + walletCommand + .command("status") + .description("Show the current browser-wallet session (address, network, heartbeat, queue)") + .action(() => wallet.status()); + + walletCommand + .command("disconnect") + .description("End the active browser-wallet session") + .action(() => wallet.disconnect()); + + // Hidden: the detached daemon process entry point. Not intended for humans. + walletCommand + .command("daemon", {hidden: true}) + .option("--network ", "Network alias") + .option("--rpc ", "Override the RPC URL") + .action((options: WalletConnectOptions) => wallet.daemon(options)); + + return program; +} diff --git a/src/index.ts b/src/index.ts index c78a5cd0..81015faa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,9 @@ 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"; +import {initializeWalletCommands} from "../src/commands/wallet"; +import {initializeBalancesCommands} from "../src/commands/balances"; export function initializeCLI() { program.version(version).description(CLI_DESCRIPTION); @@ -25,6 +28,9 @@ export function initializeCLI() { initializeNetworkCommands(program); initializeTransactionsCommands(program); initializeStakingCommands(program); + initializeVestingCommands(program); + initializeWalletCommands(program); + initializeBalancesCommands(program); program.parse(process.argv); } diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index d555a6dd..65072fed 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -7,6 +7,16 @@ 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"; +import {type BrowserSession, type WalletMode} from "../wallet/browserSend"; +import {resolveBrowserWalletSession, type SessionFallback} from "../wallet/sessionResolver"; +import {descriptorPath, readDescriptor, isPidAlive} from "../wallet/sessionDescriptor"; +import {WalletSessionClient} from "../wallet/sessionClient"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -20,7 +30,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 +38,19 @@ 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}`); + } + // A custom network's display name is the alias the user chose + // (`network add `) — not the base chain's name. Otherwise a "clarke" + // network shows up as "Genlayer Bradbury Testnet" everywhere (wizard picker, + // network info, prompts), which is confusing. + return {...applyCustomNetworkProfile(baseNetwork, customNetwork), name: stored}; + } + // Backwards compat: try parsing as JSON (old format) try { const parsed = JSON.parse(stored); @@ -55,6 +78,8 @@ export class BaseAction extends ConfigFileManager { private _genlayerClient: GenLayerClient | null = null; protected keychainManager: KeychainManager; protected accountOverride: string | null = null; + protected walletModeOverride: WalletMode | null = null; + protected browserSession: BrowserSession | null = null; constructor() { super(); @@ -62,6 +87,120 @@ export class BaseAction extends ConfigFileManager { this.keychainManager = new KeychainManager(); } + protected getCustomNetworks(): CustomNetworksConfig { + return normalizeCustomNetworks(this.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); + } + + // --- Browser-wallet (MetaMask) signing seam ------------------------------ + + /** + * Resolve the effective signing mode. Precedence: explicit --wallet flag > + * config `walletMode` > a live wallet session > "keystore". An invalid flag + * throws; an unknown config value warns and falls back to keystore. + * + * The live-session rung is what makes `genlayer wallet connect` alone enough: + * once a session is up, bare commands default to browser signing without a + * separate `config set walletMode browser`. Explicit `--wallet keystore` (or + * `walletMode=keystore` in config) still overrides a live session, so opting + * back out is one flag/config away. + */ + protected resolveWalletMode(flag?: string): WalletMode { + if (flag === "browser" || flag === "keystore") return flag; + if (flag !== undefined) { + throw new Error(`Invalid --wallet value '${flag}'. Use 'keystore' or 'browser'.`); + } + const cfg = this.getConfigByKey("walletMode"); + if (cfg === "browser") return "browser"; + if (cfg === "keystore") return "keystore"; // explicit opt-out wins over a live session + if (cfg !== null && cfg !== undefined) { + this.logWarning(`Ignoring invalid walletMode config value '${cfg}'. Using 'keystore'.`); + return "keystore"; + } + // No flag, no config: a live wallet session implies browser mode. + if (this.hasLiveWalletSession()) return "browser"; + return "keystore"; + } + + /** + * Cheap, synchronous "is a wallet session up?" gate: descriptor present and + * its daemon pid still alive. This mirrors the pid rung of + * resolveBrowserWalletSession — the authoritative /api/ping happens there when + * the command actually runs, so a stale-but-pid-alive descriptor still gets + * cleaned up and falls back correctly. Kept sync because resolveWalletMode + * (and its callers) are sync. Never throws — a bad/locked descriptor file + * just reads as "no session". + */ + protected hasLiveWalletSession(): boolean { + try { + const descriptor = readDescriptor(descriptorPath(this)); + return descriptor !== null && isPidAlive(descriptor.pid); + } catch { + return false; + } + } + + protected isBrowserWallet(config: {wallet?: string}): boolean { + return this.resolveWalletMode(config.wallet) === "browser"; + } + + /** + * Validate flag combinations for browser-wallet mode. Kept here (not in + * commander) so it is reusable and unit-testable. When browser: --password + * always conflicts; --account conflicts where the command has it. The + * invalid-value check now lives in resolveWalletMode. + */ + protected assertWalletFlags( + config: {wallet?: string; password?: string; account?: string}, + opts: {accountFlagExists: boolean; context: string}, + ): void { + if (!this.isBrowserWallet(config)) { + return; + } + if (config.password !== undefined) { + throw new Error("--password cannot be used with --wallet browser"); + } + if (opts.accountFlagExists && config.account !== undefined) { + throw new Error("--account selects a keystore; not applicable with --wallet browser"); + } + } + + /** + * Open (or reuse) a browser-wallet session for the current process. Resolves + * the chain, starts the bridge, prints the URL, and caches the session so + * multi-tx flows share one browser tab. Never touches keystore code paths. + */ + protected async getBrowserSession( + opts: {network?: string; rpc?: string; fallback?: SessionFallback} = {}, + ): Promise { + if (this.browserSession) return this.browserSession; + + const chain = opts.network + ? {...resolveNetwork(opts.network, this.getCustomNetworks())} + : resolveNetwork(this.getConfig().network, this.getCustomNetworks()); + const rpcUrl = opts.rpc || chain.rpcUrls.default.http[0]; + + // Prefer a persistent daemon session (connect-once). No live session → + // auto-start one and leave it up for subsequent commands. + this.browserSession = await resolveBrowserWalletSession({ + chain, + rpcUrl, + networkAlias: opts.network ?? this.getConfig().network, + configManager: this, + fallback: opts.fallback ?? "auto-start", + log: (msg: string) => this.log(msg), + logInfo: (msg: string) => this.logInfo(msg), + logWarning: (msg: string) => this.logWarning(msg), + }); + return this.browserSession; + } + + protected async closeBrowserSession(finalMessage?: string): Promise { + if (!this.browserSession) return; + const session = this.browserSession; + this.browserSession = null; + await session.close(finalMessage); + } + private async decryptKeystore(keystoreJson: string, attempt: number = 1): Promise { try { const message = attempt === 1 @@ -97,7 +236,22 @@ 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()); + + // Lane B (browser wallet): a plain Address account + EIP-1193 provider + // routes eth_sendTransaction through the bridge. Skip getAccount() so no + // keystore/keychain/password prompt is ever triggered. + if (this.walletModeOverride === "browser") { + const session = await this.getBrowserSession({rpc: rpcUrl}); + this._genlayerClient = createClient({ + chain: network, + endpoint: rpcUrl, + account: session.signerAddress, + provider: session.eip1193Provider, + } as Parameters[0]); + return this._genlayerClient; + } + const account = await this.getAccount(readOnly); this._genlayerClient = createClient({ chain: network, @@ -120,6 +274,89 @@ export class BaseAction extends ConfigFileManager { return BaseAction.DEFAULT_ACCOUNT_NAME; } + /** + * The keystore address of the resolved account — a pure file read, never a + * password prompt or keychain touch. Shared by every read command so "who am + * I" is answered identically. Throws if the account has no keystore. + */ + 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")); + return this.getAddress(keystoreData); + } + + /** + * The connected address of a live browser-wallet session, or null. Read-only: + * pings an already-running daemon and reads its live state (as `wallet status` + * does) — never starts a daemon or opens a tab. The descriptor's own `address` + * field is null until connect and not reliably rewritten, so we query state. + */ + protected async liveSessionAddress(): Promise
{ + try { + const descriptor = readDescriptor(descriptorPath(this)); + if (!descriptor) { + return null; + } + const client = new WalletSessionClient(descriptor); + if (!(isPidAlive(descriptor.pid) && (await client.ping()))) { + return null; + } + const state = await client.state().catch(() => null); + return state?.connected && state.address ? (state.address as Address) : null; + } catch { + return null; + } + } + + /** + * Resolve the identity a READ command should inspect without ever unlocking a + * keystore — the single source of truth for connect-once identity across every + * read. Precedence mirrors resolveWalletMode so "who am I" follows the same rule + * as "how do I sign": + * 1. `explicitAddress` — an explicit --beneficiary/--validator/--delegator + * override (pure read, no wallet). + * 2. `--account ` — explicit keystore selection wins over a session. + * 3. a live browser-wallet session's connected address — when a session is up + * (resolveWalletMode → "browser") that IS your active identity. + * 4. the active account's keystore address (file read only, no password). + * 5. last resort: a live session even if the mode wasn't "browser". + * Throws only when nothing at all resolves. + */ + protected async resolveActiveIdentity( + options: {account?: string}, + explicitAddress?: string, + ): Promise
{ + if (explicitAddress) { + return explicitAddress as Address; + } + if (options.account) { + return await this.getSignerAddress(); + } + + if (this.resolveWalletMode() === "browser") { + const sessionAddress = await this.liveSessionAddress(); + if (sessionAddress) { + return sessionAddress; + } + } + + try { + return await this.getSignerAddress(); + } catch (error) { + const sessionAddress = await this.liveSessionAddress(); + if (sessionAddress) { + return sessionAddress; + } + throw new Error( + "No address to inspect. Pass an explicit address, select an account, or connect a wallet.", + ); + } + } + private async getAccount(readOnly: boolean = false): Promise { const accountName = this.resolveAccountName(); const keystorePath = this.getKeystorePath(accountName); @@ -212,22 +449,48 @@ export class BaseAction extends ConfigFileManager { return wallet.privateKey; } - protected async promptPassword(message: string): Promise { - const answer = await inquirer.prompt([ - { - type: "password", - name: "password", - message: chalk.yellow(message), - mask: "*", - validate: (input: string) => { - if (!input) { - return "Password cannot be empty"; - } - return true; + /** + * inquirer throws an `ExitPromptError` ("User force closed the prompt") when a + * prompt can't be satisfied — no TTY and no piped stdin. That message reads + * exactly like the user hit Ctrl-C when in fact a required flag is missing. + * Rewrite it into an actionable error naming the flag(s) that make the command + * non-interactive. Crucially this only fires on an ACTUAL force-close: piped + * stdin (how the e2e harness and automation supply the password) still feeds + * inquirer normally, so we must NOT pre-empt with an `isTTY` guard. + */ + private isExitPromptError(error: unknown): boolean { + if (!error) return false; + const name = (error as {name?: string}).name; + const message = error instanceof Error ? error.message : String(error); + return name === "ExitPromptError" || /force closed the prompt/i.test(message); + } + + protected async promptPassword(message: string, nonInteractiveHint?: string): Promise { + try { + const answer = await inquirer.prompt([ + { + type: "password", + name: "password", + message: chalk.yellow(message), + mask: "*", + validate: (input: string) => { + if (!input) { + return "Password cannot be empty"; + } + return true; + }, }, - }, - ]); - return answer.password; + ]); + return answer.password; + } catch (error) { + if (this.isExitPromptError(error)) { + const guidance = + nonInteractiveHint ?? + "Provide the required value via the corresponding command flag to run non-interactively."; + throw new Error(`No interactive terminal available for a password prompt. ${guidance}`); + } + throw error; + } } protected async confirmPrompt(message: string): Promise { @@ -271,6 +534,73 @@ export class BaseAction extends ConfigFileManager { if (error !== undefined) console.error(chalk.red(this.formatOutput(error))); } + /** + * Self-stake eligibility gate shared by the validator write commands + * (staking validator-join / validator-deposit, vesting validator-create / + * validator-deposit). Both StakingAction and VestingAction extend BaseAction, + * so this is the common home they can both reach. + * + * Eligibility is SELF-stake only — delegated stake never counts. The minimum + * is the on-chain `validatorMinStakeRaw` read from `getEpochInfo()`; never + * hardcode it. Mirrors the wizard's epoch-0 carve-out (staking/wizard.ts): + * at epoch 0 the minimum is not enforced, but the informational note is still + * surfaced. + * + * Behaviour: + * - epoch 0: informational note only, never blocks. + * - resulting self-stake >= min: silent (already eligible). + * - resulting self-stake < min: block by THROWING (the caller's catch turns + * it into a failSpinner) unless `force` is set, in which case it warns and + * proceeds. The thrown/warned message names `--force`. + */ + protected assertOrWarnSelfStakeMinimum(params: { + currentEpoch: bigint; + minStakeRaw: bigint; + minStakeFormatted: string; + resultingSelfStakeRaw: bigint; + resultingSelfStakeFormatted: string; + force?: boolean; + }): void { + const { + currentEpoch, + minStakeRaw, + minStakeFormatted, + resultingSelfStakeRaw, + resultingSelfStakeFormatted, + force, + } = params; + + if (currentEpoch === 0n) { + this.logInfo( + `Epoch 0: minimum self-stake not enforced yet. Note: this validator won't become active ` + + `until its self-stake reaches ${minStakeFormatted}.`, + ); + return; + } + + if (resultingSelfStakeRaw >= minStakeRaw) { + return; + } + + const base = + `Resulting self-stake ${resultingSelfStakeFormatted} is below the ${minStakeFormatted} minimum ` + + `required to become an active validator. Only self-stake counts toward eligibility (delegated ` + + `stake does not).`; + + if (force) { + this.logWarning( + `${base} Proceeding anyway because --force was set; the validator will stay inactive until ` + + `its self-stake reaches the minimum.`, + ); + return; + } + + throw new Error( + `${base} Increase the amount, or pass --force to proceed anyway (the validator will stay ` + + `inactive until its self-stake reaches the minimum).`, + ); + } + protected startSpinner(message: string) { this.spinner.text = chalk.blue(`${message}`); this.spinner.start(); @@ -298,4 +628,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/ConfigFileManager.ts b/src/lib/config/ConfigFileManager.ts index 4393e5a3..48e7564e 100644 --- a/src/lib/config/ConfigFileManager.ts +++ b/src/lib/config/ConfigFileManager.ts @@ -26,14 +26,16 @@ export class ConfigFileManager { private ensureFolderExists(): void { if (!fs.existsSync(this.folderPath)) { - fs.mkdirSync(this.folderPath, { recursive: true }); + fs.mkdirSync(this.folderPath, { recursive: true, mode: 0o700 }); } + fs.chmodSync(this.folderPath, 0o700); } private ensureKeystoresDirExists(): void { if (!fs.existsSync(this.keystoresPath)) { - fs.mkdirSync(this.keystoresPath, { recursive: true }); + fs.mkdirSync(this.keystoresPath, { recursive: true, mode: 0o700 }); } + fs.chmodSync(this.keystoresPath, 0o700); } private ensureConfigFileExists(): void { 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..d8c624b3 --- /dev/null +++ b/src/lib/networks/customNetworks.ts @@ -0,0 +1,270 @@ +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; + /** Block explorer URL for this custom network. Custom profiles do NOT inherit the base chain's explorer (see applyCustomNetworkProfile). */ + explorer?: string; +} + +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 = {}; + // Guarded above (non-null, object, non-array); narrow from `object` to the + // record shape walkDeploymentObject expects. + walkDeploymentObject(selected as Record, [], 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, + }; + } + + // Custom profiles do NOT inherit the base chain's block explorer. The base's + // explorer indexes the BASE deployment, so surfacing it for a custom network + // is misleading — e.g. a pre-clarke profile based on bradbury would show + // explorer-bradbury links that never indexed its transactions. Show an + // explorer only when the operator set one explicitly via --explorer. + if (overrides.explorer) { + (chain as any).blockExplorers = { + default: {name: "Explorer", url: overrides.explorer}, + }; + } else { + delete (chain as any).blockExplorers; + } + + 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/src/lib/vesting/availableToStake.ts b/src/lib/vesting/availableToStake.ts new file mode 100644 index 00000000..698e8123 --- /dev/null +++ b/src/lib/vesting/availableToStake.ts @@ -0,0 +1,29 @@ +import type {Address} from "genlayer-js/types"; + +/** Minimal client surface: all this helper needs is a native-balance read. */ +interface BalanceReader { + getBalance: (args: {address: Address}) => Promise; +} + +/** + * Authoritative "available to stake" for a vesting contract. + * + * Vesting.sol enforces every staking path (vestingDelegatorJoin / + * vestingValidatorJoin / vestingValidatorDeposit) against its LIVE NATIVE + * BALANCE — each reverts `InsufficientContractBalance` when the amount exceeds + * `address(this).balance` — and blocks staking entirely once revoked. So the + * contract's balance IS the cap: it already nets withdrawals, committed + * principal, and realized rewards/losses, and correctly includes still-locked + * (unvested) tokens. It must NOT be derived from vested/total/withdrawn/ + * committed arithmetic. + * + * @returns 0 when the contract is revoked (staking disabled), otherwise its + * on-chain balance. + */ +export async function vestingAvailableToStake( + client: BalanceReader, + vestingAddress: Address, + revoked: boolean, +): Promise { + return revoked ? 0n : client.getBalance({address: vestingAddress}); +} diff --git a/src/lib/wallet/bridgePage.ts b/src/lib/wallet/bridgePage.ts new file mode 100644 index 00000000..52e3207f --- /dev/null +++ b/src/lib/wallet/bridgePage.ts @@ -0,0 +1,398 @@ +/** + * Inline HTML+JS served by BrowserWalletBridge on 127.0.0.1. Kept as a single + * template string (no static-asset build step; esbuild only bundles TS). + * + * Page-side flow: + * 1. Read the session token from location.hash (#s=) and send it on + * every API call via the X-Bridge-Token header. + * 2. Discover wallets via EIP-6963 (announceProvider/requestProvider), let the + * user pick one, and route every provider call through that ACTIVE provider + * (falling back to window.ethereum when no wallet announces). Then + * eth_requestAccounts; POST /api/connected. + * 3. wallet_switchEthereumChain (add chain on 4902), re-verify chainId. + * 4. Long-poll GET /api/next; on {type:"tx"} eth_sendTransaction then POST + * /api/result; on {type:"done"|"abort"} show final panel and stop. + * + * No secrets are embedded; the token lives only in the URL fragment the CLI + * printed/opened. + */ +export const BRIDGE_PAGE_HTML = /* html */ ` + + + + +GenLayer CLI — Wallet Bridge + + + +
+

GenLayer CLI — Wallet Bridge

+

This page connects your browser wallet to the GenLayer CLI running on this machine. It only talks to 127.0.0.1.

+
Initializing…
+
+
+ +
+ + +`; diff --git a/src/lib/wallet/browserBridge.ts b/src/lib/wallet/browserBridge.ts new file mode 100644 index 00000000..b0716f49 --- /dev/null +++ b/src/lib/wallet/browserBridge.ts @@ -0,0 +1,791 @@ +import http from "node:http"; +import {randomUUID, timingSafeEqual} from "node:crypto"; +import type {AddressInfo} from "node:net"; +import {hexToBigInt} from "viem"; +import type {Address, Hash} from "genlayer-js/types"; +import {openUrl as defaultOpenUrl} from "../clients/system"; +import {BRIDGE_PAGE_HTML} from "./bridgePage"; +import {LONG_POLL_MS, HEARTBEAT_DEAD_MS} from "./sessionConstants"; + +export interface BridgeChainParams { + chainId: number; + chainName: string; + rpcUrls: string[]; + nativeCurrency: {name: string; symbol: string; decimals: number}; + blockExplorerUrls?: string[]; +} + +export interface BridgeTxRequest { + id: string; + to: Address; + data: `0x${string}`; + value?: bigint; + gasPrice?: bigint; + gas?: bigint; + nonce?: number; + type?: string; + label: string; +} + +/** + * Wire shape of a tx over HTTP: bigint quantities are hex strings. This is + * exactly what `serializeBridgeTx` emits and what the session client POSTs to + * `/api/enqueue`. The daemon parses it back with `parseBridgeTx`. Keeping the + * pair here means client and server can never drift. + */ +export interface SerializedBridgeTx { + to: Address; + data: `0x${string}`; + value?: string; + gasPrice?: string; + gas?: string; + nonce?: string; + type?: string; + label: string; +} + +/** bigint → 0x-hex serialization for the wire (client → daemon enqueue). */ +export function serializeBridgeTx(tx: Omit): SerializedBridgeTx { + return { + to: tx.to, + data: tx.data, + value: tx.value !== undefined ? `0x${tx.value.toString(16)}` : undefined, + gasPrice: tx.gasPrice !== undefined ? `0x${tx.gasPrice.toString(16)}` : undefined, + gas: tx.gas !== undefined ? `0x${tx.gas.toString(16)}` : undefined, + nonce: tx.nonce !== undefined ? `0x${tx.nonce.toString(16)}` : undefined, + type: tx.type, + label: tx.label, + }; +} + +/** 0x-hex → bigint parsing on the daemon side (enqueue handler). */ +export function parseBridgeTx(s: SerializedBridgeTx): Omit { + return { + to: s.to, + data: s.data, + value: s.value !== undefined ? hexToBigInt(s.value as `0x${string}`) : undefined, + gasPrice: s.gasPrice !== undefined ? hexToBigInt(s.gasPrice as `0x${string}`) : undefined, + gas: s.gas !== undefined ? hexToBigInt(s.gas as `0x${string}`) : undefined, + nonce: s.nonce !== undefined ? Number(hexToBigInt(s.nonce as `0x${string}`)) : undefined, + type: s.type, + label: s.label, + }; +} + +/** Terminal record for a tx the wallet handled (result store, remote polling). */ +export type TxResultRecord = + | {state: "pending"} + | {state: "delivered"} + | {state: "done"; status: "sent"; txHash: Hash; from?: Address; completedAt: number} + | {state: "done"; status: "rejected" | "error"; message: string; from?: Address; completedAt: number}; + +/** Shape returned by GET /api/state (also mirrored in sessionClient.ts). */ +export interface BridgeSessionState { + connected: boolean; + address: Address | null; + chainId: number; + chainIdHex: string; + chainName: string; + url: string; + lastPagePollAt: number; + queuedCount: number; + createdAt: number; +} + +export interface BrowserWalletBridgeOptions { + chain: BridgeChainParams; + openUrl?: (url: string) => Promise; + connectTimeoutMs?: number; + txTimeoutMs?: number; + log?: (msg: string) => void; + /** Register a SIGINT handler that closes the server. Default: true. */ + handleSigint?: boolean; + /** + * Persistent (daemon) mode: enables /api/enqueue, /api/tx, /api/state, + * /api/ping, /api/shutdown, the result store, and heartbeat tracking. The + * page also learns it should stay open between txs. Default false — the + * per-command / wizard path keeps exactly the current one-shot behaviour. + */ + persistent?: boolean; + /** Called with the connected address on every /api/connected (daemon: rewrite descriptor). */ + onConnected?: (address: Address) => void; + /** Called when a tx is enqueued (daemon: bump lastUsed). */ + onActivity?: () => void; + /** Called when /api/shutdown is received (daemon: remove descriptor + exit). */ + onShutdown?: () => void; +} + +const RESULT_GC_MS = 10 * 60_000; +/** Routes whose POSTs are page-originated and must pass the strict Origin check. */ +const PAGE_POST_ROUTES = new Set(["/api/connected", "/api/result"]); + +/** Reason codes surfaced as HTTP 409 on /api/enqueue and mapped to fail-fast messages. */ +export type EnqueueErrorReason = "bridge-closed" | "wallet-not-connected" | "tab-closed"; +export class EnqueueError extends Error { + constructor(readonly reason: EnqueueErrorReason) { + super(reason); + this.name = "EnqueueError"; + } +} + +interface PendingTx { + request: BridgeTxRequest; + resolve: (hash: Hash) => void; + reject: (err: Error) => void; + timer?: NodeJS.Timeout; + delivered: boolean; + expectedSigner?: Address; + from?: Address; +} + +interface NextWaiter { + resolve: (payload: unknown) => void; + timer: NodeJS.Timeout; +} + +const DEFAULT_CONNECT_TIMEOUT = 180_000; +const DEFAULT_TX_TIMEOUT = 300_000; +const MAX_JSON_BODY_BYTES = 64 * 1024; +// LONG_POLL_MS is imported from ./sessionConstants (single source of truth). + +class PayloadTooLargeError extends Error { + constructor() { + super("Request body too large"); + this.name = "PayloadTooLargeError"; + } +} + +function sameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +/** + * Dependency-free localhost bridge that lets a browser wallet (MetaMask, any + * injected window.ethereum) sign-and-broadcast transactions on behalf of the + * CLI. See docs/design in bridgePage.ts for the page side. + * + * Security posture: + * - binds 127.0.0.1 only, ephemeral port (listen(0)) + * - per-session token (URL fragment) required on every /api/* call + * - Origin header checked on POSTs (blocks DNS-rebinding / cross-site POST) + * - Cache-Control: no-store everywhere; single session; closed after use. + */ +export class BrowserWalletBridge { + private readonly chain: BridgeChainParams; + private readonly openUrl: (url: string) => Promise; + private readonly connectTimeoutMs: number; + private readonly txTimeoutMs: number; + private readonly log: (msg: string) => void; + private readonly handleSigint: boolean; + private readonly persistent: boolean; + private readonly onConnected?: (address: Address) => void; + private readonly onActivity?: () => void; + private readonly onShutdown?: () => void; + + private readonly token = randomUUID(); + private server: http.Server | null = null; + private boundPort = 0; + private origin = ""; + private url = ""; + private closed = false; + private finalMessage = "All done. You can close this tab."; + private readonly createdAt = Date.now(); + + private connectedAddress: Address | null = null; + private connectResolve: ((addr: Address) => void) | null = null; + private connectReject: ((err: Error) => void) | null = null; + private connectTimer: NodeJS.Timeout | null = null; + + private readonly txQueue: PendingTx[] = []; + private nextWaiter: NextWaiter | null = null; + private sigintHandler: (() => void) | null = null; + + /** Last time the page polled /api/next — the tab-liveness heartbeat. */ + private lastPagePollAt = 0; + /** Result store for remote (HTTP-polling) callers, keyed by tx id. */ + private readonly resultStore = new Map(); + + constructor(options: BrowserWalletBridgeOptions) { + this.chain = options.chain; + this.openUrl = options.openUrl ?? defaultOpenUrl; + this.connectTimeoutMs = options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT; + this.txTimeoutMs = options.txTimeoutMs ?? DEFAULT_TX_TIMEOUT; + this.log = options.log ?? (() => {}); + this.handleSigint = options.handleSigint ?? true; + this.persistent = options.persistent ?? false; + this.onConnected = options.onConnected; + this.onActivity = options.onActivity; + this.onShutdown = options.onShutdown; + } + + /** Whether the bridge is running in persistent (daemon) mode. */ + isPersistent(): boolean { + return this.persistent; + } + + getToken(): string { + return this.token; + } + + getPort(): number { + const addr = this.server?.address() as AddressInfo | null; + return addr?.port ?? 0; + } + + /** True when the page heartbeat is fresh enough to consider the tab alive. */ + private tabAlive(): boolean { + if (this.lastPagePollAt === 0) return true; // no poll yet: not yet stale + return Date.now() - this.lastPagePollAt <= HEARTBEAT_DEAD_MS; + } + + getState(): BridgeSessionState { + return { + connected: this.connectedAddress !== null, + address: this.connectedAddress, + chainId: this.chain.chainId, + chainIdHex: `0x${this.chain.chainId.toString(16)}`, + chainName: this.chain.chainName, + url: this.url, + lastPagePollAt: this.lastPagePollAt, + queuedCount: this.txQueue.length, + createdAt: this.createdAt, + }; + } + + async start(): Promise<{url: string}> { + if (this.server) return {url: this.url}; + + this.server = http.createServer((req, res) => this.handleRequest(req, res)); + + await new Promise((resolve, reject) => { + this.server!.once("error", reject); + this.server!.listen(0, "127.0.0.1", () => { + this.server!.removeListener("error", reject); + resolve(); + }); + }); + + const address = this.server.address() as AddressInfo; + this.boundPort = address.port; + this.origin = `http://127.0.0.1:${address.port}`; + this.url = `${this.origin}/#s=${this.token}`; + + if (this.handleSigint) { + this.sigintHandler = () => { + void this.close("The CLI was interrupted."); + }; + process.once("SIGINT", this.sigintHandler); + } + + // The Tier-2 e2e harness drives its own headless chromium against this URL, + // so auto-opening the system browser would just spawn a stray tab. Skipped + // only when the harness sets GENLAYER_E2E_NO_OPEN; production is unaffected. + if (!process.env.GENLAYER_E2E_NO_OPEN) { + await this.openUrl(this.url).catch(() => { + // Non-fatal: user can open the URL manually (headless / SSH). + }); + } + + return {url: this.url}; + } + + getUrl(): string { + return this.url; + } + + async waitForConnection(): Promise
{ + if (this.connectedAddress) return this.connectedAddress; + return new Promise
((resolve, reject) => { + this.connectResolve = resolve; + this.connectReject = reject; + this.connectTimer = setTimeout(() => { + this.connectReject = null; + this.connectResolve = null; + reject( + new Error( + `Timed out waiting for the browser wallet to connect. Open this URL and connect:\n ${this.url}`, + ), + ); + }, this.connectTimeoutMs); + }); + } + + async sendTransaction(tx: Omit): Promise { + if (this.closed) throw new Error("Bridge is closed."); + const {id, promise} = this.queueTx(tx); + void id; + return promise; + } + + /** + * Persistent-mode entry point for remote callers: enqueue a tx, record its + * result in the store (so an HTTP client can poll GET /api/tx?id=…), and + * return the id synchronously. Throws a 409-mappable reason if the wallet is + * not usable right now, so commands fail fast rather than queueing into a + * dead session. + */ + enqueueTx(tx: Omit): string { + if (this.closed) throw new EnqueueError("bridge-closed"); + if (!this.connectedAddress) throw new EnqueueError("wallet-not-connected"); + if (!this.tabAlive()) throw new EnqueueError("tab-closed"); + + const {id, promise} = this.queueTx(tx); + this.resultStore.set(id, {state: "pending"}); + this.onActivity?.(); + + promise.then( + txHash => { + const rec = this.resultStore.get(id); + this.resultStore.set(id, { + state: "done", + status: "sent", + txHash, + from: (rec && "from" in rec ? (rec as any).from : undefined) ?? this.lastResultFrom.get(id), + completedAt: Date.now(), + }); + this.scheduleResultGc(); + }, + (err: Error) => { + const message = err?.message || String(err); + const status = /rejected in wallet/i.test(message) ? "rejected" : "error"; + this.resultStore.set(id, { + state: "done", + status, + message, + from: this.lastResultFrom.get(id), + completedAt: Date.now(), + }); + this.scheduleResultGc(); + }, + ); + + return id; + } + + /** Look up a stored result for a remote poller. */ + getTxResult(id: string): TxResultRecord | null { + return this.resultStore.get(id) ?? null; + } + + private lastResultFrom = new Map(); + + private scheduleResultGc(): void { + const cutoff = Date.now() - RESULT_GC_MS; + for (const [id, rec] of this.resultStore) { + if (rec.state === "done" && rec.completedAt < cutoff) { + this.resultStore.delete(id); + this.lastResultFrom.delete(id); + } + } + } + + private queueTx(tx: Omit): {id: string; promise: Promise} { + const request: BridgeTxRequest = {...tx, id: randomUUID()}; + const promise = new Promise((resolve, reject) => { + const pending: PendingTx = { + request, + resolve, + reject, + delivered: false, + expectedSigner: this.connectedAddress ?? undefined, + }; + pending.timer = setTimeout(() => { + const idx = this.txQueue.indexOf(pending); + if (idx >= 0) this.txQueue.splice(idx, 1); + reject( + new Error( + `Timed out waiting for the wallet to sign "${request.label}". ` + + `Confirm in your wallet at ${this.url}`, + ), + ); + }, this.txTimeoutMs); + + this.txQueue.push(pending); + this.tryDeliverNext(); + }); + return {id: request.id, promise}; + } + + /** Address of the tx the caller can inspect (for cross-checking `from`). */ + lastConnectedAddress(): Address | null { + return this.connectedAddress; + } + + async close(finalMessage?: string): Promise { + if (this.closed) return; + this.closed = true; + if (finalMessage) this.finalMessage = finalMessage; + + // Flush any waiter with a terminal message so the page stops polling. + if (this.nextWaiter) { + clearTimeout(this.nextWaiter.timer); + this.nextWaiter.resolve({type: "done", message: this.finalMessage}); + this.nextWaiter = null; + } + + // Reject anything still pending. + for (const pending of this.txQueue.splice(0)) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(new Error("Bridge closed before the transaction completed.")); + } + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } + if (this.connectReject) { + this.connectReject(new Error("Bridge closed before the wallet connected.")); + this.connectReject = null; + this.connectResolve = null; + } + if (this.sigintHandler) { + process.removeListener("SIGINT", this.sigintHandler); + this.sigintHandler = null; + } + + const server = this.server; + this.server = null; + if (server) { + // Give the page a brief moment to receive the terminal poll response. + await new Promise(resolve => { + setTimeout(() => { + server.closeAllConnections?.(); + server.close(() => resolve()); + }, 50); + }); + } + } + + // --- HTTP handling ------------------------------------------------------- + + private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { + res.setHeader("Cache-Control", "no-store"); + + if (!this.isValidHost(req.headers.host)) { + res.statusCode = 403; + res.end("Bad host"); + return; + } + + const url = new URL(req.url ?? "/", this.origin); + const path = url.pathname; + + if (path === "/" && req.method === "GET") { + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.end(BRIDGE_PAGE_HTML); + return; + } + + if (!path.startsWith("/api/")) { + res.statusCode = 404; + res.end("Not found"); + return; + } + + // Token auth for all API routes. + if (!this.isValidToken(req.headers["x-bridge-token"])) { + res.statusCode = 403; + res.end("Forbidden"); + return; + } + + // Origin check for PAGE-originated POSTs (anti DNS-rebinding). Client + // routes (/api/enqueue, /api/shutdown) are exempt: a Node CLI client sends + // no Origin header, and token-in-header already forces a CORS preflight for + // any cross-site browser attempt (we emit no CORS headers, so it's blocked). + if (req.method === "POST" && PAGE_POST_ROUTES.has(path)) { + const origin = req.headers["origin"]; + if (origin !== this.origin) { + res.statusCode = 403; + res.end("Bad origin"); + return; + } + } + + if (path === "/api/session" && req.method === "GET") { + this.json(res, { + status: "ok", + persistent: this.persistent, + chain: { + ...this.chain, + chainIdHex: `0x${this.chain.chainId.toString(16)}`, + }, + }); + return; + } + + // --- Client (CLI) routes — persistent mode only ----------------------- + if (path === "/api/ping" && req.method === "GET") { + this.json(res, {status: "ok"}); + return; + } + + if (path === "/api/state" && req.method === "GET") { + this.json(res, this.getState()); + return; + } + + if (path === "/api/enqueue" && req.method === "POST") { + if (!this.persistent) { + res.statusCode = 404; + res.end("Not found"); + return; + } + void this.readJson(req) + .then(body => { + try { + const parsed = parseBridgeTx(body as SerializedBridgeTx); + const id = this.enqueueTx(parsed); + this.json(res, {id}); + } catch (err) { + if (err instanceof EnqueueError) { + res.statusCode = 409; + this.json(res, {error: err.reason}); + } else { + res.statusCode = 400; + this.json(res, {error: (err as Error)?.message || "bad request"}); + } + } + }) + .catch(err => this.handleJsonReadError(res, err)); + return; + } + + if (path === "/api/tx" && req.method === "GET") { + const id = url.searchParams.get("id") ?? ""; + const rec = this.getTxResult(id); + if (!rec) { + res.statusCode = 404; + this.json(res, {state: "unknown"}); + return; + } + this.json(res, rec); + return; + } + + if (path === "/api/shutdown" && req.method === "POST") { + this.json(res, {status: "ok"}); + // Deterministic shutdown: if a daemon registered onShutdown, IT owns the + // ordered teardown (remove descriptor → close bridge → exit) so the + // "daemon exits ⇒ descriptor removed" invariant always holds — never rely + // on unref'd polling. Only close directly when nobody orchestrates + // (non-daemon / own-bridge usage). + if (this.onShutdown) { + this.onShutdown(); + } else { + void this.close("Disconnected. You can close this tab."); + } + return; + } + + if (path === "/api/connected" && req.method === "POST") { + void this.readJson(req) + .then(body => { + const address = (body?.address ?? "") as Address; + this.connectedAddress = address; + if (this.connectTimer) { + clearTimeout(this.connectTimer); + this.connectTimer = null; + } + if (this.connectResolve) { + this.connectResolve(address); + this.connectResolve = null; + this.connectReject = null; + } + this.onConnected?.(address); + this.json(res, {status: "ok"}); + }) + .catch(err => this.handleJsonReadError(res, err)); + return; + } + + if (path === "/api/next" && req.method === "GET") { + this.handleNext(res); + return; + } + + if (path === "/api/result" && req.method === "POST") { + void this.readJson(req) + .then(body => { + this.handleResult(body); + this.json(res, {status: "ok"}); + }) + .catch(err => this.handleJsonReadError(res, err)); + return; + } + + res.statusCode = 404; + res.end("Not found"); + } + + private handleNext(res: http.ServerResponse): void { + // Heartbeat: every page poll proves the tab is alive. + this.lastPagePollAt = Date.now(); + + // Deliver a queued-but-undelivered tx immediately. + const pending = this.txQueue.find(p => !p.delivered); + if (pending) { + pending.delivered = true; + if (this.resultStore.has(pending.request.id)) { + this.resultStore.set(pending.request.id, {state: "delivered"}); + } + this.json(res, {type: "tx", tx: this.serializeTx(pending.request)}); + return; + } + + if (this.closed) { + this.json(res, {type: "done", message: this.finalMessage}); + return; + } + + // Long-poll: hold until a tx arrives or the poll window elapses. + if (this.nextWaiter) { + clearTimeout(this.nextWaiter.timer); + this.nextWaiter.resolve({type: "none"}); + this.nextWaiter = null; + } + + const timer = setTimeout(() => { + if (this.nextWaiter) { + this.nextWaiter = null; + this.json(res, {type: "none"}); + } + }, LONG_POLL_MS); + + this.nextWaiter = { + resolve: payload => this.json(res, payload), + timer, + }; + } + + private handleResult(body: any): void { + const id = body?.id as string; + const pending = this.txQueue.find(p => p.request.id === id); + if (!pending) return; + + const idx = this.txQueue.indexOf(pending); + if (idx >= 0) this.txQueue.splice(idx, 1); + if (pending.timer) clearTimeout(pending.timer); + pending.from = typeof body?.from === "string" && body.from ? (body.from as Address) : undefined; + if (pending.from) this.lastResultFrom.set(id, pending.from); + + const expectedSigner = pending.expectedSigner ?? this.connectedAddress ?? undefined; + if (pending.from && expectedSigner && !sameAddress(pending.from, expectedSigner)) { + pending.reject( + new Error( + `Wallet returned a result from ${pending.from}, but the expected signer is ${expectedSigner}. ` + + "Switch back to the expected account or reconnect the wallet session.", + ), + ); + return; + } + if (pending.from && !expectedSigner) { + pending.reject( + new Error( + `Wallet returned a result from ${pending.from}, but no connected signer was recorded for this session.`, + ), + ); + return; + } + + if (body?.status === "sent" && body?.txHash) { + pending.resolve(body.txHash as Hash); + } else if (body?.status === "rejected") { + pending.reject(new Error("Transaction rejected in wallet")); + } else { + pending.reject(new Error(body?.message || "Transaction failed in wallet")); + } + } + + private tryDeliverNext(): void { + if (!this.nextWaiter) return; + const pending = this.txQueue.find(p => !p.delivered); + if (!pending) return; + pending.delivered = true; + if (this.resultStore.has(pending.request.id)) { + this.resultStore.set(pending.request.id, {state: "delivered"}); + } + const waiter = this.nextWaiter; + this.nextWaiter = null; + clearTimeout(waiter.timer); + waiter.resolve({type: "tx", tx: this.serializeTx(pending.request)}); + } + + private serializeTx(tx: BridgeTxRequest): Record { + return { + id: tx.id, + // nonce/chainId are serialized for completeness but the page deliberately + // does NOT forward them to eth_sendTransaction (MetaMask tracks its own + // pending nonce and enforces the chain itself; some wallet versions reject + // dapp-supplied nonce/chainId keys). + ...serializeBridgeTx(tx), + chainId: `0x${this.chain.chainId.toString(16)}`, + }; + } + + private json(res: http.ServerResponse, payload: unknown): void { + res.setHeader("Content-Type", "application/json; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(JSON.stringify(payload)); + } + + private isValidHost(host: string | undefined): boolean { + // Validate against the port captured at start() rather than the live + // server address: a long-poll flushed during teardown is handled after the + // server has closed (address() → null), and must still pass the check. + const port = this.boundPort; + return host === `127.0.0.1:${port}` || host === `localhost:${port}`; + } + + private isValidToken(header: string | string[] | undefined): boolean { + if (typeof header !== "string") return false; + const received = Buffer.from(header); + const expected = Buffer.from(this.token); + return received.length === expected.length && timingSafeEqual(received, expected); + } + + private handleJsonReadError(res: http.ServerResponse, err: unknown): void { + if (err instanceof PayloadTooLargeError) { + res.statusCode = 413; + res.end("Payload too large"); + return; + } + res.statusCode = 400; + this.json(res, {error: "bad request"}); + } + + private readJson(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + let settled = false; + req.on("data", c => { + if (settled) return; + const chunk = c as Buffer; + total += chunk.length; + if (total > MAX_JSON_BODY_BYTES) { + settled = true; + chunks.length = 0; + reject(new PayloadTooLargeError()); + req.resume(); + return; + } + chunks.push(chunk); + }); + req.on("end", () => { + if (settled) return; + settled = true; + try { + const raw = Buffer.concat(chunks).toString("utf-8"); + resolve(raw ? JSON.parse(raw) : {}); + } catch { + resolve({}); + } + }); + req.on("error", () => { + if (settled) return; + settled = true; + resolve({}); + }); + }); + } +} diff --git a/src/lib/wallet/browserSend.ts b/src/lib/wallet/browserSend.ts new file mode 100644 index 00000000..d3ca9844 --- /dev/null +++ b/src/lib/wallet/browserSend.ts @@ -0,0 +1,340 @@ +import { + createPublicClient, + http, + hexToBigInt, + type PublicClient, + type Chain, + type HttpTransportConfig, + type TransactionReceipt, +} from "viem"; +import type {GenLayerChain, Address, Hash} from "genlayer-js/types"; +import {BrowserWalletBridge, type BridgeChainParams, type BridgeTxRequest} from "./browserBridge"; +import type {WalletSessionClient} from "./sessionClient"; + +// GenLayer RPC rejects JSON-RPC requests with id=0 (treats 0 as missing). +// Viem starts its id counter at 0, so we ensure non-zero ids. Owned here now +// (was StakingAction.ts) and re-exported for back-compat. +export const glHttpConfig: HttpTransportConfig = { + async fetchFn(url, init) { + if (init?.body) { + const body = JSON.parse(init.body as string); + if (body.id === 0) body.id = 1; + init = {...init, body: JSON.stringify(body)}; + } + return fetch(url, init); + }, +}; + +export type WalletMode = "keystore" | "browser"; + +export interface BrowserSessionParams { + /** Already-resolved chain (via resolveNetwork / getNetwork). */ + chain: GenLayerChain; + /** config.rpc || chain.rpcUrls.default.http[0]. */ + rpcUrl: string; + log?: (msg: string) => void; + logInfo?: (msg: string) => void; + /** Injectable for tests (default: the bridge's own openUrl). */ + openUrl?: (url: string) => Promise; + /** Register a SIGINT handler (default: true; tests pass false). */ + handleSigint?: boolean; +} + +/** EIP-1193-compatible request shape the genlayer-js client provider expects. */ +export interface Eip1193Provider { + request(args: {method: string; params?: any[]}): Promise; +} + +export interface BridgeSendResult { + txHash: Hash; + from?: Address; +} + +/** + * Transport seam between "how a tx gets to the wallet" and everything above it + * (preflight, receipt wait, EIP-1193 shim, labels). A local transport owns a + * bridge in-process; a remote transport enqueues to a running daemon over HTTP. + */ +export interface BridgeTransport { + readonly kind: "local" | "remote"; + readonly signerAddress: Address; + sendTransaction(tx: Omit): Promise; + /** Local: close the bridge. Remote: no-op (detach only — never kill a shared session). */ + close(finalMessage?: string): Promise; +} + +export interface BrowserSession { + /** Present only for local (own-bridge) sessions; absent for remote daemon sessions. */ + bridge?: BrowserWalletBridge; + kind: "local" | "remote"; + sessionUrl: string; + publicClient: PublicClient; + chain: GenLayerChain; + signerAddress: Address; + /** + * Lane A: preflight (publicClient.call) + queue to the wallet + await the EVM + * receipt; throws on predicted or on-chain revert. + */ + sendTransaction(tx: { + to: Address; + data: `0x${string}`; + value?: bigint; + gas?: bigint; + label: string; + }): Promise; + /** + * Lane B: EIP-1193 shim for genlayer-js `createClient({account, provider})`. + * Forwards eth_sendTransaction to the bridge; answers eth_chainId/eth_accounts + * locally. Does NOT wait for the receipt (the SDK does that against the RPC). + */ + eip1193Provider: Eip1193Provider; + /** Label shown on the bridge page for the NEXT provider-originated tx. */ + setNextLabel(label: string): void; + close(finalMessage?: string): Promise; +} + +/** Build the bridge/add-chain params from a resolved GenLayer chain. */ +export function buildBridgeChain(chain: GenLayerChain, rpcUrl: string): BridgeChainParams { + return { + chainId: chain.id, + chainName: chain.name, + rpcUrls: [rpcUrl], + nativeCurrency: chain.nativeCurrency + ? { + name: chain.nativeCurrency.name, + symbol: chain.nativeCurrency.symbol, + decimals: chain.nativeCurrency.decimals, + } + : {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorerUrls: chain.blockExplorers?.default?.url ? [chain.blockExplorers.default.url] : undefined, + }; +} + +function sameAddress(a: Address, b: Address): boolean { + return a.toLowerCase() === b.toLowerCase(); +} + +function assertResultSigner(result: BridgeSendResult, signerAddress: Address): Hash { + if (result.from && !sameAddress(result.from, signerAddress)) { + throw new Error( + `Wallet returned a transaction from ${result.from}, but the expected signer is ${signerAddress}. ` + + "Switch back to the expected account or reconnect the wallet session.", + ); + } + return result.txHash; +} + +/** + * Build the shared signing lanes (preflight + receipt wait Lane A, EIP-1193 + * shim Lane B, labels) on top of a transport. This body is identical for local + * and remote sessions — only the transport differs. + */ +export function buildBrowserSession( + transport: BridgeTransport, + chain: GenLayerChain, + rpcUrl: string, + bridgeChain: BridgeChainParams, + kind: "local" | "remote", + sessionUrl: string, + bridge?: BrowserWalletBridge, +): BrowserSession { + const publicClient = createPublicClient({ + chain: chain as unknown as Chain, + transport: http(rpcUrl, glHttpConfig), + }); + + const signerAddress = transport.signerAddress; + const chainIdHex = `0x${chain.id.toString(16)}`; + let nextLabel: string | undefined; + + const sendTransaction = async (tx: { + to: Address; + data: `0x${string}`; + value?: bigint; + gas?: bigint; + label: string; + }): Promise => { + // Preflight to surface reverts before the wallet ever prompts. + try { + await publicClient.call({ + account: signerAddress, + to: tx.to, + data: tx.data, + value: tx.value, + }); + } catch (error: any) { + // Remote transport.close() is a no-op: one failed preflight must NOT kill + // a shared daemon session. Local closes its own single-use bridge. + await transport.close("The CLI aborted: the transaction would revert."); + throw new Error( + `Transaction would revert (preflight): ${error.shortMessage || error.message || error}`, + ); + } + + const result = await transport.sendTransaction({ + to: tx.to, + data: tx.data, + value: tx.value, + gas: tx.gas, + label: tx.label, + }); + const hash = assertResultSigner(result, signerAddress); + + const receipt = await publicClient.waitForTransactionReceipt({ + hash, + timeout: 300_000, + }); + + if (receipt.status === "reverted") { + const explorer = bridgeChain.blockExplorerUrls?.[0]; + const hint = explorer ? ` See ${explorer.replace(/\/$/, "")}/tx/${hash}` : ""; + throw new Error(`Transaction ${hash} reverted.${hint}`); + } + + return receipt; + }; + + const eip1193Provider: Eip1193Provider = { + async request({method, params = []}: {method: string; params?: any[]}) { + switch (method) { + case "eth_chainId": + // Satisfies the SDK's assertChainMatch; real chain enforcement is + // page-side (wallet_switchEthereumChain, re-verified before each send). + return chainIdHex; + case "eth_accounts": + case "eth_requestAccounts": + return [signerAddress]; + case "eth_sendTransaction": { + const req = (params[0] ?? {}) as { + to?: Address; + data?: `0x${string}`; + value?: string; + gas?: string; + gasPrice?: string; + nonce?: string; + type?: string; + }; + const result = await transport.sendTransaction({ + to: req.to as Address, + data: (req.data ?? "0x") as `0x${string}`, + value: req.value !== undefined ? hexToBigInt(req.value as `0x${string}`) : undefined, + gas: req.gas !== undefined ? hexToBigInt(req.gas as `0x${string}`) : undefined, + gasPrice: req.gasPrice !== undefined ? hexToBigInt(req.gasPrice as `0x${string}`) : undefined, + nonce: req.nonce !== undefined ? Number(hexToBigInt(req.nonce as `0x${string}`)) : undefined, + type: req.type, + label: nextLabel ?? "GenLayer transaction", + }); + const hash = assertResultSigner(result, signerAddress); + nextLabel = undefined; + return hash; + } + default: + throw new Error(`Method ${method} is not supported by the browser-wallet bridge`); + } + }, + }; + + const setNextLabel = (label: string): void => { + nextLabel = label; + }; + + return { + bridge, + kind, + sessionUrl, + publicClient, + chain, + signerAddress, + sendTransaction, + eip1193Provider, + setNextLabel, + close: (finalMessage?: string) => transport.close(finalMessage), + }; +} + +/** Transport that owns an in-process BrowserWalletBridge (per-command / wizard). */ +class LocalBridgeTransport implements BridgeTransport { + readonly kind = "local" as const; + constructor( + private readonly bridge: BrowserWalletBridge, + readonly signerAddress: Address, + ) {} + async sendTransaction(tx: Omit): Promise { + return {txHash: await this.bridge.sendTransaction(tx)}; + } + close(finalMessage?: string): Promise { + return this.bridge.close(finalMessage); + } +} + +/** Transport that enqueues to a running daemon over HTTP; close() detaches only. */ +class RemoteSessionTransport implements BridgeTransport { + readonly kind = "remote" as const; + constructor( + private readonly client: WalletSessionClient, + readonly signerAddress: Address, + ) {} + async sendTransaction(tx: Omit): Promise { + const id = await this.client.enqueueTx(tx); + return this.client.waitForTxResult(id); + } + async close(): Promise { + // No-op: the daemon session is shared and outlives this command. + } +} + +/** + * Open a browser-wallet signing session with an in-process bridge: start the + * localhost bridge, open the wallet page, wait for connect, and return both + * signing lanes. Never touches keystore/keychain/password code paths. + * Action-agnostic (reused by the wizard and the resolver's own-bridge fallback). + */ +export async function openBrowserWalletSession(params: BrowserSessionParams): Promise { + const {chain, rpcUrl} = params; + const log = params.log ?? (() => {}); + const logInfo = params.logInfo ?? (() => {}); + + const bridgeChain = buildBridgeChain(chain, rpcUrl); + + const bridge = new BrowserWalletBridge({ + chain: bridgeChain, + openUrl: params.openUrl, + handleSigint: params.handleSigint, + log, + }); + + const {url} = await bridge.start(); + logInfo(`Open this URL in a browser with your wallet to sign:\n ${url}`); + logInfo( + "(Remote/SSH? Forward the port first: ssh -L :127.0.0.1: ...; " + + "do not use -g, GatewayPorts yes, or bind the local side to a public interface.)", + ); + + const signerAddress = await bridge.waitForConnection(); + const transport = new LocalBridgeTransport(bridge, signerAddress); + return buildBrowserSession(transport, chain, rpcUrl, bridgeChain, "local", url, bridge); +} + +/** + * Build a session backed by a running daemon (discovered via the descriptor). + * Asserts the wallet is already connected, then wraps a remote transport whose + * close() is a no-op so per-command finally blocks never tear the session down. + */ +export async function openRemoteWalletSession(params: { + client: WalletSessionClient; + chain: GenLayerChain; + rpcUrl: string; + log?: (msg: string) => void; + logInfo?: (msg: string) => void; +}): Promise { + const {client, chain, rpcUrl} = params; + const state = await client.state(); + if (!state.connected || !state.address) { + throw new Error( + "The wallet session is not connected. Run 'genlayer wallet connect' and approve in your browser.", + ); + } + const bridgeChain = buildBridgeChain(chain, rpcUrl); + const transport = new RemoteSessionTransport(client, state.address); + return buildBrowserSession(transport, chain, rpcUrl, bridgeChain, "remote", state.url); +} diff --git a/src/lib/wallet/sessionClient.ts b/src/lib/wallet/sessionClient.ts new file mode 100644 index 00000000..289fc9f6 --- /dev/null +++ b/src/lib/wallet/sessionClient.ts @@ -0,0 +1,158 @@ +import type {Address, Hash} from "genlayer-js/types"; +import {serializeBridgeTx, type BridgeTxRequest, type SerializedBridgeTx} from "./browserBridge"; +import type {WalletSessionDescriptor} from "./sessionDescriptor"; +import {HEARTBEAT_DEAD_MS, TX_TIMEOUT_MS, CONNECT_TIMEOUT_MS, TAB_CLOSED_MESSAGE} from "./sessionConstants"; + +/** Mirror of BridgeSessionState over the wire (GET /api/state). */ +export interface SessionState { + connected: boolean; + address: Address | null; + chainId: number; + chainIdHex: string; + chainName: string; + url: string; + lastPagePollAt: number; + queuedCount: number; + createdAt: number; +} + +export interface SessionTxResult { + txHash: Hash; + from?: Address; +} + +type FetchFn = typeof fetch; + +/** + * HTTP client for a running wallet-session daemon. Every request carries the + * bridge token in X-Bridge-Token. No new dependencies — plain fetch against + * http://127.0.0.1:. + */ +export class WalletSessionClient { + private readonly base: string; + private readonly token: string; + private readonly fetchFn: FetchFn; + private readonly pollIntervalMs: number; + + constructor( + readonly descriptor: WalletSessionDescriptor, + opts: {fetchFn?: FetchFn; pollIntervalMs?: number} = {}, + ) { + this.base = `http://127.0.0.1:${descriptor.port}`; + this.token = descriptor.token; + this.fetchFn = opts.fetchFn ?? fetch; + this.pollIntervalMs = opts.pollIntervalMs ?? 1000; + } + + private headers(json = false): Record { + const h: Record = {"X-Bridge-Token": this.token}; + if (json) h["Content-Type"] = "application/json"; + return h; + } + + /** Liveness probe. Any connection error (ECONNREFUSED, etc.) → false. */ + async ping(): Promise { + try { + const res = await this.fetchFn(`${this.base}/api/ping`, {headers: this.headers()}); + if (!res.ok) return false; + const body: any = await res.json().catch(() => ({})); + return body?.status === "ok"; + } catch { + return false; + } + } + + async state(): Promise { + const res = await this.fetchFn(`${this.base}/api/state`, {headers: this.headers()}); + if (!res.ok) throw new Error(`Wallet session returned ${res.status} for /api/state`); + return (await res.json()) as SessionState; + } + + /** Enqueue a tx onto the daemon's wallet queue; returns the tx id. */ + async enqueueTx(tx: Omit): Promise { + const payload: SerializedBridgeTx = serializeBridgeTx(tx); + const res = await this.fetchFn(`${this.base}/api/enqueue`, { + method: "POST", + headers: this.headers(true), + body: JSON.stringify(payload), + }); + if (res.status === 409) { + const body: any = await res.json().catch(() => ({})); + if (body?.error === "tab-closed") throw new Error(TAB_CLOSED_MESSAGE); + if (body?.error === "wallet-not-connected") { + throw new Error( + "The wallet session is not connected yet. Approve the connection in your browser, " + + "or run 'genlayer wallet connect'.", + ); + } + throw new Error(`Wallet session cannot accept transactions: ${body?.error ?? "unknown"}`); + } + if (!res.ok) throw new Error(`Wallet session returned ${res.status} for /api/enqueue`); + const body: any = await res.json(); + if (!body?.id) throw new Error("Wallet session did not return a transaction id"); + return body.id as string; + } + + /** + * Poll for a tx result. Fails fast if the page heartbeat goes stale (tab + * closed) rather than blocking for the full timeout. + */ + async waitForTxResult(id: string, timeoutMs = TX_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + for (;;) { + const res = await this.fetchFn(`${this.base}/api/tx?id=${encodeURIComponent(id)}`, { + headers: this.headers(), + }); + if (res.ok) { + const rec: any = await res.json(); + if (rec.state === "done") { + if (rec.status === "sent" && rec.txHash) { + return { + txHash: rec.txHash as Hash, + from: typeof rec.from === "string" ? (rec.from as Address) : undefined, + }; + } + if (rec.status === "rejected") throw new Error("Transaction rejected in wallet"); + throw new Error(rec.message || "Transaction failed in wallet"); + } + // pending | delivered → keep polling. + } + + // Fail fast on a dead tab instead of hanging until timeout. + const st = await this.state().catch(() => null); + if (st && st.lastPagePollAt > 0 && Date.now() - st.lastPagePollAt > HEARTBEAT_DEAD_MS) { + throw new Error(TAB_CLOSED_MESSAGE); + } + + if (Date.now() > deadline) { + throw new Error(`Timed out waiting for the wallet to sign the transaction (id ${id}).`); + } + await new Promise(r => setTimeout(r, this.pollIntervalMs)); + } + } + + /** Poll session state until the wallet is connected; returns the signer address. */ + async waitForConnection(timeoutMs = CONNECT_TIMEOUT_MS): Promise
{ + const deadline = Date.now() + timeoutMs; + for (;;) { + const st = await this.state().catch(() => null); + if (st?.connected && st.address) return st.address; + if (Date.now() > deadline) { + throw new Error( + "Timed out waiting for the browser wallet to connect. " + + "Open the session tab and approve the connection, or run 'genlayer wallet connect'.", + ); + } + await new Promise(r => setTimeout(r, this.pollIntervalMs)); + } + } + + /** Ask the daemon to shut down. Best-effort — tolerate a dropped socket. */ + async shutdown(): Promise { + try { + await this.fetchFn(`${this.base}/api/shutdown`, {method: "POST", headers: this.headers(true)}); + } catch { + // The server may close the socket before the response flushes. + } + } +} diff --git a/src/lib/wallet/sessionConstants.ts b/src/lib/wallet/sessionConstants.ts new file mode 100644 index 00000000..5b550601 --- /dev/null +++ b/src/lib/wallet/sessionConstants.ts @@ -0,0 +1,65 @@ +/** + * Single source of truth for the persistent wallet-session timing constants. + * Shared by the daemon, the session client, and the bridge page so the + * heartbeat / liveness budgets never drift between producer and consumer. + */ + +/** + * Test-only escape hatch: the Tier-2 e2e harness needs the tab-closed / + * connect-timeout budgets to resolve in seconds, not minutes. A `GENLAYER_E2E_*` + * env override (positive integer, milliseconds) replaces the production default; + * when the env var is unset or invalid the production value is used unchanged, so + * normal runs are completely unaffected. Kept here (the single source of truth) + * so producer and consumer read the identical, possibly-overridden budget. + */ +function envMs(name: string, fallback: number): number { + const raw = typeof process !== "undefined" ? process.env?.[name] : undefined; + if (!raw) return fallback; + const n = Number(raw); + return Number.isFinite(n) && n > 0 ? n : fallback; +} + +/** Page long-poll window (existing bridge behaviour). */ +export const LONG_POLL_MS = envMs("GENLAYER_E2E_LONG_POLL_MS", 25_000); + +/** + * Client + `/api/enqueue` treat the tab as closed after this much silence on + * the page heartbeat (~3 missed long-poll windows; tolerates background-tab + * throttling). Commands fail fast instead of hanging on a dead tab. + */ +export const HEARTBEAT_DEAD_MS = envMs("GENLAYER_E2E_HEARTBEAT_DEAD_MS", 90_000); + +/** + * Surfaced when the page heartbeat has gone stale (tab closed / crashed). + * Single source of truth so the session client and the resolver emit the + * identical reconnect instruction. + */ +export const TAB_CLOSED_MESSAGE = + "The wallet session tab appears to be closed. Run 'genlayer wallet connect' to reconnect."; + +/** Daemon self-terminates after sustained page silence (tab closed / crashed). */ +export const TAB_DEAD_GRACE_MS = 10 * 60_000; + +/** Daemon self-terminates when unused this long (config: walletSessionTtlMinutes). */ +export const IDLE_TTL_MS = 30 * 60_000; + +/** spawn → descriptor-written + /api/ping answers. */ +export const DAEMON_READY_TIMEOUT_MS = 10_000; + +/** Wallet connect wait (existing bridge behaviour). */ +export const CONNECT_TIMEOUT_MS = envMs("GENLAYER_E2E_CONNECT_TIMEOUT_MS", 180_000); + +/** Per-tx wallet confirmation wait (existing bridge behaviour). */ +export const TX_TIMEOUT_MS = 300_000; + +/** Descriptor file name under ~/.genlayer. */ +export const SESSION_DESCRIPTOR_FILENAME = "wallet-session.json"; + +/** Daemon log file name under ~/.genlayer. */ +export const DAEMON_LOG_FILENAME = "wallet-daemon.log"; + +/** Config key controlling the default signing mode ("keystore" | "browser"). */ +export const WALLET_MODE_CONFIG_KEY = "walletMode"; + +/** Config key (minutes) overriding IDLE_TTL_MS. */ +export const WALLET_SESSION_TTL_CONFIG_KEY = "walletSessionTtlMinutes"; diff --git a/src/lib/wallet/sessionDaemon.ts b/src/lib/wallet/sessionDaemon.ts new file mode 100644 index 00000000..d366f2c0 --- /dev/null +++ b/src/lib/wallet/sessionDaemon.ts @@ -0,0 +1,238 @@ +import type {Address} from "genlayer-js/types"; +import type {ConfigFileManager} from "../config/ConfigFileManager"; +import {resolveNetwork} from "../actions/BaseAction"; +import {normalizeCustomNetworks, CUSTOM_NETWORKS_CONFIG_KEY} from "../networks/customNetworks"; +import {BrowserWalletBridge} from "./browserBridge"; +import {buildBridgeChain} from "./browserSend"; +import { + descriptorPath, + readDescriptor, + removeDescriptor, + writeDescriptor, + isPidAlive, + type WalletSessionDescriptor, +} from "./sessionDescriptor"; +import {WalletSessionClient} from "./sessionClient"; +import { + IDLE_TTL_MS, + TAB_DEAD_GRACE_MS, + CONNECT_TIMEOUT_MS, + WALLET_SESSION_TTL_CONFIG_KEY, +} from "./sessionConstants"; + +export interface RunDaemonOptions { + network?: string; + rpc?: string; + configManager: ConfigFileManager; + openUrl?: (url: string) => Promise; + idleTtlMs?: number; + tabDeadGraceMs?: number; + connectTimeoutMs?: number; + /** Injectable for tests — avoids process.exit killing the test runner. */ + onExit?: (code: number) => void; + log?: (msg: string) => void; + /** For tests: resolve as soon as the runtime is set up (bridge listening + descriptor written). */ + onReady?: (ctx: DaemonHandle) => void; +} + +export interface DaemonHandle { + bridge: BrowserWalletBridge; + descriptor: WalletSessionDescriptor; + /** Force one timer tick (tests). */ + tick(): void; + /** Stop timers + close the bridge without exiting the process (tests). */ + dispose(): Promise; +} + +const LAST_USED_THROTTLE_MS = 5000; + +/** + * The persistent wallet-session daemon runtime. Owns the bridge server + browser + * tab + descriptor file lifecycle. Self-terminates on idle TTL, sustained tab + * silence, /api/shutdown, connect timeout, or a fatal signal — always removing + * the descriptor first. + */ +export async function runWalletSessionDaemon(opts: RunDaemonOptions): Promise { + const {configManager} = opts; + const log = opts.log ?? (() => {}); + const exit = opts.onExit ?? ((code: number) => process.exit(code)); + + const idleTtlMs = resolveIdleTtl(configManager, opts.idleTtlMs); + const tabDeadGraceMs = opts.tabDeadGraceMs ?? TAB_DEAD_GRACE_MS; + const connectTimeoutMs = opts.connectTimeoutMs ?? CONNECT_TIMEOUT_MS; + + const dpath = descriptorPath(configManager); + + // Singleton guard: an already-live daemon wins. + const existing = readDescriptor(dpath); + if (existing && isPidAlive(existing.pid)) { + const client = new WalletSessionClient(existing); + if (await client.ping()) { + log(`Wallet session already running (pid ${existing.pid}). Exiting.`); + exit(0); + throw new Error("daemon-already-running"); + } + } + if (existing) removeDescriptor(dpath); + + // Resolve chain exactly like BaseAction.getBrowserSession. + const customNetworks = normalizeCustomNetworks(configManager.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); + const networkAlias = opts.network || configManager.getConfigByKey("network") || "localnet"; + const chain = opts.network + ? {...resolveNetwork(opts.network, customNetworks)} + : resolveNetwork(configManager.getConfigByKey("network"), customNetworks); + const rpcUrl = opts.rpc || chain.rpcUrls.default.http[0]; + + const bridgeChain = buildBridgeChain(chain, rpcUrl); + + let lastUsedWrite = 0; + let disposed = false; + // Forward reference: cleanupAndExit is defined after the bridge is built, but + // /api/shutdown must trigger it deterministically. The bridge calls this thunk + // synchronously from the shutdown handler. + let onShutdownCb: (() => void) | undefined; + + const bridge = new BrowserWalletBridge({ + chain: bridgeChain, + persistent: true, + handleSigint: false, // the daemon installs its own signal handling below + onShutdown: () => onShutdownCb?.(), + openUrl: opts.openUrl, + log, + onConnected: (address: Address) => { + const d = readDescriptor(dpath); + if (d) writeDescriptor(dpath, {...d, address}); + log(`Wallet connected: ${address}`); + }, + onActivity: () => { + const now = Date.now(); + if (now - lastUsedWrite < LAST_USED_THROTTLE_MS) return; + lastUsedWrite = now; + const d = readDescriptor(dpath); + if (d) writeDescriptor(dpath, {...d, lastUsed: now}); + }, + connectTimeoutMs, + }); + + await bridge.start(); + + // Write the descriptor ONLY after listening succeeds, so a readable + // descriptor always implies a live port. + const now = Date.now(); + const descriptor: WalletSessionDescriptor = { + version: 1, + pid: process.pid, + port: bridge.getPort(), + token: bridge.getToken(), + address: null, + chainId: chain.id, + network: networkAlias, + rpcUrl, + createdAt: now, + lastUsed: now, + }; + writeDescriptor(dpath, descriptor); + log(`Wallet session started (pid ${process.pid}, port ${descriptor.port}). URL: ${bridge.getUrl()}`); + + let everConnected = false; + let cleaned = false; + + const cleanupAndExit = async (code: number, finalMessage?: string): Promise => { + if (cleaned) return; + cleaned = true; + clearInterval(timer); + removeDescriptor(dpath); + await bridge.close(finalMessage).catch(() => {}); + exit(code); + }; + + const checkTimers = (): void => { + if (cleaned || disposed) return; + const state = bridge.getState(); + if (state.connected) everConnected = true; + + const d = readDescriptor(dpath); + const lastUsed = d ? Math.max(d.lastUsed, d.createdAt) : descriptor.createdAt; + + // Idle TTL. + if (Date.now() - lastUsed > idleTtlMs) { + log("Idle TTL reached — shutting down."); + void cleanupAndExit( + 0, + "Session expired after inactivity. Run 'genlayer wallet connect' to start a new one.", + ); + return; + } + + // Tab-dead: connected once, but the page heartbeat went silent. + if (everConnected && state.lastPagePollAt > 0 && Date.now() - state.lastPagePollAt > tabDeadGraceMs) { + log("Tab heartbeat lost — shutting down."); + void cleanupAndExit(0, "The wallet tab was closed. Run 'genlayer wallet connect' to start a new one."); + return; + } + + // Connect timeout: never connected within the window → no zombie daemons. + if (!everConnected && Date.now() - descriptor.createdAt > connectTimeoutMs) { + log("Connect timeout — nobody connected. Shutting down."); + void cleanupAndExit(0, "No wallet connected in time."); + return; + } + }; + + // /api/shutdown (via wallet disconnect) → deterministic ordered teardown. + // Wired here (not via unref'd polling): the bridge fires onShutdown, which + // removes the descriptor, closes the bridge, then exits — so the invariant + // "daemon process gone ⇒ descriptor removed" always holds. + onShutdownCb = () => void cleanupAndExit(0, "Disconnected. You can close this tab."); + + // NOT unref'd: the interval keeps the event loop alive so the daemon only + // ever exits through cleanupAndExit (idle/tab-dead/connect-timeout/shutdown/ + // signal), never by the loop draining after the server socket closes. + const timer = setInterval(checkTimers, 30_000); + + // Signal + fatal-error handling: always remove the descriptor first. + const onSignal = (sig: string) => () => { + log(`Received ${sig} — shutting down.`); + void cleanupAndExit(0); + }; + const sigHandlers: Record void> = {}; + if (opts.onExit === undefined) { + for (const sig of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { + const h = onSignal(sig); + sigHandlers[sig] = h; + process.once(sig, h); + } + process.once("uncaughtException", err => { + log(`uncaughtException: ${err instanceof Error ? err.stack : String(err)}`); + void cleanupAndExit(1); + }); + process.once("unhandledRejection", reason => { + log(`unhandledRejection: ${String(reason)}`); + void cleanupAndExit(1); + }); + } + + const handle: DaemonHandle = { + bridge, + descriptor, + tick: checkTimers, + dispose: async () => { + disposed = true; + clearInterval(timer); + for (const [sig, h] of Object.entries(sigHandlers)) process.removeListener(sig, h); + removeDescriptor(dpath); + await bridge.close().catch(() => {}); + }, + }; + + opts.onReady?.(handle); + return handle; +} + +function resolveIdleTtl(configManager: ConfigFileManager, override?: number): number { + if (override !== undefined) return override; + const configured = configManager.getConfigByKey(WALLET_SESSION_TTL_CONFIG_KEY); + const minutes = Number(configured); + if (Number.isFinite(minutes) && minutes > 0) return minutes * 60_000; + return IDLE_TTL_MS; +} diff --git a/src/lib/wallet/sessionDescriptor.ts b/src/lib/wallet/sessionDescriptor.ts new file mode 100644 index 00000000..9b7bde8b --- /dev/null +++ b/src/lib/wallet/sessionDescriptor.ts @@ -0,0 +1,95 @@ +import fs from "node:fs"; +import type {ConfigFileManager} from "../config/ConfigFileManager"; +import {SESSION_DESCRIPTOR_FILENAME} from "./sessionConstants"; + +/** + * On-disk descriptor for a running wallet-session daemon. Written 0600 next to + * the keystores in ~/.genlayer. Any CLI process reads it to discover the live + * daemon and talk to it over token-authed localhost HTTP. + */ +export interface WalletSessionDescriptor { + version: 1; + pid: number; + port: number; + /** Bridge session token — same one the page carries in its URL fragment. */ + token: string; + /** null until the wallet connects. */ + address: string | null; + chainId: number; + /** Network alias passed to resolveNetwork (or "custom"). */ + network: string; + rpcUrl: string; + createdAt: number; + /** Updated by the daemon on every enqueue (throttled). */ + lastUsed: number; +} + +export function descriptorPath(configManager: ConfigFileManager): string { + return configManager.getFilePath(SESSION_DESCRIPTOR_FILENAME); +} + +/** + * Atomically write the descriptor with 0600 perms: write a temp file, then + * rename over the target (rename is atomic on the same filesystem). chmod after + * rename is belt-and-braces in case the tmp inherited a laxer umask. + */ +export function writeDescriptor(path: string, d: WalletSessionDescriptor): void { + const tmp = `${path}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(d, null, 2), {mode: 0o600}); + fs.renameSync(tmp, path); + try { + fs.chmodSync(path, 0o600); + } catch { + // Non-fatal (e.g. exotic FS); the tmp already had 0600. + } +} + +function isValidDescriptor(v: any): v is WalletSessionDescriptor { + return ( + v && + v.version === 1 && + typeof v.pid === "number" && + typeof v.port === "number" && + typeof v.token === "string" && + (v.address === null || typeof v.address === "string") && + typeof v.chainId === "number" && + typeof v.network === "string" && + typeof v.rpcUrl === "string" && + typeof v.createdAt === "number" && + typeof v.lastUsed === "number" + ); +} + +/** Parse + schema-validate the descriptor, or return null (bad JSON / shape). */ +export function readDescriptor(path: string): WalletSessionDescriptor | null { + let raw: string; + try { + raw = fs.readFileSync(path, "utf-8"); + } catch { + return null; + } + try { + const parsed = JSON.parse(raw); + return isValidDescriptor(parsed) ? parsed : null; + } catch { + return null; + } +} + +export function removeDescriptor(path: string): void { + fs.rmSync(path, {force: true}); +} + +/** + * Cheap first-gate liveness check. Signal 0 does not kill; it only probes. + * EPERM means the process exists but is owned by another user (still "alive"). + * The authoritative check is a token-authed /api/ping (handles PID/port reuse). + */ +export function isPidAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (e: any) { + return e?.code === "EPERM"; + } +} diff --git a/src/lib/wallet/sessionResolver.ts b/src/lib/wallet/sessionResolver.ts new file mode 100644 index 00000000..e5326efe --- /dev/null +++ b/src/lib/wallet/sessionResolver.ts @@ -0,0 +1,131 @@ +import type {GenLayerChain} from "genlayer-js/types"; +import type {ConfigFileManager} from "../config/ConfigFileManager"; +import {openBrowserWalletSession, openRemoteWalletSession, type BrowserSession} from "./browserSend"; +import {WalletSessionClient} from "./sessionClient"; +import {descriptorPath, readDescriptor, removeDescriptor, isPidAlive} from "./sessionDescriptor"; +import {spawnWalletDaemon, waitForDaemonReady} from "./spawnDaemon"; +import { + DAEMON_LOG_FILENAME, + CONNECT_TIMEOUT_MS, + HEARTBEAT_DEAD_MS, + TAB_CLOSED_MESSAGE, +} from "./sessionConstants"; + +export type SessionFallback = "auto-start" | "own-bridge" | "error"; + +export interface ResolveSessionParams { + /** Already-resolved chain (network flag > config). */ + chain: GenLayerChain; + rpcUrl: string; + /** Network alias for the descriptor / daemon argv. */ + networkAlias?: string; + configManager: ConfigFileManager; + fallback: SessionFallback; + log?: (msg: string) => void; + logInfo?: (msg: string) => void; + logWarning?: (msg: string) => void; + openUrl?: (url: string) => Promise; + handleSigint?: boolean; + // Test seams. + spawnFn?: Parameters[0]["spawnFn"]; + fetchFn?: typeof fetch; + /** Override daemon-ready poll timeout (tests). */ + readyTimeoutMs?: number; +} + +/** + * The single entry point every browser-mode command uses. Finds a live daemon + * session and returns a remote session bound to it; otherwise applies the + * fallback (auto-start a persistent daemon, open an own in-process bridge, or + * error). Stale descriptors are cleaned up transparently. + */ +export async function resolveBrowserWalletSession(params: ResolveSessionParams): Promise { + const {chain, rpcUrl, configManager} = params; + const log = params.log ?? (() => {}); + const logInfo = params.logInfo ?? (() => {}); + const logWarning = params.logWarning ?? (() => {}); + const dpath = descriptorPath(configManager); + + // 1. Discover. + const descriptor = readDescriptor(dpath); + if (descriptor) { + // 2. Liveness (cheap pid gate, then authoritative ping). + const client = new WalletSessionClient(descriptor, {fetchFn: params.fetchFn}); + const alive = isPidAlive(descriptor.pid) && (await client.ping()); + if (!alive) { + removeDescriptor(dpath); // stale cleanup + } else { + // 3. Live session found. + let state = await client.state(); + if (state.chainId !== chain.id) { + throw new Error( + `Browser wallet session is connected to ${descriptor.network} (chain ${state.chainId}) ` + + `but this command targets ${chain.name} (chain ${chain.id}). ` + + `Run 'genlayer wallet connect --network ${params.networkAlias ?? descriptor.network}' to switch, ` + + `or pass --wallet keystore.`, + ); + } + if (!state.connected) { + await client.waitForConnection(CONNECT_TIMEOUT_MS); + // Re-read: a just-connected page has polled, so its heartbeat is fresh. + state = await client.state(); + } + // Fail fast on a dead tab (stale page heartbeat) instead of returning a + // session that only fails at the final sign step. The daemon self-manages + // its own tab-dead shutdown, so we do not touch the descriptor here — just + // surface the reconnect instruction immediately. lastPagePollAt === 0 + // means the page has never polled yet (freshly started) → not stale. + if (state.lastPagePollAt > 0 && Date.now() - state.lastPagePollAt > HEARTBEAT_DEAD_MS) { + throw new Error(TAB_CLOSED_MESSAGE); + } + return openRemoteWalletSession({client, chain, rpcUrl, log, logInfo}); + } + } + + // 4. No live session — apply the fallback. + if (params.fallback === "error") { + throw new Error("No active browser wallet session. Run 'genlayer wallet connect' first."); + } + + if (params.fallback === "auto-start") { + try { + const logPath = configManager.getFilePath(DAEMON_LOG_FILENAME); + spawnWalletDaemon({ + network: params.networkAlias, + rpc: rpcUrl, + logPath, + spawnFn: params.spawnFn, + }); + const ready = await waitForDaemonReady(dpath, { + logPath, + fetchFn: params.fetchFn, + timeoutMs: params.readyTimeoutMs, + }); + logInfo( + "Started a persistent wallet session — approve the connection in your browser. " + + "Subsequent commands will reuse it; end it with 'genlayer wallet disconnect'.", + ); + const client = new WalletSessionClient(ready, {fetchFn: params.fetchFn}); + await client.waitForConnection(CONNECT_TIMEOUT_MS); + return openRemoteWalletSession({client, chain, rpcUrl, log, logInfo}); + } catch (err) { + // Degrade to an own in-process bridge (e.g. weird packaging where re-exec + // fails). A lone command still works exactly like before. + logWarning( + `Could not start a persistent wallet session (${ + (err as Error)?.message || err + }). Falling back to a single-use bridge for this command.`, + ); + } + } + + // own-bridge (explicit, or degraded auto-start). + return openBrowserWalletSession({ + chain, + rpcUrl, + log, + logInfo, + openUrl: params.openUrl, + handleSigint: params.handleSigint, + }); +} diff --git a/src/lib/wallet/spawnDaemon.ts b/src/lib/wallet/spawnDaemon.ts new file mode 100644 index 00000000..79b38087 --- /dev/null +++ b/src/lib/wallet/spawnDaemon.ts @@ -0,0 +1,104 @@ +import fs from "node:fs"; +import {spawn, type SpawnOptions} from "node:child_process"; +import {readDescriptor, isPidAlive, type WalletSessionDescriptor} from "./sessionDescriptor"; +import {WalletSessionClient} from "./sessionClient"; +import {DAEMON_READY_TIMEOUT_MS} from "./sessionConstants"; + +type SpawnFn = ( + command: string, + args: readonly string[], + options: SpawnOptions, +) => {pid?: number; unref(): void}; + +export interface SpawnDaemonParams { + /** Network alias; omitted → daemon uses config network. */ + network?: string; + rpc?: string; + /** Default process.argv[1]; injectable for tests. */ + cliPath?: string; + /** Default process.execPath; injectable for tests. */ + execPath?: string; + /** Daemon log path (configManager.getFilePath("wallet-daemon.log")). */ + logPath: string; + /** Injectable spawn for tests. */ + spawnFn?: SpawnFn; +} + +/** + * Detach-spawn the daemon by re-exec'ing this same bundled CLI with the hidden + * `wallet daemon` subcommand. Re-exec (rather than pointing at a separate entry + * file) is the only mechanism that works uniformly for global installs, npx, + * and `node dist/index.js`, with zero esbuild config changes. + * + * The token is NEVER placed on argv (visible in `ps`); the daemon generates it + * itself and publishes it only via the 0600 descriptor file. + */ +export function spawnWalletDaemon(p: SpawnDaemonParams): number { + const execPath = p.execPath ?? process.execPath; + const cliPath = p.cliPath ?? process.argv[1]; + const spawnFn = p.spawnFn ?? (spawn as unknown as SpawnFn); + + const out = fs.openSync(p.logPath, "a"); + try { + fs.chmodSync(p.logPath, 0o600); + } catch { + // Non-fatal. + } + + const args = [cliPath, "wallet", "daemon"]; + if (p.network) args.push("--network", p.network); + if (p.rpc) args.push("--rpc", p.rpc); + + const child = spawnFn(execPath, args, { + detached: true, + stdio: ["ignore", out, out], + windowsHide: true, + env: process.env, + }); + child.unref(); + fs.closeSync(out); + + if (!child.pid) { + throw new Error("Failed to spawn the wallet-session daemon (no pid)."); + } + return child.pid; +} + +/** + * Poll until the descriptor exists, its pid is live, and its /api/ping answers + * with the token. On timeout, surface the tail of the daemon log to aid debugging. + */ +export async function waitForDaemonReady( + descriptorPath: string, + opts: { + timeoutMs?: number; + logPath?: string; + fetchFn?: typeof fetch; + intervalMs?: number; + } = {}, +): Promise { + const timeoutMs = opts.timeoutMs ?? DAEMON_READY_TIMEOUT_MS; + const intervalMs = opts.intervalMs ?? 200; + const deadline = Date.now() + timeoutMs; + + for (;;) { + const d = readDescriptor(descriptorPath); + if (d && isPidAlive(d.pid)) { + const client = new WalletSessionClient(d, {fetchFn: opts.fetchFn}); + if (await client.ping()) return d; + } + if (Date.now() > deadline) { + let tail = ""; + if (opts.logPath) { + try { + const log = fs.readFileSync(opts.logPath, "utf-8"); + tail = "\n" + log.split("\n").slice(-15).join("\n"); + } catch { + // ignore + } + } + throw new Error(`Wallet-session daemon did not become ready within ${timeoutMs}ms.${tail}`); + } + await new Promise(r => setTimeout(r, intervalMs)); + } +} diff --git a/src/lib/wallet/walletOption.ts b/src/lib/wallet/walletOption.ts new file mode 100644 index 00000000..49cc6e54 --- /dev/null +++ b/src/lib/wallet/walletOption.ts @@ -0,0 +1,23 @@ +import type {Command} from "commander"; + +/** + * Shared registrar for the `--wallet ` signing-mode flag. Applied to every + * write command so keystore (default) and browser (MetaMask via local bridge) + * are selectable uniformly. Mutual exclusion with --password/--account is + * enforced in the Action layer (BaseAction.assertWalletFlags), not commander, + * so it stays testable and reusable. + */ +export const WALLET_OPTION_FLAG = "--wallet "; +export const WALLET_OPTION_DESC = + "Signing mode: 'keystore' or 'browser' (sign in MetaMask via a local bridge; " + + "forward the port for remote/SSH: ssh -L :127.0.0.1:). " + + "Defaults to the 'walletMode' config value, else 'keystore'."; + +/** + * No commander default: with one, "flag omitted" is indistinguishable from an + * explicit "--wallet keystore", which would break the config-default override. + * The default is resolved in BaseAction.resolveWalletMode (config > keystore). + */ +export function addWalletModeOption(cmd: Command): Command { + return cmd.option(WALLET_OPTION_FLAG, WALLET_OPTION_DESC); +} diff --git a/support/ci/ACTIVE_DEV_BRANCH b/support/ci/ACTIVE_DEV_BRANCH new file mode 100644 index 00000000..e62b61ef --- /dev/null +++ b/support/ci/ACTIVE_DEV_BRANCH @@ -0,0 +1 @@ +v0.40-dev diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts new file mode 100644 index 00000000..25a7c15c --- /dev/null +++ b/tests/actions/balances.test.ts @@ -0,0 +1,354 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import {createClient} from "genlayer-js"; +import {testnetBradbury} from "genlayer-js/chains"; +import {BalancesAction} from "../../src/commands/balances/BalancesAction"; + +// Keep genlayer-js real except createClient (no network I/O). The read-only +// vesting client is stubbed per-test, so createClient is never actually hit. +vi.mock("genlayer-js", async importOriginal => { + const actual = await importOriginal(); + return {...actual, createClient: vi.fn()}; +}); + +const WEI = 10n ** 18n; + +// Only the fields BalancesAction reads matter; the client is mocked so the +// shape isn't type-checked at runtime. +function makeState(overrides: Record = {}) { + return { + name: "Team grant", + totalAmountRaw: 100n * WEI, + vestedAmountRaw: 20n * WEI, + unvestedAmountRaw: 80n * WEI, + withdrawableAmountRaw: 18n * WEI, + totalWithdrawnRaw: 2n * WEI, + revoked: false, + ...overrides, + }; +} + +function makeClient(overrides: Record = {}) { + return { + getBalance: vi.fn().mockResolvedValue(7n * WEI), + // Default: consensus infra IS deployed (non-empty bytecode) so the vesting + // + staking sections run. The no-infra case (studio) mocks this to "0x". + getCode: vi.fn().mockResolvedValue("0x6001"), + getBeneficiaryVestings: vi.fn().mockResolvedValue([]), + getVestingState: vi.fn(), + getValidatorWallets: vi.fn().mockResolvedValue([]), + validatorDeposited: vi.fn().mockResolvedValue(0n), + getActiveValidators: vi.fn().mockResolvedValue([]), + getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), + getBannedValidators: vi.fn().mockResolvedValue([]), + vestingDepositedPerValidator: vi.fn().mockResolvedValue(0n), + ...overrides, + }; +} + +describe("BalancesAction", () => { + let tempHome: string; + let action: BalancesAction; + let failSpy: any; + let renderSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + // Own hermetic home so real config/keystore reads stay isolated per test. + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "gl-cli-balances-")); + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + vi.mocked(createClient).mockReturnValue({} as any); + + action = new BalancesAction(); + + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action as any, "stopSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + failSpy = vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + // Capture the composed summary instead of asserting brittle console output. + renderSpy = vi.spyOn(action as any, "renderSummary").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHome, {recursive: true, force: true}); + }); + + function stub(client: any) { + vi.spyOn(action as any, "getReadOnlyVestingClient").mockResolvedValue(client); + } + + test("(a) address with no vesting contracts → wallet-only summary", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xBen"); + expect(summary.walletBalanceRaw).toBe(7n * WEI); + expect(summary.vestings).toEqual([]); + // No vesting → never touches vesting state / validator enumeration. + expect(client.getVestingState).not.toHaveBeenCalled(); + expect(client.getActiveValidators).not.toHaveBeenCalled(); + }); + + test("(a') consensus deployed but no staking contract (localnet): vesting shown, validator scan skipped", async () => { + // localnet has consensus (vesting factory) deployed but no staking + // contract. balances must still enumerate vestings and compute self-stake, + // but skip the validator scan (delegated principal 0) rather than fail. + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState()), + getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), + validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake still computed + // No staking contract ⇒ the validator reads must never be called. + getActiveValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")), + getQuarantinedValidatorsDetailed: vi + .fn() + .mockRejectedValue(new Error("Staking is not supported on studio-based networks")), + getBannedValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")), + getBalance: vi.fn(async ({address}: {address: string}) => (address === "0xV1" ? 30n * WEI : 7n * WEI)), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({network: "localnet"}); + + expect(failSpy).not.toHaveBeenCalled(); + expect(client.getActiveValidators).not.toHaveBeenCalled(); + expect(client.getQuarantinedValidatorsDetailed).not.toHaveBeenCalled(); + expect(client.getBannedValidators).not.toHaveBeenCalled(); + expect(client.vestingDepositedPerValidator).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.consensusAvailable).toBe(true); + expect(summary.walletBalanceRaw).toBe(7n * WEI); + expect(summary.vestings).toHaveLength(1); + const v = summary.vestings[0]; + expect(v.selfStakeRaw).toBe(5n * WEI); // self-stake from vesting reads, still shown + expect(v.delegatedRaw).toBe(0n); // no validator set ⇒ no delegated principal + expect(v.committedRaw).toBe(5n * WEI); + expect(v.availableToStakeRaw).toBe(30n * WEI); + }); + + test("(a'') no consensus infra deployed (studio RPC): wallet-only, no vesting/staking reads, never fails", async () => { + // The vesting-factory lookup resolves through ConsensusMain, which isn't + // deployed on a studio RPC (getCode ⇒ "0x"). balances must skip the whole + // consensus-dependent section and still report the wallet balance, instead + // of crashing on a garbage contract read (the real 090-02 failure). + const client = makeClient({ + getCode: vi.fn().mockResolvedValue("0x"), // ConsensusMain not deployed + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xShouldNotBeRead"]), + getBalance: vi.fn().mockResolvedValue(7n * WEI), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({network: "studionet"}); + + expect(failSpy).not.toHaveBeenCalled(); + // The consensus-dependent reads must never run. + expect(client.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(client.getActiveValidators).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.consensusAvailable).toBe(false); + expect(summary.walletBalanceRaw).toBe(7n * WEI); + expect(summary.vestings).toEqual([]); + }); + + test("(b) one vesting: committed principal computed; available is the contract balance", async () => { + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState()), + getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), + validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake principal 5 + getActiveValidators: vi.fn().mockResolvedValue(["0xVal1"]), + vestingDepositedPerValidator: vi.fn().mockResolvedValue(4n * WEI), // delegated principal 4 + // Wallet reads 7; the vesting contract's live on-chain balance is 30. + getBalance: vi.fn(async ({address}: {address: string}) => (address === "0xV1" ? 30n * WEI : 7n * WEI)), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + // testnet-bradbury carries a staking contract, so the validator-set scan + // runs (localnet/studionet have none — see the studio-degradation test). + await action.execute({network: "testnet-bradbury"}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.vestings).toHaveLength(1); + const v = summary.vestings[0]; + expect(v.selfStakeRaw).toBe(5n * WEI); + expect(v.delegatedRaw).toBe(4n * WEI); + expect(v.committedRaw).toBe(9n * WEI); + // available = the vesting contract's live balance, NOT vested−withdrawn−committed. + expect(v.availableToStakeRaw).toBe(30n * WEI); + expect(v.revoked).toBe(false); + // Delegated principal getter takes (vesting, validator); self takes (vesting, wallet). + expect(client.vestingDepositedPerValidator).toHaveBeenCalledWith("0xV1", "0xVal1"); + expect(client.validatorDeposited).toHaveBeenCalledWith("0xV1", "0xW1"); + expect(client.getBalance).toHaveBeenCalledWith({address: "0xV1"}); + }); + + test("(b') revoked vesting → available is 0 even though the contract still holds a balance", async () => { + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState({revoked: true})), + getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), + validatorDeposited: vi.fn().mockResolvedValue(10n * WEI), // still-committed principal + getActiveValidators: vi.fn().mockResolvedValue([]), + // Non-zero balance, but staking is disabled post-revoke ⇒ available must be 0. + getBalance: vi.fn().mockResolvedValue(50n * WEI), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + const v = renderSpy.mock.calls[0][0].vestings[0]; + expect(v.revoked).toBe(true); + expect(v.committedRaw).toBe(10n * WEI); // committed breakdown still shown + expect(v.availableToStakeRaw).toBe(0n); // revoked ⇒ 0, not the 50 balance + }); + + test("(c) multiple vesting contracts each summarized; validator set fetched once", async () => { + const stateA = makeState({name: "A"}); + const stateB = makeState({name: "B"}); + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xVA", "0xVB"]), + getVestingState: vi.fn().mockImplementation((addr: string) => (addr === "0xVA" ? stateA : stateB)), + getValidatorWallets: vi.fn().mockResolvedValue([]), + getActiveValidators: vi.fn().mockResolvedValue([]), + // Each contract's available-to-stake is its own live on-chain balance. + getBalance: vi.fn(async ({address}: {address: string}) => + (({"0xVA": 20n * WEI, "0xVB": 45n * WEI}) as Record)[address] ?? 7n * WEI, + ), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({network: "testnet-bradbury"}); + + const summary = renderSpy.mock.calls[0][0]; + expect(summary.vestings).toHaveLength(2); + expect(summary.vestings[0].name).toBe("A"); + expect(summary.vestings[0].availableToStakeRaw).toBe(20n * WEI); // balance of 0xVA + expect(summary.vestings[1].name).toBe("B"); + expect(summary.vestings[1].availableToStakeRaw).toBe(45n * WEI); // balance of 0xVB + // Active validator set is global: fetched once and reused across vestings. + expect(client.getActiveValidators).toHaveBeenCalledTimes(1); + }); + + test("(c') committed-delegation scan unions active + quarantined + banned validators", async () => { + // A vesting can hold committed principal against validators that left the + // active set. The scan must union all three lists (de-duped) so committed — + // and hence available-to-stake — is not under-counted. + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState()), + getValidatorWallets: vi.fn().mockResolvedValue([]), + getActiveValidators: vi.fn().mockResolvedValue(["0xActive"]), + getQuarantinedValidatorsDetailed: vi + .fn() + .mockResolvedValue([{validator: "0xQuar", untilEpoch: 5n, permanentlyBanned: false}]), + getBannedValidators: vi + .fn() + // "0xActive" also appears here to prove de-duplication (case-insensitive). + .mockResolvedValue([ + {validator: "0xBanned", untilEpoch: 9n, permanentlyBanned: true}, + {validator: "0xactive", untilEpoch: 0n, permanentlyBanned: false}, + ]), + // 1 GEN committed against every scanned validator. + vestingDepositedPerValidator: vi.fn().mockResolvedValue(1n * WEI), + getBalance: vi.fn().mockResolvedValue(7n * WEI), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({network: "testnet-bradbury"}); + + expect(failSpy).not.toHaveBeenCalled(); + // Active + quarantined + banned, with the duplicate "0xActive"/"0xactive" + // collapsed → 3 distinct validators scanned for delegated principal. + const scanned = client.vestingDepositedPerValidator.mock.calls.map((c: any[]) => c[1].toLowerCase()); + expect(new Set(scanned)).toEqual(new Set(["0xactive", "0xquar", "0xbanned"])); + const v = renderSpy.mock.calls[0][0].vestings[0]; + expect(v.delegatedRaw).toBe(3n * WEI); // 3 distinct validators × 1 GEN + }); + + test("(d) custom active network shows alias + chainId, not the base chain name", async () => { + action.writeConfig("customNetworks", { + myclarke: {base: "testnet-bradbury", overrides: {chainId: 4221, rpcUrl: "http://localhost:9999"}}, + }); + action.writeConfig("network", "myclarke"); + + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.network).toBe("myclarke"); + expect(summary.chainId).toBe(4221); + // The naive bug would print chain.name, which for a custom net is its base's name. + expect(summary.network).not.toBe(testnetBradbury.name); + }); + + test("(e) --beneficiary override needs no account (getSignerAddress not called)", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + // If the code fell back to the keystore it would reject here. + const signerSpy = vi + .spyOn(action as any, "getSignerAddress") + .mockRejectedValue(new Error("Account 'default' not found.")); + + await action.execute({beneficiary: "0xExplicit"}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xExplicit"); + expect(client.getBeneficiaryVestings).toHaveBeenCalledWith("0xExplicit", undefined); + expect(client.getBalance).toHaveBeenCalledWith({address: "0xExplicit"}); + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("(f) live browser session is the active identity (wins over the keystore default)", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + // A session is live and no keystore opt-out, so resolveWalletMode → browser. + vi.spyOn(action as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xSession"); + expect(client.getBeneficiaryVestings).toHaveBeenCalledWith("0xSession", undefined); + expect(sessionSpy).toHaveBeenCalled(); + // The keystore default must not be consulted once a live session resolves. + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("(g) explicit --account overrides a live session", async () => { + const client = makeClient({getBeneficiaryVestings: vi.fn().mockResolvedValue([])}); + stub(client); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.execute({account: "clarke"}); + + const summary = renderSpy.mock.calls[0][0]; + expect(summary.address).toBe("0xKeystore"); + // --account short-circuits before the session is ever consulted. + expect(sessionSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/actions/customNetworkProfiles.test.ts b/tests/actions/customNetworkProfiles.test.ts new file mode 100644 index 00000000..be3189a5 --- /dev/null +++ b/tests/actions/customNetworkProfiles.test.ts @@ -0,0 +1,342 @@ +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 stores and applies an --explorer override", async () => { + await action.addNetwork("bradbury-explorer", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + explorer: "https://explorer.custom.example/", + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-explorer"].overrides.explorer).toBe( + "https://explorer.custom.example/", + ); + const chain = resolveNetwork("bradbury-explorer", readConfig().customNetworks); + expect(chain.blockExplorers?.default?.url).toBe("https://explorer.custom.example/"); + }); + + test("custom network does NOT inherit the base block explorer when --explorer is omitted", async () => { + // Guard the premise: the base chain does carry an explorer. + expect(testnetBradbury.blockExplorers?.default?.url).toBeTruthy(); + + await action.addNetwork("bradbury-no-explorer", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + }); + + expect(failSpy).not.toHaveBeenCalled(); + const chain = resolveNetwork("bradbury-no-explorer", readConfig().customNetworks); + // The misleading base explorer must NOT be inherited. + expect(chain.blockExplorers).toBeUndefined(); + }); + + test("network add rejects an invalid --explorer URL", async () => { + await action.addNetwork("bradbury-bad-explorer", { + base: "testnet-bradbury", + explorer: "explorer.custom.example", + }); + + expect(failSpy).toHaveBeenCalledWith( + "Failed to add custom network profile", + expect.stringContaining("Invalid --explorer URL"), + ); + }); + + 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, --explorer, 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); + // Display name is the alias the user chose, not the base chain's name. + expect(resolvedChain.name).toBe("bradbury-clarke"); + expect(resolvedChain.name).not.toBe(baseChain.name); + }); + + test("StakingAction.getNetwork accepts a custom alias", () => { + const stakingAction = new StakingAction(); + (vi.spyOn(stakingAction as any, "getConfigByKey") as any).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 00c26034..d3ebb609 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -1,7 +1,8 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; import fs from "fs"; import os from "os"; -import {createClient, createAccount} from "genlayer-js"; +import path from "path"; +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 +20,7 @@ describe("DeployAction", () => { deployContract: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; @@ -27,11 +29,24 @@ describe("DeployAction", () => { vi.clearAllMocks(); // Setup mocks before creating the action (needed for constructor) vi.mocked(os.homedir).mockReturnValue("/mocked/home"); + vi.mocked(os.tmpdir).mockReturnValue("/mocked/tmp"); + vi.mocked(fs.mkdtempSync).mockReturnValue("/mocked/tmp/genlayer-deploy-abc"); vi.mocked(fs.existsSync).mockReturnValue(true); vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify({activeAccount: "default"})); vi.mocked(createClient).mockReturnValue(mockClient as any); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); + 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); + return ( + (statusName === "ACCEPTED" || statusName === "FINALIZED") && + executionResultName === "FINISHED_WITH_RETURN" + ); + }); deployer = new DeployAction(); vi.spyOn(deployer as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); vi.spyOn(deployer as any, "getConfig").mockReturnValue({}); @@ -81,6 +96,8 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -92,6 +109,13 @@ describe("DeployAction", () => { args: [1, 2, 3], leaderOnly: false, }); + expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: "mocked_tx_hash", + retries: 50, + interval: 5000, + waitUntil: "decided", + fullTransaction: true, + }); expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); }); @@ -104,11 +128,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", @@ -119,6 +145,8 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -136,6 +164,7 @@ describe("DeployAction", () => { messageAllocations: [{ messageType: 1, recipient: "0x0000000000000000000000000000000000000001", + callKey: DEPLOY_CALL_KEY, budget: "5", }], feeValue: "123", @@ -144,6 +173,202 @@ 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", + args: [1, 2, 3], + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_ERROR", + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("leader execution result: FINISHED_WITH_ERROR"), + }), + ); + }); + + test("fails when deployment is undetermined despite leader return", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1, 2, 3], + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "UNDETERMINED", + txExecutionResultName: "FINISHED_WITH_RETURN", + }); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("UNDETERMINED"), + }), + ); + }); + + test("diagnoses leader execution timeout", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 3, + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("leader timed out during execution"), + }), + ); + }); + + test("diagnoses non-deterministic disagreement", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 4, + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("validators disagreed on non-deterministic output"), + }), + ); + }); + + test("fails when deployment is canceled", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "CANCELED", + txExecutionResultName: "NOT_VOTED", + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("CANCELED before execution"), + }), + ); + }); + + test("accepts studio-shaped successful receipt", async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + data: { + contract_address: "0xdasdsadasdasdada", + consensus_data: { + leader_receipt: [{execution_result: "SUCCESS"}], + }, + }, + }); + + await deployer.deploy({contract: "/mocked/contract/path"}); + + expect(deployer["succeedSpinner"]).toHaveBeenCalledWith( + "Contract deployed successfully.", + expect.objectContaining({"Consensus Status": "ACCEPTED"}), + ); + }); + test("throws error for missing contract", async () => { const options: DeployOptions = {}; @@ -205,7 +430,7 @@ describe("DeployAction", () => { test("executeTsScript transpiles and executes TypeScript", async () => { const filePath = "/mocked/script.ts"; - const outFile = "/mocked/script.compiled.js"; + const outFile = path.join("/mocked/tmp/genlayer-deploy-abc", "script.compiled.js"); vi.spyOn(deployer as any, "executeJsScript").mockResolvedValue(undefined); vi.mocked(buildSync).mockImplementation((() => {}) as any); @@ -224,7 +449,12 @@ describe("DeployAction", () => { }); expect(deployer["executeJsScript"]).toHaveBeenCalledWith(filePath, outFile, undefined); - expect(fs.unlinkSync).toHaveBeenCalledWith(outFile); + expect(fs.mkdtempSync).toHaveBeenCalledWith(path.join("/mocked/tmp", "genlayer-deploy-")); + expect(fs.rmSync).toHaveBeenCalledWith("/mocked/tmp/genlayer-deploy-abc", { + recursive: true, + force: true, + }); + expect(fs.unlinkSync).not.toHaveBeenCalled(); }); test("deployScripts fails when deploy folder is missing", async () => { @@ -373,6 +603,10 @@ describe("DeployAction", () => { await deployer["executeTsScript"](filePath); expect(deployer["failSpinner"]).toHaveBeenCalledWith(`Error executing: ${filePath}`, error); + expect(fs.rmSync).toHaveBeenCalledWith("/mocked/tmp/genlayer-deploy-abc", { + recursive: true, + force: true, + }); }); test("deploys contract with rpc option", async () => { @@ -387,6 +621,8 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -425,7 +661,7 @@ describe("DeployAction", () => { test("executeTsScript passes rpc url to executeJsScript", async () => { const filePath = "/mocked/script.ts"; - const outFile = "/mocked/script.compiled.js"; + const outFile = path.join("/mocked/tmp/genlayer-deploy-abc", "script.compiled.js"); const rpcUrl = "https://custom-rpc-url.com"; vi.spyOn(deployer as any, "executeJsScript").mockResolvedValue(undefined); @@ -445,7 +681,10 @@ describe("DeployAction", () => { }); expect(deployer["executeJsScript"]).toHaveBeenCalledWith(filePath, outFile, rpcUrl); - expect(fs.unlinkSync).toHaveBeenCalledWith(outFile); + expect(fs.rmSync).toHaveBeenCalledWith("/mocked/tmp/genlayer-deploy-abc", { + recursive: true, + force: true, + }); }); test("deployScripts passes rpc url to script execution methods", async () => { @@ -466,4 +705,46 @@ describe("DeployAction", () => { rpcUrl, ); }); + + describe("DeployAction --wallet browser", () => { + test("wires the browser provider into the client and never touches the keystore", async () => { + const session = { + signerAddress: "0xBrowser", + eip1193Provider: {request: vi.fn()}, + setNextLabel: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; + // Lane B: getClient (BaseAction) builds the client itself. Stub the browser + // session opener so the real getClient runs, then assert the wiring. + const getBrowserSessionSpy = vi + .spyOn(deployer as any, "getBrowserSession") + .mockResolvedValue(session); + const getAccountSpy = vi.spyOn(deployer as any, "getAccount"); + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue("contract code"); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", + data: {contract_address: "0xdeployed"}, + }); + + await deployer.deploy({contract: "/x.py", args: [], wallet: "browser"}); + + expect(getBrowserSessionSpy).toHaveBeenCalled(); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + account: "0xBrowser", + provider: session.eip1193Provider, + }), + ); + // No keystore/keychain/password path in browser mode. + expect(getAccountSpy).not.toHaveBeenCalled(); + expect(deployer["succeedSpinner"]).toHaveBeenCalledWith( + "Contract deployed successfully.", + expect.objectContaining({"Consensus Status": "ACCEPTED"}), + ); + }); + }); }); diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts index c9634b42..605f4ef4 100644 --- a/tests/actions/estimateFees.test.ts +++ b/tests/actions/estimateFees.test.ts @@ -1,5 +1,8 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {createClient, createAccount} from "genlayer-js"; +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"; vi.mock("genlayer-js"); @@ -16,10 +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}`, + ); action = new EstimateFeesAction(); vi.spyOn(action as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); @@ -61,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]}, @@ -74,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 () => { @@ -96,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"], @@ -126,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/hasLiveWalletSession.test.ts b/tests/actions/hasLiveWalletSession.test.ts new file mode 100644 index 00000000..540fde9e --- /dev/null +++ b/tests/actions/hasLiveWalletSession.test.ts @@ -0,0 +1,55 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; + +// Control the descriptor/pid primitives the helper delegates to. +vi.mock("../../src/lib/wallet/sessionDescriptor", () => ({ + descriptorPath: vi.fn(() => "/tmp/wallet-session.json"), + readDescriptor: vi.fn(), + isPidAlive: vi.fn(), +})); + +import {BaseAction} from "../../src/lib/actions/BaseAction"; +import {readDescriptor, isPidAlive} from "../../src/lib/wallet/sessionDescriptor"; + +class TestAction extends BaseAction { + publicHasLiveSession() { + return (this as any).hasLiveWalletSession(); + } +} + +describe("BaseAction.hasLiveWalletSession", () => { + let action: TestAction; + + beforeEach(() => { + action = new TestAction(); + vi.mocked(readDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + }); + afterEach(() => vi.restoreAllMocks()); + + test("no descriptor → false (pid never checked)", () => { + vi.mocked(readDescriptor).mockReturnValue(null); + expect(action.publicHasLiveSession()).toBe(false); + expect(isPidAlive).not.toHaveBeenCalled(); + }); + + test("descriptor present + pid alive → true", () => { + vi.mocked(readDescriptor).mockReturnValue({pid: 4242} as any); + vi.mocked(isPidAlive).mockReturnValue(true); + expect(action.publicHasLiveSession()).toBe(true); + expect(isPidAlive).toHaveBeenCalledWith(4242); + }); + + test("descriptor present but pid dead → false", () => { + vi.mocked(readDescriptor).mockReturnValue({pid: 4242} as any); + vi.mocked(isPidAlive).mockReturnValue(false); + expect(action.publicHasLiveSession()).toBe(false); + }); + + test("a throwing descriptor read reads as no session (never throws)", () => { + vi.mocked(readDescriptor).mockImplementation(() => { + throw new Error("locked file"); + }); + expect(() => action.publicHasLiveSession()).not.toThrow(); + expect(action.publicHasLiveSession()).toBe(false); + }); +}); diff --git a/tests/actions/resolveActiveIdentity.test.ts b/tests/actions/resolveActiveIdentity.test.ts new file mode 100644 index 00000000..01be3b73 --- /dev/null +++ b/tests/actions/resolveActiveIdentity.test.ts @@ -0,0 +1,95 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {BaseAction} from "../../src/lib/actions/BaseAction"; + +/** + * Direct coverage of the shared connect-once identity resolver every read + * command routes through. A tiny concrete subclass exposes the protected + * seam; getSignerAddress / liveSessionAddress / resolveWalletMode are the three + * collaborators, stubbed per case to exercise each precedence rung. + */ +class TestAction extends BaseAction { + run(options: {account?: string}, explicit?: string) { + return this.resolveActiveIdentity(options, explicit); + } +} + +describe("BaseAction.resolveActiveIdentity", () => { + let action: TestAction; + let signerSpy: any; + let sessionSpy: any; + let modeSpy: any; + + beforeEach(() => { + action = new TestAction(); + signerSpy = vi.spyOn(action as any, "getSignerAddress"); + sessionSpy = vi.spyOn(action as any, "liveSessionAddress"); + modeSpy = vi.spyOn(action as any, "resolveWalletMode"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("1. explicit address wins over everything (no wallet consulted)", async () => { + signerSpy.mockResolvedValue("0xKeystore"); + sessionSpy.mockResolvedValue("0xSession"); + modeSpy.mockReturnValue("browser"); + + await expect(action.run({account: "acct"}, "0xExplicit")).resolves.toBe("0xExplicit"); + expect(signerSpy).not.toHaveBeenCalled(); + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("2. --account selects the keystore, short-circuiting a live session", async () => { + signerSpy.mockResolvedValue("0xKeystore"); + sessionSpy.mockResolvedValue("0xSession"); + modeSpy.mockReturnValue("browser"); + + await expect(action.run({account: "acct"})).resolves.toBe("0xKeystore"); + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("3. live browser session is the active identity over the keystore default", async () => { + modeSpy.mockReturnValue("browser"); + sessionSpy.mockResolvedValue("0xSession"); + signerSpy.mockResolvedValue("0xKeystore"); + + await expect(action.run({})).resolves.toBe("0xSession"); + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("4. no session → falls back to the keystore address", async () => { + modeSpy.mockReturnValue("keystore"); + sessionSpy.mockResolvedValue(null); + signerSpy.mockResolvedValue("0xKeystore"); + + await expect(action.run({})).resolves.toBe("0xKeystore"); + }); + + test("4b. browser mode but no connected session → keystore fallback", async () => { + modeSpy.mockReturnValue("browser"); + sessionSpy.mockResolvedValue(null); + signerSpy.mockResolvedValue("0xKeystore"); + + await expect(action.run({})).resolves.toBe("0xKeystore"); + expect(sessionSpy).toHaveBeenCalledTimes(1); + }); + + test("5. no keystore but a live session → last-resort session address", async () => { + modeSpy.mockReturnValue("keystore"); + // First rung (browser) skipped; the keystore read throws; then the + // last-resort session lookup succeeds. + signerSpy.mockRejectedValue(new Error("Account 'default' not found.")); + sessionSpy.mockResolvedValue("0xSession"); + + await expect(action.run({})).resolves.toBe("0xSession"); + }); + + test("6. neither keystore nor session → throws a helpful error", async () => { + modeSpy.mockReturnValue("keystore"); + signerSpy.mockRejectedValue(new Error("Account 'default' not found.")); + sessionSpy.mockResolvedValue(null); + + await expect(action.run({})).rejects.toThrow(/No address to inspect/); + }); +}); diff --git a/tests/actions/show.test.ts b/tests/actions/show.test.ts new file mode 100644 index 00000000..48f5b74c --- /dev/null +++ b/tests/actions/show.test.ts @@ -0,0 +1,94 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import {createClient} from "genlayer-js"; +import {testnetBradbury} from "genlayer-js/chains"; +import {ShowAccountAction} from "../../src/commands/account/show"; + +// Keep genlayer-js real except createClient (no network I/O in the balance query). +vi.mock("genlayer-js", async importOriginal => { + const actual = await importOriginal(); + return {...actual, createClient: vi.fn()}; +}); + +describe("ShowAccountAction network label", () => { + let tempHome: string; + let action: ShowAccountAction; + let succeedSpy: any; + let failSpy: any; + + const address = "0x1234567890123456789012345678901234567890"; + const keystoreJson = JSON.stringify({ + address, + crypto: { + cipher: "aes-128-ctr", + ciphertext: "x", + cipherparams: {iv: "x"}, + kdf: "scrypt", + kdfparams: {}, + mac: "x", + }, + version: 3, + }); + const mockClient = {getBalance: vi.fn()}; + + beforeEach(() => { + vi.clearAllMocks(); + // Own hermetic home so real config/keystore reads stay isolated. + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "gl-cli-show-")); + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + + mockClient.getBalance.mockResolvedValue(0n); + vi.mocked(createClient).mockReturnValue(mockClient as any); + + action = new ShowAccountAction(); + fs.writeFileSync(action.getKeystorePath("default"), keystoreJson); + + succeedSpy = vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + failSpy = vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn((action as any).keychainManager, "isAccountUnlocked").mockResolvedValue(false); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHome, {recursive: true, force: true}); + }); + + test("custom active network shows its alias and real chainId, not the base chain name", async () => { + action.writeConfig("customNetworks", { + myclarke: {base: "testnet-bradbury", overrides: {chainId: 4221, rpcUrl: "http://localhost:9999"}}, + }); + action.writeConfig("network", "myclarke"); + action.setActiveAccount("default"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + const result = succeedSpy.mock.calls[0][1]; + expect(result.network).toBe("myclarke"); + expect(result.chainId).toBe(4221); + // The old bug printed chain.name, which for a custom net is its base's name. + expect(result.network).not.toBe(testnetBradbury.name); + }); + + test("--network overrides the active config network for label, chainId and balance query", async () => { + action.writeConfig("customNetworks", { + myclarke: {base: "testnet-bradbury", overrides: {chainId: 4221}}, + }); + action.writeConfig("network", "localnet"); + action.setActiveAccount("default"); + + await action.execute({network: "myclarke"}); + + expect(failSpy).not.toHaveBeenCalled(); + const result = succeedSpy.mock.calls[0][1]; + expect(result.network).toBe("myclarke"); + expect(result.chainId).toBe(4221); + // The balance query must use the override network, not the config one. + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({chain: expect.objectContaining({id: 4221})}), + ); + }); +}); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 3d314c5c..177b1686 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -6,6 +6,8 @@ import {ValidatorClaimAction} from "../../src/commands/staking/validatorClaim"; import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; +import {SetOperatorAction} from "../../src/commands/staking/setOperator"; +import {SetIdentityAction} from "../../src/commands/staking/setIdentity"; import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; // Mock genlayer-js @@ -15,20 +17,33 @@ vi.mock("genlayer-js", () => ({ formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), parseStakingAmount: vi.fn((val: string) => { if (val.toLowerCase().endsWith("gen") || val.toLowerCase().endsWith("eth")) { - return BigInt(parseFloat(val.slice(0, -3)) * 1e18); + // Scale via bigint so integer GEN amounts are exact (e.g. "42000gen" == + // 42000e18, not 41999.99e18 from float rounding) — the self-stake minimum + // check compares against exactly that boundary. Keeps sub-GEN precision. + const gen = parseFloat(val.slice(0, -3)); + return BigInt(Math.round(gen * 1e9)) * BigInt(1e9); } return BigInt(val); }), abi: { STAKING_ABI: [], + VALIDATOR_WALLET_ABI: [], }, })); 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://studionet.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"]}}}, + 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 = { @@ -58,6 +73,8 @@ const mockClient = { validatorDeposit: vi.fn(), validatorExit: vi.fn(), validatorClaim: vi.fn(), + setOperator: vi.fn(), + setIdentity: vi.fn(), delegatorJoin: vi.fn(), delegatorExit: vi.fn(), delegatorClaim: vi.fn(), @@ -88,6 +105,9 @@ describe("ValidatorJoinAction", () => { action = new ValidatorJoinAction(); setupActionMocks(action); mockClient.validatorJoin.mockResolvedValue(mockValidatorJoinResult); + // Pre-submit self-stake minimum check reads epochInfo. The 42000gen joins + // used here meet the mocked 42000 GEN minimum, so it passes silently. + mockClient.getEpochInfo.mockResolvedValue(mockEpochInfo); }); afterEach(() => { @@ -101,7 +121,10 @@ describe("ValidatorJoinAction", () => { amount: expect.any(BigInt), operator: undefined, }); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator created successfully!", expect.any(Object)); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.any(Object), + ); }); test("joins as validator with operator", async () => { @@ -122,9 +145,227 @@ describe("ValidatorJoinAction", () => { }); }); -// ValidatorDepositAction, ValidatorExitAction, ValidatorClaimAction tests -// are covered by command-level tests. These actions now use viem directly -// to call ValidatorWallet contracts and require complex viem mocking. +// ValidatorDepositAction / ValidatorExitAction: keystore path goes through the +// SDK staking client (client.validatorDeposit / client.validatorExit), matching +// every other staking command. Previously these two used raw viem +// writeContract, which fails on the GenLayer consensus RPC (no EIP-1559 fee +// support) — see fix in validatorDeposit.ts / validatorExit.ts. +describe("ValidatorDepositAction", () => { + let action: ValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorDepositAction(); + setupActionMocks(action); + mockClient.validatorDeposit.mockResolvedValue(mockTxResult); + // Pre-submit checks: mixing hard-guard (wallet owned by the signing EOA) + // and self-stake minimum. Owner matches the mocked signer and vStake is + // already at the minimum, so both pass silently for the default deposits. + mockClient.getEpochInfo.mockResolvedValue(mockEpochInfo); + mockClient.getValidatorInfo.mockResolvedValue({ + address: "0xValidatorWallet", + owner: "0xMockedSigner", + operator: "0xOperator", + vStake: "42000 GEN", + vStakeRaw: 42000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("deposits to validator via the SDK client (not raw viem)", async () => { + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorDeposit).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + amount: expect.any(BigInt), + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Deposit successful!", expect.any(Object)); + }); + + test("handles errors", async () => { + mockClient.validatorDeposit.mockRejectedValue(new Error("deposit failed")); + + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to make deposit", "deposit failed"); + }); +}); + +describe("ValidatorExitAction", () => { + let action: ValidatorExitAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorExitAction(); + setupActionMocks(action); + mockClient.validatorExit.mockResolvedValue(mockTxResult); + mockClient.getEpochInfo.mockResolvedValue(mockEpochInfo); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("exits validator via the SDK client (not raw viem)", async () => { + await action.execute({validator: "0xValidatorWallet", shares: "50", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorExit).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + shares: 50n, + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Exit initiated successfully!", expect.any(Object)); + }); + + test("rejects a non-positive shares value before calling the client", async () => { + await action.execute({validator: "0xValidatorWallet", shares: "0", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorExit).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + 'Invalid shares value: "0". Must be a positive whole number.', + ); + }); + + test("handles errors", async () => { + mockClient.validatorExit.mockRejectedValue(new Error("exit failed")); + + await action.execute({validator: "0xValidatorWallet", shares: "50", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to exit", "exit failed"); + }); +}); + +// SetOperatorAction / ValidatorClaimAction / SetIdentityAction: keystore path +// goes through the SDK staking client (client.setOperator / validatorClaim / +// setIdentity), matching every other staking write. Previously these used raw +// viem writeContract, which fails on the GenLayer consensus RPC (no EIP-1559 +// fee support). getViemClients has been removed entirely, so routing through +// the SDK is the only path. +describe("SetOperatorAction", () => { + let action: SetOperatorAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new SetOperatorAction(); + setupActionMocks(action); + mockClient.setOperator.mockResolvedValue(mockTxResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("sets operator via the SDK client (not raw viem)", async () => { + await action.execute({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + stakingAddress: "0xStaking", + }); + + expect(mockClient.setOperator).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Operator updated!", expect.any(Object)); + }); + + test("handles errors", async () => { + mockClient.setOperator.mockRejectedValue(new Error("set operator failed")); + + await action.execute({ + validator: "0xValidatorWallet", + operator: "0xNewOperator", + stakingAddress: "0xStaking", + }); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to set operator", "set operator failed"); + }); +}); + +describe("ValidatorClaimAction", () => { + let action: ValidatorClaimAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorClaimAction(); + setupActionMocks(action); + mockClient.validatorClaim.mockResolvedValue({...mockTxResult, claimedAmount: 5n * BigInt(1e18)}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("claims via the SDK client (not raw viem) and surfaces claimedAmount", async () => { + await action.execute({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorClaim).toHaveBeenCalledWith({validator: "0xValidatorWallet"}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Claim successful!", + expect.objectContaining({claimedAmount: expect.any(String)}), + ); + }); + + test("handles errors", async () => { + mockClient.validatorClaim.mockRejectedValue(new Error("claim failed")); + + await action.execute({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to claim", "claim failed"); + }); +}); + +describe("SetIdentityAction", () => { + let action: SetIdentityAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new SetIdentityAction(); + setupActionMocks(action); + mockClient.setIdentity.mockResolvedValue(mockTxResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("sets identity via the SDK client (SDK owns extraCid encoding)", async () => { + await action.execute({ + validator: "0xValidatorWallet", + moniker: "MyValidator", + website: "https://example.com", + extraCid: "ipfs://cid", + stakingAddress: "0xStaking", + }); + + expect(mockClient.setIdentity).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + moniker: "MyValidator", + logoUri: undefined, + website: "https://example.com", + description: undefined, + email: undefined, + twitter: undefined, + telegram: undefined, + github: undefined, + extraCid: "ipfs://cid", + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator identity set!", expect.any(Object)); + }); + + test("handles errors", async () => { + mockClient.setIdentity.mockRejectedValue(new Error("set identity failed")); + + await action.execute({validator: "0xValidatorWallet", moniker: "MyValidator", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to set identity", "set identity failed"); + }); +}); describe("DelegatorJoinAction", () => { let action: DelegatorJoinAction; @@ -143,7 +384,10 @@ describe("DelegatorJoinAction", () => { validator: "0xValidator", amount: expect.any(BigInt), }); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Successfully joined as delegator!", expect.any(Object)); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Successfully joined as delegator!", + expect.any(Object), + ); }); }); @@ -179,7 +423,10 @@ describe("DelegatorClaimAction", () => { test("claims successfully", async () => { await action.execute({validator: "0xValidator", delegator: "0xDelegator", stakingAddress: "0xStaking"}); - expect(mockClient.delegatorClaim).toHaveBeenCalledWith({validator: "0xValidator", delegator: "0xDelegator"}); + expect(mockClient.delegatorClaim).toHaveBeenCalledWith({ + validator: "0xValidator", + delegator: "0xDelegator", + }); expect(action["succeedSpinner"]).toHaveBeenCalledWith("Claim successful!", expect.any(Object)); }); }); @@ -250,7 +497,9 @@ describe("StakingInfoAction", () => { await action.getValidatorInfo({validator: "0xValidator", stakingAddress: "0xStaking"}); expect(mockClient.isValidator).toHaveBeenCalledWith("0xValidator"); - expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved", expect.any(Object)); + // The clean grouped view prints via console.log; the success line no longer + // carries the raw result object (that lives behind --json now). + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved"); }); test("fails if not a validator", async () => { @@ -261,6 +510,53 @@ describe("StakingInfoAction", () => { expect(action["failSpinner"]).toHaveBeenCalledWith("Address 0xNotValidator is not a validator"); }); + test("validator-info honors a live wallet session over the keystore default", async () => { + mockClient.isValidator.mockResolvedValue(false); + // A session is live and no keystore opt-out → resolveWalletMode → browser. + vi.spyOn(action as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.getValidatorInfo({stakingAddress: "0xStaking"}); + + // The connected session address, not the keystore default, is queried. + expect(mockClient.isValidator).toHaveBeenCalledWith("0xSession"); + expect(sessionSpy).toHaveBeenCalled(); + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("validator-info: explicit [validator] overrides a live session", async () => { + mockClient.isValidator.mockResolvedValue(false); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + + await action.getValidatorInfo({validator: "0xExplicit", stakingAddress: "0xStaking"}); + + expect(mockClient.isValidator).toHaveBeenCalledWith("0xExplicit"); + // Explicit override short-circuits before the session is ever consulted. + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("delegation-info honors a live wallet session over the keystore default", async () => { + mockClient.getStakeInfo.mockResolvedValue({ + delegator: "0xSession", + validator: "0xValidator", + shares: 0n, + stake: "0 GEN", + stakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }); + vi.spyOn(action as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.getStakeInfo({validator: "0xValidator", stakingAddress: "0xStaking"}); + + expect(mockClient.getStakeInfo).toHaveBeenCalledWith("0xSession", "0xValidator"); + expect(sessionSpy).toHaveBeenCalled(); + expect(signerSpy).not.toHaveBeenCalled(); + }); + test("gets epoch info", async () => { await action.getEpochInfo({stakingAddress: "0xStaking"}); @@ -278,3 +574,553 @@ describe("StakingInfoAction", () => { }); }); }); + +describe("ValidatorJoinAction --wallet browser", () => { + let action: ValidatorJoinAction; + const mockBrowserJoinResult = { + transactionHash: "0xBrowserHash", + blockNumber: 456n, + gasUsed: 30000n, + validatorWallet: "0xValidatorWalletFromEvent", + operator: "0xBrowserOwner", + amount: "42000 GEN", + }; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorJoinAction(); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser SDK client and never touches the keystore", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + // The command's finally calls session.close() (no-op for remote daemon + // sessions, full close for an own bridge) — not session.bridge.close(). + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + // Browser writes run the SAME SDK call as the keystore lane; the client is + // built by getBrowserStakingClient (synchronous — Address account + provider). + const mockBrowserClient = { + validatorJoin: vi.fn().mockResolvedValue(mockBrowserJoinResult), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), + }; + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockBrowserClient); + + await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + + expect((action as any).getBrowserWalletSession).toHaveBeenCalledWith( + expect.any(Object), + "validator-join", + ); + expect(mockBrowserClient.validatorJoin).toHaveBeenCalledWith({ + amount: expect.any(BigInt), + operator: undefined, + }); + expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Join as validator")); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + + // Output shape matches the keystore path. + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({ + transactionHash: "0xBrowserHash", + validatorWallet: "0xValidatorWalletFromEvent", + operator: "0xBrowserOwner", + blockNumber: "456", + gasUsed: "30000", + }), + ); + }); + + test("closes the session even when the send fails", async () => { + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue({ + validatorJoin: vi.fn().mockRejectedValue(new Error("Transaction rejected in wallet")), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), + }); + + await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to create validator", + "Transaction rejected in wallet", + ); + expect(session.close).toHaveBeenCalledOnce(); + }); + + test("rejects --wallet browser combined with --password", async () => { + await action.execute({amount: "42000gen", wallet: "browser", password: "hunter2"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to create validator", + "--password cannot be used with --wallet browser", + ); + }); + + test("rejects --wallet browser combined with --account", async () => { + await action.execute({amount: "42000gen", wallet: "browser", account: "owner"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to create validator", + "--account selects a keystore; not applicable with --wallet browser", + ); + }); +}); + +// Shared factory for a staking browser-wallet session. Writes now run through +// the genlayer-js SDK client (getBrowserStakingClient) which routes +// eth_sendTransaction through `eip1193Provider`; `setNextLabel` sets the +// human-readable bridge label. These commands call `session.close()` (not +// session.bridge.close()) in their finally block. +function makeBrowserSession(overrides: Record = {}) { + return { + bridge: {close: vi.fn()}, + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + setNextLabel: vi.fn(), + eip1193Provider: {request: vi.fn()}, + close: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function setupBrowserActionMocks(action: any) { + vi.spyOn(action, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action, "log").mockImplementation(() => {}); +} + +describe("ValidatorDepositAction --wallet browser", () => { + let action: ValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorDepositAction(); + setupBrowserActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser SDK client, skips keystore, closes session", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getReadOnlyStakingClientSpy = vi.spyOn(action as any, "getReadOnlyStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + const mockClient = { + validatorDeposit: vi.fn().mockResolvedValue({transactionHash: "0xBH", blockNumber: 5n, gasUsed: 6n}), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), + // Owner matches the browser session signer (0xBrowserOwner) and vStake is + // at the minimum, so the mixing guard and min check pass silently. + getValidatorInfo: vi.fn().mockResolvedValue({ + address: "0xVW", + owner: "0xBrowserOwner", + operator: "0xOp", + vStake: "42000 GEN", + vStakeRaw: 42000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }), + }; + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockClient); + + await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser"}); + + expect(mockClient.validatorDeposit).toHaveBeenCalledWith({ + validator: "0xVW", + amount: expect.any(BigInt), + }); + expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Deposit")); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getReadOnlyStakingClientSpy).not.toHaveBeenCalled(); + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Deposit successful!", + expect.objectContaining({ + transactionHash: "0xBH", + validator: "0xVW", + amount: expect.any(String), + blockNumber: "5", + gasUsed: "6", + }), + ); + }); + + test("closes the session even when the send fails", async () => { + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue({ + validatorDeposit: vi.fn().mockRejectedValue(new Error("Rejected in wallet")), + getEpochInfo: vi.fn().mockResolvedValue(mockEpochInfo), + getValidatorInfo: vi.fn().mockResolvedValue({ + address: "0xVW", + owner: "0xBrowserOwner", + operator: "0xOp", + vStake: "42000 GEN", + vStakeRaw: 42000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }), + }); + + await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Failed to make deposit", "Rejected in wallet"); + expect(session.close).toHaveBeenCalledOnce(); + }); + + test("rejects --wallet browser combined with --password", async () => { + vi.spyOn(action as any, "getBrowserWalletSession").mockRejectedValue( + new Error("--password cannot be used with --wallet browser"), + ); + + await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser", password: "x"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to make deposit", + expect.stringContaining("--password cannot be used with --wallet browser"), + ); + }); +}); + +describe("SetOperatorAction --wallet browser", () => { + let action: SetOperatorAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new SetOperatorAction(); + setupBrowserActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser SDK client, skips keystore, closes session", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getReadOnlyStakingClientSpy = vi.spyOn(action as any, "getReadOnlyStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + const mockClient = { + setOperator: vi.fn().mockResolvedValue({transactionHash: "0xBH", blockNumber: 5n, gasUsed: 6n}), + }; + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockClient); + + await action.execute({validator: "0xVW", operator: "0xOp", wallet: "browser"}); + + expect(mockClient.setOperator).toHaveBeenCalledWith({validator: "0xVW", operator: "0xOp"}); + expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Set operator to 0xOp")); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getReadOnlyStakingClientSpy).not.toHaveBeenCalled(); + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Operator updated!", + expect.objectContaining({ + transactionHash: "0xBH", + validator: "0xVW", + newOperator: "0xOp", + blockNumber: "5", + gasUsed: "6", + }), + ); + }); +}); + +describe("DelegatorClaimAction --wallet browser", () => { + let action: DelegatorClaimAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new DelegatorClaimAction(); + setupBrowserActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser SDK client, defaults delegator to session signer", async () => { + const getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + const getReadOnlyStakingClientSpy = vi.spyOn(action as any, "getReadOnlyStakingClient"); + const getSignerAddressSpy = vi.spyOn(action as any, "getSignerAddress"); + const session = makeBrowserSession(); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + const mockClient = { + delegatorClaim: vi.fn().mockResolvedValue({transactionHash: "0xBH", blockNumber: 5n, gasUsed: 6n}), + }; + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue(mockClient); + + await action.execute({validator: "0xVal", wallet: "browser"}); + + // Delegator defaults to the connected session signer, never the keystore. + expect(mockClient.delegatorClaim).toHaveBeenCalledWith({ + validator: "0xVal", + delegator: "0xBrowserOwner", + }); + expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Claim delegator withdrawals")); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getReadOnlyStakingClientSpy).not.toHaveBeenCalled(); + // Browser mode reads the connected wallet from the session, never the keystore. + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Claim successful!", + expect.objectContaining({ + transactionHash: "0xBH", + delegator: "0xBrowserOwner", + validator: "0xVal", + blockNumber: "5", + gasUsed: "6", + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Self-stake eligibility gate + liquid/vesting mixing guards + clean +// validator-info view. Minimums are always driven from the mocked epochInfo — +// never a hardcoded 42000 — so the gate tracks the on-chain param. +// --------------------------------------------------------------------------- + +// Min set well above the join/deposit amounts used below so the gate trips. +const highMinEpochInfo = { + ...mockEpochInfo, + currentEpoch: 7n, + validatorMinStake: "50000 GEN", + validatorMinStakeRaw: 50000n * BigInt(1e18), +}; +const epoch0EpochInfo = {...highMinEpochInfo, currentEpoch: 0n}; + +function fullValidatorInfo(overrides: Record = {}) { + return { + address: "0xValidatorWallet", + owner: "0xMockedSigner", + operator: "0xOperator", + vStake: "1000 GEN", + vStakeRaw: 1000n * BigInt(1e18), + vShares: 100n, + dStake: "500 GEN", + dStakeRaw: 500n * BigInt(1e18), + dShares: 50n, + vDeposit: "0 GEN", + vDepositRaw: 0n, + vWithdrawal: "0 GEN", + vWithdrawalRaw: 0n, + ePrimed: 5n, + needsPriming: false, + live: true, + banned: false, + bannedEpoch: null, + pendingDeposits: [], + pendingWithdrawals: [], + identity: null, + ...overrides, + }; +} + +describe("ValidatorJoinAction self-stake minimum gate", () => { + let action: ValidatorJoinAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorJoinAction(); + setupActionMocks(action); + mockClient.validatorJoin.mockResolvedValue(mockValidatorJoinResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("blocks a join below the minimum when --force is not set", async () => { + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + + await action.execute({amount: "42000gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorJoin).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to create validator"); + expect(detail).toContain(highMinEpochInfo.validatorMinStake); + expect(detail).toContain("--force"); + }); + + test("proceeds with --force below the minimum and warns", async () => { + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + const warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + + await action.execute({amount: "42000gen", stakingAddress: "0xStaking", force: true}); + + expect(mockClient.validatorJoin).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(highMinEpochInfo.validatorMinStake)); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); + + test("does not block at epoch 0 even below the minimum", async () => { + mockClient.getEpochInfo.mockResolvedValue(epoch0EpochInfo); + + await action.execute({amount: "42000gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorJoin).toHaveBeenCalledTimes(1); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); +}); + +describe("ValidatorDepositAction minimum gate + mixing guard", () => { + let action: ValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorDepositAction(); + setupActionMocks(action); + mockClient.validatorDeposit.mockResolvedValue(mockTxResult); + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("blocks when resulting self-stake (vStake + pending + amount) is below the minimum", async () => { + // vStake 1000 + pending 0 + 10 = 1010 GEN, far below the 50000 GEN min. + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo()); + + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to make deposit"); + expect(detail).toContain(highMinEpochInfo.validatorMinStake); + expect(detail).toContain("--force"); + }); + + test("counts still-pending self-stake deposits toward the resulting stake", async () => { + // vStake 1000 + pending 49500 + 10 = 50510 GEN >= 50000 GEN min → proceeds. + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({ + pendingDeposits: [{epoch: 6n, stake: "49500 GEN", stakeRaw: 49500n * BigInt(1e18), shares: 1n}], + }), + ); + + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorDeposit).toHaveBeenCalledTimes(1); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); + + test("hard-blocks a liquid deposit into a vesting-owned wallet (no --force override)", async () => { + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo({owner: "0xVestingContract"})); + + // Even with --force the mixing guard blocks (the tx would revert on-chain). + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking", force: true}); + + expect(mockClient.validatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to make deposit"); + expect(detail).toContain("vesting"); + expect(detail).toContain("genlayer vesting validator-deposit"); + }); +}); + +describe("StakingInfoAction clean view + eligibility display", () => { + let action: StakingInfoAction; + let logSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new StakingInfoAction(); + setupActionMocks(action); + mockClient.isValidator.mockResolvedValue(true); + mockClient.getEpochInfo.mockResolvedValue(highMinEpochInfo); + logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const printed = () => logSpy.mock.calls.map((c: any[]) => String(c[0] ?? "")).join("\n"); + + test("--json prints the raw object and skips the grouped view / success line", async () => { + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo()); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking", json: true}); + + expect(action["succeedSpinner"]).not.toHaveBeenCalled(); + const parsed = JSON.parse(logSpy.mock.calls[0][0]); + expect(parsed.validator).toBe("0xValidatorWallet"); + expect(parsed.live).toBe(true); + expect(parsed.banned).toBe("Not banned"); + }); + + test("clean view keeps load-bearing values as plain substrings (e2e grep safety)", async () => { + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({vStake: "60000 GEN", vStakeRaw: 60000n * BigInt(1e18), identity: {moniker: "AcmeNode"}}), + ); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + const out = printed(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved"); + expect(out).toContain("0xValidatorWallet"); // validator address + expect(out).toContain("0xMockedSigner"); // owner + expect(out).toContain("0xOperator"); // operator + expect(out).toContain("60000 GEN"); // self-stake amount + expect(out).toContain("500 GEN"); // delegated amount + expect(out).toContain("AcmeNode"); // moniker + expect(out).toContain("Not banned"); + expect(out).toContain("live"); // the live label + expect(out).toContain("true"); // the live boolean value + // Never relabels live as "active". + expect(out).not.toContain("active"); + }); + + test("warns (display only) when effective self-stake is below the minimum, never blocks", async () => { + mockClient.getValidatorInfo.mockResolvedValue(fullValidatorInfo()); + const warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining(highMinEpochInfo.validatorMinStake)); + // Read command still succeeds — the warning never blocks. + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator info retrieved"); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); + + test("informs when a pending deposit will cross the minimum, with the activation epoch", async () => { + // vStake 1000 (< 50000 min) but pending 60000 crosses it. Deposit epoch 6 + + // 2 activation-delay epochs → activates at epoch 8. + mockClient.getValidatorInfo.mockResolvedValue( + fullValidatorInfo({ + pendingDeposits: [{epoch: 6n, stake: "60000 GEN", stakeRaw: 60000n * BigInt(1e18), shares: 1n}], + }), + ); + const infoSpy = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + const warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + + await action.getValidatorInfo({validator: "0xValidatorWallet", stakingAddress: "0xStaking"}); + + expect(warnSpy).not.toHaveBeenCalled(); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining("epoch 8")); + }); +}); diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts new file mode 100644 index 00000000..e21cd52f --- /dev/null +++ b/tests/actions/stakingWizard.test.ts @@ -0,0 +1,917 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import inquirer from "inquirer"; +import {ValidatorWizardAction} from "../../src/commands/staking/wizard"; +import {CreateAccountAction} from "../../src/commands/account/create"; +import {ExportAccountAction} from "../../src/commands/account/export"; + +vi.mock("inquirer"); +vi.mock("../../src/commands/account/create"); +vi.mock("../../src/commands/account/export"); + +const fsMock = vi.hoisted(() => ({ + readFileSync: vi.fn(() => JSON.stringify({address: "0xOperatorAddr"})), + existsSync: vi.fn(() => false), +})); +vi.mock("fs", async importOriginal => { + const actual = await importOriginal(); + const merged = {...actual, ...fsMock}; + return {...merged, default: merged}; +}); + +// genlayer-js: the wizard balance-check uses createClient(...).getBalance/getEpochInfo. +// The vesting funding source additionally reads getBeneficiaryVestings / +// getVestingState / getValidatorWallets off the same (account-less) client. +// NOTE: implementations are passed to vi.fn() (not via a later .mockResolvedValue) +// so afterEach's vi.restoreAllMocks() reverts to THESE defaults rather than to an +// empty mock — the same reason `createClient: vi.fn(() => mockGlClient)` survives. +const mockGlClient = { + getBalance: vi.fn(async () => 1000n * 10n ** 18n), + getEpochInfo: vi.fn(async () => ({ + validatorMinStakeRaw: 42n * 10n ** 18n, + validatorMinStake: "42 GEN", + currentEpoch: 5n, + })), + getBeneficiaryVestings: vi.fn(async (_beneficiary?: string) => ["0xVesting"]), + getVestingState: vi.fn(async () => ({ + totalAmountRaw: 100n * 10n ** 18n, + totalWithdrawnRaw: 0n, + })), + getValidatorWallets: vi.fn(async () => ["0xVWallet"]), +}; + +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(() => mockGlClient), + createAccount: vi.fn(() => ({address: "0xMockedAddress"})), + formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), + parseStakingAmount: vi.fn((val: string) => { + const cleaned = val.toLowerCase().replace(/gen|eth/g, ""); + return BigInt(Math.floor(parseFloat(cleaned) * 1e18)); + }), + abi: {STAKING_ABI: [], VESTING_ABI: []}, +})); + +describe("ValidatorWizardAction --wallet browser (owner)", () => { + let action: ValidatorWizardAction; + let sendTransaction: ReturnType; + let bridgeClose: ReturnType; + let setNextLabel: ReturnType; + let getBrowserWalletSessionSpy: any; + let getStakingClientSpy: any; + // Browser SDK clients. Writes run the SAME client.(...) calls as the + // keystore lane — the wallet-funded lane via getBrowserStakingClient, the + // vesting-funded lane via the private getWizardVestingBrowserClient. + let validatorJoin: ReturnType; + let setIdentity: ReturnType; + let vestingValidatorJoin: ReturnType; + let vestingValidatorSetIdentity: ReturnType; + let getValidatorWallets: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + // Silence spinners/logs. + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + // Network resolution -> a minimal chain with a staking contract. + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + // Browser session seam. Writes route through the SDK client's provider, so + // the session only needs setNextLabel + close for these assertions. + sendTransaction = vi.fn().mockResolvedValue({ + transactionHash: "0xJoinHash", + blockNumber: 10n, + gasUsed: 1000n, + status: "success", + }); + setNextLabel = vi.fn(); + bridgeClose = vi.fn().mockResolvedValue(undefined); + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: bridgeClose}, + kind: "local", + sessionUrl: "http://127.0.0.1:1/#s=t", + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + setNextLabel, + eip1193Provider: {request: vi.fn()}, + sendTransaction, + // The wizard finally block now calls session.close() (no-op for remote, + // full close for own bridge). Delegate to bridgeClose so the assertion holds. + close: bridgeClose, + }); + + // Wallet-funded browser lane: getBrowserStakingClient(...).validatorJoin / + // .setIdentity. The join result carries the decoded validator wallet. + validatorJoin = vi.fn().mockResolvedValue({ + transactionHash: "0xJoinHash", + blockNumber: 10n, + gasUsed: 1000n, + validatorWallet: "0xValidatorWalletFromEvent", + operator: "0xBrowserOwner", + amount: "42 GEN", + }); + setIdentity = vi.fn().mockResolvedValue({transactionHash: "0xIdHash", blockNumber: 11n, gasUsed: 100n}); + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue({validatorJoin, setIdentity}); + + // Vesting-funded browser lane: getWizardVestingBrowserClient(...). + // vestingValidatorJoin / .vestingValidatorSetIdentity, plus getValidatorWallets + // to resolve the just-created wallet (newest entry). + vestingValidatorJoin = vi.fn().mockResolvedValue({ + transactionHash: "0xVJoinHash", + blockNumber: 12n, + gasUsed: 100n, + }); + vestingValidatorSetIdentity = vi.fn().mockResolvedValue({ + transactionHash: "0xVIdHash", + blockNumber: 13n, + gasUsed: 100n, + }); + getValidatorWallets = vi.fn().mockResolvedValue(["0xVWallet"]); + vi.spyOn(action as any, "getWizardVestingBrowserClient").mockReturnValue({ + vestingValidatorJoin, + vestingValidatorSetIdentity, + getValidatorWallets, + }); + + // Ensure the keystore staking path is never exercised. + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + + // CreateAccount / ExportAccount are mocked classes. + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes join through the browser SDK client, keeps operator export, never uses keystore", async () => { + // Prompt sequence: + // funding source -> wallet (default; original flow unchanged) + // step4 useOperator -> true; operatorChoice -> create; operatorName -> "op"; + // export filename -> default; export password x2 + // step5 stakeAmount -> "42gen"; confirm -> true + // step7 setupIdentity -> false (skip identity prompts) + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "wallet"}) // funding source + .mockResolvedValueOnce({useOperator: true}) // step4 + .mockResolvedValueOnce({operatorChoice: "create"}) + .mockResolvedValueOnce({operatorName: "op"}) + .mockResolvedValueOnce({outputFilename: "op-keystore.json"}) + .mockResolvedValueOnce({exportPassword: "password123"}) + .mockResolvedValueOnce({confirmPassword: "password123"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) // step5 + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); // step7 + + // listAccounts is used inside step4 (create operator uniqueness check). + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + await action.execute({amount: "", wallet: "browser", network: "testnet-bradbury"} as any); + + // Bridge was started once (via ensureBrowserSession) and closed in finally. + expect(getBrowserWalletSessionSpy).toHaveBeenCalledWith( + expect.objectContaining({network: "testnet-bradbury"}), + "wizard", + ); + expect(bridgeClose).toHaveBeenCalled(); + + // Join ran the SAME SDK call as the keystore lane, through the browser + // client (Address account + provider), not the keystore staking client. + // Operator is the created operator account (fsMock address), passed straight through. + expect(validatorJoin).toHaveBeenCalledWith({amount: 42n * 10n ** 18n, operator: "0xOperatorAddr"}); + expect(setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Join as validator")); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + + // Operator keystore export still happened (step 4 unchanged). + expect(ExportAccountAction.prototype.execute).toHaveBeenCalled(); + + // Validator created spinner fired with the decoded wallet. + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xValidatorWalletFromEvent"}), + ); + }); + + test("vesting source: browser owner sends vestingValidatorJoin through the same session", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + + // funding source -> vesting (one contract, no pick prompt) + // step4 useOperator -> true; operatorChoice -> existing; operatorAddress + // step5 stakeAmount -> "42gen"; confirm -> true + // step7 setupIdentity -> false (this test focuses on join) + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: true}) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); + + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + await action.execute({amount: "", wallet: "browser", network: "testnet-bradbury"} as any); + + // Beneficiary lookup used the connected browser address. + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBrowserOwner"); + + // Join ran vestingValidatorJoin on the browser vesting client with the chosen + // operator + amount — the same SDK call as `vesting validator create`. + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + operator: "0xOperatorAddr", + amount: 42n * 10n ** 18n, + }); + expect(setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Create vesting validator")); + // Never the keystore staking client. + expect(getStakingClientSpy).not.toHaveBeenCalled(); + + // The created wallet is resolved from the vesting contract's newest entry. + expect(getValidatorWallets).toHaveBeenCalledWith("0xVesting"); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting-backed validator created successfully!", + expect.objectContaining({vesting: "0xVesting", validatorWallet: "0xVWallet"}), + ); + }); + + test("vesting source: browser owner sets identity via vestingValidatorSetIdentity through the same session", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: true}) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + // step7: guided identity (browser owner, vesting-backed) + .mockResolvedValueOnce({setupIdentity: true}) + .mockResolvedValueOnce({moniker: "MyVesting"}) + .mockResolvedValueOnce({logoUri: ""}) + .mockResolvedValueOnce({website: ""}) + .mockResolvedValueOnce({description: ""}) + .mockResolvedValueOnce({email: ""}) + .mockResolvedValueOnce({twitter: ""}) + .mockResolvedValueOnce({telegram: ""}) + .mockResolvedValueOnce({github: ""}); + + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + await action.execute({amount: "", wallet: "browser", network: "testnet-bradbury"} as any); + + // Identity ran vestingValidatorSetIdentity on the browser vesting client + // against the created wallet (0xVWallet from getValidatorWallets), extraCid empty. + expect(vestingValidatorSetIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + vesting: "0xVesting", + wallet: "0xVWallet", + moniker: "MyVesting", + extraCid: "0x", + }), + ); + // Labeled + sent through the SAME browser client lane, never the keystore. + expect(setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Set validator identity")); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator identity set!"); + }); + + test("keystore path is untouched: --account + --wallet browser is rejected up-front", async () => { + await expect(action.execute({amount: "", wallet: "browser", account: "owner"} as any)).rejects.toThrow( + /--account cannot be used with --wallet browser/, + ); + expect(getBrowserWalletSessionSpy).not.toHaveBeenCalled(); + }); +}); + +describe("ValidatorWizardAction stake source (keystore owner)", () => { + let action: ValidatorWizardAction; + let validatorJoin: ReturnType; + let vestingValidatorJoin: ReturnType; + let vestingValidatorSetIdentity: ReturnType; + let getStakingClientSpy: any; + let getBrowserWalletSessionSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + // Keystore owner "owner" -> 0xOwner (short-circuits account-selection prompts). + vi.spyOn(action as any, "accountExists").mockReturnValue(true); + vi.spyOn(action as any, "getKeystorePath").mockReturnValue("/tmp/owner-keystore.json"); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xOwner"); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + // The browser bridge must never start in keystore mode. + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockImplementation(() => { + throw new Error("browser session must not start in keystore mode"); + }); + + // Signing client exposes both the wallet (validatorJoin) and the vesting + // (vestingValidatorJoin) create methods; each test asserts which one ran. + validatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xWalletFromJoin", + transactionHash: "0xJoinTx", + amount: "42 GEN", + operator: "0xOwner", + blockNumber: 11n, + }); + vestingValidatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xVWalletCreated", + transactionHash: "0xVJoinTx", + operator: "0xOwner", + amount: "42 GEN", + blockNumber: 12n, + gasUsed: 100n, + }); + vestingValidatorSetIdentity = vi.fn().mockResolvedValue({ + transactionHash: "0xVIdTx", + blockNumber: 13n, + gasUsed: 100n, + }); + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient").mockResolvedValue({ + validatorJoin, + vestingValidatorJoin, + vestingValidatorSetIdentity, + getValidatorWallets: vi.fn().mockResolvedValue(["0xVWalletCreated"]), + } as any); + + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const run = (extra: Record = {}) => + action.execute({ + amount: "", + account: "owner", + wallet: "keystore", + network: "testnet-bradbury", + ...extra, + } as any); + + test("(a) wallet source keeps the original flow — no vesting lookup, uses validatorJoin", async () => { + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "wallet"}) + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); + + await run(); + + expect(validatorJoin).toHaveBeenCalledWith({amount: 42n * 10n ** 18n, operator: "0xOwner"}); + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + expect(mockGlClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xWalletFromJoin"}), + ); + }); + + test("(b) vesting source, one contract: vestingValidatorJoin with the chosen operator + amount", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: true}) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); // step7 (this test focuses on join) + + await run(); + + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + operator: "0xOperatorAddr", + amount: 42n * 10n ** 18n, + }); + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting-backed validator created successfully!", + expect.objectContaining({vesting: "0xVesting", validatorWallet: "0xVWalletCreated"}), + ); + }); + + test("(d) vesting source with no contracts warns and loops back to wallet — no crash", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue([]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) // none found -> warn + loop + .mockResolvedValueOnce({stakeSource: "wallet"}) // recover onto wallet flow + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); + + await run(); + + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(action["logWarning"]).toHaveBeenCalledWith(expect.stringMatching(/No vesting contracts found/)); + expect(validatorJoin).toHaveBeenCalledOnce(); + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + }); + + test("(e) multiple vesting contracts: prompts to pick and uses the chosen one", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xV1", "0xV2"]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({selectedVesting: "0xV2"}) + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: false}); // step7 (this test focuses on join) + + await run(); + + const promptCalls = vi.mocked(inquirer.prompt).mock.calls; + const askedToPick = promptCalls.some((c: any) => c[0]?.[0]?.name === "selectedVesting"); + expect(askedToPick).toBe(true); + + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xV2", + operator: "0xOwner", + amount: 42n * 10n ** 18n, + }); + expect(validatorJoin).not.toHaveBeenCalled(); + }); + + test("(f) revoked vesting contract is blocked: wizard aborts before joining", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + // A revoked contract can no longer stake on-chain (Vesting.sol blocks every + // stake path), so the balance-check step must bail out cleanly. + mockGlClient.getVestingState.mockResolvedValueOnce({ + revoked: true, + totalAmountRaw: 100n * 10n ** 18n, + totalWithdrawnRaw: 0n, + }); + vi.mocked(inquirer.prompt).mockResolvedValueOnce({stakeSource: "vesting"}); + + await run(); + + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(action["logError"]).toHaveBeenCalledWith(expect.stringMatching(/revoked/i)); + // Neither join path runs — the wizard aborted at the balance check. + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + expect(validatorJoin).not.toHaveBeenCalled(); + }); + + test("(g) vesting source: guided identity routes through vestingValidatorSetIdentity", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + // step7: same guided prompts as the wallet path + .mockResolvedValueOnce({setupIdentity: true}) + .mockResolvedValueOnce({moniker: "MyVesting"}) + .mockResolvedValueOnce({logoUri: ""}) + .mockResolvedValueOnce({website: "https://v.io"}) + .mockResolvedValueOnce({description: ""}) + .mockResolvedValueOnce({email: ""}) + .mockResolvedValueOnce({twitter: ""}) + .mockResolvedValueOnce({telegram: ""}) + .mockResolvedValueOnce({github: ""}); + + await run(); + + // Identity was set through the vesting contract, targeting the created wallet + // — NOT staking's setIdentity (there is no such raw-viem bypass). + expect(vestingValidatorSetIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + vesting: "0xVesting", + wallet: "0xVWalletCreated", + moniker: "MyVesting", + website: "https://v.io", + extraCid: "0x", + }), + ); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Validator identity set!"); + }); + + test("(h) vesting identity revert is caught: wizard warns and still reaches the summary", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + vestingValidatorSetIdentity.mockRejectedValueOnce(new Error("consensus gap")); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}) + .mockResolvedValueOnce({setupIdentity: true}) + .mockResolvedValueOnce({moniker: "MyVesting"}) + .mockResolvedValueOnce({logoUri: ""}) + .mockResolvedValueOnce({website: ""}) + .mockResolvedValueOnce({description: ""}) + .mockResolvedValueOnce({email: ""}) + .mockResolvedValueOnce({twitter: ""}) + .mockResolvedValueOnce({telegram: ""}) + .mockResolvedValueOnce({github: ""}); + + await run(); + + // Join succeeded, identity reverted → warn (not crash). The wizard does not + // hit its top-level failure path, so execute() returns and the summary runs. + expect(vestingValidatorJoin).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting-backed validator created successfully!", + expect.objectContaining({validatorWallet: "0xVWalletCreated"}), + ); + expect(action["logWarning"]).toHaveBeenCalledWith(expect.stringMatching(/Failed to set identity.*consensus gap/)); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); + + test("(i) --skip-identity is respected on the vesting path", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: false}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}); + + await run({skipIdentity: true}); + + expect(vestingValidatorJoin).toHaveBeenCalledOnce(); + expect(vestingValidatorSetIdentity).not.toHaveBeenCalled(); + // No identity prompt was ever shown (last prompt was the stake confirm). + const promptCalls = vi.mocked(inquirer.prompt).mock.calls; + const askedIdentity = promptCalls.some((c: any) => c[0]?.[0]?.name === "setupIdentity"); + expect(askedIdentity).toBe(false); + }); +}); + +describe("ValidatorWizardAction --non-interactive (keystore owner)", () => { + let action: ValidatorWizardAction; + let validatorJoin: ReturnType; + let vestingValidatorJoin: ReturnType; + let setIdentity: ReturnType; + let vestingValidatorSetIdentity: ReturnType; + let getStakingClientSpy: any; + let getBrowserWalletSessionSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + vi.spyOn(action as any, "accountExists").mockReturnValue(true); + vi.spyOn(action as any, "getKeystorePath").mockReturnValue("/tmp/owner-keystore.json"); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xOwner"); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + + // The browser bridge must never start in keystore mode. + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockImplementation(() => { + throw new Error("browser session must not start in keystore mode"); + }); + + validatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xWalletFromJoin", + transactionHash: "0xJoinTx", + amount: "42 GEN", + operator: "0xOperatorExternal", + blockNumber: 11n, + }); + vestingValidatorJoin = vi.fn().mockResolvedValue({ + validatorWallet: "0xVWalletCreated", + transactionHash: "0xVJoinTx", + operator: "0xOwner", + amount: "42 GEN", + blockNumber: 12n, + }); + setIdentity = vi.fn().mockResolvedValue({transactionHash: "0xIdTx"}); + vestingValidatorSetIdentity = vi.fn().mockResolvedValue({ + transactionHash: "0xVIdTx", + blockNumber: 13n, + gasUsed: 100n, + }); + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient").mockResolvedValue({ + validatorJoin, + vestingValidatorJoin, + setIdentity, + vestingValidatorSetIdentity, + getValidatorWallets: vi.fn().mockResolvedValue(["0xVWalletCreated"]), + } as any); + + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + const EXTERNAL_OP = "0x1111111111111111111111111111111111111111"; + + const run = (extra: Record = {}) => + action.execute({ + account: "owner", + wallet: "keystore", + network: "testnet-bradbury", + nonInteractive: true, + ...extra, + } as any); + + test("wallet source + external operator + amount + identity: full run with ZERO prompts", async () => { + await run({operator: EXTERNAL_OP, amount: "50gen", moniker: "MyValidator", website: "https://v.io"}); + + // No prompt was ever shown. + expect(inquirer.prompt).not.toHaveBeenCalled(); + + // Joined from the wallet with the external operator. + expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, operator: EXTERNAL_OP}); + expect(vestingValidatorJoin).not.toHaveBeenCalled(); + expect(getBrowserWalletSessionSpy).not.toHaveBeenCalled(); + + // Identity was applied from --moniker/--website against the validator wallet. + expect(setIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + validator: "0xWalletFromJoin", + moniker: "MyValidator", + website: "https://v.io", + }), + ); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xWalletFromJoin"}), + ); + }); + + test("--yes is an alias for --non-interactive", async () => { + await action.execute({ + account: "owner", + wallet: "keystore", + network: "testnet-bradbury", + yes: true, + operatorSame: true, + amount: "50gen", + } as any); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + // --operator-same reuses the owner address. + expect(validatorJoin).toHaveBeenCalledWith({amount: 50n * 10n ** 18n, operator: "0xOwner"}); + }); + + test("no --moniker: identity step is skipped (no setIdentity), still zero prompts", async () => { + await run({operatorSame: true, amount: "50gen"}); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(setIdentity).not.toHaveBeenCalled(); + expect(validatorJoin).toHaveBeenCalledOnce(); + }); + + test("vesting source with --vesting-contract: uses vestingValidatorJoin, no lookup prompt", async () => { + await run({ + fundingSource: "vesting", + vestingContract: "0xVesting", + operator: EXTERNAL_OP, + amount: "50gen", + }); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + // Explicit contract given → no beneficiary lookup needed. + expect(mockGlClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + operator: EXTERNAL_OP, + amount: 50n * 10n ** 18n, + }); + expect(validatorJoin).not.toHaveBeenCalled(); + }); + + test("vesting source without --vesting-contract auto-resolves the single contract", async () => { + mockGlClient.getBeneficiaryVestings.mockResolvedValue(["0xOnlyVesting"]); + await run({fundingSource: "vesting", operatorSame: true, amount: "50gen"}); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(mockGlClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xOwner"); + expect(vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xOnlyVesting", + operator: "0xOwner", + amount: 50n * 10n ** 18n, + }); + }); + + test("vesting source + --moniker: identity set from flags via vestingValidatorSetIdentity, zero prompts", async () => { + await run({ + fundingSource: "vesting", + vestingContract: "0xVesting", + operator: EXTERNAL_OP, + amount: "50gen", + moniker: "NIVesting", + website: "https://ni.io", + }); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(vestingValidatorJoin).toHaveBeenCalledOnce(); + // Identity applied through the vesting contract against the created wallet. + expect(vestingValidatorSetIdentity).toHaveBeenCalledWith( + expect.objectContaining({ + vesting: "0xVesting", + wallet: "0xVWalletCreated", + moniker: "NIVesting", + website: "https://ni.io", + extraCid: "0x", + }), + ); + // The wallet-path setIdentity is never used for a vesting-backed validator. + expect(setIdentity).not.toHaveBeenCalled(); + }); + + test("vesting source without --moniker: identity skipped, still zero prompts", async () => { + await run({fundingSource: "vesting", vestingContract: "0xVesting", operatorSame: true, amount: "50gen"}); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + expect(vestingValidatorJoin).toHaveBeenCalledOnce(); + expect(vestingValidatorSetIdentity).not.toHaveBeenCalled(); + }); + + test("missing --amount fails clearly naming the flag", async () => { + await run({operatorSame: true}); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/--amount/), + ); + }); + + test("missing operator choice fails clearly", async () => { + await run({amount: "50gen"}); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/--operator/), + ); + }); + + test("missing owner (no --account, no browser) fails naming --account", async () => { + await action.execute({ + wallet: "keystore", + network: "testnet-bradbury", + nonInteractive: true, + operatorSame: true, + amount: "50gen", + } as any); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/--account/), + ); + }); + + test("invalid --funding-source fails clearly", async () => { + await run({fundingSource: "bogus", operatorSame: true, amount: "50gen"}); + + expect(validatorJoin).not.toHaveBeenCalled(); + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Wizard failed", + expect.stringMatching(/funding-source/), + ); + }); +}); + +describe("ValidatorWizardAction --non-interactive (browser owner)", () => { + let action: ValidatorWizardAction; + let setNextLabel: ReturnType; + let bridgeClose: ReturnType; + let validatorJoin: ReturnType; + let getBrowserWalletSessionSpy: any; + let getStakingClientSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + action = new ValidatorWizardAction(); + + for (const m of [ + "startSpinner", + "setSpinnerText", + "succeedSpinner", + "failSpinner", + "stopSpinner", + "logInfo", + "logWarning", + "logError", + "log", + ]) { + vi.spyOn(action as any, m).mockImplementation(() => {}); + } + + vi.spyOn(action as any, "getCustomNetworks").mockReturnValue({}); + vi.spyOn(action as any, "getConfigByKey").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "writeConfig").mockImplementation(() => {}); + + setNextLabel = vi.fn(); + bridgeClose = vi.fn().mockResolvedValue(undefined); + getBrowserWalletSessionSpy = vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: bridgeClose}, + kind: "local", + sessionUrl: "http://127.0.0.1:1/#s=t", + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + setNextLabel, + eip1193Provider: {request: vi.fn()}, + close: bridgeClose, + }); + + // Wallet-funded browser lane runs the same client.validatorJoin(...) as keystore. + validatorJoin = vi.fn().mockResolvedValue({ + transactionHash: "0xJoinHash", + blockNumber: 10n, + gasUsed: 1000n, + validatorWallet: "0xValidatorWalletFromEvent", + operator: "0x2222222222222222222222222222222222222222", + amount: "50 GEN", + }); + vi.spyOn(action as any, "getBrowserStakingClient").mockReturnValue({validatorJoin}); + + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient"); + vi.spyOn(action as any, "listAccounts").mockReturnValue([]); + vi.mocked(CreateAccountAction.prototype.execute).mockResolvedValue(undefined as any); + vi.mocked(ExportAccountAction.prototype.execute).mockResolvedValue(undefined as any); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("browser owner runs end-to-end through the browser SDK client with ZERO prompts", async () => { + await action.execute({ + wallet: "browser", + network: "testnet-bradbury", + nonInteractive: true, + operator: "0x2222222222222222222222222222222222222222", + amount: "50gen", + skipIdentity: true, + } as any); + + expect(inquirer.prompt).not.toHaveBeenCalled(); + // Join ran the SAME SDK call as keystore, through the browser client, never + // the keystore staking client. + expect(validatorJoin).toHaveBeenCalledWith({ + amount: 50n * 10n ** 18n, + operator: "0x2222222222222222222222222222222222222222", + }); + expect(setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Join as validator")); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(bridgeClose).toHaveBeenCalled(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Validator created successfully!", + expect.objectContaining({validatorWallet: "0xValidatorWalletFromEvent"}), + ); + }); +}); diff --git a/tests/actions/trace.test.ts b/tests/actions/trace.test.ts new file mode 100644 index 00000000..04fc00ab --- /dev/null +++ b/tests/actions/trace.test.ts @@ -0,0 +1,71 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {TraceAction} from "../../src/commands/transactions/trace"; +import type {TransactionHash} from "genlayer-js/types"; + +// TraceAction routes through the typed SDK action client.debugTraceTransaction +// (same wire call as the former raw client.request({method: +// "gen_dbg_traceTransaction"})). +const mockClient = { + debugTraceTransaction: vi.fn(), +}; + +function setupActionMocks(action: any) { + vi.spyOn(action as any, "getClient").mockResolvedValue(mockClient); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); +} + +const txId = "0xdeadbeef" as TransactionHash; + +describe("TraceAction", () => { + let action: TraceAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new TraceAction(); + setupActionMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("fetches the trace via the typed SDK action", async () => { + const trace = {calls: [], result: "ok"}; + mockClient.debugTraceTransaction.mockResolvedValue(trace); + + await action.trace({txId, round: 2}); + + expect(mockClient.debugTraceTransaction).toHaveBeenCalledWith({hash: txId, round: 2}); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Execution trace retrieved", trace); + }); + + test("defaults round to 0", async () => { + mockClient.debugTraceTransaction.mockResolvedValue({}); + + await action.trace({txId}); + + expect(mockClient.debugTraceTransaction).toHaveBeenCalledWith({hash: txId, round: 0}); + }); + + test("reports when no trace is found", async () => { + mockClient.debugTraceTransaction.mockResolvedValue(null); + + await action.trace({txId}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "No trace found", + `No execution trace found for transaction ${txId}`, + ); + }); + + test("handles errors", async () => { + const err = new Error("trace boom"); + mockClient.debugTraceTransaction.mockRejectedValue(err); + + await action.trace({txId}); + + expect(action["failSpinner"]).toHaveBeenCalledWith("Error retrieving execution trace", err); + }); +}); diff --git a/tests/actions/vesting.test.ts b/tests/actions/vesting.test.ts new file mode 100644 index 00000000..e3a9680b --- /dev/null +++ b/tests/actions/vesting.test.ts @@ -0,0 +1,305 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {VestingDelegateAction} from "../../src/commands/vesting/delegate"; +import {VestingWithdrawAction} from "../../src/commands/vesting/withdraw"; +import {VestingValidatorCreateAction} from "../../src/commands/vesting/validatorCreate"; +import {VestingValidatorDepositAction} from "../../src/commands/vesting/validatorDeposit"; + +// Mock genlayer-js. The browser-wallet path routes writes through the SDK +// client built by getBrowserVestingClient, which we spy in each test, so +// stubbed ABIs are enough here. +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(), + createAccount: vi.fn(() => ({address: "0xMockedAddress"})), + formatStakingAmount: vi.fn((val: bigint) => `${Number(val) / 1e18} GEN`), + parseStakingAmount: vi.fn((val: string) => { + if (val.toLowerCase().endsWith("gen") || val.toLowerCase().endsWith("eth")) { + return BigInt(parseFloat(val.slice(0, -3)) * 1e18); + } + return BigInt(val); + }), + abi: { + VESTING_ABI: [], + }, +})); + +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://studionet.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"]}}, + }, +})); + +// Shared vesting browser-wallet session factory. Writes now run through the +// genlayer-js SDK client (getBrowserVestingClient), which routes +// eth_sendTransaction through `eip1193Provider`; `setNextLabel` sets the +// bridge label. Commands call `session.close()` in their finally block. +function makeVestingSession(overrides: Record = {}) { + return { + signerAddress: "0xBen", + setNextLabel: vi.fn(), + eip1193Provider: {request: vi.fn()}, + close: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function setupVestingBrowserMocks(action: any) { + vi.spyOn(action, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action, "log").mockImplementation(() => {}); + // Simplest resolution: the vesting address is fixed. The browser lane calls + // resolveBeneficiaryVesting(client, options) with the browser SDK client. + vi.spyOn(action, "resolveBeneficiaryVesting").mockResolvedValue("0xVesting"); +} + +describe("VestingDelegateAction --wallet browser", () => { + let action: VestingDelegateAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingDelegateAction(); + setupVestingBrowserMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser SDK client, skips keystore client, closes session", async () => { + const getVestingClientSpy = vi.spyOn(action as any, "getVestingClient"); + const session = makeVestingSession(); + vi.spyOn(action as any, "getVestingBrowserSession").mockResolvedValue(session); + const mockClient = { + vestingDelegatorJoin: vi.fn().mockResolvedValue({ + transactionHash: "0xVH", + vesting: "0xVesting", + validator: "0xVal", + beneficiary: "0xBen", + amount: "1 GEN", + blockNumber: 9n, + gasUsed: 8n, + }), + }; + vi.spyOn(action as any, "getBrowserVestingClient").mockReturnValue(mockClient); + + await action.execute({validator: "0xVal", amount: "1gen", wallet: "browser"}); + + expect(mockClient.vestingDelegatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + validator: "0xVal", + amount: expect.any(BigInt), + }); + expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Delegate")); + expect(getVestingClientSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting delegation successful!", + expect.objectContaining({ + transactionHash: "0xVH", + vesting: "0xVesting", + validator: "0xVal", + beneficiary: "0xBen", + amount: expect.any(String), + blockNumber: "9", + gasUsed: "8", + }), + ); + }); + + test("closes the session even when the send fails", async () => { + const session = makeVestingSession(); + vi.spyOn(action as any, "getVestingBrowserSession").mockResolvedValue(session); + vi.spyOn(action as any, "getBrowserVestingClient").mockReturnValue({ + vestingDelegatorJoin: vi.fn().mockRejectedValue(new Error("Rejected in wallet")), + }); + + await action.execute({validator: "0xVal", amount: "1gen", wallet: "browser"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to delegate vesting tokens", + "Rejected in wallet", + ); + expect(session.close).toHaveBeenCalledOnce(); + }); +}); + +describe("VestingWithdrawAction --wallet browser", () => { + let action: VestingWithdrawAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingWithdrawAction(); + setupVestingBrowserMocks(action); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("routes through the browser SDK client, skips keystore client, closes session", async () => { + const getVestingClientSpy = vi.spyOn(action as any, "getVestingClient"); + const session = makeVestingSession(); + vi.spyOn(action as any, "getVestingBrowserSession").mockResolvedValue(session); + const mockClient = { + vestingWithdraw: vi.fn().mockResolvedValue({ + transactionHash: "0xVH", + vesting: "0xVesting", + beneficiary: "0xBen", + amount: "1 GEN", + blockNumber: 9n, + gasUsed: 8n, + }), + }; + vi.spyOn(action as any, "getBrowserVestingClient").mockReturnValue(mockClient); + + await action.execute({amount: "1gen", wallet: "browser"}); + + expect(mockClient.vestingWithdraw).toHaveBeenCalledWith({ + vesting: "0xVesting", + amount: expect.any(BigInt), + }); + expect(session.setNextLabel).toHaveBeenCalledWith(expect.stringContaining("Withdraw")); + expect(getVestingClientSpy).not.toHaveBeenCalled(); + expect(session.close).toHaveBeenCalledOnce(); + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting withdrawal successful!", + expect.objectContaining({ + transactionHash: "0xVH", + vesting: "0xVesting", + beneficiary: "0xBen", + amount: expect.any(String), + blockNumber: "9", + gasUsed: "8", + }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Keystore-lane self-stake minimum gate + vesting/liquid mixing hard-guard. +// The minimum is driven from the mocked epochInfo, never hardcoded. +// --------------------------------------------------------------------------- + +const vestingEpochInfo = { + currentEpoch: 7n, + validatorMinStake: "50000 GEN", + validatorMinStakeRaw: 50000n * BigInt(1e18), + delegatorMinStake: "42 GEN", + delegatorMinStakeRaw: 42n * BigInt(1e18), +}; + +function setupVestingKeystoreMocks(action: any, clientOverrides: Record = {}) { + vi.spyOn(action, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(action, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action, "failSpinner").mockImplementation(() => {}); + vi.spyOn(action, "log").mockImplementation(() => {}); + vi.spyOn(action, "logInfo").mockImplementation(() => {}); + vi.spyOn(action, "logWarning").mockImplementation(() => {}); + vi.spyOn(action, "resolveBeneficiaryVesting").mockResolvedValue("0xVesting"); + const client = { + vestingValidatorJoin: vi.fn().mockResolvedValue({ + transactionHash: "0xVH", + vesting: "0xVesting", + validatorWallet: "0xWallet", + blockNumber: 1n, + gasUsed: 2n, + }), + vestingValidatorDeposit: vi.fn().mockResolvedValue({transactionHash: "0xVH", blockNumber: 1n, gasUsed: 2n}), + getValidatorWallets: vi.fn().mockResolvedValue(["0xWallet"]), + getEpochInfo: vi.fn().mockResolvedValue(vestingEpochInfo), + getValidatorInfo: vi.fn().mockResolvedValue({ + address: "0xWallet", + owner: "0xVesting", + operator: "0xOperator", + vStake: "1000 GEN", + vStakeRaw: 1000n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }), + isValidatorWallet: vi.fn().mockResolvedValue(true), + ...clientOverrides, + }; + vi.spyOn(action as any, "getVestingClient").mockResolvedValue(client); + return client; +} + +describe("VestingValidatorCreateAction self-stake minimum gate", () => { + let action: VestingValidatorCreateAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingValidatorCreateAction(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("blocks a create below the minimum without --force", async () => { + const client = setupVestingKeystoreMocks(action); + + await action.execute({operator: "0xOperator", amount: "100gen"}); + + expect(client.vestingValidatorJoin).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to create vesting-backed validator"); + expect(detail).toContain(vestingEpochInfo.validatorMinStake); + expect(detail).toContain("--force"); + }); + + test("proceeds with --force below the minimum", async () => { + const client = setupVestingKeystoreMocks(action); + + await action.execute({operator: "0xOperator", amount: "100gen", force: true}); + + expect(client.vestingValidatorJoin).toHaveBeenCalledTimes(1); + expect(action["failSpinner"]).not.toHaveBeenCalled(); + }); +}); + +describe("VestingValidatorDepositAction mixing guard", () => { + let action: VestingValidatorDepositAction; + + beforeEach(() => { + vi.clearAllMocks(); + action = new VestingValidatorDepositAction(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("hard-blocks a vesting deposit into a wallet not created by this vesting contract", async () => { + const client = setupVestingKeystoreMocks(action, {isValidatorWallet: vi.fn().mockResolvedValue(false)}); + + // Even with --force: the mixing guard is not overridable (tx would revert). + await action.execute({walletAddress: "0xLiquidWallet", amount: "100gen", force: true}); + + expect(client.vestingValidatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to deposit vesting validator tokens"); + expect(detail).toContain("staking validator-deposit"); + }); + + test("blocks a vesting deposit below the minimum without --force", async () => { + const client = setupVestingKeystoreMocks(action); // isValidatorWallet true, vStake 1000 GEN + await action.execute({walletAddress: "0xWallet", amount: "100gen"}); + + expect(client.vestingValidatorDeposit).not.toHaveBeenCalled(); + const [msg, detail] = (action["failSpinner"] as any).mock.calls[0]; + expect(msg).toBe("Failed to deposit vesting validator tokens"); + expect(detail).toContain(vestingEpochInfo.validatorMinStake); + }); +}); diff --git a/tests/actions/walletConnect.test.ts b/tests/actions/walletConnect.test.ts new file mode 100644 index 00000000..ed5daecb --- /dev/null +++ b/tests/actions/walletConnect.test.ts @@ -0,0 +1,161 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; + +// Hermetic: no real daemon, tab, descriptor file, or spawn. Control the +// descriptor/pid primitives, the daemon spawn, and the session client. +vi.mock("../../src/lib/wallet/sessionDescriptor", () => ({ + descriptorPath: vi.fn(() => "/tmp/wallet-session.json"), + readDescriptor: vi.fn(), + removeDescriptor: vi.fn(), + isPidAlive: vi.fn(), +})); +vi.mock("../../src/lib/wallet/spawnDaemon", () => ({ + spawnWalletDaemon: vi.fn(), + waitForDaemonReady: vi.fn(), +})); +vi.mock("../../src/lib/wallet/sessionClient", () => ({ + WalletSessionClient: vi.fn(), +})); + +import {WalletAction} from "../../src/commands/wallet/WalletAction"; +import {readDescriptor, isPidAlive} from "../../src/lib/wallet/sessionDescriptor"; +import {spawnWalletDaemon, waitForDaemonReady} from "../../src/lib/wallet/spawnDaemon"; +import {WalletSessionClient} from "../../src/lib/wallet/sessionClient"; +import {HEARTBEAT_DEAD_MS} from "../../src/lib/wallet/sessionConstants"; + +const ADDRESS = "0xConnected0000000000000000000000000000001"; +const CHAIN: any = {id: 4221, name: "Genlayer Bradbury Testnet"}; + +const DESCRIPTOR: any = { + version: 1, + pid: 4242, + port: 7, + token: "tok", + address: ADDRESS, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), +}; + +describe("WalletAction.connect — tab-dead recovery", () => { + let action: WalletAction; + let logInfo: any; + let logSuccess: any; + let succeedSpinner: any; + + beforeEach(() => { + vi.mocked(readDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + vi.mocked(spawnWalletDaemon).mockReset(); + vi.mocked(waitForDaemonReady).mockReset(); + vi.mocked(WalletSessionClient).mockReset(); + + action = new WalletAction(); + // Avoid config/network + FS + spinner side effects. + vi.spyOn(action as any, "resolveChain").mockReturnValue(CHAIN); + vi.spyOn(action as any, "networkAlias").mockReturnValue("testnet-bradbury"); + vi.spyOn(action as any, "getFilePath").mockReturnValue("/tmp/wallet-daemon.log"); + logInfo = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + logSuccess = vi.spyOn(action as any, "logSuccess").mockImplementation(() => {}); + vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "stopSpinner").mockImplementation(() => {}); + succeedSpinner = vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + test("healthy session (fresh heartbeat, connected) → 'Already connected', no respawn", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + lastPagePollAt: Date.now(), + }), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.connect({}); + + expect(logSuccess).toHaveBeenCalledWith(expect.stringMatching(/Already connected/)); + expect(client.shutdown).not.toHaveBeenCalled(); + expect(spawnWalletDaemon).not.toHaveBeenCalled(); + }); + + test("tab-dead session (alive + pinging + connected but STALE heartbeat) → tears down and respawns", async () => { + const stale = Date.now() - (HEARTBEAT_DEAD_MS + 30_000); + // First read discovers the stale daemon; waitForDescriptorGone then sees it gone. + vi.mocked(readDescriptor).mockReturnValueOnce(DESCRIPTOR).mockReturnValue(null); + vi.mocked(isPidAlive).mockReturnValue(true); + + const staleClient = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + lastPagePollAt: stale, + }), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const freshClient = { + state: vi.fn().mockResolvedValue({url: "http://127.0.0.1:7/gl-wallet#s=tok"}), + waitForConnection: vi.fn().mockResolvedValue(ADDRESS), + }; + vi.mocked(WalletSessionClient) + .mockImplementationOnce(() => staleClient as any) + .mockImplementationOnce(() => freshClient as any); + vi.mocked(waitForDaemonReady).mockResolvedValue({...DESCRIPTOR} as any); + + await action.connect({}); + + // The stale daemon was torn down and a brand-new one spawned. + expect(staleClient.shutdown).toHaveBeenCalledTimes(1); + expect(spawnWalletDaemon).toHaveBeenCalledTimes(1); + expect(freshClient.waitForConnection).toHaveBeenCalled(); + expect(succeedSpinner).toHaveBeenCalledWith(expect.stringMatching(/Connected as/)); + // Must NOT short-circuit with "Already connected" on the dead tab. + expect(logSuccess).not.toHaveBeenCalledWith(expect.stringMatching(/Already connected/)); + // The recovery is announced. + expect(logInfo).toHaveBeenCalledWith(expect.stringMatching(/tab was closed/i)); + }); + + test("different chain still switches (shutdown + respawn), unaffected by the heartbeat check", async () => { + vi.mocked(readDescriptor) + .mockReturnValueOnce({...DESCRIPTOR, chainId: 9999}) + .mockReturnValue(null); + vi.mocked(isPidAlive).mockReturnValue(true); + + const oldClient = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 9999, + lastPagePollAt: Date.now(), + }), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const freshClient = { + state: vi.fn().mockResolvedValue({url: "http://127.0.0.1:7/gl-wallet#s=tok"}), + waitForConnection: vi.fn().mockResolvedValue(ADDRESS), + }; + vi.mocked(WalletSessionClient) + .mockImplementationOnce(() => oldClient as any) + .mockImplementationOnce(() => freshClient as any); + vi.mocked(waitForDaemonReady).mockResolvedValue({...DESCRIPTOR} as any); + + await action.connect({network: "localnet"}); + + expect(logInfo).toHaveBeenCalledWith(expect.stringMatching(/Switching wallet session/)); + expect(oldClient.shutdown).toHaveBeenCalledTimes(1); + expect(spawnWalletDaemon).toHaveBeenCalledTimes(1); + expect(logSuccess).not.toHaveBeenCalledWith(expect.stringMatching(/Already connected/)); + }); +}); diff --git a/tests/actions/walletSession.test.ts b/tests/actions/walletSession.test.ts new file mode 100644 index 00000000..8346fd8e --- /dev/null +++ b/tests/actions/walletSession.test.ts @@ -0,0 +1,86 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import {BaseAction} from "../../src/lib/actions/BaseAction"; + +/** Minimal concrete subclass exposing the protected resolution helpers. */ +class TestAction extends BaseAction { + publicResolveMode(flag?: string) { + return (this as any).resolveWalletMode(flag); + } + publicIsBrowser(config: {wallet?: string}) { + return (this as any).isBrowserWallet(config); + } +} + +describe("BaseAction wallet-mode resolution (config default)", () => { + let action: TestAction; + let configValue: any; + let warnSpy: any; + let sessionSpy: any; + + beforeEach(() => { + action = new TestAction(); + configValue = null; + vi.spyOn(action as any, "getConfigByKey").mockImplementation((...args: any[]) => + args[0] === "walletMode" ? configValue : null, + ); + warnSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + // Default: no live session, so config tests stay hermetic (not swayed by a + // descriptor on the machine running the suite). Session-rung tests flip it. + sessionSpy = vi.spyOn(action as any, "hasLiveWalletSession").mockReturnValue(false); + }); + afterEach(() => vi.restoreAllMocks()); + + test("no flag + no config + no session → keystore", () => { + expect(action.publicResolveMode(undefined)).toBe("keystore"); + expect(action.publicIsBrowser({})).toBe(false); + }); + + test("no flag + no config + live session → browser (connect-once)", () => { + sessionSpy.mockReturnValue(true); + expect(action.publicResolveMode(undefined)).toBe("browser"); + expect(action.publicIsBrowser({})).toBe(true); + }); + + test("explicit --wallet keystore overrides a live session", () => { + sessionSpy.mockReturnValue(true); + expect(action.publicResolveMode("keystore")).toBe("keystore"); + }); + + test("walletMode=keystore config overrides a live session", () => { + sessionSpy.mockReturnValue(true); + configValue = "keystore"; + expect(action.publicResolveMode(undefined)).toBe("keystore"); + }); + + test("live session is not consulted when config already decides (browser)", () => { + configValue = "browser"; + expect(action.publicResolveMode(undefined)).toBe("browser"); + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("no flag + walletMode=browser config → browser", () => { + configValue = "browser"; + expect(action.publicResolveMode(undefined)).toBe("browser"); + expect(action.publicIsBrowser({})).toBe(true); + }); + + test("explicit --wallet keystore overrides walletMode=browser config", () => { + configValue = "browser"; + expect(action.publicResolveMode("keystore")).toBe("keystore"); + expect(action.publicIsBrowser({wallet: "keystore"})).toBe(false); + }); + + test("explicit --wallet browser works with no config", () => { + expect(action.publicResolveMode("browser")).toBe("browser"); + }); + + test("invalid config value warns and falls back to keystore", () => { + configValue = "hardware"; + expect(action.publicResolveMode(undefined)).toBe("keystore"); + expect(warnSpy).toHaveBeenCalled(); + }); + + test("invalid flag throws", () => { + expect(() => action.publicResolveMode("ledger")).toThrow(/Invalid --wallet value 'ledger'/); + }); +}); diff --git a/tests/actions/walletStatusDisconnect.test.ts b/tests/actions/walletStatusDisconnect.test.ts new file mode 100644 index 00000000..f05737b4 --- /dev/null +++ b/tests/actions/walletStatusDisconnect.test.ts @@ -0,0 +1,273 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; + +// Hermetic: no real daemon, tab, descriptor file, or spawn. Control the +// descriptor/pid primitives and the session client (mirrors walletConnect.test.ts). +vi.mock("../../src/lib/wallet/sessionDescriptor", () => ({ + descriptorPath: vi.fn(() => "/tmp/wallet-session.json"), + readDescriptor: vi.fn(), + removeDescriptor: vi.fn(), + isPidAlive: vi.fn(), +})); +vi.mock("../../src/lib/wallet/sessionClient", () => ({ + WalletSessionClient: vi.fn(), +})); + +import {WalletAction} from "../../src/commands/wallet/WalletAction"; +import {readDescriptor, removeDescriptor, isPidAlive} from "../../src/lib/wallet/sessionDescriptor"; +import {WalletSessionClient} from "../../src/lib/wallet/sessionClient"; +import {HEARTBEAT_DEAD_MS} from "../../src/lib/wallet/sessionConstants"; + +const ADDRESS = "0xConnected0000000000000000000000000000001"; + +const DESCRIPTOR: any = { + version: 1, + pid: 4242, + port: 7, + token: "tok", + address: ADDRESS, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now() - 5 * 60_000, + lastUsed: Date.now() - 2 * 60_000, +}; + +describe("WalletAction.status", () => { + let action: WalletAction; + let logInfo: any; + let logWarning: any; + let log: any; + let savedExitCode: number | string | undefined; + + beforeEach(() => { + vi.mocked(readDescriptor).mockReset(); + vi.mocked(removeDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + vi.mocked(WalletSessionClient).mockReset(); + + savedExitCode = process.exitCode; + process.exitCode = 0; + + action = new WalletAction(); + log = vi.spyOn(action as any, "log").mockImplementation(() => {}); + logInfo = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + logWarning = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + }); + afterEach(() => { + vi.restoreAllMocks(); + process.exitCode = savedExitCode; + }); + + test("no descriptor → 'No active wallet session.' and exit code 1", async () => { + vi.mocked(readDescriptor).mockReturnValue(null); + + await action.status(); + + expect(logInfo).toHaveBeenCalledWith("No active wallet session."); + expect(process.exitCode).toBe(1); + expect(WalletSessionClient).not.toHaveBeenCalled(); + }); + + test("live connected session → prints the status object, exit code 0", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + queuedCount: 0, + createdAt: DESCRIPTOR.createdAt, + lastPagePollAt: Date.now(), + }), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(log).toHaveBeenCalledWith( + "Wallet session:", + expect.objectContaining({ + status: "connected", + address: ADDRESS, + network: DESCRIPTOR.network, + chainId: 4221, + port: DESCRIPTOR.port, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + tabHeartbeat: "fresh", + queuedTransactions: 0, + }), + ); + expect(process.exitCode).toBe(0); + expect(removeDescriptor).not.toHaveBeenCalled(); + }); + + test("live-but-connecting session (not yet connected) → status 'connecting', exit code 1", async () => { + vi.mocked(readDescriptor).mockReturnValue({...DESCRIPTOR, address: null}); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: false, + address: null, + chainId: 4221, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + queuedCount: 0, + createdAt: DESCRIPTOR.createdAt, + lastPagePollAt: Date.now(), + }), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(log).toHaveBeenCalledWith( + "Wallet session:", + expect.objectContaining({status: "connecting", address: "(not connected)"}), + ); + expect(process.exitCode).toBe(1); + }); + + test("connected but stale tab heartbeat → reports heartbeat stale (still connected, exit 0)", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn().mockResolvedValue({ + connected: true, + address: ADDRESS, + chainId: 4221, + url: "http://127.0.0.1:7/gl-wallet#s=tok", + queuedCount: 2, + createdAt: DESCRIPTOR.createdAt, + lastPagePollAt: Date.now() - (HEARTBEAT_DEAD_MS + 30_000), + }), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(log).toHaveBeenCalledWith( + "Wallet session:", + expect.objectContaining({ + tabHeartbeat: expect.stringMatching(/stale/), + queuedTransactions: 2, + }), + ); + expect(process.exitCode).toBe(0); + }); + + test("stale descriptor (pid dead) → warns + cleans up, exit code 1, never reads state", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(false); + const client = { + ping: vi.fn().mockResolvedValue(true), + state: vi.fn(), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(logWarning).toHaveBeenCalledWith(expect.stringMatching(/stale/i)); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(process.exitCode).toBe(1); + expect(client.state).not.toHaveBeenCalled(); + }); + + test("stale descriptor (pid alive but ping fails) → warns + cleans up, exit code 1", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + vi.mocked(isPidAlive).mockReturnValue(true); + const client = { + ping: vi.fn().mockResolvedValue(false), + state: vi.fn(), + }; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + + await action.status(); + + expect(logWarning).toHaveBeenCalledWith(expect.stringMatching(/stale/i)); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(process.exitCode).toBe(1); + expect(client.state).not.toHaveBeenCalled(); + }); +}); + +describe("WalletAction.disconnect", () => { + let action: WalletAction; + let logInfo: any; + let logSuccess: any; + let killSpy: any; + + beforeEach(() => { + vi.mocked(readDescriptor).mockReset(); + vi.mocked(removeDescriptor).mockReset(); + vi.mocked(isPidAlive).mockReset(); + vi.mocked(WalletSessionClient).mockReset(); + + action = new WalletAction(); + logInfo = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + logSuccess = vi.spyOn(action as any, "logSuccess").mockImplementation(() => {}); + // Never send a real signal at a real pid. + killSpy = vi.spyOn(process, "kill").mockImplementation(() => true as any); + }); + afterEach(() => vi.restoreAllMocks()); + + test("no descriptor → 'No active wallet session.', no throw, no client", async () => { + vi.mocked(readDescriptor).mockReturnValue(null); + + await expect(action.disconnect()).resolves.toBeUndefined(); + + expect(logInfo).toHaveBeenCalledWith("No active wallet session."); + expect(WalletSessionClient).not.toHaveBeenCalled(); + expect(removeDescriptor).not.toHaveBeenCalled(); + expect(killSpy).not.toHaveBeenCalled(); + }); + + test("live daemon, pid exits cleanly → shutdown + removeDescriptor, no SIGTERM", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + const client = {shutdown: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + // Daemon exits within the grace window. + vi.spyOn(action as any, "waitForPidGone").mockResolvedValue(true); + + await action.disconnect(); + + expect(client.shutdown).toHaveBeenCalledTimes(1); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(killSpy).not.toHaveBeenCalled(); + expect(logSuccess).toHaveBeenCalledWith("Disconnected."); + }); + + test("live daemon, pid lingers → SIGTERM fallback then removeDescriptor", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + const client = {shutdown: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + // Grace window elapses with the daemon still alive → SIGTERM path. + vi.spyOn(action as any, "waitForPidGone").mockResolvedValue(false); + vi.mocked(isPidAlive).mockReturnValue(true); + + await action.disconnect(); + + expect(client.shutdown).toHaveBeenCalledTimes(1); + expect(killSpy).toHaveBeenCalledWith(DESCRIPTOR.pid, "SIGTERM"); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(logSuccess).toHaveBeenCalledWith("Disconnected."); + }); + + test("pid lingers but a racing exit makes isPidAlive false → no SIGTERM, still cleans up", async () => { + vi.mocked(readDescriptor).mockReturnValue(DESCRIPTOR); + const client = {shutdown: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(WalletSessionClient).mockReturnValue(client as any); + vi.spyOn(action as any, "waitForPidGone").mockResolvedValue(false); + // waitForPidGone timed out, but the daemon has since exited. + vi.mocked(isPidAlive).mockReturnValue(false); + + await action.disconnect(); + + expect(killSpy).not.toHaveBeenCalled(); + expect(removeDescriptor).toHaveBeenCalledWith("/tmp/wallet-session.json"); + expect(logSuccess).toHaveBeenCalledWith("Disconnected."); + }); +}); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index e3820c7b..b5a9bbab 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -1,5 +1,14 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {createClient, createAccount} from "genlayer-js"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import { + createClient, + createAccount, + isSuccessful, + formatStakingAmount, + deriveExternalMessageCallKey, +} from "genlayer-js"; import {WriteAction} from "../../src/commands/contracts/write"; vi.mock("genlayer-js"); @@ -10,14 +19,43 @@ 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); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); + 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"); + 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); + return ( + (statusName === "ACCEPTED" || statusName === "FINALIZED") && + executionResultName === "FINISHED_WITH_RETURN" + ); + }); writeAction = new WriteAction(); vi.spyOn(writeAction as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); @@ -34,7 +72,7 @@ describe("WriteAction", () => { test("calls writeContract successfully", async () => { const options = {args: [42, "Update"]}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -52,15 +90,22 @@ describe("WriteAction", () => { value: 0n, }); expect(writeAction["log"]).toHaveBeenCalledWith("Write Transaction Hash:", mockHash); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - mockReceipt, - ); + expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: mockHash, + retries: 100, + interval: 5000, + waitUntil: "decided", + fullTransaction: true, + }); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); }); test("calls writeContract with fee options", async () => { const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -73,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", @@ -93,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")); @@ -116,10 +225,126 @@ describe("WriteAction", () => { ); }); + test("fails when write reaches consensus but execution fails", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_ERROR", + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("leader execution result: FINISHED_WITH_ERROR"), + }), + ); + }); + + test("fails when write is undetermined despite leader return", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "UNDETERMINED", + txExecutionResultName: "FINISHED_WITH_RETURN", + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("UNDETERMINED"), + }), + ); + }); + + test("diagnoses leader execution timeout", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 3, + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("leader timed out during execution"), + }), + ); + }); + + test("diagnoses non-deterministic disagreement", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResult: 4, + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("validators disagreed on non-deterministic output"), + }), + ); + }); + + test("fails when write is canceled", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "CANCELED", + txExecutionResultName: "NOT_VOTED", + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["failSpinner"]).toHaveBeenCalledWith( + "Error during write operation", + expect.objectContaining({ + message: expect.stringContaining("CANCELED before execution"), + }), + ); + }); + + test("accepts studio-shaped successful receipt", async () => { + const mockHash = "0xMockedTransactionHash"; + + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + data: { + consensus_data: { + leader_receipt: [{execution_result: "SUCCESS"}], + }, + }, + }); + + await writeAction.write({contractAddress: "0xMockedContract", method: "updateData", args: [1]}); + + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( + "Write operation successfully executed", + expect.objectContaining({consensusStatus: "ACCEPTED"}), + ); + }); + test("uses custom RPC URL for write operations", async () => { const options = {args: [42, "Update"], rpc: "https://custom-rpc-url.com"}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -141,9 +366,52 @@ describe("WriteAction", () => { args: [42, "Update"], value: 0n, }); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - mockReceipt, - ); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); + }); + + describe("WriteAction --wallet browser", () => { + test("wires the browser provider into the client and never touches the keystore", async () => { + const session = { + signerAddress: "0xBrowser", + eip1193Provider: {request: vi.fn()}, + setNextLabel: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + }; + // Lane B: getClient (BaseAction) builds the client itself. Stub the browser + // session opener so the real getClient runs, then assert the wiring. + const getBrowserSessionSpy = vi + .spyOn(writeAction as any, "getBrowserSession") + .mockResolvedValue(session); + const getAccountSpy = vi.spyOn(writeAction as any, "getAccount"); + + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xC", + method: "m", + args: [], + wallet: "browser", + }); + + expect(getBrowserSessionSpy).toHaveBeenCalled(); + expect(createClient).toHaveBeenCalledWith( + expect.objectContaining({ + account: "0xBrowser", + provider: session.eip1193Provider, + }), + ); + // No keystore/keychain/password path in browser mode. + expect(getAccountSpy).not.toHaveBeenCalled(); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( + "Write operation successfully executed", + expect.objectContaining({consensusStatus: "ACCEPTED"}), + ); + }); }); }); diff --git a/tests/commands/balances.test.ts b/tests/commands/balances.test.ts new file mode 100644 index 00000000..6f7ada2f --- /dev/null +++ b/tests/commands/balances.test.ts @@ -0,0 +1,79 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeBalancesCommands} from "../../src/commands/balances"; +import {VestingAction} from "../../src/commands/vesting/VestingAction"; +import {BalancesAction} from "../../src/commands/balances/BalancesAction"; + +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) => BigInt(value)), +})); + +vi.mock("genlayer-js/chains", () => ({ + localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, + studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, + testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://rpc-asimov.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, + testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://rpc-bradbury.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, +})); + +const mockClient = { + getBalance: vi.fn(), + getCode: vi.fn().mockResolvedValue("0x6001"), // consensus infra deployed by default + getBeneficiaryVestings: vi.fn(), + getVestingState: vi.fn(), + getValidatorWallets: vi.fn(), + validatorDeposited: vi.fn(), + getActiveValidators: vi.fn(), + getQuarantinedValidatorsDetailed: vi.fn(), + getBannedValidators: vi.fn(), + vestingDepositedPerValidator: vi.fn(), +}; + +describe("balances command", () => { + let program: Command; + let consoleLogSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockClient.getBalance.mockResolvedValue(0n); + mockClient.getCode.mockResolvedValue("0x6001"); // consensus infra deployed + mockClient.getBeneficiaryVestings.mockResolvedValue([]); + mockClient.getActiveValidators.mockResolvedValue([]); + mockClient.getQuarantinedValidatorsDetailed.mockResolvedValue([]); + mockClient.getBannedValidators.mockResolvedValue([]); + + vi.spyOn(VestingAction.prototype as any, "getReadOnlyVestingClient").mockResolvedValue(mockClient); + vi.spyOn(VestingAction.prototype as any, "getSignerAddress").mockResolvedValue("0xBeneficiary"); + vi.spyOn(BalancesAction.prototype as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(BalancesAction.prototype as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(BalancesAction.prototype as any, "stopSpinner").mockImplementation(() => {}); + vi.spyOn(BalancesAction.prototype as any, "failSpinner").mockImplementation(() => {}); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + program = new Command(); + initializeBalancesCommands(program); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("--beneficiary queries that address and renders output", async () => { + await program.parseAsync(["node", "test", "balances", "--beneficiary", "0xExplicit"]); + + expect(mockClient.getBalance).toHaveBeenCalledWith({address: "0xExplicit"}); + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xExplicit", undefined); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test("defaults to the active account address without unlocking", async () => { + await program.parseAsync(["node", "test", "balances"]); + + expect(mockClient.getBalance).toHaveBeenCalledWith({address: "0xBeneficiary"}); + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", undefined); + }); +}); 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..4107a4b2 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -12,7 +12,10 @@ 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"; +import {ValidatorWizardAction} from "../../src/commands/staking/wizard"; +vi.mock("../../src/commands/staking/wizard"); vi.mock("../../src/commands/staking/validatorJoin"); vi.mock("../../src/commands/staking/validatorDeposit"); vi.mock("../../src/commands/staking/validatorExit"); @@ -24,6 +27,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; @@ -48,6 +52,31 @@ describe("staking commands", () => { }); }); + test("leaves --wallet unset when omitted", async () => { + program.parse(["node", "test", "staking", "validator-join", "--amount", "42000gen"]); + + // No commander default: the flag key is absent (keystore is resolved later). + const arg = (ValidatorJoinAction.prototype.execute as any).mock.calls[0][0]; + expect(arg.wallet).toBeUndefined(); + }); + + test("parses --wallet browser", async () => { + program.parse([ + "node", + "test", + "staking", + "validator-join", + "--amount", + "42000gen", + "--wallet", + "browser", + ]); + + expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith( + expect.objectContaining({wallet: "browser"}), + ); + }); + test("calls ValidatorJoinAction.execute with operator", async () => { program.parse([ "node", @@ -84,9 +113,36 @@ describe("staking commands", () => { }); }); + describe("wizard", () => { + test("leaves --wallet unset when omitted", async () => { + program.parse(["node", "test", "staking", "wizard"]); + + expect(ValidatorWizardAction).toHaveBeenCalledTimes(1); + const arg = (ValidatorWizardAction.prototype.execute as any).mock.calls[0][0]; + expect(arg.wallet).toBeUndefined(); + }); + + test("parses --wallet browser", async () => { + program.parse(["node", "test", "staking", "wizard", "--wallet", "browser"]); + + expect(ValidatorWizardAction.prototype.execute).toHaveBeenCalledWith( + expect.objectContaining({wallet: "browser"}), + ); + }); + }); + describe("validator-deposit", () => { test("calls ValidatorDepositAction.execute", async () => { - program.parse(["node", "test", "staking", "validator-deposit", "--validator", "0x1234567890123456789012345678901234567890", "--amount", "1000gen"]); + program.parse([ + "node", + "test", + "staking", + "validator-deposit", + "--validator", + "0x1234567890123456789012345678901234567890", + "--amount", + "1000gen", + ]); expect(ValidatorDepositAction).toHaveBeenCalledTimes(1); expect(ValidatorDepositAction.prototype.execute).toHaveBeenCalledWith({ @@ -98,7 +154,16 @@ describe("staking commands", () => { describe("validator-exit", () => { test("calls ValidatorExitAction.execute", async () => { - program.parse(["node", "test", "staking", "validator-exit", "--validator", "0x1234567890123456789012345678901234567890", "--shares", "100"]); + program.parse([ + "node", + "test", + "staking", + "validator-exit", + "--validator", + "0x1234567890123456789012345678901234567890", + "--shares", + "100", + ]); expect(ValidatorExitAction).toHaveBeenCalledTimes(1); expect(ValidatorExitAction.prototype.execute).toHaveBeenCalledWith({ @@ -211,6 +276,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"]); @@ -293,14 +387,7 @@ describe("staking commands", () => { describe("delegation-info", () => { test("calls StakingInfoAction.getStakeInfo", async () => { - program.parse([ - "node", - "test", - "staking", - "delegation-info", - "--validator", - "0xValidator", - ]); + program.parse(["node", "test", "staking", "delegation-info", "--validator", "0xValidator"]); expect(StakingInfoAction).toHaveBeenCalledTimes(1); expect(StakingInfoAction.prototype.getStakeInfo).toHaveBeenCalledWith({ 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..7a606529 --- /dev/null +++ b/tests/commands/vesting.test.ts @@ -0,0 +1,593 @@ +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"; +import {VestingDelegateAction} from "../../src/commands/vesting/delegate"; +import {VestingValidatorDepositAction} from "../../src/commands/vesting/validatorDeposit"; + +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(), + getEpochInfo: vi.fn(), + getValidatorInfo: 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); + // Self-stake pre-submit checks: min read from epochInfo (42 GEN here, met by + // the 42gen create / 42gen+10gen deposit) and the vesting-wallet ownership + // guard (isValidatorWallet true above). + mockClient.getEpochInfo.mockResolvedValue({ + currentEpoch: 5n, + validatorMinStake: "42 GEN", + validatorMinStakeRaw: 42n * BigInt(1e18), + delegatorMinStake: "42 GEN", + delegatorMinStakeRaw: 42n * BigInt(1e18), + }); + mockClient.getValidatorInfo.mockResolvedValue({ + address: "0xWallet", + owner: "0xVesting", + operator: "0xOperator", + vStake: "42 GEN", + vStakeRaw: 42n * BigInt(1e18), + dStakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }); + 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("list honors a live wallet session over the keystore default", async () => { + // A session is live and no keystore opt-out → resolveWalletMode → browser. + vi.spyOn(VestingAction.prototype as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi + .spyOn(VestingAction.prototype as any, "liveSessionAddress") + .mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(VestingAction.prototype as any, "getSignerAddress"); + + await program.parseAsync(["node", "test", "vesting", "list"]); + + // The connected session address, not the keystore default, drives the lookup. + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xSession", undefined); + expect(sessionSpy).toHaveBeenCalled(); + expect(signerSpy).not.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"); + }); + + test("delegate parses --wallet browser into the signing mode", async () => { + // Spy execute directly so parsing is asserted without opening a real browser + // session (the browser path is unit-tested in tests/actions/vesting.test.ts). + const executeSpy = vi + .spyOn(VestingDelegateAction.prototype as any, "execute") + .mockResolvedValue(undefined); + + await program.parseAsync([ + "node", + "test", + "vesting", + "delegate", + "0xValidator", + "--amount", + "42gen", + "--wallet", + "browser", + ]); + + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({wallet: "browser", validator: "0xValidator"}), + ); + }); + + test("delegate leaves --wallet unset when omitted (keystore resolved in the action)", async () => { + const executeSpy = vi + .spyOn(VestingDelegateAction.prototype as any, "execute") + .mockResolvedValue(undefined); + + await program.parseAsync(["node", "test", "vesting", "delegate", "0xValidator", "--amount", "42gen"]); + + // No commander default: the omitted flag is undefined; the effective mode + // (keystore, unless walletMode=browser config) is resolved in resolveWalletMode. + expect((executeSpy.mock.calls[0][0] as any).wallet).toBeUndefined(); + }); + + test("validator deposit routes the deprecated --validator-wallet flag to walletAddress", async () => { + const executeSpy = vi + .spyOn(VestingValidatorDepositAction.prototype as any, "execute") + .mockResolvedValue(undefined); + + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "deposit", + "--validator-wallet", + "0xWallet", + "--amount", + "1gen", + ]); + + // The deprecated --validator-wallet flag supplies the address; --wallet + // (signing mode) must not be interpreted as the wallet address. + const depositArg = executeSpy.mock.calls[0][0] as any; + expect(depositArg.walletAddress).toBe("0xWallet"); + expect(depositArg.wallet).toBeUndefined(); + }); +}); diff --git a/tests/commands/wallet.test.ts b/tests/commands/wallet.test.ts new file mode 100644 index 00000000..019a5d6d --- /dev/null +++ b/tests/commands/wallet.test.ts @@ -0,0 +1,64 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeWalletCommands} from "../../src/commands/wallet"; +import {WalletAction} from "../../src/commands/wallet/WalletAction"; + +vi.mock("../../src/commands/wallet/WalletAction"); + +describe("wallet commands", () => { + let program: Command; + + beforeEach(() => { + program = new Command(); + initializeWalletCommands(program); + vi.clearAllMocks(); + }); + + afterEach(() => vi.restoreAllMocks()); + + test("connect passes --network and --rpc", async () => { + program.parse([ + "node", + "test", + "wallet", + "connect", + "--network", + "testnet-bradbury", + "--rpc", + "https://r", + ]); + expect(WalletAction.prototype.connect).toHaveBeenCalledWith( + expect.objectContaining({network: "testnet-bradbury", rpc: "https://r"}), + ); + }); + + test("connect works with no flags", async () => { + program.parse(["node", "test", "wallet", "connect"]); + expect(WalletAction.prototype.connect).toHaveBeenCalledTimes(1); + }); + + test("status is dispatched", async () => { + program.parse(["node", "test", "wallet", "status"]); + expect(WalletAction.prototype.status).toHaveBeenCalledTimes(1); + }); + + test("disconnect is dispatched", async () => { + program.parse(["node", "test", "wallet", "disconnect"]); + expect(WalletAction.prototype.disconnect).toHaveBeenCalledTimes(1); + }); + + test("daemon subcommand exists (hidden) and dispatches", async () => { + program.parse(["node", "test", "wallet", "daemon", "--network", "localnet"]); + expect(WalletAction.prototype.daemon).toHaveBeenCalledWith( + expect.objectContaining({network: "localnet"}), + ); + }); + + test("daemon is hidden from the wallet help/command listing", () => { + const wallet = program.commands.find(c => c.name() === "wallet")!; + const daemon = wallet.commands.find(c => c.name() === "daemon")!; + // Present but hidden. + expect(daemon).toBeDefined(); + expect((daemon as any)._hidden).toBe(true); + }); +}); 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..815ce0b8 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -49,6 +49,18 @@ vi.mock("../src/commands/staking", () => ({ initializeStakingCommands: vi.fn(), })); +vi.mock("../src/commands/vesting", () => ({ + initializeVestingCommands: vi.fn(), +})); + +vi.mock("../src/commands/wallet", () => ({ + initializeWalletCommands: vi.fn(), +})); + +vi.mock("../src/commands/balances", () => ({ + initializeBalancesCommands: vi.fn(), +})); + describe("CLI", () => { it("should initialize CLI", () => { expect(initializeCLI).not.toThrow(); diff --git a/tests/libs/availableToStake.test.ts b/tests/libs/availableToStake.test.ts new file mode 100644 index 00000000..086e36a3 --- /dev/null +++ b/tests/libs/availableToStake.test.ts @@ -0,0 +1,47 @@ +import {describe, test, expect, vi} from "vitest"; +import {vestingAvailableToStake} from "../../src/lib/vesting/availableToStake"; +import type {Address} from "genlayer-js/types"; + +const VESTING: Address = "0xVesting000000000000000000000000000000001" as Address; + +describe("vestingAvailableToStake", () => { + test("revoked contract → returns 0n and never reads the balance", async () => { + const getBalance = vi.fn(); + const client = {getBalance}; + + const result = await vestingAvailableToStake(client, VESTING, true); + + expect(result).toBe(0n); + // Revoked staking is disabled outright — no RPC read should happen. + expect(getBalance).not.toHaveBeenCalled(); + }); + + test("not revoked → returns the on-chain balance, read against the vesting address", async () => { + const getBalance = vi.fn().mockResolvedValue(12345678901234567890n); + const client = {getBalance}; + + const result = await vestingAvailableToStake(client, VESTING, false); + + expect(result).toBe(12345678901234567890n); + expect(getBalance).toHaveBeenCalledTimes(1); + expect(getBalance).toHaveBeenCalledWith({address: VESTING}); + }); + + test("not revoked, zero balance → returns 0n (still consulted the chain)", async () => { + const getBalance = vi.fn().mockResolvedValue(0n); + const client = {getBalance}; + + const result = await vestingAvailableToStake(client, VESTING, false); + + expect(result).toBe(0n); + expect(getBalance).toHaveBeenCalledWith({address: VESTING}); + }); + + test("not revoked → a failing balance read propagates (no silent 0)", async () => { + const boom = new Error("rpc down"); + const getBalance = vi.fn().mockRejectedValue(boom); + const client = {getBalance}; + + await expect(vestingAvailableToStake(client, VESTING, false)).rejects.toThrow("rpc down"); + }); +}); diff --git a/tests/libs/baseAction.test.ts b/tests/libs/baseAction.test.ts index 91ad3f9f..b4d8b140 100644 --- a/tests/libs/baseAction.test.ts +++ b/tests/libs/baseAction.test.ts @@ -255,6 +255,38 @@ describe("BaseAction", () => { }]); }); + test("rewrites inquirer's force-close (no TTY, no piped input) into an actionable error", async () => { + // inquirer throws ExitPromptError when a prompt can't be satisfied; that + // reads like Ctrl-C when a flag is actually missing. We rewrite it, naming + // the flag. (We do NOT pre-empt with an isTTY guard — piped stdin must still + // reach inquirer; see the deploy path in the e2e harness.) + const exitErr = Object.assign(new Error("User force closed the prompt with 0 null"), { + name: "ExitPromptError", + }); + vi.mocked(inquirer.prompt).mockRejectedValue(exitErr); + + await expect( + baseAction["promptPassword"]("Enter password:", "Pass --source-password to run non-interactively."), + ).rejects.toThrow(/No interactive terminal available.*--source-password/s); + expect(inquirer.prompt).toHaveBeenCalled(); // inquirer IS invoked; we only rewrite its force-close + }); + + test("surfaces the generic hint when no flag hint is given", async () => { + const exitErr = Object.assign(new Error("User force closed the prompt with 0 null"), { + name: "ExitPromptError", + }); + vi.mocked(inquirer.prompt).mockRejectedValue(exitErr); + + await expect(baseAction["promptPassword"]("Enter password:")).rejects.toThrow( + /No interactive terminal available.*corresponding command flag/s, + ); + }); + + test("passes through a non-force-close prompt error unchanged", async () => { + vi.mocked(inquirer.prompt).mockRejectedValue(new Error("some other inquirer failure")); + await expect(baseAction["promptPassword"]("Enter password:")).rejects.toThrow(/some other inquirer failure/); + }); + test("should validate password input is not empty", async () => { vi.mocked(inquirer.prompt).mockResolvedValue({password: "valid-password"}); diff --git a/tests/libs/browserBridge.test.ts b/tests/libs/browserBridge.test.ts new file mode 100644 index 00000000..e9aa99b1 --- /dev/null +++ b/tests/libs/browserBridge.test.ts @@ -0,0 +1,467 @@ +import {describe, test, expect, vi, beforeEach, afterEach} from "vitest"; +import http from "node:http"; +import {BrowserWalletBridge, type BridgeChainParams} from "../../src/lib/wallet/browserBridge"; + +/** + * Raw HTTP GET that lets us set an arbitrary Host header. undici's `fetch` + * ignores a caller-supplied Host (it derives it from the URL), so the + * Host-validation guard can only be exercised through node:http. + */ +function rawGet( + port: number, + path: string, + headers: Record, +): Promise<{status: number; body: string}> { + return new Promise((resolve, reject) => { + const req = http.request({host: "127.0.0.1", port, path, method: "GET", headers}, res => { + let body = ""; + res.on("data", c => (body += c)); + res.on("end", () => resolve({status: res.statusCode ?? 0, body})); + }); + req.on("error", reject); + req.end(); + }); +} + +const CHAIN: BridgeChainParams = { + chainId: 4221, + chainName: "Genlayer Bradbury Testnet", + rpcUrls: ["https://rpc.example"], + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorerUrls: ["https://explorer.example"], +}; + +const ADDRESS = "0xConnectedAddress0000000000000000000000000" as `0x${string}`; + +/** Parse origin + token out of the bridge URL (http://127.0.0.1:/#s=). */ +function parse(url: string): {origin: string; token: string} { + const u = new URL(url); + const origin = `${u.protocol}//${u.host}`; + const token = new URLSearchParams(u.hash.slice(1)).get("s")!; + return {origin, token}; +} + +const activeBridges: BrowserWalletBridge[] = []; + +function makeBridge(overrides: Partial[0]> = {}) { + const openUrl = vi.fn().mockResolvedValue(undefined); + const bridge = new BrowserWalletBridge({ + chain: CHAIN, + openUrl, + handleSigint: false, + ...overrides, + }); + activeBridges.push(bridge); + return {bridge, openUrl}; +} + +describe("BrowserWalletBridge", () => { + let bridge: BrowserWalletBridge; + let origin: string; + let token: string; + let openUrl: ReturnType; + + beforeEach(async () => { + activeBridges.length = 0; + ({bridge, openUrl} = makeBridge()); + const {url} = await bridge.start(); + ({origin, token} = parse(url)); + }); + + afterEach(async () => { + // Close every bridge a test created so no server / pending promise leaks. + await Promise.all(activeBridges.map(b => b.close().catch(() => {}))); + activeBridges.length = 0; + }); + + const authGet = (path: string) => fetch(`${origin}${path}`, {headers: {"X-Bridge-Token": token}}); + const authPost = (path: string, body: unknown) => + fetch(`${origin}${path}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify(body), + }); + + test("start() binds loopback, opens the URL, and serves the page", async () => { + expect(origin).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + expect(openUrl).toHaveBeenCalledOnce(); + const res = await fetch(`${origin}/`); + expect(res.status).toBe(200); + const html = await res.text(); + expect(html).toContain("GenLayer CLI"); + expect(res.headers.get("cache-control")).toBe("no-store"); + }); + + test("/api/session returns chain params incl. chainIdHex", async () => { + const res = await authGet("/api/session"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.chain.chainId).toBe(4221); + expect(body.chain.chainIdHex).toBe("0x107d"); + expect(body.chain.chainName).toBe(CHAIN.chainName); + }); + + test("rejects API calls with a missing or wrong token (403)", async () => { + const noToken = await fetch(`${origin}/api/session`); + expect(noToken.status).toBe(403); + const badToken = await fetch(`${origin}/api/session`, {headers: {"X-Bridge-Token": "nope"}}); + expect(badToken.status).toBe(403); + }); + + test("rejects a request with a non-loopback Host header (403), accepts loopback", async () => { + const port = Number(new URL(origin).port); + const bad = await rawGet(port, "/api/session", {Host: "evil.example", "X-Bridge-Token": token}); + expect(bad.status).toBe(403); + expect(bad.body).toMatch(/bad host/i); + // Sanity: the same request with the correct loopback Host is served. + const ok = await rawGet(port, "/api/session", {Host: `127.0.0.1:${port}`, "X-Bridge-Token": token}); + expect(ok.status).toBe(200); + const okLocalhost = await rawGet(port, "/api/session", {Host: `localhost:${port}`, "X-Bridge-Token": token}); + expect(okLocalhost.status).toBe(200); + }); + + test("rejects a POST with a wrong Origin header (403)", async () => { + const res = await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: "http://evil.example"}, + body: JSON.stringify({address: ADDRESS}), + }); + expect(res.status).toBe(403); + }); + + test("connected handshake resolves waitForConnection()", async () => { + const connectionPromise = bridge.waitForConnection(); + await authPost("/api/connected", {address: ADDRESS}); + await expect(connectionPromise).resolves.toBe(ADDRESS); + }); + + test("full happy path: connect -> next(tx) -> result(sent) -> hash", async () => { + await authPost("/api/connected", {address: ADDRESS}); + await bridge.waitForConnection(); + + const sendPromise = bridge.sendTransaction({ + to: "0xStaking00000000000000000000000000000000" as `0x${string}`, + data: "0xabcdef" as `0x${string}`, + value: 100n, + label: "Join as validator", + }); + + const nextRes = await authGet("/api/next"); + const next = await nextRes.json(); + expect(next.type).toBe("tx"); + expect(next.tx.to).toBe("0xStaking00000000000000000000000000000000"); + expect(next.tx.value).toBe("0x64"); // 100 in hex + expect(next.tx.chainId).toBe("0x107d"); + expect(next.tx.label).toBe("Join as validator"); + + await authPost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xhash", from: ADDRESS}); + await expect(sendPromise).resolves.toBe("0xhash"); + }); + + test("rejects a result whose `from` differs from the connected signer (account switch)", async () => { + await authPost("/api/connected", {address: ADDRESS}); + await bridge.waitForConnection(); + + const sendPromise = bridge.sendTransaction({ + to: "0xStaking00000000000000000000000000000000" as `0x${string}`, + data: "0xabcdef" as `0x${string}`, + value: 100n, + label: "Join as validator", + }); + // Attach the rejection expectation before delivering the mismatched result + // so the rejection is never momentarily unhandled. + const assertion = expect(sendPromise).rejects.toThrow(/expected signer/i); + + const next = await (await authGet("/api/next")).json(); + const OTHER = "0xOtherAccount00000000000000000000000000000" as `0x${string}`; + // Wallet switched accounts mid-flight and returned a result from OTHER. + await authPost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xhash", from: OTHER}); + await assertion; + }); + + test("serializes gas/type/nonce pass-through as hex quantities when present", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({ + to: "0xConsensus" as any, + data: "0xdead" as `0x${string}`, + value: 100n, + gas: 21000n, + gasPrice: 1n, + nonce: 2, + type: "0x0", + label: "IC write", + }); + const next = await (await authGet("/api/next")).json(); + expect(next.tx.gas).toBe("0x5208"); // 21000 + expect(next.tx.gasPrice).toBe("0x1"); + expect(next.tx.nonce).toBe("0x2"); + expect(next.tx.type).toBe("0x0"); + await authPost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xh"}); + await expect(sendPromise).resolves.toBe("0xh"); + }); + + test("omits gas/type/nonce when absent (backward compatible)", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0xA" as any, data: "0x01", label: "bare"}); + const next = await (await authGet("/api/next")).json(); + expect(next.tx.gas).toBeUndefined(); + expect(next.tx.type).toBeUndefined(); + expect(next.tx.nonce).toBeUndefined(); + await authPost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xh2"}); + await expect(sendPromise).resolves.toBe("0xh2"); + }); + + test("multi-tx sequential: two sends delivered and resolved in order", async () => { + await authPost("/api/connected", {address: ADDRESS}); + + const p1 = bridge.sendTransaction({to: "0xA" as any, data: "0x01", label: "tx1"}); + const n1 = await (await authGet("/api/next")).json(); + expect(n1.tx.label).toBe("tx1"); + await authPost("/api/result", {id: n1.tx.id, status: "sent", txHash: "0xhash1"}); + await expect(p1).resolves.toBe("0xhash1"); + + const p2 = bridge.sendTransaction({to: "0xB" as any, data: "0x02", label: "tx2"}); + const n2 = await (await authGet("/api/next")).json(); + expect(n2.tx.label).toBe("tx2"); + await authPost("/api/result", {id: n2.tx.id, status: "sent", txHash: "0xhash2"}); + await expect(p2).resolves.toBe("0xhash2"); + }); + + test("long-poll returns {type:'none'} then a tx when queued (deliver via waiter)", async () => { + ({bridge, openUrl} = makeBridge({txTimeoutMs: 2000})); + const {url} = await bridge.start(); + ({origin, token} = parse(url)); + + // Start a poll BEFORE any tx is queued — it should be held. + const pollPromise = authGet("/api/next").then(r => r.json()); + // Queue a tx; the held waiter must be resolved with it. + const sendPromise = bridge.sendTransaction({to: "0xC" as any, data: "0x03", label: "held-tx"}); + const held = await pollPromise; + expect(held.type).toBe("tx"); + expect(held.tx.label).toBe("held-tx"); + await authPost("/api/result", {id: held.tx.id, status: "sent", txHash: "0xheld"}); + await expect(sendPromise).resolves.toBe("0xheld"); + }); + + test("rejected result rejects sendTransaction with a clear message", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0xD" as any, data: "0x04", label: "reject-me"}); + const assertion = expect(sendPromise).rejects.toThrow(/rejected in wallet/i); + const next = await (await authGet("/api/next")).json(); + await authPost("/api/result", {id: next.tx.id, status: "rejected"}); + await assertion; + }); + + test("error result rejects with the provided message", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0xE" as any, data: "0x05", label: "err-me"}); + const assertion = expect(sendPromise).rejects.toThrow(/insufficient funds/); + const next = await (await authGet("/api/next")).json(); + await authPost("/api/result", {id: next.tx.id, status: "error", message: "insufficient funds"}); + await assertion; + }); + + test("sendTransaction times out when the wallet never signs", async () => { + ({bridge} = makeBridge({txTimeoutMs: 40})); + await bridge.start(); + const sendPromise = bridge.sendTransaction({to: "0xF" as any, data: "0x06", label: "slow"}); + await expect(sendPromise).rejects.toThrow(/Timed out waiting for the wallet to sign/); + }); + + test("waitForConnection times out when nobody connects", async () => { + ({bridge} = makeBridge({connectTimeoutMs: 40})); + await bridge.start(); + await expect(bridge.waitForConnection()).rejects.toThrow(/Timed out waiting for the browser wallet/); + }); + + test("done/abort messages are delivered to a polling page", async () => { + // done + { + const {bridge: b} = makeBridge(); + const {url} = await b.start(); + const p = parse(url); + const poll = fetch(`${b.getUrl().split("#")[0]}api/next`, {headers: {"X-Bridge-Token": p.token}}).then( + r => r.json(), + ); + await b.close("wrapped up"); + const msg = await poll; + expect(msg.type).toBe("done"); + expect(msg.message).toBe("wrapped up"); + } + }); + + test("server is closed after close() (subsequent fetch fails)", async () => { + const url = bridge.getUrl(); + const base = url.split("#")[0]; + await bridge.close(); + await expect(fetch(base)).rejects.toBeTruthy(); + }); + + test("close() rejects a pending sendTransaction", async () => { + await authPost("/api/connected", {address: ADDRESS}); + const sendPromise = bridge.sendTransaction({to: "0x1" as any, data: "0x07", label: "pending"}); + // Attach the rejection expectation BEFORE close() so the rejection is never + // momentarily unhandled. + const assertion = expect(sendPromise).rejects.toThrow(/Bridge closed/); + await bridge.close(); + await assertion; + }); +}); + +describe("BrowserWalletBridge — persistent (daemon) mode", () => { + let bridge: BrowserWalletBridge; + let origin: string; + let token: string; + + const authGet = (path: string) => fetch(`${origin}${path}`, {headers: {"X-Bridge-Token": token}}); + const clientPost = (path: string, body: unknown) => + // No Origin header — mimics a Node CLI client (must be exempt from the check). + fetch(`${origin}${path}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json"}, + body: JSON.stringify(body), + }); + const pagePost = (path: string, body: unknown) => + fetch(`${origin}${path}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify(body), + }); + + beforeEach(async () => { + activeBridges.length = 0; + ({bridge} = makeBridge({persistent: true, txTimeoutMs: 5000})); + const {url} = await bridge.start(); + ({origin, token} = parse(url)); + }); + + afterEach(async () => { + await Promise.all(activeBridges.map(b => b.close().catch(() => {}))); + activeBridges.length = 0; + }); + + test("/api/ping and /api/state require the token (403 without)", async () => { + expect((await fetch(`${origin}/api/ping`)).status).toBe(403); + expect((await fetch(`${origin}/api/state`)).status).toBe(403); + expect((await authGet("/api/ping")).status).toBe(200); + }); + + test("/api/session advertises persistent:true", async () => { + const body = await (await authGet("/api/session")).json(); + expect(body.persistent).toBe(true); + }); + + test("enqueue → page next → result → /api/tx reports done/sent/hash", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + // Prime the heartbeat with one poll so the tab is considered alive. + // (a page just after connect polls immediately) + const enq = await (await clientPost("/api/enqueue", {to: "0xTo", data: "0xabcd", value: "0x64", label: "L"})).json(); + expect(typeof enq.id).toBe("string"); + + // Page fetches the tx and reports the result. + const next = await (await authGet("/api/next")).json(); + expect(next.type).toBe("tx"); + expect(next.tx.to).toBe("0xTo"); + expect(next.tx.value).toBe("0x64"); + + // Before result, /api/tx should be delivered (or pending). + const mid = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(["pending", "delivered"]).toContain(mid.state); + + await pagePost("/api/result", {id: next.tx.id, status: "sent", txHash: "0xhash", from: ADDRESS}); + const done = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(done.state).toBe("done"); + expect(done.status).toBe("sent"); + expect(done.txHash).toBe("0xhash"); + expect(done.from).toBe(ADDRESS); + }); + + test("enqueue → rejected result surfaces status:rejected", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + const enq = await (await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "r"})).json(); + const next = await (await authGet("/api/next")).json(); + await pagePost("/api/result", {id: next.tx.id, status: "rejected"}); + const done = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(done.state).toBe("done"); + expect(done.status).toBe("rejected"); + expect(done.message).toMatch(/rejected in wallet/i); + }); + + test("enqueue → error result surfaces status:error with message", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + const enq = await (await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "e"})).json(); + const next = await (await authGet("/api/next")).json(); + await pagePost("/api/result", {id: next.tx.id, status: "error", message: "insufficient funds"}); + const done = await (await authGet(`/api/tx?id=${enq.id}`)).json(); + expect(done.status).toBe("error"); + expect(done.message).toBe("insufficient funds"); + }); + + test("enqueue exempt from Origin check; page POST with bad Origin still 403", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + // client POST with NO origin succeeds (200). + const ok = await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "x"}); + expect(ok.status).toBe(200); + // page route with wrong origin is still rejected. + const bad = await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: "http://evil.example"}, + body: JSON.stringify({address: ADDRESS}), + }); + expect(bad.status).toBe(403); + }); + + test("enqueue returns 409 wallet-not-connected before connect", async () => { + const res = await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "x"}); + expect(res.status).toBe(409); + expect((await res.json()).error).toBe("wallet-not-connected"); + }); + + test("heartbeat: lastPagePollAt advances on /api/next", async () => { + const before = bridge.getState().lastPagePollAt; + // Fire a poll but do NOT await it: with nothing queued the server long-polls + // (holds ~25s). The heartbeat is stamped synchronously at handler entry. + void authGet("/api/next").catch(() => {}); + await new Promise(r => setTimeout(r, 50)); + const after = bridge.getState().lastPagePollAt; + expect(after).toBeGreaterThan(before); + expect(after).toBeGreaterThan(0); + }); + + test("two concurrent enqueues deliver strictly FIFO with independent results", async () => { + await pagePost("/api/connected", {address: ADDRESS}); + const a = await (await clientPost("/api/enqueue", {to: "0xA", data: "0x01", label: "first"})).json(); + const b = await (await clientPost("/api/enqueue", {to: "0xB", data: "0x02", label: "second"})).json(); + + const n1 = await (await authGet("/api/next")).json(); + expect(n1.tx.label).toBe("first"); + await pagePost("/api/result", {id: n1.tx.id, status: "sent", txHash: "0xh1"}); + + const n2 = await (await authGet("/api/next")).json(); + expect(n2.tx.label).toBe("second"); + await pagePost("/api/result", {id: n2.tx.id, status: "sent", txHash: "0xh2"}); + + expect((await (await authGet(`/api/tx?id=${a.id}`)).json()).txHash).toBe("0xh1"); + expect((await (await authGet(`/api/tx?id=${b.id}`)).json()).txHash).toBe("0xh2"); + }); + + test("/api/shutdown responds ok then closes the server", async () => { + const res = await clientPost("/api/shutdown", {}); + expect(res.status).toBe(200); + // Server should be closing/closed shortly after. + await new Promise(r => setTimeout(r, 120)); + await expect(fetch(`${origin}/`)).rejects.toBeTruthy(); + }); + + test("enqueue is 404 on a non-persistent bridge", async () => { + const {bridge: b} = makeBridge({persistent: false}); + const {url} = await b.start(); + const p = parse(url); + const res = await fetch(`${p.origin}/api/enqueue`, { + method: "POST", + headers: {"X-Bridge-Token": p.token, "Content-Type": "application/json"}, + body: JSON.stringify({to: "0xA", data: "0x01", label: "x"}), + }); + expect(res.status).toBe(404); + }); +}); diff --git a/tests/libs/browserSend.test.ts b/tests/libs/browserSend.test.ts new file mode 100644 index 00000000..1f6cd223 --- /dev/null +++ b/tests/libs/browserSend.test.ts @@ -0,0 +1,183 @@ +import {describe, test, expect, vi, beforeEach} from "vitest"; + +// Mock the bridge so no real HTTP server is started. +const bridgeSend = vi.fn(); +const bridgeClose = vi.fn().mockResolvedValue(undefined); +const bridgeStart = vi.fn().mockResolvedValue({url: "http://127.0.0.1:12345/#s=tok"}); +const bridgeWaitForConnection = vi.fn().mockResolvedValue("0xConnected0000000000000000000000000000001"); + +vi.mock("../../src/lib/wallet/browserBridge", () => ({ + BrowserWalletBridge: vi.fn().mockImplementation(() => ({ + start: bridgeStart, + waitForConnection: bridgeWaitForConnection, + sendTransaction: bridgeSend, + close: bridgeClose, + getUrl: () => "http://127.0.0.1:12345/#s=tok", + })), +})); + +// Mock viem's publicClient (preflight + receipt). +const publicCall = vi.fn().mockResolvedValue({data: "0x"}); +const waitForReceipt = vi.fn(); +vi.mock("viem", async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + createPublicClient: vi.fn(() => ({call: publicCall, waitForTransactionReceipt: waitForReceipt})), + }; +}); + +import {openBrowserWalletSession} from "../../src/lib/wallet/browserSend"; + +const CHAIN: any = { + id: 4221, + name: "Genlayer Bradbury Testnet", + rpcUrls: {default: {http: ["https://rpc.example"]}}, + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorers: {default: {url: "https://explorer.example"}}, +}; + +async function makeSession() { + return openBrowserWalletSession({chain: CHAIN, rpcUrl: "https://rpc.example"}); +} + +describe("openBrowserWalletSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + bridgeStart.mockResolvedValue({url: "http://127.0.0.1:12345/#s=tok"}); + bridgeWaitForConnection.mockResolvedValue("0xConnected0000000000000000000000000000001"); + publicCall.mockResolvedValue({data: "0x"}); + }); + + test("starts the bridge and resolves the connected signer address", async () => { + const session = await makeSession(); + expect(bridgeStart).toHaveBeenCalledOnce(); + expect(session.signerAddress).toBe("0xConnected0000000000000000000000000000001"); + }); + + describe("sendTransaction (Lane A)", () => { + test("preflights, queues to the bridge, waits the receipt, returns it", async () => { + bridgeSend.mockResolvedValue("0xhash"); + waitForReceipt.mockResolvedValue({ + status: "success", + transactionHash: "0xhash", + blockNumber: 1n, + gasUsed: 2n, + }); + const session = await makeSession(); + + const receipt = await session.sendTransaction({ + to: "0xTo000000000000000000000000000000000000001", + data: "0xabcd", + value: 100n, + label: "Test", + }); + + expect(publicCall).toHaveBeenCalledOnce(); + expect(bridgeSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xTo000000000000000000000000000000000000001", + data: "0xabcd", + value: 100n, + }), + ); + expect(receipt.transactionHash).toBe("0xhash"); + }); + + test("aborts + throws when the preflight predicts a revert", async () => { + publicCall.mockRejectedValue({shortMessage: "execution reverted"}); + const session = await makeSession(); + await expect(session.sendTransaction({to: "0xTo", data: "0x", label: "x"} as any)).rejects.toThrow( + /would revert \(preflight\): execution reverted/, + ); + expect(bridgeClose).toHaveBeenCalled(); + expect(bridgeSend).not.toHaveBeenCalled(); + }); + + test("throws on an on-chain revert receipt", async () => { + bridgeSend.mockResolvedValue("0xhash"); + waitForReceipt.mockResolvedValue({status: "reverted", transactionHash: "0xhash"}); + const session = await makeSession(); + await expect(session.sendTransaction({to: "0xTo", data: "0x", label: "x"} as any)).rejects.toThrow( + /reverted/, + ); + }); + }); + + describe("eip1193Provider (Lane B shim)", () => { + test("eth_chainId is answered locally as the configured chain id hex", async () => { + const session = await makeSession(); + await expect(session.eip1193Provider.request({method: "eth_chainId"})).resolves.toBe("0x107d"); + }); + + test("eth_accounts / eth_requestAccounts return the connected signer", async () => { + const session = await makeSession(); + await expect(session.eip1193Provider.request({method: "eth_accounts"})).resolves.toEqual([ + session.signerAddress, + ]); + await expect(session.eip1193Provider.request({method: "eth_requestAccounts"})).resolves.toEqual([ + session.signerAddress, + ]); + }); + + test("eth_sendTransaction forwards hex fields to the bridge and resolves the hash", async () => { + bridgeSend.mockResolvedValue("0xhash"); + const session = await makeSession(); + const hash = await session.eip1193Provider.request({ + method: "eth_sendTransaction", + params: [ + { + from: session.signerAddress, + to: "0xConsensus000000000000000000000000000000001", + data: "0xdeadbeef", + value: "0x64", + gas: "0x5208", + gasPrice: "0x1", + nonce: "0x2", + type: "0x0", + }, + ], + }); + expect(hash).toBe("0xhash"); + expect(bridgeSend).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xConsensus000000000000000000000000000000001", + data: "0xdeadbeef", + value: 100n, + gas: 21000n, + gasPrice: 1n, + nonce: 2, + type: "0x0", + }), + ); + // Does NOT wait for the receipt (the SDK does that). + expect(waitForReceipt).not.toHaveBeenCalled(); + }); + + test("setNextLabel is consumed for exactly the next send, then reset to default", async () => { + bridgeSend.mockResolvedValue("0xhash"); + const session = await makeSession(); + session.setNextLabel("Deploy Counter.py"); + await session.eip1193Provider.request({ + method: "eth_sendTransaction", + params: [{to: "0xA", data: "0x"}], + }); + expect(bridgeSend).toHaveBeenLastCalledWith(expect.objectContaining({label: "Deploy Counter.py"})); + + await session.eip1193Provider.request({ + method: "eth_sendTransaction", + params: [{to: "0xB", data: "0x"}], + }); + expect(bridgeSend).toHaveBeenLastCalledWith(expect.objectContaining({label: "GenLayer transaction"})); + }); + + test("unsupported methods throw a clear error", async () => { + const session = await makeSession(); + for (const method of ["personal_sign", "eth_signTypedData_v4", "eth_signTransaction"]) { + await expect(session.eip1193Provider.request({method})).rejects.toThrow( + new RegExp(`Method ${method} is not supported`), + ); + } + }); + }); +}); diff --git a/tests/libs/configFileManager.test.ts b/tests/libs/configFileManager.test.ts index 6ccec5f9..e92c5d47 100644 --- a/tests/libs/configFileManager.test.ts +++ b/tests/libs/configFileManager.test.ts @@ -35,10 +35,13 @@ describe("ConfigFileManager", () => { new ConfigFileManager(); expect(fs.existsSync).toHaveBeenCalledWith(mockFolderPath); - expect(fs.mkdirSync).toHaveBeenCalledWith(mockFolderPath, { recursive: true }); + // Config dir and keystores dir are created private (0o700) to protect keystores. + expect(fs.mkdirSync).toHaveBeenCalledWith(mockFolderPath, { recursive: true, mode: 0o700 }); + expect(fs.chmodSync).toHaveBeenCalledWith(mockFolderPath, 0o700); expect(fs.existsSync).toHaveBeenCalledWith(mockKeystoresPath); - expect(fs.mkdirSync).toHaveBeenCalledWith(mockKeystoresPath, { recursive: true }); + expect(fs.mkdirSync).toHaveBeenCalledWith(mockKeystoresPath, { recursive: true, mode: 0o700 }); + expect(fs.chmodSync).toHaveBeenCalledWith(mockKeystoresPath, 0o700); expect(fs.existsSync).toHaveBeenCalledWith(mockConfigFilePath); expect(fs.writeFileSync).toHaveBeenCalledWith(mockConfigFilePath, JSON.stringify({}, null, 2)); diff --git a/tests/libs/sessionClient.test.ts b/tests/libs/sessionClient.test.ts new file mode 100644 index 00000000..81fd9953 --- /dev/null +++ b/tests/libs/sessionClient.test.ts @@ -0,0 +1,157 @@ +import {describe, test, expect, beforeEach, afterEach} from "vitest"; +import { + BrowserWalletBridge, + serializeBridgeTx, + type BridgeChainParams, +} from "../../src/lib/wallet/browserBridge"; +import {WalletSessionClient} from "../../src/lib/wallet/sessionClient"; +import type {WalletSessionDescriptor} from "../../src/lib/wallet/sessionDescriptor"; + +const CHAIN: BridgeChainParams = { + chainId: 4221, + chainName: "Genlayer Bradbury Testnet", + rpcUrls: ["https://rpc.example"], + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorerUrls: ["https://explorer.example"], +}; +const ADDRESS = "0xConnected0000000000000000000000000000001" as `0x${string}`; + +function parse(url: string) { + const u = new URL(url); + return { + origin: `${u.protocol}//${u.host}`, + token: new URLSearchParams(u.hash.slice(1)).get("s")!, + port: Number(u.port), + }; +} + +/** A page simulator: connects, then polls /api/next and reports a fixed result. */ +function drivePage(origin: string, token: string, opts: {status: string; txHash?: string; message?: string}) { + const authGet = (p: string) => fetch(`${origin}${p}`, {headers: {"X-Bridge-Token": token}}); + const pagePost = (p: string, b: unknown) => + fetch(`${origin}${p}`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify(b), + }); + let stopped = false; + (async () => { + await pagePost("/api/connected", {address: ADDRESS}); + while (!stopped) { + const next = await authGet("/api/next") + .then(r => r.json()) + .catch(() => ({type: "stop"})); + if (next.type === "tx") { + await pagePost("/api/result", {id: next.tx.id, ...opts, from: ADDRESS}); + } else if (next.type === "done" || next.type === "stop") { + break; + } + } + })(); + return () => { + stopped = true; + }; +} + +describe("WalletSessionClient", () => { + let bridge: BrowserWalletBridge; + let descriptor: WalletSessionDescriptor; + let stopPage: (() => void) | null = null; + + beforeEach(async () => { + // openUrl MUST be mocked — an unmocked bridge would call the real `open` + // package and pop (then orphan) a browser tab. In-process fake page only. + bridge = new BrowserWalletBridge({ + chain: CHAIN, + handleSigint: false, + persistent: true, + txTimeoutMs: 5000, + openUrl: async () => undefined, + }); + const {url} = await bridge.start(); + const {port, token} = parse(url); + descriptor = { + version: 1, + pid: process.pid, + port, + token, + address: null, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }; + }); + + afterEach(async () => { + stopPage?.(); + await bridge.close().catch(() => {}); + }); + + test("ping() true against a live daemon, false on a dead port", async () => { + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + expect(await client.ping()).toBe(true); + const dead = new WalletSessionClient({...descriptor, port: 1}, {pollIntervalMs: 20}); + expect(await dead.ping()).toBe(false); + }); + + test("waitForConnection resolves the connected signer", async () => { + const {origin, token} = parse(bridge.getUrl()); + stopPage = drivePage(origin, token, {status: "sent", txHash: "0xhash"}); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await expect(client.waitForConnection(3000)).resolves.toBe(ADDRESS); + }); + + test("enqueueTx + waitForTxResult round-trips a sent hash", async () => { + const {origin, token} = parse(bridge.getUrl()); + stopPage = drivePage(origin, token, {status: "sent", txHash: "0xdeadbeef"}); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await client.waitForConnection(3000); + const id = await client.enqueueTx({to: "0xTo" as any, data: "0x01", value: 100n, label: "L"}); + // waitForTxResult now surfaces the signer (`from`) alongside the hash so the + // caller can verify the result came from the connected account. + await expect(client.waitForTxResult(id, 3000)).resolves.toEqual({txHash: "0xdeadbeef", from: ADDRESS}); + }); + + test("waitForTxResult throws on a rejected tx", async () => { + const {origin, token} = parse(bridge.getUrl()); + stopPage = drivePage(origin, token, {status: "rejected"}); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await client.waitForConnection(3000); + const id = await client.enqueueTx({to: "0xTo" as any, data: "0x01", label: "L"}); + await expect(client.waitForTxResult(id, 3000)).rejects.toThrow(/rejected in wallet/i); + }); + + test("enqueueTx maps 409 wallet-not-connected to a clear error", async () => { + // No page connected → daemon returns 409. + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + await expect(client.enqueueTx({to: "0xA" as any, data: "0x01", label: "x"})).rejects.toThrow( + /not connected/i, + ); + }); + + test("bigint → hex round-trip equals serializeBridgeTx output", () => { + const tx = {to: "0xA" as any, data: "0x01" as const, value: 255n, gas: 21000n, label: "L"}; + const s = serializeBridgeTx(tx); + expect(s.value).toBe("0xff"); + expect(s.gas).toBe("0x5208"); + }); + + test("waitForTxResult fails fast when the tab heartbeat is stale", async () => { + const {origin, token} = parse(bridge.getUrl()); + // Connect but do NOT poll /api/next again; then force the heartbeat stale. + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + const client = new WalletSessionClient(descriptor, {pollIntervalMs: 20}); + const id = await client.enqueueTx({to: "0xA" as any, data: "0x01", label: "x"}); + // Poke a poll so lastPagePollAt becomes non-zero, then rewind it far into the past. + void fetch(`${origin}/api/next`, {headers: {"X-Bridge-Token": token}}).catch(() => {}); + await new Promise(r => setTimeout(r, 30)); + (bridge as any).lastPagePollAt = Date.now() - 10 * 60_000; + await expect(client.waitForTxResult(id, 3000)).rejects.toThrow(/tab appears to be closed/i); + }); +}); diff --git a/tests/libs/sessionDaemon.test.ts b/tests/libs/sessionDaemon.test.ts new file mode 100644 index 00000000..773a675d --- /dev/null +++ b/tests/libs/sessionDaemon.test.ts @@ -0,0 +1,141 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {ConfigFileManager} from "../../src/lib/config/ConfigFileManager"; +import {runWalletSessionDaemon, type DaemonHandle} from "../../src/lib/wallet/sessionDaemon"; +import {descriptorPath, readDescriptor} from "../../src/lib/wallet/sessionDescriptor"; + +const ADDRESS = "0xConnected0000000000000000000000000000001" as `0x${string}`; + +describe("runWalletSessionDaemon", () => { + let dir: string; + let cfg: ConfigFileManager; + let openUrl: ReturnType; + let handles: DaemonHandle[]; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-daemon-")); + // ConfigFileManager resolves baseFolder against os.homedir(), but an + // absolute path wins (path.resolve semantics), so the temp dir is used as-is. + cfg = new ConfigFileManager(dir); + cfg.writeConfig("network", "localnet"); + openUrl = vi.fn().mockResolvedValue(undefined); + handles = []; + }); + + afterEach(async () => { + await Promise.all(handles.map(h => h.dispose().catch(() => {}))); + fs.rmSync(dir, {recursive: true, force: true}); + }); + + async function run(overrides: Partial[0]> = {}) { + const handle = await runWalletSessionDaemon({ + configManager: cfg, + openUrl, + onExit: () => {}, // never kill the test runner + log: () => {}, + ...overrides, + }); + handles.push(handle); + return handle; + } + + test("writes a descriptor only after listening; opens the tab", async () => { + const h = await run(); + const dpath = descriptorPath(cfg); + const d = readDescriptor(dpath); + expect(d).not.toBeNull(); + expect(d!.pid).toBe(process.pid); + expect(d!.port).toBeGreaterThan(0); + expect(d!.address).toBeNull(); + expect(openUrl).toHaveBeenCalledOnce(); + }); + + test("onConnected rewrites the descriptor address", async () => { + const h = await run(); + const {origin, token} = parse(h.bridge.getUrl()); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + // onConnected is synchronous inside the handler; give the microtask a beat. + await new Promise(r => setTimeout(r, 20)); + expect(readDescriptor(descriptorPath(cfg))!.address).toBe(ADDRESS); + }); + + test("idle TTL exit removes the descriptor", async () => { + const exit = vi.fn(); + const h = await run({idleTtlMs: 1, onExit: exit}); + await new Promise(r => setTimeout(r, 5)); + h.tick(); + await new Promise(r => setTimeout(r, 50)); // cleanupAndExit is async (awaits bridge.close) + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("tab-dead exit after connecting then going silent", async () => { + const exit = vi.fn(); + const h = await run({tabDeadGraceMs: 1, connectTimeoutMs: 60_000, idleTtlMs: 60_000, onExit: exit}); + const {origin, token} = parse(h.bridge.getUrl()); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + // One poll to set a heartbeat, then rewind it. + void fetch(`${origin}/api/next`, {headers: {"X-Bridge-Token": token}}).catch(() => {}); + await new Promise(r => setTimeout(r, 20)); + (h.bridge as any).lastPagePollAt = Date.now() - 10 * 60_000; + h.tick(); + await new Promise(r => setTimeout(r, 50)); + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("connect timeout exit when nobody connects", async () => { + const exit = vi.fn(); + const h = await run({connectTimeoutMs: 1, idleTtlMs: 60_000, onExit: exit}); + await new Promise(r => setTimeout(r, 5)); + h.tick(); + await new Promise(r => setTimeout(r, 50)); + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("second daemon defers to a live one (singleton guard)", async () => { + await run(); // first daemon writes a live descriptor + const exit = vi.fn(); + await expect( + runWalletSessionDaemon({configManager: cfg, openUrl, onExit: exit, log: () => {}}), + ).rejects.toThrow(/already-running/); + expect(exit).toHaveBeenCalledWith(0); + }); + + test("/api/shutdown → descriptor removed + exit(0) (deterministic, no unref polling)", async () => { + const exit = vi.fn(); + const h = await run({idleTtlMs: 60_000, connectTimeoutMs: 60_000, onExit: exit}); + expect(readDescriptor(descriptorPath(cfg))).not.toBeNull(); + + const {origin, token} = parse(h.bridge.getUrl()); + // POST /api/shutdown as a CLI client would (no Origin header). + const res = await fetch(`${origin}/api/shutdown`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json"}, + }); + expect(res.status).toBe(200); + + // onShutdown → cleanupAndExit runs the ordered teardown synchronously enough; + // give the awaited bridge.close a beat. + await new Promise(r => setTimeout(r, 100)); + // The invariant: daemon exited AND the descriptor is gone. + expect(exit).toHaveBeenCalledWith(0); + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + function parse(url: string) { + const u = new URL(url); + return {origin: `${u.protocol}//${u.host}`, token: new URLSearchParams(u.hash.slice(1)).get("s")!}; + } +}); diff --git a/tests/libs/sessionDescriptor.test.ts b/tests/libs/sessionDescriptor.test.ts new file mode 100644 index 00000000..fe5c488c --- /dev/null +++ b/tests/libs/sessionDescriptor.test.ts @@ -0,0 +1,95 @@ +import {describe, test, expect, beforeEach, afterEach} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { + writeDescriptor, + readDescriptor, + removeDescriptor, + isPidAlive, + descriptorPath, + type WalletSessionDescriptor, +} from "../../src/lib/wallet/sessionDescriptor"; + +function makeDescriptor(overrides: Partial = {}): WalletSessionDescriptor { + return { + version: 1, + pid: process.pid, + port: 51234, + token: "tok-123", + address: null, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: 1000, + lastUsed: 1000, + ...overrides, + }; +} + +describe("sessionDescriptor", () => { + let dir: string; + let file: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-session-")); + file = path.join(dir, "wallet-session.json"); + }); + + afterEach(() => { + fs.rmSync(dir, {recursive: true, force: true}); + }); + + test("descriptorPath resolves against the config manager folder", () => { + const fake = {getFilePath: (n: string) => path.join("/home/x/.genlayer", n)} as any; + expect(descriptorPath(fake)).toBe(path.join("/home/x/.genlayer", "wallet-session.json")); + }); + + test("writeDescriptor writes 0600 and readDescriptor round-trips", () => { + const d = makeDescriptor(); + writeDescriptor(file, d); + const mode = fs.statSync(file).mode & 0o777; + if (process.platform !== "win32") { + expect(mode).toBe(0o600); + } + expect(readDescriptor(file)).toEqual(d); + // No leftover temp file. + expect(fs.existsSync(`${file}.tmp`)).toBe(false); + }); + + test("writeDescriptor overwrites atomically (existing file replaced)", () => { + writeDescriptor(file, makeDescriptor({port: 1})); + writeDescriptor(file, makeDescriptor({port: 2})); + expect(readDescriptor(file)!.port).toBe(2); + }); + + test("readDescriptor returns null for a missing file", () => { + expect(readDescriptor(path.join(dir, "nope.json"))).toBeNull(); + }); + + test("readDescriptor returns null for garbage JSON", () => { + fs.writeFileSync(file, "not json{"); + expect(readDescriptor(file)).toBeNull(); + }); + + test("readDescriptor returns null for wrong version / bad shape", () => { + fs.writeFileSync(file, JSON.stringify({version: 2, pid: 1})); + expect(readDescriptor(file)).toBeNull(); + fs.writeFileSync(file, JSON.stringify({...makeDescriptor(), token: 123})); + expect(readDescriptor(file)).toBeNull(); + }); + + test("removeDescriptor deletes and is idempotent", () => { + writeDescriptor(file, makeDescriptor()); + removeDescriptor(file); + expect(fs.existsSync(file)).toBe(false); + // No throw on a second removal. + expect(() => removeDescriptor(file)).not.toThrow(); + }); + + test("isPidAlive: own pid alive, an impossibly-high pid dead", () => { + expect(isPidAlive(process.pid)).toBe(true); + // 2^30-ish pid will not exist on any real system. + expect(isPidAlive(0x3fffffff)).toBe(false); + }); +}); diff --git a/tests/libs/sessionResolver.test.ts b/tests/libs/sessionResolver.test.ts new file mode 100644 index 00000000..ff5b6d5f --- /dev/null +++ b/tests/libs/sessionResolver.test.ts @@ -0,0 +1,283 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {ConfigFileManager} from "../../src/lib/config/ConfigFileManager"; +import {BrowserWalletBridge} from "../../src/lib/wallet/browserBridge"; +import {resolveBrowserWalletSession} from "../../src/lib/wallet/sessionResolver"; +import {openRemoteWalletSession} from "../../src/lib/wallet/browserSend"; +import type {SessionState} from "../../src/lib/wallet/sessionClient"; +import {HEARTBEAT_DEAD_MS, TAB_CLOSED_MESSAGE} from "../../src/lib/wallet/sessionConstants"; +import { + descriptorPath, + readDescriptor, + writeDescriptor, + type WalletSessionDescriptor, +} from "../../src/lib/wallet/sessionDescriptor"; + +// Partial mock: keep the real bridge/session builders, but wrap +// openRemoteWalletSession so tests can assert whether acquire ever reached it. +vi.mock("../../src/lib/wallet/browserSend", async importActual => { + const actual = await importActual(); + return {...actual, openRemoteWalletSession: vi.fn(actual.openRemoteWalletSession)}; +}); + +const ADDRESS = "0xConnected0000000000000000000000000000001" as `0x${string}`; + +/** + * A fetch stub answering /api/ping + /api/state for a discovered daemon, so a + * test can pin lastPagePollAt precisely (a real in-process bridge never polls, + * so its lastPagePollAt stays 0). Any other route returns {}. + */ +function fetchStub(overrides: Partial): typeof fetch { + const state: SessionState = { + connected: true, + address: ADDRESS, + chainId: 4221, + chainIdHex: "0x107d", + chainName: "Genlayer Bradbury Testnet", + url: "http://127.0.0.1:1/gl-wallet#s=tok", + lastPagePollAt: 0, + queuedCount: 0, + createdAt: Date.now(), + ...overrides, + }; + const fn = vi.fn(async (input: any) => { + const url = typeof input === "string" ? input : String(input?.url ?? input); + if (url.endsWith("/api/ping")) return new Response(JSON.stringify({status: "ok"}), {status: 200}); + if (url.endsWith("/api/state")) return new Response(JSON.stringify(state), {status: 200}); + return new Response("{}", {status: 200}); + }); + return fn as unknown as typeof fetch; +} + +const CHAIN: any = { + id: 4221, + name: "Genlayer Bradbury Testnet", + rpcUrls: {default: {http: ["https://rpc.example"]}}, + nativeCurrency: {name: "GEN Token", symbol: "GEN", decimals: 18}, + blockExplorers: {default: {url: "https://explorer.example"}}, +}; + +function parse(url: string) { + const u = new URL(url); + return { + origin: `${u.protocol}//${u.host}`, + token: new URLSearchParams(u.hash.slice(1)).get("s")!, + port: Number(u.port), + }; +} + +describe("resolveBrowserWalletSession", () => { + let dir: string; + let cfg: ConfigFileManager; + let bridge: BrowserWalletBridge | null; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-resolver-")); + cfg = new ConfigFileManager(dir); + bridge = null; + vi.mocked(openRemoteWalletSession).mockClear(); + }); + + /** Write a descriptor for a daemon that is pid-alive (this process) on chain 4221. */ + function writeLiveDescriptor(chainId = 4221): void { + writeDescriptor(descriptorPath(cfg), { + version: 1, + pid: process.pid, + port: 1, + token: "tok", + address: ADDRESS, + chainId, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }); + } + afterEach(async () => { + await bridge?.close().catch(() => {}); + fs.rmSync(dir, {recursive: true, force: true}); + }); + + /** Start a persistent bridge, write its descriptor, connect the wallet. */ + async function liveDaemon(chainId = 4221): Promise { + bridge = new BrowserWalletBridge({ + chain: { + chainId, + chainName: "c", + rpcUrls: ["r"], + nativeCurrency: {name: "n", symbol: "s", decimals: 18}, + }, + handleSigint: false, + persistent: true, + // Mocked — never call the real `open` (would pop/orphan a browser tab). + openUrl: async () => undefined, + }); + const {url} = await bridge.start(); + const {origin, token, port} = parse(url); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + const d: WalletSessionDescriptor = { + version: 1, + pid: process.pid, + port, + token, + address: ADDRESS, + chainId, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }; + writeDescriptor(descriptorPath(cfg), d); + return d; + } + + test("live session → remote session", async () => { + await liveDaemon(); + const session = await resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + pollIntervalMs: 20, + } as any); + expect(session.kind).toBe("remote"); + expect(session.signerAddress).toBe(ADDRESS); + }); + + test("stale page heartbeat (tab closed) → rejects with reconnect message, no session", async () => { + writeLiveDescriptor(); + const stale = Date.now() - (HEARTBEAT_DEAD_MS + 30_000); + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + fetchFn: fetchStub({connected: true, address: ADDRESS, lastPagePollAt: stale}), + }), + ).rejects.toThrow(TAB_CLOSED_MESSAGE); + // Fail fast at acquire: never bound a session to the dead tab. + expect(openRemoteWalletSession).not.toHaveBeenCalled(); + // Descriptor left in place — the daemon self-manages its tab-dead shutdown. + expect(readDescriptor(descriptorPath(cfg))).not.toBeNull(); + }); + + test("fresh page heartbeat (recent poll) → resolves to a remote session", async () => { + writeLiveDescriptor(); + const session = await resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + fetchFn: fetchStub({connected: true, address: ADDRESS, lastPagePollAt: Date.now()}), + }); + expect(session.kind).toBe("remote"); + expect(session.signerAddress).toBe(ADDRESS); + expect(openRemoteWalletSession).toHaveBeenCalledTimes(1); + }); + + test("never-polled page (lastPagePollAt === 0) is treated as fresh → resolves", async () => { + writeLiveDescriptor(); + const session = await resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + fetchFn: fetchStub({connected: true, address: ADDRESS, lastPagePollAt: 0}), + }); + expect(session.kind).toBe("remote"); + expect(openRemoteWalletSession).toHaveBeenCalledTimes(1); + }); + + test("stale descriptor → cleaned up → error fallback throws", async () => { + // Descriptor with a dead pid / dead port. + writeDescriptor(descriptorPath(cfg), { + version: 1, + pid: 0x3fffffff, + port: 1, + token: "x", + address: null, + chainId: 4221, + network: "testnet-bradbury", + rpcUrl: "https://rpc.example", + createdAt: Date.now(), + lastUsed: Date.now(), + }); + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + }), + ).rejects.toThrow(/wallet connect/i); + // Stale descriptor removed. + expect(readDescriptor(descriptorPath(cfg))).toBeNull(); + }); + + test("chain mismatch throws the exact switch error", async () => { + await liveDaemon(9999); // different chain than CHAIN.id (4221) + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + }), + ).rejects.toThrow(/connected to .* but this command targets .* Run 'genlayer wallet connect/s); + }); + + test("auto-start degrades to own-bridge when spawn fails", async () => { + const openUrl = vi.fn().mockResolvedValue(undefined); + const logWarning = vi.fn(); + // spawnFn that "succeeds" but no daemon ever appears → waitForDaemonReady times out. + const spawnFn = vi.fn().mockReturnValue({pid: 4242, unref: vi.fn()}); + + const resolvePromise = resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "auto-start", + openUrl, + handleSigint: false, + spawnFn, + logWarning, + readyTimeoutMs: 300, + }); + + // The own-bridge fallback opens a real bridge and waits for connection. + // Give the daemon-ready poll a moment to time out, then satisfy the bridge. + // Shorten by connecting via the opened bridge URL. + // We can't easily reach the bridge URL here, so just assert it degrades by + // resolving the promise after we connect through openUrl's captured URL. + await vi.waitFor(() => expect(openUrl).toHaveBeenCalled(), {timeout: 15000}); + const url = openUrl.mock.calls[0][0] as string; + const {origin, token} = parse(url); + await fetch(`${origin}/api/connected`, { + method: "POST", + headers: {"X-Bridge-Token": token, "Content-Type": "application/json", Origin: origin}, + body: JSON.stringify({address: ADDRESS}), + }); + const session = await resolvePromise; + expect(session.kind).toBe("local"); + expect(logWarning).toHaveBeenCalled(); + await session.close(); + }, 20000); + + test("error mode with no descriptor throws the connect-first message", async () => { + await expect( + resolveBrowserWalletSession({ + chain: CHAIN, + rpcUrl: "https://rpc.example", + configManager: cfg, + fallback: "error", + }), + ).rejects.toThrow(/Run 'genlayer wallet connect' first/); + }); +}); diff --git a/tests/libs/spawnDaemon.test.ts b/tests/libs/spawnDaemon.test.ts new file mode 100644 index 00000000..41942e10 --- /dev/null +++ b/tests/libs/spawnDaemon.test.ts @@ -0,0 +1,117 @@ +import {describe, test, expect, beforeEach, afterEach, vi} from "vitest"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import {spawnWalletDaemon, waitForDaemonReady} from "../../src/lib/wallet/spawnDaemon"; +import {BrowserWalletBridge} from "../../src/lib/wallet/browserBridge"; +import {writeDescriptor, type WalletSessionDescriptor} from "../../src/lib/wallet/sessionDescriptor"; + +describe("spawnWalletDaemon", () => { + let dir: string; + let logPath: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-spawn-")); + logPath = path.join(dir, "wallet-daemon.log"); + }); + afterEach(() => fs.rmSync(dir, {recursive: true, force: true})); + + test("re-execs execPath + argv[1] with 'wallet daemon' and network, detached + unref", () => { + const unref = vi.fn(); + const spawnFn = vi.fn().mockReturnValue({pid: 4242, unref}); + const pid = spawnWalletDaemon({ + network: "testnet-bradbury", + rpc: "https://rpc.example", + cliPath: "/x/dist/index.js", + execPath: "/usr/bin/node", + logPath, + spawnFn, + }); + expect(pid).toBe(4242); + expect(unref).toHaveBeenCalledOnce(); + const [cmd, args, options] = spawnFn.mock.calls[0]; + expect(cmd).toBe("/usr/bin/node"); + expect(args).toEqual([ + "/x/dist/index.js", + "wallet", + "daemon", + "--network", + "testnet-bradbury", + "--rpc", + "https://rpc.example", + ]); + expect(options.detached).toBe(true); + expect(options.windowsHide).toBe(true); + }); + + test("throws when spawn returns no pid", () => { + const spawnFn = vi.fn().mockReturnValue({pid: undefined, unref: vi.fn()}); + expect(() => spawnWalletDaemon({logPath, spawnFn})).toThrow(/no pid/i); + }); +}); + +describe("waitForDaemonReady", () => { + let dir: string; + let dpath: string; + let bridge: BrowserWalletBridge; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "gl-ready-")); + dpath = path.join(dir, "wallet-session.json"); + }); + afterEach(async () => { + await bridge?.close().catch(() => {}); + fs.rmSync(dir, {recursive: true, force: true}); + }); + + test("resolves once the descriptor is live and pings", async () => { + bridge = new BrowserWalletBridge({ + chain: { + chainId: 1, + chainName: "x", + rpcUrls: ["r"], + nativeCurrency: {name: "n", symbol: "s", decimals: 18}, + }, + handleSigint: false, + persistent: true, + // Mocked — never call the real `open` (would pop/orphan a browser tab). + openUrl: async () => undefined, + }); + const {url} = await bridge.start(); + const u = new URL(url); + const d: WalletSessionDescriptor = { + version: 1, + pid: process.pid, + port: Number(u.port), + token: new URLSearchParams(u.hash.slice(1)).get("s")!, + address: null, + chainId: 1, + network: "localnet", + rpcUrl: "r", + createdAt: Date.now(), + lastUsed: Date.now(), + }; + writeDescriptor(dpath, d); + await expect(waitForDaemonReady(dpath, {timeoutMs: 2000, intervalMs: 20})).resolves.toMatchObject({ + port: d.port, + }); + }); + + test("times out with a log tail when nothing becomes ready", async () => { + fs.writeFileSync(path.join(dir, "wallet-daemon.log"), "line1\nboom: failed to bind\n"); + await expect( + waitForDaemonReady(dpath, { + timeoutMs: 150, + intervalMs: 30, + logPath: path.join(dir, "wallet-daemon.log"), + }), + ).rejects.toThrow(/did not become ready[\s\S]*boom: failed to bind/); + }); +}); + +// NOTE: There is deliberately NO real-process / real-browser end-to-end test +// here. Automated tests must never spawn a detached daemon or call the real +// openUrl (that would pop a browser and can orphan tabs/processes). The spawn +// wiring is covered above with an injected spawnFn; the daemon runtime and its +// shutdown-cleanup invariant are covered in-process (fetch-driven fake page +// against an ephemeral listen(0) bridge) in tests/libs/sessionDaemon.test.ts. 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/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..4b11f82a --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,33 @@ +import {vi} from "vitest"; + +/** + * Global test safety net: the `open` package launches a real browser. No + * automated test may ever pop a browser tab (it would also orphan the tab when + * the ephemeral bridge port dies). Every bridge/daemon test already injects a + * mocked openUrl; this guarantees that even a test that forgets cannot reach + * the real `open`. Files that specifically exercise openUrl (system.test.ts) + * declare their own file-level vi.mock("open"), which takes precedence there. + */ +vi.mock("open", () => ({default: vi.fn(async () => ({}) as any)})); + +/** + * Hermetic config dir. ConfigFileManager resolves ~/.genlayer against + * os.homedir(), and BaseAction.resolveWalletMode now reads the wallet-session + * descriptor from that dir. The human running the suite may have a live wallet + * session at their real ~/.genlayer/wallet-session.json — which would flip + * bare commands into browser mode and break otherwise-hermetic tests. + * + * Redirect os.homedir() to a throwaway per-worker temp dir so hasLiveWalletSession + * never sees a real descriptor unless a test opts in. Everything else on `os` + * (tmpdir, platform, ...) is preserved. Files that mock os themselves (bare + * vi.mock("os")) override this for their own scope; files that vi.spyOn(os, + * "homedir") layer on top of it. We intentionally derive the path from + * os.tmpdir() with plain string concat and let ConfigFileManager create the + * dir, so this stays independent of any file that mocks "fs"/"path". + */ +vi.mock("os", async importActual => { + const actual = await importActual(); + const home = `${actual.tmpdir()}/genlayer-cli-test-home-${process.pid}`; + const mocked = {...actual, homedir: () => home}; + return {...mocked, default: mocked}; +}); 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"); diff --git a/vitest.config.ts b/vitest.config.ts index 49863d8f..bf23772f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,9 +5,10 @@ export default defineConfig({ globals: true, environment: 'jsdom', testTimeout: 10000, - exclude: [...configDefaults.exclude, 'tests/smoke.test.ts'], + setupFiles: ['tests/setup.ts'], + exclude: [...configDefaults.exclude, 'tests/smoke.test.ts', 'e2e/**'], coverage: { - exclude: [...configDefaults.exclude, '*.js', 'tests/**/*.ts', 'src/types', 'scripts', 'templates'], + exclude: [...configDefaults.exclude, '*.js', 'tests/**/*.ts', 'e2e/**', 'src/types', 'scripts', 'templates'], } } }); \ No newline at end of file