From 43bf7f804de4884073c79f7ee9584771affa303d Mon Sep 17 00:00:00 2001 From: Edgars Date: Fri, 10 Jul 2026 12:13:32 +0100 Subject: [PATCH] fix(balances): wallet-only view when consensus infra is not deployed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After #389 stopped the staking scan from crashing balances on studio, the next consensus-dependent read surfaced: the vesting-factory lookup resolves through ConsensusMain → AddressManager, and on a network without that infrastructure deployed (studio, or a custom profile on a bare RPC) it decodes garbage — `genlayer balances` died with 'Position 32 is out of bounds ... name()' (e2e 090 scenario 2 on the studio stack). Custom networks inherit the base chain's ConsensusMain address even without a --consensus-main override, so a studio profile carries a non-null (localnet) address that simply isn't deployed on the studio RPC — a static check can't tell them apart. Probe eth_getCode(consensusMain) once up front: if empty, the consensus infra isn't deployed, so skip the whole consensus-dependent section (vesting + staking) and render the wallet balance only, with a clear note. Handles the entire missing-infra class in one capability check rather than one revert at a time. dev-env (real deployed address) is unaffected. Verified against a live studio stack: e2e 090 (both scenarios) green. 772/772 unit tests pass. --- src/commands/balances/BalancesAction.ts | 92 +++++++++++++++++-------- tests/actions/balances.test.ts | 41 +++++++++-- tests/commands/balances.test.ts | 10 +-- 3 files changed, 106 insertions(+), 37 deletions(-) diff --git a/src/commands/balances/BalancesAction.ts b/src/commands/balances/BalancesAction.ts index 5c38063f..fbb3af26 100644 --- a/src/commands/balances/BalancesAction.ts +++ b/src/commands/balances/BalancesAction.ts @@ -39,6 +39,8 @@ interface BalancesSummary { address: Address; walletBalanceRaw: bigint; vestings: VestingBalanceSummary[]; + /** False when the network has no consensus contracts deployed (e.g. studio): vesting + staking data is unavailable and only the wallet balance is shown. */ + consensusAvailable: boolean; } /** @@ -69,36 +71,44 @@ export class BalancesAction extends VestingAction { this.setSpinnerText(`Fetching wallet balance for ${address}...`); const walletBalanceRaw = await client.getBalance({address}); - this.setSpinnerText(`Looking up vesting contracts for ${address}...`); - const vestingAddresses = await client.getBeneficiaryVestings( - address, - this.getFactoryLookupOptions(options), - ); + // Vesting + staking are consensus-layer features: the vesting-factory + // lookup resolves through ConsensusMain → AddressManager, and the + // validator scan reads the Staking contract. On a network without that + // infrastructure deployed (e.g. studio, or a custom profile pointed at a + // bare RPC) those reads either revert or decode garbage. Probe once, up + // front, whether the consensus contracts are actually deployed on this + // RPC and skip the whole consensus-dependent section if not — the wallet + // balance is a plain native read and always works. This keeps `balances` + // useful (wallet-only) instead of crashing on a missing-infra network. + const consensusAvailable = await this.isConsensusInfraDeployed(client, chain); const vestings: VestingBalanceSummary[] = []; - if (vestingAddresses.length > 0) { - // 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. - // - // 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) - : []; + if (consensusAvailable) { + this.setSpinnerText(`Looking up vesting contracts for ${address}...`); + const vestingAddresses = await client.getBeneficiaryVestings( + address, + this.getFactoryLookupOptions(options), + ); + if (vestingAddresses.length > 0) { + // 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. A + // network can have consensus (vesting factory) but no staking + // contract, so still gate the scan on staking availability. + const validatorSet = this.isStakingAvailable(chain) + ? await this.getKnownValidatorSet(client) + : []; - for (let i = 0; i < vestingAddresses.length; i++) { - this.setSpinnerText( - `Computing balances for vesting ${i + 1}/${vestingAddresses.length} ` + - `(scanning ${validatorSet.length} validator${validatorSet.length === 1 ? "" : "s"})...`, - ); - vestings.push(await this.computeVestingSummary(client, vestingAddresses[i], validatorSet)); + for (let i = 0; i < vestingAddresses.length; i++) { + this.setSpinnerText( + `Computing balances for vesting ${i + 1}/${vestingAddresses.length} ` + + `(scanning ${validatorSet.length} validator${validatorSet.length === 1 ? "" : "s"})...`, + ); + vestings.push(await this.computeVestingSummary(client, vestingAddresses[i], validatorSet)); + } } } @@ -110,6 +120,7 @@ export class BalancesAction extends VestingAction { address, walletBalanceRaw, vestings, + consensusAvailable, }); } catch (error: any) { this.failSpinner("Failed to fetch balances", error.message || error); @@ -136,6 +147,25 @@ export class BalancesAction extends VestingAction { return !!address && address !== "0x0000000000000000000000000000000000000000"; } + /** + * Whether the consensus infrastructure (ConsensusMain, which the vesting + * factory + staking lookups resolve through) is actually DEPLOYED on the + * active RPC — a runtime capability probe, not a static config check. + * + * Custom networks inherit the base chain's ConsensusMain address even when + * `--consensus-main` isn't overridden, so a studio profile carries a non-null + * (localnet) address that simply isn't deployed on the studio RPC. A static + * check can't tell them apart; `eth_getCode` can — an undeployed address + * returns `0x`. When absent, `balances` skips the consensus-dependent + * sections and reports the wallet balance only. + */ + private async isConsensusInfraDeployed(client: VestingClient, chain: {consensusMainContract?: {address?: string} | null}): Promise { + const address = chain.consensusMainContract?.address; + if (!address || address === "0x0000000000000000000000000000000000000000") return false; + const code = await client.getCode({address: address as Address}); + return !!code && code !== "0x"; + } + /** * The full set of validators a vesting could have committed principal to: * active + quarantined + banned, de-duplicated (case-insensitively, keeping @@ -224,6 +254,14 @@ export class BalancesAction extends VestingAction { console.log(`${chalk.cyan("Wallet")}: ${fmt(summary.walletBalanceRaw)}`); console.log(""); + if (!summary.consensusAvailable) { + console.log( + chalk.yellow("Vesting & staking data is unavailable on this network (no consensus contracts deployed)."), + ); + console.log(""); + return; + } + if (summary.vestings.length === 0) { console.log(chalk.yellow(`No vesting contracts found for ${summary.address}`)); console.log(""); diff --git a/tests/actions/balances.test.ts b/tests/actions/balances.test.ts index b6b99e22..25a7c15c 100644 --- a/tests/actions/balances.test.ts +++ b/tests/actions/balances.test.ts @@ -33,6 +33,9 @@ function makeState(overrides: Record = {}) { function makeClient(overrides: Record = {}) { return { getBalance: vi.fn().mockResolvedValue(7n * WEI), + // Default: consensus infra IS deployed (non-empty bytecode) so the vesting + // + staking sections run. The no-infra case (studio) mocks this to "0x". + getCode: vi.fn().mockResolvedValue("0x6001"), getBeneficiaryVestings: vi.fn().mockResolvedValue([]), getVestingState: vi.fn(), getValidatorWallets: vi.fn().mockResolvedValue([]), @@ -95,16 +98,16 @@ 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. + test("(a') consensus deployed but no staking contract (localnet): vesting shown, validator scan skipped", async () => { + // localnet has consensus (vesting factory) deployed but no staking + // contract. balances must still enumerate vestings and compute self-stake, + // but skip the validator scan (delegated principal 0) rather than fail. 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. + // No staking contract ⇒ the validator reads must never be called. getActiveValidators: vi.fn().mockRejectedValue(new Error("Staking is not supported on studio-based networks")), getQuarantinedValidatorsDetailed: vi .fn() @@ -115,7 +118,7 @@ describe("BalancesAction", () => { stub(client); vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); - await action.execute({network: "studionet"}); + await action.execute({network: "localnet"}); expect(failSpy).not.toHaveBeenCalled(); expect(client.getActiveValidators).not.toHaveBeenCalled(); @@ -123,6 +126,7 @@ describe("BalancesAction", () => { expect(client.getBannedValidators).not.toHaveBeenCalled(); expect(client.vestingDepositedPerValidator).not.toHaveBeenCalled(); const summary = renderSpy.mock.calls[0][0]; + expect(summary.consensusAvailable).toBe(true); expect(summary.walletBalanceRaw).toBe(7n * WEI); expect(summary.vestings).toHaveLength(1); const v = summary.vestings[0]; @@ -132,6 +136,31 @@ describe("BalancesAction", () => { expect(v.availableToStakeRaw).toBe(30n * WEI); }); + test("(a'') no consensus infra deployed (studio RPC): wallet-only, no vesting/staking reads, never fails", async () => { + // The vesting-factory lookup resolves through ConsensusMain, which isn't + // deployed on a studio RPC (getCode ⇒ "0x"). balances must skip the whole + // consensus-dependent section and still report the wallet balance, instead + // of crashing on a garbage contract read (the real 090-02 failure). + const client = makeClient({ + getCode: vi.fn().mockResolvedValue("0x"), // ConsensusMain not deployed + getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xShouldNotBeRead"]), + getBalance: vi.fn().mockResolvedValue(7n * WEI), + }); + stub(client); + vi.spyOn(action as any, "getSignerAddress").mockResolvedValue("0xBen"); + + await action.execute({network: "studionet"}); + + expect(failSpy).not.toHaveBeenCalled(); + // The consensus-dependent reads must never run. + expect(client.getBeneficiaryVestings).not.toHaveBeenCalled(); + expect(client.getActiveValidators).not.toHaveBeenCalled(); + const summary = renderSpy.mock.calls[0][0]; + expect(summary.consensusAvailable).toBe(false); + expect(summary.walletBalanceRaw).toBe(7n * WEI); + expect(summary.vestings).toEqual([]); + }); + test("(b) one vesting: committed principal computed; available is the contract balance", async () => { const client = makeClient({ getBeneficiaryVestings: vi.fn().mockResolvedValue(["0xV1"]), diff --git a/tests/commands/balances.test.ts b/tests/commands/balances.test.ts index 61babf42..6f7ada2f 100644 --- a/tests/commands/balances.test.ts +++ b/tests/commands/balances.test.ts @@ -12,14 +12,15 @@ vi.mock("genlayer-js", () => ({ })); vi.mock("genlayer-js/chains", () => ({ - localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}}, - studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}}, - testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, - testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://testnet.genlayer.com"]}}}, + localnet: {id: 1, name: "localnet", rpcUrls: {default: {http: ["http://localhost:8545"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, + studionet: {id: 2, name: "studionet", rpcUrls: {default: {http: ["https://studio.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, + testnetAsimov: {id: 3, name: "testnet-asimov", rpcUrls: {default: {http: ["https://rpc-asimov.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, + testnetBradbury: {id: 4, name: "testnet-bradbury", rpcUrls: {default: {http: ["https://rpc-bradbury.genlayer.com"]}}, consensusMainContract: {address: "0x00000000000000000000000000000000000000c0"}}, })); const mockClient = { getBalance: vi.fn(), + getCode: vi.fn().mockResolvedValue("0x6001"), // consensus infra deployed by default getBeneficiaryVestings: vi.fn(), getVestingState: vi.fn(), getValidatorWallets: vi.fn(), @@ -38,6 +39,7 @@ describe("balances command", () => { vi.clearAllMocks(); mockClient.getBalance.mockResolvedValue(0n); + mockClient.getCode.mockResolvedValue("0x6001"); // consensus infra deployed mockClient.getBeneficiaryVestings.mockResolvedValue([]); mockClient.getActiveValidators.mockResolvedValue([]); mockClient.getQuarantinedValidatorsDetailed.mockResolvedValue([]);