Skip to content

Commit 8a414bb

Browse files
committed
fix(balances): degrade gracefully when staking is unavailable
The balances command scans the global validator set (active + quarantined + banned) to compute delegated committed principal per vesting. Those SDK reads throw on networks with no staking contract ("Staking is not supported on studio-based networks"), so `genlayer balances` failed outright on studio — even though wallet balance and vesting totals need no staking data. Gate the scan on staking availability, mirroring the SDK's own guard (missing or zero staking address ⇒ unsupported): when absent, use an empty validator set so delegated principal is 0 (correct — there is no delegation without staking) and still render wallet + vesting holdings. Self-stake, vested/withdrawable and available-to-stake all come from vesting/wallet reads and are unaffected. Fixes genlayer-cli (studio) e2e 090_cli_network_and_balances #2.
1 parent e35a57d commit 8a414bb

2 files changed

Lines changed: 63 additions & 5 deletions

File tree

‎src/commands/balances/BalancesAction.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,15 @@ export class BalancesAction extends VestingAction {
8383
// left the active set (quarantined/banned) — scanning only the active
8484
// set would under-count committed and thus mis-state available-to-stake,
8585
// so union active + quarantined + banned.
86-
this.setSpinnerText("Enumerating validator set...");
87-
const validatorSet = await this.getKnownValidatorSet(client);
86+
//
87+
// Studio-based networks have no staking contract, so the validator set
88+
// (and thus delegated committed principal) is unavailable — the SDK's
89+
// staking reads throw there. Degrade to an empty set so `balances` still
90+
// reports wallet + vesting holdings instead of failing outright; on
91+
// studio there is no delegation, so delegated principal is 0 anyway.
92+
const validatorSet = this.isStakingAvailable(chain)
93+
? await this.getKnownValidatorSet(client)
94+
: [];
8895

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

128+
/**
129+
* Whether the resolved network has a usable staking contract. Mirrors the
130+
* SDK's own guard (missing or zero address ⇒ unsupported), so studio-based
131+
* networks — which carry no staking contract — are detected up front and the
132+
* validator-set scan is skipped rather than left to throw mid-read.
133+
*/
134+
private isStakingAvailable(chain: {stakingContract?: {address?: string} | null}): boolean {
135+
const address = chain.stakingContract?.address;
136+
return !!address && address !== "0x0000000000000000000000000000000000000000";
137+
}
138+
121139
/**
122140
* The full set of validators a vesting could have committed principal to:
123141
* active + quarantined + banned, de-duplicated (case-insensitively, keeping
124142
* the first-seen casing). Committed principal survives a validator leaving the
125143
* active set, so an active-only scan would under-count it.
126144
*/
127145
private async getKnownValidatorSet(client: VestingClient): Promise<Address[]> {
146+
this.setSpinnerText("Enumerating validator set...");
128147
const [active, quarantined, banned] = await Promise.all([
129148
client.getActiveValidators(),
130149
client.getQuarantinedValidatorsDetailed(),

‎tests/actions/balances.test.ts‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,43 @@ describe("BalancesAction", () => {
9595
expect(client.getActiveValidators).not.toHaveBeenCalled();
9696
});
9797

98+
test("(a') studio network (no staking contract): validator scan skipped, wallet + vesting still reported", async () => {
99+
// studionet carries no staking contract, so the SDK's validator reads
100+
// throw. `balances` must degrade — skip the scan (delegated principal 0),
101+
// never fail — and still report wallet + vesting holdings.
102+
const client = makeClient({
103+
getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]),
104+
getVestingState: vi.fn().mockResolvedValue(makeState()),
105+
getValidatorWallets: vi.fn().mockResolvedValue(["0xW1"]),
106+
validatorDeposited: vi.fn().mockResolvedValue(5n * WEI), // self-stake still computed
107+
// Any staking read would throw on studio; assert we never call them.
108+
getActiveValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")),
109+
getQuarantinedValidatorsDetailed: vi
110+
.fn()
111+
.mockRejectedValue(new Error("Staking is not supported on studio-based networks")),
112+
getBannedValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")),
113+
getBalance: vi.fn(async ({address}: {address: string}) => (address === "0xV1" ? 30n * WEI : 7n * WEI)),
114+
});
115+
stub(client);
116+
vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen");
117+
118+
await action.execute({network: "studionet"});
119+
120+
expect(failSpy).not.toHaveBeenCalled();
121+
expect(client.getActiveValidators).not.toHaveBeenCalled();
122+
expect(client.getQuarantinedValidatorsDetailed).not.toHaveBeenCalled();
123+
expect(client.getBannedValidators).not.toHaveBeenCalled();
124+
expect(client.vestingDepositedPerValidator).not.toHaveBeenCalled();
125+
const summary = renderSpy.mock.calls[0][0];
126+
expect(summary.walletBalanceRaw).toBe(7n * WEI);
127+
expect(summary.vestings).toHaveLength(1);
128+
const v = summary.vestings[0];
129+
expect(v.selfStakeRaw).toBe(5n * WEI); // self-stake from vesting reads, still shown
130+
expect(v.delegatedRaw).toBe(0n); // no validator set ⇒ no delegated principal
131+
expect(v.committedRaw).toBe(5n * WEI);
132+
expect(v.availableToStakeRaw).toBe(30n * WEI);
133+
});
134+
98135
test("(b) one vesting: committed principal computed; available is the contract balance", async () => {
99136
const client = makeClient({
100137
getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]),
@@ -109,7 +146,9 @@ describe("BalancesAction", () => {
109146
stub(client);
110147
vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen");
111148

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

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

167-
await action.execute({});
206+
await action.execute({network: "testnet-bradbury"});
168207

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

205-
await action.execute({});
244+
await action.execute({network: "testnet-bradbury"});
206245

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

0 commit comments

Comments
 (0)