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
23 changes: 21 additions & 2 deletions src/commands/balances/BalancesAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,15 @@ export class BalancesAction extends VestingAction {
// 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 validatorSet = await this.getKnownValidatorSet(client);
//
// Studio-based networks have no staking contract, so the validator set
// (and thus delegated committed principal) is unavailable — the SDK's
// staking reads throw there. Degrade to an empty set so `balances` still
// reports wallet + vesting holdings instead of failing outright; on
// studio there is no delegation, so delegated principal is 0 anyway.
const validatorSet = this.isStakingAvailable(chain)
? await this.getKnownValidatorSet(client)
: [];

for (let i = 0; i < vestingAddresses.length; i++) {
this.setSpinnerText(
Expand Down Expand Up @@ -118,13 +125,25 @@ export class BalancesAction extends VestingAction {
return this.resolveActiveIdentity(options, options.beneficiary);
}

/**
* Whether the resolved network has a usable staking contract. Mirrors the
* SDK's own guard (missing or zero address ⇒ unsupported), so studio-based
* networks — which carry no staking contract — are detected up front and the
* validator-set scan is skipped rather than left to throw mid-read.
*/
private isStakingAvailable(chain: {stakingContract?: {address?: string} | null}): boolean {
const address = chain.stakingContract?.address;
return !!address && address !== "0x0000000000000000000000000000000000000000";
}

/**
* The full set of validators a vesting could have committed principal to:
* active + quarantined + banned, de-duplicated (case-insensitively, keeping
* the first-seen casing). Committed principal survives a validator leaving the
* active set, so an active-only scan would under-count it.
*/
private async getKnownValidatorSet(client: VestingClient): Promise<Address[]> {
this.setSpinnerText("Enumerating validator set...");
const [active, quarantined, banned] = await Promise.all([
client.getActiveValidators(),
client.getQuarantinedValidatorsDetailed(),
Expand Down
45 changes: 42 additions & 3 deletions tests/actions/balances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,43 @@ describe("BalancesAction", () => {
expect(client.getActiveValidators).not.toHaveBeenCalled();
});

test("(a') studio network (no staking contract): validator scan skipped, wallet + vesting still reported", async () => {
// studionet carries no staking contract, so the SDK's validator reads
// throw. `balances` must degrade — skip the scan (delegated principal 0),
// never fail — and still report wallet + vesting holdings.
const client = makeClient({
getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]),
getVestingState: vi.fn().mockResolvedValue(makeState()),
getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]),
validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake still computed
// Any staking read would throw on studio; assert we never call them.
getActiveValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")),
getQuarantinedValidatorsDetailed: vi
.fn()
.mockRejectedValue(new Error("Staking is not supported on studio-based networks")),
getBannedValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")),
getBalance: vi.fn(async ({address}: {address: string}) => (address === "0xV1" ? 30n * WEI : 7n * WEI)),
});
stub(client);
vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen");

await action.execute({network: "studionet"});

expect(failSpy).not.toHaveBeenCalled();
expect(client.getActiveValidators).not.toHaveBeenCalled();
expect(client.getQuarantinedValidatorsDetailed).not.toHaveBeenCalled();
expect(client.getBannedValidators).not.toHaveBeenCalled();
expect(client.vestingDepositedPerValidator).not.toHaveBeenCalled();
const summary = renderSpy.mock.calls[0][0];
expect(summary.walletBalanceRaw).toBe(7n * WEI);
expect(summary.vestings).toHaveLength(1);
const v = summary.vestings[0];
expect(v.selfStakeRaw).toBe(5n * WEI); // self-stake from vesting reads, still shown
expect(v.delegatedRaw).toBe(0n); // no validator set ⇒ no delegated principal
expect(v.committedRaw).toBe(5n * WEI);
expect(v.availableToStakeRaw).toBe(30n * WEI);
});

test("(b) one vesting: committed principal computed; available is the contract balance", async () => {
const client = makeClient({
getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]),
Expand All @@ -109,7 +146,9 @@ describe("BalancesAction", () => {
stub(client);
vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen");

await action.execute({});
// testnet-bradbury carries a staking contract, so the validator-set scan
// runs (localnet/studionet have none — see the studio-degradation test).
await action.execute({network: "testnet-bradbury"});

expect(failSpy).not.toHaveBeenCalled();
const summary = renderSpy.mock.calls[0][0];
Expand Down Expand Up @@ -164,7 +203,7 @@ describe("BalancesAction", () => {
stub(client);
vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen");

await action.execute({});
await action.execute({network: "testnet-bradbury"});

const summary = renderSpy.mock.calls[0][0];
expect(summary.vestings).toHaveLength(2);
Expand Down Expand Up @@ -202,7 +241,7 @@ describe("BalancesAction", () => {
stub(client);
vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen");

await action.execute({});
await action.execute({network: "testnet-bradbury"});

expect(failSpy).not.toHaveBeenCalled();
// Active + quarantined + banned, with the duplicate "0xActive"/"0xactive"
Expand Down
Loading