Skip to content
Draft
Show file tree
Hide file tree
Changes from 10 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
180 changes: 180 additions & 0 deletions src/governance-app-frontend/src/common/utils/neuron.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,29 @@ import { ICP_TRANSACTION_FEE_E8Sn } from '@constants/extra';
import { mockDisbursement, mockNeuron } from '@fixtures/neuron';

import {
formatRemainingTime,
getFollowingHealth,
getNeuronMaturityDisbursementsInProgressE8s,
getSecondsSinceVotingPowerRefresh,
getSecondsUntilDecayStarts,
getSecondsUntilFollowingCleared,
hasValueAboveTransactionFee,
isNonEmptyNeuron,
} from './neuron';

const SECONDS_IN_MONTH = BigInt(30 * 24 * 60 * 60);
// Matches the protocol defaults from governance.proto VotingPowerEconomics:
// voting power starts to decline 6 months after the last refresh, and following
// is cleared one month later (so 7 months after the last refresh in total).
// `clearFollowingAfterSeconds` is the duration of the reduction phase, not the
// absolute deadline from refresh.
const ECONOMICS = {
startReducingVotingPowerAfterSeconds: 6n * SECONDS_IN_MONTH,
clearFollowingAfterSeconds: 1n * SECONDS_IN_MONTH,
};
Comment thread
yhabib marked this conversation as resolved.
const NOW = new Date('2026-01-01T00:00:00Z');
const NOW_SECONDS = BigInt(Math.floor(NOW.getTime() / 1000));

describe('getNeuronMaturityDisbursementsInProgressE8s', () => {
it('returns 0 when fullNeuron is undefined', () => {
const neuron = mockNeuron({ fullNeuron: undefined });
Expand Down Expand Up @@ -124,3 +142,165 @@ describe('isNonEmptyNeuron', () => {
expect(isNonEmptyNeuron(neuron)).toBe(true);
});
});

describe('getSecondsSinceVotingPowerRefresh', () => {
it('returns undefined when no refresh timestamp is set', () => {
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: undefined });
expect(getSecondsSinceVotingPowerRefresh(neuron, NOW)).toBeUndefined();
});

it('returns elapsed seconds since the refresh', () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - SECONDS_IN_MONTH,
});
expect(getSecondsSinceVotingPowerRefresh(neuron, NOW)).toBe(SECONDS_IN_MONTH);
});

it('clamps to 0 when the refresh timestamp is in the future', () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS + SECONDS_IN_MONTH,
});
expect(getSecondsSinceVotingPowerRefresh(neuron, NOW)).toBe(0n);
});

it('falls back to fullNeuron.votingPowerRefreshedTimestampSeconds', () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: undefined,
fullNeuron: { votingPowerRefreshedTimestampSeconds: NOW_SECONDS - SECONDS_IN_MONTH },
});
expect(getSecondsSinceVotingPowerRefresh(neuron, NOW)).toBe(SECONDS_IN_MONTH);
});
});

describe('getSecondsUntilFollowingCleared', () => {
it('returns remaining seconds before the deadline (startReducing + clearFollowing from refresh)', () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - 5n * SECONDS_IN_MONTH,
});
// Deadline is at 6mo + 1mo = 7mo after refresh; with 5mo elapsed, 2mo remain.
expect(getSecondsUntilFollowingCleared(neuron, ECONOMICS, NOW)).toBe(2n * SECONDS_IN_MONTH);
});

it('returns 0 once the deadline has passed', () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - 8n * SECONDS_IN_MONTH,
});
expect(getSecondsUntilFollowingCleared(neuron, ECONOMICS, NOW)).toBe(0n);
});

it('returns undefined when economics are missing', () => {
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: NOW_SECONDS });
expect(getSecondsUntilFollowingCleared(neuron, undefined, NOW)).toBeUndefined();
});

it('returns undefined when the refresh timestamp is missing', () => {
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: undefined });
expect(getSecondsUntilFollowingCleared(neuron, ECONOMICS, NOW)).toBeUndefined();
});
});

