From f290257bab22332d3fff2c3464092f8ac93eda06 Mon Sep 17 00:00:00 2001 From: Edgars Date: Wed, 8 Jul 2026 23:03:54 +0100 Subject: [PATCH 01/22] 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. --- README.md | 9 + .../staking/staking/validator-join.mdx | 1 + .../api-references/staking/staking/wizard.mdx | 1 + src/commands/staking/StakingAction.ts | 164 ++++++- src/commands/staking/index.ts | 46 +- src/commands/staking/validatorJoin.ts | 48 +++ src/commands/staking/wizard.ts | 169 ++++++-- src/lib/wallet/bridgePage.ts | 261 +++++++++++ src/lib/wallet/browserBridge.ts | 405 ++++++++++++++++++ src/lib/wallet/stakingTx.ts | 113 +++++ tests/actions/staking.test.ts | 131 +++++- tests/actions/stakingWizard.test.ts | 162 +++++++ tests/commands/staking.test.ts | 79 +++- tests/libs/browserBridge.test.ts | 222 ++++++++++ tests/libs/stakingTx.test.ts | 116 +++++ 15 files changed, 1870 insertions(+), 57 deletions(-) create mode 100644 src/lib/wallet/bridgePage.ts create mode 100644 src/lib/wallet/browserBridge.ts create mode 100644 src/lib/wallet/stakingTx.ts create mode 100644 tests/actions/stakingWizard.test.ts create mode 100644 tests/libs/browserBridge.test.ts create mode 100644 tests/libs/stakingTx.test.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/staking/staking/validator-join.mdx b/docs/api-references/staking/staking/validator-join.mdx index e2d56331..53b91db3 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..c47cd175 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' (default) or 'browser' (owner signs in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -h | --help | display help for command | No | | diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index ab78e8c9..fad609b1 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -3,8 +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 HttpTransportConfig, + type TransactionReceipt, +} from "viem"; import {privateKeyToAccount} from "viem/accounts"; +import {BrowserWalletBridge, type BridgeChainParams} from "../../lib/wallet/browserBridge"; // 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. @@ -39,6 +50,22 @@ export interface StakingConfig { network?: string; account?: string; password?: string; + wallet?: "keystore" | "browser"; +} + +export interface BrowserWalletSession { + bridge: BrowserWalletBridge; + publicClient: PublicClient; + chain: GenLayerChain; + stakingAddress: string; + signerAddress: Address; + /** Preflight (publicClient.call) + queue to the wallet + await the receipt. */ + sendTransaction(tx: { + to: Address; + data: `0x${string}`; + value?: bigint; + label: string; + }): Promise; } export class StakingAction extends BaseAction { @@ -58,6 +85,131 @@ export class StakingAction extends BaseAction { return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } + protected isBrowserWallet(config: StakingConfig): boolean { + return config.wallet === "browser"; + } + + /** + * Validate flag combinations for browser-wallet mode. Kept here (not in + * commander) so it is reusable and unit-testable. `context` scopes the + * `--account` message ("validator-join" vs "wizard"). + */ + protected assertBrowserWalletFlags(config: StakingConfig, context: "validator-join" | "wizard"): void { + if (!this.isBrowserWallet(config)) { + if (config.wallet !== undefined && config.wallet !== "keystore") { + throw new Error(`Invalid --wallet value '${config.wallet}'. Use 'keystore' or 'browser'.`); + } + return; + } + + if (config.password !== undefined) { + throw new Error("--password cannot be used with --wallet browser"); + } + 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 browser-wallet signing session: starts the localhost bridge, opens + * the wallet page, waits for connect, and returns a sendTransaction() that + * preflights (publicClient.call), queues the tx to the wallet, and awaits the + * receipt. 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.", + ); + } + + const bridgeChain: BridgeChainParams = { + 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, + }; + + const publicClient = createPublicClient({ + chain: chain as unknown as Chain, + transport: http(rpcUrl, glHttpConfig), + }); + + const bridge = new BrowserWalletBridge({ + chain: bridgeChain, + log: (msg: string) => this.log(msg), + }); + + const {url} = await bridge.start(); + this.logInfo(`Open this URL in a browser with your wallet to sign:\n ${url}`); + this.logInfo("(Remote/SSH? Forward the port first: ssh -L :127.0.0.1: ...)"); + + const signerAddress = await bridge.waitForConnection(); + + const sendTransaction = async (tx: { + to: Address; + data: `0x${string}`; + value?: 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) { + await bridge.close("The CLI aborted: the transaction would revert."); + throw new Error( + `Transaction would revert (preflight): ${error.shortMessage || error.message || error}`, + ); + } + + const hash = await bridge.sendTransaction({ + to: tx.to, + data: tx.data, + value: tx.value, + 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; + }; + + return {bridge, publicClient, chain, stakingAddress, signerAddress, sendTransaction}; + } + protected async getStakingClient(config: StakingConfig): Promise> { if (!this._stakingClient) { // Set account override if provided @@ -133,7 +285,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 +302,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 +427,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/index.ts b/src/commands/staking/index.ts index b5b097eb..2effeb87 100644 --- a/src/commands/staking/index.ts +++ b/src/commands/staking/index.ts @@ -27,6 +27,11 @@ export function initializeStakingCommands(program: Command) { .option("--skip-identity", "Skip identity setup step") .option("--rpc ", "RPC URL for the network") .option("--staking-address
", "Staking contract address (overrides chain config)") + .option( + "--wallet ", + "Signing mode: 'keystore' (default) or 'browser' (owner signs in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L :127.0.0.1:)", + "keystore", + ) .action(async (options: WizardOptions) => { const wizard = new ValidatorWizardAction(); await wizard.execute(options); @@ -36,13 +41,21 @@ export function initializeStakingCommands(program: Command) { 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')") + .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)") + .option( + "--wallet ", + "Signing mode: 'keystore' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L :127.0.0.1:)", + "keystore", + ) .action(async (options: ValidatorJoinOptions) => { const action = new ValidatorJoinAction(); await action.execute(options); @@ -145,16 +158,22 @@ export function initializeStakingCommands(program: Command) { .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}); - }); + .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]") @@ -330,7 +349,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/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 8185fd91..45befe7d 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,47 @@ 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 { + await session.bridge.close(); + } + } } diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index d7137b8a..e4cbfc83 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -1,4 +1,4 @@ -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"; @@ -8,6 +8,9 @@ import {formatEther, parseEther} from "viem"; import {createClient} from "genlayer-js"; import {readFileSync, existsSync} from "fs"; import path from "path"; +import {buildValidatorJoinTx, buildSetIdentityTx, extractValidatorWallet} from "../../lib/wallet/stakingTx"; + +const BROWSER_WALLET_CHOICE = "__browser_wallet__"; export interface WizardOptions extends StakingConfig { skipIdentity?: boolean; @@ -24,6 +27,7 @@ interface WizardState { operatorKeystorePath?: string; stakeAmount: string; validatorWalletAddress?: string; // the validator contract address returned from validatorJoin + ownerIsBrowserWallet?: boolean; identity?: { moniker: string; logoUri?: string; @@ -43,6 +47,8 @@ function ensureHexPrefix(address: string): string { } export class ValidatorWizardAction extends StakingAction { + private browserSession: BrowserWalletSession | null = null; + constructor() { super(); } @@ -52,6 +58,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 +70,7 @@ export class ValidatorWizardAction extends StakingAction { // Step 2: Network Selection await this.stepNetworkSelection(state, options); - // Step 3: Balance Check + // 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 +95,49 @@ export class ValidatorWizardAction extends StakingAction { return; } this.failSpinner("Wizard failed", error.message || error); + } finally { + if (this.browserSession) { + await this.browserSession.bridge.close(); + this.browserSession = null; + } } } + /** + * 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.browserSession) return this.browserSession; + + this.browserSession = await this.getBrowserWalletSession( + {...options, network: state.networkAlias}, + "wizard", + ); + state.accountAddress = this.browserSession.signerAddress; + if (!state.accountName) state.accountName = "browser wallet"; + return this.browserSession; + } + private async stepAccountSetup(state: Partial, options: WizardOptions): Promise { console.log("Step 1: Account Setup"); console.log("---------------------\n"); + // Browser-wallet owner (via --wallet browser). 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 (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 +177,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 +197,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", @@ -224,6 +278,15 @@ export class ValidatorWizardAction extends StakingAction { 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. + if (state.ownerIsBrowserWallet) { + 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`); + } + this.startSpinner("Checking balance and staking requirements..."); const network = resolveNetwork(state.networkAlias!, this.getCustomNetworks()); @@ -263,7 +326,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.`, ); } @@ -592,6 +655,10 @@ export class ValidatorWizardAction extends StakingAction { console.log("Step 6: Join as Validator"); console.log("-------------------------\n"); + if (state.ownerIsBrowserWallet) { + return this.stepJoinValidatorBrowser(state, options); + } + this.startSpinner("Creating validator..."); try { @@ -627,6 +694,40 @@ 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"); + } + } + private async stepIdentitySetup(state: Partial, options: WizardOptions): Promise { console.log("Step 7: Identity Setup"); console.log("----------------------\n"); @@ -724,27 +825,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(""); @@ -801,7 +918,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/lib/wallet/bridgePage.ts b/src/lib/wallet/bridgePage.ts new file mode 100644 index 00000000..0c950663 --- /dev/null +++ b/src/lib/wallet/bridgePage.ts @@ -0,0 +1,261 @@ +/** + * 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..cc56232f --- /dev/null +++ b/src/lib/wallet/browserBridge.ts @@ -0,0 +1,405 @@ +import http from "node:http"; +import {randomUUID} from "node:crypto"; +import type {AddressInfo} from "node:net"; +import type {Address, Hash} from "genlayer-js/types"; +import {openUrl as defaultOpenUrl} from "../clients/system"; +import {BRIDGE_PAGE_HTML} from "./bridgePage"; + +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; + label: string; +} + +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; +} + +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; +const LONG_POLL_MS = 25_000; + +/** + * 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 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 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; + + 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; + } + + 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 request: BridgeTxRequest = {...tx, id: randomUUID()}; + + return 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(); + }); + } + + /** 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 state-changing POSTs (anti DNS-rebinding). + if (req.method === "POST") { + 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", + chain: { + ...this.chain, + chainIdHex: `0x${this.chain.chainId.toString(16)}`, + }, + }); + 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.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 { + // Deliver a queued-but-undelivered tx immediately. + const pending = this.txQueue.find(p => !p.delivered); + if (pending) { + pending.delivered = true; + 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 (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; + 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, + 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, + chainId: `0x${this.chain.chainId.toString(16)}`, + label: tx.label, + }; + } + + 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/stakingTx.ts b/src/lib/wallet/stakingTx.ts new file mode 100644 index 00000000..36d1f900 --- /dev/null +++ b/src/lib/wallet/stakingTx.ts @@ -0,0 +1,113 @@ +import {encodeFunctionData, decodeEventLog, toHex, 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. These mirror the encode/decode logic in genlayer-js + * `src/staking/actions.ts` (validatorJoin ~217-251, setIdentity ~330-360) but + * 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). + * + * Kept dependency-free and side-effect-free so they are trivially unit-testable. + */ + +export interface BuiltTx { + to: Address; + data: `0x${string}`; +} + +/** + * 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 { + const data = operator + ? encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "validatorJoin", + args: [operator as Address], + }) + : encodeFunctionData({ + abi: abi.STAKING_ABI, + functionName: "validatorJoin", + }); + + return {to: normalizeAddress(stakingAddress), data}; +} + +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 { + let extraCidBytes: `0x${string}` = "0x"; + if (identity.extraCid) { + extraCidBytes = identity.extraCid.startsWith("0x") + ? (identity.extraCid as `0x${string}`) + : toHex(new TextEncoder().encode(identity.extraCid)); + } + + const data = encodeFunctionData({ + abi: abi.VALIDATOR_WALLET_ABI, + functionName: "setIdentity", + args: [ + identity.moniker, + identity.logoUri || "", + identity.website || "", + identity.description || "", + identity.email || "", + identity.twitter || "", + identity.telegram || "", + identity.github || "", + extraCidBytes, + ], + }); + + return {to: normalizeAddress(validatorWallet), data}; +} + +/** + * 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.`, + ); +} + +function normalizeAddress(address: string): Address { + return (address.startsWith("0x") ? address : `0x${address}`) as Address; +} diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 3d314c5c..12468ad9 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -27,8 +27,24 @@ vi.mock("genlayer-js", () => ({ 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 +117,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 +162,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 +201,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 +303,99 @@ 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"); + const bridge = {close: vi.fn().mockResolvedValue(undefined)}; + const sendTransaction = vi.fn().mockResolvedValue(mockReceipt); + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge, + 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(bridge.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 bridge even when the send fails", async () => { + const bridge = {close: vi.fn().mockResolvedValue(undefined)}; + vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ + bridge, + 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(bridge.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", + ); + }); +}); diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts new file mode 100644 index 00000000..69a10573 --- /dev/null +++ b/tests/actions/stakingWizard.test.ts @@ -0,0 +1,162 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import inquirer from "inquirer"; +import {ValidatorWizardAction} from "../../src/commands/staking/wizard"; +import {CreateAccountAction} from "../../src/commands/account/create"; +import {ExportAccountAction} from "../../src/commands/account/export"; + +vi.mock("inquirer"); +vi.mock("../../src/commands/account/create"); +vi.mock("../../src/commands/account/export"); + +const fsMock = vi.hoisted(() => ({ + readFileSync: vi.fn(() => JSON.stringify({address: "0xOperatorAddr"})), + existsSync: vi.fn(() => false), +})); +vi.mock("fs", async importOriginal => { + const actual = await importOriginal(); + const merged = {...actual, ...fsMock}; + return {...merged, default: merged}; +}); + +// genlayer-js: the wizard balance-check uses createClient(...).getBalance/getEpochInfo. +const mockGlClient = { + getBalance: vi.fn().mockResolvedValue(1000n * 10n ** 18n), + getEpochInfo: vi.fn().mockResolvedValue({ + validatorMinStakeRaw: 42n * 10n ** 18n, + validatorMinStake: "42 GEN", + currentEpoch: 5n, + }), +}; + +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: []}, +})); + +// 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"), +})); + +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}, + stakingAddress: "0xStaking", + signerAddress: "0xBrowserOwner", + sendTransaction, + }); + + // 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: + // 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({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("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(); + }); +}); diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index 3866529e..d8b1c852 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"); @@ -47,9 +49,35 @@ describe("staking commands", () => { expect(ValidatorJoinAction).toHaveBeenCalledTimes(1); expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith({ amount: "42000gen", + wallet: "keystore", }); }); + test("defaults --wallet to keystore", async () => { + program.parse(["node", "test", "staking", "validator-join", "--amount", "42000gen"]); + + expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith( + expect.objectContaining({wallet: "keystore"}), + ); + }); + + 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", @@ -65,6 +93,7 @@ describe("staking commands", () => { expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith({ amount: "42000gen", operator: "0xOperator", + wallet: "keystore", }); }); @@ -86,9 +115,37 @@ describe("staking commands", () => { }); }); + describe("wizard", () => { + test("defaults --wallet to keystore", async () => { + program.parse(["node", "test", "staking", "wizard"]); + + expect(ValidatorWizardAction).toHaveBeenCalledTimes(1); + expect(ValidatorWizardAction.prototype.execute).toHaveBeenCalledWith( + expect.objectContaining({wallet: "keystore"}), + ); + }); + + 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 +157,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 +390,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/libs/browserBridge.test.ts b/tests/libs/browserBridge.test.ts new file mode 100644 index 00000000..89b03957 --- /dev/null +++ b/tests/libs/browserBridge.test.ts @@ -0,0 +1,222 @@ +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("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; + }); +}); 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/); + }); +}); From 06564efd46bd21a780b3b54f0a1f37660b6c5780 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 00:01:06 +0100 Subject: [PATCH 02/22] 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. --- .../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-prime.mdx | 1 + .../api-references/staking/staking/wizard.mdx | 2 +- src/commands/staking/StakingAction.ts | 139 ++------ src/commands/staking/delegatorClaim.ts | 44 +++ src/commands/staking/delegatorExit.ts | 60 ++++ src/commands/staking/delegatorJoin.ts | 46 +++ src/commands/staking/index.ts | 299 +++++++++--------- 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/validatorPrime.ts | 88 ++++++ src/commands/staking/wizard.ts | 22 +- src/lib/actions/BaseAction.ts | 76 +++++ src/lib/wallet/bridgePage.ts | 5 + src/lib/wallet/browserBridge.ts | 10 + src/lib/wallet/browserSend.ts | 217 +++++++++++++ src/lib/wallet/stakingTx.ts | 123 +------ src/lib/wallet/txBuilders.ts | 114 +++++++ src/lib/wallet/walletOption.ts | 17 + tests/actions/staking.test.ts | 181 +++++++++++ tests/commands/staking.test.ts | 10 + tests/libs/browserBridge.test.ts | 32 ++ tests/libs/browserSend.test.ts | 183 +++++++++++ tests/libs/txBuilders.test.ts | 152 +++++++++ 35 files changed, 1681 insertions(+), 391 deletions(-) create mode 100644 src/lib/wallet/browserSend.ts create mode 100644 src/lib/wallet/txBuilders.ts create mode 100644 src/lib/wallet/walletOption.ts create mode 100644 tests/libs/browserSend.test.ts create mode 100644 tests/libs/txBuilders.test.ts diff --git a/docs/api-references/staking/staking/delegator-claim.mdx b/docs/api-references/staking/staking/delegator-claim.mdx index 25c71120..1f1aafb7 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..5301adaa 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..fb97e708 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..114601e1 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..712449ab 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "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..f61b548d 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "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..403487fb 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "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..2997e2ff 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "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..ec8a0a0e 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "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..96883c72 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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 c47cd175..052d5504 100644 --- a/docs/api-references/staking/staking/wizard.mdx +++ b/docs/api-references/staking/staking/wizard.mdx @@ -17,5 +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' (default) or 'browser' (owner signs in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --wallet <mode> | Signing mode: 'keystore' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -h | --help | display help for command | No | | diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index fad609b1..8690db2f 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -11,24 +11,10 @@ import { type WalletClient, type Chain, type Account, - type HttpTransportConfig, type TransactionReceipt, } from "viem"; import {privateKeyToAccount} from "viem/accounts"; -import {BrowserWalletBridge, type BridgeChainParams} from "../../lib/wallet/browserBridge"; - -// 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 {openBrowserWalletSession, glHttpConfig, type BrowserSession} from "../../lib/wallet/browserSend"; // Extended ABI for tree traversal (not in SDK) const STAKING_TREE_ABI = [ @@ -53,20 +39,8 @@ export interface StakingConfig { wallet?: "keystore" | "browser"; } -export interface BrowserWalletSession { - bridge: BrowserWalletBridge; - publicClient: PublicClient; - chain: GenLayerChain; - stakingAddress: string; - signerAddress: Address; - /** Preflight (publicClient.call) + queue to the wallet + await the receipt. */ - sendTransaction(tx: { - to: Address; - data: `0x${string}`; - value?: bigint; - label: string; - }): Promise; -} +/** 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; @@ -85,26 +59,16 @@ export class StakingAction extends BaseAction { return resolveNetwork(this.getConfig().network, this.getCustomNetworks()); } - protected isBrowserWallet(config: StakingConfig): boolean { - return config.wallet === "browser"; - } - /** - * Validate flag combinations for browser-wallet mode. Kept here (not in - * commander) so it is reusable and unit-testable. `context` scopes the - * `--account` message ("validator-join" vs "wizard"). + * 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 { - if (!this.isBrowserWallet(config)) { - if (config.wallet !== undefined && config.wallet !== "keystore") { - throw new Error(`Invalid --wallet value '${config.wallet}'. Use 'keystore' or 'browser'.`); - } - return; - } - - if (config.password !== undefined) { - throw new Error("--password cannot be used with --wallet browser"); - } + // 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"); @@ -114,10 +78,9 @@ export class StakingAction extends BaseAction { } /** - * Build a browser-wallet signing session: starts the localhost bridge, opens - * the wallet page, waits for connect, and returns a sendTransaction() that - * preflights (publicClient.call), queues the tx to the wallet, and awaits the - * receipt. Never touches keystore/keychain/password code paths. + * 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, @@ -135,79 +98,15 @@ export class StakingAction extends BaseAction { ); } - const bridgeChain: BridgeChainParams = { - 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, - }; - - const publicClient = createPublicClient({ - chain: chain as unknown as Chain, - transport: http(rpcUrl, glHttpConfig), - }); - - const bridge = new BrowserWalletBridge({ - chain: bridgeChain, + const session = await openBrowserWalletSession({ + chain, + rpcUrl, log: (msg: string) => this.log(msg), + logInfo: (msg: string) => this.logInfo(msg), }); + this.browserSession = session; - const {url} = await bridge.start(); - this.logInfo(`Open this URL in a browser with your wallet to sign:\n ${url}`); - this.logInfo("(Remote/SSH? Forward the port first: ssh -L :127.0.0.1: ...)"); - - const signerAddress = await bridge.waitForConnection(); - - const sendTransaction = async (tx: { - to: Address; - data: `0x${string}`; - value?: 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) { - await bridge.close("The CLI aborted: the transaction would revert."); - throw new Error( - `Transaction would revert (preflight): ${error.shortMessage || error.message || error}`, - ); - } - - const hash = await bridge.sendTransaction({ - to: tx.to, - data: tx.data, - value: tx.value, - 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; - }; - - return {bridge, publicClient, chain, stakingAddress, signerAddress, sendTransaction}; + return {...session, stakingAddress}; } protected async getStakingClient(config: StakingConfig): Promise> { 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 2effeb87..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,58 +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)") - .option( - "--wallet ", - "Signing mode: 'keystore' (default) or 'browser' (owner signs in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L :127.0.0.1:)", - "keystore", - ) - .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)") - .option( - "--wallet ", - "Signing mode: 'keystore' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L :127.0.0.1:)", - "keystore", - ) - .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"); @@ -80,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"); @@ -99,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"); @@ -117,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"); @@ -136,29 +133,31 @@ 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( + 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, @@ -175,24 +174,25 @@ export function initializeStakingCommands(program: Command) { }, ); - 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"); @@ -203,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"); @@ -223,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"); @@ -243,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"); 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/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 e4cbfc83..4ec33e07 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -47,8 +47,6 @@ function ensureHexPrefix(address: string): string { } export class ValidatorWizardAction extends StakingAction { - private browserSession: BrowserWalletSession | null = null; - constructor() { super(); } @@ -96,10 +94,10 @@ export class ValidatorWizardAction extends StakingAction { } this.failSpinner("Wizard failed", error.message || error); } finally { - if (this.browserSession) { - await this.browserSession.bridge.close(); - this.browserSession = null; - } + const session = this._wizardSession; + this._wizardSession = null; + this.browserSession = null; + if (session) await session.bridge.close(); } } @@ -112,17 +110,21 @@ export class ValidatorWizardAction extends StakingAction { state: Partial, options: WizardOptions, ): Promise { - if (this.browserSession) return this.browserSession; + if (this._wizardSession) return this._wizardSession; - this.browserSession = await this.getBrowserWalletSession( + // getBrowserWalletSession sets this.browserSession (base field) internally. + this._wizardSession = await this.getBrowserWalletSession( {...options, network: state.networkAlias}, "wizard", ); - state.accountAddress = this.browserSession.signerAddress; + state.accountAddress = this._wizardSession.signerAddress; if (!state.accountName) state.accountName = "browser wallet"; - return this.browserSession; + 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"); diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 92a0d665..c57df354 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -13,6 +13,7 @@ import { normalizeCustomNetworks, type CustomNetworksConfig, } from "../networks/customNetworks"; +import {openBrowserWalletSession, type BrowserSession, type WalletMode} from "../wallet/browserSend"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -70,6 +71,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 +84,64 @@ export class BaseAction extends ConfigFileManager { return normalizeCustomNetworks(this.getConfigByKey(CUSTOM_NETWORKS_CONFIG_KEY)); } + // --- Browser-wallet (MetaMask) signing seam ------------------------------ + + protected isBrowserWallet(config: {wallet?: string}): boolean { + return 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. + */ + protected assertWalletFlags( + config: {wallet?: string; password?: string; account?: string}, + opts: {accountFlagExists: boolean; context: string}, + ): void { + if (!this.isBrowserWallet(config)) { + if (config.wallet !== undefined && config.wallet !== "keystore") { + throw new Error(`Invalid --wallet value '${config.wallet}'. Use 'keystore' or 'browser'.`); + } + 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} = {}): 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]; + + this.browserSession = await openBrowserWalletSession({ + chain, + rpcUrl, + log: (msg: string) => this.log(msg), + logInfo: (msg: string) => this.logInfo(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 +178,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/wallet/bridgePage.ts b/src/lib/wallet/bridgePage.ts index 0c950663..90f04c4d 100644 --- a/src/lib/wallet/bridgePage.ts +++ b/src/lib/wallet/bridgePage.ts @@ -206,6 +206,11 @@ export const BRIDGE_PAGE_HTML = /* html */ ` }; if (tx.value) params.value = toHexQuantity(tx.value); if (tx.gasPrice) params.gasPrice = toHexQuantity(tx.gasPrice); + if (tx.gas) params.gas = toHexQuantity(tx.gas); + if (tx.type) params.type = tx.type; // "0x0" for the SDK's legacy IC txs + // Deliberately DROP tx.nonce and tx.chainId: MetaMask manages its own + // pending nonce and enforces the chain (ensureChain above), and some + // wallet versions reject unknown/mismatched nonce/chainId keys. var txHash = await window.ethereum.request({ method: "eth_sendTransaction", params: [params], diff --git a/src/lib/wallet/browserBridge.ts b/src/lib/wallet/browserBridge.ts index cc56232f..21b7b565 100644 --- a/src/lib/wallet/browserBridge.ts +++ b/src/lib/wallet/browserBridge.ts @@ -19,6 +19,9 @@ export interface BridgeTxRequest { data: `0x${string}`; value?: bigint; gasPrice?: bigint; + gas?: bigint; + nonce?: number; + type?: string; label: string; } @@ -376,6 +379,13 @@ export class BrowserWalletBridge { 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/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). + nonce: tx.nonce !== undefined ? `0x${tx.nonce.toString(16)}` : undefined, + type: tx.type, chainId: `0x${this.chain.chainId.toString(16)}`, label: tx.label, }; diff --git a/src/lib/wallet/browserSend.ts b/src/lib/wallet/browserSend.ts new file mode 100644 index 00000000..c15865b9 --- /dev/null +++ b/src/lib/wallet/browserSend.ts @@ -0,0 +1,217 @@ +import { + createPublicClient, + http, + hexToBigInt, + type PublicClient, + type Chain, + type HttpTransportConfig, + type TransactionReceipt, +} from "viem"; +import type {GenLayerChain, Address} from "genlayer-js/types"; +import {BrowserWalletBridge, type BridgeChainParams} from "./browserBridge"; + +// GenLayer RPC rejects JSON-RPC requests with id=0 (treats 0 as missing). +// Viem starts its id counter at 0, so we ensure non-zero ids. Owned here now +// (was StakingAction.ts) and re-exported for back-compat. +export const glHttpConfig: HttpTransportConfig = { + async fetchFn(url, init) { + if (init?.body) { + const body = JSON.parse(init.body as string); + if (body.id === 0) body.id = 1; + init = {...init, body: JSON.stringify(body)}; + } + return fetch(url, init); + }, +}; + +export type WalletMode = "keystore" | "browser"; + +export interface BrowserSessionParams { + /** Already-resolved chain (via resolveNetwork / getNetwork). */ + chain: GenLayerChain; + /** config.rpc || chain.rpcUrls.default.http[0]. */ + rpcUrl: string; + log?: (msg: string) => void; + logInfo?: (msg: string) => void; + /** Injectable for tests (default: the bridge's own openUrl). */ + openUrl?: (url: string) => Promise; + /** Register a SIGINT handler (default: true; tests pass false). */ + handleSigint?: boolean; +} + +/** EIP-1193-compatible request shape the genlayer-js client provider expects. */ +export interface Eip1193Provider { + request(args: {method: string; params?: any[]}): Promise; +} + +export interface BrowserSession { + bridge: BrowserWalletBridge; + 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; +} + +/** + * Open a browser-wallet signing session: 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 staking, + * vesting, and the BaseAction Lane B client). + */ +export async function openBrowserWalletSession(params: BrowserSessionParams): Promise { + const {chain, rpcUrl} = params; + const log = params.log ?? (() => {}); + const logInfo = params.logInfo ?? (() => {}); + + const bridgeChain: BridgeChainParams = { + 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, + }; + + const publicClient = createPublicClient({ + chain: chain as unknown as Chain, + transport: http(rpcUrl, glHttpConfig), + }); + + 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 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) { + await bridge.close("The CLI aborted: the transaction would revert."); + throw new Error( + `Transaction would revert (preflight): ${error.shortMessage || error.message || error}`, + ); + } + + const hash = await bridge.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 bridge.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, + publicClient, + chain, + signerAddress, + sendTransaction, + eip1193Provider, + setNextLabel, + close: (finalMessage?: string) => bridge.close(finalMessage), + }; +} diff --git a/src/lib/wallet/stakingTx.ts b/src/lib/wallet/stakingTx.ts index 36d1f900..3465e496 100644 --- a/src/lib/wallet/stakingTx.ts +++ b/src/lib/wallet/stakingTx.ts @@ -1,113 +1,12 @@ -import {encodeFunctionData, decodeEventLog, toHex, 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. These mirror the encode/decode logic in genlayer-js - * `src/staking/actions.ts` (validatorJoin ~217-251, setIdentity ~330-360) but - * 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). - * - * Kept dependency-free and side-effect-free so they are trivially unit-testable. - */ - -export interface BuiltTx { - to: Address; - data: `0x${string}`; -} - -/** - * 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 { - const data = operator - ? encodeFunctionData({ - abi: abi.STAKING_ABI, - functionName: "validatorJoin", - args: [operator as Address], - }) - : encodeFunctionData({ - abi: abi.STAKING_ABI, - functionName: "validatorJoin", - }); - - return {to: normalizeAddress(stakingAddress), data}; -} - -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 { - let extraCidBytes: `0x${string}` = "0x"; - if (identity.extraCid) { - extraCidBytes = identity.extraCid.startsWith("0x") - ? (identity.extraCid as `0x${string}`) - : toHex(new TextEncoder().encode(identity.extraCid)); - } - - const data = encodeFunctionData({ - abi: abi.VALIDATOR_WALLET_ABI, - functionName: "setIdentity", - args: [ - identity.moniker, - identity.logoUri || "", - identity.website || "", - identity.description || "", - identity.email || "", - identity.twitter || "", - identity.telegram || "", - identity.github || "", - extraCidBytes, - ], - }); - - return {to: normalizeAddress(validatorWallet), data}; -} - -/** - * 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.`, - ); -} - -function normalizeAddress(address: string): Address { - return (address.startsWith("0x") ? address : `0x${address}`) as Address; -} + * 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..e3aa1194 --- /dev/null +++ b/src/lib/wallet/walletOption.ts @@ -0,0 +1,17 @@ +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' (default) or 'browser' (sign in MetaMask via a local bridge; " + + "forward the port for remote/SSH: ssh -L :127.0.0.1:)"; + +export function addWalletModeOption(cmd: Command): Command { + return cmd.option(WALLET_OPTION_FLAG, WALLET_OPTION_DESC, "keystore"); +} diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 12468ad9..3176c530 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,9 +22,17 @@ 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"]}}}, @@ -399,3 +408,175 @@ describe("ValidatorJoinAction --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/commands/staking.test.ts b/tests/commands/staking.test.ts index d8b1c852..40d1640f 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -151,6 +151,7 @@ describe("staking commands", () => { expect(ValidatorDepositAction.prototype.execute).toHaveBeenCalledWith({ validator: "0x1234567890123456789012345678901234567890", amount: "1000gen", + wallet: "keystore", }); }); }); @@ -172,6 +173,7 @@ describe("staking commands", () => { expect(ValidatorExitAction.prototype.execute).toHaveBeenCalledWith({ validator: "0x1234567890123456789012345678901234567890", shares: "100", + wallet: "keystore", }); }); }); @@ -183,6 +185,7 @@ describe("staking commands", () => { expect(ValidatorClaimAction).toHaveBeenCalledTimes(1); expect(ValidatorClaimAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", + wallet: "keystore", }); }); }); @@ -204,6 +207,7 @@ describe("staking commands", () => { expect(DelegatorJoinAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", amount: "42gen", + wallet: "keystore", }); }); }); @@ -225,6 +229,7 @@ describe("staking commands", () => { expect(DelegatorExitAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", shares: "50", + wallet: "keystore", }); }); }); @@ -246,6 +251,7 @@ describe("staking commands", () => { expect(DelegatorClaimAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", delegator: "0xDelegator", + wallet: "keystore", }); }); }); @@ -315,6 +321,7 @@ describe("staking commands", () => { expect(ValidatorPrimeAction).toHaveBeenCalledTimes(1); expect(ValidatorPrimeAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", + wallet: "keystore", }); }); }); @@ -336,6 +343,7 @@ describe("staking commands", () => { expect(SetOperatorAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", operator: "0xOperator", + wallet: "keystore", }); }); }); @@ -357,6 +365,7 @@ describe("staking commands", () => { expect(SetIdentityAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", moniker: "My Validator", + wallet: "keystore", }); }); @@ -384,6 +393,7 @@ describe("staking commands", () => { website: "https://example.com", twitter: "myhandle", github: "mygithub", + wallet: "keystore", }); }); }); diff --git a/tests/libs/browserBridge.test.ts b/tests/libs/browserBridge.test.ts index 89b03957..86718a1c 100644 --- a/tests/libs/browserBridge.test.ts +++ b/tests/libs/browserBridge.test.ts @@ -124,6 +124,38 @@ describe("BrowserWalletBridge", () => { 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}); 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/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}`); + }); +}); From e987e5de5673c0f2404184587f2155c43bc1f865 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 00:01:28 +0100 Subject: [PATCH 03/22] 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. --- 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 + 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 +++++ tests/actions/vesting.test.ts | 160 ++++++++++++++++++ tests/commands/vesting.test.ts | 60 +++++++ 27 files changed, 924 insertions(+), 60 deletions(-) create mode 100644 tests/actions/vesting.test.ts diff --git a/docs/api-references/vesting/claim.mdx b/docs/api-references/vesting/claim.mdx index 43d64d47..b7bd6de9 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..3b4f9225 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..f051ef4c 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..0204983d 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..e7ebbdc0 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..696df192 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..58bfeedd 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..cdcfbd8d 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..749e1b1f 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..40815c04 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..4255c9e7 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..0e87f886 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..6e358cf9 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -h | --help | display help for command | No | | 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/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/commands/vesting.test.ts b/tests/commands/vesting.test.ts index 88f0ac38..d8314da7 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,62 @@ 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 defaults to keystore signing mode when --wallet omitted", async () => { + const executeSpy = vi + .spyOn(VestingDelegateAction.prototype as any, "execute") + .mockResolvedValue(undefined); + + await program.parseAsync(["node", "test", "vesting", "delegate", "0xValidator", "--amount", "42gen"]); + + expect(executeSpy).toHaveBeenCalledWith(expect.objectContaining({wallet: "keystore"})); + }); + + 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. + expect(executeSpy).toHaveBeenCalledWith( + expect.objectContaining({walletAddress: "0xWallet", wallet: "keystore"}), + ); + }); }); From f4e0ce557b1a87f3778eaf2d87aef223a95f783e Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 00:01:39 +0100 Subject: [PATCH 04/22] 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. --- docs/api-references/contracts/deploy.mdx | 1 + docs/api-references/contracts/write.mdx | 1 + src/commands/contracts/deploy.ts | 34 ++++++++++----- src/commands/contracts/index.ts | 53 +++++++++++++----------- src/commands/contracts/write.ts | 6 +++ tests/actions/deploy.test.ts | 42 +++++++++++++++++++ tests/actions/write.test.ts | 43 +++++++++++++++++++ tests/commands/deploy.test.ts | 4 ++ tests/commands/write.test.ts | 4 ++ 9 files changed, 153 insertions(+), 35 deletions(-) diff --git a/docs/api-references/contracts/deploy.mdx b/docs/api-references/contracts/deploy.mdx index ca96ac0b..1d61a6e3 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..11829131 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -h | --help | display help for command | No | | diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index 845831c6..d4677b4e 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 (options?.wallet === "browser") 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 (options.wallet === "browser") 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..5aeefa22 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 (wallet === "browser") 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/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/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/deploy.test.ts b/tests/commands/deploy.test.ts index 85a493f8..704774ba 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -27,6 +27,7 @@ describe("deploy command", () => { expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ contract: "./path/to/contract", args: [], + wallet: "keystore", }); }); @@ -49,6 +50,7 @@ describe("deploy command", () => { contract: "./path/to/contract", args: [1, 2, 3], rpc: "https://custom-rpc-url.com", + wallet: "keystore", }); }); @@ -74,6 +76,7 @@ describe("deploy command", () => { fees, feeValue: "4", validUntil: "999", + wallet: "keystore", }); }); @@ -98,6 +101,7 @@ describe("deploy command", () => { feeProfile: "./artifacts/fee-profile.json", feePreset: "high", appealRounds: "3", + wallet: "keystore", }); }); diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index 7e562312..bff14145 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -28,6 +28,7 @@ describe("write command", () => { contractAddress: "0xMockedContract", method: "setData", args: [], + wallet: "keystore", }); }); @@ -51,6 +52,7 @@ describe("write command", () => { method: "updateCounter", args: [100, "someString", true], rpc: "https://custom-rpc-url-for-write.com", + wallet: "keystore", }); }); @@ -77,6 +79,7 @@ describe("write command", () => { fees, feeValue: "4", validUntil: "999", + wallet: "keystore", }); }); @@ -102,6 +105,7 @@ describe("write command", () => { feeProfile: "./artifacts/fee-profile.json", feePreset: "standard", appealRounds: "2", + wallet: "keystore", }); }); From ab057927139ec795fb278c5a5c49f9ee7c8f839b Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 00:01:49 +0100 Subject: [PATCH 05/22] 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. --- docs/api-references/finalize-batch.mdx | 1 + docs/api-references/finalize.mdx | 1 + docs/api-references/transactions/appeal.mdx | 1 + src/commands/transactions/appeal.ts | 7 +++ src/commands/transactions/finalize.ts | 29 ++++++++++- src/commands/transactions/index.ts | 54 +++++++++++---------- tests/commands/appeal.test.ts | 3 ++ tests/commands/finalize.test.ts | 6 ++- 8 files changed, 74 insertions(+), 28 deletions(-) diff --git a/docs/api-references/finalize-batch.mdx b/docs/api-references/finalize-batch.mdx index afc1f6f9..fdfa8c64 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -h | --help | display help for command | No | | diff --git a/docs/api-references/finalize.mdx b/docs/api-references/finalize.mdx index b977d82d..4abe4aff 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -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..5009d848 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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | | -h | --help | display help for command | No | | diff --git a/src/commands/transactions/appeal.ts b/src/commands/transactions/appeal.ts index 0c4d9082..68790ef1 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 (wallet === "browser") 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..179c50e0 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 (wallet === "browser") 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 (wallet === "browser") 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/tests/commands/appeal.test.ts b/tests/commands/appeal.test.ts index 9d5461ac..ac961f8a 100644 --- a/tests/commands/appeal.test.ts +++ b/tests/commands/appeal.test.ts @@ -24,6 +24,7 @@ describe("appeal command", () => { expect(AppealAction).toHaveBeenCalledTimes(1); expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ txId: mockTxId, + wallet: "keystore", }); }); @@ -40,6 +41,7 @@ describe("appeal command", () => { expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ txId: mockTxId, rpc: "https://custom-rpc-url-for-appeal.com", + wallet: "keystore", }); }); @@ -48,6 +50,7 @@ describe("appeal command", () => { expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ txId: mockTxId, bond: "500gen", + wallet: "keystore", }); }); diff --git a/tests/commands/finalize.test.ts b/tests/commands/finalize.test.ts index 5176296a..3f2f8dcd 100644 --- a/tests/commands/finalize.test.ts +++ b/tests/commands/finalize.test.ts @@ -22,7 +22,7 @@ describe("finalize command", () => { test("FinalizeAction.finalize is called with txId", async () => { program.parse(["node", "test", "finalize", mockTxId]); expect(FinalizeAction).toHaveBeenCalledTimes(1); - expect(FinalizeAction.prototype.finalize).toHaveBeenCalledWith({txId: mockTxId}); + expect(FinalizeAction.prototype.finalize).toHaveBeenCalledWith({txId: mockTxId, wallet: "keystore"}); }); test("FinalizeAction.finalize is called with custom RPC URL", async () => { @@ -30,6 +30,7 @@ describe("finalize command", () => { expect(FinalizeAction.prototype.finalize).toHaveBeenCalledWith({ txId: mockTxId, rpc: "https://custom.com", + wallet: "keystore", }); }); @@ -61,6 +62,7 @@ describe("finalize-batch command", () => { program.parse(["node", "test", "finalize-batch", mockTxId1]); expect(FinalizeAction.prototype.finalizeBatch).toHaveBeenCalledWith({ txIds: [mockTxId1], + wallet: "keystore", }); }); @@ -68,6 +70,7 @@ describe("finalize-batch command", () => { program.parse(["node", "test", "finalize-batch", mockTxId1, mockTxId2]); expect(FinalizeAction.prototype.finalizeBatch).toHaveBeenCalledWith({ txIds: [mockTxId1, mockTxId2], + wallet: "keystore", }); }); @@ -78,6 +81,7 @@ describe("finalize-batch command", () => { expect(FinalizeAction.prototype.finalizeBatch).toHaveBeenCalledWith({ txIds: [mockTxId1, mockTxId2], rpc: "https://custom.com", + wallet: "keystore", }); }); }); From eaeca04227ae605a26fdc75a0a3f55e54cf8a200 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:39:57 +0100 Subject: [PATCH 06/22] 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. --- src/lib/wallet/sessionConstants.ts | 42 ++++++++++++ src/lib/wallet/sessionDescriptor.ts | 95 ++++++++++++++++++++++++++++ tests/libs/sessionDescriptor.test.ts | 93 +++++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/lib/wallet/sessionConstants.ts create mode 100644 src/lib/wallet/sessionDescriptor.ts create mode 100644 tests/libs/sessionDescriptor.test.ts diff --git a/src/lib/wallet/sessionConstants.ts b/src/lib/wallet/sessionConstants.ts new file mode 100644 index 00000000..856ef726 --- /dev/null +++ b/src/lib/wallet/sessionConstants.ts @@ -0,0 +1,42 @@ +/** + * 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; + +/** 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/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/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); + }); +}); From 2dcaf1bf1a8de30e5b12304b4c0c2ad1a5d3ce65 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:40:11 +0100 Subject: [PATCH 07/22] 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). --- src/lib/wallet/browserBridge.ts | 305 +++++++++++++++++++++++++++++-- tests/libs/browserBridge.test.ts | 158 ++++++++++++++++ 2 files changed, 450 insertions(+), 13 deletions(-) diff --git a/src/lib/wallet/browserBridge.ts b/src/lib/wallet/browserBridge.ts index 21b7b565..e4abfad2 100644 --- a/src/lib/wallet/browserBridge.ts +++ b/src/lib/wallet/browserBridge.ts @@ -1,9 +1,11 @@ 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; @@ -25,6 +27,71 @@ export interface BridgeTxRequest { 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; @@ -33,6 +100,32 @@ export interface BrowserWalletBridgeOptions { 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 { @@ -51,7 +144,7 @@ interface NextWaiter { const DEFAULT_CONNECT_TIMEOUT = 180_000; const DEFAULT_TX_TIMEOUT = 300_000; -const LONG_POLL_MS = 25_000; +// LONG_POLL_MS is imported from ./sessionConstants (single source of truth). /** * Dependency-free localhost bridge that lets a browser wallet (MetaMask, any @@ -71,6 +164,10 @@ export class BrowserWalletBridge { 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; @@ -78,6 +175,7 @@ export class BrowserWalletBridge { 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; @@ -88,6 +186,11 @@ export class BrowserWalletBridge { 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; @@ -95,6 +198,44 @@ export class BrowserWalletBridge { 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}> { @@ -151,9 +292,76 @@ export class BrowserWalletBridge { async sendTransaction(tx: Omit): Promise { if (this.closed) throw new Error("Bridge is closed."); - const request: BridgeTxRequest = {...tx, id: randomUUID()}; + 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; + } - return new Promise((resolve, reject) => { + 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, @@ -174,6 +382,7 @@ export class BrowserWalletBridge { this.txQueue.push(pending); this.tryDeliverNext(); }); + return {id: request.id, promise}; } /** Address of the tx the caller can inspect (for cross-checking `from`). */ @@ -252,8 +461,11 @@ export class BrowserWalletBridge { return; } - // Origin check for state-changing POSTs (anti DNS-rebinding). - if (req.method === "POST") { + // 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; @@ -265,6 +477,7 @@ export class BrowserWalletBridge { 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)}`, @@ -273,6 +486,68 @@ export class BrowserWalletBridge { 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; @@ -286,6 +561,7 @@ export class BrowserWalletBridge { this.connectResolve = null; this.connectReject = null; } + this.onConnected?.(address); this.json(res, {status: "ok"}); }); return; @@ -309,10 +585,16 @@ export class BrowserWalletBridge { } 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; } @@ -351,6 +633,7 @@ export class BrowserWalletBridge { 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); @@ -366,6 +649,9 @@ export class BrowserWalletBridge { 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); @@ -375,19 +661,12 @@ export class BrowserWalletBridge { private serializeTx(tx: BridgeTxRequest): Record { return { id: tx.id, - 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/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). - nonce: tx.nonce !== undefined ? `0x${tx.nonce.toString(16)}` : undefined, - type: tx.type, + ...serializeBridgeTx(tx), chainId: `0x${this.chain.chainId.toString(16)}`, - label: tx.label, }; } diff --git a/tests/libs/browserBridge.test.ts b/tests/libs/browserBridge.test.ts index 86718a1c..45ca25ec 100644 --- a/tests/libs/browserBridge.test.ts +++ b/tests/libs/browserBridge.test.ts @@ -252,3 +252,161 @@ describe("BrowserWalletBridge", () => { 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); + }); +}); From 6d43a3705ed86ee2b41fadcb833457b837489fdd Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:40:29 +0100 Subject: [PATCH 08/22] 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. --- src/lib/wallet/browserSend.ts | 163 +++++++++++++++++++++++++------ src/lib/wallet/sessionClient.ts | 151 ++++++++++++++++++++++++++++ tests/libs/sessionClient.test.ts | 145 +++++++++++++++++++++++++++ 3 files changed, 427 insertions(+), 32 deletions(-) create mode 100644 src/lib/wallet/sessionClient.ts create mode 100644 tests/libs/sessionClient.test.ts diff --git a/src/lib/wallet/browserSend.ts b/src/lib/wallet/browserSend.ts index c15865b9..a2479312 100644 --- a/src/lib/wallet/browserSend.ts +++ b/src/lib/wallet/browserSend.ts @@ -7,8 +7,9 @@ import { type HttpTransportConfig, type TransactionReceipt, } from "viem"; -import type {GenLayerChain, Address} from "genlayer-js/types"; -import {BrowserWalletBridge, type BridgeChainParams} from "./browserBridge"; +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 @@ -44,8 +45,24 @@ 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 { - bridge: BrowserWalletBridge; + /** 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; @@ -71,18 +88,9 @@ export interface BrowserSession { close(finalMessage?: string): Promise; } -/** - * Open a browser-wallet signing session: 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 staking, - * vesting, and the BaseAction Lane B client). - */ -export async function openBrowserWalletSession(params: BrowserSessionParams): Promise { - const {chain, rpcUrl} = params; - const log = params.log ?? (() => {}); - const logInfo = params.logInfo ?? (() => {}); - - const bridgeChain: BridgeChainParams = { +/** 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], @@ -95,25 +103,28 @@ export async function openBrowserWalletSession(params: BrowserSessionParams): Pr : {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 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 signerAddress = transport.signerAddress; const chainIdHex = `0x${chain.id.toString(16)}`; let nextLabel: string | undefined; @@ -133,13 +144,15 @@ export async function openBrowserWalletSession(params: BrowserSessionParams): Pr value: tx.value, }); } catch (error: any) { - await bridge.close("The CLI aborted: the transaction would revert."); + // 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 bridge.sendTransaction({ + const hash = await transport.sendTransaction({ to: tx.to, data: tx.data, value: tx.value, @@ -181,7 +194,7 @@ export async function openBrowserWalletSession(params: BrowserSessionParams): Pr nonce?: string; type?: string; }; - const hash = await bridge.sendTransaction({ + 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, @@ -206,12 +219,98 @@ export async function openBrowserWalletSession(params: BrowserSessionParams): Pr return { bridge, + kind, + sessionUrl, publicClient, chain, signerAddress, sendTransaction, eip1193Provider, setNextLabel, - close: (finalMessage?: string) => bridge.close(finalMessage), + 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..27cd4807 --- /dev/null +++ b/src/lib/wallet/sessionClient.ts @@ -0,0 +1,151 @@ +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} 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; + +const TAB_CLOSED_MESSAGE = + "The wallet session tab appears to be closed. Run 'genlayer wallet connect' to reconnect."; + +/** + * 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/tests/libs/sessionClient.test.ts b/tests/libs/sessionClient.test.ts new file mode 100644 index 00000000..0c7244ce --- /dev/null +++ b/tests/libs/sessionClient.test.ts @@ -0,0 +1,145 @@ +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); + }); +}); From d0bf0701603d6d34717f55fd45c5fcb1070889f8 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:40:45 +0100 Subject: [PATCH 09/22] 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. --- src/lib/wallet/sessionDaemon.ts | 235 +++++++++++++++++++++++++++++ src/lib/wallet/sessionResolver.ts | 125 +++++++++++++++ src/lib/wallet/spawnDaemon.ts | 100 ++++++++++++ tests/libs/sessionDaemon.test.ts | 141 +++++++++++++++++ tests/libs/sessionResolver.test.ts | 175 +++++++++++++++++++++ tests/libs/spawnDaemon.test.ts | 112 ++++++++++++++ 6 files changed, 888 insertions(+) create mode 100644 src/lib/wallet/sessionDaemon.ts create mode 100644 src/lib/wallet/sessionResolver.ts create mode 100644 src/lib/wallet/spawnDaemon.ts create mode 100644 tests/libs/sessionDaemon.test.ts create mode 100644 tests/libs/sessionResolver.test.ts create mode 100644 tests/libs/spawnDaemon.test.ts diff --git a/src/lib/wallet/sessionDaemon.ts b/src/lib/wallet/sessionDaemon.ts new file mode 100644 index 00000000..c42bf3a0 --- /dev/null +++ b/src/lib/wallet/sessionDaemon.ts @@ -0,0 +1,235 @@ +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/sessionResolver.ts b/src/lib/wallet/sessionResolver.ts new file mode 100644 index 00000000..6b98ac1d --- /dev/null +++ b/src/lib/wallet/sessionResolver.ts @@ -0,0 +1,125 @@ +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} 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. + const 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); + } + 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..7197eac6 --- /dev/null +++ b/src/lib/wallet/spawnDaemon.ts @@ -0,0 +1,100 @@ +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/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/sessionResolver.test.ts b/tests/libs/sessionResolver.test.ts new file mode 100644 index 00000000..8333152e --- /dev/null +++ b/tests/libs/sessionResolver.test.ts @@ -0,0 +1,175 @@ +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 { + descriptorPath, + readDescriptor, + writeDescriptor, + type WalletSessionDescriptor, +} from "../../src/lib/wallet/sessionDescriptor"; + +const ADDRESS = "0xConnected0000000000000000000000000000001" as `0x${string}`; + +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; + }); + 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 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..f750ae70 --- /dev/null +++ b/tests/libs/spawnDaemon.test.ts @@ -0,0 +1,112 @@ +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. From e387f6d85f1df466c15ccd6991f6deb363de23de Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:40:55 +0100 Subject: [PATCH 10/22] 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. --- src/commands/wallet/WalletAction.ts | 176 ++++++++++++++++++++++++++++ src/commands/wallet/index.ts | 36 ++++++ src/index.ts | 2 + tests/commands/wallet.test.ts | 55 +++++++++ tests/index.test.ts | 4 + 5 files changed, 273 insertions(+) create mode 100644 src/commands/wallet/WalletAction.ts create mode 100644 src/commands/wallet/index.ts create mode 100644 tests/commands/wallet.test.ts diff --git a/src/commands/wallet/WalletAction.ts b/src/commands/wallet/WalletAction.ts new file mode 100644 index 00000000..9822cbe3 --- /dev/null +++ b/src/commands/wallet/WalletAction.ts @@ -0,0 +1,176 @@ +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) { + 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; + } + // 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..713527f7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ 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"; export function initializeCLI() { program.version(version).description(CLI_DESCRIPTION); @@ -27,6 +28,7 @@ export function initializeCLI() { initializeTransactionsCommands(program); initializeStakingCommands(program); initializeVestingCommands(program); + initializeWalletCommands(program); program.parse(process.argv); } diff --git a/tests/commands/wallet.test.ts b/tests/commands/wallet.test.ts new file mode 100644 index 00000000..021fe5eb --- /dev/null +++ b/tests/commands/wallet.test.ts @@ -0,0 +1,55 @@ +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..2c584c85 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -53,6 +53,10 @@ vi.mock("../src/commands/vesting", () => ({ initializeVestingCommands: vi.fn(), })); +vi.mock("../src/commands/wallet", () => ({ + initializeWalletCommands: vi.fn(), +})); + describe("CLI", () => { it("should initialize CLI", () => { expect(initializeCLI).not.toThrow(); From 628edd30750f4bd304695b2d150a6a60a83ba947 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:41:11 +0100 Subject: [PATCH 11/22] 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). --- src/commands/contracts/deploy.ts | 4 +- src/commands/contracts/write.ts | 2 +- src/commands/staking/StakingAction.ts | 12 +++++- src/commands/staking/validatorJoin.ts | 4 +- src/commands/staking/wizard.ts | 4 +- src/commands/transactions/appeal.ts | 2 +- src/commands/transactions/finalize.ts | 4 +- src/lib/actions/BaseAction.ts | 43 +++++++++++++++---- src/lib/wallet/walletOption.ts | 12 ++++-- tests/actions/staking.test.ts | 18 ++++---- tests/actions/stakingWizard.test.ts | 5 +++ tests/actions/walletSession.test.ts | 59 +++++++++++++++++++++++++++ tests/commands/appeal.test.ts | 3 -- tests/commands/deploy.test.ts | 4 -- tests/commands/finalize.test.ts | 6 +-- tests/commands/staking.test.ts | 27 ++++-------- tests/commands/vesting.test.ts | 12 +++--- tests/commands/write.test.ts | 4 -- 18 files changed, 156 insertions(+), 69 deletions(-) create mode 100644 tests/actions/walletSession.test.ts diff --git a/src/commands/contracts/deploy.ts b/src/commands/contracts/deploy.ts index d4677b4e..d3eb1bcc 100644 --- a/src/commands/contracts/deploy.ts +++ b/src/commands/contracts/deploy.ts @@ -75,7 +75,7 @@ export class DeployAction extends BaseAction { } async deployScripts(options?: DeployScriptsOptions) { - if (options?.wallet === "browser") this.walletModeOverride = "browser"; + 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."); @@ -122,7 +122,7 @@ export class DeployAction extends BaseAction { } async deploy(options: DeployOptions): Promise { - if (options.wallet === "browser") this.walletModeOverride = "browser"; + if (this.isBrowserWallet({wallet: options.wallet})) this.walletModeOverride = "browser"; try { const client = await this.getClient(options.rpc); this.browserSession?.setNextLabel( diff --git a/src/commands/contracts/write.ts b/src/commands/contracts/write.ts index 5aeefa22..882e3c3b 100644 --- a/src/commands/contracts/write.ts +++ b/src/commands/contracts/write.ts @@ -32,7 +32,7 @@ export class WriteAction extends BaseAction { contractAddress: string; method: string; }): Promise { - if (wallet === "browser") this.walletModeOverride = "browser"; + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); await client.initializeConsensusSmartContract(); this.browserSession?.setNextLabel(`${method} on ${contractAddress}`); diff --git a/src/commands/staking/StakingAction.ts b/src/commands/staking/StakingAction.ts index 8690db2f..40644785 100644 --- a/src/commands/staking/StakingAction.ts +++ b/src/commands/staking/StakingAction.ts @@ -14,7 +14,8 @@ import { type TransactionReceipt, } from "viem"; import {privateKeyToAccount} from "viem/accounts"; -import {openBrowserWalletSession, glHttpConfig, type BrowserSession} from "../../lib/wallet/browserSend"; +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 = [ @@ -98,11 +99,18 @@ export class StakingAction extends BaseAction { ); } - const session = await openBrowserWalletSession({ + // 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; diff --git a/src/commands/staking/validatorJoin.ts b/src/commands/staking/validatorJoin.ts index 45befe7d..d41281be 100644 --- a/src/commands/staking/validatorJoin.ts +++ b/src/commands/staking/validatorJoin.ts @@ -89,7 +89,9 @@ export class ValidatorJoinAction extends StakingAction { } catch (error: any) { this.failSpinner("Failed to create validator", error.message || error); } finally { - await session.bridge.close(); + // 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/wizard.ts b/src/commands/staking/wizard.ts index 4ec33e07..eb9a5a36 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -97,7 +97,9 @@ export class ValidatorWizardAction extends StakingAction { const session = this._wizardSession; this._wizardSession = null; this.browserSession = null; - if (session) await session.bridge.close(); + // 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(); } } diff --git a/src/commands/transactions/appeal.ts b/src/commands/transactions/appeal.ts index 68790ef1..f5f80866 100644 --- a/src/commands/transactions/appeal.ts +++ b/src/commands/transactions/appeal.ts @@ -28,7 +28,7 @@ export class AppealAction extends BaseAction { bond?: string; wallet?: "keystore" | "browser"; }): Promise { - if (wallet === "browser") this.walletModeOverride = "browser"; + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); this.browserSession?.setNextLabel(`Appeal ${txId}`); diff --git a/src/commands/transactions/finalize.ts b/src/commands/transactions/finalize.ts index 179c50e0..af271387 100644 --- a/src/commands/transactions/finalize.ts +++ b/src/commands/transactions/finalize.ts @@ -20,7 +20,7 @@ export class FinalizeAction extends BaseAction { rpc?: string; wallet?: "keystore" | "browser"; }): Promise { - if (wallet === "browser") this.walletModeOverride = "browser"; + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); this.browserSession?.setNextLabel(`Finalize ${txId}`); @@ -49,7 +49,7 @@ export class FinalizeAction extends BaseAction { return; } - if (wallet === "browser") this.walletModeOverride = "browser"; + if (this.isBrowserWallet({wallet})) this.walletModeOverride = "browser"; const client = await this.getClient(rpc); this.browserSession?.setNextLabel(`Finalize ${txIds.length} idle transaction(s)`); diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index c57df354..067d3e0b 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -13,7 +13,8 @@ import { normalizeCustomNetworks, type CustomNetworksConfig, } from "../networks/customNetworks"; -import {openBrowserWalletSession, type BrowserSession, type WalletMode} from "../wallet/browserSend"; +import {type BrowserSession, type WalletMode} from "../wallet/browserSend"; +import {resolveBrowserWalletSession, type SessionFallback} from "../wallet/sessionResolver"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -86,23 +87,41 @@ export class BaseAction extends ConfigFileManager { // --- Browser-wallet (MetaMask) signing seam ------------------------------ + /** + * Resolve the effective signing mode. Precedence: explicit --wallet flag > + * config `walletMode` > "keystore". An invalid flag throws; an unknown config + * value warns and falls back to keystore. Centralizing here means "flag + * omitted" cleanly defers to config while explicit `--wallet keystore` still + * overrides a `walletMode=browser` config. + */ + 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 !== null && cfg !== undefined && cfg !== "keystore") { + this.logWarning(`Ignoring invalid walletMode config value '${cfg}'. Using 'keystore'.`); + } + return "keystore"; + } + protected isBrowserWallet(config: {wallet?: string}): boolean { - return config.wallet === "browser"; + 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. + * 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)) { - if (config.wallet !== undefined && config.wallet !== "keystore") { - throw new Error(`Invalid --wallet value '${config.wallet}'. Use 'keystore' or 'browser'.`); - } return; } if (config.password !== undefined) { @@ -118,7 +137,9 @@ export class BaseAction extends ConfigFileManager { * 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} = {}): Promise { + protected async getBrowserSession( + opts: {network?: string; rpc?: string; fallback?: SessionFallback} = {}, + ): Promise { if (this.browserSession) return this.browserSession; const chain = opts.network @@ -126,11 +147,17 @@ export class BaseAction extends ConfigFileManager { : resolveNetwork(this.getConfig().network, this.getCustomNetworks()); const rpcUrl = opts.rpc || chain.rpcUrls.default.http[0]; - this.browserSession = await openBrowserWalletSession({ + // 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; } diff --git a/src/lib/wallet/walletOption.ts b/src/lib/wallet/walletOption.ts index e3aa1194..49cc6e54 100644 --- a/src/lib/wallet/walletOption.ts +++ b/src/lib/wallet/walletOption.ts @@ -9,9 +9,15 @@ import type {Command} from "commander"; */ export const WALLET_OPTION_FLAG = "--wallet "; export const WALLET_OPTION_DESC = - "Signing mode: 'keystore' (default) or 'browser' (sign in MetaMask via a local bridge; " + - "forward the port for remote/SSH: ssh -L :127.0.0.1:)"; + "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, "keystore"); + return cmd.option(WALLET_OPTION_FLAG, WALLET_OPTION_DESC); } diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 3176c530..a67ce704 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -339,10 +339,13 @@ describe("ValidatorJoinAction --wallet browser", () => { 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"); - const bridge = {close: vi.fn().mockResolvedValue(undefined)}; + // 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, + bridge: {close: vi.fn()}, + close, stakingAddress: "0xStaking", signerAddress: "0xBrowserOwner", sendTransaction, @@ -357,7 +360,7 @@ describe("ValidatorJoinAction --wallet browser", () => { expect(sendTransaction).toHaveBeenCalledOnce(); expect(getStakingClientSpy).not.toHaveBeenCalled(); expect(getSignerAddressSpy).not.toHaveBeenCalled(); - expect(bridge.close).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); // Output shape matches the keystore path. expect(action["succeedSpinner"]).toHaveBeenCalledWith( @@ -372,10 +375,11 @@ describe("ValidatorJoinAction --wallet browser", () => { ); }); - test("closes the bridge even when the send fails", async () => { - const bridge = {close: vi.fn().mockResolvedValue(undefined)}; + test("closes the session even when the send fails", async () => { + const close = vi.fn().mockResolvedValue(undefined); vi.spyOn(action as any, "getBrowserWalletSession").mockResolvedValue({ - bridge, + bridge: {close: vi.fn()}, + close, stakingAddress: "0xStaking", signerAddress: "0xBrowserOwner", sendTransaction: vi.fn().mockRejectedValue(new Error("Transaction rejected in wallet")), @@ -387,7 +391,7 @@ describe("ValidatorJoinAction --wallet browser", () => { "Failed to create validator", "Transaction rejected in wallet", ); - expect(bridge.close).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); }); test("rejects --wallet browser combined with --password", async () => { diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index 69a10573..68bccce7 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -87,9 +87,14 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { 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. diff --git a/tests/actions/walletSession.test.ts b/tests/actions/walletSession.test.ts new file mode 100644 index 00000000..74c24f2b --- /dev/null +++ b/tests/actions/walletSession.test.ts @@ -0,0 +1,59 @@ +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; + + 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(() => {}); + }); + afterEach(() => vi.restoreAllMocks()); + + test("no flag + no config → keystore", () => { + expect(action.publicResolveMode(undefined)).toBe("keystore"); + expect(action.publicIsBrowser({})).toBe(false); + }); + + 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/commands/appeal.test.ts b/tests/commands/appeal.test.ts index ac961f8a..9d5461ac 100644 --- a/tests/commands/appeal.test.ts +++ b/tests/commands/appeal.test.ts @@ -24,7 +24,6 @@ describe("appeal command", () => { expect(AppealAction).toHaveBeenCalledTimes(1); expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ txId: mockTxId, - wallet: "keystore", }); }); @@ -41,7 +40,6 @@ describe("appeal command", () => { expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ txId: mockTxId, rpc: "https://custom-rpc-url-for-appeal.com", - wallet: "keystore", }); }); @@ -50,7 +48,6 @@ describe("appeal command", () => { expect(AppealAction.prototype.appeal).toHaveBeenCalledWith({ txId: mockTxId, bond: "500gen", - wallet: "keystore", }); }); diff --git a/tests/commands/deploy.test.ts b/tests/commands/deploy.test.ts index 704774ba..85a493f8 100644 --- a/tests/commands/deploy.test.ts +++ b/tests/commands/deploy.test.ts @@ -27,7 +27,6 @@ describe("deploy command", () => { expect(DeployAction.prototype.deploy).toHaveBeenCalledWith({ contract: "./path/to/contract", args: [], - wallet: "keystore", }); }); @@ -50,7 +49,6 @@ describe("deploy command", () => { contract: "./path/to/contract", args: [1, 2, 3], rpc: "https://custom-rpc-url.com", - wallet: "keystore", }); }); @@ -76,7 +74,6 @@ describe("deploy command", () => { fees, feeValue: "4", validUntil: "999", - wallet: "keystore", }); }); @@ -101,7 +98,6 @@ describe("deploy command", () => { feeProfile: "./artifacts/fee-profile.json", feePreset: "high", appealRounds: "3", - wallet: "keystore", }); }); diff --git a/tests/commands/finalize.test.ts b/tests/commands/finalize.test.ts index 3f2f8dcd..5176296a 100644 --- a/tests/commands/finalize.test.ts +++ b/tests/commands/finalize.test.ts @@ -22,7 +22,7 @@ describe("finalize command", () => { test("FinalizeAction.finalize is called with txId", async () => { program.parse(["node", "test", "finalize", mockTxId]); expect(FinalizeAction).toHaveBeenCalledTimes(1); - expect(FinalizeAction.prototype.finalize).toHaveBeenCalledWith({txId: mockTxId, wallet: "keystore"}); + expect(FinalizeAction.prototype.finalize).toHaveBeenCalledWith({txId: mockTxId}); }); test("FinalizeAction.finalize is called with custom RPC URL", async () => { @@ -30,7 +30,6 @@ describe("finalize command", () => { expect(FinalizeAction.prototype.finalize).toHaveBeenCalledWith({ txId: mockTxId, rpc: "https://custom.com", - wallet: "keystore", }); }); @@ -62,7 +61,6 @@ describe("finalize-batch command", () => { program.parse(["node", "test", "finalize-batch", mockTxId1]); expect(FinalizeAction.prototype.finalizeBatch).toHaveBeenCalledWith({ txIds: [mockTxId1], - wallet: "keystore", }); }); @@ -70,7 +68,6 @@ describe("finalize-batch command", () => { program.parse(["node", "test", "finalize-batch", mockTxId1, mockTxId2]); expect(FinalizeAction.prototype.finalizeBatch).toHaveBeenCalledWith({ txIds: [mockTxId1, mockTxId2], - wallet: "keystore", }); }); @@ -81,7 +78,6 @@ describe("finalize-batch command", () => { expect(FinalizeAction.prototype.finalizeBatch).toHaveBeenCalledWith({ txIds: [mockTxId1, mockTxId2], rpc: "https://custom.com", - wallet: "keystore", }); }); }); diff --git a/tests/commands/staking.test.ts b/tests/commands/staking.test.ts index 40d1640f..4107a4b2 100644 --- a/tests/commands/staking.test.ts +++ b/tests/commands/staking.test.ts @@ -49,16 +49,15 @@ describe("staking commands", () => { expect(ValidatorJoinAction).toHaveBeenCalledTimes(1); expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith({ amount: "42000gen", - wallet: "keystore", }); }); - test("defaults --wallet to keystore", async () => { + test("leaves --wallet unset when omitted", async () => { program.parse(["node", "test", "staking", "validator-join", "--amount", "42000gen"]); - expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith( - expect.objectContaining({wallet: "keystore"}), - ); + // 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 () => { @@ -93,7 +92,6 @@ describe("staking commands", () => { expect(ValidatorJoinAction.prototype.execute).toHaveBeenCalledWith({ amount: "42000gen", operator: "0xOperator", - wallet: "keystore", }); }); @@ -116,13 +114,12 @@ describe("staking commands", () => { }); describe("wizard", () => { - test("defaults --wallet to keystore", async () => { + test("leaves --wallet unset when omitted", async () => { program.parse(["node", "test", "staking", "wizard"]); expect(ValidatorWizardAction).toHaveBeenCalledTimes(1); - expect(ValidatorWizardAction.prototype.execute).toHaveBeenCalledWith( - expect.objectContaining({wallet: "keystore"}), - ); + const arg = (ValidatorWizardAction.prototype.execute as any).mock.calls[0][0]; + expect(arg.wallet).toBeUndefined(); }); test("parses --wallet browser", async () => { @@ -151,7 +148,6 @@ describe("staking commands", () => { expect(ValidatorDepositAction.prototype.execute).toHaveBeenCalledWith({ validator: "0x1234567890123456789012345678901234567890", amount: "1000gen", - wallet: "keystore", }); }); }); @@ -173,7 +169,6 @@ describe("staking commands", () => { expect(ValidatorExitAction.prototype.execute).toHaveBeenCalledWith({ validator: "0x1234567890123456789012345678901234567890", shares: "100", - wallet: "keystore", }); }); }); @@ -185,7 +180,6 @@ describe("staking commands", () => { expect(ValidatorClaimAction).toHaveBeenCalledTimes(1); expect(ValidatorClaimAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", - wallet: "keystore", }); }); }); @@ -207,7 +201,6 @@ describe("staking commands", () => { expect(DelegatorJoinAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", amount: "42gen", - wallet: "keystore", }); }); }); @@ -229,7 +222,6 @@ describe("staking commands", () => { expect(DelegatorExitAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", shares: "50", - wallet: "keystore", }); }); }); @@ -251,7 +243,6 @@ describe("staking commands", () => { expect(DelegatorClaimAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", delegator: "0xDelegator", - wallet: "keystore", }); }); }); @@ -321,7 +312,6 @@ describe("staking commands", () => { expect(ValidatorPrimeAction).toHaveBeenCalledTimes(1); expect(ValidatorPrimeAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", - wallet: "keystore", }); }); }); @@ -343,7 +333,6 @@ describe("staking commands", () => { expect(SetOperatorAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", operator: "0xOperator", - wallet: "keystore", }); }); }); @@ -365,7 +354,6 @@ describe("staking commands", () => { expect(SetIdentityAction.prototype.execute).toHaveBeenCalledWith({ validator: "0xValidator", moniker: "My Validator", - wallet: "keystore", }); }); @@ -393,7 +381,6 @@ describe("staking commands", () => { website: "https://example.com", twitter: "myhandle", github: "mygithub", - wallet: "keystore", }); }); }); diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts index d8314da7..33851bfb 100644 --- a/tests/commands/vesting.test.ts +++ b/tests/commands/vesting.test.ts @@ -517,14 +517,16 @@ describe("vesting commands", () => { ); }); - test("delegate defaults to keystore signing mode when --wallet omitted", async () => { + 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"]); - expect(executeSpy).toHaveBeenCalledWith(expect.objectContaining({wallet: "keystore"})); + // 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 () => { @@ -546,8 +548,8 @@ describe("vesting commands", () => { // The deprecated --validator-wallet flag supplies the address; --wallet // (signing mode) must not be interpreted as the wallet address. - expect(executeSpy).toHaveBeenCalledWith( - expect.objectContaining({walletAddress: "0xWallet", wallet: "keystore"}), - ); + const depositArg = executeSpy.mock.calls[0][0] as any; + expect(depositArg.walletAddress).toBe("0xWallet"); + expect(depositArg.wallet).toBeUndefined(); }); }); diff --git a/tests/commands/write.test.ts b/tests/commands/write.test.ts index bff14145..7e562312 100644 --- a/tests/commands/write.test.ts +++ b/tests/commands/write.test.ts @@ -28,7 +28,6 @@ describe("write command", () => { contractAddress: "0xMockedContract", method: "setData", args: [], - wallet: "keystore", }); }); @@ -52,7 +51,6 @@ describe("write command", () => { method: "updateCounter", args: [100, "someString", true], rpc: "https://custom-rpc-url-for-write.com", - wallet: "keystore", }); }); @@ -79,7 +77,6 @@ describe("write command", () => { fees, feeValue: "4", validUntil: "999", - wallet: "keystore", }); }); @@ -105,7 +102,6 @@ describe("write command", () => { feeProfile: "./artifacts/fee-profile.json", feePreset: "standard", appealRounds: "2", - wallet: "keystore", }); }); From 25d3a3e6c1cf7e303cc36dc14d72c0836059549d Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:41:21 +0100 Subject: [PATCH 12/22] 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. --- docs/api-references/_meta.json | 3 ++- docs/api-references/contracts/deploy.mdx | 2 +- docs/api-references/contracts/write.mdx | 2 +- docs/api-references/finalize-batch.mdx | 2 +- docs/api-references/finalize.mdx | 2 +- docs/api-references/index.mdx | 1 + .../staking/staking/delegator-claim.mdx | 2 +- .../staking/staking/delegator-exit.mdx | 2 +- .../staking/staking/delegator-join.mdx | 2 +- .../staking/staking/prime-all.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-join.mdx | 2 +- .../staking/staking/validator-prime.mdx | 2 +- .../api-references/staking/staking/wizard.mdx | 2 +- docs/api-references/transactions/appeal.mdx | 2 +- docs/api-references/vesting/claim.mdx | 2 +- docs/api-references/vesting/delegate.mdx | 2 +- docs/api-references/vesting/undelegate.mdx | 2 +- .../vesting/validator/claim.mdx | 2 +- .../vesting/validator/create.mdx | 2 +- .../vesting/validator/deposit.mdx | 2 +- .../api-references/vesting/validator/exit.mdx | 2 +- .../api-references/vesting/validator/join.mdx | 2 +- .../validator/operator-transfer/cancel.mdx | 2 +- .../validator/operator-transfer/complete.mdx | 2 +- .../validator/operator-transfer/initiate.mdx | 2 +- .../vesting/validator/set-identity.mdx | 2 +- docs/api-references/vesting/withdraw.mdx | 2 +- 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 +++++++++++ tests/setup.ts | 11 ++++++++ vitest.config.ts | 1 + 38 files changed, 117 insertions(+), 31 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 tests/setup.ts 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 1d61a6e3..1fc24809 100644 --- a/docs/api-references/contracts/deploy.mdx +++ b/docs/api-references/contracts/deploy.mdx @@ -21,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 11829131..a6542d4a 100644 --- a/docs/api-references/contracts/write.mdx +++ b/docs/api-references/contracts/write.mdx @@ -25,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 fdfa8c64..37fa3895 100644 --- a/docs/api-references/finalize-batch.mdx +++ b/docs/api-references/finalize-batch.mdx @@ -17,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 4abe4aff..74ad96ea 100644 --- a/docs/api-references/finalize.mdx +++ b/docs/api-references/finalize.mdx @@ -17,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 1f1aafb7..9f8ea959 100644 --- a/docs/api-references/staking/staking/delegator-claim.mdx +++ b/docs/api-references/staking/staking/delegator-claim.mdx @@ -23,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 5301adaa..31e233ec 100644 --- a/docs/api-references/staking/staking/delegator-exit.mdx +++ b/docs/api-references/staking/staking/delegator-exit.mdx @@ -23,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 fb97e708..2efe155c 100644 --- a/docs/api-references/staking/staking/delegator-join.mdx +++ b/docs/api-references/staking/staking/delegator-join.mdx @@ -23,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 114601e1..96a929ef 100644 --- a/docs/api-references/staking/staking/prime-all.mdx +++ b/docs/api-references/staking/staking/prime-all.mdx @@ -17,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 712449ab..66fea2cd 100644 --- a/docs/api-references/staking/staking/set-identity.mdx +++ b/docs/api-references/staking/staking/set-identity.mdx @@ -30,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "keystore") | 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 f61b548d..bde668f7 100644 --- a/docs/api-references/staking/staking/set-operator.mdx +++ b/docs/api-references/staking/staking/set-operator.mdx @@ -23,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "keystore") | 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 403487fb..64ca0a66 100644 --- a/docs/api-references/staking/staking/validator-claim.mdx +++ b/docs/api-references/staking/staking/validator-claim.mdx @@ -21,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "keystore") | 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 2997e2ff..029c7071 100644 --- a/docs/api-references/staking/staking/validator-deposit.mdx +++ b/docs/api-references/staking/staking/validator-deposit.mdx @@ -22,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "keystore") | 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 ec8a0a0e..383aa2f3 100644 --- a/docs/api-references/staking/staking/validator-exit.mdx +++ b/docs/api-references/staking/staking/validator-exit.mdx @@ -22,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) (default: "keystore") | 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 53b91db3..09a65a35 100644 --- a/docs/api-references/staking/staking/validator-join.mdx +++ b/docs/api-references/staking/staking/validator-join.mdx @@ -19,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 96883c72..0ced6924 100644 --- a/docs/api-references/staking/staking/validator-prime.mdx +++ b/docs/api-references/staking/staking/validator-prime.mdx @@ -22,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 052d5504..1923cc78 100644 --- a/docs/api-references/staking/staking/wizard.mdx +++ b/docs/api-references/staking/staking/wizard.mdx @@ -17,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 5009d848..271a6a93 100644 --- a/docs/api-references/transactions/appeal.mdx +++ b/docs/api-references/transactions/appeal.mdx @@ -18,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 b7bd6de9..0d068d80 100644 --- a/docs/api-references/vesting/claim.mdx +++ b/docs/api-references/vesting/claim.mdx @@ -24,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 3b4f9225..3b984e6f 100644 --- a/docs/api-references/vesting/delegate.mdx +++ b/docs/api-references/vesting/delegate.mdx @@ -25,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 f051ef4c..c1c602c7 100644 --- a/docs/api-references/vesting/undelegate.mdx +++ b/docs/api-references/vesting/undelegate.mdx @@ -24,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 0204983d..8b359a80 100644 --- a/docs/api-references/vesting/validator/claim.mdx +++ b/docs/api-references/vesting/validator/claim.mdx @@ -24,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 e7ebbdc0..f6b28c43 100644 --- a/docs/api-references/vesting/validator/create.mdx +++ b/docs/api-references/vesting/validator/create.mdx @@ -25,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 696df192..fcf8099b 100644 --- a/docs/api-references/vesting/validator/deposit.mdx +++ b/docs/api-references/vesting/validator/deposit.mdx @@ -25,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 58bfeedd..a44f12cf 100644 --- a/docs/api-references/vesting/validator/exit.mdx +++ b/docs/api-references/vesting/validator/exit.mdx @@ -25,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 cdcfbd8d..35a4a9f1 100644 --- a/docs/api-references/vesting/validator/join.mdx +++ b/docs/api-references/vesting/validator/join.mdx @@ -25,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 749e1b1f..504afe5d 100644 --- a/docs/api-references/vesting/validator/operator-transfer/cancel.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/cancel.mdx @@ -24,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 40815c04..20af8759 100644 --- a/docs/api-references/vesting/validator/operator-transfer/complete.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/complete.mdx @@ -24,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 4255c9e7..473d51fc 100644 --- a/docs/api-references/vesting/validator/operator-transfer/initiate.mdx +++ b/docs/api-references/vesting/validator/operator-transfer/initiate.mdx @@ -26,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 0e87f886..2379836d 100644 --- a/docs/api-references/vesting/validator/set-identity.mdx +++ b/docs/api-references/vesting/validator/set-identity.mdx @@ -33,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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 6e358cf9..a065322e 100644 --- a/docs/api-references/vesting/withdraw.mdx +++ b/docs/api-references/vesting/withdraw.mdx @@ -20,5 +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' (default) or 'browser' (sign in MetaMask via a local bridge; forward the port for remote/SSH: ssh -L <port>:127.0.0.1:<port>) | No | `keystore` | +| | --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/tests/setup.ts b/tests/setup.ts new file mode 100644 index 00000000..794d45c2 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,11 @@ +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)})); 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 f1ad2de8785b0dfb05641553134404fddb756378 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 10:42:59 +0100 Subject: [PATCH 13/22] style(wallet): prettier-format session runtime + tests --- src/commands/wallet/WalletAction.ts | 7 ++++--- src/lib/wallet/sessionDaemon.ts | 5 ++++- src/lib/wallet/sessionResolver.ts | 13 ++----------- src/lib/wallet/spawnDaemon.ts | 6 +++++- tests/commands/wallet.test.ts | 11 ++++++++++- tests/libs/sessionClient.test.ts | 16 +++++++++++++--- tests/libs/sessionResolver.test.ts | 13 +++++++++++-- tests/libs/spawnDaemon.test.ts | 7 ++++++- 8 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/commands/wallet/WalletAction.ts b/src/commands/wallet/WalletAction.ts index 9822cbe3..019cf20c 100644 --- a/src/commands/wallet/WalletAction.ts +++ b/src/commands/wallet/WalletAction.ts @@ -42,7 +42,9 @@ export class WalletAction extends BaseAction { 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.`); + this.logInfo( + `A session is starting on ${existing.network}. Approve the connection in your browser.`, + ); } return; } @@ -102,8 +104,7 @@ export class WalletAction extends BaseAction { 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; + const heartbeatFresh = state.lastPagePollAt > 0 ? now - state.lastPagePollAt <= HEARTBEAT_DEAD_MS : true; this.log("Wallet session:", { status: state.connected ? "connected" : "connecting", diff --git a/src/lib/wallet/sessionDaemon.ts b/src/lib/wallet/sessionDaemon.ts index c42bf3a0..d366f2c0 100644 --- a/src/lib/wallet/sessionDaemon.ts +++ b/src/lib/wallet/sessionDaemon.ts @@ -157,7 +157,10 @@ export async function runWalletSessionDaemon(opts: RunDaemonOptions): Promise idleTtlMs) { log("Idle TTL reached — shutting down."); - void cleanupAndExit(0, "Session expired after inactivity. Run 'genlayer wallet connect' to start a new one."); + void cleanupAndExit( + 0, + "Session expired after inactivity. Run 'genlayer wallet connect' to start a new one.", + ); return; } diff --git a/src/lib/wallet/sessionResolver.ts b/src/lib/wallet/sessionResolver.ts index 6b98ac1d..acbb87d8 100644 --- a/src/lib/wallet/sessionResolver.ts +++ b/src/lib/wallet/sessionResolver.ts @@ -1,17 +1,8 @@ import type {GenLayerChain} from "genlayer-js/types"; import type {ConfigFileManager} from "../config/ConfigFileManager"; -import { - openBrowserWalletSession, - openRemoteWalletSession, - type BrowserSession, -} from "./browserSend"; +import {openBrowserWalletSession, openRemoteWalletSession, type BrowserSession} from "./browserSend"; import {WalletSessionClient} from "./sessionClient"; -import { - descriptorPath, - readDescriptor, - removeDescriptor, - isPidAlive, -} from "./sessionDescriptor"; +import {descriptorPath, readDescriptor, removeDescriptor, isPidAlive} from "./sessionDescriptor"; import {spawnWalletDaemon, waitForDaemonReady} from "./spawnDaemon"; import {DAEMON_LOG_FILENAME, CONNECT_TIMEOUT_MS} from "./sessionConstants"; diff --git a/src/lib/wallet/spawnDaemon.ts b/src/lib/wallet/spawnDaemon.ts index 7197eac6..79b38087 100644 --- a/src/lib/wallet/spawnDaemon.ts +++ b/src/lib/wallet/spawnDaemon.ts @@ -4,7 +4,11 @@ import {readDescriptor, isPidAlive, type WalletSessionDescriptor} from "./sessio import {WalletSessionClient} from "./sessionClient"; import {DAEMON_READY_TIMEOUT_MS} from "./sessionConstants"; -type SpawnFn = (command: string, args: readonly string[], options: SpawnOptions) => {pid?: number; unref(): void}; +type SpawnFn = ( + command: string, + args: readonly string[], + options: SpawnOptions, +) => {pid?: number; unref(): void}; export interface SpawnDaemonParams { /** Network alias; omitted → daemon uses config network. */ diff --git a/tests/commands/wallet.test.ts b/tests/commands/wallet.test.ts index 021fe5eb..019a5d6d 100644 --- a/tests/commands/wallet.test.ts +++ b/tests/commands/wallet.test.ts @@ -17,7 +17,16 @@ describe("wallet commands", () => { afterEach(() => vi.restoreAllMocks()); test("connect passes --network and --rpc", async () => { - program.parse(["node", "test", "wallet", "connect", "--network", "testnet-bradbury", "--rpc", "https://r"]); + 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"}), ); diff --git a/tests/libs/sessionClient.test.ts b/tests/libs/sessionClient.test.ts index 0c7244ce..e988a1ba 100644 --- a/tests/libs/sessionClient.test.ts +++ b/tests/libs/sessionClient.test.ts @@ -1,5 +1,9 @@ import {describe, test, expect, beforeEach, afterEach} from "vitest"; -import {BrowserWalletBridge, serializeBridgeTx, type BridgeChainParams} from "../../src/lib/wallet/browserBridge"; +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"; @@ -14,7 +18,11 @@ 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)}; + 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. */ @@ -30,7 +38,9 @@ function drivePage(origin: string, token: string, opts: {status: string; txHash? (async () => { await pagePost("/api/connected", {address: ADDRESS}); while (!stopped) { - const next = await authGet("/api/next").then(r => r.json()).catch(() => ({type: "stop"})); + 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") { diff --git a/tests/libs/sessionResolver.test.ts b/tests/libs/sessionResolver.test.ts index 8333152e..b7e4c973 100644 --- a/tests/libs/sessionResolver.test.ts +++ b/tests/libs/sessionResolver.test.ts @@ -24,7 +24,11 @@ const CHAIN: any = { 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)}; + return { + origin: `${u.protocol}//${u.host}`, + token: new URLSearchParams(u.hash.slice(1)).get("s")!, + port: Number(u.port), + }; } describe("resolveBrowserWalletSession", () => { @@ -45,7 +49,12 @@ describe("resolveBrowserWalletSession", () => { /** 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}}, + 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). diff --git a/tests/libs/spawnDaemon.test.ts b/tests/libs/spawnDaemon.test.ts index f750ae70..41942e10 100644 --- a/tests/libs/spawnDaemon.test.ts +++ b/tests/libs/spawnDaemon.test.ts @@ -66,7 +66,12 @@ describe("waitForDaemonReady", () => { 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}}, + 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). From 33a9cf42663f82cfa1cb819b54bd28ae0cac667e Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 11:36:57 +0100 Subject: [PATCH 14/22] 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). --- src/lib/actions/BaseAction.ts | 37 +++++++++++++-- tests/actions/hasLiveWalletSession.test.ts | 55 ++++++++++++++++++++++ tests/actions/walletSession.test.ts | 29 +++++++++++- tests/setup.ts | 22 +++++++++ 4 files changed, 137 insertions(+), 6 deletions(-) create mode 100644 tests/actions/hasLiveWalletSession.test.ts diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 067d3e0b..7a80078b 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -15,6 +15,7 @@ import { } 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 = { @@ -89,10 +90,14 @@ export class BaseAction extends ConfigFileManager { /** * Resolve the effective signing mode. Precedence: explicit --wallet flag > - * config `walletMode` > "keystore". An invalid flag throws; an unknown config - * value warns and falls back to keystore. Centralizing here means "flag - * omitted" cleanly defers to config while explicit `--wallet keystore` still - * overrides a `walletMode=browser` config. + * 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; @@ -101,12 +106,34 @@ export class BaseAction extends ConfigFileManager { } const cfg = this.getConfigByKey("walletMode"); if (cfg === "browser") return "browser"; - if (cfg !== null && cfg !== undefined && cfg !== "keystore") { + 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"; } 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/walletSession.test.ts b/tests/actions/walletSession.test.ts index 74c24f2b..8346fd8e 100644 --- a/tests/actions/walletSession.test.ts +++ b/tests/actions/walletSession.test.ts @@ -15,6 +15,7 @@ describe("BaseAction wallet-mode resolution (config default)", () => { let action: TestAction; let configValue: any; let warnSpy: any; + let sessionSpy: any; beforeEach(() => { action = new TestAction(); @@ -23,14 +24,40 @@ describe("BaseAction wallet-mode resolution (config default)", () => { 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 → keystore", () => { + 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"); diff --git a/tests/setup.ts b/tests/setup.ts index 794d45c2..4b11f82a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -9,3 +9,25 @@ import {vi} from "vitest"; * 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}; +}); From 1213657f6e002300a211ca3b0e6ee67b46ce1425 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 11:37:05 +0100 Subject: [PATCH 15/22] feat(staking): wizard lists custom networks and honors live wallet session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/staking/wizard.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index eb9a5a36..1130fd5d 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -131,10 +131,12 @@ export class ValidatorWizardAction extends StakingAction { console.log("Step 1: Account Setup"); console.log("---------------------\n"); - // Browser-wallet owner (via --wallet browser). 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 (options.wallet === "browser") { + // 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."); @@ -263,19 +265,27 @@ 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`); } private async stepBalanceCheck(state: Partial, options: WizardOptions): Promise { From b99e4ea5c8310f3d72c3945f016b90c49924e3a8 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 11:37:14 +0100 Subject: [PATCH 16/22] feat(account): account show reflects active network + --network flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/account/index.ts | 1 + src/commands/account/show.ts | 20 ++++++-- tests/actions/show.test.ts | 94 +++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 4 deletions(-) create mode 100644 tests/actions/show.test.ts 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/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})}), + ); + }); +}); From c6affb60967896071e319e6ad6f22a53c7f1bc27 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 11:56:25 +0100 Subject: [PATCH 17/22] feat(balances): add read-only balances view (wallet + vesting + committed stake) --- src/commands/balances/BalancesAction.ts | 234 ++++++++++++++++++++++++ src/commands/balances/index.ts | 18 ++ src/index.ts | 2 + tests/actions/balances.test.ts | 221 ++++++++++++++++++++++ tests/commands/balances.test.ts | 73 ++++++++ tests/index.test.ts | 4 + 6 files changed, 552 insertions(+) create mode 100644 src/commands/balances/BalancesAction.ts create mode 100644 src/commands/balances/index.ts create mode 100644 tests/actions/balances.test.ts create mode 100644 tests/commands/balances.test.ts diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts new file mode 100644 index 00000000..932b29d6 --- /dev/null +++ b/src/commands/balances/BalancesAction.ts @@ -0,0 +1,234 @@ +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 {readDescriptor, descriptorPath} from "../../lib/wallet/sessionDescriptor"; +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; + /** Self-stake committed from this vesting (sum over its validator wallets). */ + selfStakeRaw: bigint; + /** Delegated committed from this vesting (active + pending, over the validator set). */ + delegatedRaw: bigint; + committedRaw: bigint; + /** DERIVED estimate: vested − withdrawn − committed, floored at 0. */ + 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 + * stake, and a derived available-to-stake estimate. 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: + * 1. --beneficiary (pure read, no wallet needed) + * 2. the active account's keystore address (file read only, no password) + * 3. a live browser-wallet session's connected address, if one exists + * (reads the on-disk descriptor only — never starts a daemon or a tab). + */ + private async resolveAddress(options: BalancesOptions): Promise
{ + if (options.beneficiary) { + return options.beneficiary as Address; + } + + try { + return await this.getSignerAddress(); + } catch (error) { + if (this.isBrowserWallet(options)) { + const descriptor = readDescriptor(descriptorPath(this)); + if (descriptor?.address) { + return descriptor.address as Address; + } + } + throw new Error( + "No address to inspect. Pass --beneficiary
, select an account, or connect a wallet.", + ); + } + } + + private async computeVestingSummary( + client: VestingClient, + vesting: Address, + activeValidators: Address[], + ): Promise { + const state = await client.getVestingState(vesting); + + // Self-stake committed from this vesting: sum deposits across its 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 from this vesting: active stake plus everything still + // tied up (pending deposits activating + pending withdrawals unbonding), + // over the active validator set (see execute()). getStakeInfo takes the + // delegator first — here the vesting contract is the delegator. + let delegatedRaw = 0n; + for (const validator of activeValidators) { + const info = await client.getStakeInfo(vesting, validator); + const pendingDeposits = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); + const pendingWithdrawals = info.pendingWithdrawals.reduce((sum, w) => sum + w.stakeRaw, 0n); + delegatedRaw += info.stakeRaw + pendingDeposits + pendingWithdrawals; + } + + const committedRaw = selfStakeRaw + delegatedRaw; + const vestedRaw = state.vestedAmountRaw; + const totalWithdrawnRaw = state.totalWithdrawnRaw; + + // DERIVED estimate — the contract/SDK exposes no direct "available to stake" + // getter (withdraw/delegate take an explicit --amount). Floor at 0 because + // committed can exceed vested when unvested tokens were also staked. + const availableRaw = vestedRaw - totalWithdrawnRaw - committedRaw; + const availableToStakeRaw = availableRaw > 0n ? availableRaw : 0n; + + return { + vesting, + name: state.name || "", + totalRaw: state.totalAmountRaw, + vestedRaw, + lockedRaw: state.unvestedAmountRaw, + withdrawableRaw: state.withdrawableAmountRaw, + totalWithdrawnRaw, + 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, + }); + 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 (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(fmt(v.availableToStakeRaw))], + ); + 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 a DERIVED estimate: vested − withdrawn − committed"), + ); + console.log( + chalk.gray(" (floored at 0), scanned over the active validator set — not a direct on-chain value."), + ); + 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/index.ts b/src/index.ts index 713527f7..81015faa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ 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); @@ -29,6 +30,7 @@ export function initializeCLI() { initializeStakingCommands(program); initializeVestingCommands(program); initializeWalletCommands(program); + initializeBalancesCommands(program); program.parse(process.argv); } diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts new file mode 100644 index 00000000..eaa68e99 --- /dev/null +++ b/tests/actions/balances.test.ts @@ -0,0 +1,221 @@ +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, + ...overrides, + }; +} + +function makeStakeInfo(overrides: Record = {}) { + return { + delegator: "0xV1", + validator: "0xVal1", + shares: 0n, + stake: "", + stakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + ...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([]), + getStakeInfo: vi.fn(), + ...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 with self-stake + a delegation → committed & available computed", async () => { + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState({vestedAmountRaw: 20n * WEI, totalWithdrawnRaw: 2n * WEI})), + getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), + validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake 5 + getActiveValidators: vi.fn().mockResolvedValue(["0xVal1"]), + getStakeInfo: vi.fn().mockResolvedValue( + makeStakeInfo({ + stakeRaw: 3n * WEI, // active delegated 3 + pendingDeposits: [{stakeRaw: 1n * WEI}], // + 1 activating + pendingWithdrawals: [], + }), + ), + }); + 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); // 3 active + 1 pending + expect(v.committedRaw).toBe(9n * WEI); + // available ≈ vested(20) − withdrawn(2) − committed(9) = 9 + expect(v.availableToStakeRaw).toBe(9n * WEI); + // getStakeInfo takes (delegator=vesting, validator). + expect(client.getStakeInfo).toHaveBeenCalledWith("0xV1", "0xVal1"); + expect(client.validatorDeposited).toHaveBeenCalledWith("0xV1", "0xW1"); + }); + + test("(b') available-to-stake floors at 0 when committed exceeds vested", async () => { + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState({vestedAmountRaw: 5n * WEI, totalWithdrawnRaw: 0n})), + getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), + validatorDeposited: vi.fn().mockResolvedValue(10n * WEI), // committed > vested + getActiveValidators: vi.fn().mockResolvedValue([]), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + const v = renderSpy.mock.calls[0][0].vestings[0]; + expect(v.committedRaw).toBe(10n * WEI); + expect(v.availableToStakeRaw).toBe(0n); + }); + + test("(c) multiple vesting contracts each summarized; validator set fetched once", async () => { + const stateA = makeState({name: "A", vestedAmountRaw: 20n * WEI, totalWithdrawnRaw: 0n}); + const stateB = makeState({name: "B", vestedAmountRaw: 50n * WEI, totalWithdrawnRaw: 5n * WEI}); + 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([]), + }); + 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); // 20 - 0 - 0 + expect(summary.vestings[1].name).toBe("B"); + expect(summary.vestings[1].availableToStakeRaw).toBe(45n * WEI); // 50 - 5 - 0 + // 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(); + }); +}); diff --git a/tests/commands/balances.test.ts b/tests/commands/balances.test.ts new file mode 100644 index 00000000..77db7859 --- /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(), + getStakeInfo: 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/index.test.ts b/tests/index.test.ts index 2c584c85..815ce0b8 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -57,6 +57,10 @@ 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(); From eaa311165d1841d2d4a87cb05dcfdd4e24bcd815 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 12:06:24 +0100 Subject: [PATCH 18/22] 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. --- src/commands/staking/wizard.ts | 338 +++++++++++++++++++++++++++- tests/actions/stakingWizard.test.ts | 238 +++++++++++++++++++- 2 files changed, 569 insertions(+), 7 deletions(-) diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 1130fd5d..9ab9371f 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -5,10 +5,12 @@ 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"; const BROWSER_WALLET_CHOICE = "__browser_wallet__"; @@ -20,6 +22,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; @@ -68,6 +74,10 @@ export class ValidatorWizardAction extends StakingAction { // Step 2: Network Selection await this.stepNetworkSelection(state, options); + // 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); @@ -288,19 +298,124 @@ export class ValidatorWizardAction extends StakingAction { 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. + // 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) { - console.log("Connect your browser wallet to continue..."); + 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()); @@ -350,6 +465,77 @@ 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" = totalAmount - totalWithdrawn: the funds the contract + * still holds. We deliberately do NOT restrict to the vested/withdrawable slice + * because vesting-backed staking can commit locked (unvested) tokens too — those + * funds simply return to the vesting contract on exit. Only SDK-exposed + * VestingState fields are used (no bespoke formula). 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; + + const totalRaw = BigInt(vestingState.totalAmountRaw ?? 0n); + const withdrawnRaw = BigInt(vestingState.totalWithdrawnRaw ?? 0n); + const available = totalRaw > withdrawnRaw ? totalRaw - withdrawnRaw : 0n; + + 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"); @@ -669,6 +855,15 @@ 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); } @@ -742,10 +937,140 @@ export class ValidatorWizardAction extends StakingAction { } } + /** + * 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", @@ -901,6 +1226,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})`); diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index 68bccce7..bd3c1b48 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -3,6 +3,7 @@ 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"); @@ -19,13 +20,24 @@ vi.mock("fs", async importOriginal => { }); // 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().mockResolvedValue(1000n * 10n ** 18n), - getEpochInfo: vi.fn().mockResolvedValue({ + 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", () => ({ @@ -36,7 +48,7 @@ vi.mock("genlayer-js", () => ({ const cleaned = val.toLowerCase().replace(/gen|eth/g, ""); return BigInt(Math.floor(parseFloat(cleaned) * 1e18)); }), - abi: {STAKING_ABI: []}, + abi: {STAKING_ABI: [], VESTING_ABI: []}, })); // Pure tx-builders (real behavior covered in tests/libs/stakingTx.test.ts). @@ -46,6 +58,11 @@ vi.mock("../../src/lib/wallet/stakingTx", () => ({ 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; @@ -111,11 +128,13 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { 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"}) @@ -158,6 +177,51 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { ); }); + 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/, @@ -165,3 +229,169 @@ describe("ValidatorWizardAction --wallet browser (owner)", () => { 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(); + }); +}); From 257e3af8182ed709dffbfd51c1ef116bcd5fbbd3 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 12:32:03 +0100 Subject: [PATCH 19/22] fix(vesting): available-to-stake is the contract balance, not derived math --- src/commands/balances/BalancesAction.ts | 62 ++++++++++++---------- src/commands/staking/wizard.ts | 38 ++++++++++---- src/lib/vesting/availableToStake.ts | 29 +++++++++++ tests/actions/balances.test.ts | 69 +++++++++++-------------- tests/actions/stakingWizard.test.ts | 20 +++++++ tests/commands/balances.test.ts | 2 +- 6 files changed, 143 insertions(+), 77 deletions(-) create mode 100644 src/lib/vesting/availableToStake.ts diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts index 932b29d6..fc5fc517 100644 --- a/src/commands/balances/BalancesAction.ts +++ b/src/commands/balances/BalancesAction.ts @@ -2,6 +2,7 @@ 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} from "../../lib/wallet/sessionDescriptor"; import {formatEther} from "viem"; import Table from "cli-table3"; @@ -21,12 +22,15 @@ interface VestingBalanceSummary { lockedRaw: bigint; withdrawableRaw: bigint; totalWithdrawnRaw: bigint; - /** Self-stake committed from this vesting (sum over its validator wallets). */ + /** 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 from this vesting (active + pending, over the validator set). */ + /** Delegated committed principal (cost basis, summed over the validator set). */ delegatedRaw: bigint; + /** Committed principal (self-stake + delegated); informational only. */ committedRaw: bigint; - /** DERIVED estimate: vested − withdrawn − committed, floored at 0. */ + /** AUTHORITATIVE on-chain figure: the contract's live native balance (0 if revoked). */ availableToStakeRaw: bigint; } @@ -40,8 +44,9 @@ interface BalancesSummary { /** * Read-only "what do I hold" view: wallet GEN + per-vesting totals, committed - * stake, and a derived available-to-stake estimate. Never signs, never writes, - * and never unlocks the keystore — a read only needs the address. + * 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() { @@ -135,8 +140,8 @@ export class BalancesAction extends VestingAction { ): Promise { const state = await client.getVestingState(vesting); - // Self-stake committed from this vesting: sum deposits across its validator - // wallets (see vesting validatorList). + // 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) { @@ -144,36 +149,34 @@ export class BalancesAction extends VestingAction { selfStakeRaw += typeof deposited === "bigint" ? deposited : this.parseAmount(String(deposited)); } - // Delegated committed from this vesting: active stake plus everything still - // tied up (pending deposits activating + pending withdrawals unbonding), - // over the active validator set (see execute()). getStakeInfo takes the - // delegator first — here the vesting contract is the delegator. + // 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) { - const info = await client.getStakeInfo(vesting, validator); - const pendingDeposits = info.pendingDeposits.reduce((sum, d) => sum + d.stakeRaw, 0n); - const pendingWithdrawals = info.pendingWithdrawals.reduce((sum, w) => sum + w.stakeRaw, 0n); - delegatedRaw += info.stakeRaw + pendingDeposits + pendingWithdrawals; + delegatedRaw += await client.vestingDepositedPerValidator(vesting, validator); } const committedRaw = selfStakeRaw + delegatedRaw; - const vestedRaw = state.vestedAmountRaw; - const totalWithdrawnRaw = state.totalWithdrawnRaw; - // DERIVED estimate — the contract/SDK exposes no direct "available to stake" - // getter (withdraw/delegate take an explicit --amount). Floor at 0 because - // committed can exceed vested when unvested tokens were also staked. - const availableRaw = vestedRaw - totalWithdrawnRaw - committedRaw; - const availableToStakeRaw = availableRaw > 0n ? availableRaw : 0n; + // 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, + vestedRaw: state.vestedAmountRaw, lockedRaw: state.unvestedAmountRaw, withdrawableRaw: state.withdrawableAmountRaw, - totalWithdrawnRaw, + totalWithdrawnRaw: state.totalWithdrawnRaw, + revoked: state.revoked, selfStakeRaw, delegatedRaw, committedRaw, @@ -204,16 +207,19 @@ export class BalancesAction extends VestingAction { 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 (staked)", fmt(v.committedRaw)], + ["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(fmt(v.availableToStakeRaw))], + [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()); @@ -221,10 +227,10 @@ export class BalancesAction extends VestingAction { }); console.log( - chalk.gray("≈ Available to stake is a DERIVED estimate: vested − withdrawn − committed"), + chalk.gray("Available to stake is the vesting contract's live on-chain balance (0 once revoked)."), ); console.log( - chalk.gray(" (floored at 0), scanned over the active validator set — not a direct on-chain value."), + 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"}`), diff --git a/src/commands/staking/wizard.ts b/src/commands/staking/wizard.ts index 9ab9371f..91d970da 100644 --- a/src/commands/staking/wizard.ts +++ b/src/commands/staking/wizard.ts @@ -11,6 +11,7 @@ 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__"; @@ -466,13 +467,14 @@ export class ValidatorWizardAction extends StakingAction { } /** - * 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" = totalAmount - totalWithdrawn: the funds the contract - * still holds. We deliberately do NOT restrict to the vested/withdrawable slice - * because vesting-backed staking can commit locked (unvested) tokens too — those - * funds simply return to the vesting contract on exit. Only SDK-exposed - * VestingState fields are used (no bespoke formula). Gas is still paid from the + * 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 { @@ -498,9 +500,25 @@ export class ValidatorWizardAction extends StakingAction { const minStakeFormatted = epochInfo.validatorMinStake; const currentEpoch = epochInfo.currentEpoch; - const totalRaw = BigInt(vestingState.totalAmountRaw ?? 0n); - const withdrawnRaw = BigInt(vestingState.totalWithdrawnRaw ?? 0n); - const available = totalRaw > withdrawnRaw ? totalRaw - withdrawnRaw : 0n; + // 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)}`); 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/tests/actions/balances.test.ts b/tests/actions/balances.test.ts index eaa68e99..5c4039de 100644 --- a/tests/actions/balances.test.ts +++ b/tests/actions/balances.test.ts @@ -25,19 +25,7 @@ function makeState(overrides: Record = {}) { unvestedAmountRaw: 80n * WEI, withdrawableAmountRaw: 18n * WEI, totalWithdrawnRaw: 2n * WEI, - ...overrides, - }; -} - -function makeStakeInfo(overrides: Record = {}) { - return { - delegator: "0xV1", - validator: "0xVal1", - shares: 0n, - stake: "", - stakeRaw: 0n, - pendingDeposits: [], - pendingWithdrawals: [], + revoked: false, ...overrides, }; } @@ -50,7 +38,7 @@ function makeClient(overrides: Record = {}) { getValidatorWallets: vi.fn().mockResolvedValue([]), validatorDeposited: vi.fn().mockResolvedValue(0n), getActiveValidators: vi.fn().mockResolvedValue([]), - getStakeInfo: vi.fn(), + vestingDepositedPerValidator: vi.fn().mockResolvedValue(0n), ...overrides, }; } @@ -105,20 +93,16 @@ describe("BalancesAction", () => { expect(client.getActiveValidators).not.toHaveBeenCalled(); }); - test("(b) one vesting with self-stake + a delegation → committed & available computed", async () => { + 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({vestedAmountRaw: 20n * WEI, totalWithdrawnRaw: 2n * WEI})), + getVestingState: vi.fn().mockResolvedValue(makeState()), getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), - validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake 5 + validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake principal 5 getActiveValidators: vi.fn().mockResolvedValue(["0xVal1"]), - getStakeInfo: vi.fn().mockResolvedValue( - makeStakeInfo({ - stakeRaw: 3n * WEI, // active delegated 3 - pendingDeposits: [{stakeRaw: 1n * WEI}], // + 1 activating - pendingWithdrawals: [], - }), - ), + 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"); @@ -130,22 +114,26 @@ describe("BalancesAction", () => { expect(summary.vestings).toHaveLength(1); const v = summary.vestings[0]; expect(v.selfStakeRaw).toBe(5n * WEI); - expect(v.delegatedRaw).toBe(4n * WEI); // 3 active + 1 pending + expect(v.delegatedRaw).toBe(4n * WEI); expect(v.committedRaw).toBe(9n * WEI); - // available ≈ vested(20) − withdrawn(2) − committed(9) = 9 - expect(v.availableToStakeRaw).toBe(9n * WEI); - // getStakeInfo takes (delegator=vesting, validator). - expect(client.getStakeInfo).toHaveBeenCalledWith("0xV1", "0xVal1"); + // 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') available-to-stake floors at 0 when committed exceeds vested", async () => { + 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({vestedAmountRaw: 5n * WEI, totalWithdrawnRaw: 0n})), + getVestingState: vi.fn().mockResolvedValue(makeState({revoked: true})), getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]), - validatorDeposited: vi.fn().mockResolvedValue(10n * WEI), // committed > vested + 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"); @@ -153,18 +141,23 @@ describe("BalancesAction", () => { await action.execute({}); const v = renderSpy.mock.calls[0][0].vestings[0]; - expect(v.committedRaw).toBe(10n * WEI); - expect(v.availableToStakeRaw).toBe(0n); + 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", vestedAmountRaw: 20n * WEI, totalWithdrawnRaw: 0n}); - const stateB = makeState({name: "B", vestedAmountRaw: 50n * WEI, totalWithdrawnRaw: 5n * WEI}); + 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"); @@ -174,9 +167,9 @@ describe("BalancesAction", () => { 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); // 20 - 0 - 0 + 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); // 50 - 5 - 0 + 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); }); diff --git a/tests/actions/stakingWizard.test.ts b/tests/actions/stakingWizard.test.ts index bd3c1b48..3831f1d6 100644 --- a/tests/actions/stakingWizard.test.ts +++ b/tests/actions/stakingWizard.test.ts @@ -394,4 +394,24 @@ describe("ValidatorWizardAction stake source (keystore owner)", () => { }); 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/commands/balances.test.ts b/tests/commands/balances.test.ts index 77db7859..a4dbc0d6 100644 --- a/tests/commands/balances.test.ts +++ b/tests/commands/balances.test.ts @@ -25,7 +25,7 @@ const mockClient = { getValidatorWallets: vi.fn(), validatorDeposited: vi.fn(), getActiveValidators: vi.fn(), - getStakeInfo: vi.fn(), + vestingDepositedPerValidator: vi.fn(), }; describe("balances command", () => { From e2eb8a6ec61ba42c8385ddf6720e83f0eea38427 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 13:15:34 +0100 Subject: [PATCH 20/22] fix(balances): a live wallet session is the active identity over the keystore default --- src/commands/balances/BalancesAction.ts | 58 ++++++++++++++++++++----- tests/actions/balances.test.ts | 33 ++++++++++++++ 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts index fc5fc517..8777a222 100644 --- a/src/commands/balances/BalancesAction.ts +++ b/src/commands/balances/BalancesAction.ts @@ -3,7 +3,8 @@ 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} from "../../lib/wallet/sessionDescriptor"; +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"; @@ -107,25 +108,37 @@ export class BalancesAction extends VestingAction { } /** - * Resolve the address to inspect without ever unlocking a keystore: - * 1. --beneficiary (pure read, no wallet needed) - * 2. the active account's keystore address (file read only, no password) - * 3. a live browser-wallet session's connected address, if one exists - * (reads the on-disk descriptor only — never starts a daemon or a tab). + * 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) { - if (this.isBrowserWallet(options)) { - const descriptor = readDescriptor(descriptorPath(this)); - if (descriptor?.address) { - return descriptor.address as Address; - } + 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.", @@ -133,6 +146,29 @@ export class BalancesAction extends VestingAction { } } + /** + * 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, diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts index 5c4039de..53f19464 100644 --- a/tests/actions/balances.test.ts +++ b/tests/actions/balances.test.ts @@ -211,4 +211,37 @@ describe("BalancesAction", () => { 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(); + }); }); From 6ac742e72474d60b769144a237c7600decc991f8 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 13:23:59 +0100 Subject: [PATCH 21/22] fix(network): custom networks display their alias as the name --- src/lib/actions/BaseAction.ts | 6 +++++- tests/actions/customNetworkProfiles.test.ts | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index 7a80078b..e59faa13 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -43,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) 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", () => { From 379cdba23c4b3a19217d7ea2ca831fcbdab3b916 Mon Sep 17 00:00:00 2001 From: Edgars Date: Thu, 9 Jul 2026 13:41:42 +0100 Subject: [PATCH 22/22] fix(wallet): detect a closed tab at session acquire + let connect recover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/wallet/WalletAction.ts | 37 ++++--- src/lib/wallet/sessionClient.ts | 5 +- src/lib/wallet/sessionConstants.ts | 8 ++ src/lib/wallet/sessionResolver.ts | 19 +++- tests/actions/walletConnect.test.ts | 161 ++++++++++++++++++++++++++++ tests/libs/sessionResolver.test.ts | 99 +++++++++++++++++ 6 files changed, 311 insertions(+), 18 deletions(-) create mode 100644 tests/actions/walletConnect.test.ts diff --git a/src/commands/wallet/WalletAction.ts b/src/commands/wallet/WalletAction.ts index 019cf20c..e66982d6 100644 --- a/src/commands/wallet/WalletAction.ts +++ b/src/commands/wallet/WalletAction.ts @@ -39,21 +39,34 @@ export class WalletAction extends BaseAction { if (await client.ping()) { const state = await client.state().catch(() => null); if (state && state.chainId === chain.id) { - if (state.connected && state.address) { - this.logSuccess(`Already connected as ${state.address} on ${existing.network}.`); + 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 { - this.logInfo( - `A session is starting on ${existing.network}. Approve the connection in your browser.`, - ); + 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; } - 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); } - // 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); } diff --git a/src/lib/wallet/sessionClient.ts b/src/lib/wallet/sessionClient.ts index 27cd4807..ae26de20 100644 --- a/src/lib/wallet/sessionClient.ts +++ b/src/lib/wallet/sessionClient.ts @@ -1,7 +1,7 @@ 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} from "./sessionConstants"; +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 { @@ -18,9 +18,6 @@ export interface SessionState { type FetchFn = typeof fetch; -const TAB_CLOSED_MESSAGE = - "The wallet session tab appears to be closed. Run 'genlayer wallet connect' to reconnect."; - /** * HTTP client for a running wallet-session daemon. Every request carries the * bridge token in X-Bridge-Token. No new dependencies — plain fetch against diff --git a/src/lib/wallet/sessionConstants.ts b/src/lib/wallet/sessionConstants.ts index 856ef726..ca241437 100644 --- a/src/lib/wallet/sessionConstants.ts +++ b/src/lib/wallet/sessionConstants.ts @@ -14,6 +14,14 @@ export const LONG_POLL_MS = 25_000; */ 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; diff --git a/src/lib/wallet/sessionResolver.ts b/src/lib/wallet/sessionResolver.ts index acbb87d8..e5326efe 100644 --- a/src/lib/wallet/sessionResolver.ts +++ b/src/lib/wallet/sessionResolver.ts @@ -4,7 +4,12 @@ import {openBrowserWalletSession, openRemoteWalletSession, type BrowserSession} import {WalletSessionClient} from "./sessionClient"; import {descriptorPath, readDescriptor, removeDescriptor, isPidAlive} from "./sessionDescriptor"; import {spawnWalletDaemon, waitForDaemonReady} from "./spawnDaemon"; -import {DAEMON_LOG_FILENAME, CONNECT_TIMEOUT_MS} from "./sessionConstants"; +import { + DAEMON_LOG_FILENAME, + CONNECT_TIMEOUT_MS, + HEARTBEAT_DEAD_MS, + TAB_CLOSED_MESSAGE, +} from "./sessionConstants"; export type SessionFallback = "auto-start" | "own-bridge" | "error"; @@ -51,7 +56,7 @@ export async function resolveBrowserWalletSession(params: ResolveSessionParams): removeDescriptor(dpath); // stale cleanup } else { // 3. Live session found. - const state = await client.state(); + let state = await client.state(); if (state.chainId !== chain.id) { throw new Error( `Browser wallet session is connected to ${descriptor.network} (chain ${state.chainId}) ` + @@ -62,6 +67,16 @@ export async function resolveBrowserWalletSession(params: ResolveSessionParams): } 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}); } 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/libs/sessionResolver.test.ts b/tests/libs/sessionResolver.test.ts index b7e4c973..ff5b6d5f 100644 --- a/tests/libs/sessionResolver.test.ts +++ b/tests/libs/sessionResolver.test.ts @@ -5,6 +5,9 @@ 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, @@ -12,8 +15,42 @@ import { 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", @@ -40,7 +77,24 @@ describe("resolveBrowserWalletSession", () => { 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}); @@ -96,6 +150,51 @@ describe("resolveBrowserWalletSession", () => { 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), {