Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 39 additions & 65 deletions src/commands/balances/BalancesAction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand DownExpand Up@@ -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));
}
}

Expand All@@ -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 <name> — 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<Address> {
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 <address>, 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<Address | null> {
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<Address[]> {
const [active, quarantined, banned] = await Promise.all([
client.getActiveValidators(),
client.getQuarantinedValidatorsDetailed(),
client.getBannedValidators(),
]);

const seen = new Map<string, Address>();
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<VestingBalanceSummary> {
const state = await client.getVestingState(vesting);

Expand All@@ -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);
}

Expand Down
11 changes: 6 additions & 5 deletions src/commands/staking/stakingInfo.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);

Expand DownExpand Up@@ -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}...`);
Expand DownExpand Up@@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/commands/staking/validatorHistory.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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);
Expand Down
6 changes: 4 additions & 2 deletions src/commands/staking/validators.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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([
Expand Down
3 changes: 1 addition & 2 deletions src/commands/vesting/list.ts
Original file line numberDiff line numberDiff line change
@@ -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";
Expand DownExpand Up@@ -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}...`);

Expand Down
2 changes: 1 addition & 1 deletion src/commands/vesting/validatorList.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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));

Expand Down
8 changes: 8 additions & 0 deletions src/commands/vesting/vestingTypes.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -153,4 +153,12 @@ export type VestingClient = GenLayerClient<GenLayerChain> & {
validatorWalletCount: (vesting: Address) => Promise<bigint>;
validatorDeposited: (vesting: Address, wallet: Address) => Promise<bigint | string>;
isValidatorWallet: (vesting: Address, wallet: Address) => Promise<boolean>;
getActiveValidators: () => Promise<Address[]>;
getQuarantinedValidatorsDetailed: () => Promise<
Array<{validator: Address; untilEpoch: bigint; permanentlyBanned: boolean}>
>;
getBannedValidators: () => Promise<
Array<{validator: Address; untilEpoch: bigint; permanentlyBanned: boolean}>
>;
vestingDepositedPerValidator: (vesting: Address, validator: Address) => Promise<bigint>;
};
84 changes: 84 additions & 0 deletions src/lib/actions/BaseAction.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<string, GenLayerChain> = {
Expand DownExpand Up@@ -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<Address> {
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<Address | null> {
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 <name>` — 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<Address> {
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<Account | Address> {
const accountName = this.resolveAccountName();
const keystorePath = this.getKeystorePath(accountName);
Expand Down
39 changes: 39 additions & 0 deletions tests/actions/balances.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,6 +38,8 @@ function makeClient(overrides: Record<string, any> = {}) {
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,
};
Expand DownExpand Up@@ -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"}},
Expand Down
Loading
Loading