describe('formatRemainingTime', () => {
const SECONDS_IN_DAY_BIG = BigInt(24 * 60 * 60);

it('returns empty string for non-positive values', () => {
expect(formatRemainingTime(0n)).toBe('');
expect(formatRemainingTime(-1n)).toBe('');
});

it('rounds to a whole day when just under a day boundary (the flicker fix)', () => {
// 9d 23h 59m 59s — would normally render as "9 days, 23 hours"
expect(formatRemainingTime(10n * SECONDS_IN_DAY_BIG - 1n)).toBe('10 days');
});

it('rounds to a whole day when just over a day boundary', () => {
// 10d 1s — would normally render as "10 days, 1 second"
expect(formatRemainingTime(10n * SECONDS_IN_DAY_BIG + 1n)).toBe('10 days');
});

it('uses the nearest day when between day boundaries', () => {
// 10d 12h — rounds up to 11 days (banker's-style ties go up here).
expect(formatRemainingTime(10n * SECONDS_IN_DAY_BIG + 12n * 3600n)).toBe('11 days');
// 10d 11h — rounds down to 10 days.
expect(formatRemainingTime(10n * SECONDS_IN_DAY_BIG + 11n * 3600n)).toBe('10 days');
});

it('preserves precision for sub-day durations', () => {
expect(formatRemainingTime(BigInt(3 * 60 * 60))).toBe('3 hours');
expect(formatRemainingTime(BigInt(23 * 60 * 60 + 30 * 60))).toBe('23 hours, 30 minutes');
});
});

describe('getSecondsUntilDecayStarts', () => {
it('returns remaining seconds before startReducing is reached', () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - 5n * SECONDS_IN_MONTH,
});
expect(getSecondsUntilDecayStarts(neuron, ECONOMICS, NOW)).toBe(1n * SECONDS_IN_MONTH);
});

it('returns 0 once startReducing has been crossed', () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - 7n * SECONDS_IN_MONTH,
});
expect(getSecondsUntilDecayStarts(neuron, ECONOMICS, NOW)).toBe(0n);
});

it('returns undefined when economics are missing', () => {
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: NOW_SECONDS });
expect(getSecondsUntilDecayStarts(neuron, undefined, NOW)).toBeUndefined();
});

it('returns undefined when the refresh timestamp is missing', () => {
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: undefined });
expect(getSecondsUntilDecayStarts(neuron, ECONOMICS, NOW)).toBeUndefined();
});
});

describe('getFollowingHealth', () => {
it("returns 'ok' well inside the safe window", () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - 1n * SECONDS_IN_MONTH,
});
expect(getFollowingHealth(neuron, ECONOMICS, NOW)).toBe('ok');
});

it("returns 'warning' inside the proactive notice window before voting power starts to reduce", () => {
// 20 days before startReducing (which is 6mo). Falls within the 30-day window.
const refreshed = NOW_SECONDS - (6n * SECONDS_IN_MONTH - BigInt(20 * 24 * 60 * 60));
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: refreshed });
expect(getFollowingHealth(neuron, ECONOMICS, NOW)).toBe('warning');
});

it("stays 'ok' just outside the proactive notice window", () => {
// 35 days before startReducing — outside the 30-day notice window.
const refreshed = NOW_SECONDS - (6n * SECONDS_IN_MONTH - BigInt(35 * 24 * 60 * 60));
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: refreshed });
expect(getFollowingHealth(neuron, ECONOMICS, NOW)).toBe('ok');
});

it("transitions to 'decaying' the moment voting power starts to reduce", () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - 6n * SECONDS_IN_MONTH,
});
expect(getFollowingHealth(neuron, ECONOMICS, NOW)).toBe('decaying');
});

it("stays 'decaying' between startReducing and the clear deadline", () => {
// 6.5 months elapsed: past startReducing but before startReducing + clearFollowing.
const refreshed = NOW_SECONDS - (6n * SECONDS_IN_MONTH + BigInt(15 * 24 * 60 * 60));
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: refreshed });
expect(getFollowingHealth(neuron, ECONOMICS, NOW)).toBe('decaying');
});

