From 8a414bbb2983ae35b598b2196ba54f197eaa42c7 Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 10 Jul 2026 10:52:35 +0100 Subject: [PATCH] fix(balances): degrade gracefully when staking is unavailable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/commands/balances/BalancesAction.ts | 23 +++++++++++-- tests/actions/balances.test.ts | 45 +++++++++++++++++++++++-- 2 files changed, 63 insertions(+), 5 deletions(-) diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts index 206c62bd..5c38063f 100644 --- a/src/commands/balances/BalancesAction.ts +++ b/src/commands/balances/BalancesAction.ts @@ -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( @@ -118,6 +125,17 @@ 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 @@ -125,6 +143,7 @@ export class BalancesAction extends VestingAction { * active set, so an active-only scan would under-count it. */ private async getKnownValidatorSet(client: VestingClient): Promise { + this.setSpinnerText("Enumerating validator set..."); const [active, quarantined, banned] = await Promise.all([ client.getActiveValidators(), client.getQuarantinedValidatorsDetailed(), diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts index 48b76f15..b6b99e22 100644 --- a/tests/actions/balances.test.ts +++ b/tests/actions/balances.test.ts @@ -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"]), @@ -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]; @@ -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); @@ -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"