diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts index 8777a222..206c62bd 100644 --- a/src/commands/balances/BalancesAction.ts +++ b/src/commands/balances/BalancesAction.ts @@ -3,8 +3,6 @@ import {resolveNetwork} from "../../lib/actions/BaseAction"; import type {Address} from "genlayer-js/types"; import type {VestingClient} from "../vesting/vestingTypes"; import {vestingAvailableToStake} from "../../lib/vesting/availableToStake"; -import {readDescriptor, descriptorPath, isPidAlive} from "../../lib/wallet/sessionDescriptor"; -import {WalletSessionClient} from "../../lib/wallet/sessionClient"; import {formatEther} from "viem"; import Table from "cli-table3"; import chalk from "chalk"; @@ -79,17 +77,21 @@ export class BalancesAction extends VestingAction { 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). + // The validator set is global; fetch it once and reuse across every + // vesting. Committed-delegation lookup is O(#vestings × #validators). + // A vesting can hold committed principal against validators that later + // left the active set (quarantined/banned) — scanning only the active + // set would under-count committed and thus mis-state available-to-stake, + // so union active + quarantined + banned. this.setSpinnerText("Enumerating validator set..."); - const activeValidators = await client.getActiveValidators(); + const validatorSet = await this.getKnownValidatorSet(client); 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"})...`, + `(scanning ${validatorSet.length} validator${validatorSet.length === 1 ? "" : "s"})...`, ); - vestings.push(await this.computeVestingSummary(client, vestingAddresses[i], activeValidators)); + vestings.push(await this.computeVestingSummary(client, vestingAddresses[i], validatorSet)); } } @@ -108,71 +110,42 @@ export class BalancesAction extends VestingAction { } /** - * 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". + * Resolve the address to inspect without ever unlocking a keystore, via the + * shared connect-once resolver. `--beneficiary` is the explicit override; a + * live browser session otherwise wins over the keystore default. */ private async resolveAddress(options: BalancesOptions): Promise
{ - if (options.beneficiary) { - return options.beneficiary as Address; - } - if (options.account) { - return await this.getSignerAddress(); - } - - if (this.resolveWalletMode() === "browser") { - const sessionAddress = await this.liveSessionAddress(); - if (sessionAddress) { - return sessionAddress; - } - } - - try { - return await this.getSignerAddress(); - } catch (error) { - const sessionAddress = await this.liveSessionAddress(); - if (sessionAddress) { - return sessionAddress; - } - throw new Error( - "No address to inspect. Pass --beneficiary
, select an account, or connect a wallet.", - ); - } + return this.resolveActiveIdentity(options, options.beneficiary); } /** - * 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. + * The full set of validators a vesting could have committed principal to: + * active + quarantined + banned, de-duplicated (case-insensitively, keeping + * the first-seen casing). Committed principal survives a validator leaving the + * active set, so an active-only scan would under-count it. */ - private async 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 getKnownValidatorSet(client: VestingClient): Promise { + const [active, quarantined, banned] = await Promise.all([ + client.getActiveValidators(), + client.getQuarantinedValidatorsDetailed(), + client.getBannedValidators(), + ]); + + const seen = new Map(); + const add = (addr: Address) => { + const key = addr.toLowerCase(); + if (!seen.has(key)) seen.set(key, addr); + }; + active.forEach(add); + quarantined.forEach(v => add(v.validator)); + banned.forEach(v => add(v.validator)); + return Array.from(seen.values()); } private async computeVestingSummary( client: VestingClient, vesting: Address, - activeValidators: Address[], + knownValidators: Address[], ): Promise { const state = await client.getVestingState(vesting); @@ -186,11 +159,12 @@ export class BalancesAction extends VestingAction { } // 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). + // delegating to each known validator (see execute() for the one-shot set, + // which unions active + quarantined + banned). This on-chain principal + // getter is consistent with the balance identity used below + // (balance = deposits − withdrawals + rewards − losses). let delegatedRaw = 0n; - for (const validator of activeValidators) { + for (const validator of knownValidators) { delegatedRaw += await client.vestingDepositedPerValidator(vesting, validator); } diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts index 2c580483..0d8f92a8 100644 --- a/src/commands/staking/stakingInfo.ts +++ b/src/commands/staking/stakingInfo.ts @@ -22,7 +22,7 @@ export class StakingInfoAction extends StakingAction { try { const client = await this.getReadOnlyStakingClient(options); - const validatorAddress = options.validator || (await this.getSignerAddress()); + const validatorAddress = await this.resolveActiveIdentity(options, options.validator); const isValidator = await client.isValidator(validatorAddress as Address); @@ -121,7 +121,7 @@ export class StakingInfoAction extends StakingAction { try { const client = await this.getReadOnlyStakingClient(options); - const delegatorAddress = options.delegator || (await this.getSignerAddress()); + const delegatorAddress = await this.resolveActiveIdentity(options, options.delegator); const isOwnDelegation = !options.delegator; this.setSpinnerText(`Fetching delegation info for ${delegatorAddress}...`); @@ -361,12 +361,13 @@ export class StakingInfoAction extends StakingAction { try { const client = await this.getReadOnlyStakingClient(options); - // Get current user's address to mark "mine" + // Get current user's address to mark "mine" — honor a live wallet session + // so "mine" tracks the connected identity, not just the keystore default. let myAddress: Address | null = null; try { - myAddress = await this.getSignerAddress(); + myAddress = await this.resolveActiveIdentity(options); } catch { - // No account configured, that's fine + // No account or session configured, that's fine } // Use tree traversal to get ALL validators (including not-yet-primed) diff --git a/src/commands/staking/validatorHistory.ts b/src/commands/staking/validatorHistory.ts index aed86ad3..e0273cac 100644 --- a/src/commands/staking/validatorHistory.ts +++ b/src/commands/staking/validatorHistory.ts @@ -85,7 +85,7 @@ export class ValidatorHistoryAction extends StakingAction { } const client = await this.getReadOnlyStakingClient(options); - const validatorAddress = options.validator || (await this.getSignerAddress()); + const validatorAddress = await this.resolveActiveIdentity(options, options.validator); // Verify it's a validator const isValidator = await client.isValidator(validatorAddress as Address); diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts index e1da318e..8399f539 100644 --- a/src/commands/staking/validators.ts +++ b/src/commands/staking/validators.ts @@ -101,11 +101,13 @@ export class ValidatorsAction extends StakingAction { try { const client: any = await this.getReadOnlyStakingClient(options); + // Honor a live wallet session so "mine" tracks the connected identity, not + // just the keystore default. Listing still works with neither configured. let myAddress: Address | null = null; try { - myAddress = await this.getSignerAddress(); + myAddress = await this.resolveActiveIdentity(options); } catch { - // Listing validators should not require a local account. + // Listing validators should not require a local account or session. } const [allTreeAddresses, activeAddresses, quarantinedList, bannedList, epochInfo] = await Promise.all([ diff --git a/src/commands/vesting/list.ts b/src/commands/vesting/list.ts index ad1a69c1..516f672b 100644 --- a/src/commands/vesting/list.ts +++ b/src/commands/vesting/list.ts @@ -1,5 +1,4 @@ import {VestingAction, VestingConfig} from "./VestingAction"; -import type {Address} from "genlayer-js/types"; import type {VestingState} from "./vestingTypes"; import Table from "cli-table3"; import chalk from "chalk"; @@ -93,7 +92,7 @@ export class VestingListAction extends VestingAction { try { const client = await this.getReadOnlyVestingClient(options); - const beneficiary = (options.beneficiary as Address | undefined) || (await this.getSignerAddress()); + const beneficiary = await this.resolveActiveIdentity(options, options.beneficiary); this.setSpinnerText(`Fetching vesting contracts for ${beneficiary}...`); diff --git a/src/commands/vesting/validatorList.ts b/src/commands/vesting/validatorList.ts index ca94d206..612b2e70 100644 --- a/src/commands/vesting/validatorList.ts +++ b/src/commands/vesting/validatorList.ts @@ -31,7 +31,7 @@ export class VestingValidatorListAction extends VestingAction { if (options.vesting) { vesting = options.vesting as Address; } else { - const beneficiary = (options.beneficiary as Address | undefined) || (await this.getSignerAddress()); + const beneficiary = await this.resolveActiveIdentity(options, options.beneficiary); this.setSpinnerText(`Resolving vesting contract for ${beneficiary}...`); const vestings = await client.getBeneficiaryVestings(beneficiary, this.getFactoryLookupOptions(options)); diff --git a/src/commands/vesting/vestingTypes.ts b/src/commands/vesting/vestingTypes.ts index aeaf76c8..667b1915 100644 --- a/src/commands/vesting/vestingTypes.ts +++ b/src/commands/vesting/vestingTypes.ts @@ -153,4 +153,12 @@ export type VestingClient = GenLayerClient & { validatorWalletCount: (vesting: Address) => Promise; validatorDeposited: (vesting: Address, wallet: Address) => Promise; isValidatorWallet: (vesting: Address, wallet: Address) => Promise; + getActiveValidators: () => Promise; + getQuarantinedValidatorsDetailed: () => Promise< + Array<{validator: Address; untilEpoch: bigint; permanentlyBanned: boolean}> + >; + getBannedValidators: () => Promise< + Array<{validator: Address; untilEpoch: bigint; permanentlyBanned: boolean}> + >; + vestingDepositedPerValidator: (vesting: Address, validator: Address) => Promise; }; diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index e59faa13..5361659a 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -16,6 +16,7 @@ import { import {type BrowserSession, type WalletMode} from "../wallet/browserSend"; import {resolveBrowserWalletSession, type SessionFallback} from "../wallet/sessionResolver"; import {descriptorPath, readDescriptor, isPidAlive} from "../wallet/sessionDescriptor"; +import {WalletSessionClient} from "../wallet/sessionClient"; // Built-in networks - always resolve fresh from genlayer-js export const BUILT_IN_NETWORKS: Record = { @@ -273,6 +274,89 @@ export class BaseAction extends ConfigFileManager { return BaseAction.DEFAULT_ACCOUNT_NAME; } + /** + * The keystore address of the resolved account — a pure file read, never a + * password prompt or keychain touch. Shared by every read command so "who am + * I" is answered identically. Throws if the account has no keystore. + */ + protected async getSignerAddress(): Promise
{ + const accountName = this.resolveAccountName(); + const keystorePath = this.getKeystorePath(accountName); + if (!existsSync(keystorePath)) { + throw new Error(`Account '${accountName}' not found.`); + } + const keystoreData = JSON.parse(readFileSync(keystorePath, "utf-8")); + return this.getAddress(keystoreData); + } + + /** + * The connected address of a live browser-wallet session, or null. Read-only: + * pings an already-running daemon and reads its live state (as `wallet status` + * does) — never starts a daemon or opens a tab. The descriptor's own `address` + * field is null until connect and not reliably rewritten, so we query state. + */ + protected async liveSessionAddress(): Promise
{ + try { + const descriptor = readDescriptor(descriptorPath(this)); + if (!descriptor) { + return null; + } + const client = new WalletSessionClient(descriptor); + if (!(isPidAlive(descriptor.pid) && (await client.ping()))) { + return null; + } + const state = await client.state().catch(() => null); + return state?.connected && state.address ? (state.address as Address) : null; + } catch { + return null; + } + } + + /** + * Resolve the identity a READ command should inspect without ever unlocking a + * keystore — the single source of truth for connect-once identity across every + * read. Precedence mirrors resolveWalletMode so "who am I" follows the same rule + * as "how do I sign": + * 1. `explicitAddress` — an explicit --beneficiary/--validator/--delegator + * override (pure read, no wallet). + * 2. `--account ` — explicit keystore selection wins over a session. + * 3. a live browser-wallet session's connected address — when a session is up + * (resolveWalletMode → "browser") that IS your active identity. + * 4. the active account's keystore address (file read only, no password). + * 5. last resort: a live session even if the mode wasn't "browser". + * Throws only when nothing at all resolves. + */ + protected async resolveActiveIdentity( + options: {account?: string}, + explicitAddress?: string, + ): Promise
{ + if (explicitAddress) { + return explicitAddress as Address; + } + if (options.account) { + return await this.getSignerAddress(); + } + + if (this.resolveWalletMode() === "browser") { + const sessionAddress = await this.liveSessionAddress(); + if (sessionAddress) { + return sessionAddress; + } + } + + try { + return await this.getSignerAddress(); + } catch (error) { + const sessionAddress = await this.liveSessionAddress(); + if (sessionAddress) { + return sessionAddress; + } + throw new Error( + "No address to inspect. Pass an explicit address, select an account, or connect a wallet.", + ); + } + } + private async getAccount(readOnly: boolean = false): Promise { const accountName = this.resolveAccountName(); const keystorePath = this.getKeystorePath(accountName); diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts index 53f19464..48b76f15 100644 --- a/tests/actions/balances.test.ts +++ b/tests/actions/balances.test.ts @@ -38,6 +38,8 @@ function makeClient(overrides: Record = {}) { getValidatorWallets: vi.fn().mockResolvedValue([]), validatorDeposited: vi.fn().mockResolvedValue(0n), getActiveValidators: vi.fn().mockResolvedValue([]), + getQuarantinedValidatorsDetailed: vi.fn().mockResolvedValue([]), + getBannedValidators: vi.fn().mockResolvedValue([]), vestingDepositedPerValidator: vi.fn().mockResolvedValue(0n), ...overrides, }; @@ -174,6 +176,43 @@ describe("BalancesAction", () => { expect(client.getActiveValidators).toHaveBeenCalledTimes(1); }); + test("(c') committed-delegation scan unions active + quarantined + banned validators", async () => { + // A vesting can hold committed principal against validators that left the + // active set. The scan must union all three lists (de-duped) so committed — + // and hence available-to-stake — is not under-counted. + const client = makeClient({ + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), + getVestingState: vi.fn().mockResolvedValue(makeState()), + getValidatorWallets: vi.fn().mockResolvedValue([]), + getActiveValidators: vi.fn().mockResolvedValue(["0xActive"]), + getQuarantinedValidatorsDetailed: vi + .fn() + .mockResolvedValue([{validator: "0xQuar", untilEpoch: 5n, permanentlyBanned: false}]), + getBannedValidators: vi + .fn() + // "0xActive" also appears here to prove de-duplication (case-insensitive). + .mockResolvedValue([ + {validator: "0xBanned", untilEpoch: 9n, permanentlyBanned: true}, + {validator: "0xactive", untilEpoch: 0n, permanentlyBanned: false}, + ]), + // 1 GEN committed against every scanned validator. + vestingDepositedPerValidator: vi.fn().mockResolvedValue(1n * WEI), + getBalance: vi.fn().mockResolvedValue(7n * WEI), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({}); + + expect(failSpy).not.toHaveBeenCalled(); + // Active + quarantined + banned, with the duplicate "0xActive"/"0xactive" + // collapsed → 3 distinct validators scanned for delegated principal. + const scanned = client.vestingDepositedPerValidator.mock.calls.map((c: any[]) => c[1].toLowerCase()); + expect(new Set(scanned)).toEqual(new Set(["0xactive", "0xquar", "0xbanned"])); + const v = renderSpy.mock.calls[0][0].vestings[0]; + expect(v.delegatedRaw).toBe(3n * WEI); // 3 distinct validators × 1 GEN + }); + test("(d) custom active network shows alias + chainId, not the base chain name", async () => { action.writeConfig("customNetworks", { myclarke: {base: "testnet-bradbury", overrides: {chainId: 4221, rpcUrl: "http://localhost:9999"}}, diff --git a/tests/actions/resolveActiveIdentity.test.ts b/tests/actions/resolveActiveIdentity.test.ts new file mode 100644 index 00000000..01be3b73 --- /dev/null +++ b/tests/actions/resolveActiveIdentity.test.ts @@ -0,0 +1,95 @@ +import {describe, test, vi, beforeEach, afterEach, expect} from "vitest"; +import {BaseAction} from "../../src/lib/actions/BaseAction"; + +/** + * Direct coverage of the shared connect-once identity resolver every read + * command routes through. A tiny concrete subclass exposes the protected + * seam; getSignerAddress / liveSessionAddress / resolveWalletMode are the three + * collaborators, stubbed per case to exercise each precedence rung. + */ +class TestAction extends BaseAction { + run(options: {account?: string}, explicit?: string) { + return this.resolveActiveIdentity(options, explicit); + } +} + +describe("BaseAction.resolveActiveIdentity", () => { + let action: TestAction; + let signerSpy: any; + let sessionSpy: any; + let modeSpy: any; + + beforeEach(() => { + action = new TestAction(); + signerSpy = vi.spyOn(action as any, "getSignerAddress"); + sessionSpy = vi.spyOn(action as any, "liveSessionAddress"); + modeSpy = vi.spyOn(action as any, "resolveWalletMode"); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test("1. explicit address wins over everything (no wallet consulted)", async () => { + signerSpy.mockResolvedValue("0xKeystore"); + sessionSpy.mockResolvedValue("0xSession"); + modeSpy.mockReturnValue("browser"); + + await expect(action.run({account: "acct"}, "0xExplicit")).resolves.toBe("0xExplicit"); + expect(signerSpy).not.toHaveBeenCalled(); + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("2. --account selects the keystore, short-circuiting a live session", async () => { + signerSpy.mockResolvedValue("0xKeystore"); + sessionSpy.mockResolvedValue("0xSession"); + modeSpy.mockReturnValue("browser"); + + await expect(action.run({account: "acct"})).resolves.toBe("0xKeystore"); + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("3. live browser session is the active identity over the keystore default", async () => { + modeSpy.mockReturnValue("browser"); + sessionSpy.mockResolvedValue("0xSession"); + signerSpy.mockResolvedValue("0xKeystore"); + + await expect(action.run({})).resolves.toBe("0xSession"); + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("4. no session → falls back to the keystore address", async () => { + modeSpy.mockReturnValue("keystore"); + sessionSpy.mockResolvedValue(null); + signerSpy.mockResolvedValue("0xKeystore"); + + await expect(action.run({})).resolves.toBe("0xKeystore"); + }); + + test("4b. browser mode but no connected session → keystore fallback", async () => { + modeSpy.mockReturnValue("browser"); + sessionSpy.mockResolvedValue(null); + signerSpy.mockResolvedValue("0xKeystore"); + + await expect(action.run({})).resolves.toBe("0xKeystore"); + expect(sessionSpy).toHaveBeenCalledTimes(1); + }); + + test("5. no keystore but a live session → last-resort session address", async () => { + modeSpy.mockReturnValue("keystore"); + // First rung (browser) skipped; the keystore read throws; then the + // last-resort session lookup succeeds. + signerSpy.mockRejectedValue(new Error("Account 'default' not found.")); + sessionSpy.mockResolvedValue("0xSession"); + + await expect(action.run({})).resolves.toBe("0xSession"); + }); + + test("6. neither keystore nor session → throws a helpful error", async () => { + modeSpy.mockReturnValue("keystore"); + signerSpy.mockRejectedValue(new Error("Account 'default' not found.")); + sessionSpy.mockResolvedValue(null); + + await expect(action.run({})).rejects.toThrow(/No address to inspect/); + }); +}); diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index a67ce704..afc05c4a 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -295,6 +295,53 @@ describe("StakingInfoAction", () => { expect(action["failSpinner"]).toHaveBeenCalledWith("Address 0xNotValidator is not a validator"); }); + test("validator-info honors a live wallet session over the keystore default", async () => { + mockClient.isValidator.mockResolvedValue(false); + // A session is live and no keystore opt-out → resolveWalletMode → browser. + vi.spyOn(action as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.getValidatorInfo({stakingAddress: "0xStaking"}); + + // The connected session address, not the keystore default, is queried. + expect(mockClient.isValidator).toHaveBeenCalledWith("0xSession"); + expect(sessionSpy).toHaveBeenCalled(); + expect(signerSpy).not.toHaveBeenCalled(); + }); + + test("validator-info: explicit [validator] overrides a live session", async () => { + mockClient.isValidator.mockResolvedValue(false); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + + await action.getValidatorInfo({validator: "0xExplicit", stakingAddress: "0xStaking"}); + + expect(mockClient.isValidator).toHaveBeenCalledWith("0xExplicit"); + // Explicit override short-circuits before the session is ever consulted. + expect(sessionSpy).not.toHaveBeenCalled(); + }); + + test("delegation-info honors a live wallet session over the keystore default", async () => { + mockClient.getStakeInfo.mockResolvedValue({ + delegator: "0xSession", + validator: "0xValidator", + shares: 0n, + stake: "0 GEN", + stakeRaw: 0n, + pendingDeposits: [], + pendingWithdrawals: [], + }); + vi.spyOn(action as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi.spyOn(action as any, "liveSessionAddress").mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xKeystore"); + + await action.getStakeInfo({validator: "0xValidator", stakingAddress: "0xStaking"}); + + expect(mockClient.getStakeInfo).toHaveBeenCalledWith("0xSession", "0xValidator"); + expect(sessionSpy).toHaveBeenCalled(); + expect(signerSpy).not.toHaveBeenCalled(); + }); + test("gets epoch info", async () => { await action.getEpochInfo({stakingAddress: "0xStaking"}); diff --git a/tests/commands/balances.test.ts b/tests/commands/balances.test.ts index a4dbc0d6..61babf42 100644 --- a/tests/commands/balances.test.ts +++ b/tests/commands/balances.test.ts @@ -25,6 +25,8 @@ const mockClient = { getValidatorWallets: vi.fn(), validatorDeposited: vi.fn(), getActiveValidators: vi.fn(), + getQuarantinedValidatorsDetailed: vi.fn(), + getBannedValidators: vi.fn(), vestingDepositedPerValidator: vi.fn(), }; @@ -38,6 +40,8 @@ describe("balances command", () => { mockClient.getBalance.mockResolvedValue(0n); mockClient.getBeneficiaryVestings.mockResolvedValue([]); mockClient.getActiveValidators.mockResolvedValue([]); + mockClient.getQuarantinedValidatorsDetailed.mockResolvedValue([]); + mockClient.getBannedValidators.mockResolvedValue([]); vi.spyOn(VestingAction.prototype as any, "getReadOnlyVestingClient").mockResolvedValue(mockClient); vi.spyOn(VestingAction.prototype as any, "getSignerAddress").mockResolvedValue("0xBeneficiary"); diff --git a/tests/commands/vesting.test.ts b/tests/commands/vesting.test.ts index 33851bfb..497ce67c 100644 --- a/tests/commands/vesting.test.ts +++ b/tests/commands/vesting.test.ts @@ -192,6 +192,22 @@ describe("vesting commands", () => { expect(consoleLogSpy).toHaveBeenCalled(); }); + test("list honors a live wallet session over the keystore default", async () => { + // A session is live and no keystore opt-out → resolveWalletMode → browser. + vi.spyOn(VestingAction.prototype as any, "resolveWalletMode").mockReturnValue("browser"); + const sessionSpy = vi + .spyOn(VestingAction.prototype as any, "liveSessionAddress") + .mockResolvedValue("0xSession"); + const signerSpy = vi.spyOn(VestingAction.prototype as any, "getSignerAddress"); + + await program.parseAsync(["node", "test", "vesting", "list"]); + + // The connected session address, not the keystore default, drives the lookup. + expect(mockClient.getBeneficiaryVestings).toHaveBeenCalledWith("0xSession", undefined); + expect(sessionSpy).toHaveBeenCalled(); + expect(signerSpy).not.toHaveBeenCalled(); + }); + test("delegate resolves vesting and calls vestingDelegatorJoin", async () => { await program.parseAsync([ "node",