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
92 changes: 65 additions & 27 deletions src/commands/balances/BalancesAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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));
}
}
}

Expand All @@ -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);
Expand All @@ -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<boolean> {
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
Expand Down Expand Up @@ -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("");
Expand Down
41 changes: 35 additions & 6 deletions tests/actions/balances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ function makeState(overrides: Record<string, any> = {}) {
function makeClient(overrides: Record<string, any> = {}) {
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([]),
Expand Down Expand Up @@ -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()
Expand All @@ -115,14 +118,15 @@ 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();
expect(client.getQuarantinedValidatorsDetailed).not.toHaveBeenCalled();
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];
Expand All @@ -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"]),
Expand Down
10 changes: 6 additions & 4 deletions tests/commands/balances.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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([]);
Expand Down
Loading