diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..72f1be05 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Keep hashbang-bearing JavaScript importable by Node/Vitest on Windows. +*.mjs text eol=lf diff --git a/package-lock.json b/package-lock.json index f0586ad5..d55272d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,7 +46,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", @@ -5682,9 +5682,9 @@ } }, "node_modules/genlayer-js": { - "version": "1.1.8", - "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#facd9e9dc9a289d0110fe3b5b1a14a2938fe6e01", - "integrity": "sha512-ExvojIzcD0bVUMMJ0ir0dUdnXfPefLPEH92vfGWW2CSUxAGdeHq0tu16PSbIITwdib1bV9cr+Qmbp6JDAVz9Jw==", + "version": "2.0.0-rc.1", + "resolved": "git+ssh://git@github.com/genlayerlabs/genlayer-js.git#b629021092e23db08fd884f982d8eb34682f7bad", + "integrity": "sha512-AH0HmJDHwWz0ErVoT4hkvcD4Ya1QNRestL2Qz7vnJaAc7wxAQXS49z0w85YOjaYrDN+U++u5O8yjHJZ0Az8NMA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 12bd5327..ba24f1b9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/commands/staking/stakingInfo.ts b/src/commands/staking/stakingInfo.ts index e1cfcc28..086a23e8 100644 --- a/src/commands/staking/stakingInfo.ts +++ b/src/commands/staking/stakingInfo.ts @@ -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 @@ -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}`); @@ -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 diff --git a/src/commands/staking/validators.ts b/src/commands/staking/validators.ts index 81313d21..80f9e7e8 100644 --- a/src/commands/staking/validators.ts +++ b/src/commands/staking/validators.ts @@ -73,7 +73,7 @@ interface ValidatorRow { operator: Address; moniker?: string; active: boolean; - live: boolean; + hasUnclaimedRewards: boolean; banned: boolean; bannedUntilEpoch?: string; status: string; @@ -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) { @@ -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 @@ -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, @@ -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, diff --git a/src/lib/actions/BaseAction.ts b/src/lib/actions/BaseAction.ts index d8c08a86..55ebb5da 100644 --- a/src/lib/actions/BaseAction.ts +++ b/src/lib/actions/BaseAction.ts @@ -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 @@ -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 '.", + ); + return; + } this.logInfo( `Epoch 0: minimum self-stake not enforced yet. Note: this validator won't become active ` + `until its self-stake reaches ${minStakeFormatted}.`, diff --git a/tests/actions/staking.test.ts b/tests/actions/staking.test.ts index 5ee03ff6..1109a692 100644 --- a/tests/actions/staking.test.ts +++ b/tests/actions/staking.test.ts @@ -580,7 +580,7 @@ describe("StakingInfoAction", () => { vWithdrawalRaw: 0n, ePrimed: 5n, needsPriming: false, - live: true, + hasUnclaimedRewards: false, banned: false, bannedEpoch: null, pendingDeposits: [], @@ -1063,7 +1063,7 @@ function fullValidatorInfo(overrides: Record = {}) { vWithdrawalRaw: 0n, ePrimed: 5n, needsPriming: false, - live: true, + hasUnclaimedRewards: false, banned: false, bannedEpoch: null, pendingDeposits: [], @@ -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", () => { @@ -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"); }); @@ -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"); }); diff --git a/tests/commands/stakingValidators.test.ts b/tests/commands/stakingValidators.test.ts index bbcf633d..3a0674fa 100644 --- a/tests/commands/stakingValidators.test.ts +++ b/tests/commands/stakingValidators.test.ts @@ -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, @@ -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; } @@ -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 { @@ -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}); @@ -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(); @@ -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(); diff --git a/tests/smoke.test.ts b/tests/smoke.test.ts index 68ff4e71..7c9efc3f 100644 --- a/tests/smoke.test.ts +++ b/tests/smoke.test.ts @@ -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);