Skip to content
2 changes: 2 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Keep hashbang-bearing JavaScript importable by Node/Vitest on Windows.
*.mjs text eol=lf
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
"eslint-config-prettier": "^10.0.0",
"eslint-import-resolver-typescript": "^4.0.0",
"eslint-plugin-import": "^2.29.1",
"genlayer-js": "github:genlayerlabs/genlayer-js#facd9e9dc9a289d0110fe3b5b1a14a2938fe6e01",
"genlayer-js": "github:genlayerlabs/genlayer-js#b629021092e23db08fd884f982d8eb34682f7bad",
"jsdom": "^26.0.0",
"prettier": "^3.2.5",
"release-it": "^19.0.0",
Expand Down
9 changes: 5 additions & 4 deletions src/commands/staking/stakingInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export class StakingInfoAction extends StakingAction {
vWithdrawal: info.vWithdrawal,
ePrimed: info.ePrimed.toString(),
needsPriming: info.needsPriming,
live: info.live,
hasUnclaimedRewards: info.hasUnclaimedRewards,
banned: info.banned ? info.bannedEpoch?.toString() : "Not banned",
selfStakePendingDeposits: (() => {
// In debug mode, show all deposits; otherwise filter to truly pending only
Expand Down Expand Up @@ -194,9 +194,9 @@ export class StakingInfoAction extends StakingAction {
console.log(` ${label("Withdrawal (vWithdrawal):")} ${info.vWithdrawal}`);
console.log("");

// Status. `live` printed as the raw boolean, an opaque contract flag.
// Status. `hasUnclaimedRewards` printed as the raw boolean, an opaque contract flag.
console.log(chalk.bold("Status"));
console.log(` ${label("live:")} ${info.live} ${chalk.gray("(contract flag: primed & not exited — not an eligibility signal)")}`);
console.log(` ${label("hasUnclaimedRewards:")} ${info.hasUnclaimedRewards} ${chalk.gray("(rewards still to be consumed at priming — not an eligibility signal)")}`);
console.log(` ${label("needsPriming:")} ${info.needsPriming}`);
console.log(` ${label("Banned:")} ${result.banned}`);
console.log(` ${label("Primed epoch (ePrimed):")} ${info.ePrimed}`);
Expand Down Expand Up @@ -625,7 +625,8 @@ export class StakingInfoAction extends StakingAction {
} else if (info.needsPriming) {
status = "needs-priming";
} else {
status = info.live ? "pending" : "inactive";
const activating = info.pendingDeposits.some(d => d.epoch + ACTIVATION_DELAY_EPOCHS > epochInfo.currentEpoch);
status = activating ? "pending" : "inactive";
}

const isMine = myAddress
Expand Down
11 changes: 5 additions & 6 deletions src/commands/staking/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ interface ValidatorRow {
operator: Address;
moniker?: string;
active: boolean;
live: boolean;
hasUnclaimedRewards: boolean;
banned: boolean;
bannedUntilEpoch?: string;
status: string;
Expand Down Expand Up @@ -230,6 +230,7 @@ export class ValidatorsAction extends StakingAction {
let bannedUntilEpoch: string | undefined;
const belowMin = info.vStakeRaw < validatorMinStakeRaw;
const totalStakeRaw = info.vStakeRaw + info.dStakeRaw;
const trulyPendingDeposits = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch);

if (isBanned) {
if (bannedInfo?.permanentlyBanned) {
Expand All @@ -252,10 +253,8 @@ export class ValidatorsAction extends StakingAction {
} else if (info.needsPriming) {
status = "needs-priming";
} else {
status = info.live ? "pending" : "inactive";
status = trulyPendingDeposits.length > 0 ? "pending" : "inactive";
}

const trulyPendingDeposits = info.pendingDeposits.filter(d => d.epoch + ACTIVATION_DELAY_EPOCHS > currentEpoch);
const trulyPendingWithdrawals = info.pendingWithdrawals.filter(w => w.epoch + UNBONDING_PERIOD_EPOCHS > currentEpoch);

const isMine = myAddress
Expand All @@ -269,7 +268,7 @@ export class ValidatorsAction extends StakingAction {
operator: info.operator,
moniker: info.identity?.moniker || undefined,
active: isActive,
live: info.live,
hasUnclaimedRewards: info.hasUnclaimedRewards,
banned: isBanned,
bannedUntilEpoch,
status,
Expand Down Expand Up @@ -509,7 +508,7 @@ export class ValidatorsAction extends StakingAction {
operator: row.operator,
moniker: row.moniker || null,
active: row.active,
live: row.live,
hasUnclaimedRewards: row.hasUnclaimedRewards,
banned: row.banned,
bannedUntilEpoch: row.bannedUntilEpoch || null,
status: row.status,
Expand Down
11 changes: 10 additions & 1 deletion src/lib/actions/BaseAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,7 +596,9 @@ export class BaseAction extends ConfigFileManager {
* surfaced.
*
* Behaviour:
* - epoch 0: informational note only, never blocks.
* - epoch 0: informational note only, never blocks. When the resulting
* self-stake already meets the minimum, the note says so without implying
* activation (the join only registers the validator).
* - resulting self-stake >= min: silent (already eligible).
* - resulting self-stake < min: block by THROWING (the caller's catch turns
* it into a failSpinner) unless `force` is set, in which case it warns and
Expand All @@ -620,6 +622,13 @@ export class BaseAction extends ConfigFileManager {
} = params;

if (currentEpoch === 0n) {
if (resultingSelfStakeRaw >= minStakeRaw) {
this.logInfo(
"Epoch 0: self-stake meets the current minimum. Registration does not imply activation; " +
"check it with 'genlayer staking validator-info <validator-wallet>'.",
);
return;
}
this.logInfo(
`Epoch 0: minimum self-stake not enforced yet. Note: this validator won't become active ` +
`until its self-stake reaches ${minStakeFormatted}.`,
Expand Down
22 changes: 16 additions & 6 deletions tests/actions/staking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,7 +580,7 @@ describe("StakingInfoAction", () => {
vWithdrawalRaw: 0n,
ePrimed: 5n,
needsPriming: false,
live: true,
hasUnclaimedRewards: false,
banned: false,
bannedEpoch: null,
pendingDeposits: [],
Expand Down Expand Up @@ -1063,7 +1063,7 @@ function fullValidatorInfo(overrides: Record<string, any> = {}) {
vWithdrawalRaw: 0n,
ePrimed: 5n,
needsPriming: false,
live: true,
hasUnclaimedRewards: false,
banned: false,
bannedEpoch: null,
pendingDeposits: [],
Expand Down Expand Up @@ -1118,6 +1118,15 @@ describe("ValidatorJoinAction self-stake minimum gate", () => {
expect(mockClient.validatorJoin).toHaveBeenCalledTimes(1);
expect(action["failSpinner"]).not.toHaveBeenCalled();
});

test("reports a satisfied epoch-zero minimum without claiming activation", async () => {
mockClient.getEpochInfo.mockResolvedValue(epoch0EpochInfo);
const info = vi.spyOn(action as any, "logInfo").mockImplementation(() => {});
await action.execute({amount: "50000gen", stakingAddress: "0xStaking"});
expect(mockClient.validatorJoin).toHaveBeenCalledTimes(1);
expect(info).toHaveBeenCalledWith(expect.stringContaining("self-stake meets the current minimum"));
expect(info).toHaveBeenCalledWith(expect.stringContaining("Registration does not imply activation"));
});
});

describe("ValidatorDepositAction minimum gate + mixing guard", () => {
Expand Down Expand Up @@ -1208,7 +1217,8 @@ describe("StakingInfoAction clean view + eligibility display", () => {
expect(action["succeedSpinner"]).not.toHaveBeenCalled();
const parsed = JSON.parse(logSpy.mock.calls[0][0]);
expect(parsed.validator).toBe("0xValidatorWallet");
expect(parsed.live).toBe(true);
expect(parsed.hasUnclaimedRewards).toBe(false);
expect(parsed).not.toHaveProperty("live");
expect(parsed.banned).toBe("Not banned");
});

Expand All @@ -1232,9 +1242,9 @@ describe("StakingInfoAction clean view + eligibility display", () => {
expect(out).toContain("500 GEN"); // delegated amount
expect(out).toContain("AcmeNode"); // moniker
expect(out).toContain("Not banned");
expect(out).toContain("live"); // the live label
expect(out).toContain("true"); // the live boolean value
// Never relabels live as "active".
expect(out).toContain("hasUnclaimedRewards"); // the raw contract flag label
expect(out).toContain("false"); // its boolean value
// Never relabels the flag as "active".
expect(out).not.toContain("active");
});

Expand Down
42 changes: 33 additions & 9 deletions tests/commands/stakingValidators.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function validatorInfo(
selfStake: number,
delegatedStake: number,
moniker: string,
options: {live?: boolean; needsPriming?: boolean} = {},
options: {hasUnclaimedRewards?: boolean; needsPriming?: boolean; pendingDeposits?: any[]} = {},
) {
return {
address,
Expand All @@ -33,11 +33,11 @@ function validatorInfo(
vWithdrawal: "0 GEN",
vWithdrawalRaw: 0n,
ePrimed: 5n,
live: options.live ?? true,
hasUnclaimedRewards: options.hasUnclaimedRewards ?? false,
banned: false,
needsPriming: options.needsPriming ?? false,
identity: {moniker},
pendingDeposits: [],
pendingDeposits: options.pendingDeposits ?? [],
pendingWithdrawals: [],
} as any;
}
Expand All @@ -52,19 +52,27 @@ function jsonResponse(body: unknown) {
function createMockClient({
currentEpoch = 6n,
validatorMinStakeRaw = rawGen(75),
betaLive = true,
betaHasUnclaimedRewards = false,
betaNeedsPriming = true,
betaPendingDeposits = [],
betaSelfStake = 50,
activeValidators = [A],
}: {
currentEpoch?: bigint;
validatorMinStakeRaw?: bigint;
betaLive?: boolean;
betaHasUnclaimedRewards?: boolean;
betaNeedsPriming?: boolean;
betaPendingDeposits?: any[];
betaSelfStake?: number;
activeValidators?: string[];
} = {}) {
const infos = new Map([
[A.toLowerCase(), validatorInfo(A, 100, 20, "Alpha")],
[B.toLowerCase(), validatorInfo(B, betaSelfStake, 10, "Beta", {live: betaLive, needsPriming: true})],
[B.toLowerCase(), validatorInfo(B, betaSelfStake, 10, "Beta", {
hasUnclaimedRewards: betaHasUnclaimedRewards,
needsPriming: betaNeedsPriming,
pendingDeposits: betaPendingDeposits,
})],
]);

return {
Expand Down Expand Up @@ -137,8 +145,24 @@ describe("staking validators action", () => {
expect(output.activeCount).toBe(1);
});

test("status is pending only while a self-stake deposit is still activating", async () => {
// Above the minimum, primed, not selected yet. `hasUnclaimedRewards` must not drive status.
const base = {betaSelfStake: 100, betaNeedsPriming: false, betaHasUnclaimedRewards: true};

let action = setupAction(createMockClient({...base, betaPendingDeposits: [{epoch: 5n, stakeRaw: rawGen(100)}]}));
await action.execute({json: true});
let output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string);
expect(output.validators[1].status).toBe("pending");
expect(output.validators[1].hasUnclaimedRewards).toBe(true);

action = setupAction(createMockClient({...base, betaPendingDeposits: [{epoch: 3n, stakeRaw: rawGen(100)}]}));
await action.execute({json: true});
output = JSON.parse(logSpy.mock.calls.at(-1)?.[0] as string);
expect(output.validators[1].status).toBe("inactive");
});

test("keeps joined but non-selectable validators out of the active set", async () => {
const action = setupAction(createMockClient({betaSelfStake: 100, betaLive: true}));
const action = setupAction(createMockClient({betaSelfStake: 100, betaHasUnclaimedRewards: true}));

await action.execute({json: true});

Expand Down Expand Up @@ -171,7 +195,7 @@ describe("staking validators action", () => {
});

test("renders epoch 0 below-min validators as pending activation", async () => {
const action = setupAction(createMockClient({currentEpoch: 0n, betaLive: false}));
const action = setupAction(createMockClient({currentEpoch: 0n, betaHasUnclaimedRewards: false}));
await action.execute({});

const output = loggedOutput();
Expand All @@ -182,7 +206,7 @@ describe("staking validators action", () => {
});

test("renders epoch 2 below-min validators as inactive below-min", async () => {
const action = setupAction(createMockClient({currentEpoch: 2n, betaLive: false}));
const action = setupAction(createMockClient({currentEpoch: 2n, betaHasUnclaimedRewards: false}));
await action.execute({});

const output = loggedOutput();
Expand Down
2 changes: 1 addition & 1 deletion tests/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ describe(`Testnet ${name} - CLI Staking Smoke Tests`, () => {
expect(typeof info.vShares).toBe("bigint");
expect(typeof info.dStake).toBe("string");
expect(typeof info.dStakeRaw).toBe("bigint");
expect(typeof info.live).toBe("boolean");
expect(typeof info.hasUnclaimedRewards).toBe("boolean");
expect(typeof info.banned).toBe("boolean");
expect(typeof info.needsPriming).toBe("boolean");
expect(Array.isArray(info.pendingDeposits)).toBe(true);
Expand Down
Loading