it("returns 'expired' once following is cleared (startReducing + clearFollowing)", () => {
const neuron = mockNeuron({
votingPowerRefreshedTimestampSeconds: NOW_SECONDS - 7n * SECONDS_IN_MONTH,
});
expect(getFollowingHealth(neuron, ECONOMICS, NOW)).toBe('expired');
});

it('returns undefined when thresholds are missing', () => {
const neuron = mockNeuron({ votingPowerRefreshedTimestampSeconds: NOW_SECONDS });
expect(getFollowingHealth(neuron, undefined, NOW)).toBeUndefined();
});
});
137 changes: 136 additions & 1 deletion src/governance-app-frontend/src/common/utils/neuron.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type NeuronInfo, NeuronState } from '@icp-sdk/canisters/nns';
import { type I18nSecondsToDuration, nonNullish } from '@dfinity/utils';
import { type I18nSecondsToDuration, isNullish, nonNullish } from '@dfinity/utils';

import { FOLLOWABLE_TOPIC_SET } from '@features/voting/utils/topicFollowing';

Expand Down Expand Up @@ -281,6 +281,23 @@ export const formatDissolveDelay = ({
return parts.slice(0, 2).join(', ');
};

/**
* Variant of `formatDissolveDelay` for "time remaining" labels in the
* FollowingStatus components. Rounds to the nearest whole day once the
* duration is at least one day so the display doesn't flicker between e.g.
* "10 days" and "9 days, 23 hours" when `Date.now()` crosses a second
* boundary between renders. Sub-day values are passed through unchanged.
*/
export const formatRemainingTime = (seconds: bigint, i18n?: I18nSecondsToDuration): string => {
if (seconds <= 0n) return '';
const secondsInDayBig = BigInt(SECONDS_IN_DAY);
const display =
seconds >= secondsInDayBig
? ((seconds + secondsInDayBig / 2n) / secondsInDayBig) * secondsInDayBig
: seconds;
return formatDissolveDelay({ seconds: display, i18n });
};

export const getNeuronHasNoFollowing = (neuron: NeuronInfo): boolean => {
const followees = neuron.fullNeuron?.followees ?? [];

Expand All @@ -291,6 +308,124 @@ export const getNeuronHasNoFollowing = (neuron: NeuronInfo): boolean => {
.every((topicFollowees) => topicFollowees.followees.length === 0);
};

export type FollowingHealth = 'ok' | 'warning' | 'decaying' | 'expired';

export type VotingPowerEconomicsThresholds = {
startReducingVotingPowerAfterSeconds: bigint | undefined | null;
clearFollowingAfterSeconds: bigint | undefined | null;
};

/**
* How long before voting power starts to decay we begin to surface a warning.
* Matches nns-dapp's NOTIFICATION_PERIOD_BEFORE_REWARD_LOSS_STARTS_DAYS (30 days).
*/
export const FOLLOWING_WARNING_WINDOW_SECONDS = BigInt(30 * SECONDS_IN_DAY);

export const getVotingPowerRefreshedTimestampSeconds = (neuron: NeuronInfo): bigint | undefined =>
neuron.votingPowerRefreshedTimestampSeconds ??
neuron.fullNeuron?.votingPowerRefreshedTimestampSeconds ??
undefined;

/**
* Seconds since the neuron's voting power was last refreshed. A refresh happens
* automatically on most controller actions (setting followees, voting…) or
* explicitly via `refreshVotingPower`.
*/
export const getSecondsSinceVotingPowerRefresh = (
neuron: NeuronInfo,
referenceDate: Date = new Date(),
): bigint | undefined => {
const refreshed = getVotingPowerRefreshedTimestampSeconds(neuron);
if (isNullish(refreshed)) return undefined;
const now = BigInt(Math.floor(referenceDate.getTime() / 1000));
return now > refreshed ? now - refreshed : 0n;
};

/**
* Seconds remaining before the neuron's following is cleared by the protocol.
* The protocol clears following at `startReducing + clearFollowing` seconds
* after the last refresh — `clearFollowingAfterSeconds` is the duration of the
* voting-power-reduction phase, not the absolute deadline from refresh. See
* governance.proto VotingPowerEconomics.
*
* Returns `undefined` if the threshold or refresh timestamp is missing.
* Returns `0n` once the deadline has passed.
*/
export const getSecondsUntilFollowingCleared = (
neuron: NeuronInfo,
economics: VotingPowerEconomicsThresholds | undefined,
referenceDate: Date = new Date(),
): bigint | undefined => {
const elapsed = getSecondsSinceVotingPowerRefresh(neuron, referenceDate);
const startReducing = economics?.startReducingVotingPowerAfterSeconds;
const clearAfter = economics?.clearFollowingAfterSeconds;
if (isNullish(elapsed) || isNullish(startReducing) || isNullish(clearAfter)) {
return undefined;
}
const deadline = startReducing + clearAfter;
return deadline > elapsed ? deadline - elapsed : 0n;
};

/**
* Seconds remaining before voting power starts to decay (i.e. when the
* neuron enters the `decaying` health state). Returns `0n` once the boundary
* has been crossed; returns `undefined` if thresholds or refresh timestamp
* are missing.
*/
export const getSecondsUntilDecayStarts = (
neuron: NeuronInfo,
economics: VotingPowerEconomicsThresholds | undefined,
referenceDate: Date = new Date(),
): bigint | undefined => {
const elapsed = getSecondsSinceVotingPowerRefresh(neuron, referenceDate);
const startReducing = economics?.startReducingVotingPowerAfterSeconds;
if (isNullish(elapsed) || isNullish(startReducing)) {
return undefined;
}
return startReducing > elapsed ? startReducing - elapsed : 0n;
};

/**
* Classifies the current following health into four phases that map onto the
* protocol's voting-power lifecycle:
*
* Time since refresh: 0 ────── (startReducing − warningWindow) ─── startReducing ──────── (startReducing + clearFollowing) ──►
* Voting power: | full full → decreasing linearly → 0 |
* Followees: | intact intact cleared
* Health value: | ok | warning | decaying | expired
*
* - `ok` — well inside the safe window; voting power full, followees intact.
* - `warning` — within the proactive notice window before decay. Voting power
* is still full, followees still intact, but action is recommended
* so the user avoids losing any rewards.
* - `decaying` — voting power is actively decreasing toward zero. Followees are
* still intact, but the neuron is earning less per vote. Confirming
* stops the bleed.
* - `expired` — past `startReducing + clearFollowing`; voting power is zero
* and followees have been cleared by the protocol.
*/
export const getFollowingHealth = (
neuron: NeuronInfo,
economics: VotingPowerEconomicsThresholds | undefined,
referenceDate: Date = new Date(),
): FollowingHealth | undefined => {
const elapsed = getSecondsSinceVotingPowerRefresh(neuron, referenceDate);
const startReducing = economics?.startReducingVotingPowerAfterSeconds;
const clearAfter = economics?.clearFollowingAfterSeconds;
if (isNullish(elapsed) || isNullish(startReducing) || isNullish(clearAfter)) {
return undefined;
}
const clearDeadline = startReducing + clearAfter;
const warningStart =
startReducing > FOLLOWING_WARNING_WINDOW_SECONDS
? startReducing - FOLLOWING_WARNING_WINDOW_SECONDS
: 0n;
if (elapsed >= clearDeadline) return 'expired';
if (elapsed >= startReducing) return 'decaying';
if (elapsed >= warningStart) return 'warning';
return 'ok';
};

/**
* Returns true if the current user is a hotkey of the neuron (and not the controller).
* Hotkeys have limited permissions: they can vote, set followees,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export enum AnalyticsEvent {
StakingSetConfiguration = 'staking_set_configuration',
StakingConfirmation = 'staking_confirmation',
StakingConfirmationError = 'staking_confirmation_error',
StakingRefreshVotingPower = 'staking_refresh_voting_power',
StakingRefreshVotingPowerError = 'staking_refresh_voting_power_error',

// Following flow
FollowingSimpleConfirmation = 'following_simple_confirmation',
Expand Down
Loading
Loading