From 5d008844ad0b97760bbd025ed5aac61b41b2b881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 9 Jun 2026 23:41:26 +0100 Subject: [PATCH 01/45] fix: fail CLI writes on execution errors (#345) --- src/commands/contracts/deploy.ts | 3 ++ src/commands/contracts/execution.ts | 75 +++++++++++++++++++++++++++++ src/commands/contracts/write.ts | 3 ++ tests/actions/deploy.test.ts | 34 +++++++++++++ tests/actions/write.test.ts | 31 ++++++++++-- 5 files changed, 143 insertions(+), 3 deletions(-) create mode 100644 src/commands/contracts/execution.ts diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 198f9c84..3dc0fff6 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -5,6 +5,7 @@ import {pathToFileURL} from "url"; import {TransactionStatus} from "genlayer-js/types"; import {buildSync} from "esbuild"; import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {assertSuccessfulExecution} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { contract?: string; @@ -147,7 +148,9 @@ export class DeployAction extends BaseAction { retries: 50, interval: 5000, status: TransactionStatus.ACCEPTED, + fullTransaction: true, }); + assertSuccessfulExecution("Deployment", hash, result); this.log("Deployment Receipt:", result); diff --git a/src/commands/contracts/execution.ts b/src/commands/contracts/execution.ts new file mode 100644 index 00000000..07784170 --- /dev/null +++ b/src/commands/contracts/execution.ts @@ -0,0 +1,75 @@ +import {ExecutionResult} 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 === "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; + } + return undefined; +} + +function transactionExecutionResult(receipt: unknown): ExecutionResult | undefined { + 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"]); + + 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; +} + +export function assertSuccessfulExecution( + operation: string, + hash: unknown, + receipt: unknown, +): void { + const result = transactionExecutionResult(receipt); + if (result === ExecutionResult.FINISHED_WITH_RETURN) return; + + if (result === undefined) { + throw new Error( + `${operation} ${String(hash)} reached consensus status but did not expose an execution result.`, + ); + } + + throw new Error(`${operation} ${String(hash)} execution failed with ${result}.`); +} diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 3187098d..100e2939 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -2,6 +2,7 @@ // import type {GenLayerClient} from "genlayer-js/types"; import {BaseAction} from "../../lib/actions/BaseAction"; import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {assertSuccessfulExecution} from "./execution"; export interface WriteOptions extends ContractFeeCliOptions { args: any[]; @@ -53,7 +54,9 @@ export class WriteAction extends BaseAction { hash, retries: 100, interval: 5000, + fullTransaction: true, }); + assertSuccessfulExecution("Write", hash, result); this.succeedSpinner("Write operation successfully executed", result); } catch (error) { this.failSpinner("Error during write operation", error); diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 00c26034..a0db7c53 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -81,6 +81,7 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -92,6 +93,13 @@ describe("DeployAction", () => { args: [1, 2, 3], leaderOnly: false, }); + expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: "mocked_tx_hash", + retries: 50, + interval: 5000, + status: "ACCEPTED", + fullTransaction: true, + }); expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); }); @@ -119,6 +127,7 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); @@ -144,6 +153,30 @@ describe("DeployAction", () => { }); }); + 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({ + txExecutionResultName: "FINISHED_WITH_ERROR", + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + expect(deployer["failSpinner"]).toHaveBeenCalledWith( + "Error deploying contract", + expect.objectContaining({ + message: expect.stringContaining("execution failed with FINISHED_WITH_ERROR"), + }), + ); + }); + test("throws error for missing contract", async () => { const options: DeployOptions = {}; @@ -387,6 +420,7 @@ describe("DeployAction", () => { vi.mocked(fs.readFileSync).mockReturnValue(contractContent); vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + txExecutionResultName: "FINISHED_WITH_RETURN", data: {contract_address: "0xdasdsadasdasdada"}, }); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index e3820c7b..530b0386 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -34,7 +34,7 @@ describe("WriteAction", () => { test("calls writeContract successfully", async () => { const options = {args: [42, "Update"]}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -52,6 +52,12 @@ describe("WriteAction", () => { value: 0n, }); expect(writeAction["log"]).toHaveBeenCalledWith("Write Transaction Hash:", mockHash); + expect(mockClient.waitForTransactionReceipt).toHaveBeenCalledWith({ + hash: mockHash, + retries: 100, + interval: 5000, + fullTransaction: true, + }); expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( "Write operation successfully executed", mockReceipt, @@ -60,7 +66,7 @@ describe("WriteAction", () => { test("calls writeContract with fee options", async () => { const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success"}; + const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -116,10 +122,29 @@ 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({ + status: "success", + 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("execution failed with FINISHED_WITH_ERROR"), + }), + ); + }); + 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 = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); From 6fadcd7c9ef181042c823faeec1b7e7fb4d902b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 13:14:28 +0100 Subject: [PATCH 02/45] fix(contracts)!: require consensus acceptance for success, not just the leader's execution result (#346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(contracts)!: require consensus acceptance for success, not just leader execution result - assertSuccessfulExecution now requires status ∈ {ACCEPTED, FINALIZED} via the SDK's isSuccessful in addition to FINISHED_WITH_RETURN: txExecutionResult is the LEADER's result and exists even on UNDETERMINED — previously an undetermined deploy printed 'deployed successfully' with an address that would never materialize - failure messages now name both the consensus outcome and the leader result ('decided as UNDETERMINED (no validator majority); leader execution result: FINISHED_WITH_RETURN') - map VoteType 3 (TIMEOUT) / 4 (NONDET_DISAGREE) with real diagnoses instead of 'did not expose an execution result' - deploy/write use waitUntil: 'decided' (new SDK API) and report the reached consensus status; explicit fee deposits are echoed before sending - callKey derivation deduplicated: import from genlayer-js (incl. new DEPLOY_CALL_KEY for deploy-targeted Mode-2 allocations) * build: pin genlayer-js to the v2 feature branch until the npm release npm 1.1.8 lacks the new fee APIs (isSuccessful, DEPLOY_CALL_KEY, waitUntil), so CI cannot build against it. github:genlayerlabs/genlayer-js#feat/ v06-fee-estimation-rework builds via the SDK's new prepare script (verified: fresh install produces dist + exports). Re-pin to the npm semver once genlayer-js v2 ships (release-sequencing item). * ci: run validate-code on pushes to the v0.40 line Push trigger was pinned to v0.39 — merges to v0.40-dev/v0.40 got no post-merge CI. * ci: make codecov upload non-blocking The OIDC upload fails on ubuntu/windows independent of this PR (#345 merged with identical failures); tests/build stay blocking. * build: re-pin genlayer-js to v2-dev (feature branch merged) --- .github/workflows/validate-code.yml | 7 +- package-lock.json | 20 +++- package.json | 2 +- src/commands/contracts/deploy.ts | 14 ++- src/commands/contracts/execution.ts | 80 +++++++++++++++- src/commands/contracts/fees.ts | 49 ++++------ src/commands/contracts/write.ts | 13 ++- tests/actions/deploy.test.ts | 125 ++++++++++++++++++++++++- tests/actions/estimateFees.test.ts | 5 +- tests/actions/write.test.ts | 140 ++++++++++++++++++++++++++-- 10 files changed, 394 insertions(+), 61 deletions(-) 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/package-lock.json b/package-lock.json index 02b49aa6..434d9ee9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.1.8", + "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", @@ -325,6 +325,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -348,6 +349,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1646,6 +1648,7 @@ "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -2159,6 +2162,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 +2285,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", @@ -2917,6 +2922,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5582,8 +5588,7 @@ }, "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#28e99fbc10ad962019e8314fb1b0d83d5a0e0938", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -6822,6 +6827,7 @@ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -6850,6 +6856,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8481,6 +8488,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@nodeutils/defaults-deep": "1.1.0", "@octokit/rest": "21.1.1", @@ -9515,6 +9523,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9812,6 +9821,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9910,6 +9920,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -10087,6 +10098,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 +10212,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10651,6 +10664,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" }, diff --git a/package.json b/package.json index 34276046..64058f2d 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "^1.1.8", + "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", "keytar": "^7.9.0", "node-fetch": "^3.0.0", diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 3dc0fff6..9fad7e68 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -2,10 +2,10 @@ import fs from "fs"; 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 {assertSuccessfulExecution} from "./execution"; +import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { contract?: string; @@ -133,12 +133,16 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; - const fees = parseTransactionFees(options); + const fees = parseTransactionFees(options, {deployTargeted: true}); 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; @@ -147,12 +151,13 @@ 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 @@ -161,6 +166,7 @@ export class DeployAction extends BaseAction { this.succeedSpinner("Contract deployed successfully.", { "Transaction Hash": hash, "Contract Address": contractAddress, + "Consensus Status": transactionConsensusStatus(result), }); } catch (error) { this.failSpinner("Error deploying contract", error); diff --git a/src/commands/contracts/execution.ts b/src/commands/contracts/execution.ts index 07784170..69d45a75 100644 --- a/src/commands/contracts/execution.ts +++ b/src/commands/contracts/execution.ts @@ -1,4 +1,5 @@ -import {ExecutionResult} from "genlayer-js/types"; +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; @@ -16,6 +17,9 @@ function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { 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)); @@ -25,11 +29,31 @@ function normalizeExecutionResult(value: unknown): ExecutionResult | undefined { 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 transactionExecutionResult(receipt: unknown): ExecutionResult | 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 : {}; @@ -40,7 +64,26 @@ function transactionExecutionResult(receipt: unknown): ExecutionResult | undefin 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"]), @@ -57,19 +100,46 @@ function transactionExecutionResult(receipt: unknown): ExecutionResult | undefin 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 (result === ExecutionResult.FINISHED_WITH_RETURN) return; + 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)} reached consensus status but did not expose an execution result.`, + `${operation} ${String(hash)} transaction was decided as ${decidedAs}; leader execution result: UNKNOWN.`, ); } - throw new Error(`${operation} ${String(hash)} execution failed with ${result}.`); + 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..acd914dc 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -1,4 +1,8 @@ -import {hexToBytes, keccak256, toHex, type Hex} from "viem"; +import { + DEPLOY_CALL_KEY, + deriveExternalMessageCallKey, + deriveInternalMessageCallKey, +} from "genlayer-js"; export interface ContractFeeCliOptions { fees?: string; @@ -48,34 +52,6 @@ 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."); - } - return `0x${toHex(bytes).slice(2).padEnd(64, "0")}` as Hex; -}; - -const deriveInternalMessageCallKey = (methodName = ""): Hex => { - const methodBytes = new TextEncoder().encode(methodName); - if (methodBytes.length < 32) { - return bytesToPaddedCallKey(methodBytes); - } - - 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)); -}; - const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | undefined => { if (messageType === undefined) { return undefined; @@ -178,7 +154,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 +167,22 @@ const normalizeMessageTypes = (fees: Record): Record = } const messageType = normalizeMessageType(allocation.messageType, index); - return normalizeMessageAllocationCallKey({ + 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 => { +export const parseTransactionFees = ( + options: ContractFeeCliOptions, + config: {deployTargeted?: boolean} = {}, +): Record | undefined => { const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); let fees = options.fees ? parseJsonObject(options.fees, "--fees") : undefined; @@ -207,7 +190,7 @@ export const parseTransactionFees = (options: ContractFeeCliOptions): Record { 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 +92,7 @@ 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"}, }); @@ -97,7 +109,7 @@ describe("DeployAction", () => { hash: "mocked_tx_hash", retries: 50, interval: 5000, - status: "ACCEPTED", + waitUntil: "decided", fullTransaction: true, }); expect(mockClient.deployContract).toHaveReturnedWith(Promise.resolve("mocked_tx_hash")); @@ -127,6 +139,7 @@ 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"}, }); @@ -145,6 +158,7 @@ describe("DeployAction", () => { messageAllocations: [{ messageType: 1, recipient: "0x0000000000000000000000000000000000000001", + callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", budget: "5", }], feeValue: "123", @@ -163,6 +177,7 @@ describe("DeployAction", () => { 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"}, }); @@ -172,11 +187,114 @@ describe("DeployAction", () => { expect(deployer["failSpinner"]).toHaveBeenCalledWith( "Error deploying contract", expect.objectContaining({ - message: expect.stringContaining("execution failed with FINISHED_WITH_ERROR"), + 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 = {}; @@ -420,6 +538,7 @@ 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"}, }); diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts index c9634b42..cf072465 100644 --- a/tests/actions/estimateFees.test.ts +++ b/tests/actions/estimateFees.test.ts @@ -1,5 +1,5 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {createClient, createAccount} from "genlayer-js"; +import {createClient, createAccount, deriveInternalMessageCallKey} from "genlayer-js"; import {EstimateFeesAction} from "../../src/commands/contracts/estimateFees"; vi.mock("genlayer-js"); @@ -20,6 +20,9 @@ describe("EstimateFeesAction", () => { 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(() => {}); diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index 530b0386..1e483526 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -1,5 +1,11 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; -import {createClient, createAccount} from "genlayer-js"; +import { + createClient, + createAccount, + isSuccessful, + formatStakingAmount, + deriveExternalMessageCallKey, +} from "genlayer-js"; import {WriteAction} from "../../src/commands/contracts/write"; vi.mock("genlayer-js"); @@ -18,6 +24,26 @@ describe("WriteAction", () => { 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 +60,7 @@ describe("WriteAction", () => { test("calls writeContract successfully", async () => { const options = {args: [42, "Update"]}; const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -56,17 +82,18 @@ describe("WriteAction", () => { hash: mockHash, retries: 100, interval: 5000, + waitUntil: "decided", fullTransaction: true, }); expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( "Write operation successfully executed", - mockReceipt, + {...mockReceipt, consensusStatus: "ACCEPTED"}, ); }); test("calls writeContract with fee options", async () => { const mockHash = "0xMockedTransactionHash"; - const mockReceipt = {status: "success", txExecutionResultName: "FINISHED_WITH_RETURN"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -127,7 +154,7 @@ describe("WriteAction", () => { vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ - status: "success", + statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_ERROR", }); @@ -136,15 +163,112 @@ describe("WriteAction", () => { expect(writeAction["failSpinner"]).toHaveBeenCalledWith( "Error during write operation", expect.objectContaining({ - message: expect.stringContaining("execution failed with FINISHED_WITH_ERROR"), + 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", txExecutionResultName: "FINISHED_WITH_RETURN"}; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); @@ -168,7 +292,7 @@ describe("WriteAction", () => { }); expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( "Write operation successfully executed", - mockReceipt, + {...mockReceipt, consensusStatus: "ACCEPTED"}, ); }); }); From 21002551c254d5f906714a91ec1fa182add4fdb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 10 Jun 2026 15:46:07 +0100 Subject: [PATCH 03/45] ci: keep main forwarded to active dev branch (#348) --- .github/scripts/validate-branch-policy.sh | 107 ++++++++++++++++++++++ .github/workflows/branch-policy.yml | 24 +++++ .github/workflows/fast-forward-main.yaml | 57 ++++++++++++ .github/workflows/retarget-main-prs.yaml | 53 +++++++++++ support/ci/ACTIVE_DEV_BRANCH | 1 + 5 files changed, 242 insertions(+) create mode 100755 .github/scripts/validate-branch-policy.sh create mode 100644 .github/workflows/branch-policy.yml create mode 100644 .github/workflows/fast-forward-main.yaml create mode 100644 .github/workflows/retarget-main-prs.yaml create mode 100644 support/ci/ACTIVE_DEV_BRANCH 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/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/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 < Date: Thu, 11 Jun 2026 15:55:37 +0100 Subject: [PATCH 04/45] fix(system): propagate command-check and version parse fixes to v0.40-dev (#350) Propagates #349 to v0.40-dev. --- src/lib/clients/system.ts | 36 +++++++++++++++++++----------------- tests/libs/system.test.ts | 39 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 55 insertions(+), 20 deletions(-) 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/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({ From 8c898c39f4f8d178dd980965025af445deda5eb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 11 Jun 2026 17:34:38 +0100 Subject: [PATCH 05/45] docs: add branching guide (#353) * docs: add branching guide * ci: harden testnet smoke timeout --- .github/workflows/smoke.yml | 4 ++- CONTRIBUTING.md | 20 ++++--------- docs/BRANCHING.md | 58 +++++++++++++++++++++++++++++++++++++ tests/smoke.test.ts | 21 +++++++++----- 4 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 docs/BRANCHING.md diff --git a/.github/workflows/smoke.yml b/.github/workflows/smoke.yml index 13650c28..ec29f228 100644 --- a/.github/workflows/smoke.yml +++ b/.github/workflows/smoke.yml @@ -10,7 +10,7 @@ jobs: smoke: name: Testnet Smoke Tests runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 10 continue-on-error: true steps: @@ -41,3 +41,5 @@ jobs: - name: Run smoke tests run: npm run test:smoke + env: + CLI_SMOKE_TIMEOUT_MS: 240000 diff --git a/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/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/tests/smoke.test.ts b/tests/smoke.test.ts index 56808871..7c34ed33 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -1,16 +1,19 @@ import {describe, it, expect, beforeAll} from "vitest"; -import {execSync} from "child_process"; +import {execFile} from "child_process"; +import {promisify} from "util"; import path from "path"; import {createClient, parseStakingAmount, formatStakingAmount} from "genlayer-js"; import {testnetAsimov, testnetBradbury} from "genlayer-js/chains"; import type {Address, GenLayerChain} from "genlayer-js/types"; const CLI = path.resolve(__dirname, "../dist/index.js"); +const execFileAsync = promisify(execFile); // Testnet validator-list fetches ALL validators + per-validator detail in -// batches; on bradbury/asimov that routinely passes 30s. 90s gives headroom -// without hiding real hangs. +// batches; on bradbury/asimov that routinely passes 30s. Keep RPC calls capped +// at 90s, but give the full CLI smoke path extra room for live testnet slowness. const TIMEOUT = 90_000; +const CLI_TIMEOUT = Number(process.env.CLI_SMOKE_TIMEOUT_MS ?? 180_000); const testnets: {name: string; chain: GenLayerChain}[] = [ {name: "Asimov", chain: testnetAsimov}, @@ -127,14 +130,16 @@ describe(`Testnet ${name} - CLI Staking Smoke Tests`, () => { } }, TIMEOUT); - it("CLI: genlayer staking validators lists validators", () => { - const output = execSync( - `node ${CLI} staking validators --network ${name === "Asimov" ? "testnet-asimov" : "testnet-bradbury"}`, - {encoding: "utf-8", timeout: TIMEOUT}, + it("CLI: genlayer staking validators lists validators", async () => { + const {stdout, stderr} = await execFileAsync( + "node", + [CLI, "staking", "validators", "--network", name === "Asimov" ? "testnet-asimov" : "testnet-bradbury"], + {encoding: "utf-8", timeout: CLI_TIMEOUT}, ); + const output = `${stdout}${stderr}`; expect(output).toContain("active"); expect(output).toMatch(/Total: \d+ validators/); - }, TIMEOUT); + }, CLI_TIMEOUT + 10_000); it("parseStakingAmount and formatStakingAmount round-trip", () => { const parsed = parseStakingAmount("1.5gen"); From 6edfcfa6d1ad2fcbf761c1ed9f3a194d31243624 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 16 Jun 2026 10:45:55 +0100 Subject: [PATCH 06/45] feat: support fee profiles in contract commands (#355) * feat: support fee profiles in contract commands * test: make fee profile deploy test portable --- README.md | 59 +++++- docs/api-references/contracts/deploy.mdx | 16 +- docs/api-references/contracts/write.mdx | 14 +- docs/api-references/estimate-fees.mdx | 28 +++ docs/api-references/index.mdx | 1 + src/commands/contracts/deploy.ts | 13 +- src/commands/contracts/estimateFees.ts | 39 ++-- src/commands/contracts/fees.ts | 247 +++++++++++++++++++---- src/commands/contracts/index.ts | 52 ++--- src/commands/contracts/write.ts | 27 ++- tests/actions/deploy.test.ts | 101 +++++++-- tests/actions/estimateFees.test.ts | 176 ++++++++++++++-- tests/actions/write.test.ts | 128 +++++++++--- tests/commands/deploy.test.ts | 48 +++-- tests/commands/estimateFees.test.ts | 42 ++-- tests/commands/write.test.ts | 25 +++ 16 files changed, 820 insertions(+), 196 deletions(-) create mode 100644 docs/api-references/estimate-fees.mdx diff --git a/README.md b/README.md index d33dc464..61f32d6a 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,9 @@ OPTIONS (deploy): --contract (Optional) Path to the intelligent contract to deploy --rpc RPC URL for the network --fees Transaction fee options JSON passed to genlayer-js + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --fee-value Explicit fee deposit value --valid-until Unix timestamp after which the transaction is invalid --args Contract arguments (see Argument Types below) @@ -187,6 +190,9 @@ OPTIONS (call): OPTIONS (write): --rpc RPC URL for the network --fees Transaction fee options JSON passed to genlayer-js + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --fee-value Explicit fee deposit value --valid-until Unix timestamp after which the transaction is invalid --args Method arguments (see Argument Types below) @@ -194,6 +200,9 @@ OPTIONS (write): OPTIONS (estimate-fees): --rpc RPC URL for the network --fees Fee estimate options JSON, or a transaction fee object + --fee-profile Fee profile generated by gltest --fee-profile + --fee-preset Fee profile appeal posture: low, standard, or high + --appeal-rounds Override fee profile appeal rounds --include-report Include simulation fee accounting/report in the generated estimate output --args Method arguments for simulation-derived estimates @@ -205,11 +214,14 @@ EXAMPLES: genlayer deploy --contract ./my_contract.gpy genlayer deploy --contract ./my_contract.gpy --args "arg1" "arg2" 123 genlayer deploy --contract ./my_contract.gpy --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' + genlayer deploy --contract ./my_contract.gpy --fee-profile ./artifacts/fee-profile.json genlayer call 0x123456789abcdef greet --args "Hello World!" genlayer write 0x123456789abcdef updateValue --args 42 genlayer write 0x123456789abcdef updateValue --fees '{"distribution":{"leaderTimeunitsAllocation":"100","validatorTimeunitsAllocation":"200","rotations":["0"]}}' --args 42 + genlayer write 0x123456789abcdef updateValue --fee-profile ./artifacts/fee-profile.json --fee-preset standard --args 42 genlayer estimate-fees genlayer estimate-fees 0x123456789abcdef updateValue --args 42 + genlayer estimate-fees 0x123456789abcdef updateValue --fee-profile ./artifacts/fee-profile.json --json genlayer write 0x123456789abcdef sendReward --args 0x6857Ed54CbafaA74Fc0357145eC0ee1536ca45A0 genlayer write 0x123456789abcdef setScores --args '[1, 2, 3]' genlayer write 0x123456789abcdef setConfig --args '{"timeout": 30, "retries": 5}' @@ -218,6 +230,28 @@ EXAMPLES: ##### Transaction Fee Options +For reproducible application presets, pass the profile produced by +`gltest --fee-profile`: + +```bash +genlayer estimate-fees 0x123456789abcdef settle \ + --fee-profile ./artifacts/fee-profile.json \ + --fee-preset standard \ + --json + +genlayer write 0x123456789abcdef settle \ + --fee-profile ./artifacts/fee-profile.json \ + --fee-preset standard +``` + +`deploy` reads the profile's `deploy` entry. `write` and targeted +`estimate-fees` read `methods[method]`. The CLI converts the measured profile +entry into SDK fee-estimate options, asks `genlayer-js` for a transaction fee +preset, then sends that preset with the transaction. `--fee-preset` controls the +default appeal posture (`low`, `standard`, or `high`); use `--appeal-rounds` +for an explicit override. `--fees` can still be provided alongside +`--fee-profile` to override individual values, including `messageAllocations`. + `--fees` accepts the same transaction fee object as `genlayer-js`. Quote large integer values as strings to preserve precision. `messageAllocations[].messageType` may be `"internal"`, `"external"`, `0`, or `1`. @@ -253,29 +287,32 @@ preset for reproducible gas-unit debugging. The `--args` option automatically detects and converts values to the correct type: -| Type | Syntax | Example | -|------|--------|---------| -| Boolean | `true`, `false` | `--args true false` | -| Null | `null` | `--args null` | -| Integer | numeric value | `--args 42 -1` | -| Hex integer | `0x` prefix | `--args 0x1a` | -| String | any other value | `--args hello "multi word"` | -| Address | 40 hex chars with `0x` or `addr#` prefix | `--args 0x6857...a0` or `--args addr#6857...a0` | -| Bytes | `b#` prefix + hex | `--args b#deadbeef` | -| Array | JSON array in quotes | `--args '[1, 2, "three"]'` | -| Dict | JSON object in quotes | `--args '{"key": "value"}'` | +| Type | Syntax | Example | +| ----------- | ---------------------------------------- | ----------------------------------------------- | +| Boolean | `true`, `false` | `--args true false` | +| Null | `null` | `--args null` | +| Integer | numeric value | `--args 42 -1` | +| Hex integer | `0x` prefix | `--args 0x1a` | +| String | any other value | `--args hello "multi word"` | +| Address | 40 hex chars with `0x` or `addr#` prefix | `--args 0x6857...a0` or `--args addr#6857...a0` | +| Bytes | `b#` prefix + hex | `--args b#deadbeef` | +| Array | JSON array in quotes | `--args '[1, 2, "three"]'` | +| Dict | JSON object in quotes | `--args '{"key": "value"}'` | Large numbers that exceed JavaScript's safe integer range are automatically handled as BigInt to preserve precision. ##### Deploy Behavior + - If `--contract` is specified, the command will **deploy the given contract**. - If `--contract` is omitted, the CLI will **search for scripts inside the `deploy` folder**, sort them, and execute them sequentially. ##### Call vs Write + - `call` - Calls a contract method without sending a transaction or changing the state (read-only) - `write` - Sends a transaction to a contract method that modifies the state ##### Schema + - `schema` - Retrieves the contract schema #### Transaction Operations diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index b0677d0c..dddbf07e 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -10,8 +10,14 @@ Deploy intelligent contracts ### Options -| Short | Long | Description | Required | Default | -| --- | --- | --- | :---: | --- | -| | --contract <contractPath> | Path to the smart contract to deploy | No | | -| | --rpc <rpcUrl> | RPC URL for the network | No | | -| -h | --help | display help for command | No | | +| Short | Long | Description | Required | Default | +| ----- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | ------- | +| | --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 | | +| -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..0bb09d22 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -15,7 +15,13 @@ Sends a transaction to a contract method that modifies the state ### Options -| Short | Long | Description | Required | Default | -| --- | --- | --- | :---: | --- | -| | --rpc <rpcUrl> | RPC URL for the network | No | | -| -h | --help | display help for command | No | | +| 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 | | +| -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..88154ea6 --- /dev/null +++ b/docs/api-references/estimate-fees.mdx @@ -0,0 +1,28 @@ +--- +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 | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index 49295cfd..60c40a5d 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -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 diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 9fad7e68..845831c6 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -4,7 +4,7 @@ import {BaseAction} from "../../lib/actions/BaseAction"; import {pathToFileURL} from "url"; import {formatStakingAmount} from "genlayer-js"; import {buildSync} from "esbuild"; -import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface DeployOptions extends ContractFeeCliOptions { @@ -133,7 +133,10 @@ export class DeployAction extends BaseAction { const leaderOnly = false; const deployParams: any = {code: contractCode, args: options.args, leaderOnly}; - const fees = parseTransactionFees(options, {deployTargeted: true}); + const fees = await resolveTransactionFees(client, options, { + deployTargeted: true, + profileTarget: {kind: "deploy"}, + }); const validUntil = parseValidUntil(options); if (fees) deployParams.fees = fees; if (validUntil !== undefined) deployParams.validUntil = validUntil; @@ -160,8 +163,10 @@ export class DeployAction extends BaseAction { this.log("Consensus Status:", transactionConsensusStatus(result)); const contractAddress = - result.data?.contract_address ?? // localnet/studio - (result.txDataDecoded as any)?.contractAddress; // testnet + // localnet/studio + result.data?.contract_address ?? + // testnet + (result.txDataDecoded as any)?.contractAddress; this.succeedSpinner("Contract deployed successfully.", { "Transaction Hash": hash, diff --git a/src/commands/contracts/estimateFees.ts b/src/commands/contracts/estimateFees.ts index fa3cf873..10e5b11c 100644 --- a/src/commands/contracts/estimateFees.ts +++ b/src/commands/contracts/estimateFees.ts @@ -1,19 +1,14 @@ import {BaseAction} from "../../lib/actions/BaseAction"; -import {ContractFeeCliOptions, parseFeeEstimateOptions} from "./fees"; +import {ContractFeeCliOptions, FeeProfileTarget, parseFeeEstimateOptions, toTransactionFees} from "./fees"; -export interface EstimateFeesOptions extends Pick { +export interface EstimateFeesOptions + extends Pick { args?: any[]; rpc?: string; json?: boolean; includeReport?: boolean; } -const toTransactionFees = (estimate: Record): Record => ({ - distribution: estimate.distribution, - ...(estimate.messageAllocations ? {messageAllocations: estimate.messageAllocations} : {}), - feeValue: estimate.feeValue ?? estimate.fee_value, -}); - const toJsonSafe = (value: any): any => { if (typeof value === "bigint") return value.toString(); if (Array.isArray(value)) return value.map(toJsonSafe); @@ -27,9 +22,8 @@ const toJsonSafe = (value: any): any => { return value; }; -const simulationFeeReport = (simulation: Record): Record | undefined => ( - simulation.feeReport ?? simulation.feeAccounting?.execution_fee_report -); +const simulationFeeReport = (simulation: Record): Record | undefined => + simulation.feeReport ?? simulation.feeAccounting?.execution_fee_report; const withSimulationReport = (estimate: unknown, simulation: unknown): unknown => { if (!simulation || typeof simulation !== "object" || Array.isArray(simulation)) { @@ -39,7 +33,7 @@ const withSimulationReport = (estimate: unknown, simulation: unknown): unknown = const simulationRecord = simulation as Record; return { ...(estimate && typeof estimate === "object" && !Array.isArray(estimate) - ? estimate as Record + ? (estimate as Record) : {estimate}), simulation: { feeAccounting: simulationRecord.feeAccounting, @@ -59,6 +53,9 @@ export class EstimateFeesAction extends BaseAction { args, rpc, fees, + feeProfile, + feePreset, + appealRounds, json, includeReport, }: EstimateFeesOptions & { @@ -68,17 +65,22 @@ export class EstimateFeesAction extends BaseAction { try { const client = await this.getClient(rpc, true); await client.initializeConsensusSmartContract(); - const estimateOptions = parseFeeEstimateOptions({fees}); if (!json) this.startSpinner("Estimating transaction fees..."); let estimate: unknown; if (contractAddress || method) { if (!contractAddress || !method) { - this.failSpinner("Both contractAddress and method are required for simulation-derived fee estimates."); + this.failSpinner( + "Both contractAddress and method are required for simulation-derived fee estimates.", + ); return; } + const estimateOptions = parseFeeEstimateOptions( + {fees, feeProfile, feePreset, appealRounds}, + {profileTarget: {kind: "method", method}}, + ); if (!json) this.setSpinnerText(`Simulating ${method} on ${contractAddress}...`); if (!includeReport && typeof client.estimateTransactionFeesForWrite === "function") { estimate = await client.estimateTransactionFeesForWrite({ @@ -93,7 +95,9 @@ export class EstimateFeesAction extends BaseAction { return; } if (typeof client.estimateTransactionFeesFromSimulation !== "function") { - this.failSpinner("The active genlayer-js client does not support simulation-derived fee estimates."); + this.failSpinner( + "The active genlayer-js client does not support simulation-derived fee estimates.", + ); return; } @@ -118,6 +122,11 @@ export class EstimateFeesAction extends BaseAction { this.failSpinner("--include-report requires both contractAddress and method."); return; } + const profileTarget: FeeProfileTarget | undefined = feeProfile ? {kind: "deploy"} : undefined; + const estimateOptions = parseFeeEstimateOptions( + {fees, feeProfile, feePreset, appealRounds}, + {profileTarget}, + ); estimate = await client.estimateTransactionFees(estimateOptions); } diff --git a/src/commands/contracts/fees.ts b/src/commands/contracts/fees.ts index acd914dc..f276e96d 100644 --- a/src/commands/contracts/fees.ts +++ b/src/commands/contracts/fees.ts @@ -1,15 +1,40 @@ -import { - DEPLOY_CALL_KEY, - deriveExternalMessageCallKey, - deriveInternalMessageCallKey, -} from "genlayer-js"; +import fs from "fs"; +import path from "path"; +import {DEPLOY_CALL_KEY, deriveExternalMessageCallKey, deriveInternalMessageCallKey} from "genlayer-js"; export interface ContractFeeCliOptions { fees?: string; + feeProfile?: string; + feePreset?: string; + appealRounds?: string; feeValue?: string; validUntil?: string; } +export type FeeProfileTarget = {kind: "deploy"} | {kind: "method"; method: string}; + +type FeeParseConfig = { + deployTargeted?: boolean; + profileTarget?: FeeProfileTarget; +}; + +const FEE_PROFILE_PRESET_APPEAL_ROUNDS: Record = { + low: "0", + standard: "1", + high: "2", +}; + +const FEE_PROFILE_FIELDS = [ + "leaderTimeunitsAllocation", + "validatorTimeunitsAllocation", + "executionBudgetPerRound", + "executionConsumed", + "totalMessageFees", + "maxPriceGenPerTimeUnit", + "storageFeeMaxGasPrice", + "receiptFeeMaxGasPrice", +]; + const parseJsonObject = (value: string, optionName: string): Record => { let parsed: unknown; try { @@ -52,6 +77,23 @@ const parseBigNumberishOption = (value: string | undefined, optionName: string): return trimmed; }; +const toSafeNonNegativeNumber = (value: string, optionName: string): number => { + const parsed = BigInt(value); + if (parsed > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${optionName} is too large.`); + } + return Number(parsed); +}; + +const parseProfilePresetAppealRounds = (options: ContractFeeCliOptions): string => { + const preset = options.feePreset ?? "standard"; + const appealRounds = FEE_PROFILE_PRESET_APPEAL_ROUNDS[preset]; + if (appealRounds === undefined) { + throw new Error("--fee-preset must be one of: low, standard, high."); + } + return appealRounds; +}; + const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | undefined => { if (messageType === undefined) { return undefined; @@ -61,7 +103,9 @@ const normalizeMessageType = (messageType: unknown, index: number): 0 | 1 | unde if (messageType === 0 || messageType === 1) { return messageType; } - throw new Error(`--fees.messageAllocations[${index}].messageType must be "internal", "external", 0, or 1.`); + throw new Error( + `--fees.messageAllocations[${index}].messageType must be "internal", "external", 0, or 1.`, + ); } if (typeof messageType !== "string") { @@ -103,27 +147,20 @@ const normalizeMessageAllocationCallKey = ( messageType: 0 | 1 | undefined, index: number, ): Record => { - const helperFields = [ - "callKeyMethod", - "callKeySelector", - "callKeyCalldata", - "functionSelector", - ].filter((field) => allocation[field] !== undefined); + const helperFields = ["callKeyMethod", "callKeySelector", "callKeyCalldata", "functionSelector"].filter( + field => allocation[field] !== undefined, + ); if (allocation.callKey !== undefined && helperFields.length > 0) { - throw new Error(`--fees.messageAllocations[${index}] cannot combine callKey with call-key helper fields.`); + throw new Error( + `--fees.messageAllocations[${index}] cannot combine callKey with call-key helper fields.`, + ); } if (helperFields.length > 1) { throw new Error(`--fees.messageAllocations[${index}] must use only one call-key helper field.`); } - const { - callKeyMethod, - callKeySelector, - callKeyCalldata, - functionSelector, - ...normalized - } = allocation; + const {callKeyMethod, callKeySelector, callKeyCalldata, functionSelector, ...normalized} = allocation; if (helperFields.length === 0) { return normalized; @@ -132,7 +169,9 @@ const normalizeMessageAllocationCallKey = ( const helperField = helperFields[0]; if (helperField === "callKeyMethod") { if (messageType === 0) { - throw new Error(`--fees.messageAllocations[${index}].callKeyMethod requires an internal message allocation.`); + throw new Error( + `--fees.messageAllocations[${index}].callKeyMethod requires an internal message allocation.`, + ); } normalized.messageType = messageType ?? 1; normalized.callKey = deriveInternalMessageCallKey(readStringField(allocation, helperField, index)); @@ -140,7 +179,9 @@ const normalizeMessageAllocationCallKey = ( } if (messageType === 1) { - throw new Error(`--fees.messageAllocations[${index}].${helperField} requires an external message allocation.`); + throw new Error( + `--fees.messageAllocations[${index}].${helperField} requires an external message allocation.`, + ); } const selectorOrCalldata = readStringField(allocation, helperField, index); @@ -167,10 +208,14 @@ const normalizeMessageTypes = (fees: Record, deployTargeted = false } const messageType = normalizeMessageType(allocation.messageType, index); - const normalized = normalizeMessageAllocationCallKey({ - ...allocation, - ...(messageType === undefined ? {} : {messageType}), - }, messageType, index); + const normalized = normalizeMessageAllocationCallKey( + { + ...allocation, + ...(messageType === undefined ? {} : {messageType}), + }, + messageType, + index, + ); if (deployTargeted && normalized.callKey === undefined) { normalized.callKey = DEPLOY_CALL_KEY; } @@ -179,9 +224,105 @@ const normalizeMessageTypes = (fees: Record, deployTargeted = false }; }; +const flattenFeeEstimateOptions = ( + parsed: Record, + config: FeeParseConfig = {}, +): Record => { + const normalized = normalizeMessageTypes(parsed, config.deployTargeted); + if ( + normalized.distribution && + typeof normalized.distribution === "object" && + !Array.isArray(normalized.distribution) + ) { + const {distribution, messageAllocations, ...rest} = normalized; + return { + ...distribution, + ...(messageAllocations !== undefined ? {messageAllocations} : {}), + ...rest, + }; + } + return normalized; +}; + +const readFeeProfile = (profilePath: string): Record => { + const resolvedPath = path.resolve(profilePath); + let content: string; + try { + content = fs.readFileSync(resolvedPath, "utf-8"); + } catch (error) { + throw new Error(`Unable to read --fee-profile at ${resolvedPath}.`); + } + return parseJsonObject(content, "--fee-profile"); +}; + +const feeProfileEntry = (profile: Record, target: FeeProfileTarget): Record => { + const entry = target.kind === "deploy" ? profile.deploy : profile.methods?.[target.method]; + + if (!entry || typeof entry !== "object" || Array.isArray(entry)) { + const targetLabel = target.kind === "deploy" ? "deploy" : `method "${target.method}"`; + throw new Error(`--fee-profile does not contain a fee profile for ${targetLabel}.`); + } + return entry; +}; + +const profileEntryToEstimateOptions = ( + entry: Record, + options: ContractFeeCliOptions, +): Record => { + assertSafeJsonNumbers(entry, "--fee-profile entry"); + const result: Record = {}; + + for (const key of FEE_PROFILE_FIELDS) { + if (entry[key] !== undefined) { + result[key] = entry[key]; + } + } + + if (entry.messageAllocations !== undefined) { + result.messageAllocations = entry.messageAllocations; + } + + if (entry.rotations !== undefined) { + result.rotations = entry.rotations; + if (entry.appealRounds !== undefined) { + result.appealRounds = entry.appealRounds; + } + return result; + } + + const appealRounds = parseBigNumberishOption( + options.appealRounds ?? entry.appealRounds?.toString() ?? parseProfilePresetAppealRounds(options), + "--appeal-rounds", + )!; + const rotationsPerRound = parseBigNumberishOption( + entry.rotationsPerRound?.toString() ?? "0", + "--fee-profile rotationsPerRound", + )!; + const rotationCount = toSafeNonNegativeNumber(appealRounds, "--appeal-rounds") + 1; + + result.appealRounds = appealRounds; + result.rotations = Array(rotationCount).fill(rotationsPerRound); + return result; +}; + +const parseProfileEstimateOptions = ( + options: ContractFeeCliOptions, + config: FeeParseConfig = {}, +): Record | undefined => { + if (!options.feeProfile) { + return undefined; + } + + const target = config.profileTarget ?? {kind: "deploy" as const}; + return flattenFeeEstimateOptions( + profileEntryToEstimateOptions(feeProfileEntry(readFeeProfile(options.feeProfile), target), options), + config, + ); +}; + export const parseTransactionFees = ( options: ContractFeeCliOptions, - config: {deployTargeted?: boolean} = {}, + config: FeeParseConfig = {}, ): Record | undefined => { const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); let fees = options.fees ? parseJsonObject(options.fees, "--fees") : undefined; @@ -197,21 +338,51 @@ export const parseTransactionFees = ( return fees; }; -export const parseFeeEstimateOptions = (options: Pick): Record | undefined => { +export const parseFeeEstimateOptions = ( + options: Pick, + config: FeeParseConfig = {}, +): Record | undefined => { + const profileOptions = parseProfileEstimateOptions(options, config); if (!options.fees) { - return undefined; + return profileOptions; } - const parsed = normalizeMessageTypes(parseJsonObject(options.fees, "--fees")); - if (parsed.distribution && typeof parsed.distribution === "object" && !Array.isArray(parsed.distribution)) { - const {distribution, messageAllocations, ...rest} = parsed; - return { - ...distribution, - ...(messageAllocations !== undefined ? {messageAllocations} : {}), - ...rest, - }; + const explicitOptions = flattenFeeEstimateOptions(parseJsonObject(options.fees, "--fees"), config); + if (!profileOptions) { + return explicitOptions; + } + return { + ...profileOptions, + ...explicitOptions, + }; +}; + +export const toTransactionFees = (estimate: Record): Record => ({ + distribution: estimate.distribution, + ...(estimate.messageAllocations ? {messageAllocations: estimate.messageAllocations} : {}), + feeValue: estimate.feeValue ?? estimate.fee_value, +}); + +export const resolveTransactionFees = async ( + client: {estimateTransactionFees?: (options?: Record) => Promise>}, + options: ContractFeeCliOptions, + config: FeeParseConfig = {}, +): Promise | undefined> => { + if (!options.feeProfile) { + return parseTransactionFees(options, config); + } + + if (typeof client.estimateTransactionFees !== "function") { + throw new Error("The active genlayer-js client does not support fee profile estimation."); + } + + const estimateOptions = parseFeeEstimateOptions(options, config); + const transactionFees = toTransactionFees(await client.estimateTransactionFees(estimateOptions)); + const feeValue = parseBigNumberishOption(options.feeValue, "--fee-value"); + if (feeValue !== undefined) { + transactionFees.feeValue = feeValue; } - return parsed; + return transactionFees; }; export const parseValidUntil = (options: ContractFeeCliOptions): string | undefined => { diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index 07c2f993..c18b5df2 100644 --- a/src/commands/contracts/index.ts +++ b/src/commands/contracts/index.ts @@ -78,7 +78,7 @@ const ARGS_HELP = [ ' str: hello, "multi word"', " address: 0x6857...a0 (40 hex chars) or addr#6857...a0", " bytes: b#deadbeef", - ' array: \'[1, 2, "three"]\'', + " array: '[1, 2, \"three\"]'", ' dict: \'{"key": "value"}\'', ].join("\n"); @@ -100,6 +100,12 @@ const FEE_ESTIMATE_HELP = [ "Use callKeyMethod for internal messages, or callKeySelector/callKeyCalldata for external messages.", ].join("\n"); +const FEE_PROFILE_HELP = [ + "Path to a fee profile generated by gltest --fee-profile.", + "Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry.", + "--fees can still be provided to override profile values.", +].join("\n"); + export function initializeContractsCommands(program: Command) { program .command("deploy") @@ -107,6 +113,9 @@ export function initializeContractsCommands(program: Command) { .option("--contract ", "Path to the smart contract to deploy") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--fee-value ", "Fee deposit value to send with the transaction") .option("--valid-until ", "Unix timestamp after which the transaction is invalid") .option("--args ", ARGS_HELP, parseArg, []) @@ -124,12 +133,7 @@ export function initializeContractsCommands(program: Command) { .command("call ") .description("Call a contract method without sending a transaction or changing the state") .option("--rpc ", "RPC URL for the network") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) + .option("--args ", ARGS_HELP, parseArg, []) .action(async (contractAddress: string, method: string, options: CallOptions) => { const callAction = new CallAction(); await callAction.call({contractAddress, method, ...options}); @@ -140,14 +144,12 @@ export function initializeContractsCommands(program: Command) { .description("Sends a transaction to a contract method that modifies the state") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEES_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--fee-value ", "Fee deposit value to send with the transaction") .option("--valid-until ", "Unix timestamp after which the transaction is invalid") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) + .option("--args ", ARGS_HELP, parseArg, []) .action(async (contractAddress: string, method: string, options: WriteOptions) => { const writeAction = new WriteAction(); await writeAction.write({contractAddress, method, ...options}); @@ -158,18 +160,22 @@ export function initializeContractsCommands(program: Command) { .description("Build a transaction fee preset, optionally from a Studio/localnet write simulation") .option("--rpc ", "RPC URL for the network") .option("--fees ", FEE_ESTIMATE_HELP) + .option("--fee-profile ", FEE_PROFILE_HELP) + .option("--fee-preset ", "Fee profile appeal posture: low, standard, or high") + .option("--appeal-rounds ", "Override fee profile appeal rounds") .option("--json", "Print the fee estimate as JSON without spinner output") .option("--include-report", "Include simulation fee accounting/report in the generated estimate output") - .option( - "--args ", - ARGS_HELP, - parseArg, - [], - ) - .action(async (contractAddress: string | undefined, method: string | undefined, options: EstimateFeesOptions) => { - const estimateFeesAction = new EstimateFeesAction(); - await estimateFeesAction.estimate({contractAddress, method, ...options}); - }); + .option("--args ", ARGS_HELP, parseArg, []) + .action( + async ( + contractAddress: string | undefined, + method: string | undefined, + options: EstimateFeesOptions, + ) => { + const estimateFeesAction = new EstimateFeesAction(); + await estimateFeesAction.estimate({contractAddress, method, ...options}); + }, + ); program .command("schema ") diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index a30aacbd..42622b8a 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -2,7 +2,7 @@ // import type {GenLayerClient} from "genlayer-js/types"; import {formatStakingAmount} from "genlayer-js"; import {BaseAction} from "../../lib/actions/BaseAction"; -import {ContractFeeCliOptions, parseTransactionFees, parseValidUntil} from "./fees"; +import {ContractFeeCliOptions, parseValidUntil, resolveTransactionFees} from "./fees"; import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution"; export interface WriteOptions extends ContractFeeCliOptions { @@ -21,16 +21,14 @@ export class WriteAction extends BaseAction { args, rpc, fees, + feeProfile, + feePreset, + appealRounds, feeValue, validUntil, - }: { + }: WriteOptions & { contractAddress: string; method: string; - args: any[]; - rpc?: string; - fees?: string; - feeValue?: string; - validUntil?: string; }): Promise { const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); @@ -43,8 +41,19 @@ export class WriteAction extends BaseAction { args, value: 0n, }; - const parsedFees = parseTransactionFees({fees, feeValue, validUntil}); - const parsedValidUntil = parseValidUntil({fees, feeValue, validUntil}); + const parsedFees = await resolveTransactionFees( + client, + {fees, feeProfile, feePreset, appealRounds, feeValue, validUntil}, + {profileTarget: {kind: "method", method}}, + ); + const parsedValidUntil = parseValidUntil({ + fees, + feeProfile, + feePreset, + appealRounds, + feeValue, + validUntil, + }); if (parsedFees) writeParams.fees = parsedFees; if (parsedValidUntil !== undefined) writeParams.validUntil = parsedValidUntil; if (parsedFees?.feeValue !== undefined) { diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 82ec1bc6..5df2a065 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -19,6 +19,7 @@ describe("DeployAction", () => { deployContract: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; @@ -35,9 +36,9 @@ describe("DeployAction", () => { vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); vi.mocked(isSuccessful).mockImplementation((receipt: any) => { const statusName = receipt.statusName ?? receipt.status; - const executionResultName = receipt.txExecutionResultName ?? ( - receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined - ); + const executionResultName = + receipt.txExecutionResultName ?? + (receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined); return ( (statusName === "ACCEPTED" || statusName === "FINALIZED") && executionResultName === "FINISHED_WITH_RETURN" @@ -124,11 +125,13 @@ describe("DeployAction", () => { leaderTimeunitsAllocation: "10", rotations: ["0"], }, - messageAllocations: [{ - messageType: "internal", - recipient: "0x0000000000000000000000000000000000000001", - budget: "5", - }], + messageAllocations: [ + { + messageType: "internal", + recipient: "0x0000000000000000000000000000000000000001", + budget: "5", + }, + ], }), feeValue: "123", validUntil: "999", @@ -155,18 +158,88 @@ describe("DeployAction", () => { leaderTimeunitsAllocation: "10", rotations: ["0"], }, - messageAllocations: [{ - messageType: 1, - recipient: "0x0000000000000000000000000000000000000001", - callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", - budget: "5", - }], + messageAllocations: [ + { + messageType: 1, + recipient: "0x0000000000000000000000000000000000000001", + callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", + budget: "5", + }, + ], feeValue: "123", }, validUntil: "999", }); }); + test("deploys contract with fees estimated from a fee profile", async () => { + const options: DeployOptions = { + contract: "/mocked/contract/path", + args: [1], + feeProfile: "/mocked/fee-profile.json", + feeValue: "999", + }; + const contractContent = "contract code"; + const feeProfile = { + version: 1, + network: "localnet", + deploy: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + rotationsPerRound: "1", + }, + methods: {}, + }; + const feeEstimate = { + distribution: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }, + feeValue: "123", + }; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(((filePath: string) => { + const normalizedPath = filePath.replace(/\\/g, "/"); + if (normalizedPath === "/mocked/contract/path") return contractContent; + if (normalizedPath.endsWith("/fee-profile.json")) return JSON.stringify(feeProfile); + return JSON.stringify({activeAccount: "default"}); + }) as any); + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(feeEstimate); + vi.mocked(mockClient.deployContract).mockResolvedValue("mocked_tx_hash"); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue({ + statusName: "ACCEPTED", + txExecutionResultName: "FINISHED_WITH_RETURN", + data: {contract_address: "0xdasdsadasdasdada"}, + }); + + await deployer.deploy(options); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }); + expect(mockClient.deployContract).toHaveBeenCalledWith({ + code: contractContent, + args: [1], + leaderOnly: false, + fees: { + distribution: feeEstimate.distribution, + feeValue: "999", + }, + }); + }); + test("fails when deployment reaches consensus but execution fails", async () => { const options: DeployOptions = { contract: "/mocked/contract/path", diff --git a/tests/actions/estimateFees.test.ts b/tests/actions/estimateFees.test.ts index cf072465..605f4ef4 100644 --- a/tests/actions/estimateFees.test.ts +++ b/tests/actions/estimateFees.test.ts @@ -1,4 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; import {createClient, createAccount, deriveInternalMessageCallKey} from "genlayer-js"; import {EstimateFeesAction} from "../../src/commands/contracts/estimateFees"; @@ -16,13 +19,21 @@ describe("EstimateFeesAction", () => { const mockPrivateKey = "mocked_private_key"; + const writeFeeProfile = (profile: Record): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-cli-fees-")); + const profilePath = path.join(dir, "fee-profile.json"); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + return profilePath; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); vi.mocked(createAccount).mockReturnValue({privateKey: mockPrivateKey} as any); - vi.mocked(deriveInternalMessageCallKey).mockImplementation((methodName = "") => ( - `0x${Buffer.from(methodName, "utf8").toString("hex").padEnd(64, "0")}` as `0x${string}` - )); + vi.mocked(deriveInternalMessageCallKey).mockImplementation( + (methodName = "") => + `0x${Buffer.from(methodName, "utf8").toString("hex").padEnd(64, "0")}` as `0x${string}`, + ); action = new EstimateFeesAction(); vi.spyOn(action as any, "getAccount").mockResolvedValue({privateKey: mockPrivateKey}); vi.spyOn(action as any, "startSpinner").mockImplementation(() => {}); @@ -64,6 +75,55 @@ describe("EstimateFeesAction", () => { }); }); + test("builds a static fee estimate from the deploy fee profile entry", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + deploy: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + rotationsPerRound: "1", + }, + methods: {}, + }); + const estimate = { + distribution: { + leaderTimeunitsAllocation: 100n, + validatorTimeunitsAllocation: 200n, + executionBudgetPerRound: 300n, + totalMessageFees: 0n, + appealRounds: 1n, + rotations: [1n, 1n], + }, + feeValue: 1700n, + }; + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(estimate); + + await action.estimate({feeProfile: profilePath}); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "0", + appealRounds: "1", + rotations: ["1", "1"], + }, + feeValue: "1700", + }); + }); + test("prints a static fee estimate as JSON without spinner output", async () => { const estimate = { distribution: {leaderTimeunitsAllocation: 100n, rotations: [0n]}, @@ -77,11 +137,13 @@ describe("EstimateFeesAction", () => { expect(action["startSpinner"]).not.toHaveBeenCalled(); expect(action["succeedSpinner"]).not.toHaveBeenCalled(); - expect(logSpy).toHaveBeenCalledWith(JSON.stringify({ - distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, - feeValue: "1100", - policy: {enabled: true}, - })); + expect(logSpy).toHaveBeenCalledWith( + JSON.stringify({ + distribution: {leaderTimeunitsAllocation: "100", rotations: ["0"]}, + feeValue: "1100", + policy: {enabled: true}, + }), + ); }); test("derives a fee estimate for a target write through the SDK one-call helper", async () => { @@ -99,20 +161,24 @@ describe("EstimateFeesAction", () => { method: "update", args: ["after"], fees: JSON.stringify({ - messageAllocations: [{ - messageType: "internal", - callKeyMethod: "settle_campaign", - budget: "110", - }], + messageAllocations: [ + { + messageType: "internal", + callKeyMethod: "settle_campaign", + budget: "110", + }, + ], }), }); expect(mockClient.estimateTransactionFeesForWrite).toHaveBeenCalledWith({ - messageAllocations: [{ - messageType: 1, - callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, - budget: "110", - }], + messageAllocations: [ + { + messageType: 1, + callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, + budget: "110", + }, + ], address: "0x0000000000000000000000000000000000000001", functionName: "update", args: ["after"], @@ -129,6 +195,80 @@ describe("EstimateFeesAction", () => { }); }); + test("derives a target write fee estimate from the matching method fee profile entry", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + methods: { + update: { + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "55", + rotationsPerRound: "1", + }, + }, + }); + const finalEstimate = { + distribution: { + leaderTimeunitsAllocation: 100n, + totalMessageFees: 80n, + appealRounds: 2n, + rotations: [1n, 1n, 1n], + }, + messageAllocations: [{messageType: 1, budget: 80n}], + feeValue: 1780n, + }; + vi.mocked(mockClient.estimateTransactionFeesForWrite).mockResolvedValue(finalEstimate); + + await action.estimate({ + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + args: ["after"], + feeProfile: profilePath, + feePreset: "high", + fees: JSON.stringify({ + totalMessageFees: "80", + messageAllocations: [ + { + messageType: "internal", + callKeyMethod: "settle_campaign", + budget: "80", + }, + ], + }), + }); + + expect(mockClient.estimateTransactionFeesForWrite).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "100", + validatorTimeunitsAllocation: "200", + executionBudgetPerRound: "300", + totalMessageFees: "80", + appealRounds: "2", + rotations: ["1", "1", "1"], + messageAllocations: [ + { + messageType: 1, + callKey: `0x${Buffer.from("settle_campaign", "utf8").toString("hex").padEnd(64, "0")}`, + budget: "80", + }, + ], + address: "0x0000000000000000000000000000000000000001", + functionName: "update", + args: ["after"], + }); + expect(action["succeedSpinner"]).toHaveBeenCalledWith("Fee estimate generated", { + distribution: { + leaderTimeunitsAllocation: "100", + totalMessageFees: "80", + appealRounds: "2", + rotations: ["1", "1", "1"], + }, + messageAllocations: [{messageType: 1, budget: "80"}], + feeValue: "1780", + }); + }); + test("falls back to explicit simulation when the SDK one-call helper is unavailable", async () => { const legacyClient = { ...mockClient, diff --git a/tests/actions/write.test.ts b/tests/actions/write.test.ts index 1e483526..22938c05 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -1,4 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; import { createClient, createAccount, @@ -16,10 +19,18 @@ describe("WriteAction", () => { writeContract: vi.fn(), waitForTransactionReceipt: vi.fn(), initializeConsensusSmartContract: vi.fn(), + estimateTransactionFees: vi.fn(), }; const mockPrivateKey = "mocked_private_key"; + const writeFeeProfile = (profile: Record): string => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "genlayer-cli-fees-")); + const profilePath = path.join(dir, "fee-profile.json"); + fs.writeFileSync(profilePath, JSON.stringify(profile)); + return profilePath; + }; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(createClient).mockReturnValue(mockClient as any); @@ -27,18 +38,19 @@ describe("WriteAction", () => { vi.mocked(formatStakingAmount).mockImplementation((value: bigint) => `${value.toString()} GEN`); vi.mocked(deriveExternalMessageCallKey).mockImplementation( (selectorOrCalldata: `0x${string}` | Uint8Array = "0x") => { - const hex = typeof selectorOrCalldata === "string" - ? selectorOrCalldata.slice(2) - : Buffer.from(selectorOrCalldata).toString("hex"); + const hex = + typeof selectorOrCalldata === "string" + ? selectorOrCalldata.slice(2) + : Buffer.from(selectorOrCalldata).toString("hex"); if (hex.length < 8) return "0x0000000000000000000000000000000000000000000000000000000000000000"; return `0x${hex.slice(0, 8).padEnd(64, "0")}`; }, ); vi.mocked(isSuccessful).mockImplementation((receipt: any) => { const statusName = receipt.statusName ?? receipt.status; - const executionResultName = receipt.txExecutionResultName ?? ( - receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined - ); + const executionResultName = + receipt.txExecutionResultName ?? + (receipt.txExecutionResult === 1 ? "FINISHED_WITH_RETURN" : undefined); return ( (statusName === "ACCEPTED" || statusName === "FINALIZED") && executionResultName === "FINISHED_WITH_RETURN" @@ -85,10 +97,10 @@ describe("WriteAction", () => { waitUntil: "decided", fullTransaction: true, }); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - {...mockReceipt, consensusStatus: "ACCEPTED"}, - ); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); }); test("calls writeContract with fee options", async () => { @@ -106,12 +118,14 @@ describe("WriteAction", () => { distribution: { totalMessageFees: "3", }, - messageAllocations: [{ - messageType: "external", - recipient: "0x0000000000000000000000000000000000000001", - callKeySelector: "0xaabbccdd", - budget: "3", - }], + messageAllocations: [ + { + messageType: "external", + recipient: "0x0000000000000000000000000000000000000001", + callKeySelector: "0xaabbccdd", + budget: "3", + }, + ], }), feeValue: "4", validUntil: "999", @@ -126,18 +140,80 @@ describe("WriteAction", () => { distribution: { totalMessageFees: "3", }, - messageAllocations: [{ - messageType: 0, - recipient: "0x0000000000000000000000000000000000000001", - callKey: `0xaabbccdd${"0".repeat(56)}`, - budget: "3", - }], + messageAllocations: [ + { + messageType: 0, + recipient: "0x0000000000000000000000000000000000000001", + callKey: `0xaabbccdd${"0".repeat(56)}`, + budget: "3", + }, + ], feeValue: "4", }, validUntil: "999", }); }); + test("calls writeContract with fees estimated from a method fee profile", async () => { + const profilePath = writeFeeProfile({ + version: 1, + network: "localnet", + methods: { + updateData: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + rotationsPerRound: "1", + }, + }, + }); + const feeEstimate = { + distribution: { + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + appealRounds: "2", + rotations: ["1", "1", "1"], + }, + feeValue: "123", + }; + const mockHash = "0xMockedTransactionHash"; + const mockReceipt = {statusName: "ACCEPTED", txExecutionResultName: "FINISHED_WITH_RETURN"}; + + vi.mocked(mockClient.estimateTransactionFees).mockResolvedValue(feeEstimate); + vi.mocked(mockClient.writeContract).mockResolvedValue(mockHash); + vi.mocked(mockClient.waitForTransactionReceipt).mockResolvedValue(mockReceipt); + + await writeAction.write({ + contractAddress: "0xMockedContract", + method: "updateData", + args: [42], + feeProfile: profilePath, + feePreset: "high", + }); + + expect(mockClient.estimateTransactionFees).toHaveBeenCalledWith({ + leaderTimeunitsAllocation: "10", + validatorTimeunitsAllocation: "20", + executionBudgetPerRound: "30", + totalMessageFees: "5", + appealRounds: "2", + rotations: ["1", "1", "1"], + }); + expect(mockClient.writeContract).toHaveBeenCalledWith({ + address: "0xMockedContract", + functionName: "updateData", + args: [42], + value: 0n, + fees: { + distribution: feeEstimate.distribution, + feeValue: "123", + }, + }); + }); + test("handles writeContract errors", async () => { vi.mocked(mockClient.writeContract).mockRejectedValue(new Error("Mocked write error")); @@ -290,9 +366,9 @@ describe("WriteAction", () => { args: [42, "Update"], value: 0n, }); - expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith( - "Write operation successfully executed", - {...mockReceipt, consensusStatus: "ACCEPTED"}, - ); + expect(writeAction["succeedSpinner"]).toHaveBeenCalledWith("Write operation successfully executed", { + ...mockReceipt, + consensusStatus: "ACCEPTED", + }); }); }); diff --git a/tests/commands/deploy.test.ts b/tests/commands/deploy.test.ts index 271e60ec..85a493f8 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -1,7 +1,7 @@ -import { Command } from "commander"; -import { vi, describe, beforeEach, afterEach, test, expect } from "vitest"; -import { initializeContractsCommands } from "../../src/commands/contracts"; -import { DeployAction } from "../../src/commands/contracts/deploy"; +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeContractsCommands} from "../../src/commands/contracts"; +import {DeployAction} from "../../src/commands/contracts/deploy"; vi.mock("../../src/commands/contracts/deploy"); vi.mock("esbuild", () => ({ @@ -42,13 +42,13 @@ describe("deploy command", () => { "2", "3", "--rpc", - "https://custom-rpc-url.com" + "https://custom-rpc-url.com", ]); expect(DeployAction).toHaveBeenCalledTimes(1); expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ contract: "./path/to/contract", args: [1, 2, 3], - rpc: "https://custom-rpc-url.com" + rpc: "https://custom-rpc-url.com", }); }); @@ -77,32 +77,52 @@ describe("deploy command", () => { }); }); + test("DeployAction.deploy receives fee profile options", async () => { + program.parse([ + "node", + "test", + "deploy", + "--contract", + "./path/to/contract", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "high", + "--appeal-rounds", + "3", + ]); + + expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ + contract: "./path/to/contract", + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "high", + appealRounds: "3", + }); + }); + test("DeployAction is instantiated when the deploy command is executed", async () => { program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]); expect(DeployAction).toHaveBeenCalledTimes(1); }); test("throws error for unrecognized options", async () => { - const deployCommand = program.commands.find((cmd) => cmd.name() === "deploy"); + const deployCommand = program.commands.find(cmd => cmd.name() === "deploy"); deployCommand?.exitOverride(); expect(() => program.parse(["node", "test", "deploy", "--unknown"])).toThrowError( - "error: unknown option '--unknown'" + "error: unknown option '--unknown'", ); }); test("DeployAction.deploy is called without throwing errors for valid options", async () => { program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]); vi.mocked(DeployAction.prototype.deploy).mockResolvedValueOnce(undefined); - expect(() => - program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"]) - ).not.toThrow(); + expect(() => program.parse(["node", "test", "deploy", "--contract", "./path/to/contract"])).not.toThrow(); }); test("DeployAction.deployScripts is called without throwing errors", async () => { program.parse(["node", "test", "deploy"]); vi.mocked(DeployAction.prototype.deployScripts).mockResolvedValueOnce(undefined); - expect(() => - program.parse(["node", "test", "deploy"]) - ).not.toThrow(); + expect(() => program.parse(["node", "test", "deploy"])).not.toThrow(); }); }); diff --git a/tests/commands/estimateFees.test.ts b/tests/commands/estimateFees.test.ts index 942644da..cb8ba54a 100644 --- a/tests/commands/estimateFees.test.ts +++ b/tests/commands/estimateFees.test.ts @@ -23,15 +23,7 @@ describe("estimate-fees command", () => { test("EstimateFeesAction.estimate is called with static estimate options", async () => { const fees = '{"distribution":{"totalMessageFees":"3"}}'; - program.parse([ - "node", - "test", - "estimate-fees", - "--fees", - fees, - "--rpc", - "http://127.0.0.1:4000/api", - ]); + program.parse(["node", "test", "estimate-fees", "--fees", fees, "--rpc", "http://127.0.0.1:4000/api"]); expect(EstimateFeesAction).toHaveBeenCalledTimes(1); expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ @@ -43,6 +35,31 @@ describe("estimate-fees command", () => { }); }); + test("EstimateFeesAction.estimate receives fee profile options", async () => { + program.parse([ + "node", + "test", + "estimate-fees", + "0x0000000000000000000000000000000000000001", + "update", + "--fee-profile", + "./artifacts/fee-profile.json", + "--fee-preset", + "high", + "--appeal-rounds", + "3", + ]); + + expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ + args: [], + feeProfile: "./artifacts/fee-profile.json", + feePreset: "high", + appealRounds: "3", + contractAddress: "0x0000000000000000000000000000000000000001", + method: "update", + }); + }); + test("EstimateFeesAction.estimate is called with simulation target and args", async () => { program.parse([ "node", @@ -63,12 +80,7 @@ describe("estimate-fees command", () => { }); test("EstimateFeesAction.estimate receives json output flag", async () => { - program.parse([ - "node", - "test", - "estimate-fees", - "--json", - ]); + program.parse(["node", "test", "estimate-fees", "--json"]); expect(EstimateFeesAction.prototype.estimate).toHaveBeenCalledWith({ args: [], diff --git a/tests/commands/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); From 0e76bcef4524907f80d1567ad688550e24c7192b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 7 Jul 2026 21:21:51 +0100 Subject: [PATCH 07/45] feat: staking validators discovery (#357) * feat: add staking validators discovery * feat: epoch-aware validator listing with below-min indicator * fix(staking): account-less client for read-only staking queries getReadOnlyStakingClient threw 'Account not found' on fresh installs; listings and other reads don't need a local account. --- src/commands/staking/StakingAction.ts | 7 +- src/commands/staking/index.ts | 13 +- src/commands/staking/validators.ts | 623 +++++++++++++++++++++++ tests/commands/staking.test.ts | 31 ++ tests/commands/stakingValidators.test.ts | 197 +++++++ 5 files changed, 866 insertions(+), 5 deletions(-) create mode 100644 src/commands/staking/validators.ts create mode 100644 tests/commands/stakingValidators.test.ts diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 12d92133..5eb6b067 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -113,7 +113,12 @@ export class StakingAction extends BaseAction { const keystorePath = this.getKeystorePath(accountName); if (!existsSync(keystorePath)) { - throw new Error(`Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`); + // Read-only queries don't need a local account: fall back to an + // account-less client so listings work on a fresh install. + return createClient({ + chain: network, + endpoint: config.rpc, + }); } const keystoreData = JSON.parse(readFileSync(keystorePath, "utf-8")); diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index 41a1d535..c2252f30 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -1,4 +1,5 @@ import {Command} from "commander"; +import type {StakingConfig} from "./StakingAction"; import {ValidatorJoinAction, ValidatorJoinOptions} from "./validatorJoin"; import {ValidatorDepositAction, ValidatorDepositOptions} from "./validatorDeposit"; import {ValidatorExitAction, ValidatorExitOptions} from "./validatorExit"; @@ -11,6 +12,7 @@ import {DelegatorExitAction, DelegatorExitOptions} from "./delegatorExit"; import {DelegatorClaimAction, DelegatorClaimOptions} from "./delegatorClaim"; import {StakingInfoAction, StakingInfoOptions} from "./stakingInfo"; import {ValidatorHistoryAction, ValidatorHistoryOptions} from "./validatorHistory"; +import {ValidatorsAction, ValidatorsOptions} from "./validators"; import {ValidatorWizardAction, WizardOptions} from "./wizard"; export function initializeStakingCommands(program: Command) { @@ -324,14 +326,17 @@ export function initializeStakingCommands(program: Command) { staking .command("validators") - .description("Show validator set with stake, status, and voting power") + .description("List validators with stake, status, and optional explorer performance") .option("--all", "Include banned validators") + .option("--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 ", "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: StakingInfoOptions & {all?: boolean}) => { - const action = new StakingInfoAction(); - await action.listValidators(options); + .action(async (options: ValidatorsOptions) => { + const action = new ValidatorsAction(); + await action.execute(options); }); staking diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts new file mode 100644 index 00000000..cfd8617e --- /dev/null +++ b/src/commands/staking/validators.ts @@ -0,0 +1,623 @@ +import {resolveNetwork} from "../../lib/actions/BaseAction"; +import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import type {Address, GenLayerChain, ValidatorInfo} from "genlayer-js/types"; +import Table from "cli-table3"; +import chalk from "chalk"; + +const ACTIVATION_DELAY_EPOCHS = 2n; +const UNBONDING_PERIOD_EPOCHS = 7n; +const EXPLORER_PAGE_SIZE = 100; +const EXPLORER_TIMEOUT_MS = 5000; + +export interface ValidatorsOptions extends StakingConfig { + all?: boolean; + json?: boolean; + explorerUrl?: string; + sortBy?: "stake" | "uptime" | string; +} + +interface ExplorerValidatorSummary { + validator_address?: string; + validatorAddress?: string; + status?: string | null; + is_active?: boolean; + isActive?: boolean; + apy?: string | null; + idle_pct_7d?: number | null; + idlePct7d?: number | null; + rotation_pct_7d?: number | null; + rotationPct7d?: number | null; + minority_pct_7d?: number | null; + minorityPct7d?: number | null; + transaction_count?: number; + transactionCount?: number | null; +} + +interface ExplorerAddressValidator { + delegators?: unknown[]; + total_votes_7d?: number; + totalVotes7d?: number | null; + minority_votes_7d?: number; + minorityVotes7d?: number | null; + successful_appeals_7d?: number; + successfulAppeals7d?: number | null; + idle_pct_7d?: number | null; + idlePct7d?: number | null; + rotation_pct_7d?: number | null; + rotationPct7d?: number | null; + minority_pct_7d?: number | null; + minorityPct7d?: number | null; + apy?: string | null; +} + +interface ExplorerPerformance { + apy?: string | null; + uptimePct?: number | null; + idlePct7d?: number | null; + rotationPct7d?: number | null; + minorityPct7d?: number | null; + totalVotes7d?: number | null; + minorityVotes7d?: number | null; + successfulAppeals7d?: number | null; + transactionCount?: number | null; +} + +interface ExplorerData { + endpoint: string; + validators: Map; +} + +interface ValidatorRow { + address: Address; + owner: Address; + operator: Address; + moniker?: string; + active: boolean; + live: boolean; + banned: boolean; + bannedUntilEpoch?: string; + status: string; + belowMin: boolean; + selfStake: string; + selfStakeRaw: bigint; + delegatedStake: string; + delegatedStakeRaw: bigint; + totalStake: string; + totalStakeRaw: bigint; + pendingDepositRaw: bigint; + pendingWithdrawalRaw: bigint; + primedEpoch: string; + needsPriming: boolean; + delegatorCount: number | null; + epochsActive: number | null; + isMine: boolean; + performance?: ExplorerPerformance; +} + +export class ValidatorsAction extends StakingAction { + async execute(options: ValidatorsOptions): Promise { + this.startSpinner("Fetching validator set..."); + + try { + const client: any = await this.getReadOnlyStakingClient(options); + + let myAddress: Address | null = null; + try { + myAddress = await this.getSignerAddress(); + } catch { + // Listing validators should not require a local account. + } + + const [allTreeAddresses, activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ + this.getAllValidatorsFromTree(options), + client.getActiveValidators(), + client.getQuarantinedValidatorsDetailed(), + client.getBannedValidators(), + client.getEpochInfo(), + ]); + + const quarantinedSet = new Map(quarantinedList.map((v: any) => [v.validator.toLowerCase(), v])); + const bannedSet = new Map(bannedList.map((v: any) => [v.validator.toLowerCase(), v])); + const activeSet = new Set(activeAddresses.map((a: string) => a.toLowerCase())); + const currentEpoch = BigInt(epochInfo.currentEpoch); + const validatorMinStakeRaw = BigInt(epochInfo.validatorMinStakeRaw ?? 0n); + + const allAddresses: Address[] = options.all + ? allTreeAddresses + : allTreeAddresses.filter((addr: Address) => !bannedSet.has(addr.toLowerCase())); + + this.setSpinnerText(`Fetching details for ${allAddresses.length} validators...`); + + const validatorInfos = await this.fetchValidatorInfos(client, allAddresses); + const explorerUrl = options.explorerUrl || this.getDefaultExplorerUrl(options); + const explorerData = explorerUrl + ? await this.fetchExplorerData(explorerUrl, validatorInfos.map(info => info.address)) + : null; + + const rows = validatorInfos.map(info => { + const addrLower = info.address.toLowerCase(); + const isQuarantined = quarantinedSet.has(addrLower); + const isBanned = info.banned || bannedSet.has(addrLower); + const isActive = activeSet.has(addrLower); + const bannedInfo = bannedSet.get(addrLower); + const quarantinedInfo = quarantinedSet.get(addrLower); + const performance = explorerData?.validators.get(addrLower); + + return this.buildRow({ + info, + currentEpoch, + validatorMinStakeRaw, + isActive, + isQuarantined, + isBanned, + bannedInfo, + quarantinedInfo, + myAddress, + performance, + }); + }); + + const sortedRows = this.sortRows(rows, options.sortBy || "stake"); + + this.stopSpinner(); + + if (options.json) { + const output = { + count: sortedRows.length, + activeCount: sortedRows.filter(row => row.active).length, + current_epoch: currentEpoch.toString(), + sortBy: this.normalizeSortBy(options.sortBy || "stake"), + explorer: explorerData + ? {enabled: true, url: explorerUrl, endpoint: explorerData.endpoint} + : {enabled: false, url: explorerUrl || null}, + validators: sortedRows.map(row => this.toJsonRow(row)), + }; + console.log(JSON.stringify(output, null, 2)); + return; + } + + this.printTable(sortedRows, currentEpoch); + } catch (error: any) { + this.failSpinner("Failed to list validators", error.message || error); + } + } + + private async fetchValidatorInfos(client: any, addresses: Address[]): Promise { + const BATCH_SIZE = 5; + const validatorInfos: ValidatorInfo[] = []; + + for (let i = 0; i < addresses.length; i += BATCH_SIZE) { + const batch = addresses.slice(i, i + BATCH_SIZE); + const batchResults = await Promise.all( + batch.map(addr => client.getValidatorInfo(addr as Address)), + ); + validatorInfos.push(...batchResults); + if (i + BATCH_SIZE < addresses.length) { + this.setSpinnerText(`Fetching details... ${Math.min(i + BATCH_SIZE, addresses.length)}/${addresses.length}`); + } + } + + return validatorInfos; + } + + private buildRow({ + info, + currentEpoch, + validatorMinStakeRaw, + isActive, + isQuarantined, + isBanned, + bannedInfo, + quarantinedInfo, + myAddress, + performance, + }: { + info: ValidatorInfo; + currentEpoch: bigint; + validatorMinStakeRaw: bigint; + isActive: boolean; + isQuarantined: boolean; + isBanned: boolean; + bannedInfo: any; + quarantinedInfo: any; + myAddress: Address | null; + performance?: ExplorerPerformance & {delegatorCount?: number}; + }): ValidatorRow { + let status: string; + let bannedUntilEpoch: string | undefined; + const belowMin = info.vStakeRaw < validatorMinStakeRaw; + const totalStakeRaw = info.vStakeRaw + info.dStakeRaw; + + if (isBanned) { + if (bannedInfo?.permanentlyBanned) { + status = "banned"; + bannedUntilEpoch = "permanent"; + } else { + const epoch = bannedInfo?.untilEpoch ?? info.bannedEpoch; + status = epoch !== undefined ? `banned(e${epoch})` : "banned"; + bannedUntilEpoch = epoch !== undefined ? epoch.toString() : undefined; + } + } else if (isQuarantined) { + const untilEpoch = quarantinedInfo?.untilEpoch; + status = untilEpoch !== undefined ? `quarantined(e${untilEpoch})` : "quarantined"; + } else if (belowMin && currentEpoch < ACTIVATION_DELAY_EPOCHS) { + status = "pending-activation"; + } else if (belowMin && currentEpoch >= ACTIVATION_DELAY_EPOCHS) { + status = "inactive/below-min"; + } else if (isActive) { + status = "active"; + } else { + status = info.live ? "pending" : "inactive"; + } + + const trulyPendingDeposits = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch); + const trulyPendingWithdrawals = info.pendingWithdrawals.filter(w => w.epoch + UNBONDING_PERIOD_EPOCHS > currentEpoch); + + const isMine = myAddress + ? info.owner.toLowerCase() === myAddress.toLowerCase() || + info.operator.toLowerCase() === myAddress.toLowerCase() + : false; + + return { + address: info.address, + owner: info.owner, + operator: info.operator, + moniker: info.identity?.moniker || undefined, + active: isActive, + live: info.live, + banned: isBanned, + bannedUntilEpoch, + status, + belowMin, + selfStake: info.vStake, + selfStakeRaw: info.vStakeRaw, + delegatedStake: info.dStake, + delegatedStakeRaw: info.dStakeRaw, + totalStake: this.formatAmount(totalStakeRaw), + totalStakeRaw, + pendingDepositRaw: trulyPendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n), + pendingWithdrawalRaw: trulyPendingWithdrawals.reduce((sum, w) => sum + w.stakeRaw, 0n), + primedEpoch: info.ePrimed.toString(), + needsPriming: info.needsPriming, + delegatorCount: performance?.delegatorCount ?? null, + epochsActive: null, + isMine, + performance, + }; + } + + private sortRows(rows: ValidatorRow[], sortBy: string): ValidatorRow[] { + const normalized = this.normalizeSortBy(sortBy); + const sorted = [...rows]; + + if (normalized === "uptime") { + sorted.sort((a, b) => { + const au = a.performance?.uptimePct; + const bu = b.performance?.uptimePct; + if (au === undefined || au === null) return bu === undefined || bu === null ? this.compareStakeDescending(a, b) : 1; + if (bu === undefined || bu === null) return -1; + if (bu !== au) return bu - au; + return this.compareStakeDescending(a, b); + }); + return sorted; + } + + sorted.sort((a, b) => this.compareStakeDescending(a, b)); + return sorted; + } + + private compareStakeDescending(a: ValidatorRow, b: ValidatorRow): number { + if (a.totalStakeRaw > b.totalStakeRaw) return -1; + if (a.totalStakeRaw < b.totalStakeRaw) return 1; + return a.address.localeCompare(b.address); + } + + private normalizeSortBy(sortBy: string): "stake" | "uptime" { + return sortBy === "uptime" ? "uptime" : "stake"; + } + + private resolveExplorerNetwork(config: StakingConfig): GenLayerChain { + if (config.network) { + const network = BUILT_IN_NETWORKS[config.network]; + if (!network) { + throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); + } + return network; + } + + return resolveNetwork(this.getConfig().network); + } + + 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/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index abcad7ef..3866529e 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -12,6 +12,7 @@ import {DelegatorJoinAction} from "../../src/commands/staking/delegatorJoin"; import {DelegatorExitAction} from "../../src/commands/staking/delegatorExit"; import {DelegatorClaimAction} from "../../src/commands/staking/delegatorClaim"; import {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; +import {ValidatorsAction} from "../../src/commands/staking/validators"; vi.mock("../../src/commands/staking/validatorJoin"); vi.mock("../../src/commands/staking/validatorDeposit"); @@ -24,6 +25,7 @@ vi.mock("../../src/commands/staking/delegatorJoin"); vi.mock("../../src/commands/staking/delegatorExit"); vi.mock("../../src/commands/staking/delegatorClaim"); vi.mock("../../src/commands/staking/stakingInfo"); +vi.mock("../../src/commands/staking/validators"); describe("staking commands", () => { let program: Command; @@ -211,6 +213,35 @@ describe("staking commands", () => { }); }); + describe("validators", () => { + test("calls ValidatorsAction.execute with discovery options", async () => { + program.parse([ + "node", + "test", + "staking", + "validators", + "--json", + "--sort-by", + "uptime", + "--explorer-url", + "https://explorer.example.com", + "--network", + "testnet-asimov", + "--staking-address", + "0xStaking", + ]); + + expect(ValidatorsAction).toHaveBeenCalledTimes(1); + expect(ValidatorsAction.prototype.execute).toHaveBeenCalledWith({ + json: true, + sortBy: "uptime", + explorerUrl: "https://explorer.example.com", + network: "testnet-asimov", + stakingAddress: "0xStaking", + }); + }); + }); + describe("validator-prime", () => { test("calls ValidatorPrimeAction.execute", async () => { program.parse(["node", "test", "staking", "validator-prime", "--validator", "0xValidator"]); diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts new file mode 100644 index 00000000..6ce6beb5 --- /dev/null +++ b/tests/commands/stakingValidators.test.ts @@ -0,0 +1,197 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"; +import {ValidatorsAction} from "../../src/commands/staking/validators"; + +const A = "0x1111111111111111111111111111111111111111"; +const B = "0x2222222222222222222222222222222222222222"; +const OWNER = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const OPERATOR = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const GEN = 10n ** 18n; + +function rawGen(amount: number): bigint { + return BigInt(amount) * GEN; +} + +function validatorInfo( + address: string, + selfStake: number, + delegatedStake: number, + moniker: string, + options: {live?: boolean} = {}, +) { + return { + address, + owner: OWNER, + operator: OPERATOR, + vStake: `${selfStake} GEN`, + vStakeRaw: rawGen(selfStake), + vShares: 0n, + dStake: `${delegatedStake} GEN`, + dStakeRaw: rawGen(delegatedStake), + dShares: 0n, + vDeposit: "0 GEN", + vDepositRaw: 0n, + vWithdrawal: "0 GEN", + vWithdrawalRaw: 0n, + ePrimed: 5n, + live: options.live ?? true, + banned: false, + needsPriming: false, + identity: {moniker}, + pendingDeposits: [], + pendingWithdrawals: [], + } as any; +} + +function jsonResponse(body: unknown) { + return { + ok: true, + json: vi.fn().mockResolvedValue(body), + } as any; +} + +function createMockClient({ + currentEpoch = 6n, + validatorMinStakeRaw = rawGen(75), + betaLive = true, +}: { + currentEpoch?: bigint; + validatorMinStakeRaw?: bigint; + betaLive?: boolean; +} = {}) { + const infos = new Map([ + [A.toLowerCase(), validatorInfo(A, 100, 20, "Alpha")], + [B.toLowerCase(), validatorInfo(B, 50, 10, "Beta", {live: betaLive})], + ]); + + return { + getActiveValidators: vi.fn().mockResolvedValue([A]), + getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), + getBannedValidators: vi.fn().mockResolvedValue([]), + getEpochInfo: vi.fn().mockResolvedValue({ + currentEpoch, + validatorMinStakeRaw, + validatorMinStake: `${validatorMinStakeRaw / GEN} GEN`, + }), + getValidatorInfo: vi.fn((address: string) => Promise.resolve(infos.get(address.toLowerCase()))), + }; +} + +function setupAction(mockClient = createMockClient()) { + const action = new ValidatorsAction(); + + vi.spyOn(action as any, "startSpinner").mockImplementation(() => undefined); + vi.spyOn(action as any, "setSpinnerText").mockImplementation(() => undefined); + vi.spyOn(action as any, "stopSpinner").mockImplementation(() => undefined); + vi.spyOn(action as any, "failSpinner").mockImplementation((message: unknown, error?: unknown) => { + throw new Error(`${message}: ${String(error)}`); + }); + vi.spyOn(action as any, "getReadOnlyStakingClient").mockResolvedValue(mockClient); + vi.spyOn(action as any, "getAllValidatorsFromTree").mockResolvedValue([A, B]); + vi.spyOn(action as any, "getSignerAddress").mockRejectedValue(new Error("no account")); + vi.spyOn(action as any, "getConfig").mockReturnValue({network: "localnet"}); + vi.spyOn(action as any, "formatAmount").mockImplementation((amount: unknown) => String((amount as bigint) / GEN) + " GEN"); + + return action; +} + +describe("staking validators action", () => { + let logSpy: ReturnType; + const loggedOutput = () => logSpy.mock.calls.map(call => String(call[0])).join("\n"); + + beforeEach(() => { + logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + test("emits chain-only JSON without requiring explorer", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const action = setupAction(); + await action.execute({json: true}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(output.current_epoch).toBe("6"); + expect(output.explorer).toEqual({enabled: false, url: null}); + expect(output.validators).toHaveLength(2); + expect(output.validators[0].address).toBe(A); + expect(output.validators[0].active).toBe(true); + expect(output.validators[0].below_min).toBe(false); + expect(output.validators[0].status).toBe("active"); + expect(output.validators[0].stake.totalRaw).toBe(rawGen(120).toString()); + expect(output.validators[0].delegatorCount).toBeNull(); + expect(output.validators[0].performance).toBeNull(); + expect(output.validators[1].below_min).toBe(true); + expect(output.validators[1].status).toBe("inactive/below-min"); + }); + + test("renders epoch 0 below-min validators as pending activation", async () => { + const action = setupAction(createMockClient({currentEpoch: 0n, betaLive: false})); + await action.execute({}); + + const output = loggedOutput(); + + expect(output).toContain("Current epoch: 0"); + expect(output).toContain("pending-activation"); + expect(output).not.toContain("inactive/below-min"); + }); + + test("renders epoch 2 below-min validators as inactive below-min", async () => { + const action = setupAction(createMockClient({currentEpoch: 2n, betaLive: false})); + await action.execute({}); + + const output = loggedOutput(); + + expect(output).toContain("Current epoch: 2"); + expect(output).toContain("inactive/below-min"); + expect(output).not.toContain("pending-activation"); + }); + + test("merges explorer performance and sorts by uptime", async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.startsWith("https://explorer.example.com/api/v1/validators")) { + return jsonResponse({ + total: 2, + validators: [ + {validator_address: A, idle_pct_7d: 10, rotation_pct_7d: 2, minority_pct_7d: 1, apy: "5.00%", transaction_count: 7}, + {validator_address: B, idle_pct_7d: 1, rotation_pct_7d: 0, minority_pct_7d: 0, apy: "4.00%", transaction_count: 9}, + ], + }); + } + + if (url === `https://explorer.example.com/api/v1/address/${A}`) { + return jsonResponse({validator: {delegators: [{}], total_votes_7d: 11, minority_votes_7d: 1, successful_appeals_7d: 0}}); + } + + if (url === `https://explorer.example.com/api/v1/address/${B}`) { + return jsonResponse({validator: {delegators: [{}, {}], total_votes_7d: 21, minority_votes_7d: 0, successful_appeals_7d: 1}}); + } + + return {ok: false, json: vi.fn()} as any; + }); + vi.stubGlobal("fetch", fetchMock); + + const action = setupAction(); + await action.execute({json: true, explorerUrl: "https://explorer.example.com", sortBy: "uptime"}); + + const output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string); + + expect(output.explorer).toEqual({ + enabled: true, + url: "https://explorer.example.com", + endpoint: "https://explorer.example.com/api/v1/validators", + }); + expect(output.sortBy).toBe("uptime"); + expect(output.validators[0].address).toBe(B); + expect(output.validators[0].delegatorCount).toBe(2); + expect(output.validators[0].performance.uptimePct).toBe(99); + expect(output.validators[0].performance.totalVotes7d).toBe(21); + expect(output.validators[1].address).toBe(A); + }); +}); From 7bcc41e7a063bd4a628f689bdf4d321b50230aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 7 Jul 2026 21:23:25 +0100 Subject: [PATCH 08/45] feat: vesting commands (#358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add vesting commands * feat(vesting): validator subcommands — create/join, deposit, exit, claim, operator-transfer, set-identity, list/status Drives the CON-607 Vesting.sol validator leg through the SDK's named vestingValidator* actions; list/status enumerate getValidatorWallets with per-wallet deposited principal. --- src/commands/vesting/VestingAction.ts | 159 ++++++ src/commands/vesting/claim.ts | 40 ++ src/commands/vesting/delegate.ts | 45 ++ src/commands/vesting/index.ts | 280 ++++++++++ src/commands/vesting/list.ts | 157 ++++++ src/commands/vesting/undelegate.ts | 53 ++ src/commands/vesting/validatorClaim.ts | 40 ++ src/commands/vesting/validatorCreate.ts | 45 ++ src/commands/vesting/validatorDeposit.ts | 44 ++ src/commands/vesting/validatorExit.ts | 53 ++ src/commands/vesting/validatorList.ts | 93 ++++ .../vesting/validatorOperatorTransfer.ts | 116 +++++ src/commands/vesting/validatorSetIdentity.ts | 70 +++ src/commands/vesting/vestingTypes.ts | 156 ++++++ src/commands/vesting/withdraw.ts | 41 ++ src/index.ts | 2 + tests/actions/deploy.test.ts | 16 +- tests/commands/vesting.test.ts | 493 ++++++++++++++++++ tests/index.test.ts | 4 + 19 files changed, 1898 insertions(+), 9 deletions(-) create mode 100644 src/commands/vesting/VestingAction.ts create mode 100644 src/commands/vesting/claim.ts create mode 100644 src/commands/vesting/delegate.ts create mode 100644 src/commands/vesting/index.ts create mode 100644 src/commands/vesting/list.ts create mode 100644 src/commands/vesting/undelegate.ts create mode 100644 src/commands/vesting/validatorClaim.ts create mode 100644 src/commands/vesting/validatorCreate.ts create mode 100644 src/commands/vesting/validatorDeposit.ts create mode 100644 src/commands/vesting/validatorExit.ts create mode 100644 src/commands/vesting/validatorList.ts create mode 100644 src/commands/vesting/validatorOperatorTransfer.ts create mode 100644 src/commands/vesting/validatorSetIdentity.ts create mode 100644 src/commands/vesting/vestingTypes.ts create mode 100644 src/commands/vesting/withdraw.ts create mode 100644 tests/commands/vesting.test.ts diff --git a/src/commands/vesting/VestingAction.ts b/src/commands/vesting/VestingAction.ts new file mode 100644 index 00000000..ba0d1414 --- /dev/null +++ b/src/commands/vesting/VestingAction.ts @@ -0,0 +1,159 @@ +import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; +import {createClient, createAccount, formatStakingAmount, parseStakingAmount} from "genlayer-js"; +import type {Address, GenLayerChain} from "genlayer-js/types"; +import {existsSync, readFileSync} from "fs"; +import {ethers} from "ethers"; +import type {VestingClient, VestingFactoryLookupOptions} from "./vestingTypes"; + +export {BUILT_IN_NETWORKS}; + +export interface VestingConfig { + rpc?: string; + network?: string; + account?: string; + password?: string; + vesting?: string; + factory?: string; + addressManager?: string; +} + +export class VestingAction extends BaseAction { + private _vestingClient: VestingClient | null = null; + private _passwordOverride: string | undefined; + + constructor() { + super(); + } + + private getNetwork(config: VestingConfig): GenLayerChain { + if (config.network) { + const network = BUILT_IN_NETWORKS[config.network]; + if (!network) { + throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); + } + return {...network}; + } + + return resolveNetwork(this.getConfig().network); + } + + protected async getVestingClient(config: VestingConfig): Promise { + if (!this._vestingClient) { + if (config.account) { + this.accountOverride = config.account; + } + if (config.password) { + this._passwordOverride = config.password; + } + + const network = this.getNetwork(config); + const privateKey = await this.getPrivateKeyForVesting(); + const account = createAccount(privateKey as `0x${string}`); + + this._vestingClient = createClient({ + chain: network, + endpoint: config.rpc, + account, + }) as VestingClient; + } + return this._vestingClient; + } + + protected async getReadOnlyVestingClient(config: VestingConfig): Promise { + if (config.account) { + this.accountOverride = config.account; + } + + const network = this.getNetwork(config); + + return createClient({ + chain: network, + endpoint: config.rpc, + }) as VestingClient; + } + + private async getPrivateKeyForVesting(): Promise { + const accountName = this.resolveAccountName(); + const keystorePath = this.getKeystorePath(accountName); + + if (!existsSync(keystorePath)) { + throw new Error(`Account '${accountName}' not found. Run 'genlayer account create --name ${accountName}' first.`); + } + + const keystoreJson = readFileSync(keystorePath, "utf-8"); + const keystoreData = JSON.parse(keystoreJson); + + if (!this.isValidKeystoreFormat(keystoreData)) { + throw new Error("Invalid keystore format."); + } + + const cachedKey = await this.keychainManager.getPrivateKey(accountName); + if (cachedKey) { + const tempAccount = createAccount(cachedKey as `0x${string}`); + const cachedAddress = tempAccount.address.toLowerCase(); + const keystoreAddress = `0x${keystoreData.address.toLowerCase().replace(/^0x/, '')}`; + + if (cachedAddress !== keystoreAddress) { + await this.keychainManager.removePrivateKey(accountName); + } else { + return cachedKey; + } + } + + let password: string; + if (this._passwordOverride) { + password = this._passwordOverride; + } else { + this.stopSpinner(); + password = await this.promptPassword(`Enter password to unlock account '${accountName}':`); + } + this.startSpinner("Unlocking account..."); + + const wallet = await ethers.Wallet.fromEncryptedJson(keystoreJson, password); + return wallet.privateKey; + } + + protected parseAmount(amount: string): bigint { + return parseStakingAmount(amount); + } + + protected formatAmount(amount: bigint): string { + return formatStakingAmount(amount); + } + + protected async getSignerAddress(): Promise
{ + const accountName = this.resolveAccountName(); + const keystorePath = this.getKeystorePath(accountName); + if (!existsSync(keystorePath)) { + throw new Error(`Account '${accountName}' not found.`); + } + const keystoreData = JSON.parse(readFileSync(keystorePath, "utf-8")); + const addr = keystoreData.address as string; + return (addr.startsWith("0x") ? addr : `0x${addr}`) as Address; + } + + protected getFactoryLookupOptions(options: VestingConfig): VestingFactoryLookupOptions | undefined { + const lookup: VestingFactoryLookupOptions = {}; + if (options.factory) lookup.factory = options.factory as Address; + if (options.addressManager) lookup.addressManager = options.addressManager as Address; + return Object.keys(lookup).length > 0 ? lookup : undefined; + } + + protected async resolveBeneficiaryVesting(client: VestingClient, options: VestingConfig): Promise
{ + if (options.vesting) { + return options.vesting as Address; + } + + const beneficiary = await this.getSignerAddress(); + const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); + + if (vestings.length === 0) { + throw new Error(`No vesting contract found for beneficiary ${beneficiary}.`); + } + if (vestings.length > 1) { + throw new Error(`Multiple vesting contracts found for beneficiary ${beneficiary}. Use --vesting
.`); + } + + return vestings[0]; + } +} diff --git a/src/commands/vesting/claim.ts b/src/commands/vesting/claim.ts new file mode 100644 index 00000000..13481e81 --- /dev/null +++ b/src/commands/vesting/claim.ts @@ -0,0 +1,40 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingClaimOptions extends VestingConfig { + validator: string; +} + +export class VestingClaimAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingClaimOptions): Promise { + this.startSpinner("Claiming vesting delegation withdrawal..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Claiming vesting delegation withdrawal from validator ${options.validator}...`); + + const result = await client.vestingDelegatorClaim({ + vesting, + validator: options.validator as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + validator: options.validator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting claim successful!", output); + } catch (error: any) { + this.failSpinner("Failed to claim vesting withdrawal", error.message || error); + } + } +} diff --git a/src/commands/vesting/delegate.ts b/src/commands/vesting/delegate.ts new file mode 100644 index 00000000..e6c24720 --- /dev/null +++ b/src/commands/vesting/delegate.ts @@ -0,0 +1,45 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingDelegateOptions extends VestingConfig { + validator: string; + amount: string; +} + +export class VestingDelegateAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingDelegateOptions): Promise { + this.startSpinner("Delegating vesting tokens..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Delegating ${this.formatAmount(amount)} from vesting ${vesting} to validator ${options.validator}...`); + + const result = await client.vestingDelegatorJoin({ + vesting, + validator: options.validator as Address, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting: result.vesting, + validator: result.validator, + beneficiary: result.beneficiary, + amount: result.amount, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting delegation successful!", output); + } catch (error: any) { + this.failSpinner("Failed to delegate vesting tokens", error.message || error); + } + } +} diff --git a/src/commands/vesting/index.ts b/src/commands/vesting/index.ts new file mode 100644 index 00000000..25aba95c --- /dev/null +++ b/src/commands/vesting/index.ts @@ -0,0 +1,280 @@ +import {Command} from "commander"; +import {VestingListAction, VestingListOptions} from "./list"; +import {VestingDelegateAction, VestingDelegateOptions} from "./delegate"; +import {VestingUndelegateAction, VestingUndelegateOptions} from "./undelegate"; +import {VestingClaimAction, VestingClaimOptions} from "./claim"; +import {VestingWithdrawAction, VestingWithdrawOptions} from "./withdraw"; +import {VestingValidatorCreateAction, VestingValidatorCreateOptions} from "./validatorCreate"; +import {VestingValidatorDepositAction, VestingValidatorDepositOptions} from "./validatorDeposit"; +import {VestingValidatorExitAction, VestingValidatorExitOptions} from "./validatorExit"; +import {VestingValidatorClaimAction, VestingValidatorClaimOptions} from "./validatorClaim"; +import { + VestingValidatorCancelOperatorTransferAction, + VestingValidatorCompleteOperatorTransferAction, + VestingValidatorInitiateOperatorTransferAction, + VestingValidatorOperatorTransferOptions, +} from "./validatorOperatorTransfer"; +import {VestingValidatorSetIdentityAction, VestingValidatorSetIdentityOptions} from "./validatorSetIdentity"; +import {VestingValidatorListAction, VestingValidatorListOptions} from "./validatorList"; + +function addReadOptions(command: Command): Command { + return command + .option("--account ", "Account to use (for default beneficiary address)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--factory
", "VestingFactory address (overrides AddressManager lookup)") + .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); +} + +function addWriteOptions(command: Command): Command { + return command + .option("--vesting
", "Vesting contract address (overrides beneficiary lookup)") + .option("--account ", "Account to use") + .option("--password ", "Password to unlock account (skips interactive prompt)") + .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--rpc ", "RPC URL for the network") + .option("--factory
", "VestingFactory address (overrides AddressManager lookup)") + .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); +} + +function addValidatorReadOptions(command: Command): Command { + return addReadOptions( + command + .option("--vesting
", "Vesting contract address (overrides beneficiary lookup)") + .option("--beneficiary
", "Beneficiary address (defaults to signer)"), + ); +} + +function addWalletOption(command: Command): Command { + return command.option("--wallet
", "Validator wallet address (deprecated, use positional arg)"); +} + +function requireWallet(walletArg: string | undefined, options: {wallet?: string}): string { + const wallet = walletArg || options.wallet; + if (!wallet) { + console.error("Error: validator wallet address is required"); + process.exit(1); + } + return wallet; +} + +function requireOperator(operatorArg: string | undefined, options: {operator?: string}): string { + const operator = operatorArg || options.operator; + if (!operator) { + console.error("Error: operator address is required"); + process.exit(1); + } + return operator; +} + +export function initializeVestingCommands(program: Command) { + const vesting = program.command("vesting").description("Vesting operations for beneficiaries"); + + addReadOptions( + vesting + .command("list") + .description("List beneficiary vesting contracts and state") + .option("--beneficiary
", "Beneficiary address (defaults to signer)"), + ).action(async (options: VestingListOptions) => { + const action = new VestingListAction(); + await action.execute(options); + }); + + addWriteOptions( + vesting + .command("delegate [validator]") + .description("Delegate vesting-held tokens to a validator") + .option("--validator
", "Validator address to delegate to (deprecated, use positional arg)") + .requiredOption("--amount ", "Amount to delegate (in wei or with 'eth'/'gen' suffix)"), + ).action(async (validatorArg: string | undefined, options: VestingDelegateOptions) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new VestingDelegateAction(); + await action.execute({...options, validator}); + }); + + addWriteOptions( + vesting + .command("undelegate [validator]") + .description("Undelegate all vesting delegation shares from a validator") + .option("--validator
", "Validator address to undelegate from (deprecated, use positional arg)"), + ).action(async (validatorArg: string | undefined, options: VestingUndelegateOptions) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new VestingUndelegateAction(); + await action.execute({...options, validator}); + }); + + addWriteOptions( + vesting + .command("claim [validator]") + .description("Claim vesting delegation withdrawals after unbonding period") + .option("--validator
", "Validator address to claim from (deprecated, use positional arg)"), + ).action(async (validatorArg: string | undefined, options: VestingClaimOptions) => { + const validator = validatorArg || options.validator; + if (!validator) { + console.error("Error: validator address is required"); + process.exit(1); + } + const action = new VestingClaimAction(); + await action.execute({...options, validator}); + }); + + addWriteOptions( + vesting + .command("withdraw") + .description("Withdraw vested tokens to the beneficiary") + .requiredOption("--amount ", "Amount to withdraw (in wei or with 'eth'/'gen' suffix)"), + ).action(async (options: VestingWithdrawOptions) => { + const action = new VestingWithdrawAction(); + await action.execute(options); + }); + + const validator = vesting.command("validator").description("Vesting-backed validator operations"); + + const addCreateCommand = (name: string) => { + addWriteOptions( + validator + .command(`${name} [operator]`) + .description("Create a vesting-backed validator") + .option("--operator
", "Operator address (deprecated, use positional arg)") + .requiredOption("--amount ", "Amount to self-stake (in wei or with 'eth'/'gen' suffix)"), + ).action(async (operatorArg: string | undefined, options: VestingValidatorCreateOptions) => { + const operator = requireOperator(operatorArg, options); + const action = new VestingValidatorCreateAction(); + await action.execute({...options, operator}); + }); + }; + + addCreateCommand("create"); + addCreateCommand("join"); + + addWriteOptions( + addWalletOption( + validator + .command("deposit [wallet]") + .description("Deposit more vesting-held tokens to a validator wallet") + .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorDepositOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorDepositAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + validator + .command("exit [wallet]") + .description("Exit vesting validator self-stake by withdrawing shares") + .requiredOption("--shares ", "Number of shares to withdraw"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorExitOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorExitAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + validator + .command("claim [wallet]") + .description("Claim vesting validator withdrawals after unbonding period"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorClaimOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorClaimAction(); + await action.execute({...options, wallet}); + }); + + const operatorTransfer = validator.command("operator-transfer").description("Manage vesting validator operator transfers"); + + addWriteOptions( + addWalletOption( + operatorTransfer + .command("initiate [wallet] [newOperator]") + .description("Initiate a vesting validator operator transfer") + .option("--new-operator
", "New operator address (deprecated, use positional arg)"), + ), + ).action(async (walletArg: string | undefined, newOperatorArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { + const wallet = requireWallet(walletArg, options); + const newOperator = newOperatorArg || options.newOperator; + if (!newOperator) { + console.error("Error: new operator address is required"); + process.exit(1); + } + const action = new VestingValidatorInitiateOperatorTransferAction(); + await action.execute({...options, wallet, newOperator}); + }); + + addWriteOptions( + addWalletOption( + operatorTransfer + .command("complete [wallet]") + .description("Complete a vesting validator operator transfer"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorCompleteOperatorTransferAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + operatorTransfer + .command("cancel [wallet]") + .description("Cancel a vesting validator operator transfer"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorCancelOperatorTransferAction(); + await action.execute({...options, wallet}); + }); + + addWriteOptions( + addWalletOption( + validator + .command("set-identity [wallet]") + .description("Set vesting validator identity metadata") + .option("--moniker ", "Validator display name") + .option("--logo-uri ", "Logo URI") + .option("--website ", "Website URL") + .option("--description ", "Description") + .option("--email ", "Contact email") + .option("--twitter ", "Twitter handle") + .option("--telegram ", "Telegram handle") + .option("--github ", "GitHub handle") + .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)"), + ), + ).action(async (walletArg: string | undefined, options: VestingValidatorSetIdentityOptions) => { + const wallet = requireWallet(walletArg, options); + const action = new VestingValidatorSetIdentityAction(); + await action.execute({...options, wallet}); + }); + + addValidatorReadOptions( + validator + .command("list") + .description("List validator wallets owned by a vesting contract"), + ).action(async (options: VestingValidatorListOptions) => { + const action = new VestingValidatorListAction(); + await action.execute(options); + }); + + addValidatorReadOptions( + validator + .command("status") + .description("Show validator wallets owned by a vesting contract"), + ).action(async (options: VestingValidatorListOptions) => { + const action = new VestingValidatorListAction(); + await action.execute(options); + }); + + return program; +} diff --git a/src/commands/vesting/list.ts b/src/commands/vesting/list.ts new file mode 100644 index 00000000..ad1a69c1 --- /dev/null +++ b/src/commands/vesting/list.ts @@ -0,0 +1,157 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import type {VestingState} from "./vestingTypes"; +import Table from "cli-table3"; +import chalk from "chalk"; + +export interface VestingListOptions extends VestingConfig { + beneficiary?: string; +} + +const CATEGORY_LABELS: Record = { + 0: "Unspecified", + 1: "Team", + 2: "Advisor", + 3: "Investor", + 4: "Foundation", + 5: "Ecosystem", + 6: "Other", +}; + +function formatTimestamp(value: bigint): string { + if (value === 0n) return "not set"; + return new Date(Number(value) * 1000).toISOString(); +} + +function formatDuration(seconds: bigint): string { + if (seconds === 0n) return "0s"; + + let remaining = Number(seconds); + const days = Math.floor(remaining / 86400); + remaining %= 86400; + const hours = Math.floor(remaining / 3600); + remaining %= 3600; + const minutes = Math.floor(remaining / 60); + const secs = remaining % 60; + + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (hours) parts.push(`${hours}h`); + if (minutes) parts.push(`${minutes}m`); + if (secs || parts.length === 0) parts.push(`${secs}s`); + return parts.join(" "); +} + +function formatBps(value: bigint): string { + return `${(Number(value) / 100).toFixed(2).replace(/\.00$/, "")}%`; +} + +function formatManualUnlock(state: VestingState): string { + if (!state.needsManualUnlock) return "manual: no"; + return `manual: ${state.manualUnlocked ? "unlocked" : "required"}`; +} + +function formatSchedule(state: VestingState): string { + return [ + `start: ${formatTimestamp(state.startDate)}`, + `cliff: ${formatDuration(state.cliffDuration)}`, + `period: ${formatDuration(state.periodDuration)} x ${state.numberOfPeriods.toString()}`, + `cliff unlock: ${formatBps(state.cliffUnlockBps)}`, + formatManualUnlock(state), + ].join("\n"); +} + +function formatRevocation(state: VestingState): string { + if (state.revoked) { + return [ + `revoked: yes`, + `at: ${formatTimestamp(state.revokedAt)}`, + `vested: ${state.vestedAtRevocation}`, + `total: ${state.totalAmountAtRevocation}`, + ].join("\n"); + } + + if (state.vestingStopped) { + return [ + `revoked: no`, + `stopped: yes`, + `at: ${formatTimestamp(state.vestingStoppedAt)}`, + `vested: ${state.vestedAtStop}`, + ].join("\n"); + } + + return ["revoked: no", "stopped: no"].join("\n"); +} + +export class VestingListAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingListOptions): Promise { + this.startSpinner("Fetching vesting contracts..."); + + try { + const client = await this.getReadOnlyVestingClient(options); + const beneficiary = (options.beneficiary as Address | undefined) || (await this.getSignerAddress()); + + this.setSpinnerText(`Fetching vesting contracts for ${beneficiary}...`); + + const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); + + if (vestings.length === 0) { + this.succeedSpinner("No vesting contracts found", {beneficiary, count: 0}); + return; + } + + this.setSpinnerText(`Fetching state for ${vestings.length} vesting contract${vestings.length === 1 ? "" : "s"}...`); + const states = await Promise.all( + vestings.map(async (vesting) => ({ + vesting, + state: await client.getVestingState(vesting), + })), + ); + + this.stopSpinner(); + + const table = new Table({ + head: [ + chalk.cyan("Contract"), + chalk.cyan("Name"), + chalk.cyan("Category"), + chalk.cyan("Total"), + chalk.cyan("Vested"), + chalk.cyan("Locked"), + chalk.cyan("Withdrawable"), + chalk.cyan("Schedule"), + chalk.cyan("Revocation"), + ], + style: {head: [], border: []}, + wordWrap: true, + }); + + states.forEach(({vesting, state}) => { + table.push([ + vesting, + state.name || "-", + CATEGORY_LABELS[state.category] || state.category.toString(), + state.totalAmount, + state.vestedAmount, + state.unvestedAmount, + state.withdrawableAmount, + formatSchedule(state), + formatRevocation(state), + ]); + }); + + console.log(""); + console.log(table.toString()); + console.log(""); + console.log(chalk.gray(`Beneficiary: ${beneficiary}`)); + console.log(chalk.gray(`Total: ${states.length} vesting contract${states.length === 1 ? "" : "s"}`)); + console.log(""); + } catch (error: any) { + this.failSpinner("Failed to list vesting contracts", error.message || error); + } + } +} diff --git a/src/commands/vesting/undelegate.ts b/src/commands/vesting/undelegate.ts new file mode 100644 index 00000000..bc232142 --- /dev/null +++ b/src/commands/vesting/undelegate.ts @@ -0,0 +1,53 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingUndelegateOptions extends VestingConfig { + validator: string; +} + +export class VestingUndelegateAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingUndelegateOptions): Promise { + this.startSpinner("Initiating vesting undelegation..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Fetching vesting delegation shares for validator ${options.validator}...`); + const stakeInfo = await client.getStakeInfo(vesting, options.validator as Address); + const shares = stakeInfo.shares; + + if (shares <= 0n) { + this.failSpinner(`No delegation shares found for vesting ${vesting} with validator ${options.validator}.`); + return; + } + + this.setSpinnerText(`Undelegating ${shares.toString()} shares from validator ${options.validator}...`); + + const result = await client.vestingDelegatorExit({ + vesting, + validator: options.validator as Address, + shares, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + validator: options.validator, + sharesWithdrawn: shares.toString(), + stake: stakeInfo.stake, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period", + }; + + this.succeedSpinner("Vesting undelegation initiated!", output); + } catch (error: any) { + this.failSpinner("Failed to undelegate vesting tokens", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorClaim.ts b/src/commands/vesting/validatorClaim.ts new file mode 100644 index 00000000..c6d33490 --- /dev/null +++ b/src/commands/vesting/validatorClaim.ts @@ -0,0 +1,40 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorClaimOptions extends VestingConfig { + wallet: string; +} + +export class VestingValidatorClaimAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorClaimOptions): Promise { + this.startSpinner("Claiming vesting validator withdrawal..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Claiming vesting validator withdrawal from wallet ${options.wallet}...`); + + const result = await client.vestingValidatorClaim({ + vesting, + wallet: options.wallet as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator claim successful!", output); + } catch (error: any) { + this.failSpinner("Failed to claim vesting validator withdrawal", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts new file mode 100644 index 00000000..94d481bf --- /dev/null +++ b/src/commands/vesting/validatorCreate.ts @@ -0,0 +1,45 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorCreateOptions extends VestingConfig { + operator: string; + amount: string; +} + +export class VestingValidatorCreateAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorCreateOptions): Promise { + this.startSpinner("Creating vesting-backed validator..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Creating validator with ${this.formatAmount(amount)} from vesting ${vesting}...`); + + const result = await client.vestingValidatorJoin({ + vesting, + operator: options.operator as Address, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + validatorWallet: result.validatorWallet || result.wallet, + operator: result.operator || options.operator, + amount: result.amount || this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting-backed validator created!", output); + } catch (error: any) { + this.failSpinner("Failed to create vesting-backed validator", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorDeposit.ts b/src/commands/vesting/validatorDeposit.ts new file mode 100644 index 00000000..f6a0b802 --- /dev/null +++ b/src/commands/vesting/validatorDeposit.ts @@ -0,0 +1,44 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorDepositOptions extends VestingConfig { + wallet: string; + amount: string; +} + +export class VestingValidatorDepositAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorDepositOptions): Promise { + this.startSpinner("Depositing vesting tokens to validator..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.wallet}...`); + + const result = await client.vestingValidatorDeposit({ + vesting, + wallet: options.wallet as Address, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + amount: this.formatAmount(amount), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator deposit successful!", output); + } catch (error: any) { + this.failSpinner("Failed to deposit vesting validator tokens", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorExit.ts b/src/commands/vesting/validatorExit.ts new file mode 100644 index 00000000..18e53363 --- /dev/null +++ b/src/commands/vesting/validatorExit.ts @@ -0,0 +1,53 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorExitOptions extends VestingConfig { + wallet: string; + shares: string; +} + +export class VestingValidatorExitAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorExitOptions): Promise { + this.startSpinner("Initiating vesting validator exit..."); + + try { + let shares: bigint; + try { + shares = BigInt(options.shares); + if (shares <= 0n) throw new Error("must be positive"); + } catch { + this.failSpinner(`Invalid shares value: "${options.shares}". Must be a positive whole number.`); + return; + } + + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Exiting ${shares.toString()} validator shares from wallet ${options.wallet}...`); + + const result = await client.vestingValidatorExit({ + vesting, + wallet: options.wallet as Address, + shares, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + sharesWithdrawn: shares.toString(), + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + note: "Withdrawal will be claimable after the unbonding period unless settled immediately in epoch 0", + }; + + this.succeedSpinner("Vesting validator exit initiated!", output); + } catch (error: any) { + this.failSpinner("Failed to exit vesting validator", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorList.ts b/src/commands/vesting/validatorList.ts new file mode 100644 index 00000000..ca94d206 --- /dev/null +++ b/src/commands/vesting/validatorList.ts @@ -0,0 +1,93 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import Table from "cli-table3"; +import chalk from "chalk"; + +export interface VestingValidatorListOptions extends VestingConfig { + beneficiary?: string; +} + +interface WalletState { + wallet: Address; + deposited: bigint | string; +} + +export class VestingValidatorListAction extends VestingAction { + constructor() { + super(); + } + + private formatDeposited(value: bigint | string): string { + return typeof value === "bigint" ? this.formatAmount(value) : value; + } + + async execute(options: VestingValidatorListOptions): Promise { + this.startSpinner("Fetching vesting validator wallets..."); + + try { + const client = await this.getReadOnlyVestingClient(options); + let vesting: Address; + + if (options.vesting) { + vesting = options.vesting as Address; + } else { + const beneficiary = (options.beneficiary as Address | undefined) || (await this.getSignerAddress()); + this.setSpinnerText(`Resolving vesting contract for ${beneficiary}...`); + const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); + + if (vestings.length === 0) { + this.succeedSpinner("No vesting contracts found", {beneficiary, count: 0}); + return; + } + if (vestings.length > 1) { + throw new Error(`Multiple vesting contracts found for beneficiary ${beneficiary}. Use --vesting
.`); + } + vesting = vestings[0]; + } + + this.setSpinnerText(`Fetching validator wallets for vesting ${vesting}...`); + const wallets = await client.getValidatorWallets(vesting); + + if (wallets.length === 0) { + this.succeedSpinner("No vesting validator wallets found", {vesting, count: 0}); + return; + } + + const states: WalletState[] = await Promise.all( + wallets.map(async (wallet) => ({ + wallet, + deposited: await client.validatorDeposited(vesting, wallet), + })), + ); + + this.stopSpinner(); + + const table = new Table({ + head: [ + chalk.cyan("Vesting"), + chalk.cyan("Validator Wallet"), + chalk.cyan("Deposited"), + ], + style: {head: [], border: []}, + wordWrap: true, + }); + + states.forEach(({wallet, deposited}) => { + table.push([ + vesting, + wallet, + this.formatDeposited(deposited), + ]); + }); + + console.log(""); + console.log(table.toString()); + console.log(""); + console.log(chalk.gray(`Vesting: ${vesting}`)); + console.log(chalk.gray(`Total: ${states.length} validator wallet${states.length === 1 ? "" : "s"}`)); + console.log(""); + } catch (error: any) { + this.failSpinner("Failed to list vesting validator wallets", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorOperatorTransfer.ts b/src/commands/vesting/validatorOperatorTransfer.ts new file mode 100644 index 00000000..51b026e4 --- /dev/null +++ b/src/commands/vesting/validatorOperatorTransfer.ts @@ -0,0 +1,116 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; + +export interface VestingValidatorOperatorTransferOptions extends VestingConfig { + wallet: string; + newOperator?: string; +} + +export class VestingValidatorInitiateOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + this.startSpinner("Initiating vesting validator operator transfer..."); + + try { + if (!options.newOperator) { + this.failSpinner("New operator address is required."); + return; + } + + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Initiating operator transfer for wallet ${options.wallet} to ${options.newOperator}...`); + + const result = await client.vestingValidatorInitiateOperatorTransfer({ + vesting, + wallet: options.wallet as Address, + newOperator: options.newOperator as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + newOperator: options.newOperator, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator operator transfer initiated!", output); + } catch (error: any) { + this.failSpinner("Failed to initiate vesting validator operator transfer", error.message || error); + } + } +} + +export class VestingValidatorCompleteOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + this.startSpinner("Completing vesting validator operator transfer..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Completing operator transfer for wallet ${options.wallet}...`); + + const result = await client.vestingValidatorCompleteOperatorTransfer({ + vesting, + wallet: options.wallet as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator operator transfer completed!", output); + } catch (error: any) { + this.failSpinner("Failed to complete vesting validator operator transfer", error.message || error); + } + } +} + +export class VestingValidatorCancelOperatorTransferAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorOperatorTransferOptions): Promise { + this.startSpinner("Cancelling vesting validator operator transfer..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Cancelling operator transfer for wallet ${options.wallet}...`); + + const result = await client.vestingValidatorCancelOperatorTransfer({ + vesting, + wallet: options.wallet as Address, + }); + + const output = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting validator operator transfer cancelled!", output); + } catch (error: any) { + this.failSpinner("Failed to cancel vesting validator operator transfer", error.message || error); + } + } +} diff --git a/src/commands/vesting/validatorSetIdentity.ts b/src/commands/vesting/validatorSetIdentity.ts new file mode 100644 index 00000000..368e7e5b --- /dev/null +++ b/src/commands/vesting/validatorSetIdentity.ts @@ -0,0 +1,70 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; +import type {Address} from "genlayer-js/types"; +import {toHex} from "viem"; + +export interface VestingValidatorSetIdentityOptions extends VestingConfig { + wallet: string; + moniker?: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; + extraCid?: string; +} + +export class VestingValidatorSetIdentityAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingValidatorSetIdentityOptions): Promise { + this.startSpinner("Setting vesting validator identity..."); + + try { + const client = await this.getVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(client, options); + const extraCid = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x"; + + this.setSpinnerText(`Setting identity for vesting validator wallet ${options.wallet}...`); + + const result = await client.vestingValidatorSetIdentity({ + vesting, + wallet: options.wallet as Address, + moniker: options.moniker || "", + logoUri: options.logoUri || "", + website: options.website || "", + description: options.description || "", + email: options.email || "", + twitter: options.twitter || "", + telegram: options.telegram || "", + github: options.github || "", + extraCid, + }); + + const output: Record = { + transactionHash: result.transactionHash, + vesting, + wallet: options.wallet, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + if (options.moniker) output.moniker = options.moniker; + if (options.logoUri) output.logoUri = options.logoUri; + if (options.website) output.website = options.website; + if (options.description) output.description = options.description; + if (options.email) output.email = options.email; + if (options.twitter) output.twitter = options.twitter; + if (options.telegram) output.telegram = options.telegram; + if (options.github) output.github = options.github; + if (options.extraCid) output.extraCid = options.extraCid; + + this.succeedSpinner("Vesting validator identity set!", output); + } catch (error: any) { + this.failSpinner("Failed to set vesting validator identity", error.message || error); + } + } +} diff --git a/src/commands/vesting/vestingTypes.ts b/src/commands/vesting/vestingTypes.ts new file mode 100644 index 00000000..aeaf76c8 --- /dev/null +++ b/src/commands/vesting/vestingTypes.ts @@ -0,0 +1,156 @@ +import type {Address, GenLayerChain, GenLayerClient} from "genlayer-js/types"; + +// LOCKSTEP(genlayer-js#feat/vesting-actions): local CLI-facing type shim until +// genlayer-js#v2-dev publishes VestingActions and VestingState. +export interface VestingTransactionResult { + transactionHash: `0x${string}`; + blockNumber: bigint; + gasUsed: bigint; +} + +export interface VestingDelegatorJoinResult extends VestingTransactionResult { + vesting: Address; + validator: Address; + beneficiary: Address; + amount: string; + amountRaw: bigint; +} + +export interface VestingWithdrawResult extends VestingTransactionResult { + vesting: Address; + beneficiary: Address; + amount: string; + amountRaw: bigint; +} + +export interface VestingValidatorJoinResult extends VestingTransactionResult { + vesting?: Address; + validatorWallet?: Address; + wallet?: Address; + operator?: Address; + beneficiary?: Address; + amount?: string; + amountRaw?: bigint; +} + +export interface VestingFactoryLookupOptions { + factory?: Address; + addressManager?: Address; +} + +export interface VestingState { + name: string; + category: number; + beneficiary: Address; + creator: Address; + revoker: Address; + factory: Address; + addressManager: Address; + totalAmount: string; + totalAmountRaw: bigint; + startDate: bigint; + cliffDuration: bigint; + periodDuration: bigint; + numberOfPeriods: bigint; + cliffUnlockBps: bigint; + needsManualUnlock: boolean; + manualUnlocked: boolean; + revoked: boolean; + vestingStopped: boolean; + totalWithdrawn: string; + totalWithdrawnRaw: bigint; + vestedAtRevocation: string; + vestedAtRevocationRaw: bigint; + totalAmountAtRevocation: string; + totalAmountAtRevocationRaw: bigint; + revokedAt: bigint; + vestingStoppedAt: bigint; + vestedAtStop: string; + vestedAtStopRaw: bigint; + postRevocationBeneficiaryRewards: string; + postRevocationBeneficiaryRewardsRaw: bigint; + postRevocationBeneficiaryLosses: string; + postRevocationBeneficiaryLossesRaw: bigint; + accumulatedRewards: string; + accumulatedRewardsRaw: bigint; + accumulatedLosses: string; + accumulatedLossesRaw: bigint; + vestedAmount: string; + vestedAmountRaw: bigint; + unvestedAmount: string; + unvestedAmountRaw: bigint; + withdrawableAmount: string; + withdrawableAmountRaw: bigint; +} + +export type VestingClient = GenLayerClient & { + getBeneficiaryVestings: (beneficiary: Address, options?: VestingFactoryLookupOptions) => Promise; + getVestingState: (vesting: Address) => Promise; + vestingDelegatorJoin: (options: { + vesting: Address; + validator: Address; + amount: bigint | string; + }) => Promise; + vestingDelegatorExit: (options: { + vesting: Address; + validator: Address; + shares: bigint | string; + }) => Promise; + vestingDelegatorClaim: (options: { + vesting: Address; + validator: Address; + }) => Promise; + vestingWithdraw: (options: { + vesting: Address; + amount: bigint | string; + }) => Promise; + vestingValidatorJoin: (options: { + vesting: Address; + operator: Address; + amount: bigint | string; + }) => Promise; + vestingValidatorDeposit: (options: { + vesting: Address; + wallet: Address; + amount: bigint | string; + }) => Promise; + vestingValidatorExit: (options: { + vesting: Address; + wallet: Address; + shares: bigint | string; + }) => Promise; + vestingValidatorClaim: (options: { + vesting: Address; + wallet: Address; + }) => Promise; + vestingValidatorInitiateOperatorTransfer: (options: { + vesting: Address; + wallet: Address; + newOperator: Address; + }) => Promise; + vestingValidatorCompleteOperatorTransfer: (options: { + vesting: Address; + wallet: Address; + }) => Promise; + vestingValidatorCancelOperatorTransfer: (options: { + vesting: Address; + wallet: Address; + }) => Promise; + vestingValidatorSetIdentity: (options: { + vesting: Address; + wallet: Address; + moniker: string; + logoUri: string; + website: string; + description: string; + email: string; + twitter: string; + telegram: string; + github: string; + extraCid: `0x${string}`; + }) => Promise; + getValidatorWallets: (vesting: Address) => Promise; + validatorWalletCount: (vesting: Address) => Promise; + validatorDeposited: (vesting: Address, wallet: Address) => Promise; + isValidatorWallet: (vesting: Address, wallet: Address) => Promise; +}; diff --git a/src/commands/vesting/withdraw.ts b/src/commands/vesting/withdraw.ts new file mode 100644 index 00000000..024d58e3 --- /dev/null +++ b/src/commands/vesting/withdraw.ts @@ -0,0 +1,41 @@ +import {VestingAction, VestingConfig} from "./VestingAction"; + +export interface VestingWithdrawOptions extends VestingConfig { + amount: string; +} + +export class VestingWithdrawAction extends VestingAction { + constructor() { + super(); + } + + async execute(options: VestingWithdrawOptions): Promise { + this.startSpinner("Withdrawing vested tokens..."); + + try { + const client = await this.getVestingClient(options); + const amount = this.parseAmount(options.amount); + const vesting = await this.resolveBeneficiaryVesting(client, options); + + this.setSpinnerText(`Withdrawing ${this.formatAmount(amount)} from vesting ${vesting}...`); + + const result = await client.vestingWithdraw({ + vesting, + amount, + }); + + const output = { + transactionHash: result.transactionHash, + vesting: result.vesting, + beneficiary: result.beneficiary, + amount: result.amount, + blockNumber: result.blockNumber.toString(), + gasUsed: result.gasUsed.toString(), + }; + + this.succeedSpinner("Vesting withdrawal successful!", output); + } catch (error: any) { + this.failSpinner("Failed to withdraw vested tokens", error.message || error); + } + } +} diff --git a/src/index.ts b/src/index.ts index c78a5cd0..c8bc1850 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import {initializeScaffoldCommands} from "../src/commands/scaffold"; import {initializeNetworkCommands} from "../src/commands/network"; import {initializeTransactionsCommands} from "../src/commands/transactions"; import {initializeStakingCommands} from "../src/commands/staking"; +import {initializeVestingCommands} from "../src/commands/vesting"; export function initializeCLI() { program.version(version).description(CLI_DESCRIPTION); @@ -25,6 +26,7 @@ export function initializeCLI() { initializeNetworkCommands(program); initializeTransactionsCommands(program); initializeStakingCommands(program); + initializeVestingCommands(program); program.parse(process.argv); } diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index 5df2a065..b6453055 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -1,7 +1,7 @@ import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; import fs from "fs"; import os from "os"; -import {createClient, createAccount, isSuccessful, formatStakingAmount} from "genlayer-js"; +import {createClient, createAccount, isSuccessful, formatStakingAmount, DEPLOY_CALL_KEY} from "genlayer-js"; import {DeployAction, DeployOptions} from "../../src/commands/contracts/deploy"; import {buildSync} from "esbuild"; import {pathToFileURL} from "url"; @@ -158,14 +158,12 @@ describe("DeployAction", () => { leaderTimeunitsAllocation: "10", rotations: ["0"], }, - messageAllocations: [ - { - messageType: 1, - recipient: "0x0000000000000000000000000000000000000001", - callKey: "0x0000000000000000000000000000000000000000000000000000000000000001", - budget: "5", - }, - ], + messageAllocations: [{ + messageType: 1, + recipient: "0x0000000000000000000000000000000000000001", + callKey: DEPLOY_CALL_KEY, + budget: "5", + }], feeValue: "123", }, validUntil: "999", diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts new file mode 100644 index 00000000..88f0ac38 --- /dev/null +++ b/tests/commands/vesting.test.ts @@ -0,0 +1,493 @@ +import {Command} from "commander"; +import {vi, describe, beforeEach, afterEach, test, expect} from "vitest"; +import {initializeVestingCommands} from "../../src/commands/vesting"; +import {VestingAction} from "../../src/commands/vesting/VestingAction"; + +vi.mock("genlayer-js", () => ({ + createClient: vi.fn(), + createAccount: vi.fn(() => ({address: "0xBeneficiary"})), + formatStakingAmount: vi.fn((value: bigint) => `${Number(value) / 1e18} GEN`), + parseStakingAmount: vi.fn((value: string) => { + const lower = value.toLowerCase(); + if (lower.endsWith("gen") || lower.endsWith("eth")) { + return BigInt(Math.trunc(Number(lower.slice(0, -3)) * 1e18)); + } + return BigInt(value); + }), +})); + +vi.mock("genlayer-js/chains", () => ({ + localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, + studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}}, + testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, + testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, +})); + +const mockTxResult = { + transactionHash: "0xTxHash" as `0x${string}`, + blockNumber: 123n, + gasUsed: 21000n, +}; + +const mockVestingState = { + name: "Team grant", + category: 1, + beneficiary: "0xBeneficiary", + creator: "0xCreator", + revoker: "0xRevoker", + factory: "0xFactory", + addressManager: "0xAddressManager", + totalAmount: "100 GEN", + totalAmountRaw: 100n, + startDate: 1710000000n, + cliffDuration: 86400n, + periodDuration: 604800n, + numberOfPeriods: 12n, + cliffUnlockBps: 1000n, + needsManualUnlock: false, + manualUnlocked: false, + revoked: false, + vestingStopped: false, + totalWithdrawn: "10 GEN", + totalWithdrawnRaw: 10n, + vestedAtRevocation: "0 GEN", + vestedAtRevocationRaw: 0n, + totalAmountAtRevocation: "0 GEN", + totalAmountAtRevocationRaw: 0n, + revokedAt: 0n, + vestingStoppedAt: 0n, + vestedAtStop: "0 GEN", + vestedAtStopRaw: 0n, + postRevocationBeneficiaryRewards: "0 GEN", + postRevocationBeneficiaryRewardsRaw: 0n, + postRevocationBeneficiaryLosses: "0 GEN", + postRevocationBeneficiaryLossesRaw: 0n, + accumulatedRewards: "0 GEN", + accumulatedRewardsRaw: 0n, + accumulatedLosses: "0 GEN", + accumulatedLossesRaw: 0n, + vestedAmount: "40 GEN", + vestedAmountRaw: 40n, + unvestedAmount: "60 GEN", + unvestedAmountRaw: 60n, + withdrawableAmount: "30 GEN", + withdrawableAmountRaw: 30n, +}; + +const mockClient = { + getBeneficiaryVestings: vi.fn(), + getVestingState: vi.fn(), + vestingDelegatorJoin: vi.fn(), + vestingDelegatorExit: vi.fn(), + vestingDelegatorClaim: vi.fn(), + vestingWithdraw: vi.fn(), + vestingValidatorJoin: vi.fn(), + vestingValidatorDeposit: vi.fn(), + vestingValidatorExit: vi.fn(), + vestingValidatorClaim: vi.fn(), + vestingValidatorInitiateOperatorTransfer: vi.fn(), + vestingValidatorCompleteOperatorTransfer: vi.fn(), + vestingValidatorCancelOperatorTransfer: vi.fn(), + vestingValidatorSetIdentity: vi.fn(), + getValidatorWallets: vi.fn(), + validatorWalletCount: vi.fn(), + validatorDeposited: vi.fn(), + isValidatorWallet: vi.fn(), + getStakeInfo: vi.fn(), +}; + +describe("vesting commands", () => { + let program: Command; + let consoleLogSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockClient.getBeneficiaryVestings.mockResolvedValue(["0xVesting"]); + mockClient.getVestingState.mockResolvedValue(mockVestingState); + mockClient.vestingDelegatorJoin.mockResolvedValue({ + ...mockTxResult, + vesting: "0xVesting", + validator: "0xValidator", + beneficiary: "0xBeneficiary", + amount: "42 GEN", + amountRaw: 42n, + }); + mockClient.vestingDelegatorExit.mockResolvedValue(mockTxResult); + mockClient.vestingDelegatorClaim.mockResolvedValue(mockTxResult); + mockClient.vestingWithdraw.mockResolvedValue({ + ...mockTxResult, + vesting: "0xVesting", + beneficiary: "0xBeneficiary", + amount: "10 GEN", + amountRaw: 10n, + }); + mockClient.vestingValidatorJoin.mockResolvedValue({ + ...mockTxResult, + vesting: "0xVesting", + validatorWallet: "0xWallet", + operator: "0xOperator", + beneficiary: "0xBeneficiary", + amount: "42 GEN", + amountRaw: 42n, + }); + mockClient.vestingValidatorDeposit.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorExit.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorClaim.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorInitiateOperatorTransfer.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorCompleteOperatorTransfer.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorCancelOperatorTransfer.mockResolvedValue(mockTxResult); + mockClient.vestingValidatorSetIdentity.mockResolvedValue(mockTxResult); + mockClient.getValidatorWallets.mockResolvedValue(["0xWallet"]); + mockClient.validatorWalletCount.mockResolvedValue(1n); + mockClient.validatorDeposited.mockResolvedValue(42n); + mockClient.isValidatorWallet.mockResolvedValue(true); + mockClient.getStakeInfo.mockResolvedValue({ + delegator: "0xVesting", + validator: "0xValidator", + shares: 50n, + stake: "50 GEN", + stakeRaw: 50n, + pendingDeposits: [], + pendingWithdrawals: [], + }); + + vi.spyOn(VestingAction.prototype as any, "getReadOnlyVestingClient").mockResolvedValue(mockClient); + vi.spyOn(VestingAction.prototype as any, "getVestingClient").mockResolvedValue(mockClient); + vi.spyOn(VestingAction.prototype as any, "getSignerAddress").mockResolvedValue("0xBeneficiary"); + vi.spyOn(VestingAction.prototype as any, "startSpinner").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "setSpinnerText").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "stopSpinner").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "succeedSpinner").mockImplementation(() => {}); + vi.spyOn(VestingAction.prototype as any, "failSpinner").mockImplementation(() => {}); + + consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + + program = new Command(); + initializeVestingCommands(program); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("list fetches beneficiary vesting contracts and state", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "list", + "--beneficiary", + "0xBeneficiary", + "--factory", + "0xFactory", + ]); + + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", { + factory: "0xFactory", + }); + expect(mockClient.getVestingState).toHaveBeenCalledWith("0xVesting"); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test("delegate resolves vesting and calls vestingDelegatorJoin", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "delegate", + "0xValidator", + "--amount", + "42gen", + ]); + + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", undefined); + expect(mockClient.vestingDelegatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + validator: "0xValidator", + amount: expect.any(BigInt), + }); + }); + + test("delegate accepts explicit vesting address", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "delegate", + "--validator", + "0xValidator", + "--amount", + "42gen", + "--vesting", + "0xExplicitVesting", + ]); + + expect(mockClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(mockClient.vestingDelegatorJoin).toHaveBeenCalledWith({ + vesting: "0xExplicitVesting", + validator: "0xValidator", + amount: expect.any(BigInt), + }); + }); + + test("undelegate exits all current shares", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "undelegate", + "0xValidator", + ]); + + expect(mockClient.getStakeInfo).toHaveBeenCalledWith("0xVesting", "0xValidator"); + expect(mockClient.vestingDelegatorExit).toHaveBeenCalledWith({ + vesting: "0xVesting", + validator: "0xValidator", + shares: 50n, + }); + }); + + test("claim calls vestingDelegatorClaim", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "claim", + "0xValidator", + ]); + + expect(mockClient.vestingDelegatorClaim).toHaveBeenCalledWith({ + vesting: "0xVesting", + validator: "0xValidator", + }); + }); + + test("withdraw calls vestingWithdraw", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "withdraw", + "--amount", + "10gen", + ]); + + expect(mockClient.vestingWithdraw).toHaveBeenCalledWith({ + vesting: "0xVesting", + amount: expect.any(BigInt), + }); + }); + + test("validator create calls vestingValidatorJoin", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "create", + "0xOperator", + "--amount", + "42gen", + ]); + + expect(mockClient.vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xVesting", + operator: "0xOperator", + amount: expect.any(BigInt), + }); + }); + + test("validator join accepts operator option and explicit vesting address", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "join", + "--operator", + "0xOperator", + "--amount", + "42gen", + "--vesting", + "0xExplicitVesting", + ]); + + expect(mockClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(mockClient.vestingValidatorJoin).toHaveBeenCalledWith({ + vesting: "0xExplicitVesting", + operator: "0xOperator", + amount: expect.any(BigInt), + }); + }); + + test("validator deposit calls vestingValidatorDeposit", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "deposit", + "0xWallet", + "--amount", + "10gen", + ]); + + expect(mockClient.vestingValidatorDeposit).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + amount: expect.any(BigInt), + }); + }); + + test("validator exit calls vestingValidatorExit", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "exit", + "0xWallet", + "--shares", + "100", + ]); + + expect(mockClient.vestingValidatorExit).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + shares: 100n, + }); + }); + + test("validator claim calls vestingValidatorClaim", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "claim", + "0xWallet", + ]); + + expect(mockClient.vestingValidatorClaim).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + }); + }); + + test("validator operator-transfer initiate calls SDK action", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "operator-transfer", + "initiate", + "0xWallet", + "0xNewOperator", + ]); + + expect(mockClient.vestingValidatorInitiateOperatorTransfer).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + newOperator: "0xNewOperator", + }); + }); + + test("validator operator-transfer complete calls SDK action", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "operator-transfer", + "complete", + "0xWallet", + ]); + + expect(mockClient.vestingValidatorCompleteOperatorTransfer).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + }); + }); + + test("validator operator-transfer cancel calls SDK action", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "operator-transfer", + "cancel", + "0xWallet", + ]); + + expect(mockClient.vestingValidatorCancelOperatorTransfer).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + }); + }); + + test("validator set-identity calls vestingValidatorSetIdentity with empty-string defaults", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "set-identity", + "0xWallet", + "--moniker", + "My Validator", + "--website", + "https://example.com", + "--twitter", + "myhandle", + ]); + + expect(mockClient.vestingValidatorSetIdentity).toHaveBeenCalledWith({ + vesting: "0xVesting", + wallet: "0xWallet", + moniker: "My Validator", + logoUri: "", + website: "https://example.com", + description: "", + email: "", + twitter: "myhandle", + telegram: "", + github: "", + extraCid: expect.any(String), + }); + }); + + test("validator list fetches wallets and deposited amounts", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "list", + "--vesting", + "0xVesting", + ]); + + expect(mockClient.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(mockClient.getValidatorWallets).toHaveBeenCalledWith("0xVesting"); + expect(mockClient.validatorDeposited).toHaveBeenCalledWith("0xVesting", "0xWallet"); + expect(consoleLogSpy).toHaveBeenCalled(); + }); + + test("validator status resolves vesting from beneficiary", async () => { + await program.parseAsync([ + "node", + "test", + "vesting", + "validator", + "status", + "--beneficiary", + "0xBeneficiary", + ]); + + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xBeneficiary", undefined); + expect(mockClient.getValidatorWallets).toHaveBeenCalledWith("0xVesting"); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 16150c17..a5d99f68 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -49,6 +49,10 @@ vi.mock("../src/commands/staking", () => ({ initializeStakingCommands: vi.fn(), })); +vi.mock("../src/commands/vesting", () => ({ + initializeVestingCommands: vi.fn(), +})); + describe("CLI", () => { it("should initialize CLI", () => { expect(initializeCLI).not.toThrow(); From 45a3ce99b72a9ad02e2a84362fd70b77ce41bdb6 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:11:33 +0100 Subject: [PATCH 09/45] chore(deps): update dependency uuid to v11.1.1 [security] (#320) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package-lock.json | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index 434d9ee9..25a9539d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -325,7 +325,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -349,7 +348,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1648,7 +1646,6 @@ "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -2162,7 +2159,6 @@ "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" } @@ -2285,7 +2281,6 @@ "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", @@ -2922,7 +2917,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -6827,7 +6821,6 @@ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "devOptional": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -6856,7 +6849,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8488,7 +8480,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@nodeutils/defaults-deep": "1.1.0", "@octokit/rest": "21.1.1", @@ -9523,7 +9514,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -9821,7 +9811,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9920,7 +9909,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -9985,9 +9973,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" @@ -10098,7 +10086,6 @@ "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", @@ -10212,7 +10199,6 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -10664,7 +10650,6 @@ "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" }, From 561370f955f23dc2e1952daac2c42aecb3f3431c Mon Sep 17 00:00:00 2001 From: Tobu <36818942+Tobu8888@users.noreply.github.com> Date: Wed, 8 Jul 2026 04:42:26 +0700 Subject: [PATCH 10/45] fix(init): use backend provider id "google" for Gemini (#359) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selecting Gemini during `genlayer init` failed with: Requested providers '{'geminiai'}' do not match any stored providers. The selected provider id is forwarded verbatim to sim_createRandomValidators, but the backend's llm_provider table stores Gemini as "google". Rename the provider id geminiai -> google so it matches. Display name ("Gemini") and env var (GEMINI_API_KEY) are unchanged. Since "geminiai" never resolved to a valid provider, no working configuration relied on it. Fixes #271 Co-authored-by: Edgars Nemše --- src/lib/config/simulator.ts | 6 +++--- tests/services/simulator.test.ts | 15 +++++++++++++++ 2 files changed, 18 insertions(+), 3 deletions(-) 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/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(); From f1f130461dc7d719c9f34086aeb40d8426f0602e Mon Sep 17 00:00:00 2001 From: Albert Castellana Date: Wed, 8 Jul 2026 00:56:26 +0200 Subject: [PATCH 11/45] fix(docs-sync): stop overwriting the generated root _meta.json (#352) The sync-docs workflow rsynced the generated category-based docs/api-references/_meta.json into genlayer-docs and then immediately overwrote it with a hardcoded heredoc containing the pre-grouping flat command list (init, up, deploy, ...). Those keys no longer match the directory layout, so the genlayer-docs sidebar rendered broken entries on every sync (fixed manually in genlayer-docs#426; this removes the cause). Also make the generated root meta complete: - add "index": "Overview" for the generated index.mdx - append ungrouped top-level commands (estimate-fees, finalize, finalize-batch) so they get explicit nav entries instead of relying on Nextra's implicit append Snapshot under docs/api-references regenerated against current main (picks up the new estimate-fees command and latest help text). Co-authored-by: Albert Castellana Co-authored-by: Claude Fable 5 Co-authored-by: Edgars --- .github/workflows/sync-docs.yml | 24 -------------------- docs/api-references/_meta.json | 6 ++++- docs/api-references/finalize-batch.mdx | 20 ++++++++++++++++ docs/api-references/finalize.mdx | 20 ++++++++++++++++ docs/api-references/index.mdx | 4 +++- docs/api-references/transactions/receipt.mdx | 2 +- scripts/generate-cli-docs.mjs | 10 +++++++- 7 files changed, 58 insertions(+), 28 deletions(-) create mode 100644 docs/api-references/finalize-batch.mdx create mode 100644 docs/api-references/finalize.mdx diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 080706ca..abb65083 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -72,30 +72,6 @@ jobs: rsync -a "${{ github.workspace }}/docs/api-references/" pages/api-references/genlayer-cli/ # Copy README as sibling file (strip badges/emojis) sed -E '/^\[!\[.*\]\(https:\/\/(img\.shields\.io|dcbadge|badge\.fury)/d' "${{ github.workspace }}/README.md" > pages/api-references/genlayer-cli.mdx - # Write _meta.json - cat > pages/api-references/genlayer-cli/_meta.json << 'METAEOF' - { - "init": "init", - "up": "up", - "stop": "stop", - "new": "new", - "config": "config", - "network": "network", - "deploy": "deploy", - "call": "call", - "write": "write", - "schema": "schema", - "code": "code", - "receipt": "receipt", - "trace": "trace", - "appeal": "appeal", - "appeal-bond": "appeal-bond", - "account": "account", - "staking": "staking", - "localnet": "localnet", - "update": "update" - } - METAEOF if [ -z "$(git status --porcelain)" ]; then echo "No changes to commit" exit 0 diff --git a/docs/api-references/_meta.json b/docs/api-references/_meta.json index aeb6b07b..9b9b4046 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -1,9 +1,13 @@ { + "index": "Overview", "environment": "Environment", "configuration": "Configuration", "contracts": "Contracts", "transactions": "Transactions", "accounts": "Accounts", "staking": "Staking", - "localnet": "Localnet" + "localnet": "Localnet", + "estimate-fees": "estimate-fees", + "finalize": "finalize", + "finalize-batch": "finalize-batch" } \ No newline at end of file diff --git a/docs/api-references/finalize-batch.mdx b/docs/api-references/finalize-batch.mdx new file mode 100644 index 00000000..afc1f6f9 --- /dev/null +++ b/docs/api-references/finalize-batch.mdx @@ -0,0 +1,20 @@ +--- +title: finalize-batch +--- + +Finalize a batch of idle transactions in a single call (public call) + +### Usage + +`$ genlayer finalize-batch [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/finalize.mdx b/docs/api-references/finalize.mdx new file mode 100644 index 00000000..b977d82d --- /dev/null +++ b/docs/api-references/finalize.mdx @@ -0,0 +1,20 @@ +--- +title: finalize +--- + +Finalize a transaction that is ready to be finalized (public call) + +### Usage + +`$ genlayer finalize [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/index.mdx b/docs/api-references/index.mdx index 60c40a5d..faf3b24f 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -6,7 +6,7 @@ GenLayer CLI is a development environment for the GenLayer ecosystem. It allows developers to interact with the protocol by creating accounts, sending transactions, and working with Intelligent Contracts by testing, debugging, and deploying them. -Version: `0.34.0` +Version: `0.39.1` ### Command List @@ -29,6 +29,8 @@ 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 --- 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/scripts/generate-cli-docs.mjs b/scripts/generate-cli-docs.mjs index f661f9ac..30474d0b 100644 --- a/scripts/generate-cli-docs.mjs +++ b/scripts/generate-cli-docs.mjs @@ -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 From 32a0a42d687a27c633a0ee29b6fbc84842c3cc18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Tue, 7 Jul 2026 23:56:30 +0100 Subject: [PATCH 12/45] fix: drop getSlashingAddress from validator-history (#361) getSlashingAddress() was removed from the genlayer-js SDK (v0.39+/v2-dev), causing `genlayer staking validator-history` to crash with `client.getSlashingAddress is not a function` before any history is fetched. Resolve the idleness (slashing) contract address dynamically via viem readContract against consensusMainContract.getIdlenessAddress(), falling back to the staking contract address if resolution fails so reward events still display. Port of #344 (by @ygd58) from the dead v0.39 line to v0.40-dev. Supersedes #344. Fixes #341 Co-authored-by: Edgars --- src/commands/staking/validatorHistory.ts | 32 ++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/commands/staking/validatorHistory.ts b/src/commands/staking/validatorHistory.ts index 071ad84c..6858daf5 100644 --- a/src/commands/staking/validatorHistory.ts +++ b/src/commands/staking/validatorHistory.ts @@ -1,6 +1,6 @@ import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} 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"; @@ -104,7 +104,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({ From 185d22bff86884b330c2037c2875a5d1629ec72b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 15:04:04 +0100 Subject: [PATCH 13/45] feat(network): custom network profiles with deployment-file import (#362) * feat(network): custom network profiles with deployment-file import genlayer network add --base [--deployment ] [--rpc ] [--consensus-main|--consensus-data|--staking|--fee-manager |--rounds-storage|--appeals ] [--chain-id ] [--deployment-key ] Profiles persist as base + address overrides only; resolveNetwork loads the base chain fresh from genlayer-js and applies overrides, so ABIs never go stale. network set/list/info/remove and StakingAction --network accept custom aliases. The consensus deployments.json shape is parsed by walking the tree for ContractName->address leaves (ConsensusMain, ConsensusData, GenStaking/Staking, FeeManager, Rounds/RoundsStorage, Appeals); flags take precedence over the file. Adds a prepare script so npm install from a git ref builds dist. Verified: 576 vitest tests, full manual smoke (add/list/set/info/remove with a deployment file). * chore(deps): bump genlayer-js to v2-dev tip for vesting actions The locked v2-dev SHA (28e99fbc) predates the vesting client actions; vestingValidatorJoin and friends land at 666d1156. Verified live: vesting validator create succeeds against a #1162-branch consensus deployment. * docs(cli): regenerate API references; fix option placeholder regex The docs generator's option regex only matched placeholders, so flags with dots or hyphens in the value name (--base , --deployment , --deployment-key ) were silently dropped from the options tables. Widen to <[^>]+> and regenerate: adds the network add/remove pages and the previously undocumented vesting command section (validator create/deposit/exit/claim, operator-transfer, set-identity, delegate/undelegate/claim/withdraw/list). * fix(vesting): resolve validator wallet address in create output The join receipt does not carry the new wallet address, so the output printed validatorWallet: undefined. Read getValidatorWallets from the vesting contract after the join and report the newest entry. Verified live against a #1162-branch deployment. --- docs/api-references/_meta.json | 3 +- docs/api-references/accounts/account/send.mdx | 2 +- docs/api-references/configuration/network.mdx | 4 +- .../configuration/network/add.mdx | 32 ++ .../configuration/network/remove.mdx | 21 ++ docs/api-references/contracts/call.mdx | 1 + docs/api-references/contracts/deploy.mdx | 23 +- docs/api-references/contracts/write.mdx | 21 +- docs/api-references/estimate-fees.mdx | 21 +- docs/api-references/index.mdx | 1 + .../localnet/validators/create-random.mdx | 4 +- docs/api-references/staking/staking.mdx | 2 +- .../staking/staking/active-validators.mdx | 2 +- .../staking/staking/banned-validators.mdx | 2 +- .../staking/staking/delegation-info.mdx | 2 +- .../staking/staking/delegator-claim.mdx | 2 +- .../staking/staking/delegator-exit.mdx | 2 +- .../staking/staking/delegator-join.mdx | 2 +- .../staking/staking/epoch-info.mdx | 2 +- .../staking/staking/prime-all.mdx | 2 +- .../staking/quarantined-validators.mdx | 2 +- .../staking/staking/set-identity.mdx | 2 +- .../staking/staking/set-operator.mdx | 2 +- .../staking/staking/validator-claim.mdx | 2 +- .../staking/staking/validator-deposit.mdx | 2 +- .../staking/staking/validator-exit.mdx | 2 +- .../staking/staking/validator-history.mdx | 2 +- .../staking/staking/validator-info.mdx | 2 +- .../staking/staking/validator-join.mdx | 2 +- .../staking/staking/validator-prime.mdx | 2 +- .../staking/staking/validators.mdx | 7 +- docs/api-references/vesting.mdx | 28 ++ docs/api-references/vesting/claim.mdx | 27 ++ docs/api-references/vesting/delegate.mdx | 28 ++ docs/api-references/vesting/list.mdx | 21 ++ docs/api-references/vesting/undelegate.mdx | 27 ++ docs/api-references/vesting/validator.mdx | 31 ++ .../vesting/validator/claim.mdx | 27 ++ .../vesting/validator/create.mdx | 28 ++ .../vesting/validator/deposit.mdx | 28 ++ .../api-references/vesting/validator/exit.mdx | 28 ++ .../api-references/vesting/validator/join.mdx | 28 ++ .../api-references/vesting/validator/list.mdx | 22 ++ .../vesting/validator/operator-transfer.mdx | 25 ++ .../validator/operator-transfer/cancel.mdx | 27 ++ .../validator/operator-transfer/complete.mdx | 27 ++ .../validator/operator-transfer/initiate.mdx | 29 ++ .../vesting/validator/set-identity.mdx | 36 +++ .../vesting/validator/status.mdx | 22 ++ docs/api-references/vesting/withdraw.mdx | 23 ++ package-lock.json | 17 +- package.json | 1 + scripts/generate-cli-docs.mjs | 2 +- src/commands/account/index.ts | 2 +- src/commands/account/send.ts | 10 +- src/commands/account/show.ts | 2 +- src/commands/network/index.ts | 25 ++ src/commands/network/setNetwork.ts | 266 ++++++++++++++-- src/commands/staking/StakingAction.ts | 8 +- src/commands/staking/index.ts | 38 +-- src/commands/staking/validatorHistory.ts | 14 +- src/commands/staking/validators.ts | 10 +- src/commands/staking/wizard.ts | 10 +- src/commands/vesting/VestingAction.ts | 8 +- src/commands/vesting/index.ts | 4 +- src/commands/vesting/validatorCreate.ts | 14 +- src/lib/actions/BaseAction.ts | 25 +- src/lib/networks/customNetworks.ts | 253 +++++++++++++++ tests/actions/customNetworkProfiles.test.ts | 297 ++++++++++++++++++ tests/commands/network.test.ts | 56 ++++ 70 files changed, 1597 insertions(+), 155 deletions(-) create mode 100644 docs/api-references/configuration/network/add.mdx create mode 100644 docs/api-references/configuration/network/remove.mdx create mode 100644 docs/api-references/vesting.mdx create mode 100644 docs/api-references/vesting/claim.mdx create mode 100644 docs/api-references/vesting/delegate.mdx create mode 100644 docs/api-references/vesting/list.mdx create mode 100644 docs/api-references/vesting/undelegate.mdx create mode 100644 docs/api-references/vesting/validator.mdx create mode 100644 docs/api-references/vesting/validator/claim.mdx create mode 100644 docs/api-references/vesting/validator/create.mdx create mode 100644 docs/api-references/vesting/validator/deposit.mdx create mode 100644 docs/api-references/vesting/validator/exit.mdx create mode 100644 docs/api-references/vesting/validator/join.mdx create mode 100644 docs/api-references/vesting/validator/list.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer/cancel.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer/complete.mdx create mode 100644 docs/api-references/vesting/validator/operator-transfer/initiate.mdx create mode 100644 docs/api-references/vesting/validator/set-identity.mdx create mode 100644 docs/api-references/vesting/validator/status.mdx create mode 100644 docs/api-references/vesting/withdraw.mdx create mode 100644 src/lib/networks/customNetworks.ts create mode 100644 tests/actions/customNetworkProfiles.test.ts diff --git a/docs/api-references/_meta.json b/docs/api-references/_meta.json index 9b9b4046..bf2c81cb 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -9,5 +9,6 @@ "localnet": "Localnet", "estimate-fees": "estimate-fees", "finalize": "finalize", - "finalize-batch": "finalize-batch" + "finalize-batch": "finalize-batch", + "vesting": "vesting" } \ No newline at end of file diff --git a/docs/api-references/accounts/account/send.mdx b/docs/api-references/accounts/account/send.mdx index 86477235..42498133 100644 --- a/docs/api-references/accounts/account/send.mdx +++ b/docs/api-references/accounts/account/send.mdx @@ -18,7 +18,7 @@ Send GEN to an address | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --account <name> | Account to send from | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/configuration/network.mdx b/docs/api-references/configuration/network.mdx index 5a1cec9d..c71d6c34 100644 --- a/docs/api-references/configuration/network.mdx +++ b/docs/api-references/configuration/network.mdx @@ -20,6 +20,8 @@ Network configuration ### Subcommands +- `genlayer add` — Add a custom network profile - `genlayer set` — Set the network to use -- `genlayer info` — Show current network configuration and contract addresses +- `genlayer info` — Show current network configuration and contract - `genlayer list` — List available networks +- `genlayer remove` — Remove a custom network profile diff --git a/docs/api-references/configuration/network/add.mdx b/docs/api-references/configuration/network/add.mdx new file mode 100644 index 00000000..a070fa9c --- /dev/null +++ b/docs/api-references/configuration/network/add.mdx @@ -0,0 +1,32 @@ +--- +title: network add +--- + +Add a custom network profile +Arguments: +alias Custom network alias + +### Usage + +`$ genlayer network add [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --base <built-in-alias> | Built-in base network alias | No | | +| | --deployment <path.json> | Consensus deployments JSON file | No | | +| | --deployment-key <dot.path> | Deployment JSON sub-object to scan | No | | +| | --rpc <url> | Node RPC URL override | No | | +| | --consensus-main <addr> | ConsensusMain contract address override | No | | +| | --consensus-data <addr> | ConsensusData contract address override | No | | +| | --staking <addr> | Staking contract address override | No | | +| | --fee-manager <addr> | FeeManager contract address override | No | | +| | --rounds-storage <addr> | RoundsStorage contract address override | No | | +| | --appeals <addr> | Appeals contract address override | No | | +| | --chain-id <n> | Chain ID override | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/configuration/network/remove.mdx b/docs/api-references/configuration/network/remove.mdx new file mode 100644 index 00000000..f9b3aeb8 --- /dev/null +++ b/docs/api-references/configuration/network/remove.mdx @@ -0,0 +1,21 @@ +--- +title: network remove +--- + +Remove a custom network profile +Arguments: +alias Custom network alias to remove + +### Usage + +`$ genlayer network remove [options] ` + +### Arguments + +- `` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/call.mdx b/docs/api-references/contracts/call.mdx index 02b21da6..d275fec6 100644 --- a/docs/api-references/contracts/call.mdx +++ b/docs/api-references/contracts/call.mdx @@ -18,4 +18,5 @@ Call a contract method without sending a transaction or changing the state | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --args <args...> | Contract arguments. Supported types: | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index dddbf07e..ca96ac0b 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -10,14 +10,15 @@ Deploy intelligent contracts ### Options -| Short | Long | Description | Required | Default | -| ----- | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------: | ------- | -| | --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 | | -| -h | --help | display help for command | No | | +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --contract <contractPath> | Path to the smart contract to deploy | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Transaction fee options JSON passed to genlayer-js. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --appeal-rounds <count> | Override fee profile appeal rounds | No | | +| | --fee-value <wei> | Fee deposit value to send with the transaction | No | | +| | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | +| | --args <args...> | Contract arguments. Supported types: | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/contracts/write.mdx b/docs/api-references/contracts/write.mdx index 0bb09d22..b4760e37 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -15,13 +15,14 @@ Sends a transaction to a contract method that modifies the state ### Options -| 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 | | -| -h | --help | display help for command | No | | +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --fees <json> | Transaction fee options JSON passed to genlayer-js. | No | | +| | --fee-profile <path> | Path to a fee profile generated by gltest --fee-profile. Deploy uses the profile deploy entry; write and targeted estimate-fees use the matching method entry. --fees can still be provided to override profile values. | No | | +| | --fee-preset <preset> | Fee profile appeal posture: low, standard, or high | No | | +| | --appeal-rounds <count> | Override fee profile appeal rounds | No | | +| | --fee-value <wei> | Fee deposit value to send with the transaction | No | | +| | --valid-until <unixTimestamp> | Unix timestamp after which the transaction is invalid | No | | +| | --args <args...> | Contract arguments. Supported types: | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/estimate-fees.mdx b/docs/api-references/estimate-fees.mdx index 88154ea6..217e9ae6 100644 --- a/docs/api-references/estimate-fees.mdx +++ b/docs/api-references/estimate-fees.mdx @@ -16,13 +16,14 @@ simulation ### 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 | | -| -h | --help | display help for command | No | | +| 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/index.mdx b/docs/api-references/index.mdx index faf3b24f..dc97f6cb 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -32,6 +32,7 @@ Version: `0.39.1` - `genlayer finalize` — Finalize a transaction that is ready to be finalized (public call) - `genlayer finalize-batch` — Finalize a batch of idle transactions in a single call (public call) - `genlayer staking` — Staking operations for validators and delegators +- `genlayer vesting` — Vesting operations for beneficiaries --- diff --git a/docs/api-references/localnet/localnet/validators/create-random.mdx b/docs/api-references/localnet/localnet/validators/create-random.mdx index 4fb23b53..926480b0 100644 --- a/docs/api-references/localnet/localnet/validators/create-random.mdx +++ b/docs/api-references/localnet/localnet/validators/create-random.mdx @@ -12,5 +12,7 @@ Create random validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --count <count> | Number of validators to create openai ollama) gpt-4o) | No | `[]` | +| | --count <count> | Number of validators to create | No | `1` | +| | --providers <providers...> | Space-separated list of provider names (e.g., openai ollama) | No | `[]` | +| | --models <models...> | Space-separated list of model names (e.g., gpt-4 gpt-4o) | No | `[]` | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking.mdx b/docs/api-references/staking/staking.mdx index 17f18239..f1bd84ac 100644 --- a/docs/api-references/staking/staking.mdx +++ b/docs/api-references/staking/staking.mdx @@ -38,5 +38,5 @@ Staking operations for validators and delegators - `genlayer active-validators` — List all active validators - `genlayer quarantined-validators` — List all quarantined validators - `genlayer banned-validators` — List all banned validators -- `genlayer validators` — Show validator set with stake, status, and voting power +- `genlayer validators` — List validators with stake, status, and optional explorer performance - `genlayer validator-history` — Show slash and reward history for a validator (default: last 10 epochs) diff --git a/docs/api-references/staking/staking/active-validators.mdx b/docs/api-references/staking/staking/active-validators.mdx index 89a90a41..441eba20 100644 --- a/docs/api-references/staking/staking/active-validators.mdx +++ b/docs/api-references/staking/staking/active-validators.mdx @@ -12,7 +12,7 @@ List all active validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/banned-validators.mdx b/docs/api-references/staking/staking/banned-validators.mdx index 83b750aa..c3b33247 100644 --- a/docs/api-references/staking/staking/banned-validators.mdx +++ b/docs/api-references/staking/staking/banned-validators.mdx @@ -12,7 +12,7 @@ List all banned validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegation-info.mdx b/docs/api-references/staking/staking/delegation-info.mdx index b75fe6ce..447750c4 100644 --- a/docs/api-references/staking/staking/delegation-info.mdx +++ b/docs/api-references/staking/staking/delegation-info.mdx @@ -19,7 +19,7 @@ Get delegation info for a delegator with a validator | | --validator <address> | Validator address (deprecated, use positional arg) | No | | | | --delegator <address> | Delegator address (defaults to signer) | No | | | | --account <name> | Account to use (for default delegator address) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-claim.mdx b/docs/api-references/staking/staking/delegator-claim.mdx index 325eb379..25c71120 100644 --- a/docs/api-references/staking/staking/delegator-claim.mdx +++ b/docs/api-references/staking/staking/delegator-claim.mdx @@ -20,7 +20,7 @@ Claim delegator withdrawals after unbonding period | | --delegator <address> | Delegator address (defaults to signer) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-exit.mdx b/docs/api-references/staking/staking/delegator-exit.mdx index 1e8b5ad0..b4f8864d 100644 --- a/docs/api-references/staking/staking/delegator-exit.mdx +++ b/docs/api-references/staking/staking/delegator-exit.mdx @@ -20,7 +20,7 @@ Exit as a delegator by withdrawing shares from a validator | | --shares <shares> | Number of shares to withdraw | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/delegator-join.mdx b/docs/api-references/staking/staking/delegator-join.mdx index 3219e752..d213113a 100644 --- a/docs/api-references/staking/staking/delegator-join.mdx +++ b/docs/api-references/staking/staking/delegator-join.mdx @@ -20,7 +20,7 @@ Join as a delegator by staking with a validator | | --amount <amount> | Amount to stake (in wei or with 'eth'/'gen' suffix) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/epoch-info.mdx b/docs/api-references/staking/staking/epoch-info.mdx index 7f4cf570..97a50bca 100644 --- a/docs/api-references/staking/staking/epoch-info.mdx +++ b/docs/api-references/staking/staking/epoch-info.mdx @@ -13,7 +13,7 @@ Get current epoch and staking parameters | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --epoch <number> | Show data for specific epoch (current or previous only) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/prime-all.mdx b/docs/api-references/staking/staking/prime-all.mdx index 5d69dbea..be8d1a7c 100644 --- a/docs/api-references/staking/staking/prime-all.mdx +++ b/docs/api-references/staking/staking/prime-all.mdx @@ -14,7 +14,7 @@ Prime all validators that need priming | --- | --- | --- | :---: | --- | | | --account <name> | Account to use (pays gas) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/quarantined-validators.mdx b/docs/api-references/staking/staking/quarantined-validators.mdx index c6a55a01..b90d615e 100644 --- a/docs/api-references/staking/staking/quarantined-validators.mdx +++ b/docs/api-references/staking/staking/quarantined-validators.mdx @@ -12,7 +12,7 @@ List all quarantined validators | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/set-identity.mdx b/docs/api-references/staking/staking/set-identity.mdx index 9212e863..85046475 100644 --- a/docs/api-references/staking/staking/set-identity.mdx +++ b/docs/api-references/staking/staking/set-identity.mdx @@ -28,6 +28,6 @@ Set validator identity metadata (moniker, website, socials, etc.) | | --extra-cid <cid> | Extra data as IPFS CID or hex bytes (0x...) | No | | | | --account <name> | Account to use (must be validator operator) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/set-operator.mdx b/docs/api-references/staking/staking/set-operator.mdx index 5c5b2e9f..0d08a104 100644 --- a/docs/api-references/staking/staking/set-operator.mdx +++ b/docs/api-references/staking/staking/set-operator.mdx @@ -21,6 +21,6 @@ Change the operator address for a validator wallet | | --operator <address> | New operator address (deprecated, use positional arg) | No | | | | --account <name> | Account to use (must be validator owner) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-claim.mdx b/docs/api-references/staking/staking/validator-claim.mdx index bddb4657..3085a3af 100644 --- a/docs/api-references/staking/staking/validator-claim.mdx +++ b/docs/api-references/staking/staking/validator-claim.mdx @@ -19,6 +19,6 @@ Claim validator withdrawals after unbonding period | | --validator <address> | Validator wallet contract address (deprecated, use positional arg) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-deposit.mdx b/docs/api-references/staking/staking/validator-deposit.mdx index 1f0dd6a6..daf32fa0 100644 --- a/docs/api-references/staking/staking/validator-deposit.mdx +++ b/docs/api-references/staking/staking/validator-deposit.mdx @@ -20,6 +20,6 @@ Make an additional deposit to a validator wallet | | --amount <amount> | Amount to deposit (in wei or with 'eth'/'gen' suffix) | No | | | | --account <name> | Account to use (must be validator owner) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-exit.mdx b/docs/api-references/staking/staking/validator-exit.mdx index 3c8dd58c..c5489f03 100644 --- a/docs/api-references/staking/staking/validator-exit.mdx +++ b/docs/api-references/staking/staking/validator-exit.mdx @@ -20,6 +20,6 @@ Exit as a validator by withdrawing shares | | --shares <shares> | Number of shares to withdraw | No | | | | --account <name> | Account to use (must be validator owner) | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-history.mdx b/docs/api-references/staking/staking/validator-history.mdx index e2f29f85..d2279901 100644 --- a/docs/api-references/staking/staking/validator-history.mdx +++ b/docs/api-references/staking/staking/validator-history.mdx @@ -23,7 +23,7 @@ Show slash and reward history for a validator (default: last 10 epochs) | | --all | Fetch complete history from genesis (slow) | No | | | | --limit <count> | Maximum number of events to show | No | `50` | | | --account <name> | Account to use (for default validator address) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-info.mdx b/docs/api-references/staking/staking/validator-info.mdx index bc4e6fd8..f0088acf 100644 --- a/docs/api-references/staking/staking/validator-info.mdx +++ b/docs/api-references/staking/staking/validator-info.mdx @@ -18,7 +18,7 @@ Get information about a validator | --- | --- | --- | :---: | --- | | | --validator <address> | Validator address (deprecated, use positional arg) | No | | | | --account <name> | Account to use (for default validator address) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | | --debug | Show raw unfiltered pending deposits/withdrawals | No | | diff --git a/docs/api-references/staking/staking/validator-join.mdx b/docs/api-references/staking/staking/validator-join.mdx index 24e9ff3b..e2d56331 100644 --- a/docs/api-references/staking/staking/validator-join.mdx +++ b/docs/api-references/staking/staking/validator-join.mdx @@ -16,7 +16,7 @@ Join as a validator by staking tokens | | --operator <address> | Operator address (defaults to signer) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validator-prime.mdx b/docs/api-references/staking/staking/validator-prime.mdx index 33d335a7..769d53f4 100644 --- a/docs/api-references/staking/staking/validator-prime.mdx +++ b/docs/api-references/staking/staking/validator-prime.mdx @@ -19,7 +19,7 @@ Prime a validator to prepare their stake record for the next epoch | | --validator <address> | Validator address to prime (deprecated, use positional arg) | No | | | | --account <name> | Account to use | No | | | | --password <password> | Password to unlock account (skips interactive prompt) | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/staking/staking/validators.mdx b/docs/api-references/staking/staking/validators.mdx index 015b1518..e17e4965 100644 --- a/docs/api-references/staking/staking/validators.mdx +++ b/docs/api-references/staking/staking/validators.mdx @@ -2,7 +2,7 @@ title: staking validators --- -Show validator set with stake, status, and voting power +List validators with stake, status, and optional explorer performance ### Usage @@ -13,7 +13,10 @@ Show validator set with stake, status, and voting power | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --all | Include banned validators | No | | -| | --network <network> | Network to use (localnet, testnet-asimov) | No | | +| | --json | Output machine-readable JSON | No | | +| | --sort-by <field> | Sort validators by stake or uptime (default: stake) | No | `stake` | +| | --explorer-url <url> | Explorer backend or explorer base URL for optional performance enrichment | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | | | --rpc <rpcUrl> | RPC URL for the network | No | | | | --staking-address <address> | Staking contract address (overrides chain config) | No | | | -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting.mdx b/docs/api-references/vesting.mdx new file mode 100644 index 00000000..1742df61 --- /dev/null +++ b/docs/api-references/vesting.mdx @@ -0,0 +1,28 @@ +--- +title: vesting +--- + +Vesting operations for beneficiaries + +### Usage + +`$ genlayer vesting [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer list` — List beneficiary vesting contracts and state +- `genlayer delegate` — Delegate vesting-held tokens to a validator +- `genlayer undelegate` — Undelegate all vesting delegation shares +- `genlayer claim` — Claim vesting delegation withdrawals after +- `genlayer withdraw` — Withdraw vested tokens to the beneficiary +- `genlayer validator` — Vesting-backed validator operations diff --git a/docs/api-references/vesting/claim.mdx b/docs/api-references/vesting/claim.mdx new file mode 100644 index 00000000..43d64d47 --- /dev/null +++ b/docs/api-references/vesting/claim.mdx @@ -0,0 +1,27 @@ +--- +title: vesting claim +--- + +Claim vesting delegation withdrawals after unbonding period + +### Usage + +`$ genlayer vesting claim [options] [validator]` + +### Arguments + +- `[validator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator address to claim from (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/delegate.mdx b/docs/api-references/vesting/delegate.mdx new file mode 100644 index 00000000..c0523790 --- /dev/null +++ b/docs/api-references/vesting/delegate.mdx @@ -0,0 +1,28 @@ +--- +title: vesting delegate +--- + +Delegate vesting-held tokens to a validator + +### Usage + +`$ genlayer vesting delegate [options] [validator]` + +### Arguments + +- `[validator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator address to delegate to (deprecated, use positional arg) | No | | +| | --amount <amount> | Amount to delegate (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/list.mdx b/docs/api-references/vesting/list.mdx new file mode 100644 index 00000000..36131325 --- /dev/null +++ b/docs/api-references/vesting/list.mdx @@ -0,0 +1,21 @@ +--- +title: vesting list +--- + +List beneficiary vesting contracts and state + +### Usage + +`$ genlayer vesting list [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --beneficiary <address> | Beneficiary address (defaults to signer) | No | | +| | --account <name> | Account to use (for default beneficiary address) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/undelegate.mdx b/docs/api-references/vesting/undelegate.mdx new file mode 100644 index 00000000..3ab6c783 --- /dev/null +++ b/docs/api-references/vesting/undelegate.mdx @@ -0,0 +1,27 @@ +--- +title: vesting undelegate +--- + +Undelegate all vesting delegation shares from a validator + +### Usage + +`$ genlayer vesting undelegate [options] [validator]` + +### Arguments + +- `[validator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --validator <address> | Validator address to undelegate from (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator.mdx b/docs/api-references/vesting/validator.mdx new file mode 100644 index 00000000..265a5fea --- /dev/null +++ b/docs/api-references/vesting/validator.mdx @@ -0,0 +1,31 @@ +--- +title: vesting validator +--- + +Vesting-backed validator operations + +### Usage + +`$ genlayer vesting validator [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer create` — Create a vesting-backed validator +- `genlayer join` — Create a vesting-backed validator +- `genlayer deposit` — Deposit more vesting-held tokens to a +- `genlayer exit` — Exit vesting validator self-stake by +- `genlayer claim` — Claim vesting validator withdrawals after +- `genlayer operator-transfer` — Manage vesting validator operator transfers +- `genlayer set-identity` — Set vesting validator identity metadata +- `genlayer list` — List validator wallets owned by a vesting +- `genlayer status` — Show validator wallets owned by a vesting diff --git a/docs/api-references/vesting/validator/claim.mdx b/docs/api-references/vesting/validator/claim.mdx new file mode 100644 index 00000000..cf112d09 --- /dev/null +++ b/docs/api-references/vesting/validator/claim.mdx @@ -0,0 +1,27 @@ +--- +title: vesting validator claim +--- + +Claim vesting validator withdrawals after unbonding period + +### Usage + +`$ genlayer vesting validator claim [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/create.mdx b/docs/api-references/vesting/validator/create.mdx new file mode 100644 index 00000000..f159851b --- /dev/null +++ b/docs/api-references/vesting/validator/create.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator create +--- + +Create a vesting-backed validator + +### Usage + +`$ genlayer vesting validator create [options] [operator]` + +### Arguments + +- `[operator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --operator <address> | Operator address (deprecated, use positional arg) | No | | +| | --amount <amount> | Amount to self-stake (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/deposit.mdx b/docs/api-references/vesting/validator/deposit.mdx new file mode 100644 index 00000000..13ead169 --- /dev/null +++ b/docs/api-references/vesting/validator/deposit.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator deposit +--- + +Deposit more vesting-held tokens to a validator wallet + +### Usage + +`$ genlayer vesting validator deposit [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --amount <amount> | Amount to deposit (in wei or with 'eth'/'gen' suffix) | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/exit.mdx b/docs/api-references/vesting/validator/exit.mdx new file mode 100644 index 00000000..02a3541c --- /dev/null +++ b/docs/api-references/vesting/validator/exit.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator exit +--- + +Exit vesting validator self-stake by withdrawing shares + +### Usage + +`$ genlayer vesting validator exit [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --shares <shares> | Number of shares to withdraw | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/join.mdx b/docs/api-references/vesting/validator/join.mdx new file mode 100644 index 00000000..e2bd862b --- /dev/null +++ b/docs/api-references/vesting/validator/join.mdx @@ -0,0 +1,28 @@ +--- +title: vesting validator join +--- + +Create a vesting-backed validator + +### Usage + +`$ genlayer vesting validator join [options] [operator]` + +### Arguments + +- `[operator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --operator <address> | Operator address (deprecated, use positional arg) | No | | +| | --amount <amount> | Amount to self-stake (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/list.mdx b/docs/api-references/vesting/validator/list.mdx new file mode 100644 index 00000000..9cdc9e72 --- /dev/null +++ b/docs/api-references/vesting/validator/list.mdx @@ -0,0 +1,22 @@ +--- +title: vesting validator list +--- + +List validator wallets owned by a vesting contract + +### Usage + +`$ genlayer vesting validator list [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --beneficiary <address> | Beneficiary address (defaults to signer) | No | | +| | --account <name> | Account to use (for default beneficiary address) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer.mdx b/docs/api-references/vesting/validator/operator-transfer.mdx new file mode 100644 index 00000000..0210b4e4 --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer.mdx @@ -0,0 +1,25 @@ +--- +title: vesting validator operator-transfer +--- + +Manage vesting validator operator transfers + +### Usage + +`$ genlayer vesting validator operator-transfer [options] [command]` + +### Arguments + +- `[command]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| -h | --help | display help for command | No | | + +### Subcommands + +- `genlayer initiate` — Initiate a vesting validator operator transfer +- `genlayer complete` — Complete a vesting validator operator transfer +- `genlayer cancel` — Cancel a vesting validator operator transfer diff --git a/docs/api-references/vesting/validator/operator-transfer/cancel.mdx b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx new file mode 100644 index 00000000..13f88d5f --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx @@ -0,0 +1,27 @@ +--- +title: vesting validator operator-transfer cancel +--- + +Cancel a vesting validator operator transfer + +### Usage + +`$ genlayer vesting validator operator-transfer cancel [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer/complete.mdx b/docs/api-references/vesting/validator/operator-transfer/complete.mdx new file mode 100644 index 00000000..47dda0c7 --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/complete.mdx @@ -0,0 +1,27 @@ +--- +title: vesting validator operator-transfer complete +--- + +Complete a vesting validator operator transfer + +### Usage + +`$ genlayer vesting validator operator-transfer complete [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/operator-transfer/initiate.mdx b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx new file mode 100644 index 00000000..e3596106 --- /dev/null +++ b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx @@ -0,0 +1,29 @@ +--- +title: vesting validator operator-transfer initiate +--- + +Initiate a vesting validator operator transfer + +### Usage + +`$ genlayer vesting validator operator-transfer initiate [options] [wallet] [newOperator]` + +### Arguments + +- `[wallet]` +- `[newOperator]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --new-operator <address> | New operator address (deprecated, use positional arg) | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/set-identity.mdx b/docs/api-references/vesting/validator/set-identity.mdx new file mode 100644 index 00000000..463f37b8 --- /dev/null +++ b/docs/api-references/vesting/validator/set-identity.mdx @@ -0,0 +1,36 @@ +--- +title: vesting validator set-identity +--- + +Set vesting validator identity metadata + +### Usage + +`$ genlayer vesting validator set-identity [options] [wallet]` + +### Arguments + +- `[wallet]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --moniker <name> | Validator display name | No | | +| | --logo-uri <uri> | Logo URI | No | | +| | --website <url> | Website URL | No | | +| | --description <text> | Description | No | | +| | --email <email> | Contact email | No | | +| | --twitter <handle> | Twitter handle | No | | +| | --telegram <handle> | Telegram handle | No | | +| | --github <handle> | GitHub handle | No | | +| | --extra-cid <cid> | Extra data as IPFS CID or hex bytes (0x...) | No | | +| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/validator/status.mdx b/docs/api-references/vesting/validator/status.mdx new file mode 100644 index 00000000..8ce0d443 --- /dev/null +++ b/docs/api-references/vesting/validator/status.mdx @@ -0,0 +1,22 @@ +--- +title: vesting validator status +--- + +Show validator wallets owned by a vesting contract + +### Usage + +`$ genlayer vesting validator status [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --beneficiary <address> | Beneficiary address (defaults to signer) | No | | +| | --account <name> | Account to use (for default beneficiary address) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/docs/api-references/vesting/withdraw.mdx b/docs/api-references/vesting/withdraw.mdx new file mode 100644 index 00000000..294c87a0 --- /dev/null +++ b/docs/api-references/vesting/withdraw.mdx @@ -0,0 +1,23 @@ +--- +title: vesting withdraw +--- + +Withdraw vested tokens to the beneficiary + +### Usage + +`$ genlayer vesting withdraw [options]` + +### Options + +| Short | Long | Description | Required | Default | +| --- | --- | --- | :---: | --- | +| | --amount <amount> | Amount to withdraw (in wei or with 'eth'/'gen' suffix) | No | | +| | --vesting <address> | Vesting contract address (overrides beneficiary lookup) | No | | +| | --account <name> | Account to use | No | | +| | --password <password> | Password to unlock account (skips interactive prompt) | No | | +| | --network <network> | built-in or custom network alias (see: genlayer network list) | No | | +| | --rpc <rpcUrl> | RPC URL for the network | No | | +| | --factory <address> | VestingFactory address (overrides AddressManager lookup) | No | | +| | --address-manager <address> | AddressManager address (overrides consensus lookup) | No | | +| -h | --help | display help for command | No | | diff --git a/package-lock.json b/package-lock.json index 25a9539d..a09354be 100644 --- a/package-lock.json +++ b/package-lock.json @@ -325,6 +325,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -348,6 +349,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -1646,6 +1648,7 @@ "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^5.0.0", "@octokit/graphql": "^8.2.2", @@ -2159,6 +2162,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 +2285,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", @@ -2917,6 +2922,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5582,7 +5588,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#28e99fbc10ad962019e8314fb1b0d83d5a0e0938", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#666d1156c3c136bf79916c53b9764ff3a2a87a5c", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -6821,6 +6827,7 @@ "integrity": "sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==", "devOptional": true, "license": "MIT", + "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -6849,6 +6856,7 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -8480,6 +8488,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@nodeutils/defaults-deep": "1.1.0", "@octokit/rest": "21.1.1", @@ -9514,6 +9523,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9811,6 +9821,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -9909,6 +9920,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -10086,6 +10098,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", @@ -10199,6 +10212,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10650,6 +10664,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" }, diff --git a/package.json b/package.json index 64058f2d..405ab3d0 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "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", + "prepare": "npm run build", "release": "./scripts/release.sh", "postinstall": "node ./scripts/postinstall.js", "docs:cli": "node scripts/generate-cli-docs.mjs" diff --git a/scripts/generate-cli-docs.mjs b/scripts/generate-cli-docs.mjs index 30474d0b..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] || ''; diff --git a/src/commands/account/index.ts b/src/commands/account/index.ts index 3fa23f15..88580d46 100644 --- a/src/commands/account/index.ts +++ b/src/commands/account/index.ts @@ -98,7 +98,7 @@ export function initializeAccountCommands(program: Command) { .command("send ") .description("Send GEN to an address") .option("--rpc ", "RPC URL for the network") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--account ", "Account to send from") .option("--password ", "Password to unlock account (skips interactive prompt)") .action(async (to: string, amount: string, options: {rpc?: string; network?: string; account?: string; password?: string}) => { diff --git a/src/commands/account/send.ts b/src/commands/account/send.ts index 716d577b..e6905b46 100644 --- a/src/commands/account/send.ts +++ b/src/commands/account/send.ts @@ -1,4 +1,4 @@ -import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; +import {BaseAction, resolveNetwork} from "../../lib/actions/BaseAction"; import {parseEther, formatEther} from "viem"; import {createClient, createAccount} from "genlayer-js"; import type {GenLayerChain, Address, Hash} from "genlayer-js/types"; @@ -21,13 +21,9 @@ export class SendAction extends BaseAction { private getNetwork(networkOption?: string): GenLayerChain { if (networkOption) { - const network = BUILT_IN_NETWORKS[networkOption]; - if (!network) { - throw new Error(`Unknown network: ${networkOption}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); - } - return network; + return resolveNetwork(networkOption, this.getCustomNetworks()); } - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } private parseAmount(amount: string): bigint { diff --git a/src/commands/account/show.ts b/src/commands/account/show.ts index 161bac41..ee74506f 100644 --- a/src/commands/account/show.ts +++ b/src/commands/account/show.ts @@ -15,7 +15,7 @@ export class ShowAccountAction extends BaseAction { } private getNetwork(): GenLayerChain { - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } async execute(options?: ShowAccountOptions): Promise { diff --git a/src/commands/network/index.ts b/src/commands/network/index.ts index a5f7ee27..4e786e0a 100644 --- a/src/commands/network/index.ts +++ b/src/commands/network/index.ts @@ -6,6 +6,24 @@ export function initializeNetworkCommands(program: Command) { const network = program.command("network").description("Network configuration"); + // genlayer network add + network + .command("add") + .description("Add a custom network profile") + .argument("", "Custom network alias") + .requiredOption("--base ", "Built-in base network alias") + .option("--deployment ", "Consensus deployments JSON file") + .option("--deployment-key ", "Deployment JSON sub-object to scan") + .option("--rpc ", "Node RPC URL override") + .option("--consensus-main ", "ConsensusMain contract address override") + .option("--consensus-data ", "ConsensusData contract address override") + .option("--staking ", "Staking contract address override") + .option("--fee-manager ", "FeeManager contract address override") + .option("--rounds-storage ", "RoundsStorage contract address override") + .option("--appeals ", "Appeals contract address override") + .option("--chain-id ", "Chain ID override") + .action((alias: string, options) => networkActions.addNetwork(alias, options)); + // genlayer network set [name] network .command("set") @@ -25,5 +43,12 @@ export function initializeNetworkCommands(program: Command) { .description("List available networks") .action(() => networkActions.listNetworks()); + // genlayer network remove + network + .command("remove") + .description("Remove a custom network profile") + .argument("", "Custom network alias to remove") + .action((alias: string) => networkActions.removeNetwork(alias)); + return program; } diff --git a/src/commands/network/setNetwork.ts b/src/commands/network/setNetwork.ts index 550e1fef..de771cb8 100644 --- a/src/commands/network/setNetwork.ts +++ b/src/commands/network/setNetwork.ts @@ -1,61 +1,113 @@ import {BaseAction, BUILT_IN_NETWORKS, resolveNetwork} from "../../lib/actions/BaseAction"; import inquirer, {DistinctQuestion} from "inquirer"; +import { + CONTRACT_OVERRIDES, + CUSTOM_NETWORKS_CONFIG_KEY, + getContractAddress, + isValidAddress, + normalizeCustomNetworks, + parseDeploymentFile, + type ContractOverrideKey, + type CustomNetworkOverrides, + type CustomNetworkProfile, + type CustomNetworksConfig, +} from "../../lib/networks/customNetworks"; +import type {Address, GenLayerChain} from "genlayer-js/types"; -const networks = Object.entries(BUILT_IN_NETWORKS).map(([alias, network]) => ({ +const builtInNetworks = Object.entries(BUILT_IN_NETWORKS).map(([alias, network]) => ({ name: network.name, alias, - value: network, + type: "built-in" as const, })); +const CONTRACT_FLAG_OPTIONS: Array<{ + optionKey: keyof AddNetworkOptions; + overrideKey: ContractOverrideKey; + label: string; +}> = [ + {optionKey: "consensusMain", overrideKey: "consensusMain", label: "--consensus-main"}, + {optionKey: "consensusData", overrideKey: "consensusData", label: "--consensus-data"}, + {optionKey: "staking", overrideKey: "staking", label: "--staking"}, + {optionKey: "feeManager", overrideKey: "feeManager", label: "--fee-manager"}, + {optionKey: "roundsStorage", overrideKey: "roundsStorage", label: "--rounds-storage"}, + {optionKey: "appeals", overrideKey: "appeals", label: "--appeals"}, +]; + +export interface AddNetworkOptions { + base: string; + deployment?: string; + deploymentKey?: string; + rpc?: string; + consensusMain?: string; + consensusData?: string; + staking?: string; + feeManager?: string; + roundsStorage?: string; + appeals?: string; + chainId?: string; +} + +type NetworkEntry = + | {alias: string; name: string; type: "built-in"} + | {alias: string; name: string; type: "custom"; base: string; profile: CustomNetworkProfile}; + export class NetworkActions extends BaseAction { constructor() { super(); } - async showInfo(): Promise { - const storedNetwork = this.getConfigByKey("network") || "localnet"; - const network = resolveNetwork(storedNetwork); - - const info: Record = { - alias: storedNetwork, - name: network.name, - chainId: network.id?.toString() || "unknown", - rpc: network.rpcUrls?.default?.http?.[0] || "unknown", - mainContract: network.consensusMainContract?.address || "not set", - stakingContract: network.stakingContract?.address || "not set", - }; + async addNetwork(alias: string, options: AddNetworkOptions): Promise { + try { + const customNetworks = this.readCustomNetworks(); + const profile = this.buildCustomNetworkProfile(alias, options, customNetworks); + customNetworks[alias] = profile; + this.writeConfig(CUSTOM_NETWORKS_CONFIG_KEY, customNetworks); - if (network.blockExplorers?.default?.url) { - info.explorer = network.blockExplorers.default.url; + const network = resolveNetwork(alias, customNetworks); + this.succeedSpinner("Custom network profile added", this.formatNetworkInfo(alias, network, profile)); + } catch (error: any) { + this.failSpinner("Failed to add custom network profile", error.message || error); } + } + + async showInfo(): Promise { + const storedNetwork = this.getConfigByKey("network") || "localnet"; + const customNetworks = this.readCustomNetworks(); + const network = resolveNetwork(storedNetwork, customNetworks); + const profile = customNetworks[storedNetwork]; - this.succeedSpinner("Current network", info); + this.succeedSpinner("Current network", this.formatNetworkInfo(storedNetwork, network, profile)); } async listNetworks(): Promise { const currentNetwork = this.getConfigByKey("network") || "localnet"; + const entries = this.getNetworkEntries(); console.log(""); - for (const net of networks) { + for (const net of entries) { const marker = net.alias === currentNetwork ? "*" : " "; - console.log(`${marker} ${net.alias.padEnd(16)} ${net.name}`); + if (net.type === "custom") { + console.log(`${marker} ${net.alias.padEnd(20)} custom base: ${net.base} ${net.name}`); + } else { + console.log(`${marker} ${net.alias.padEnd(20)} built-in ${net.name}`); + } } console.log(""); } async setNetwork(networkName?: string): Promise { + const entries = this.getNetworkEntries(); + if (networkName || networkName === "") { - if (!networks.some(n => n.name === networkName || n.alias === networkName)) { - this.failSpinner(`Network ${networkName} not found`); - return; - } - const selectedNetwork = networks.find(n => n.name === networkName || n.alias === networkName); + const selectedNetwork = entries.find(n => + n.alias === networkName || (n.type === "built-in" && n.name === networkName), + ); if (!selectedNetwork) { this.failSpinner(`Network ${networkName} not found`); return; } this.writeConfig("network", selectedNetwork.alias); - this.succeedSpinner(`Network successfully set to ${selectedNetwork.name}`); + this.succeedSpinner(`Network successfully set to ${this.getNetworkDisplayName(selectedNetwork)}`); return; } @@ -64,14 +116,172 @@ export class NetworkActions extends BaseAction { type: "list", name: "selectedNetwork", message: "Select which network do you want to use:", - choices: networks.map(n => ({name: n.name, value: n.alias})), + choices: entries.map(n => ({ + name: this.getNetworkChoiceName(n), + value: n.alias, + })), }, ]; const networkAnswer = await inquirer.prompt(networkQuestions); const selectedAlias = networkAnswer.selectedNetwork; - const selectedNetwork = networks.find(n => n.alias === selectedAlias)!; + const selectedNetwork = entries.find(n => n.alias === selectedAlias)!; this.writeConfig("network", selectedAlias); - this.succeedSpinner(`Network successfully set to ${selectedNetwork.name}`); + this.succeedSpinner(`Network successfully set to ${this.getNetworkDisplayName(selectedNetwork)}`); + } + + async removeNetwork(alias: string): Promise { + try { + if (BUILT_IN_NETWORKS[alias]) { + throw new Error(`Cannot remove built-in network: ${alias}`); + } + + const customNetworks = this.readCustomNetworks(); + if (!customNetworks[alias]) { + throw new Error(`Custom network ${alias} not found`); + } + + delete customNetworks[alias]; + this.writeConfig(CUSTOM_NETWORKS_CONFIG_KEY, customNetworks); + + if ((this.getConfigByKey("network") || "localnet") === alias) { + this.writeConfig("network", "localnet"); + this.logWarning(`Removed active network ${alias}; active network reset to localnet.`); + } + + this.succeedSpinner(`Custom network ${alias} removed`); + } catch (error: any) { + this.failSpinner("Failed to remove custom network profile", error.message || error); + } + } + + private buildCustomNetworkProfile( + alias: string, + options: AddNetworkOptions, + customNetworks: CustomNetworksConfig, + ): CustomNetworkProfile { + if (BUILT_IN_NETWORKS[alias]) { + throw new Error(`Custom network alias cannot collide with built-in network: ${alias}`); + } + if (customNetworks[alias]) { + throw new Error(`Custom network ${alias} already exists`); + } + if (!options.base || !BUILT_IN_NETWORKS[options.base]) { + throw new Error(`Base network must be one of: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); + } + + const hasOverrideInput = Boolean( + options.deployment || + options.rpc || + options.chainId !== undefined || + CONTRACT_FLAG_OPTIONS.some(option => Boolean(options[option.optionKey])), + ); + if (!hasOverrideInput) { + throw new Error("Provide at least one override: --deployment, --rpc, --chain-id, or a contract address flag"); + } + + const overrides: CustomNetworkOverrides = {}; + if (options.deployment) { + const parsed = parseDeploymentFile(options.deployment, options.deploymentKey); + Object.assign(overrides, parsed.overrides); + for (const notice of parsed.notices) { + this.logInfo(notice); + } + } + + for (const option of CONTRACT_FLAG_OPTIONS) { + const address = options[option.optionKey]; + if (!address) continue; + if (!isValidAddress(address)) { + throw new Error(`Invalid address for ${option.label}: ${address}`); + } + overrides[option.overrideKey] = address as Address; + } + + if (options.rpc) { + overrides.rpcUrl = options.rpc; + } + + if (options.chainId !== undefined) { + const chainId = Number(options.chainId); + if (!Number.isInteger(chainId) || chainId <= 0) { + throw new Error(`Invalid --chain-id value: ${options.chainId}`); + } + overrides.chainId = chainId; + } + + return { + base: options.base, + overrides, + }; + } + + private formatNetworkInfo( + alias: string, + network: GenLayerChain, + profile?: CustomNetworkProfile, + ): Record { + const info: Record = { + alias, + type: profile ? "custom" : "built-in", + }; + + if (profile) { + info.base = profile.base; + } + + info.name = network.name; + info.chainId = this.formatInheritedValue(network.id?.toString() || "unknown", Boolean(profile?.overrides.chainId), profile); + info.rpc = this.formatInheritedValue(network.rpcUrls?.default?.http?.[0] || "unknown", Boolean(profile?.overrides.rpcUrl), profile); + + for (const contract of CONTRACT_OVERRIDES) { + const address = getContractAddress(network, contract.overrideKey) || "not set"; + info[contract.label] = this.formatInheritedValue(address, Boolean(profile?.overrides[contract.overrideKey]), profile); + } + + if (network.blockExplorers?.default?.url) { + info.explorer = network.blockExplorers.default.url; + } + + return info; + } + + private formatInheritedValue(value: string, overridden: boolean, profile?: CustomNetworkProfile): string { + if (!profile) return value; + return `${value} (${overridden ? "overridden" : "inherited"})`; + } + + private getNetworkEntries(): NetworkEntry[] { + const customNetworks = this.readCustomNetworks(); + const customEntries = Object.entries(customNetworks).map(([alias, profile]) => { + const network = resolveNetwork(alias, customNetworks); + return { + alias, + name: network.name, + type: "custom" as const, + base: profile.base, + profile, + }; + }); + + return [...builtInNetworks, ...customEntries]; + } + + private getNetworkChoiceName(entry: NetworkEntry): string { + if (entry.type === "custom") { + return `${entry.alias} (custom, base: ${entry.base})`; + } + return entry.name; + } + + private getNetworkDisplayName(entry: NetworkEntry): string { + if (entry.type === "custom") { + return `${entry.alias} (custom)`; + } + return entry.name; + } + + private readCustomNetworks(): CustomNetworksConfig { + return normalizeCustomNetworks(this.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); } } diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 5eb6b067..ab78e8c9 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -52,14 +52,10 @@ export class StakingAction extends BaseAction { private getNetwork(config: StakingConfig): GenLayerChain { // Priority: --network option > global config > localnet default if (config.network) { - const network = BUILT_IN_NETWORKS[config.network]; - if (!network) { - throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); - } - return {...network}; + return {...resolveNetwork(config.network, this.getCustomNetworks())}; } - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } protected async getStakingClient(config: StakingConfig): Promise> { diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index c2252f30..b5b097eb 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -40,7 +40,7 @@ export function initializeStakingCommands(program: Command) { .option("--operator
", "Operator address (defaults to signer)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: ValidatorJoinOptions) => { @@ -55,7 +55,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--amount ", "Amount to deposit (in wei or with 'eth'/'gen' suffix)") .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { const validator = validatorArg || options.validator; @@ -74,7 +74,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--shares ", "Number of shares to withdraw") .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: ValidatorExitOptions) => { const validator = validatorArg || options.validator; @@ -92,7 +92,7 @@ export function initializeStakingCommands(program: Command) { .option("--validator
", "Validator wallet contract address (deprecated, use positional arg)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: ValidatorClaimOptions) => { const validator = validatorArg || options.validator; @@ -110,7 +110,7 @@ export function initializeStakingCommands(program: Command) { .option("--validator
", "Validator address to prime (deprecated, use positional arg)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: ValidatorPrimeOptions) => { @@ -128,7 +128,7 @@ export function initializeStakingCommands(program: Command) { .description("Prime all validators that need priming") .option("--account ", "Account to use (pays gas)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingConfig) => { @@ -143,7 +143,7 @@ export function initializeStakingCommands(program: Command) { .option("--operator
", "New operator address (deprecated, use positional arg)") .option("--account ", "Account to use (must be validator owner)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, operatorArg: string | undefined, options: SetOperatorOptions) => { const validator = validatorArg || options.validator; @@ -171,7 +171,7 @@ export function initializeStakingCommands(program: Command) { .option("--extra-cid ", "Extra data as IPFS CID or hex bytes (0x...)") .option("--account ", "Account to use (must be validator operator)") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .action(async (validatorArg: string | undefined, options: SetIdentityOptions) => { const validator = validatorArg || options.validator; @@ -191,7 +191,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--amount ", "Amount to stake (in wei or with 'eth'/'gen' suffix)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: DelegatorJoinOptions) => { @@ -211,7 +211,7 @@ export function initializeStakingCommands(program: Command) { .requiredOption("--shares ", "Number of shares to withdraw") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: DelegatorExitOptions) => { @@ -231,7 +231,7 @@ export function initializeStakingCommands(program: Command) { .option("--delegator
", "Delegator address (defaults to signer)") .option("--account ", "Account to use") .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: DelegatorClaimOptions) => { @@ -250,7 +250,7 @@ export function initializeStakingCommands(program: Command) { .description("Get information about a validator") .option("--validator
", "Validator address (deprecated, use positional arg)") .option("--account ", "Account to use (for default validator address)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .option("--debug", "Show raw unfiltered pending deposits/withdrawals") @@ -266,7 +266,7 @@ export function initializeStakingCommands(program: Command) { .option("--validator
", "Validator address (deprecated, use positional arg)") .option("--delegator
", "Delegator address (defaults to signer)") .option("--account ", "Account to use (for default delegator address)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: StakingInfoOptions & {delegator?: string}) => { @@ -283,7 +283,7 @@ export function initializeStakingCommands(program: Command) { .command("epoch-info") .description("Get current epoch and staking parameters") .option("--epoch ", "Show data for specific epoch (current or previous only)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions & {epoch?: string}) => { @@ -294,7 +294,7 @@ export function initializeStakingCommands(program: Command) { staking .command("active-validators") .description("List all active validators") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions) => { @@ -305,7 +305,7 @@ export function initializeStakingCommands(program: Command) { staking .command("quarantined-validators") .description("List all quarantined validators") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions) => { @@ -316,7 +316,7 @@ export function initializeStakingCommands(program: Command) { staking .command("banned-validators") .description("List all banned validators") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (options: StakingInfoOptions) => { @@ -331,7 +331,7 @@ export function initializeStakingCommands(program: Command) { .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 ", "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: ValidatorsOptions) => { @@ -349,7 +349,7 @@ export function initializeStakingCommands(program: Command) { .option("--all", "Fetch complete history from genesis (slow)") .option("--limit ", "Maximum number of events to show (default: 50)") .option("--account ", "Account to use (for default validator address)") - .option("--network ", "Network to use (localnet, testnet-asimov)") + .option("--network ", "built-in or custom network alias (see: genlayer network list)") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") .action(async (validatorArg: string | undefined, options: ValidatorHistoryOptions) => { diff --git a/src/commands/staking/validatorHistory.ts b/src/commands/staking/validatorHistory.ts index 6858daf5..aed86ad3 100644 --- a/src/commands/staking/validatorHistory.ts +++ b/src/commands/staking/validatorHistory.ts @@ -1,4 +1,5 @@ -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, getContract} from "viem"; import Table from "cli-table3"; @@ -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 { diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts index cfd8617e..e1da318e 100644 --- a/src/commands/staking/validators.ts +++ b/src/commands/staking/validators.ts @@ -1,5 +1,5 @@ import {resolveNetwork} from "../../lib/actions/BaseAction"; -import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address, GenLayerChain, ValidatorInfo} from "genlayer-js/types"; import Table from "cli-table3"; import chalk from "chalk"; @@ -318,14 +318,10 @@ export class ValidatorsAction extends StakingAction { private resolveExplorerNetwork(config: StakingConfig): GenLayerChain { if (config.network) { - const network = BUILT_IN_NETWORKS[config.network]; - if (!network) { - throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); - } - return network; + return resolveNetwork(config.network, this.getCustomNetworks()); } - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } private getDefaultExplorerUrl(options: ValidatorsOptions): string | undefined { diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 00452588..d7137b8a 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -1,4 +1,5 @@ import {StakingAction, StakingConfig, BUILT_IN_NETWORKS} from "./StakingAction"; +import {resolveNetwork} from "../../lib/actions/BaseAction"; import {CreateAccountAction} from "../account/create"; import {ExportAccountAction} from "../account/export"; import inquirer from "inquirer"; @@ -187,10 +188,7 @@ export class ValidatorWizardAction extends StakingAction { console.log("-------------------------\n"); if (options.network) { - const network = BUILT_IN_NETWORKS[options.network]; - if (!network) { - this.failSpinner(`Unknown network: ${options.network}`); - } + const network = resolveNetwork(options.network, this.getCustomNetworks()); state.networkAlias = options.network; this.writeConfig("network", options.network); console.log(`Using network: ${network.name}\n`); @@ -228,7 +226,7 @@ export class ValidatorWizardAction extends StakingAction { this.startSpinner("Checking balance and staking requirements..."); - const network = BUILT_IN_NETWORKS[state.networkAlias!]; + const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); const client = createClient({ chain: network, account: state.accountAddress as Address, @@ -780,7 +778,7 @@ export class ValidatorWizardAction extends StakingAction { } console.log(` Staked Amount: ${state.stakeAmount}`); - console.log(` Network: ${BUILT_IN_NETWORKS[state.networkAlias].name}`); + console.log(` Network: ${resolveNetwork(state.networkAlias, this.getCustomNetworks()).name}`); if (state.identity) { console.log(` Identity:`); diff --git a/src/commands/vesting/VestingAction.ts b/src/commands/vesting/VestingAction.ts index ba0d1414..8e1627d0 100644 --- a/src/commands/vesting/VestingAction.ts +++ b/src/commands/vesting/VestingAction.ts @@ -27,14 +27,10 @@ export class VestingAction extends BaseAction { private getNetwork(config: VestingConfig): GenLayerChain { if (config.network) { - const network = BUILT_IN_NETWORKS[config.network]; - if (!network) { - throw new Error(`Unknown network: ${config.network}. Available: ${Object.keys(BUILT_IN_NETWORKS).join(", ")}`); - } - return {...network}; + return {...resolveNetwork(config.network, this.getCustomNetworks())}; } - return resolveNetwork(this.getConfig().network); + return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } protected async getVestingClient(config: VestingConfig): Promise { diff --git a/src/commands/vesting/index.ts b/src/commands/vesting/index.ts index 25aba95c..c73ef39f 100644 --- a/src/commands/vesting/index.ts +++ b/src/commands/vesting/index.ts @@ -20,7 +20,7 @@ import {VestingValidatorListAction, VestingValidatorListOptions} from "./validat function addReadOptions(command: Command): Command { return command .option("--account ", "Account to use (for default beneficiary 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("--factory
", "VestingFactory address (overrides AddressManager lookup)") .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); @@ -31,7 +31,7 @@ function addWriteOptions(command: Command): 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 ", "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("--factory
", "VestingFactory address (overrides AddressManager lookup)") .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); diff --git a/src/commands/vesting/validatorCreate.ts b/src/commands/vesting/validatorCreate.ts index 94d481bf..0b76aaf0 100644 --- a/src/commands/vesting/validatorCreate.ts +++ b/src/commands/vesting/validatorCreate.ts @@ -27,10 +27,22 @@ export class VestingValidatorCreateAction extends VestingAction { 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: result.validatorWallet || result.wallet, + validatorWallet, operator: result.operator || options.operator, amount: result.amount || this.formatAmount(amount), blockNumber: result.blockNumber.toString(), diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index d555a6dd..92a0d665 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -7,6 +7,12 @@ import { inspect } from "util"; import {createClient, createAccount} from "genlayer-js"; import {localnet, studionet, testnetAsimov, testnetBradbury} from "genlayer-js/chains"; import type {GenLayerClient, GenLayerChain, Hash, Address, Account} from "genlayer-js/types"; +import { + applyCustomNetworkProfile, + CUSTOM_NETWORKS_CONFIG_KEY, + normalizeCustomNetworks, + type CustomNetworksConfig, +} from "../networks/customNetworks"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -20,7 +26,7 @@ export const BUILT_IN_NETWORKS: Record = { * Resolves a stored network config to a fresh chain object. * Handles both new format (alias string) and old format (JSON object) for backwards compat. */ -export function resolveNetwork(stored: string | undefined): GenLayerChain { +export function resolveNetwork(stored: string | undefined, customNetworks?: CustomNetworksConfig): GenLayerChain { if (!stored) return localnet; // Try as alias first (new format) @@ -28,6 +34,15 @@ export function resolveNetwork(stored: string | undefined): GenLayerChain { return BUILT_IN_NETWORKS[stored]; } + const customNetwork = customNetworks?.[stored]; + if (customNetwork) { + const baseNetwork = BUILT_IN_NETWORKS[customNetwork.base]; + if (!baseNetwork) { + throw new Error(`Custom network ${stored} references unknown base network: ${customNetwork.base}`); + } + return applyCustomNetworkProfile(baseNetwork, customNetwork); + } + // Backwards compat: try parsing as JSON (old format) try { const parsed = JSON.parse(stored); @@ -62,6 +77,10 @@ export class BaseAction extends ConfigFileManager { this.keychainManager = new KeychainManager(); } + protected getCustomNetworks(): CustomNetworksConfig { + return normalizeCustomNetworks(this.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); + } + private async decryptKeystore(keystoreJson: string, attempt: number = 1): Promise { try { const message = attempt === 1 @@ -97,7 +116,7 @@ export class BaseAction extends ConfigFileManager { protected async getClient(rpcUrl?: string, readOnly: boolean = false): Promise> { if (!this._genlayerClient) { - const network = resolveNetwork(this.getConfig().network); + const network = resolveNetwork(this.getConfig().network, this.getCustomNetworks()); const account = await this.getAccount(readOnly); this._genlayerClient = createClient({ chain: network, @@ -298,4 +317,4 @@ export class BaseAction extends ConfigFileManager { protected setSpinnerText(message: string): void { this.spinner.text = chalk.blue(message); } -} \ No newline at end of file +} diff --git a/src/lib/networks/customNetworks.ts b/src/lib/networks/customNetworks.ts new file mode 100644 index 00000000..d3f815fb --- /dev/null +++ b/src/lib/networks/customNetworks.ts @@ -0,0 +1,253 @@ +import {readFileSync} from "fs"; +import type {Address, GenLayerChain} from "genlayer-js/types"; + +export const CUSTOM_NETWORKS_CONFIG_KEY = "customNetworks"; +export const ADDRESS_REGEX = /^0x[0-9a-fA-F]{40}$/; + +export type ContractOverrideKey = + | "consensusMain" + | "consensusData" + | "staking" + | "feeManager" + | "roundsStorage" + | "appeals"; + +export interface CustomNetworkOverrides { + rpcUrl?: string; + chainId?: number; + consensusMain?: Address; + consensusData?: Address; + staking?: Address; + feeManager?: Address; + roundsStorage?: Address; + appeals?: Address; +} + +export interface CustomNetworkProfile { + base: string; + overrides: CustomNetworkOverrides; +} + +export type CustomNetworksConfig = Record; + +export const CONTRACT_OVERRIDES: Array<{ + overrideKey: ContractOverrideKey; + chainField: keyof GenLayerChain; + label: string; +}> = [ + {overrideKey: "consensusMain", chainField: "consensusMainContract", label: "consensusMain"}, + {overrideKey: "consensusData", chainField: "consensusDataContract", label: "consensusData"}, + {overrideKey: "staking", chainField: "stakingContract", label: "staking"}, + {overrideKey: "feeManager", chainField: "feeManagerContract", label: "feeManager"}, + {overrideKey: "roundsStorage", chainField: "roundsStorageContract", label: "roundsStorage"}, + {overrideKey: "appeals", chainField: "appealsContract", label: "appeals"}, +]; + +const DEPLOYMENT_KEY_TO_OVERRIDE: Record = { + ConsensusMain: "consensusMain", + ConsensusData: "consensusData", + GenStaking: "staking", + Staking: "staking", + FeeManager: "feeManager", + Rounds: "roundsStorage", + RoundsStorage: "roundsStorage", + Appeals: "appeals", +}; + +const CONSENSUS_MAIN_WITH_FEES = "ConsensusMainWithFees"; +const SCANNED_DEPLOYMENT_KEYS = new Set([ + ...Object.keys(DEPLOYMENT_KEY_TO_OVERRIDE), + CONSENSUS_MAIN_WITH_FEES, +]); + +interface FoundDeploymentAddress { + path: string; + address: string; +} + +export interface ParsedDeploymentOverrides { + overrides: Partial>; + notices: string[]; +} + +export function normalizeCustomNetworks(value: unknown): CustomNetworksConfig { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return {}; + } + return value as CustomNetworksConfig; +} + +export function isValidAddress(value: string): value is Address { + return ADDRESS_REGEX.test(value); +} + +export function parseDeploymentFile(filePath: string, deploymentKey?: string): ParsedDeploymentOverrides { + const content = readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(content); + return parseDeploymentObject(parsed, deploymentKey); +} + +export function parseDeploymentObject(input: unknown, deploymentKey?: string): ParsedDeploymentOverrides { + const selected = deploymentKey ? selectDeploymentObject(input, deploymentKey) : input; + if (!selected || typeof selected !== "object" || Array.isArray(selected)) { + throw new Error("Deployment selection must be a JSON object"); + } + + const found: Record = {}; + walkDeploymentObject(selected, [], found); + + for (const [contractName, entries] of Object.entries(found)) { + if (entries.length > 1) { + const paths = entries.map(entry => entry.path).join(", "); + throw new Error( + `Ambiguous ${contractName} entries found at ${paths}. ` + + "Pass --deployment-key to select one deployment.", + ); + } + } + + const overrides: ParsedDeploymentOverrides["overrides"] = {}; + const fieldSources: Partial> = {}; + + for (const [contractName, entries] of Object.entries(found)) { + const entry = entries[0]; + if (!entry) continue; + if (!isValidAddress(entry.address)) { + throw new Error(`Invalid address for ${contractName} at ${entry.path}: ${entry.address}`); + } + if (contractName === CONSENSUS_MAIN_WITH_FEES) { + continue; + } + + const overrideKey = DEPLOYMENT_KEY_TO_OVERRIDE[contractName]; + if (!overrideKey) continue; + if (fieldSources[overrideKey]) { + throw new Error( + `Multiple deployment entries map to ${overrideKey}: ${fieldSources[overrideKey]} and ${contractName}. ` + + `Use an explicit --${toFlagName(overrideKey)} override.`, + ); + } + overrides[overrideKey] = entry.address as Address; + fieldSources[overrideKey] = contractName; + } + + const notices: string[] = []; + if (found.ConsensusMain?.length && found[CONSENSUS_MAIN_WITH_FEES]?.length) { + notices.push( + "ConsensusMainWithFees exists in the deployment file; using ConsensusMain. " + + "Use --consensus-main to choose ConsensusMainWithFees.", + ); + } + + return {overrides, notices}; +} + +export function applyCustomNetworkProfile( + baseChain: GenLayerChain, + profile: CustomNetworkProfile, +): GenLayerChain { + const chain = cloneChain(baseChain); + const overrides = profile.overrides || {}; + + if (overrides.chainId !== undefined) { + (chain as any).id = overrides.chainId; + } + + if (overrides.rpcUrl) { + const rpcUrls = (chain.rpcUrls || {}) as any; + const defaultRpc = rpcUrls.default || {}; + const currentHttp = Array.isArray(defaultRpc.http) ? defaultRpc.http : []; + (chain as any).rpcUrls = { + ...rpcUrls, + default: { + ...defaultRpc, + http: [overrides.rpcUrl, ...currentHttp.slice(1)], + }, + }; + } + + for (const contract of CONTRACT_OVERRIDES) { + const address = overrides[contract.overrideKey]; + if (!address) continue; + const current = (chain as any)[contract.chainField]; + (chain as any)[contract.chainField] = { + ...(current || {}), + address, + }; + } + + return chain; +} + +export function getContractAddress(chain: GenLayerChain, overrideKey: ContractOverrideKey): string | undefined { + const contract = CONTRACT_OVERRIDES.find(item => item.overrideKey === overrideKey); + if (!contract) return undefined; + return (chain as any)[contract.chainField]?.address; +} + +function selectDeploymentObject(input: unknown, deploymentKey: string): unknown { + const segments = deploymentKey.split(".").filter(Boolean); + if (!segments.length) { + throw new Error("--deployment-key must not be empty"); + } + + let selected = input as any; + for (const segment of segments) { + if (!selected || typeof selected !== "object" || Array.isArray(selected) || !(segment in selected)) { + throw new Error(`Deployment key not found: ${deploymentKey}`); + } + selected = selected[segment]; + } + + return selected; +} + +function walkDeploymentObject( + node: Record, + path: string[], + found: Record, +): void { + for (const [key, value] of Object.entries(node)) { + const nextPath = [...path, key]; + if (typeof value === "string" && SCANNED_DEPLOYMENT_KEYS.has(key)) { + if (!found[key]) found[key] = []; + found[key].push({path: nextPath.join("."), address: value}); + continue; + } + if (value && typeof value === "object" && !Array.isArray(value)) { + walkDeploymentObject(value as Record, nextPath, found); + } + } +} + +function cloneChain(baseChain: GenLayerChain): GenLayerChain { + const chain = {...baseChain} as any; + + if (baseChain.rpcUrls) { + chain.rpcUrls = {}; + for (const [key, value] of Object.entries(baseChain.rpcUrls as any)) { + chain.rpcUrls[key] = value && typeof value === "object" + ? { + ...value, + http: Array.isArray((value as any).http) ? [...(value as any).http] : (value as any).http, + } + : value; + } + } + + for (const contract of CONTRACT_OVERRIDES) { + const current = (baseChain as any)[contract.chainField]; + if (current) { + chain[contract.chainField] = {...current}; + } + } + + return chain as GenLayerChain; +} + +function toFlagName(overrideKey: ContractOverrideKey): string { + return overrideKey.replace(/[A-Z]/g, match => `-${match.toLowerCase()}`); +} diff --git a/tests/actions/customNetworkProfiles.test.ts b/tests/actions/customNetworkProfiles.test.ts new file mode 100644 index 00000000..a9e6f0f8 --- /dev/null +++ b/tests/actions/customNetworkProfiles.test.ts @@ -0,0 +1,297 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from "vitest"; +import fs from "fs"; +import os from "os"; +import path from "path"; +import {tmpdir} from "os"; +import {NetworkActions} from "../../src/commands/network/setNetwork"; +import {resolveNetwork} from "../../src/lib/actions/BaseAction"; +import {parseDeploymentObject} from "../../src/lib/networks/customNetworks"; +import {testnetBradbury} from "genlayer-js/chains"; +import {StakingAction} from "../../src/commands/staking/StakingAction"; + +const ADDR_1 = "0x1111111111111111111111111111111111111111"; +const ADDR_2 = "0x2222222222222222222222222222222222222222"; +const ADDR_3 = "0x3333333333333333333333333333333333333333"; +const ADDR_4 = "0x4444444444444444444444444444444444444444"; +const ADDR_5 = "0x5555555555555555555555555555555555555555"; +const ADDR_6 = "0x6666666666666666666666666666666666666666"; +const ADDR_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + +describe("custom network profiles", () => { + let tempHome: string; + let action: NetworkActions; + let succeedSpy: any; + let failSpy: any; + let warningSpy: any; + let infoSpy: any; + let consoleSpy: any; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(tmpdir(), "genlayer-custom-network-")); + vi.spyOn(os, "homedir").mockReturnValue(tempHome); + action = new NetworkActions(); + succeedSpy = vi.spyOn(action as any, "succeedSpinner").mockImplementation(() => {}); + failSpy = vi.spyOn(action as any, "failSpinner").mockImplementation(() => {}); + warningSpy = vi.spyOn(action as any, "logWarning").mockImplementation(() => {}); + infoSpy = vi.spyOn(action as any, "logInfo").mockImplementation(() => {}); + consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + fs.rmSync(tempHome, {recursive: true, force: true}); + }); + + test("network add stores flags-only overrides", async () => { + await action.addNetwork("bradbury-clarke", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + consensusMain: ADDR_1, + chainId: "4222", + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-clarke"]).toEqual({ + base: "testnet-bradbury", + overrides: { + consensusMain: ADDR_1, + rpcUrl: "http://localhost:9999", + chainId: 4222, + }, + }); + expect(succeedSpy).toHaveBeenCalledWith( + "Custom network profile added", + expect.objectContaining({ + alias: "bradbury-clarke", + base: "testnet-bradbury", + consensusMain: `${ADDR_1} (overridden)`, + rpc: "http://localhost:9999 (overridden)", + }), + ); + }); + + test("network add sources overrides from a deployment file", async () => { + const deploymentPath = writeDeployment({ + genlayerTestnet: { + deployment_x: { + ConsensusMain: ADDR_1, + ConsensusData: ADDR_2, + GenStaking: ADDR_3, + FeeManager: ADDR_4, + Rounds: ADDR_5, + Appeals: ADDR_6, + }, + }, + }); + + await action.addNetwork("bradbury-deployment", { + base: "testnet-bradbury", + deployment: deploymentPath, + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-deployment"].overrides).toEqual({ + consensusMain: ADDR_1, + consensusData: ADDR_2, + staking: ADDR_3, + feeManager: ADDR_4, + roundsStorage: ADDR_5, + appeals: ADDR_6, + }); + }); + + test("network add gives address flags precedence over deployment file", async () => { + const deploymentPath = writeDeployment({ + deployment_x: { + ConsensusMain: ADDR_1, + ConsensusData: ADDR_2, + }, + }); + + await action.addNetwork("bradbury-precedence", { + base: "testnet-bradbury", + deployment: deploymentPath, + consensusMain: ADDR_A, + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks["bradbury-precedence"].overrides).toEqual({ + consensusMain: ADDR_A, + consensusData: ADDR_2, + }); + }); + + test("network add validates base, alias, addresses, and override presence", async () => { + await action.addNetwork("localnet", {base: "testnet-bradbury", rpc: "http://localhost:9999"}); + await action.addNetwork("bad-base", {base: "missing", rpc: "http://localhost:9999"}); + await action.addNetwork("bad-address", {base: "testnet-bradbury", consensusMain: "0x123"}); + await action.addNetwork("empty", {base: "testnet-bradbury"}); + + expect(failSpy).toHaveBeenNthCalledWith( + 1, + "Failed to add custom network profile", + "Custom network alias cannot collide with built-in network: localnet", + ); + expect(failSpy).toHaveBeenNthCalledWith( + 2, + "Failed to add custom network profile", + "Base network must be one of: localnet, studionet, testnet-asimov, testnet-bradbury", + ); + expect(failSpy).toHaveBeenNthCalledWith( + 3, + "Failed to add custom network profile", + "Invalid address for --consensus-main: 0x123", + ); + expect(failSpy).toHaveBeenNthCalledWith( + 4, + "Failed to add custom network profile", + "Provide at least one override: --deployment, --rpc, --chain-id, or a contract address flag", + ); + }); + + test("network add rejects ambiguous deployment contract names", async () => { + const deploymentPath = writeDeployment({ + net_a: {deployment: {ConsensusMain: ADDR_1}}, + net_b: {deployment: {ConsensusMain: ADDR_2}}, + }); + + await action.addNetwork("ambiguous", { + base: "testnet-bradbury", + deployment: deploymentPath, + }); + + expect(failSpy).toHaveBeenCalledWith( + "Failed to add custom network profile", + expect.stringContaining("Pass --deployment-key "), + ); + }); + + test("network add supports --deployment-key", async () => { + const deploymentPath = writeDeployment({ + net_a: {deployment: {ConsensusMain: ADDR_1}}, + net_b: {deployment: {ConsensusMain: ADDR_2}}, + }); + + await action.addNetwork("keyed", { + base: "testnet-bradbury", + deployment: deploymentPath, + deploymentKey: "net_b.deployment", + }); + + expect(failSpy).not.toHaveBeenCalled(); + expect(readConfig().customNetworks.keyed.overrides).toEqual({ + consensusMain: ADDR_2, + }); + }); + + test("network set, list, info, and remove handle custom profiles", async () => { + await action.addNetwork("bradbury-clarke", { + base: "testnet-bradbury", + rpc: "http://localhost:9999", + consensusMain: ADDR_1, + }); + succeedSpy.mockClear(); + + await action.setNetwork("bradbury-clarke"); + expect(readConfig().network).toBe("bradbury-clarke"); + expect(succeedSpy).toHaveBeenCalledWith("Network successfully set to bradbury-clarke (custom)"); + + await action.listNetworks(); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("bradbury-clarke")); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("custom base: testnet-bradbury")); + + succeedSpy.mockClear(); + await action.showInfo(); + expect(succeedSpy).toHaveBeenCalledWith( + "Current network", + expect.objectContaining({ + alias: "bradbury-clarke", + type: "custom", + base: "testnet-bradbury", + rpc: "http://localhost:9999 (overridden)", + consensusMain: `${ADDR_1} (overridden)`, + consensusData: expect.stringContaining("(inherited)"), + }), + ); + + await action.removeNetwork("bradbury-clarke"); + expect(warningSpy).toHaveBeenCalledWith("Removed active network bradbury-clarke; active network reset to localnet."); + expect(readConfig().network).toBe("localnet"); + expect(readConfig().customNetworks["bradbury-clarke"]).toBeUndefined(); + }); + + test("network remove refuses built-ins", async () => { + await action.removeNetwork("localnet"); + + expect(failSpy).toHaveBeenCalledWith( + "Failed to remove custom network profile", + "Cannot remove built-in network: localnet", + ); + }); + + test("deployment parser notices ConsensusMainWithFees when ConsensusMain is present", () => { + const parsed = parseDeploymentObject({ + deployment: { + ConsensusMain: ADDR_1, + ConsensusMainWithFees: ADDR_2, + }, + }); + + expect(parsed.overrides.consensusMain).toBe(ADDR_1); + expect(parsed.notices[0]).toContain("ConsensusMainWithFees exists"); + }); + + test("resolveNetwork applies custom overrides while retaining base ABI objects", () => { + const resolved = resolveNetwork("bradbury-clarke", { + "bradbury-clarke": { + base: "testnet-bradbury", + overrides: { + consensusMain: ADDR_1, + rpcUrl: "http://localhost:9999", + chainId: 4222, + }, + }, + }); + + const resolvedChain = resolved as any; + const baseChain = testnetBradbury as any; + expect(resolved).not.toBe(testnetBradbury); + expect(resolvedChain.id).toBe(4222); + expect(resolvedChain.rpcUrls.default.http[0]).toBe("http://localhost:9999"); + expect(resolvedChain.consensusMainContract.address).toBe(ADDR_1); + expect(resolvedChain.consensusMainContract.abi).toBe(baseChain.consensusMainContract.abi); + expect(resolvedChain.consensusDataContract.abi).toBe(baseChain.consensusDataContract.abi); + }); + + test("StakingAction.getNetwork accepts a custom alias", () => { + const stakingAction = new StakingAction(); + vi.spyOn(stakingAction as any, "getConfigByKey").mockImplementation((key: string) => { + if (key === "customNetworks") { + return { + "bradbury-clarke": { + base: "testnet-bradbury", + overrides: { + staking: ADDR_3, + }, + }, + }; + } + return null; + }); + + const network = (stakingAction as any).getNetwork({network: "bradbury-clarke"}); + + expect(network.stakingContract.address).toBe(ADDR_3); + expect(network.stakingContract.abi).toBe((testnetBradbury as any).stakingContract.abi); + }); + + function writeDeployment(content: unknown): string { + const deploymentPath = path.join(tempHome, "deployment.json"); + fs.writeFileSync(deploymentPath, JSON.stringify(content, null, 2)); + return deploymentPath; + } + + function readConfig(): Record { + return JSON.parse(fs.readFileSync(path.join(tempHome, ".genlayer", "genlayer-config.json"), "utf-8")); + } +}); diff --git a/tests/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"); + }); }); From a4f8a63ae9c945ecf99a707e700857d6f7729df7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 22:29:54 +0100 Subject: [PATCH 14/45] fix: make git install build lifecycle robust (#363) * fix: make git install build script self contained * chore: refresh genlayer-js lockfile * fix: make keychain dependency optional * fix: restore git prepare build * fix: include build dependency for git installs * chore: keep esbuild as dev dependency --- package-lock.json | 48 +++++++++++++++++++---------------------- package.json | 10 +++++---- scripts/run-esbuild.mjs | 12 +++++++++++ 3 files changed, 40 insertions(+), 30 deletions(-) create mode 100644 scripts/run-esbuild.mjs diff --git a/package-lock.json b/package-lock.json index a09354be..38a4e4b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "fs-extra": "^11.3.0", "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", - "keytar": "^7.9.0", "node-fetch": "^3.0.0", "open": "^10.1.0", "ora": "^8.2.0", @@ -42,7 +41,6 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^3.0.0", - "cross-env": "^7.0.3", "esbuild": ">=0.25.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", @@ -53,6 +51,9 @@ "release-it": "^19.0.0", "ts-node": "^10.9.2", "typescript": "^5.4.5" + }, + "optionalDependencies": { + "keytar": "^7.9.0" } }, "node_modules/@adraffy/ens-normalize": { @@ -4066,25 +4067,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -4216,6 +4198,7 @@ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", "license": "MIT", + "optional": true, "dependencies": { "mimic-response": "^3.1.0" }, @@ -4358,6 +4341,7 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", "license": "Apache-2.0", + "optional": true, "engines": { "node": ">=8" } @@ -5279,6 +5263,7 @@ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", "license": "(MIT OR WTFPL)", + "optional": true, "engines": { "node": ">=6" } @@ -5588,7 +5573,7 @@ }, "node_modules/genlayer-js": { "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#666d1156c3c136bf79916c53b9764ff3a2a87a5c", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#57889281db1e34df49abcf6129ffe6f347e4de4d", "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -5799,7 +5784,8 @@ "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/glob": { "version": "10.4.5", @@ -6961,6 +6947,7 @@ "integrity": "sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==", "hasInstallScript": true, "license": "MIT", + "optional": true, "dependencies": { "node-addon-api": "^4.3.0", "prebuild-install": "^7.0.1" @@ -7267,6 +7254,7 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", "license": "MIT", + "optional": true, "engines": { "node": ">=10" }, @@ -7359,7 +7347,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/napi-postinstall": { "version": "0.3.3", @@ -7434,6 +7423,7 @@ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz", "integrity": "sha512-OhYaY5sDsIka7H7AtijtI9jwGYLyl29eQn/W623DiN/MIv5sUqc4g7BIDThX+gb7di9f6xK02nkp8sdfFWZLTg==", "license": "MIT", + "optional": true, "dependencies": { "semver": "^7.3.5" }, @@ -7445,7 +7435,8 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/node-domexception": { "version": "1.0.0", @@ -8127,6 +8118,7 @@ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", "license": "MIT", + "optional": true, "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -8809,6 +8801,7 @@ "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "devOptional": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -8992,7 +8985,8 @@ "url": "https://feross.org/support" } ], - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/simple-get": { "version": "4.0.1", @@ -9013,6 +9007,7 @@ } ], "license": "MIT", + "optional": true, "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", @@ -9697,6 +9692,7 @@ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", "license": "Apache-2.0", + "optional": true, "dependencies": { "safe-buffer": "^5.0.1" }, diff --git a/package.json b/package.json index 405ab3d0..1e8b9a90 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,10 @@ "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage", "test:smoke": "vitest run --config vitest.smoke.config.ts", - "dev": "cross-env NODE_ENV=development node esbuild.config.js", - "build": "cross-env NODE_ENV=production node esbuild.config.js", + "dev": "node scripts/run-esbuild.mjs development", + "build": "node scripts/run-esbuild.mjs production", "prepare": "npm run build", + "prepack": "npm run build", "release": "./scripts/release.sh", "postinstall": "node ./scripts/postinstall.js", "docs:cli": "node scripts/generate-cli-docs.mjs" @@ -47,7 +48,6 @@ "@typescript-eslint/eslint-plugin": "^8.0.0", "@typescript-eslint/parser": "^8.0.0", "@vitest/coverage-v8": "^3.0.0", - "cross-env": "^7.0.3", "esbuild": ">=0.25.0", "eslint": "^9.0.0", "eslint-config-prettier": "^10.0.0", @@ -69,7 +69,6 @@ "fs-extra": "^11.3.0", "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", - "keytar": "^7.9.0", "node-fetch": "^3.0.0", "open": "^10.1.0", "ora": "^8.2.0", @@ -78,6 +77,9 @@ "viem": "^2.21.54", "vitest": "^3.0.0" }, + "optionalDependencies": { + "keytar": "^7.9.0" + }, "overrides": { "vite": { "rollup": "npm:@rollup/wasm-node" diff --git a/scripts/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'); From 841e35ed1d07703998d62923fca2fd7b29ef83a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 22:52:33 +0100 Subject: [PATCH 15/45] ci: publish prereleases to npm dist tags (#364) * ci: add clarke cli tarball release * ci: publish prereleases to npm dist tags --- .github/workflows/publish.yml | 50 ++++++++++++++++++++++++++++++++--- 1 file changed, 47 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a8c7dea2..e5e27ca3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,6 +7,11 @@ name: Publish Package to NPM # workflow fires on the tag push, sanity-checks the tag matches # package.json, builds, publishes to npm, and creates the GitHub # Release. It never bumps or tags by itself. +# +# Stable tags such as v0.39.2 publish to npm's latest dist-tag. +# Prerelease tags such as v0.40.0-rc1 publish to a matching prerelease +# dist-tag (rc1) and create a GitHub prerelease. Prereleases must never +# become latest. on: workflow_dispatch: push: @@ -32,8 +37,14 @@ jobs: - run: npm ci - - name: Verify tag matches package.json version + - name: Verify tag and resolve npm dist-tag + id: version run: | + if [ "${GITHUB_REF_TYPE:-}" != "tag" ]; then + echo "Publish must run from a git tag. Current ref type: ${GITHUB_REF_TYPE:-unknown}" >&2 + exit 1 + fi + TAG_VERSION="${GITHUB_REF_NAME#v}" PKG_VERSION="$(node -p "require('./package.json').version")" if [ "$TAG_VERSION" != "$PKG_VERSION" ]; then @@ -41,12 +52,34 @@ jobs: echo "Re-cut the release via scripts/release.sh so the tag and the committed version match." >&2 exit 1 fi + + IS_PRERELEASE=false + NPM_DIST_TAG=latest + if [[ "$TAG_VERSION" == *-* ]]; then + IS_PRERELEASE=true + NPM_DIST_TAG="${TAG_VERSION#*-}" + NPM_DIST_TAG="${NPM_DIST_TAG%%+*}" + + if [ -z "$NPM_DIST_TAG" ] || [ "$NPM_DIST_TAG" = "latest" ]; then + echo "Invalid prerelease npm dist-tag: '$NPM_DIST_TAG'" >&2 + exit 1 + fi + + if [[ "$NPM_DIST_TAG" =~ ^v?[0-9] ]]; then + echo "Invalid prerelease npm dist-tag '$NPM_DIST_TAG': dist-tags must not look like versions." >&2 + exit 1 + fi + fi + echo "Tag $GITHUB_REF_NAME matches package.json $PKG_VERSION." + echo "npm dist-tag: $NPM_DIST_TAG" + echo "is_prerelease=$IS_PRERELEASE" >> "$GITHUB_OUTPUT" + echo "npm_dist_tag=$NPM_DIST_TAG" >> "$GITHUB_OUTPUT" - run: npm run build - name: Publish to npm - run: npm publish --provenance --access public + run: npm publish --provenance --access public --tag "${{ steps.version.outputs.npm_dist_tag }}" env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -62,6 +95,17 @@ jobs: if [ -z "$NOTES" ]; then NOTES="Release $GITHUB_REF_NAME" fi + + NOTES="$NOTES + + npm install -g genlayer@${{ steps.version.outputs.npm_dist_tag }}" + + RELEASE_FLAGS=() + if [ "${{ steps.version.outputs.is_prerelease }}" = "true" ]; then + RELEASE_FLAGS+=(--prerelease) + fi + gh release create "$GITHUB_REF_NAME" \ --title "$GITHUB_REF_NAME" \ - --notes "$NOTES" + --notes "$NOTES" \ + "${RELEASE_FLAGS[@]}" From b5dbdb7b851d63a993441427c176990913ff8b50 Mon Sep 17 00:00:00 2001 From: Edgars Date: Wed, 8 Jul 2026 22:54:44 +0100 Subject: [PATCH 16/45] Release v0.40.0-rc1 [skip ci] --- CHANGELOG.md | 27 +++++++++++++++++++++++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c207c6db..8fb80b24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## [0.40.0-rc1](https://github.com/genlayerlabs/genlayer-cli/compare/v0.39.1...v0.40.0-rc1) (2026-07-08) + +### ⚠ BREAKING CHANGES + +* **contracts:** require consensus acceptance for success, not just the leader's execution result (#346) + +### Features + +* add v0.6 fee-aware commands ([#340](https://github.com/genlayerlabs/genlayer-cli/issues/340)) ([ca083ab](https://github.com/genlayerlabs/genlayer-cli/commit/ca083abbf854960a6638d5af63096b532a03cc5a)) +* branch-per-major release model ([#311](https://github.com/genlayerlabs/genlayer-cli/issues/311)) ([6fd2f3a](https://github.com/genlayerlabs/genlayer-cli/commit/6fd2f3a678ce348c92470828b03bdfc596afa9cb)), closes [genlayer-js#172](https://github.com/genlayerlabs/genlayer-js/issues/172) +* **network:** custom network profiles with deployment-file import ([#362](https://github.com/genlayerlabs/genlayer-cli/issues/362)) ([185d22b](https://github.com/genlayerlabs/genlayer-cli/commit/185d22bff86884b330c2037c2875a5d1629ec72b)), closes [#1162](https://github.com/genlayerlabs/genlayer-cli/issues/1162) [#1162](https://github.com/genlayerlabs/genlayer-cli/issues/1162) +* staking validators discovery ([#357](https://github.com/genlayerlabs/genlayer-cli/issues/357)) ([0e76bce](https://github.com/genlayerlabs/genlayer-cli/commit/0e76bcef4524907f80d1567ad688550e24c7192b)) +* support fee profiles in contract commands ([#355](https://github.com/genlayerlabs/genlayer-cli/issues/355)) ([6edfcfa](https://github.com/genlayerlabs/genlayer-cli/commit/6edfcfa6d1ad2fcbf761c1ed9f3a194d31243624)) +* vesting commands ([#358](https://github.com/genlayerlabs/genlayer-cli/issues/358)) ([7bcc41e](https://github.com/genlayerlabs/genlayer-cli/commit/7bcc41e7a063bd4a628f689bdf4d321b50230aff)) + +### Bug Fixes + +* **contracts:** require consensus acceptance for success, not just the leader's execution result ([#346](https://github.com/genlayerlabs/genlayer-cli/issues/346)) ([6fadcd7](https://github.com/genlayerlabs/genlayer-cli/commit/6fadcd7c9ef181042c823faeec1b7e7fb4d902b2)), closes [#345](https://github.com/genlayerlabs/genlayer-cli/issues/345) +* **docs-sync:** stop overwriting the generated root _meta.json ([#352](https://github.com/genlayerlabs/genlayer-cli/issues/352)) ([f1f1304](https://github.com/genlayerlabs/genlayer-cli/commit/f1f130461dc7d719c9f34086aeb40d8426f0602e)), closes [genlayer-docs#426](https://github.com/genlayerlabs/genlayer-docs/issues/426) +* drop getSlashingAddress from validator-history ([#361](https://github.com/genlayerlabs/genlayer-cli/issues/361)) ([32a0a42](https://github.com/genlayerlabs/genlayer-cli/commit/32a0a42d687a27c633a0ee29b6fbc84842c3cc18)), closes [#344](https://github.com/genlayerlabs/genlayer-cli/issues/344) [#344](https://github.com/genlayerlabs/genlayer-cli/issues/344) [#341](https://github.com/genlayerlabs/genlayer-cli/issues/341) +* fail CLI writes on execution errors ([#345](https://github.com/genlayerlabs/genlayer-cli/issues/345)) ([5d00884](https://github.com/genlayerlabs/genlayer-cli/commit/5d008844ad0b97760bbd025ed5aac61b41b2b881)) +* **init:** use backend provider id "google" for Gemini ([#359](https://github.com/genlayerlabs/genlayer-cli/issues/359)) ([561370f](https://github.com/genlayerlabs/genlayer-cli/commit/561370f955f23dc2e1952daac2c42aecb3f3431c)), closes [#271](https://github.com/genlayerlabs/genlayer-cli/issues/271) +* **localnet:** print validator count to stdout ([37519e1](https://github.com/genlayerlabs/genlayer-cli/commit/37519e18b6155bc762fa24ea809a53c3e83ac9a7)) +* make git install build lifecycle robust ([#363](https://github.com/genlayerlabs/genlayer-cli/issues/363)) ([a4f8a63](https://github.com/genlayerlabs/genlayer-cli/commit/a4f8a63ae9c945ecf99a707e700857d6f7729df7)) +* run CI on v0.39 branch ([#322](https://github.com/genlayerlabs/genlayer-cli/issues/322)) ([e129bab](https://github.com/genlayerlabs/genlayer-cli/commit/e129bab0c471c2d59d360b5ca8c1cb3190774ff8)) +* **system:** propagate command-check and version parse fixes to v0.40-dev ([#350](https://github.com/genlayerlabs/genlayer-cli/issues/350)) ([9ebf9e0](https://github.com/genlayerlabs/genlayer-cli/commit/9ebf9e0d1ab5266c58d2e8a274c0b42acc8ad753)), closes [#349](https://github.com/genlayerlabs/genlayer-cli/issues/349) + ## 0.39.1 (2026-05-06) ### Bug Fixes diff --git a/package-lock.json b/package-lock.json index 38a4e4b1..a9874ed5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-rc1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-rc1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 1e8b9a90..1ce24e47 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genlayer", - "version": "0.39.1", + "version": "0.40.0-rc1", "description": "GenLayer Command Line Tool", "main": "src/index.ts", "type": "module", From 3cd1cac564932d67e5b1200d4396ca24556d2e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 23:19:44 +0100 Subject: [PATCH 17/45] fix: avoid git dependency in npm prerelease install (#368) --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index a9874ed5..75fe4182 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,6 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", "node-fetch": "^3.0.0", "open": "^10.1.0", @@ -46,6 +45,7 @@ "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", diff --git a/package.json b/package.json index 1ce24e47..09e45390 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "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", @@ -67,7 +68,6 @@ "dotenv": "^17.0.0", "ethers": "^6.13.4", "fs-extra": "^11.3.0", - "genlayer-js": "github:genlayerlabs/genlayer-js#v2-dev", "inquirer": "^12.0.0", "node-fetch": "^3.0.0", "open": "^10.1.0", From 3996ab4c75d2c0e9237cd7624eedb404173b45ca Mon Sep 17 00:00:00 2001 From: Edgars Date: Wed, 8 Jul 2026 23:20:22 +0100 Subject: [PATCH 18/45] Release v0.40.0-rc2 [skip ci] --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb80b24..06098a18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [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 diff --git a/package-lock.json b/package-lock.json index 75fe4182..4876982b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer", - "version": "0.40.0-rc1", + "version": "0.40.0-rc2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer", - "version": "0.40.0-rc1", + "version": "0.40.0-rc2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 09e45390..0f21c6de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genlayer", - "version": "0.40.0-rc1", + "version": "0.40.0-rc2", "description": "GenLayer Command Line Tool", "main": "src/index.ts", "type": "module", From 374835872a582601a673daf32e833e2c2726c4a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 23:39:38 +0100 Subject: [PATCH 19/45] ci: add npm dist-tag channel workflow (#369) --- .github/workflows/npm-dist-tag.yml | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 .github/workflows/npm-dist-tag.yml diff --git a/.github/workflows/npm-dist-tag.yml b/.github/workflows/npm-dist-tag.yml new file mode 100644 index 00000000..78004309 --- /dev/null +++ b/.github/workflows/npm-dist-tag.yml @@ -0,0 +1,81 @@ +name: Manage npm dist-tag + +on: + workflow_dispatch: + inputs: + package_version: + description: "Published genlayer version to point the dist-tag at, e.g. 0.40.0-rc2" + required: true + type: string + dist_tag: + description: "npm dist-tag to set, e.g. clarke" + required: true + type: string + +permissions: + contents: read + +jobs: + set-dist-tag: + runs-on: ubuntu-latest + environment: Publish + steps: + - uses: actions/setup-node@v4 + with: + node-version: "24" + registry-url: "https://registry.npmjs.org" + + - name: Validate inputs + env: + PACKAGE_VERSION: ${{ inputs.package_version }} + DIST_TAG: ${{ inputs.dist_tag }} + run: | + set -euo pipefail + + PACKAGE_VERSION="${PACKAGE_VERSION#v}" + + if [[ ! "$PACKAGE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z._-]+)?(\+[0-9A-Za-z._-]+)?$ ]]; then + echo "Invalid package_version: $PACKAGE_VERSION" >&2 + exit 1 + fi + + if [[ ! "$DIST_TAG" =~ ^[A-Za-z][A-Za-z0-9._-]*$ ]]; then + echo "Invalid dist_tag: $DIST_TAG" >&2 + exit 1 + fi + + if [ "$DIST_TAG" = "latest" ]; then + echo "This workflow intentionally refuses to move latest." >&2 + exit 1 + fi + + if [[ "$DIST_TAG" =~ ^v?[0-9] ]]; then + echo "Invalid dist_tag '$DIST_TAG': dist-tags must not look like versions." >&2 + exit 1 + fi + + PUBLISHED_VERSION="$(npm view "genlayer@$PACKAGE_VERSION" version)" + if [ "$PUBLISHED_VERSION" != "$PACKAGE_VERSION" ]; then + echo "Published package mismatch: requested $PACKAGE_VERSION, npm returned $PUBLISHED_VERSION" >&2 + exit 1 + fi + + { + echo "package_version=$PACKAGE_VERSION" + echo "dist_tag=$DIST_TAG" + } >> "$GITHUB_ENV" + + - name: Set npm dist-tag + run: npm dist-tag add "genlayer@$package_version" "$dist_tag" + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + + - name: Verify npm dist-tag + run: | + set -euo pipefail + ACTUAL_VERSION="$(npm view "genlayer@$dist_tag" version)" + if [ "$ACTUAL_VERSION" != "$package_version" ]; then + echo "dist-tag $dist_tag points at $ACTUAL_VERSION, expected $package_version" >&2 + exit 1 + fi + echo "genlayer@$dist_tag -> $ACTUAL_VERSION" From d5203118a7789969c4d6d3ff5fcb6f62ebe721cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Wed, 8 Jul 2026 23:52:39 +0100 Subject: [PATCH 20/45] ci: use npm auth token secret for package operations (#370) --- .github/workflows/npm-dist-tag.yml | 2 +- .github/workflows/publish.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/npm-dist-tag.yml b/.github/workflows/npm-dist-tag.yml index 78004309..4996dee6 100644 --- a/.github/workflows/npm-dist-tag.yml +++ b/.github/workflows/npm-dist-tag.yml @@ -68,7 +68,7 @@ jobs: - name: Set npm dist-tag run: npm dist-tag add "genlayer@$package_version" "$dist_tag" env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN || secrets.NPM_TOKEN }} - name: Verify npm dist-tag run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e5e27ca3..1b428bef 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -81,7 +81,7 @@ jobs: - name: Publish to npm 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: From 6c1b27b6a3562b929622fff9cf5139ba89ac95a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 00:04:38 +0100 Subject: [PATCH 21/45] ci: publish prereleases to named npm channels (#371) --- .github/workflows/publish.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1b428bef..9364859a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -9,9 +9,9 @@ name: Publish Package to NPM # Release. It never bumps or tags by itself. # # Stable tags such as v0.39.2 publish to npm's latest dist-tag. -# Prerelease tags such as v0.40.0-rc1 publish to a matching prerelease -# dist-tag (rc1) and create a GitHub prerelease. Prereleases must never -# become latest. +# 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: @@ -57,8 +57,9 @@ jobs: NPM_DIST_TAG=latest if [[ "$TAG_VERSION" == *-* ]]; then IS_PRERELEASE=true - NPM_DIST_TAG="${TAG_VERSION#*-}" - NPM_DIST_TAG="${NPM_DIST_TAG%%+*}" + 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 From 6f95f824c3a96f3b56232ab1a0f65ed7fa47e845 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 00:05:17 +0100 Subject: [PATCH 22/45] Release v0.40.0-clarke.1 [skip ci] --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06098a18..77cccf5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Changelog +## [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 diff --git a/package-lock.json b/package-lock.json index 4876982b..3f2a3c34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer", - "version": "0.40.0-rc2", + "version": "0.40.0-clarke.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer", - "version": "0.40.0-rc2", + "version": "0.40.0-clarke.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0f21c6de..a8fd1a13 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genlayer", - "version": "0.40.0-rc2", + "version": "0.40.0-clarke.1", "description": "GenLayer Command Line Tool", "main": "src/index.ts", "type": "module", From 3396474b775d998ab3778ac7cfd1e2e197f8b47f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 00:18:01 +0100 Subject: [PATCH 23/45] ci: remove obsolete npm dist-tag workflow (#372) --- .github/workflows/npm-dist-tag.yml | 81 ------------------------------ 1 file changed, 81 deletions(-) delete mode 100644 .github/workflows/npm-dist-tag.yml diff --git a/.github/workflows/npm-dist-tag.yml b/.github/workflows/npm-dist-tag.yml deleted file mode 100644 index 4996dee6..00000000 --- a/.github/workflows/npm-dist-tag.yml +++ /dev/null @@ -1,81 +0,0 @@ -name: Manage npm dist-tag - -on: - workflow_dispatch: - inputs: - package_version: - description: "Published genlayer version to point the dist-tag at, e.g. 0.40.0-rc2" - required: true - type: string - dist_tag: - description: "npm dist-tag to set, e.g. clarke" - required: true - type: string - -permissions: - contents: read - -jobs: - set-dist-tag: - runs-on: ubuntu-latest - environment: Publish - steps: - - uses: actions/setup-node@v4 - with: - node-version: "24" - registry-url: "https://registry.npmjs.org" - - - name: Validate inputs - env: - PACKAGE_VERSION: ${{ inputs.package_version }} - DIST_TAG: ${{ inputs.dist_tag }} - run: | - set -euo pipefail - - PACKAGE_VERSION="${PACKAGE_VERSION#v}" - - if [[ ! "$PACKAGE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z._-]+)?(\+[0-9A-Za-z._-]+)?$ ]]; then - echo "Invalid package_version: $PACKAGE_VERSION" >&2 - exit 1 - fi - - if [[ ! "$DIST_TAG" =~ ^[A-Za-z][A-Za-z0-9._-]*$ ]]; then - echo "Invalid dist_tag: $DIST_TAG" >&2 - exit 1 - fi - - if [ "$DIST_TAG" = "latest" ]; then - echo "This workflow intentionally refuses to move latest." >&2 - exit 1 - fi - - if [[ "$DIST_TAG" =~ ^v?[0-9] ]]; then - echo "Invalid dist_tag '$DIST_TAG': dist-tags must not look like versions." >&2 - exit 1 - fi - - PUBLISHED_VERSION="$(npm view "genlayer@$PACKAGE_VERSION" version)" - if [ "$PUBLISHED_VERSION" != "$PACKAGE_VERSION" ]; then - echo "Published package mismatch: requested $PACKAGE_VERSION, npm returned $PUBLISHED_VERSION" >&2 - exit 1 - fi - - { - echo "package_version=$PACKAGE_VERSION" - echo "dist_tag=$DIST_TAG" - } >> "$GITHUB_ENV" - - - name: Set npm dist-tag - run: npm dist-tag add "genlayer@$package_version" "$dist_tag" - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN || secrets.NPM_TOKEN }} - - - name: Verify npm dist-tag - run: | - set -euo pipefail - ACTUAL_VERSION="$(npm view "genlayer@$dist_tag" version)" - if [ "$ACTUAL_VERSION" != "$package_version" ]; then - echo "dist-tag $dist_tag points at $ACTUAL_VERSION, expected $package_version" >&2 - exit 1 - fi - echo "genlayer@$dist_tag -> $ACTUAL_VERSION" From ab128c0b76b4f9d97ed660d91a556a5f769cef6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 14:10:31 +0100 Subject: [PATCH 24/45] feat(staking): browser-wallet signing for validator-join and wizard (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(staking): browser-wallet signing for validator-join and wizard Add a dependency-free localhost bridge (node:http) that lets a browser wallet (MetaMask / any injected window.ethereum) sign-and-broadcast the validator-join and wizard-identity transactions. Design: raw-viem bridge, not an SDK account. genlayer-js executeWrite requires account.signTransaction (sign -> sendRawTransaction), which MetaMask cannot satisfy (it only does eth_sendTransaction). The CLI encodes calldata itself, a localhost page eth_sendTransactions it, and the CLI waits for the receipt via a viem publicClient (reusing the glHttpConfig id!=0 quirk) and decodes ValidatorJoin for the wallet. New: src/lib/wallet/{stakingTx,bridgePage,browserBridge}.ts. Flag --wallet on validator-join and wizard, with hard errors when combined with --password/--account. Only the join + identity sends route through the bridge; operator keystore generation/export and config summary are unchanged. Other commands can adopt later via the getBrowserWalletSession seam. * feat(wallet): shared browser-wallet plumbing + all staking writes Generalize the #367 browser-wallet bridge into a reusable signing layer and adopt it across every staking write command. - Extract StakingAction.getBrowserWalletSession into a shared src/lib/wallet/browserSend.ts (openBrowserWalletSession); add the Lane B eip1193Provider shim (eth_sendTransaction -> bridge; eth_chainId/eth_accounts local) and setNextLabel. Owns glHttpConfig. - Grow stakingTx.ts into txBuilders.ts (generic buildTx + encodeExtraCid); stakingTx re-exports for back-compat. - Add shared addWalletModeOption registrar (--wallet ). - BaseAction: isBrowserWallet, assertWalletFlags, getBrowserSession/ closeBrowserSession, and getClient browser mode (Lane B, skips keystore). - Widen bridge protocol (gas/type pass-through; page drops nonce/chainId). - Adopt --wallet browser on the 9 remaining staking writes + prime-all; refactor validator-join/wizard onto the shared registrar. Tests: txBuilders, browserSend (incl. eip1193 shim), bridge gas/type pass-through, per-command keystore-untouched + output parity + flag conflicts. Docs regenerated. * feat(vesting): browser-wallet signing for all vesting writes Adopt --wallet browser (Lane A) across all 12 vesting write commands via buildTx(VESTING_ABI, vesting, fn, args). Beneficiary resolves to the connected wallet address; the vesting lookup runs on the account-less read-only client. BREAKING (deprecated-flag rename): the deprecated --wallet
alias on 'vesting validator ...' subcommands is renamed to --validator-wallet
so --wallet is free for the signing-mode flag. The recommended positional argument form is unaffected. Internally the resolved positional is carried as 'walletAddress' (VestingConfig gains wallet mode + validatorWallet alias). Tests: vesting action browser-mode (routes through session, keystore client untouched, output parity) + command parsing incl. the --validator-wallet rename. Docs regenerated. * feat(contracts): browser-wallet signing for deploy and write (Lane B) Add --wallet browser to 'deploy' (incl. deploy-scripts mode) and 'contracts write'. Sets walletModeOverride so BaseAction.getClient builds the genlayer-js client with {account:
, provider: eip1193 shim}; the SDK's existing json-rpc-account branch builds the addTransaction tx, deposits fees, waits, and extracts the GenLayer txId. No genlayer-js changes. closeBrowserSession in finally; setNextLabel for a friendly wallet prompt. Tests: createClient wired with {account, provider} and getAccount never called in browser mode; command parsing. Docs regenerated. * feat(transactions): browser-wallet signing for appeal and finalize (Lane B) Add --wallet browser to 'transactions appeal', 'finalize', and 'finalize-batch'. These already funnel through BaseAction.getClient, so browser mode is the same Lane B client construction + closeBrowserSession in finally; the SDK's consensus provider branch does the send. Docs regenerated. * feat(wallet): session descriptor + shared timing constants Foundations for the persistent connect-once browser-wallet session: - sessionConstants.ts: single source of truth for heartbeat/TTL/timeout budgets (LONG_POLL, HEARTBEAT_DEAD, TAB_DEAD_GRACE, IDLE_TTL, DAEMON_READY, CONNECT/TX timeouts) plus descriptor/log filenames and the walletMode/walletSessionTtlMinutes config keys. - sessionDescriptor.ts: on-disk descriptor (~/.genlayer/wallet-session.json) written atomically at 0600; read+schema-validated (bad JSON/version -> null); isPidAlive() cheap first-gate (signal 0, EPERM = alive). Descriptor unit tests: atomic 0600 write, round-trip, garbage/version rejection, idempotent remove, pid-alive. * feat(wallet): persistent daemon mode on the browser bridge Add an opt-in persistent mode so one long-lived bridge can broker txs for many CLI processes: - New client routes (token-authed): GET /api/ping, GET /api/state, POST /api/enqueue, GET /api/tx?id=, POST /api/shutdown. Page routes (/api/connected, /api/result) keep the strict Origin check; client routes are Origin-exempt (a Node client sends no Origin; token-in-header forces a CORS preflight the server never satisfies). - Result store so remote HTTP callers can poll a tx to done/sent/rejected/error (GC 10min after completion); enqueue returns 409 wallet-not-connected/tab-closed for fail-fast. - Heartbeat: lastPagePollAt stamped on every /api/next; getState() exposes it. - onConnected / onActivity / onShutdown callbacks for the daemon. - Shared serializeBridgeTx/parseBridgeTx pair (client and server can't drift); serializeTx now reuses it. LONG_POLL_MS sourced from sessionConstants. Existing bridge tests unchanged; +11 persistent-mode tests (auth, enqueue round-trip, rejected/error, Origin exemption vs page 403, heartbeat, FIFO, shutdown, 404 on non-persistent). * refactor(wallet): split bridge transport; add remote session + client Separate "how a tx reaches the wallet" from the shared signing lanes so a command can run over either an in-process bridge or a remote daemon: - BridgeTransport seam; buildBrowserSession(transport, ...) holds the shared preflight + receipt-wait (Lane A), EIP-1193 shim (Lane B), and labels verbatim. BrowserSession gains kind/sessionUrl; bridge is now optional (present only for local sessions). - openBrowserWalletSession re-based on a LocalBridgeTransport (existing tests pass unchanged). New openRemoteWalletSession wraps a RemoteSessionTransport whose close() is a no-op so one failed preflight never kills a shared session. - WalletSessionClient: fetch-only client for a running daemon (ping/state/ enqueueTx/waitForTxResult/waitForConnection/shutdown), token on every call, fail-fast on stale page heartbeat, 409 -> clear reconnect messages. sessionClient tests drive a real in-process persistent bridge with a fetch-simulated page (openUrl mocked); browserSend tests unchanged. * feat(wallet): session daemon runtime, detached spawn, and resolver - sessionDaemon.ts: runWalletSessionDaemon owns the bridge + tab + descriptor lifecycle. Writes the descriptor only after listen succeeds; rewrites address onConnected; bumps lastUsed (throttled) onActivity. Self-terminates on idle TTL, tab-dead heartbeat loss, connect timeout, signal, or fatal error, and on /api/shutdown -- ALWAYS removing the descriptor first. Deterministic shutdown: the bridge's onShutdown fires an ordered teardown (remove descriptor -> close bridge -> exit); the keep-alive timer is NOT unref'd, so the daemon only ever exits through cleanupAndExit (never by the loop draining after the socket closes) -> the "daemon gone => descriptor removed" invariant always holds. - spawnDaemon.ts: spawnWalletDaemon re-execs the bundled CLI (process.execPath + argv[1] + "wallet daemon"), detached + unref, stdio -> 0600 logfile; NO token on argv. waitForDaemonReady polls descriptor+pid+/api/ping, surfaces the log tail on timeout. - sessionResolver.ts: resolveBrowserWalletSession -- discover live session (pid + ping), stale-cleanup, chain-mismatch hard error, and fallback (auto-start-and-persist default | own-bridge | error), degrading auto-start to own-bridge if spawn/ready fails. In-process/mocked tests only (injected spawnFn, mocked openUrl, no real daemon or browser): daemon descriptor/onConnected/idle/tab-dead/connect-timeout/ singleton/shutdown-cleanup; spawn argv+unref and ready/timeout; resolver live/stale/mismatch/auto-start-degrade/error matrix. * feat(wallet): wallet connect/status/disconnect commands (+ hidden daemon) New `genlayer wallet` command group (registered in src/index.ts): - connect [--network --rpc]: reuse a matching live session, or spawn the detached daemon, print the bridge URL + SSH port-forward hint, and wait for the wallet to connect. Different chain -> explicit shutdown + switch. - status: address/network/chainId/port/URL/age/idle/heartbeat/queue; exit 0 only when live+connected (scriptable). - disconnect: /api/shutdown -> wait for exit (SIGTERM fallback) -> remove descriptor (idempotent). - daemon (hidden): detached-process entry point -> runWalletSessionDaemon. wallet command tests dispatch connect/status/disconnect/daemon and assert daemon is help-hidden; index test mocks the new initializer. * feat(wallet): walletMode config default + per-command session adoption Make browser mode reusable across commands and configurable as the default: - walletMode config: BaseAction.resolveWalletMode centralises precedence (--wallet flag > walletMode config > keystore); invalid flag throws, unknown config value warns + keystore. walletOption drops the hardcoded commander "keystore" default so an omitted flag is distinguishable from an explicit one (and can defer to config). write/deploy/appeal/finalize switch their direct `wallet === "browser"` checks to isBrowserWallet(...). - Adoption: BaseAction.getBrowserSession and StakingAction.getBrowserWalletSession now go through resolveBrowserWalletSession (fallback auto-start for writes, own-bridge for the wizard). Per-command finally blocks are untouched (closeBrowserSession/session.close() are no-ops for remote sessions, so a shared daemon survives); validatorJoin and the wizard switch session.bridge.close() -> session.close(). walletSession tests cover resolveWalletMode matrix; command/action tests updated for the no-commander-default behaviour and session.close() (remote sessions survive finally; wizard/validator-join assert session.close, not bridge.close). * test(wallet): global open() safety net + regenerated CLI docs - tests/setup.ts (wired via vitest.config setupFiles): globally mocks the `open` package so no automated test can ever launch a real browser or orphan a tab. Every bridge/daemon test already injects a mocked openUrl; this is the belt-and-suspenders guarantee. system.test.ts keeps its own file-level vi.mock("open") which takes precedence there. - Regenerated api-references: new wallet connect/status/disconnect pages (hidden `daemon` excluded) and the updated --wallet help text/default across all write commands. * style(wallet): prettier-format session runtime + tests * feat(wallet): live wallet session implies browser mode resolveWalletMode gains a session rung: with no --wallet flag and no walletMode config, a live wallet session (descriptor present + daemon pid alive) now resolves to browser mode, so `wallet connect` alone is enough to route subsequent commands through the bridge. Explicit --wallet keystore or walletMode=keystore still overrides a live session. Add hasLiveWalletSession() (sync, never-throws descriptor+pid probe) and make the test suite hermetic: tests/setup.ts redirects os.homedir() to a throwaway per-worker temp dir so the descriptor read never sees the developer's real ~/.genlayer/wallet-session.json (which would otherwise flip commands into browser mode and break otherwise-hermetic tests). * feat(staking): wizard lists custom networks and honors live wallet session Step 2 network picker now appends custom networks (from `genlayer network add`) alongside the built-ins, and the post-selection echo resolves through both maps so a custom alias no longer crashes on BUILT_IN_NETWORKS lookup. Step 1 owner detection switches from `options.wallet === "browser"` to resolveWalletMode(options.wallet) === "browser", so the wizard auto-selects the browser-wallet owner for --wallet browser, walletMode=browser config, or a live wallet session — consistent with every other command. The keystore path and interactive "Connect browser wallet" menu item are unchanged. * feat(account): account show reflects active network + --network flag account show printed `network.name`, which for a custom active network is the inherited base chain name (e.g. "Genlayer Localnet") — a misleading label. Print the active network alias instead (--network flag > config network key > localnet) and add a chainId field so a custom network is unambiguous. Add a --network flag mirroring `account send`, so both the balance query and the printed alias/chainId reflect the chosen network. New unit test covers a custom active network (alias + real chainId, not the base name) and the --network override. * feat(balances): add read-only balances view (wallet + vesting + committed stake) * feat(staking): wizard can fund a validator from a vesting contract Add a funding-source step after network selection: keep 'Your wallet' as the default (original flow unchanged) or fund the self-stake from a vesting contract. For the vesting source the wizard resolves the beneficiary's vesting contract (none -> warn + loop back; one -> use it; many -> pick), checks the contract's available-to-stake (totalAmount - totalWithdrawn) against the minimum stake while keeping a wallet gas sanity warning, and builds+sends vestingValidatorJoin instead of validatorJoin for both keystore (client.vestingValidatorJoin) and browser (buildTx + shared session.sendTransaction) owners. Identity setup is skipped for vesting-backed validators with a pointer to 'genlayer vesting validator set-identity'; the summary reflects the funding source and notes funds return to the vesting contract on exit. * fix(vesting): available-to-stake is the contract balance, not derived math * fix(balances): a live wallet session is the active identity over the keystore default * fix(network): custom networks display their alias as the name * fix(wallet): detect a closed tab at session acquire + let connect recover Browser-mode commands (notably `staking wizard`) only discovered a closed wallet tab at the final sign step, after every wizard step was filled in. resolveBrowserWalletSession now checks the page-heartbeat freshness in its live-session branch and throws the reconnect message at acquire time, so the wizard fails at the balance-check step instead of the sign step. lastPagePollAt === 0 (never polled) stays fresh, and a just-connected page is re-read so it is not misflagged. `wallet connect` reused a daemon that was pid-alive + pinging + state.connected but whose tab was dead, printing "Already connected" and returning — leaving the user stuck. The reuse branch now tears the stale daemon down (reusing the chain-switch teardown) and starts a fresh session; the healthy path is unchanged. TAB_CLOSED_MESSAGE is centralized in sessionConstants and shared by the client and the resolver. --- README.md | 9 + docs/api-references/_meta.json | 3 +- docs/api-references/contracts/deploy.mdx | 1 + docs/api-references/contracts/write.mdx | 1 + docs/api-references/finalize-batch.mdx | 1 + docs/api-references/finalize.mdx | 1 + docs/api-references/index.mdx | 1 + .../staking/staking/delegator-claim.mdx | 1 + .../staking/staking/delegator-exit.mdx | 1 + .../staking/staking/delegator-join.mdx | 1 + .../staking/staking/prime-all.mdx | 1 + .../staking/staking/set-identity.mdx | 1 + .../staking/staking/set-operator.mdx | 1 + .../staking/staking/validator-claim.mdx | 1 + .../staking/staking/validator-deposit.mdx | 1 + .../staking/staking/validator-exit.mdx | 1 + .../staking/staking/validator-join.mdx | 1 + .../staking/staking/validator-prime.mdx | 1 + .../api-references/staking/staking/wizard.mdx | 1 + docs/api-references/transactions/appeal.mdx | 1 + docs/api-references/vesting/claim.mdx | 1 + docs/api-references/vesting/delegate.mdx | 1 + docs/api-references/vesting/undelegate.mdx | 1 + .../vesting/validator/claim.mdx | 3 +- .../vesting/validator/create.mdx | 1 + .../vesting/validator/deposit.mdx | 3 +- .../api-references/vesting/validator/exit.mdx | 3 +- .../api-references/vesting/validator/join.mdx | 1 + .../validator/operator-transfer/cancel.mdx | 3 +- .../validator/operator-transfer/complete.mdx | 3 +- .../validator/operator-transfer/initiate.mdx | 3 +- .../vesting/validator/set-identity.mdx | 3 +- docs/api-references/vesting/withdraw.mdx | 1 + docs/api-references/wallet.mdx | 25 + docs/api-references/wallet/connect.mdx | 17 + docs/api-references/wallet/disconnect.mdx | 15 + docs/api-references/wallet/status.mdx | 15 + src/commands/account/index.ts | 1 + src/commands/account/show.ts | 20 +- src/commands/balances/BalancesAction.ts | 276 +++++++ src/commands/balances/index.ts | 18 + src/commands/contracts/deploy.ts | 34 +- src/commands/contracts/index.ts | 53 +- src/commands/contracts/write.ts | 6 + src/commands/staking/StakingAction.ts | 97 ++- src/commands/staking/delegatorClaim.ts | 44 ++ src/commands/staking/delegatorExit.ts | 60 ++ src/commands/staking/delegatorJoin.ts | 46 ++ src/commands/staking/index.ts | 315 ++++---- src/commands/staking/setIdentity.ts | 62 ++ src/commands/staking/setOperator.ts | 42 ++ src/commands/staking/validatorClaim.ts | 39 + src/commands/staking/validatorDeposit.ts | 42 ++ src/commands/staking/validatorExit.ts | 57 ++ src/commands/staking/validatorJoin.ts | 50 ++ src/commands/staking/validatorPrime.ts | 88 +++ src/commands/staking/wizard.ts | 539 +++++++++++++- src/commands/transactions/appeal.ts | 7 + src/commands/transactions/finalize.ts | 29 +- src/commands/transactions/index.ts | 54 +- src/commands/vesting/VestingAction.ts | 20 +- src/commands/vesting/claim.ts | 43 ++ src/commands/vesting/delegate.ts | 49 ++ src/commands/vesting/index.ts | 58 +- src/commands/vesting/undelegate.ts | 57 ++ src/commands/vesting/validatorClaim.ts | 53 +- src/commands/vesting/validatorCreate.ts | 59 ++ src/commands/vesting/validatorDeposit.ts | 56 +- src/commands/vesting/validatorExit.ts | 65 +- .../vesting/validatorOperatorTransfer.ts | 158 +++- src/commands/vesting/validatorSetIdentity.ts | 74 +- src/commands/vesting/withdraw.ts | 45 ++ src/commands/wallet/WalletAction.ts | 190 +++++ src/commands/wallet/index.ts | 36 + src/index.ts | 4 + src/lib/actions/BaseAction.ts | 136 +++- src/lib/vesting/availableToStake.ts | 29 + src/lib/wallet/bridgePage.ts | 266 +++++++ src/lib/wallet/browserBridge.ts | 694 ++++++++++++++++++ src/lib/wallet/browserSend.ts | 316 ++++++++ src/lib/wallet/sessionClient.ts | 148 ++++ src/lib/wallet/sessionConstants.ts | 50 ++ src/lib/wallet/sessionDaemon.ts | 238 ++++++ src/lib/wallet/sessionDescriptor.ts | 95 +++ src/lib/wallet/sessionResolver.ts | 131 ++++ src/lib/wallet/spawnDaemon.ts | 104 +++ src/lib/wallet/stakingTx.ts | 12 + src/lib/wallet/txBuilders.ts | 114 +++ src/lib/wallet/walletOption.ts | 23 + tests/actions/balances.test.ts | 247 +++++++ tests/actions/customNetworkProfiles.test.ts | 3 + tests/actions/deploy.test.ts | 42 ++ tests/actions/hasLiveWalletSession.test.ts | 55 ++ tests/actions/show.test.ts | 94 +++ tests/actions/staking.test.ts | 316 +++++++- tests/actions/stakingWizard.test.ts | 417 +++++++++++ tests/actions/vesting.test.ts | 160 ++++ tests/actions/walletConnect.test.ts | 161 ++++ tests/actions/walletSession.test.ts | 86 +++ tests/actions/write.test.ts | 43 ++ tests/commands/balances.test.ts | 73 ++ tests/commands/staking.test.ts | 76 +- tests/commands/vesting.test.ts | 62 ++ tests/commands/wallet.test.ts | 64 ++ tests/index.test.ts | 8 + tests/libs/browserBridge.test.ts | 412 +++++++++++ tests/libs/browserSend.test.ts | 183 +++++ tests/libs/sessionClient.test.ts | 155 ++++ tests/libs/sessionDaemon.test.ts | 141 ++++ tests/libs/sessionDescriptor.test.ts | 93 +++ tests/libs/sessionResolver.test.ts | 283 +++++++ tests/libs/spawnDaemon.test.ts | 117 +++ tests/libs/stakingTx.test.ts | 116 +++ tests/libs/txBuilders.test.ts | 152 ++++ tests/setup.ts | 33 + vitest.config.ts | 1 + 116 files changed, 8690 insertions(+), 334 deletions(-) create mode 100644 docs/api-references/wallet.mdx create mode 100644 docs/api-references/wallet/connect.mdx create mode 100644 docs/api-references/wallet/disconnect.mdx create mode 100644 docs/api-references/wallet/status.mdx create mode 100644 src/commands/balances/BalancesAction.ts create mode 100644 src/commands/balances/index.ts create mode 100644 src/commands/wallet/WalletAction.ts create mode 100644 src/commands/wallet/index.ts create mode 100644 src/lib/vesting/availableToStake.ts create mode 100644 src/lib/wallet/bridgePage.ts create mode 100644 src/lib/wallet/browserBridge.ts create mode 100644 src/lib/wallet/browserSend.ts create mode 100644 src/lib/wallet/sessionClient.ts create mode 100644 src/lib/wallet/sessionConstants.ts create mode 100644 src/lib/wallet/sessionDaemon.ts create mode 100644 src/lib/wallet/sessionDescriptor.ts create mode 100644 src/lib/wallet/sessionResolver.ts create mode 100644 src/lib/wallet/spawnDaemon.ts create mode 100644 src/lib/wallet/stakingTx.ts create mode 100644 src/lib/wallet/txBuilders.ts create mode 100644 src/lib/wallet/walletOption.ts create mode 100644 tests/actions/balances.test.ts create mode 100644 tests/actions/hasLiveWalletSession.test.ts create mode 100644 tests/actions/show.test.ts create mode 100644 tests/actions/stakingWizard.test.ts create mode 100644 tests/actions/vesting.test.ts create mode 100644 tests/actions/walletConnect.test.ts create mode 100644 tests/actions/walletSession.test.ts create mode 100644 tests/commands/balances.test.ts create mode 100644 tests/commands/wallet.test.ts create mode 100644 tests/libs/browserBridge.test.ts create mode 100644 tests/libs/browserSend.test.ts create mode 100644 tests/libs/sessionClient.test.ts create mode 100644 tests/libs/sessionDaemon.test.ts create mode 100644 tests/libs/sessionDescriptor.test.ts create mode 100644 tests/libs/sessionResolver.test.ts create mode 100644 tests/libs/spawnDaemon.test.ts create mode 100644 tests/libs/stakingTx.test.ts create mode 100644 tests/libs/txBuilders.test.ts create mode 100644 tests/setup.ts diff --git a/README.md b/README.md index 61f32d6a..234778fe 100644 --- a/README.md +++ b/README.md @@ -478,6 +478,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 @@ -497,6 +498,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 diff --git a/docs/api-references/_meta.json b/docs/api-references/_meta.json index bf2c81cb..3bffb8a8 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -10,5 +10,6 @@ "estimate-fees": "estimate-fees", "finalize": "finalize", "finalize-batch": "finalize-batch", - "vesting": "vesting" + "vesting": "vesting", + "wallet": "wallet" } \ No newline at end of file diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index ca96ac0b..1fc24809 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -21,4 +21,5 @@ Deploy intelligent contracts | | --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 b4760e37..a6542d4a 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -25,4 +25,5 @@ Sends a transaction to a contract method that modifies the state | | --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/finalize-batch.mdx b/docs/api-references/finalize-batch.mdx index afc1f6f9..37fa3895 100644 --- a/docs/api-references/finalize-batch.mdx +++ b/docs/api-references/finalize-batch.mdx @@ -17,4 +17,5 @@ Finalize a batch of idle transactions in a single call (public call) | 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 index b977d82d..74ad96ea 100644 --- a/docs/api-references/finalize.mdx +++ b/docs/api-references/finalize.mdx @@ -17,4 +17,5 @@ Finalize a transaction that is ready to be finalized (public call) | 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 dc97f6cb..47162dbd 100644 --- a/docs/api-references/index.mdx +++ b/docs/api-references/index.mdx @@ -33,6 +33,7 @@ Version: `0.39.1` - `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 --- diff --git a/docs/api-references/staking/staking/delegator-claim.mdx b/docs/api-references/staking/staking/delegator-claim.mdx index 25c71120..9f8ea959 100644 --- a/docs/api-references/staking/staking/delegator-claim.mdx +++ b/docs/api-references/staking/staking/delegator-claim.mdx @@ -23,4 +23,5 @@ Claim delegator withdrawals after unbonding period | | --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 b4f8864d..31e233ec 100644 --- a/docs/api-references/staking/staking/delegator-exit.mdx +++ b/docs/api-references/staking/staking/delegator-exit.mdx @@ -23,4 +23,5 @@ Exit as a delegator by withdrawing shares from a validator | | --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 d213113a..2efe155c 100644 --- a/docs/api-references/staking/staking/delegator-join.mdx +++ b/docs/api-references/staking/staking/delegator-join.mdx @@ -23,4 +23,5 @@ Join as a delegator by staking with a validator | | --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/prime-all.mdx b/docs/api-references/staking/staking/prime-all.mdx index be8d1a7c..96a929ef 100644 --- a/docs/api-references/staking/staking/prime-all.mdx +++ b/docs/api-references/staking/staking/prime-all.mdx @@ -17,4 +17,5 @@ Prime all validators that need priming | | --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/set-identity.mdx b/docs/api-references/staking/staking/set-identity.mdx index 85046475..66fea2cd 100644 --- a/docs/api-references/staking/staking/set-identity.mdx +++ b/docs/api-references/staking/staking/set-identity.mdx @@ -30,4 +30,5 @@ Set validator identity metadata (moniker, website, socials, etc.) | | --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 | | +| | --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 0d08a104..bde668f7 100644 --- a/docs/api-references/staking/staking/set-operator.mdx +++ b/docs/api-references/staking/staking/set-operator.mdx @@ -23,4 +23,5 @@ Change the operator address for a validator wallet | | --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 | | +| | --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 3085a3af..64ca0a66 100644 --- a/docs/api-references/staking/staking/validator-claim.mdx +++ b/docs/api-references/staking/staking/validator-claim.mdx @@ -21,4 +21,5 @@ Claim validator withdrawals after unbonding period | | --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 | | +| | --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 daf32fa0..029c7071 100644 --- a/docs/api-references/staking/staking/validator-deposit.mdx +++ b/docs/api-references/staking/staking/validator-deposit.mdx @@ -22,4 +22,5 @@ Make an additional deposit to a validator wallet | | --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 | | +| | --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 c5489f03..383aa2f3 100644 --- a/docs/api-references/staking/staking/validator-exit.mdx +++ b/docs/api-references/staking/staking/validator-exit.mdx @@ -22,4 +22,5 @@ Exit as a validator by withdrawing shares | | --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 | | +| | --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-join.mdx b/docs/api-references/staking/staking/validator-join.mdx index e2d56331..09a65a35 100644 --- a/docs/api-references/staking/staking/validator-join.mdx +++ b/docs/api-references/staking/staking/validator-join.mdx @@ -19,4 +19,5 @@ Join as a validator by staking tokens | | --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 769d53f4..0ced6924 100644 --- a/docs/api-references/staking/staking/validator-prime.mdx +++ b/docs/api-references/staking/staking/validator-prime.mdx @@ -22,4 +22,5 @@ Prime a validator to prepare their stake record for the next epoch | | --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/wizard.mdx b/docs/api-references/staking/staking/wizard.mdx index 60733ceb..1923cc78 100644 --- a/docs/api-references/staking/staking/wizard.mdx +++ b/docs/api-references/staking/staking/wizard.mdx @@ -17,4 +17,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/vesting/claim.mdx b/docs/api-references/vesting/claim.mdx index 43d64d47..0d068d80 100644 --- a/docs/api-references/vesting/claim.mdx +++ b/docs/api-references/vesting/claim.mdx @@ -24,4 +24,5 @@ Claim vesting delegation withdrawals after unbonding period | | --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 index c0523790..3b984e6f 100644 --- a/docs/api-references/vesting/delegate.mdx +++ b/docs/api-references/vesting/delegate.mdx @@ -25,4 +25,5 @@ Delegate vesting-held tokens to a validator | | --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/undelegate.mdx b/docs/api-references/vesting/undelegate.mdx index 3ab6c783..c1c602c7 100644 --- a/docs/api-references/vesting/undelegate.mdx +++ b/docs/api-references/vesting/undelegate.mdx @@ -24,4 +24,5 @@ Undelegate all vesting delegation shares from a validator | | --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/claim.mdx b/docs/api-references/vesting/validator/claim.mdx index cf112d09..8b359a80 100644 --- a/docs/api-references/vesting/validator/claim.mdx +++ b/docs/api-references/vesting/validator/claim.mdx @@ -16,7 +16,7 @@ Claim vesting validator withdrawals after unbonding period | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --wallet <address> | Validator wallet 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 | | @@ -24,4 +24,5 @@ Claim vesting validator withdrawals after unbonding period | | --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 index f159851b..f6b28c43 100644 --- a/docs/api-references/vesting/validator/create.mdx +++ b/docs/api-references/vesting/validator/create.mdx @@ -25,4 +25,5 @@ Create a vesting-backed validator | | --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 index 13ead169..fcf8099b 100644 --- a/docs/api-references/vesting/validator/deposit.mdx +++ b/docs/api-references/vesting/validator/deposit.mdx @@ -17,7 +17,7 @@ Deposit more vesting-held tokens to a validator wallet | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --amount <amount> | Amount to deposit (in wei or with 'eth'/'gen' suffix) | No | | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --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 | | @@ -25,4 +25,5 @@ Deposit more vesting-held tokens to a validator wallet | | --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 index 02a3541c..a44f12cf 100644 --- a/docs/api-references/vesting/validator/exit.mdx +++ b/docs/api-references/vesting/validator/exit.mdx @@ -17,7 +17,7 @@ Exit vesting validator self-stake by withdrawing shares | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --shares <shares> | Number of shares to withdraw | No | | -| | --wallet <address> | Validator wallet 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 | | @@ -25,4 +25,5 @@ Exit vesting validator self-stake by withdrawing shares | | --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 index e2bd862b..35a4a9f1 100644 --- a/docs/api-references/vesting/validator/join.mdx +++ b/docs/api-references/vesting/validator/join.mdx @@ -25,4 +25,5 @@ Create a vesting-backed validator | | --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/cancel.mdx b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx index 13f88d5f..504afe5d 100644 --- a/docs/api-references/vesting/validator/operator-transfer/cancel.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx @@ -16,7 +16,7 @@ Cancel a vesting validator operator transfer | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --wallet <address> | Validator wallet 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 | | @@ -24,4 +24,5 @@ Cancel a vesting validator operator transfer | | --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 index 47dda0c7..20af8759 100644 --- a/docs/api-references/vesting/validator/operator-transfer/complete.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/complete.mdx @@ -16,7 +16,7 @@ Complete a vesting validator operator transfer | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | -| | --wallet <address> | Validator wallet 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 | | @@ -24,4 +24,5 @@ Complete a vesting validator operator transfer | | --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 index e3596106..473d51fc 100644 --- a/docs/api-references/vesting/validator/operator-transfer/initiate.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx @@ -18,7 +18,7 @@ Initiate a vesting validator operator transfer | Short | Long | Description | Required | Default | | --- | --- | --- | :---: | --- | | | --new-operator <address> | New operator address (deprecated, use positional arg) | No | | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --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 | | @@ -26,4 +26,5 @@ Initiate a vesting validator operator transfer | | --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 index 463f37b8..2379836d 100644 --- a/docs/api-references/vesting/validator/set-identity.mdx +++ b/docs/api-references/vesting/validator/set-identity.mdx @@ -25,7 +25,7 @@ Set vesting validator identity metadata | | --telegram <handle> | Telegram handle | No | | | | --github <handle> | GitHub handle | No | | | | --extra-cid <cid> | Extra data as IPFS CID or hex bytes (0x...) | No | | -| | --wallet <address> | Validator wallet address (deprecated, use positional arg) | No | | +| | --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 | | @@ -33,4 +33,5 @@ Set vesting validator identity metadata | | --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/withdraw.mdx b/docs/api-references/vesting/withdraw.mdx index 294c87a0..a065322e 100644 --- a/docs/api-references/vesting/withdraw.mdx +++ b/docs/api-references/vesting/withdraw.mdx @@ -20,4 +20,5 @@ Withdraw vested tokens to the beneficiary | | --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/src/commands/account/index.ts b/src/commands/account/index.ts index 88580d46..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(); diff --git a/src/commands/account/show.ts b/src/commands/account/show.ts index ee74506f..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,7 +15,11 @@ export class ShowAccountAction extends BaseAction { super(); } - private getNetwork(): GenLayerChain { + 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()); } @@ -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..8777a222 --- /dev/null +++ b/src/commands/balances/BalancesAction.ts @@ -0,0 +1,276 @@ +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 {readDescriptor, descriptorPath, isPidAlive} from "../../lib/wallet/sessionDescriptor"; +import {WalletSessionClient} from "../../lib/wallet/sessionClient"; +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[]; +} + +/** + * 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}); + + this.setSpinnerText(`Looking up vesting contracts for ${address}...`); + const vestingAddresses = await client.getBeneficiaryVestings( + address, + this.getFactoryLookupOptions(options), + ); + + const vestings: VestingBalanceSummary[] = []; + if (vestingAddresses.length > 0) { + // The active validator set is global; fetch it once and reuse across + // every vesting. Committed-delegation lookup is O(#vestings × #validators). + this.setSpinnerText("Enumerating validator set..."); + const activeValidators = await client.getActiveValidators(); + + for (let i = 0; i < vestingAddresses.length; i++) { + this.setSpinnerText( + `Computing balances for vesting ${i + 1}/${vestingAddresses.length} ` + + `(scanning ${activeValidators.length} validator${activeValidators.length === 1 ? "" : "s"})...`, + ); + vestings.push(await this.computeVestingSummary(client, vestingAddresses[i], activeValidators)); + } + } + + this.stopSpinner(); + + this.renderSummary({ + network: networkAlias, + chainId: chain.id, + address, + walletBalanceRaw, + vestings, + }); + } catch (error: any) { + this.failSpinner("Failed to fetch balances", error.message || error); + } + } + + /** + * Resolve the address to inspect without ever unlocking a keystore. Precedence + * mirrors resolveWalletMode so "who am I" follows the same connect-once rule as + * "how do I sign": + * 1. --beneficiary — explicit address 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". + */ + private async resolveAddress(options: BalancesOptions): Promise
{ + if (options.beneficiary) { + return options.beneficiary 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 --beneficiary
, select an account, or connect a wallet.", + ); + } + } + + /** + * 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. + */ + private 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; + } + } + + private async computeVestingSummary( + client: VestingClient, + vesting: Address, + activeValidators: 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 active validator (see execute() for the one-shot set). + // 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 activeValidators) { + 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.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 845831c6..d3eb1bcc 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -11,10 +11,12 @@ 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 { @@ -73,6 +75,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."); @@ -98,24 +101,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(); @@ -175,6 +187,8 @@ export class DeployAction extends BaseAction { }); } catch (error) { this.failSpinner("Error deploying contract", error); + } finally { + await this.closeBrowserSession(); } } } diff --git a/src/commands/contracts/index.ts b/src/commands/contracts/index.ts index c18b5df2..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})$/; @@ -107,19 +108,20 @@ const FEE_PROFILE_HELP = [ ].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-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) => { + 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); @@ -139,18 +141,19 @@ export function initializeContractsCommands(program: Command) { 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-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) => { + 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}); }); diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 42622b8a..882e3c3b 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -8,6 +8,7 @@ import {assertSuccessfulExecution, transactionConsensusStatus} from "./execution export interface WriteOptions extends ContractFeeCliOptions { args: any[]; rpc?: string; + wallet?: "keystore" | "browser"; } export class WriteAction extends BaseAction { @@ -20,6 +21,7 @@ export class WriteAction extends BaseAction { method, args, rpc, + wallet, fees, feeProfile, feePreset, @@ -30,8 +32,10 @@ export class WriteAction extends BaseAction { contractAddress: string; method: 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 { @@ -78,6 +82,8 @@ export class WriteAction extends BaseAction { }); } catch (error) { this.failSpinner("Error during write operation", error); + } finally { + await this.closeBrowserSession(); } } } diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index ab78e8c9..40644785 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -3,21 +3,19 @@ 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 { + createPublicClient, + createWalletClient, + http, + type PublicClient, + type WalletClient, + type Chain, + type Account, + type TransactionReceipt, +} 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 {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 +37,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; @@ -58,6 +60,63 @@ export class StakingAction extends BaseAction { 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"); + } + 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; + + 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}; + } + protected async getStakingClient(config: StakingConfig): Promise> { if (!this._stakingClient) { // Set account override if provided @@ -133,7 +192,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"); @@ -148,7 +209,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 @@ -273,12 +334,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..7a78b0f2 100644 --- a/src/commands/staking/delegatorClaim.ts +++ b/src/commands/staking/delegatorClaim.ts @@ -1,5 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface DelegatorClaimOptions extends StakingConfig { validator: string; @@ -12,6 +14,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 +44,42 @@ 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 {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "delegatorClaim", [ + delegatorAddress as Address, + options.validator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Claim delegator withdrawals`, + }); + + this.succeedSpinner("Claim successful!", { + transactionHash: receipt.transactionHash, + delegator: delegatorAddress, + validator: options.validator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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..300a69d1 100644 --- a/src/commands/staking/delegatorExit.ts +++ b/src/commands/staking/delegatorExit.ts @@ -1,5 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface DelegatorExitOptions extends StakingConfig { validator: string; @@ -12,6 +14,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 +59,58 @@ 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 {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "delegatorExit", [ + options.validator as Address, + shares, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Exit ${shares} shares from validator`, + }); + + // Check epoch to determine note + const readClient = await this.getReadOnlyStakingClient(options); + const epochInfo = await readClient.getEpochInfo(); + const isEpochZero = epochInfo.currentEpoch === 0n; + + this.succeedSpinner("Exit initiated successfully!", { + transactionHash: receipt.transactionHash, + validator: options.validator, + sharesWithdrawn: shares.toString(), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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..3820b838 100644 --- a/src/commands/staking/delegatorJoin.ts +++ b/src/commands/staking/delegatorJoin.ts @@ -1,6 +1,8 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; import chalk from "chalk"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface DelegatorJoinOptions extends StakingConfig { validator: string; @@ -13,6 +15,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 +47,44 @@ 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 {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "delegatorJoin", [ + options.validator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Delegate ${this.formatAmount(amount)} to validator`, + }); + + this.succeedSpinner("Successfully joined as delegator!", { + transactionHash: receipt.transactionHash, + validator: options.validator, + amount: this.formatAmount(amount), + delegator: session.signerAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 b5b097eb..699d8ca1 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -1,5 +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"; @@ -19,45 +20,51 @@ 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") + .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); + }); // 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 ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--staking-address
", "Staking contract address (overrides chain config)") - .action(async (options: ValidatorJoinOptions) => { - 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("--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 ", "built-in or custom network alias (see: genlayer network list)") - .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"), + ).action(async (validatorArg: string | undefined, options: ValidatorDepositOptions) => { const validator = validatorArg || options.validator; if (!validator) { console.error("Error: validator address is required"); @@ -67,16 +74,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 ", "built-in or custom network alias (see: genlayer network list)") - .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"); @@ -86,15 +94,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 ", "built-in or custom network alias (see: genlayer network list)") - .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"); @@ -104,16 +113,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 ", "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) => { + 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"); @@ -123,57 +133,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 ", "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) => { + 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 ", "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}); - }); + 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 ", "built-in or custom network alias (see: genlayer network list)") - .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"); @@ -184,17 +203,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 ", "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) => { + 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"); @@ -204,17 +224,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 ", "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) => { + 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"); @@ -224,17 +245,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 ", "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) => { + 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"); @@ -330,7 +352,10 @@ export function initializeStakingCommands(program: Command) { .option("--all", "Include banned validators") .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( + "--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)") diff --git a/src/commands/staking/setIdentity.ts b/src/commands/staking/setIdentity.ts index eb20acdb..ee5f1075 100644 --- a/src/commands/staking/setIdentity.ts +++ b/src/commands/staking/setIdentity.ts @@ -2,6 +2,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; import {toHex} from "viem"; +import {buildSetIdentityTx} from "../../lib/wallet/txBuilders"; export interface SetIdentityOptions extends StakingConfig { validator: string; @@ -22,6 +23,10 @@ export class SetIdentityAction extends StakingAction { } async execute(options: SetIdentityOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting validator identity..."); try { @@ -75,4 +80,61 @@ export class SetIdentityAction extends StakingAction { 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; + const {to, data} = buildSetIdentityTx(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, + }); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Set identity (${options.moniker})`, + }); + + const output: Record = { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + moniker: options.moniker, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/setOperator.ts b/src/commands/staking/setOperator.ts index 446cef56..5775a76c 100644 --- a/src/commands/staking/setOperator.ts +++ b/src/commands/staking/setOperator.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface SetOperatorOptions extends StakingConfig { validator: string; @@ -13,6 +14,10 @@ export class SetOperatorAction extends StakingAction { } async execute(options: SetOperatorOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting operator..."); try { @@ -43,4 +48,41 @@ 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; + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "setOperator", [ + options.operator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Set operator to ${options.operator}`, + }); + + this.succeedSpinner("Operator updated!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + newOperator: options.operator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to set operator", error.message || error); + } finally { + await session.close(); + } + } } diff --git a/src/commands/staking/validatorClaim.ts b/src/commands/staking/validatorClaim.ts index 319ffbbd..e3c8a5ac 100644 --- a/src/commands/staking/validatorClaim.ts +++ b/src/commands/staking/validatorClaim.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorClaimOptions extends StakingConfig { validator: string; @@ -12,6 +13,10 @@ export class ValidatorClaimAction extends StakingAction { } async execute(options: ValidatorClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming validator withdrawals..."); try { @@ -40,4 +45,38 @@ export class ValidatorClaimAction extends StakingAction { 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; + } + + this.startSpinner("Confirm the transaction in your browser wallet..."); + try { + const validatorWallet = options.validator as Address; + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "validatorClaim"); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Claim validator withdrawals`, + }); + + this.succeedSpinner("Claim successful!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } 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..6a71fd12 100644 --- a/src/commands/staking/validatorDeposit.ts +++ b/src/commands/staking/validatorDeposit.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorDepositOptions extends StakingConfig { amount: string; @@ -13,6 +14,10 @@ export class ValidatorDepositAction extends StakingAction { } async execute(options: ValidatorDepositOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Making validator deposit..."); try { @@ -45,4 +50,41 @@ 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 {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "validatorDeposit"); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Deposit ${this.formatAmount(amount)} to validator`, + }); + + this.succeedSpinner("Deposit successful!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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..6829cbdd 100644 --- a/src/commands/staking/validatorExit.ts +++ b/src/commands/staking/validatorExit.ts @@ -1,6 +1,7 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorExitOptions extends StakingConfig { validator: string; @@ -13,6 +14,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 { @@ -60,4 +65,56 @@ 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 {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as any, validatorWallet, "validatorExit", [shares]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Exit validator (${shares} shares)`, + }); + + // Check epoch to determine note + const readClient = await this.getReadOnlyStakingClient(options); + const epochInfo = await readClient.getEpochInfo(); + const isEpochZero = epochInfo.currentEpoch === 0n; + + this.succeedSpinner("Exit initiated successfully!", { + transactionHash: receipt.transactionHash, + validator: validatorWallet, + sharesWithdrawn: shares.toString(), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 8185fd91..d41281be 100644 --- a/src/commands/staking/validatorJoin.ts +++ b/src/commands/staking/validatorJoin.ts @@ -1,5 +1,6 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {buildValidatorJoinTx, extractValidatorWallet} from "../../lib/wallet/stakingTx"; export interface ValidatorJoinOptions extends StakingConfig { amount: string; @@ -12,6 +13,10 @@ export class ValidatorJoinAction extends StakingAction { } async execute(options: ValidatorJoinOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Creating a new validator..."); try { @@ -44,4 +49,49 @@ 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 {to, data} = buildValidatorJoinTx(session.stakingAddress, options.operator); + + this.log(` From (browser wallet): ${session.signerAddress}`); + if (options.operator) { + this.log(` Operator: ${options.operator}`); + } + + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Join as validator (${this.formatAmount(amount)})`, + }); + + const validatorWallet = extractValidatorWallet(receipt); + + this.succeedSpinner("Validator created successfully!", { + transactionHash: receipt.transactionHash, + validatorWallet, + amount: this.formatAmount(amount), + operator: options.operator ?? session.signerAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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..b0f12cf0 100644 --- a/src/commands/staking/validatorPrime.ts +++ b/src/commands/staking/validatorPrime.ts @@ -1,6 +1,8 @@ import {StakingAction, StakingConfig} from "./StakingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; import chalk from "chalk"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface ValidatorPrimeOptions extends StakingConfig { validator: string; @@ -12,6 +14,10 @@ export class ValidatorPrimeAction extends StakingAction { } async execute(options: ValidatorPrimeOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Priming validator..."); try { @@ -34,7 +40,46 @@ 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 {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "validatorPrime", [ + options.validator as Address, + ]); + + this.log(` From (browser wallet): ${session.signerAddress}`); + const receipt = await session.sendTransaction({ + to, + data, + label: `Prime ${options.validator}`, + }); + + this.succeedSpinner("Validator primed for next epoch!", { + transactionHash: receipt.transactionHash, + validator: options.validator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.gasUsed.toString(), + }); + } catch (error: any) { + this.failSpinner("Failed to prime validator", error.message || error); + } finally { + await session.close(); + } + } + async primeAll(options: StakingConfig): Promise { + if (this.isBrowserWallet(options)) { + return this.primeAllWithBrowserWallet(options); + } + this.startSpinner("Fetching validators..."); try { @@ -70,4 +115,47 @@ 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); + + 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 { + const {to, data} = buildTx(abi.STAKING_ABI as any, session.stakingAddress, "validatorPrime", [addr]); + const receipt = await session.sendTransaction({to, data, label: `Prime ${addr}`}); + console.log(chalk.green(`primed ${receipt.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/wizard.ts b/src/commands/staking/wizard.ts index d7137b8a..91d970da 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -1,13 +1,19 @@ -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 {formatEther, parseEther} from "viem"; -import {createClient} from "genlayer-js"; +import {createClient, abi} from "genlayer-js"; import {readFileSync, existsSync} from "fs"; import path from "path"; +import {buildValidatorJoinTx, buildSetIdentityTx, extractValidatorWallet} from "../../lib/wallet/stakingTx"; +import {buildTx} from "../../lib/wallet/txBuilders"; +import type {VestingClient, VestingValidatorJoinResult} from "../vesting/vestingTypes"; +import {vestingAvailableToStake} from "../../lib/vesting/availableToStake"; + +const BROWSER_WALLET_CHOICE = "__browser_wallet__"; export interface WizardOptions extends StakingConfig { skipIdentity?: boolean; @@ -17,6 +23,10 @@ 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; @@ -24,6 +34,7 @@ interface WizardState { operatorKeystorePath?: string; stakeAmount: string; validatorWalletAddress?: string; // the validator contract address returned from validatorJoin + ownerIsBrowserWallet?: boolean; identity?: { moniker: string; logoUri?: string; @@ -52,6 +63,9 @@ export class ValidatorWizardAction extends StakingAction { console.log(" GenLayer Validator Setup Wizard"); console.log("========================================\n"); + // Validate flag combinations up-front (throws on --account/--password + browser). + this.assertBrowserWalletFlags(options, "wizard"); + const state: Partial = {}; try { @@ -61,7 +75,11 @@ 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 @@ -86,13 +104,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); @@ -132,6 +194,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, @@ -148,7 +214,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", @@ -205,25 +276,147 @@ 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; + } + + /** + * 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"); + + // 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; + } } 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 = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); @@ -263,7 +456,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.`, ); } @@ -273,6 +466,94 @@ export class ValidatorWizardAction extends StakingAction { console.log("Balance sufficient!\n"); } + /** + * 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): Promise { console.log("Step 4: Operator Setup"); console.log("----------------------\n"); @@ -592,6 +873,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 { @@ -627,10 +921,174 @@ 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!); + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const {to, data} = buildValidatorJoinTx(session.stakingAddress, state.operatorAddress); + const receipt = await session.sendTransaction({ + to, + data, + value: amount, + label: `Join as validator (${this.formatAmount(amount)})`, + }); + + const validatorWallet = extractValidatorWallet(receipt); + state.validatorWalletAddress = ensureHexPrefix(validatorWallet); + + this.succeedSpinner("Validator created successfully!", { + transactionHash: receipt.transactionHash, + validatorWallet: state.validatorWalletAddress, + amount: this.formatAmount(amount), + operator: state.operatorAddress, + blockNumber: receipt.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; + + this.startSpinner("Confirm the transaction in your browser wallet..."); + + try { + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorJoin", [ + state.operatorAddress, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Create vesting validator (${this.formatAmount(amount)})`, + }); + + // The join receipt does not carry the wallet address; read the newest entry + // the vesting contract tracks. + let validatorWallet: Address | undefined; + try { + const readClient = this.getWizardVestingReadClient(state, options); + const wallets = await readClient.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: receipt.transactionHash, + vesting, + validatorWallet: state.validatorWalletAddress, + amount: this.formatAmount(amount), + operator: state.operatorAddress, + blockNumber: receipt.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"); + } + } + private async stepIdentitySetup(state: Partial, options: WizardOptions): Promise { console.log("Step 7: Identity Setup"); console.log("----------------------\n"); + // Setting identity on a vesting-backed validator wallet goes through the + // vesting contract (a different call than staking's setIdentity). Rather than + // fork the whole identity step, point the user at the dedicated command. + if (state.stakeSource === "vesting") { + const walletHint = state.validatorWalletAddress || ""; + console.log("Identity setup is skipped for vesting-backed validators in the wizard."); + console.log( + `Set it later with: genlayer vesting validator set-identity ${walletHint} ` + + `--vesting ${state.vestingContract} --moniker ""\n`, + ); + return; + } + const {setupIdentity} = await inquirer.prompt([ { type: "confirm", @@ -724,27 +1182,43 @@ export class ValidatorWizardAction extends StakingAction { 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 = ensureHexPrefix(state.validatorWalletAddress || state.accountAddress!); - // 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, - }); + try { + if (state.ownerIsBrowserWallet) { + const session = await this.ensureBrowserSession(state, options); + this.setSpinnerText("Confirm the identity transaction in your browser wallet..."); + const {to, data} = buildSetIdentityTx(validatorAddress, { + moniker, + logoUri: logoUri || undefined, + website: website || undefined, + description: description || undefined, + email: email || undefined, + twitter: twitter || undefined, + telegram: telegram || undefined, + github: github || undefined, + }); + await session.sendTransaction({to, data, label: `Set validator identity (${moniker})`}); + } else { + const client = await this.getStakingClient({ + ...options, + account: state.accountName, + network: state.networkAlias, + }); + + await client.setIdentity({ + validator: 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, + }); + } this.succeedSpinner("Validator identity set!"); console.log(""); @@ -770,6 +1244,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})`); @@ -801,7 +1282,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/vesting/VestingAction.ts b/src/commands/vesting/VestingAction.ts index 8e1627d0..ef1c504b 100644 --- a/src/commands/vesting/VestingAction.ts +++ b/src/commands/vesting/VestingAction.ts @@ -15,6 +15,10 @@ export interface VestingConfig { 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 { @@ -135,12 +139,26 @@ export class VestingAction extends BaseAction { 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}); + } + protected async resolveBeneficiaryVesting(client: VestingClient, options: VestingConfig): Promise
{ if (options.vesting) { return options.vesting as Address; } - const beneficiary = await this.getSignerAddress(); + // 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) { diff --git a/src/commands/vesting/claim.ts b/src/commands/vesting/claim.ts index 13481e81..3d27dc7f 100644 --- a/src/commands/vesting/claim.ts +++ b/src/commands/vesting/claim.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingClaimOptions extends VestingConfig { validator: string; @@ -11,6 +13,10 @@ export class VestingClaimAction extends VestingAction { } async execute(options: VestingClaimOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Claiming vesting delegation withdrawal..."); try { @@ -37,4 +43,41 @@ export class VestingClaimAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingDelegatorClaim", [options.validator]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Claim vesting delegation withdrawal", + }); + + this.succeedSpinner("Vesting claim successful!", { + transactionHash: receipt.transactionHash, + vesting, + validator: options.validator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 index e6c24720..7807e3d1 100644 --- a/src/commands/vesting/delegate.ts +++ b/src/commands/vesting/delegate.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingDelegateOptions extends VestingConfig { validator: string; @@ -12,6 +14,10 @@ export class VestingDelegateAction extends VestingAction { } async execute(options: VestingDelegateOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Delegating vesting tokens..."); try { @@ -42,4 +48,47 @@ export class VestingDelegateAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingDelegatorJoin", [ + options.validator, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Delegate ${this.formatAmount(amount)} to validator`, + }); + + this.succeedSpinner("Vesting delegation successful!", { + transactionHash: receipt.transactionHash, + vesting, + validator: options.validator, + beneficiary: session.signerAddress, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 index c73ef39f..19b24c9b 100644 --- a/src/commands/vesting/index.ts +++ b/src/commands/vesting/index.ts @@ -16,6 +16,7 @@ import { } 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 @@ -27,14 +28,16 @@ function addReadOptions(command: Command): Command { } function addWriteOptions(command: Command): Command { - return command - .option("--vesting
", "Vesting contract address (overrides beneficiary lookup)") - .option("--account ", "Account to use") - .option("--password ", "Password to unlock account (skips interactive prompt)") - .option("--network ", "built-in or custom network alias (see: genlayer network list)") - .option("--rpc ", "RPC URL for the network") - .option("--factory
", "VestingFactory address (overrides AddressManager lookup)") - .option("--address-manager
", "AddressManager address (overrides consensus lookup)"); + 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 { @@ -45,12 +48,15 @@ function addValidatorReadOptions(command: Command): Command { ); } -function addWalletOption(command: Command): Command { - return command.option("--wallet
", "Validator wallet address (deprecated, use positional arg)"); +function addValidatorWalletOption(command: Command): Command { + return command.option( + "--validator-wallet
", + "Validator wallet address (deprecated, use positional arg)", + ); } -function requireWallet(walletArg: string | undefined, options: {wallet?: string}): string { - const wallet = walletArg || options.wallet; +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); @@ -156,7 +162,7 @@ export function initializeVestingCommands(program: Command) { addCreateCommand("join"); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("deposit [wallet]") .description("Deposit more vesting-held tokens to a validator wallet") @@ -165,11 +171,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorDepositOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorDepositAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("exit [wallet]") .description("Exit vesting validator self-stake by withdrawing shares") @@ -178,11 +184,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorExitOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorExitAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("claim [wallet]") .description("Claim vesting validator withdrawals after unbonding period"), @@ -190,13 +196,13 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorClaimOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorClaimAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); const operatorTransfer = validator.command("operator-transfer").description("Manage vesting validator operator transfers"); addWriteOptions( - addWalletOption( + addValidatorWalletOption( operatorTransfer .command("initiate [wallet] [newOperator]") .description("Initiate a vesting validator operator transfer") @@ -210,11 +216,11 @@ export function initializeVestingCommands(program: Command) { process.exit(1); } const action = new VestingValidatorInitiateOperatorTransferAction(); - await action.execute({...options, wallet, newOperator}); + await action.execute({...options, walletAddress: wallet, newOperator}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( operatorTransfer .command("complete [wallet]") .description("Complete a vesting validator operator transfer"), @@ -222,11 +228,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorCompleteOperatorTransferAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( operatorTransfer .command("cancel [wallet]") .description("Cancel a vesting validator operator transfer"), @@ -234,11 +240,11 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorOperatorTransferOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorCancelOperatorTransferAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addWriteOptions( - addWalletOption( + addValidatorWalletOption( validator .command("set-identity [wallet]") .description("Set vesting validator identity metadata") @@ -255,7 +261,7 @@ export function initializeVestingCommands(program: Command) { ).action(async (walletArg: string | undefined, options: VestingValidatorSetIdentityOptions) => { const wallet = requireWallet(walletArg, options); const action = new VestingValidatorSetIdentityAction(); - await action.execute({...options, wallet}); + await action.execute({...options, walletAddress: wallet}); }); addValidatorReadOptions( diff --git a/src/commands/vesting/undelegate.ts b/src/commands/vesting/undelegate.ts index bc232142..93f4557e 100644 --- a/src/commands/vesting/undelegate.ts +++ b/src/commands/vesting/undelegate.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingUndelegateOptions extends VestingConfig { validator: string; @@ -11,6 +13,10 @@ export class VestingUndelegateAction extends VestingAction { } async execute(options: VestingUndelegateOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating vesting undelegation..."); try { @@ -50,4 +56,55 @@ export class VestingUndelegateAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const stakeInfo = await readClient.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; + } + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingDelegatorExit", [ + options.validator, + shares, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Undelegate ${shares.toString()} shares from validator`, + }); + + this.succeedSpinner("Vesting undelegation initiated!", { + transactionHash: receipt.transactionHash, + vesting, + validator: options.validator, + sharesWithdrawn: shares.toString(), + stake: stakeInfo.stake, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 index c6d33490..2b806323 100644 --- a/src/commands/vesting/validatorClaim.ts +++ b/src/commands/vesting/validatorClaim.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorClaimOptions extends VestingConfig { - wallet: string; + walletAddress: string; } export class VestingValidatorClaimAction extends VestingAction { @@ -11,23 +13,27 @@ export class VestingValidatorClaimAction extends VestingAction { } 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.wallet}...`); + this.setSpinnerText(`Claiming vesting validator withdrawal from wallet ${options.walletAddress}...`); const result = await client.vestingValidatorClaim({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -37,4 +43,43 @@ export class VestingValidatorClaimAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorClaim", [ + options.walletAddress, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Claim vesting validator withdrawal", + }); + + this.succeedSpinner("Vesting validator claim successful!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 index 0b76aaf0..c23fdc71 100644 --- a/src/commands/vesting/validatorCreate.ts +++ b/src/commands/vesting/validatorCreate.ts @@ -1,5 +1,7 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorCreateOptions extends VestingConfig { operator: string; @@ -12,6 +14,10 @@ export class VestingValidatorCreateAction extends VestingAction { } async execute(options: VestingValidatorCreateOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Creating vesting-backed validator..."); try { @@ -54,4 +60,57 @@ export class VestingValidatorCreateAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorJoin", [ + options.operator, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Create vesting validator", + }); + + // 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; + try { + const wallets = await readClient.getValidatorWallets(vesting); + validatorWallet = wallets[wallets.length - 1]; + } catch { + validatorWallet = "(read getValidatorWallets to inspect)"; + } + + this.succeedSpinner("Vesting-backed validator created!", { + transactionHash: receipt.transactionHash, + vesting, + validatorWallet, + operator: options.operator, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 index f6a0b802..5f4cedc0 100644 --- a/src/commands/vesting/validatorDeposit.ts +++ b/src/commands/vesting/validatorDeposit.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorDepositOptions extends VestingConfig { - wallet: string; + walletAddress: string; amount: string; } @@ -12,6 +14,10 @@ export class VestingValidatorDepositAction extends VestingAction { } async execute(options: VestingValidatorDepositOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Depositing vesting tokens to validator..."); try { @@ -19,18 +25,18 @@ export class VestingValidatorDepositAction extends VestingAction { const amount = this.parseAmount(options.amount); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.wallet}...`); + this.setSpinnerText(`Depositing ${this.formatAmount(amount)} from vesting ${vesting} to wallet ${options.walletAddress}...`); const result = await client.vestingValidatorDeposit({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, amount, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, amount: this.formatAmount(amount), blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), @@ -41,4 +47,46 @@ export class VestingValidatorDepositAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorDeposit", [ + options.walletAddress, + amount, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Deposit ${this.formatAmount(amount)} to validator wallet`, + }); + + this.succeedSpinner("Vesting validator deposit successful!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 index 18e53363..7c2005d5 100644 --- a/src/commands/vesting/validatorExit.ts +++ b/src/commands/vesting/validatorExit.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorExitOptions extends VestingConfig { - wallet: string; + walletAddress: string; shares: string; } @@ -12,6 +14,10 @@ export class VestingValidatorExitAction extends VestingAction { } async execute(options: VestingValidatorExitOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating vesting validator exit..."); try { @@ -27,18 +33,18 @@ export class VestingValidatorExitAction extends VestingAction { const client = await this.getVestingClient(options); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Exiting ${shares.toString()} validator shares from wallet ${options.wallet}...`); + this.setSpinnerText(`Exiting ${shares.toString()} validator shares from wallet ${options.walletAddress}...`); const result = await client.vestingValidatorExit({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, shares, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, sharesWithdrawn: shares.toString(), blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), @@ -50,4 +56,55 @@ export class VestingValidatorExitAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorExit", [ + options.walletAddress, + shares, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Exit ${shares.toString()} validator shares`, + }); + + this.succeedSpinner("Vesting validator exit initiated!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + sharesWithdrawn: shares.toString(), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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/validatorOperatorTransfer.ts b/src/commands/vesting/validatorOperatorTransfer.ts index 51b026e4..607fcb82 100644 --- a/src/commands/vesting/validatorOperatorTransfer.ts +++ b/src/commands/vesting/validatorOperatorTransfer.ts @@ -1,8 +1,10 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingValidatorOperatorTransferOptions extends VestingConfig { - wallet: string; + walletAddress: string; newOperator?: string; } @@ -12,6 +14,10 @@ export class VestingValidatorInitiateOperatorTransferAction extends VestingActio } async execute(options: VestingValidatorOperatorTransferOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Initiating vesting validator operator transfer..."); try { @@ -23,18 +29,18 @@ export class VestingValidatorInitiateOperatorTransferAction extends VestingActio const client = await this.getVestingClient(options); const vesting = await this.resolveBeneficiaryVesting(client, options); - this.setSpinnerText(`Initiating operator transfer for wallet ${options.wallet} to ${options.newOperator}...`); + this.setSpinnerText(`Initiating operator transfer for wallet ${options.walletAddress} to ${options.newOperator}...`); const result = await client.vestingValidatorInitiateOperatorTransfer({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, newOperator: options.newOperator as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, newOperator: options.newOperator, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), @@ -45,6 +51,52 @@ export class VestingValidatorInitiateOperatorTransferAction extends VestingActio 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorInitiateOperatorTransfer", [ + options.walletAddress, + options.newOperator, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Initiate validator operator transfer", + }); + + this.succeedSpinner("Vesting validator operator transfer initiated!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + newOperator: options.newOperator, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 { @@ -53,23 +105,27 @@ export class VestingValidatorCompleteOperatorTransferAction extends VestingActio } 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.wallet}...`); + this.setSpinnerText(`Completing operator transfer for wallet ${options.walletAddress}...`); const result = await client.vestingValidatorCompleteOperatorTransfer({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -79,6 +135,45 @@ export class VestingValidatorCompleteOperatorTransferAction extends VestingActio 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorCompleteOperatorTransfer", [ + options.walletAddress, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Complete validator operator transfer", + }); + + this.succeedSpinner("Vesting validator operator transfer completed!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 { @@ -87,23 +182,27 @@ export class VestingValidatorCancelOperatorTransferAction extends VestingAction } 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.wallet}...`); + this.setSpinnerText(`Cancelling operator transfer for wallet ${options.walletAddress}...`); const result = await client.vestingValidatorCancelOperatorTransfer({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, }); const output = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -113,4 +212,43 @@ export class VestingValidatorCancelOperatorTransferAction extends VestingAction 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorCancelOperatorTransfer", [ + options.walletAddress, + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Cancel validator operator transfer", + }); + + this.succeedSpinner("Vesting validator operator transfer cancelled!", { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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 index 368e7e5b..13bb7a40 100644 --- a/src/commands/vesting/validatorSetIdentity.ts +++ b/src/commands/vesting/validatorSetIdentity.ts @@ -1,9 +1,11 @@ import {VestingAction, VestingConfig} from "./VestingAction"; import type {Address} from "genlayer-js/types"; import {toHex} from "viem"; +import {abi} from "genlayer-js"; +import {buildTx, encodeExtraCid} from "../../lib/wallet/txBuilders"; export interface VestingValidatorSetIdentityOptions extends VestingConfig { - wallet: string; + walletAddress: string; moniker?: string; logoUri?: string; website?: string; @@ -21,6 +23,10 @@ export class VestingValidatorSetIdentityAction extends VestingAction { } async execute(options: VestingValidatorSetIdentityOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Setting vesting validator identity..."); try { @@ -28,11 +34,11 @@ export class VestingValidatorSetIdentityAction extends VestingAction { const vesting = await this.resolveBeneficiaryVesting(client, options); const extraCid = options.extraCid ? toHex(new TextEncoder().encode(options.extraCid)) : "0x"; - this.setSpinnerText(`Setting identity for vesting validator wallet ${options.wallet}...`); + this.setSpinnerText(`Setting identity for vesting validator wallet ${options.walletAddress}...`); const result = await client.vestingValidatorSetIdentity({ vesting, - wallet: options.wallet as Address, + wallet: options.walletAddress as Address, moniker: options.moniker || "", logoUri: options.logoUri || "", website: options.website || "", @@ -47,7 +53,7 @@ export class VestingValidatorSetIdentityAction extends VestingAction { const output: Record = { transactionHash: result.transactionHash, vesting, - wallet: options.wallet, + wallet: options.walletAddress, blockNumber: result.blockNumber.toString(), gasUsed: result.gasUsed.toString(), }; @@ -67,4 +73,64 @@ export class VestingValidatorSetIdentityAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingValidatorSetIdentity", [ + options.walletAddress, + options.moniker || "", + options.logoUri || "", + options.website || "", + options.description || "", + options.email || "", + options.twitter || "", + options.telegram || "", + options.github || "", + encodeExtraCid(options.extraCid), + ]); + + const receipt = await session.sendTransaction({ + to, + data, + label: "Set vesting validator identity", + }); + + const output: Record = { + transactionHash: receipt.transactionHash, + vesting, + wallet: options.walletAddress, + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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/withdraw.ts b/src/commands/vesting/withdraw.ts index 024d58e3..0412c938 100644 --- a/src/commands/vesting/withdraw.ts +++ b/src/commands/vesting/withdraw.ts @@ -1,4 +1,6 @@ import {VestingAction, VestingConfig} from "./VestingAction"; +import {abi} from "genlayer-js"; +import {buildTx} from "../../lib/wallet/txBuilders"; export interface VestingWithdrawOptions extends VestingConfig { amount: string; @@ -10,6 +12,10 @@ export class VestingWithdrawAction extends VestingAction { } async execute(options: VestingWithdrawOptions): Promise { + if (this.isBrowserWallet(options)) { + return this.executeWithBrowserWallet(options); + } + this.startSpinner("Withdrawing vested tokens..."); try { @@ -38,4 +44,43 @@ export class VestingWithdrawAction extends VestingAction { 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 readClient = await this.getReadOnlyVestingClient(options); + const vesting = await this.resolveBeneficiaryVesting(readClient, options); + const amount = this.parseAmount(options.amount); + + const {to, data} = buildTx(abi.VESTING_ABI as any, vesting, "vestingWithdraw", [amount]); + + const receipt = await session.sendTransaction({ + to, + data, + label: `Withdraw ${this.formatAmount(amount)} from vesting`, + }); + + this.succeedSpinner("Vesting withdrawal successful!", { + transactionHash: receipt.transactionHash, + vesting, + beneficiary: session.signerAddress, + amount: this.formatAmount(amount), + blockNumber: receipt.blockNumber.toString(), + gasUsed: receipt.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..e66982d6 --- /dev/null +++ b/src/commands/wallet/WalletAction.ts @@ -0,0 +1,190 @@ +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: ...)"); + + 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 c8bc1850..81015faa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ 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); @@ -27,6 +29,8 @@ export function initializeCLI() { 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 92a0d665..e59faa13 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -13,6 +13,9 @@ import { 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"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -40,7 +43,11 @@ export function resolveNetwork(stored: string | undefined, customNetworks?: Cust if (!baseNetwork) { throw new Error(`Custom network ${stored} references unknown base network: ${customNetwork.base}`); } - return applyCustomNetworkProfile(baseNetwork, customNetwork); + // 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) @@ -70,6 +77,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(); @@ -81,6 +90,116 @@ export class BaseAction extends ConfigFileManager { 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 @@ -117,6 +236,21 @@ export class BaseAction extends ConfigFileManager { protected async getClient(rpcUrl?: string, readOnly: boolean = false): Promise> { if (!this._genlayerClient) { 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, 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..90f04c4d --- /dev/null +++ b/src/lib/wallet/bridgePage.ts @@ -0,0 +1,266 @@ +/** + * 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. Detect window.ethereum; 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..e4abfad2 --- /dev/null +++ b/src/lib/wallet/browserBridge.ts @@ -0,0 +1,694 @@ +import http from "node:http"; +import {randomUUID} 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; + from?: Address; +} + +interface NextWaiter { + resolve: (payload: unknown) => void; + timer: NodeJS.Timeout; +} + +const DEFAULT_CONNECT_TIMEOUT = 180_000; +const DEFAULT_TX_TIMEOUT = 300_000; +// LONG_POLL_MS is imported from ./sessionConstants (single source of truth). + +/** + * 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 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.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); + } + + 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, + }; + 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"); + + 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 (req.headers["x-bridge-token"] !== this.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"}); + } + } + }); + 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"}); + }); + 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"}); + }); + 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 = body?.from as Address | undefined; + if (pending.from) this.lastResultFrom.set(id, pending.from); + + 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 readJson(req: http.IncomingMessage): Promise { + return new Promise(resolve => { + const chunks: Buffer[] = []; + req.on("data", c => chunks.push(c as Buffer)); + req.on("end", () => { + try { + const raw = Buffer.concat(chunks).toString("utf-8"); + resolve(raw ? JSON.parse(raw) : {}); + } catch { + resolve({}); + } + }); + req.on("error", () => resolve({})); + }); + } +} diff --git a/src/lib/wallet/browserSend.ts b/src/lib/wallet/browserSend.ts new file mode 100644 index 00000000..a2479312 --- /dev/null +++ b/src/lib/wallet/browserSend.ts @@ -0,0 +1,316 @@ +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; +} + +/** + * 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, + }; +} + +/** + * 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 hash = await transport.sendTransaction({ + to: tx.to, + data: tx.data, + value: tx.value, + gas: tx.gas, + label: tx.label, + }); + + 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 hash = 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", + }); + 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, + ) {} + sendTransaction(tx: Omit): Promise { + return 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: ...)"); + + 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..ae26de20 --- /dev/null +++ b/src/lib/wallet/sessionClient.ts @@ -0,0 +1,148 @@ +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; +} + +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 rec.txHash as Hash; + 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..ca241437 --- /dev/null +++ b/src/lib/wallet/sessionConstants.ts @@ -0,0 +1,50 @@ +/** + * 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. + */ + +/** Page long-poll window (existing bridge behaviour). */ +export const 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 = 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 = 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/stakingTx.ts b/src/lib/wallet/stakingTx.ts new file mode 100644 index 00000000..3465e496 --- /dev/null +++ b/src/lib/wallet/stakingTx.ts @@ -0,0 +1,12 @@ +/** + * Back-compat shim. The staking tx builders now live in the generalized + * `txBuilders.ts` (shared across staking + vesting). Re-exported here so the + * PR #367 call sites and tests that import from `stakingTx` keep working. + */ +export { + buildValidatorJoinTx, + buildSetIdentityTx, + extractValidatorWallet, + type BuiltTx, + type IdentityFields, +} from "./txBuilders"; diff --git a/src/lib/wallet/txBuilders.ts b/src/lib/wallet/txBuilders.ts new file mode 100644 index 00000000..70b4e309 --- /dev/null +++ b/src/lib/wallet/txBuilders.ts @@ -0,0 +1,114 @@ +import {encodeFunctionData, decodeEventLog, toHex, type Abi, type TransactionReceipt} from "viem"; +import {abi} from "genlayer-js"; +import type {Address} from "genlayer-js/types"; + +/** + * Pure transaction-building / event-decoding helpers for the browser-wallet + * signing path (Lane A: staking + vesting). These expose only the calldata so + * the CLI can hand `{to, data}` to a browser wallet that signs-and-broadcasts + * (eth_sendTransaction) instead of the SDK's sign-then-sendRawTransaction path + * (which MetaMask cannot satisfy). + * + * Dependency-free and side-effect-free so they are trivially unit-testable. + */ + +export interface BuiltTx { + to: Address; + data: `0x${string}`; +} + +function normalizeAddress(address: string): Address { + return (address.startsWith("0x") ? address : `0x${address}`) as Address; +} + +/** + * Generic calldata builder: `encodeFunctionData` against any ABI + a target. + * Per-command usage is a one-liner; avoids bespoke builders per function. + */ +export function buildTx(abiDef: Abi, to: string, functionName: string, args?: unknown[]): BuiltTx { + const data = encodeFunctionData( + args && args.length > 0 ? {abi: abiDef, functionName, args} : {abi: abiDef, functionName}, + ); + return {to: normalizeAddress(to), data}; +} + +/** + * Encode an `extraCid` identity field to bytes hex. `0x`-prefixed input passes + * through; anything else is UTF-8 encoded; empty/undefined → "0x". Mirrors the + * genlayer-js encoding used by setIdentity / vestingValidatorSetIdentity. + */ +export function encodeExtraCid(extraCid?: string): `0x${string}` { + if (!extraCid) return "0x"; + return extraCid.startsWith("0x") ? (extraCid as `0x${string}`) : toHex(new TextEncoder().encode(extraCid)); +} + +/** + * Build the calldata for `validatorJoin`. Two payable overloads exist: + * `validatorJoin(address _operator)` and `validatorJoin()`. Stake is msg.value + * (carried separately as the tx `value`, not encoded here). + */ +export function buildValidatorJoinTx(stakingAddress: string, operator?: string): BuiltTx { + return buildTx( + abi.STAKING_ABI as unknown as Abi, + stakingAddress, + "validatorJoin", + operator ? [operator as Address] : undefined, + ); +} + +export interface IdentityFields { + moniker: string; + logoUri?: string; + website?: string; + description?: string; + email?: string; + twitter?: string; + telegram?: string; + github?: string; + extraCid?: string; +} + +/** + * Build the calldata for `setIdentity` on a ValidatorWallet contract. + * `to` is the validator wallet address (not the staking contract). + */ +export function buildSetIdentityTx(validatorWallet: string, identity: IdentityFields): BuiltTx { + return buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, validatorWallet, "setIdentity", [ + identity.moniker, + identity.logoUri || "", + identity.website || "", + identity.description || "", + identity.email || "", + identity.twitter || "", + identity.telegram || "", + identity.github || "", + encodeExtraCid(identity.extraCid), + ]); +} + +/** + * Decode the `ValidatorJoin` event from a receipt's logs and return the new + * ValidatorWallet contract address. Throws with the same "event not found" + * style as genlayer-js if no matching log is present. + */ +export function extractValidatorWallet(receipt: TransactionReceipt): Address { + for (const log of receipt.logs) { + try { + const decoded = decodeEventLog({ + abi: abi.STAKING_ABI, + data: log.data, + topics: log.topics, + }); + if (decoded.eventName === "ValidatorJoin") { + return (decoded.args as unknown as {validator: Address}).validator; + } + } catch { + // Not a ValidatorJoin event - keep searching. + } + } + + throw new Error( + `ValidatorJoin event not found in transaction ${receipt.transactionHash}. ` + + `Transaction succeeded but validator wallet address could not be determined.`, + ); +} 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/tests/actions/balances.test.ts b/tests/actions/balances.test.ts new file mode 100644 index 00000000..53f19464 --- /dev/null +++ b/tests/actions/balances.test.ts @@ -0,0 +1,247 @@ +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), + getBeneficiaryVestings: vi.fn().mockResolvedValue([]), + getVestingState: vi.fn(), + getValidatorWallets: vi.fn().mockResolvedValue([]), + validatorDeposited: vi.fn().mockResolvedValue(0n), + getActiveValidators: 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("(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"); + + await action.execute({}); + + 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({}); + + 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("(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 index a9e6f0f8..880af1fb 100644 --- a/tests/actions/customNetworkProfiles.test.ts +++ b/tests/actions/customNetworkProfiles.test.ts @@ -261,6 +261,9 @@ describe("custom network profiles", () => { 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", () => { diff --git a/tests/actions/deploy.test.ts b/tests/actions/deploy.test.ts index b6453055..35ba7877 100644 --- a/tests/actions/deploy.test.ts +++ b/tests/actions/deploy.test.ts @@ -690,4 +690,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/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/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..a67ce704 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -6,6 +6,7 @@ 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 {StakingInfoAction} from "../../src/commands/staking/stakingInfo"; // Mock genlayer-js @@ -21,14 +22,38 @@ vi.mock("genlayer-js", () => ({ }), abi: { STAKING_ABI: [], + VALIDATOR_WALLET_ABI: [], }, })); +// buildTx is used by the browser-wallet paths of ValidatorDeposit/SetOperator/ +// DelegatorClaim. The genlayer-js mock stubs the ABIs with [], so mock the pure +// tx-builder helper too (real behavior covered in tests/libs/txBuilders.test.ts). +vi.mock("../../src/lib/wallet/txBuilders", () => ({ + buildTx: vi.fn(() => ({to: "0xTarget", data: "0xdata"})), +})); + 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"]}}, + }, +})); + +// The genlayer-js mock above stubs `abi` with an empty ABI, so mock the pure +// tx-builder module (its real behavior is covered in tests/libs/stakingTx.test.ts). +vi.mock("../../src/lib/wallet/stakingTx", () => ({ + buildValidatorJoinTx: vi.fn(() => ({to: "0xStaking", data: "0xdata"})), + buildSetIdentityTx: vi.fn(() => ({to: "0xValidatorWallet", data: "0xidentity"})), + extractValidatorWallet: vi.fn(() => "0xValidatorWalletFromEvent"), })); const mockTxResult = { @@ -101,7 +126,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 () => { @@ -143,7 +171,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 +210,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)); }); }); @@ -278,3 +312,275 @@ describe("StakingInfoAction", () => { }); }); }); + +describe("ValidatorJoinAction --wallet browser", () => { + let action: ValidatorJoinAction; + const mockReceipt = { + transactionHash: "0xBrowserHash", + blockNumber: 456n, + gasUsed: 30000n, + status: "success", + }; + + 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 session 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 close = vi.fn().mockResolvedValue(undefined); + const sendTransaction = vi.fn().mockResolvedValue(mockReceipt); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: vi.fn()}, + close, + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction, + }); + + await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + + expect((action as any).getBrowserWalletSession).toHaveBeenCalledWith( + expect.any(Object), + "validator-join", + ); + expect(sendTransaction).toHaveBeenCalledOnce(); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + expect(getSignerAddressSpy).not.toHaveBeenCalled(); + expect(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 close = vi.fn().mockResolvedValue(undefined); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge: {close: vi.fn()}, + close, + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction: vi.fn().mockRejectedValue(new Error("Transaction rejected in wallet")), + }); + + await action.execute({amount: "42000gen", wallet: "browser", stakingAddress: "0xStaking"}); + + expect(action["failSpinner"]).toHaveBeenCalledWith( + "Failed to create validator", + "Transaction rejected in wallet", + ); + expect(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. 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", + sendTransaction: vi.fn().mockResolvedValue({ + transactionHash: "0xBH", + blockNumber: 5n, + gasUsed: 6n, + status: "success", + }), + 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 session, 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); + + await action.execute({validator: "0xVW", amount: "1gen", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + 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({ + sendTransaction: vi.fn().mockRejectedValue(new Error("Rejected in wallet")), + }); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue(session); + + 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 session, 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); + + await action.execute({validator: "0xVW", operator: "0xOp", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + 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 session, 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); + + await action.execute({validator: "0xVal", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + 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", + }), + ); + }); +}); diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts new file mode 100644 index 00000000..3831f1d6 --- /dev/null +++ b/tests/actions/stakingWizard.test.ts @@ -0,0 +1,417 @@ +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"; +import {buildTx} from "../../src/lib/wallet/txBuilders"; + +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: []}, +})); + +// Pure tx-builders (real behavior covered in tests/libs/stakingTx.test.ts). +vi.mock("../../src/lib/wallet/stakingTx", () => ({ + buildValidatorJoinTx: vi.fn(() => ({to: "0xStaking", data: "0xjoin"})), + buildSetIdentityTx: vi.fn(() => ({to: "0xValidatorWallet", data: "0xidentity"})), + extractValidatorWallet: vi.fn(() => "0xValidatorWalletFromEvent"), +})); + +// Generic vesting calldata builder (real behavior covered in tests/libs/txBuilders.test.ts). +vi.mock("../../src/lib/wallet/txBuilders", () => ({ + buildTx: vi.fn(() => ({to: "0xVesting", data: "0xvestingjoin"})), +})); + +describe("ValidatorWizardAction --wallet browser (owner)", () => { + let action: ValidatorWizardAction; + let sendTransaction: ReturnType; + let bridgeClose: ReturnType; + let getBrowserWalletSessionSpy: any; + let getStakingClientSpy: any; + + 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. + sendTransaction = vi.fn().mockResolvedValue({ + transactionHash: "0xJoinHash", + blockNumber: 10n, + gasUsed: 1000n, + status: "success", + }); + 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", + 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, + }); + + // 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 bridge, 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 tx went through the bridge, not the keystore staking client. + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xStaking", + data: "0xjoin", + label: 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 + // (identity is skipped for vesting-backed validators) + vi.mocked(inquirer.prompt) + .mockResolvedValueOnce({stakeSource: "vesting"}) + .mockResolvedValueOnce({useOperator: true}) + .mockResolvedValueOnce({operatorChoice: "existing"}) + .mockResolvedValueOnce({operatorAddress: "0xOperatorAddr"}) + .mockResolvedValueOnce({stakeAmount: "42gen"}) + .mockResolvedValueOnce({confirm: true}); + + 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"); + + // Calldata was built for vestingValidatorJoin with the chosen operator + amount. + expect(vi.mocked(buildTx)).toHaveBeenCalledWith([], "0xVesting", "vestingValidatorJoin", [ + "0xOperatorAddr", + 42n * 10n ** 18n, + ]); + + // Join went through the SAME browser session, no msg.value, never the keystore. + expect(sendTransaction).toHaveBeenCalledWith( + expect.objectContaining({ + to: "0xVesting", + data: "0xvestingjoin", + label: expect.stringContaining("Create vesting validator"), + }), + ); + expect(sendTransaction.mock.calls[0][0].value).toBeUndefined(); + expect(getStakingClientSpy).not.toHaveBeenCalled(); + + expect(action["succeedSpinner"]).toHaveBeenCalledWith( + "Vesting-backed validator created successfully!", + expect.objectContaining({vesting: "0xVesting", validatorWallet: "0xVWallet"}), + ); + }); + + 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 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, + }); + getStakingClientSpy = vi.spyOn(action as any, "getStakingClient").mockResolvedValue({ + validatorJoin, + vestingValidatorJoin, + 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}); + + 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}); + + 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(); + }); +}); diff --git a/tests/actions/vesting.test.ts b/tests/actions/vesting.test.ts new file mode 100644 index 00000000..b7aa2dd0 --- /dev/null +++ b/tests/actions/vesting.test.ts @@ -0,0 +1,160 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {VestingDelegateAction} from "../../src/commands/vesting/delegate"; +import {VestingWithdrawAction} from "../../src/commands/vesting/withdraw"; + +// Mock genlayer-js. The browser-wallet path builds calldata via the mocked +// txBuilders helper below, 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"]}}, + }, +})); + +// The genlayer-js mock stubs `abi` with empty ABIs, so mock the pure tx-builder +// module (real behavior is covered in tests/libs/txBuilders.test.ts). +vi.mock("../../src/lib/wallet/txBuilders", () => ({ + buildTx: vi.fn(() => ({to: "0xVesting", data: "0xdata"})), +})); + +// Shared vesting browser-wallet session factory. Commands call `session.close()` +// in their finally block. +function makeVestingSession(overrides: Record = {}) { + return { + signerAddress: "0xBen", + sendTransaction: vi.fn().mockResolvedValue({ + transactionHash: "0xVH", + blockNumber: 9n, + gasUsed: 8n, + }), + 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(() => {}); + // Read-only client used to resolve the beneficiary vesting; account-less. + vi.spyOn(action, "getReadOnlyVestingClient").mockResolvedValue({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xVesting"]), + }); + // Simplest resolution: the vesting address is fixed. + 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 session, 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); + + await action.execute({validator: "0xVal", amount: "1gen", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + 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({ + sendTransaction: vi.fn().mockRejectedValue(new Error("Rejected in wallet")), + }); + vi.spyOn(action as any, "getVestingBrowserSession").mockResolvedValue(session); + + 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 session, 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); + + await action.execute({amount: "1gen", wallet: "browser"}); + + expect(session.sendTransaction).toHaveBeenCalledOnce(); + 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", + }), + ); + }); +}); 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/write.test.ts b/tests/actions/write.test.ts index 22938c05..b5a9bbab 100644 --- a/tests/actions/write.test.ts +++ b/tests/actions/write.test.ts @@ -371,4 +371,47 @@ describe("WriteAction", () => { 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..a4dbc0d6 --- /dev/null +++ b/tests/commands/balances.test.ts @@ -0,0 +1,73 @@ +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"]}}}, + 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 mockClient = { + getBalance: vi.fn(), + getBeneficiaryVestings: vi.fn(), + getVestingState: vi.fn(), + getValidatorWallets: vi.fn(), + validatorDeposited: vi.fn(), + getActiveValidators: vi.fn(), + vestingDepositedPerValidator: vi.fn(), +}; + +describe("balances command", () => { + let program: Command; + let consoleLogSpy: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockClient.getBalance.mockResolvedValue(0n); + mockClient.getBeneficiaryVestings.mockResolvedValue([]); + mockClient.getActiveValidators.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/staking.test.ts b/tests/commands/staking.test.ts index 3866529e..4107a4b2 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -13,7 +13,9 @@ 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"); @@ -50,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", @@ -86,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({ @@ -100,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({ @@ -324,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/vesting.test.ts b/tests/commands/vesting.test.ts index 88f0ac38..33851bfb 100644 --- a/tests/commands/vesting.test.ts +++ b/tests/commands/vesting.test.ts @@ -2,6 +2,8 @@ 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(), @@ -490,4 +492,64 @@ describe("vesting commands", () => { 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/index.test.ts b/tests/index.test.ts index a5d99f68..815ce0b8 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -53,6 +53,14 @@ 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/browserBridge.test.ts b/tests/libs/browserBridge.test.ts new file mode 100644 index 00000000..45ca25ec --- /dev/null +++ b/tests/libs/browserBridge.test.ts @@ -0,0 +1,412 @@ +import {describe, test, expect, vi, beforeEach, afterEach} from "vitest"; +import {BrowserWalletBridge, type BridgeChainParams} from "../../src/lib/wallet/browserBridge"; + +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 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("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/sessionClient.test.ts b/tests/libs/sessionClient.test.ts new file mode 100644 index 00000000..e988a1ba --- /dev/null +++ b/tests/libs/sessionClient.test.ts @@ -0,0 +1,155 @@ +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"}); + await expect(client.waitForTxResult(id, 3000)).resolves.toBe("0xdeadbeef"); + }); + + 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..de58a48e --- /dev/null +++ b/tests/libs/sessionDescriptor.test.ts @@ -0,0 +1,93 @@ +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("/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; + 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/stakingTx.test.ts b/tests/libs/stakingTx.test.ts new file mode 100644 index 00000000..0c213d05 --- /dev/null +++ b/tests/libs/stakingTx.test.ts @@ -0,0 +1,116 @@ +import {describe, test, expect} from "vitest"; +import {encodeFunctionData, encodeEventTopics, encodeAbiParameters, type TransactionReceipt} from "viem"; +import {abi} from "genlayer-js"; +import { + buildValidatorJoinTx, + buildSetIdentityTx, + extractValidatorWallet, +} from "../../src/lib/wallet/stakingTx"; + +const STAKING = "0x4A4449E617F8D10FDeD0b461CadEf83939E821A5"; +const OPERATOR = "0x1111111111111111111111111111111111111111"; +const VALIDATOR_WALLET = "0x2222222222222222222222222222222222222222"; + +describe("buildValidatorJoinTx", () => { + test("encodes the no-operator overload (bare validatorJoin())", () => { + const {to, data} = buildValidatorJoinTx(STAKING); + const expected = encodeFunctionData({abi: abi.STAKING_ABI, functionName: "validatorJoin"}); + expect(to).toBe(STAKING); + expect(data).toBe(expected); + }); + + test("encodes the operator overload with the operator arg", () => { + const {data} = buildValidatorJoinTx(STAKING, OPERATOR); + const expected = encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "validatorJoin", + args: [OPERATOR as `0x${string}`], + }); + expect(data).toBe(expected); + }); + + test("selectors differ between the two overloads", () => { + const bare = buildValidatorJoinTx(STAKING).data; + const withOp = buildValidatorJoinTx(STAKING, OPERATOR).data; + expect(bare.slice(0, 10)).not.toBe(withOp.slice(0, 10)); + }); + + test("prefixes a bare (0x-less) staking address", () => { + const {to} = buildValidatorJoinTx(STAKING.slice(2)); + expect(to).toBe(STAKING); + }); +}); + +describe("buildSetIdentityTx", () => { + test("encodes moniker with empty optional fields and 0x extraCid", () => { + const {to, data} = buildSetIdentityTx(VALIDATOR_WALLET, {moniker: "MyValidator"}); + const expected = encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setIdentity", + args: ["MyValidator", "", "", "", "", "", "", "", "0x"], + }); + expect(to).toBe(VALIDATOR_WALLET); + expect(data).toBe(expected); + }); + + test("hex extraCid is passed through verbatim", () => { + const {data} = buildSetIdentityTx(VALIDATOR_WALLET, {moniker: "V", extraCid: "0xdeadbeef"}); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setIdentity", + args: ["V", "", "", "", "", "", "", "", "0xdeadbeef"], + }), + ); + }); + + test("non-hex extraCid is UTF-8 hex-encoded", () => { + const {data} = buildSetIdentityTx(VALIDATOR_WALLET, {moniker: "V", extraCid: "cid"}); + const cidHex = ("0x" + Buffer.from("cid", "utf-8").toString("hex")) as `0x${string}`; + expect(data).toBe( + encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setIdentity", + args: ["V", "", "", "", "", "", "", "", cidHex], + }), + ); + }); +}); + +describe("extractValidatorWallet", () => { + function receiptWithJoinLog(validator: string): TransactionReceipt { + // ValidatorJoin(operator, validator, amount) — all non-indexed. + const topics = encodeEventTopics({abi: abi.STAKING_ABI, eventName: "ValidatorJoin"}); + const data = encodeAbiParameters( + [ + {name: "operator", type: "address"}, + {name: "validator", type: "address"}, + {name: "amount", type: "uint256"}, + ], + [OPERATOR as `0x${string}`, validator as `0x${string}`, 42000n * 10n ** 18n], + ); + return { + transactionHash: "0xabc" as `0x${string}`, + logs: [{data, topics}], + } as unknown as TransactionReceipt; + } + + test("returns the validator wallet address from the ValidatorJoin log", () => { + const receipt = receiptWithJoinLog(VALIDATOR_WALLET); + expect(extractValidatorWallet(receipt).toLowerCase()).toBe(VALIDATOR_WALLET.toLowerCase()); + }); + + test("ignores unrelated / undecodable logs and finds the join event", () => { + const receipt = receiptWithJoinLog(VALIDATOR_WALLET); + (receipt.logs as any).unshift({data: "0x", topics: ["0xdeadbeef"]}); + expect(extractValidatorWallet(receipt).toLowerCase()).toBe(VALIDATOR_WALLET.toLowerCase()); + }); + + test("throws a clear error when no ValidatorJoin event is present", () => { + const receipt = { + transactionHash: "0xdef" as `0x${string}`, + logs: [], + } as unknown as TransactionReceipt; + expect(() => extractValidatorWallet(receipt)).toThrow(/ValidatorJoin event not found/); + }); +}); diff --git a/tests/libs/txBuilders.test.ts b/tests/libs/txBuilders.test.ts new file mode 100644 index 00000000..862ffb27 --- /dev/null +++ b/tests/libs/txBuilders.test.ts @@ -0,0 +1,152 @@ +import {describe, test, expect} from "vitest"; +import {encodeFunctionData, type Abi} from "viem"; +import {abi} from "genlayer-js"; +import {buildTx, encodeExtraCid} from "../../src/lib/wallet/txBuilders"; + +const VALIDATOR_WALLET = "0x2222222222222222222222222222222222222222"; +const VESTING = "0x3333333333333333333333333333333333333333"; +const OPERATOR = "0x1111111111111111111111111111111111111111"; +const VALIDATOR = "0x4444444444444444444444444444444444444444"; + +describe("buildTx (generic calldata builder)", () => { + test("prefixes a bare (0x-less) address", () => { + const {to} = buildTx( + abi.VALIDATOR_WALLET_ABI as unknown as Abi, + VALIDATOR_WALLET.slice(2), + "validatorClaim", + ); + expect(to).toBe(VALIDATOR_WALLET); + }); + + test("no-arg call matches encodeFunctionData without args", () => { + const {data} = buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, VALIDATOR_WALLET, "validatorDeposit"); + expect(data).toBe(encodeFunctionData({abi: abi.VALIDATOR_WALLET_ABI, functionName: "validatorDeposit"})); + }); + + // --- Validator-wallet family --- + test("setOperator(address) encodes against VALIDATOR_WALLET_ABI", () => { + const {to, data} = buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, VALIDATOR_WALLET, "setOperator", [ + OPERATOR, + ]); + expect(to).toBe(VALIDATOR_WALLET); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setOperator", + args: [OPERATOR as `0x${string}`], + }), + ); + }); + + test("validatorExit(uint256) encodes shares arg", () => { + const {data} = buildTx(abi.VALIDATOR_WALLET_ABI as unknown as Abi, VALIDATOR_WALLET, "validatorExit", [ + 100n, + ]); + expect(data).toBe( + encodeFunctionData({abi: abi.VALIDATOR_WALLET_ABI, functionName: "validatorExit", args: [100n]}), + ); + }); + + // --- Staking-diamond family --- + test("delegatorClaim(delegator, validator) preserves ARG ORDER (delegator first)", () => { + const delegator = "0x5555555555555555555555555555555555555555"; + const {data} = buildTx(abi.STAKING_ABI as unknown as Abi, VESTING, "delegatorClaim", [ + delegator, + VALIDATOR, + ]); + expect(data).toBe( + encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "delegatorClaim", + args: [delegator as `0x${string}`, VALIDATOR as `0x${string}`], + }), + ); + }); + + test("delegatorExit(validator, shares) arg order", () => { + const {data} = buildTx(abi.STAKING_ABI as unknown as Abi, VESTING, "delegatorExit", [VALIDATOR, 7n]); + expect(data).toBe( + encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "delegatorExit", + args: [VALIDATOR as `0x${string}`, 7n], + }), + ); + }); + + // --- Vesting family --- + test("vestingDelegatorJoin(validator, amount) encodes against VESTING_ABI", () => { + const {to, data} = buildTx(abi.VESTING_ABI as unknown as Abi, VESTING, "vestingDelegatorJoin", [ + VALIDATOR, + 42n * 10n ** 18n, + ]); + expect(to).toBe(VESTING); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VESTING_ABI, + functionName: "vestingDelegatorJoin", + args: [VALIDATOR as `0x${string}`, 42n * 10n ** 18n], + }), + ); + }); + + test("vestingWithdraw(amount) encodes a single uint arg", () => { + const {data} = buildTx(abi.VESTING_ABI as unknown as Abi, VESTING, "vestingWithdraw", [5n]); + expect(data).toBe( + encodeFunctionData({abi: abi.VESTING_ABI, functionName: "vestingWithdraw", args: [5n]}), + ); + }); + + test("vestingValidatorSetIdentity encodes 10 ordered args incl. extraCid hex + utf8", () => { + const utf8Cid = encodeExtraCid("cid"); + const {data} = buildTx(abi.VESTING_ABI as unknown as Abi, VESTING, "vestingValidatorSetIdentity", [ + VALIDATOR_WALLET, + "Moniker", + "", + "", + "", + "", + "", + "", + "", + utf8Cid, + ]); + expect(data).toBe( + encodeFunctionData({ + abi: abi.VESTING_ABI, + functionName: "vestingValidatorSetIdentity", + args: [VALIDATOR_WALLET as `0x${string}`, "Moniker", "", "", "", "", "", "", "", utf8Cid], + }), + ); + + const hexCid = encodeExtraCid("0xdeadbeef"); + const {data: hexData} = buildTx( + abi.VESTING_ABI as unknown as Abi, + VESTING, + "vestingValidatorSetIdentity", + [VALIDATOR_WALLET, "V", "", "", "", "", "", "", "", hexCid], + ); + expect(hexData).toBe( + encodeFunctionData({ + abi: abi.VESTING_ABI, + functionName: "vestingValidatorSetIdentity", + args: [VALIDATOR_WALLET as `0x${string}`, "V", "", "", "", "", "", "", "", "0xdeadbeef"], + }), + ); + }); +}); + +describe("encodeExtraCid", () => { + test("undefined / empty → 0x", () => { + expect(encodeExtraCid()).toBe("0x"); + expect(encodeExtraCid("")).toBe("0x"); + }); + + test("0x-prefixed passes through verbatim", () => { + expect(encodeExtraCid("0xdeadbeef")).toBe("0xdeadbeef"); + }); + + test("non-hex string is UTF-8 hex-encoded", () => { + expect(encodeExtraCid("cid")).toBe(("0x" + Buffer.from("cid", "utf-8").toString("hex")) as `0x${string}`); + }); +}); 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/vitest.config.ts b/vitest.config.ts index 49863d8f..44466610 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ globals: true, environment: 'jsdom', testTimeout: 10000, + setupFiles: ['tests/setup.ts'], exclude: [...configDefaults.exclude, 'tests/smoke.test.ts'], coverage: { exclude: [...configDefaults.exclude, '*.js', 'tests/**/*.ts', 'src/types', 'scripts', 'templates'], From f892371a711b9e507a685844b5afc8b150660b00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 14:29:59 +0100 Subject: [PATCH 25/45] test: make wallet session descriptor tests portable (#373) --- tests/libs/sessionDescriptor.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/libs/sessionDescriptor.test.ts b/tests/libs/sessionDescriptor.test.ts index de58a48e..fe5c488c 100644 --- a/tests/libs/sessionDescriptor.test.ts +++ b/tests/libs/sessionDescriptor.test.ts @@ -42,14 +42,16 @@ describe("sessionDescriptor", () => { 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("/home/x/.genlayer/wallet-session.json"); + 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; - expect(mode).toBe(0o600); + if (process.platform !== "win32") { + expect(mode).toBe(0o600); + } expect(readDescriptor(file)).toEqual(d); // No leftover temp file. expect(fs.existsSync(`${file}.tmp`)).toBe(false); From ffa8ae7e2165959b1f1737f384fd4b5a2c0fc9c3 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 14:32:31 +0100 Subject: [PATCH 26/45] Release v0.40.0-clarke.2 [skip ci] --- CHANGELOG.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77cccf5d..ca504dc6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [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) diff --git a/package-lock.json b/package-lock.json index 3f2a3c34..f7601984 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "genlayer", - "version": "0.40.0-clarke.1", + "version": "0.40.0-clarke.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "genlayer", - "version": "0.40.0-clarke.1", + "version": "0.40.0-clarke.2", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index a8fd1a13..187d8fa9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "genlayer", - "version": "0.40.0-clarke.1", + "version": "0.40.0-clarke.2", "description": "GenLayer Command Line Tool", "main": "src/index.ts", "type": "module", From 121ab27951840389e5afa92f6b869ec22dae2be3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 21:01:04 +0100 Subject: [PATCH 27/45] test(wallet): cover WalletAction status/disconnect + availableToStake (#379) --- tests/actions/walletStatusDisconnect.test.ts | 273 +++++++++++++++++++ tests/libs/availableToStake.test.ts | 47 ++++ 2 files changed, 320 insertions(+) create mode 100644 tests/actions/walletStatusDisconnect.test.ts create mode 100644 tests/libs/availableToStake.test.ts 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/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"); + }); +}); From de8858a13b3ab7d8830a52a69a0d277a36a79ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 21:01:14 +0100 Subject: [PATCH 28/45] docs(cli): document browser-wallet signing, custom networks, balances, vesting wizard (#381) --- CLAUDE.md | 4 +- README.md | 97 ++++++++++++++++++- docs/api-references/_meta.json | 1 + docs/api-references/accounts/account/show.mdx | 1 + docs/api-references/balances.mdx | 19 ++++ docs/api-references/index.mdx | 3 +- docs/api-references/staking/staking.mdx | 2 +- .../api-references/staking/staking/wizard.mdx | 4 +- src/commands/staking/index.ts | 2 +- 9 files changed, 126 insertions(+), 7 deletions(-) create mode 100644 docs/api-references/balances.mdx 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/README.md b/README.md index 234778fe..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. @@ -585,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/api-references/_meta.json b/docs/api-references/_meta.json index 3bffb8a8..eba5ac45 100644 --- a/docs/api-references/_meta.json +++ b/docs/api-references/_meta.json @@ -7,6 +7,7 @@ "accounts": "Accounts", "staking": "Staking", "localnet": "Localnet", + "balances": "balances", "estimate-fees": "estimate-fees", "finalize": "finalize", "finalize-batch": "finalize-batch", 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/index.mdx b/docs/api-references/index.mdx index 47162dbd..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.39.1` +Version: `0.40.0-clarke.2` ### Command List @@ -34,6 +34,7 @@ Version: `0.39.1` - `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/staking/staking.mdx b/docs/api-references/staking/staking.mdx index f1bd84ac..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 diff --git a/docs/api-references/staking/staking/wizard.mdx b/docs/api-references/staking/staking/wizard.mdx index 1923cc78..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 diff --git a/src/commands/staking/index.ts b/src/commands/staking/index.ts index 699d8ca1..6cc95dac 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -23,7 +23,7 @@ export function initializeStakingCommands(program: Command) { addWalletModeOption( staking .command("wizard") - .description("Interactive wizard to become a validator") + .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)") .option("--account ", "Account to use (skip selection)") .option("--network ", "Network to use (skip selection)") .option("--skip-identity", "Skip identity setup step") From 4b26cc69aaa1982522268834ff3d9a0ba0584f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 21:01:24 +0100 Subject: [PATCH 29/45] test(wallet): Tier-2 browser-signing e2e (Playwright + mock provider on anvil) (#380) Add a headless, deterministic Tier-2 e2e harness that drives the real bridge page (bridgePage.ts) in chromium with an injected mock window.ethereum. eth_sendTransaction is delegated to Node via page.exposeFunction(__glSign), where a viem local account (anvil dev key) actually signs+broadcasts to an ephemeral anvil, so the full loop runs for real: bridge + page JS + session daemon + chain, with zero human/extension. Lanes (all green on anvil): - S1 connect/status/disconnect (descriptor 0600, /api/ping, teardown) - S2 validator-join --wallet browser signs + mines against a recording StakingStub, asserted via real receipt + on-chain callCount - S4 session reuse: two sequential joins over one daemon/tab - S5 config default walletMode=browser; --wallet keystore overrides - S6 user-reject (4001) + tab-closed fail-fast (short env timeouts) Lane B (S3, IC deploy on Docker localnet) is deferred as a nightly follow-up: describe.skip guarded by GENLAYER_E2E_LOCALNET. Prod-safe support changes: GENLAYER_E2E_* env overrides for the timing constants (unset in prod) and a GENLAYER_E2E_NO_OPEN guard so the daemon does not auto-open a system browser under test. New e2e-wallet.yml CI job (informational, not a required gate initially). Playwright specs are kept out of the vitest glob so test:coverage is unaffected. --- .github/workflows/e2e-wallet.yml | 63 +++++++ .gitignore | 7 +- e2e/config-default.e2e.ts | 69 ++++++++ e2e/errors.e2e.ts | 128 ++++++++++++++ e2e/fixtures/StakingStub.json | 114 ++++++++++++ e2e/fixtures/StakingStub.sol | 33 ++++ e2e/fixtures/chain.ts | 141 +++++++++++++++ e2e/fixtures/cli.ts | 257 +++++++++++++++++++++++++++ e2e/fixtures/mockProvider.ts | 64 +++++++ e2e/helpers/bridgePage.ts | 116 +++++++++++++ e2e/lane-a-staking.e2e.ts | 89 ++++++++++ e2e/lane-b-deploy.e2e.ts | 25 +++ e2e/wallet-session.e2e.ts | 105 +++++++++++ package-lock.json | 269 +++++++++++++++++++++++++++++ package.json | 4 + playwright.config.ts | 18 ++ src/lib/wallet/browserBridge.ts | 11 +- src/lib/wallet/sessionConstants.ts | 21 ++- vitest.config.ts | 4 +- 19 files changed, 1529 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/e2e-wallet.yml create mode 100644 e2e/config-default.e2e.ts create mode 100644 e2e/errors.e2e.ts create mode 100644 e2e/fixtures/StakingStub.json create mode 100644 e2e/fixtures/StakingStub.sol create mode 100644 e2e/fixtures/chain.ts create mode 100644 e2e/fixtures/cli.ts create mode 100644 e2e/fixtures/mockProvider.ts create mode 100644 e2e/helpers/bridgePage.ts create mode 100644 e2e/lane-a-staking.e2e.ts create mode 100644 e2e/lane-b-deploy.e2e.ts create mode 100644 e2e/wallet-session.e2e.ts create mode 100644 playwright.config.ts 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/.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/e2e/config-default.e2e.ts b/e2e/config-default.e2e.ts new file mode 100644 index 00000000..8de7bbe0 --- /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", "--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", "--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..63a1aacb --- /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", "--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", "--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..b2ad8ad4 --- /dev/null +++ b/e2e/fixtures/mockProvider.ts @@ -0,0 +1,64 @@ +/** + * 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)}; + window.ethereum = { + 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 }; + } + }, + }; + })(); + `; +}; 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..e95abb31 --- /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", "--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", "--amount", "1", "--wallet", "browser"], + scratch, + ); + const second = await runCli( + ["staking", "validator-join", "--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 f7601984..c2dfceca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,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", @@ -809,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" @@ -827,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" @@ -836,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", @@ -850,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", @@ -860,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" @@ -872,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" @@ -881,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" @@ -893,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", @@ -916,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", @@ -926,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" @@ -935,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" @@ -947,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" @@ -959,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" @@ -968,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", @@ -1012,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" @@ -1021,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", @@ -1034,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" @@ -1047,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" @@ -1060,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" @@ -1859,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", @@ -1947,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": { @@ -2140,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": { @@ -2922,6 +2961,7 @@ "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": { @@ -2935,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" @@ -2980,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", @@ -3045,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", @@ -3074,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", @@ -3096,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", @@ -3117,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", @@ -3135,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", @@ -3153,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", @@ -3217,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" @@ -3236,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" @@ -3251,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": { @@ -3443,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", @@ -3461,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", @@ -3474,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", @@ -3490,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" @@ -3792,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": { @@ -4071,6 +4128,7 @@ "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", @@ -4122,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", @@ -4139,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", @@ -4156,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", @@ -4231,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": { @@ -4265,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", @@ -4294,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", @@ -4406,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" @@ -4443,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", @@ -4492,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", @@ -4560,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" @@ -4569,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" @@ -4584,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" @@ -4596,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", @@ -4611,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" @@ -4623,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", @@ -4690,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" @@ -4724,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", @@ -4825,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", @@ -4836,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" @@ -4880,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" @@ -4897,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" @@ -4906,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", @@ -4939,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", @@ -4949,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" @@ -4958,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" @@ -4970,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" @@ -4979,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", @@ -4995,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" @@ -5007,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", @@ -5017,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", @@ -5033,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" @@ -5045,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" @@ -5054,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" @@ -5066,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", @@ -5083,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" @@ -5109,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" @@ -5121,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" @@ -5133,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" @@ -5151,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" @@ -5305,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": { @@ -5341,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": { @@ -5386,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" @@ -5411,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", @@ -5440,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", @@ -5453,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" @@ -5537,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" @@ -5546,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", @@ -5566,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" @@ -5574,6 +5684,7 @@ "node_modules/genlayer-js": { "version": "1.1.8", "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#57889281db1e34df49abcf6129ffe6f347e4de4d", + "dev": true, "license": "MIT", "dependencies": { "eslint-plugin-import": "^2.30.0", @@ -5606,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", @@ -5630,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", @@ -5656,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", @@ -5812,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" @@ -5824,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" @@ -5836,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", @@ -5852,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" @@ -5899,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" @@ -5911,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" @@ -5920,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" @@ -5932,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" @@ -5947,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" @@ -5959,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" @@ -5974,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" @@ -6099,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", @@ -6115,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" @@ -6175,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", @@ -6199,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", @@ -6216,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", @@ -6235,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" @@ -6250,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", @@ -6276,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" @@ -6288,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" @@ -6303,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", @@ -6320,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", @@ -6351,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" @@ -6360,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" @@ -6384,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", @@ -6402,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" @@ -6444,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" @@ -6456,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" @@ -6478,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", @@ -6511,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", @@ -6529,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" @@ -6541,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" @@ -6579,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", @@ -6595,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", @@ -6612,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" @@ -6639,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" @@ -6651,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" @@ -6666,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", @@ -6697,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": { @@ -6828,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" @@ -6903,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" @@ -6957,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" @@ -6966,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", @@ -6979,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" @@ -7035,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": { @@ -7152,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" @@ -7370,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": { @@ -7558,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" @@ -7570,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" @@ -7579,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", @@ -7599,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", @@ -7617,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", @@ -7631,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", @@ -7698,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", @@ -7755,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", @@ -7835,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" @@ -7850,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" @@ -7906,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" @@ -7986,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" @@ -7995,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" @@ -8004,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": { @@ -8076,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" @@ -8144,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" @@ -8247,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" @@ -8404,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", @@ -8426,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", @@ -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", @@ -8814,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", @@ -8831,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", @@ -8846,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", @@ -8860,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" @@ -8872,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" @@ -8881,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", @@ -8900,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", @@ -8916,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", @@ -8934,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", @@ -9171,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", @@ -9256,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", @@ -9277,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", @@ -9295,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", @@ -9351,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" @@ -9373,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" @@ -9397,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" @@ -9409,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" @@ -9673,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", @@ -9710,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" @@ -9734,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", @@ -9748,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", @@ -9767,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", @@ -9788,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", @@ -9830,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": { @@ -9850,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", @@ -9959,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" @@ -10379,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" @@ -10394,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", @@ -10413,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", @@ -10440,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", @@ -10458,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", @@ -10518,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" @@ -10800,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 187d8fa9..32805c2e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,9 @@ "test:watch": "vitest --watch", "test:coverage": "vitest run --coverage", "test:smoke": "vitest run --config vitest.smoke.config.ts", + "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", @@ -38,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", 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/src/lib/wallet/browserBridge.ts b/src/lib/wallet/browserBridge.ts index e4abfad2..6e9488b9 100644 --- a/src/lib/wallet/browserBridge.ts +++ b/src/lib/wallet/browserBridge.ts @@ -262,9 +262,14 @@ export class BrowserWalletBridge { process.once("SIGINT", this.sigintHandler); } - await this.openUrl(this.url).catch(() => { - // Non-fatal: user can open the URL manually (headless / SSH). - }); + // 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}; } diff --git a/src/lib/wallet/sessionConstants.ts b/src/lib/wallet/sessionConstants.ts index ca241437..5b550601 100644 --- a/src/lib/wallet/sessionConstants.ts +++ b/src/lib/wallet/sessionConstants.ts @@ -4,15 +4,30 @@ * 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 = 25_000; +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 = 90_000; +export const HEARTBEAT_DEAD_MS = envMs("GENLAYER_E2E_HEARTBEAT_DEAD_MS", 90_000); /** * Surfaced when the page heartbeat has gone stale (tab closed / crashed). @@ -32,7 +47,7 @@ export const IDLE_TTL_MS = 30 * 60_000; export const DAEMON_READY_TIMEOUT_MS = 10_000; /** Wallet connect wait (existing bridge behaviour). */ -export const CONNECT_TIMEOUT_MS = 180_000; +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; diff --git a/vitest.config.ts b/vitest.config.ts index 44466610..bf23772f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,9 +6,9 @@ export default defineConfig({ environment: 'jsdom', testTimeout: 10000, setupFiles: ['tests/setup.ts'], - exclude: [...configDefaults.exclude, 'tests/smoke.test.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 From 3de9a6a2ee07bd596d3706228d9584f529ce1e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 21:48:26 +0100 Subject: [PATCH 30/45] fix(staking): validator-deposit/exit work through the CLI (#382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `staking validator-deposit` and `staking validator-exit` keystore paths called viem's `walletClient.writeContract` directly. viem then negotiates its own fee/tx-type against the GenLayer consensus RPC, which has no EIP-1559 fee support, so the write fails. Every other staking command (and both `vesting validator` commands) instead go through the SDK's staking client, whose `executeWrite` pins `type: "legacy"` and does manual nonce/gas + sign + sendRawTransaction. Route both commands through `client.validatorDeposit` / `client.validatorExit`, which forward to the ValidatorWallet's own functions (preserving msg.sender == ValidatorWallet on re-entry into Staking) over the correct legacy-tx path. The genlayer-e2e cli-driver hard-skips these two with a "SDK bug" note; that was a misdiagnosis — the SDK actions exist and are correct, the CLI just wasn't using them. Add keystore-path tests asserting both commands call the SDK client and never touch getViemClients. --- src/commands/staking/validatorDeposit.ts | 25 ++++--- src/commands/staking/validatorExit.ts | 29 ++++---- tests/actions/staking.test.ts | 89 +++++++++++++++++++++++- 3 files changed, 116 insertions(+), 27 deletions(-) diff --git a/src/commands/staking/validatorDeposit.ts b/src/commands/staking/validatorDeposit.ts index 6a71fd12..fd2fa73d 100644 --- a/src/commands/staking/validatorDeposit.ts +++ b/src/commands/staking/validatorDeposit.ts @@ -24,25 +24,28 @@ export class ValidatorDepositAction extends StakingAction { 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); 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); diff --git a/src/commands/staking/validatorExit.ts b/src/commands/staking/validatorExit.ts index 6829cbdd..44fc2f51 100644 --- a/src/commands/staking/validatorExit.ts +++ b/src/commands/staking/validatorExit.ts @@ -31,30 +31,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", diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index a67ce704..0df859e1 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -150,9 +150,92 @@ 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); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("deposits to validator via the SDK client (not raw viem)", async () => { + const getViemSpy = vi.spyOn(action as any, "getViemClients"); + + await action.execute({validator: "0xValidatorWallet", amount: "10gen", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorDeposit).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + amount: expect.any(BigInt), + }); + expect(getViemSpy).not.toHaveBeenCalled(); + 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 () => { + const getViemSpy = vi.spyOn(action as any, "getViemClients"); + + await action.execute({validator: "0xValidatorWallet", shares: "50", stakingAddress: "0xStaking"}); + + expect(mockClient.validatorExit).toHaveBeenCalledWith({ + validator: "0xValidatorWallet", + shares: 50n, + }); + expect(getViemSpy).not.toHaveBeenCalled(); + 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"); + }); +}); describe("DelegatorJoinAction", () => { let action: DelegatorJoinAction; From e55f917f269ece99c98dee6337695c7e5c459b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgars=20Nem=C5=A1e?= Date: Thu, 9 Jul 2026 21:48:38 +0100 Subject: [PATCH 31/45] fix(wallet): harden bridge (verify signer, Host check, constant-time token, 0700, url scrub, body cap) (#383) Targeted hardening of the browser-wallet signing bridge per security review; all existing behavior and tests preserved. - Verify signer: reject a wallet result whose from differs from the connected/expected signer, both in the bridge (handleResult) and in browserSend (assertResultSigner); plumb from through sessionClient.waitForTxResult. On accountsChanged the page re-POSTs /api/connected so the daemon stays coherent, and the misleading CLI-will-verify-the-sender copy now matches the enforced behavior. - Host-header validation: reject Host != 127.0.0.1:/localhost: with 403 before route dispatch (validated against the port captured at start() so long-polls flushed during teardown still pass). - Constant-time token compare via length-guarded timingSafeEqual. - Create ~/.genlayer and keystores dir with mode 0o700 + chmodSync. - Scrub the hash token from the URL via history.replaceState. - Cap request bodies at 64KB (413 PayloadTooLargeError). - Warn against ssh -g / GatewayPorts yes / public-interface binding. Existing no-CORS, origin-fail-closed, and token-on-every-route checks are unchanged. --- src/commands/wallet/WalletAction.ts | 5 +- src/lib/config/ConfigFileManager.ts | 6 +- src/lib/wallet/bridgePage.ts | 29 ++++- src/lib/wallet/browserBridge.ts | 170 +++++++++++++++++++++------ src/lib/wallet/browserSend.ts | 38 ++++-- src/lib/wallet/sessionClient.ts | 14 ++- tests/libs/browserBridge.test.ts | 55 +++++++++ tests/libs/configFileManager.test.ts | 7 +- tests/libs/sessionClient.test.ts | 4 +- 9 files changed, 271 insertions(+), 57 deletions(-) diff --git a/src/commands/wallet/WalletAction.ts b/src/commands/wallet/WalletAction.ts index e66982d6..edc5c5a5 100644 --- a/src/commands/wallet/WalletAction.ts +++ b/src/commands/wallet/WalletAction.ts @@ -83,7 +83,10 @@ export class WalletAction extends BaseAction { 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: ...)"); + 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 { 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/wallet/bridgePage.ts b/src/lib/wallet/bridgePage.ts index 90f04c4d..178fff4a 100644 --- a/src/lib/wallet/bridgePage.ts +++ b/src/lib/wallet/bridgePage.ts @@ -63,6 +63,7 @@ export const BRIDGE_PAGE_HTML = /* html */ `