diff --git a/apps/governance/src/i18n/translations/dev.json b/apps/governance/src/i18n/translations/dev.json index 27080d007..46fc8e889 100644 --- a/apps/governance/src/i18n/translations/dev.json +++ b/apps/governance/src/i18n/translations/dev.json @@ -890,5 +890,23 @@ "HowToPropose": "How to make a proposal", "HowToProposeRawStep1": "1. Sense check your proposal with the community on the forum:", "HowToProposeRawStep2": "2. Use the appropriate proposal template in the docs:", - "HowToProposeRawStep3": "3. Submit on-chain below" + "HowToProposeRawStep3": "3. Submit on-chain below", + "BenefitTiers": "Benefit tiers", + "BenefitTierMinimumEpochs": "Minimum epochs", + "BenefitTierMinimumEpochsDescription": "The minimum number of epochs the party needs to be in the referral set to be eligible for the benefit", + "BenefitTierMinimumRunningNotionalTakerVolume": "Minimum running notional taker volume", + "BenefitTierMinimumRunningNotionalTakerVolumeDescription": "The minimum running notional for the given benefit tier", + "BenefitTierReferralDiscountFactor": "Referral discount factor", + "BenefitTierReferralDiscountFactorDescription": "The proportion of the referee's taker fees to be discounted", + "BenefitTierReferralRewardFactor": "Referral reward factor", + "BenefitTierReferralRewardFactorDescription": "The proportion of the referee's taker fees to be rewarded to the referrer", + "StakingTiers": "Staking tiers", + "StakingTierMinimumStakedTokens": "Minimum staked tokens", + "StakingTierMinimumStakedTokensDescription": "Required number of governance tokens ($VEGA) a referrer must have staked to receive the multiplier", + "StakingTierReferralRewardMultiplier": "Referral reward multiplier", + "StakingTierReferralRewardMultiplierDescription": "Multiplier applied to the referral reward factor when calculating referral rewards due to the referrer", + "WindowLength": "Window length", + "WindowLengthDescription": "Number of epochs over which to evaluate a referral set's running volume", + "EndOfProgramTimestamp": "End of program", + "EndOfProgramTimestampDescription": "Time after which when the current epoch ends, the programs will end and benefits will be disabled." } diff --git a/apps/governance/src/routes/proposals/components/proposal-referral-program-details/index.tsx b/apps/governance/src/routes/proposals/components/proposal-referral-program-details/index.tsx new file mode 100644 index 000000000..f6fb6d049 --- /dev/null +++ b/apps/governance/src/routes/proposals/components/proposal-referral-program-details/index.tsx @@ -0,0 +1 @@ +export * from './proposal-referral-program-details'; diff --git a/apps/governance/src/routes/proposals/components/proposal-referral-program-details/proposal-referral-program-details.spec.tsx b/apps/governance/src/routes/proposals/components/proposal-referral-program-details/proposal-referral-program-details.spec.tsx new file mode 100644 index 000000000..64bb15c89 --- /dev/null +++ b/apps/governance/src/routes/proposals/components/proposal-referral-program-details/proposal-referral-program-details.spec.tsx @@ -0,0 +1,164 @@ +import { render, screen } from '@testing-library/react'; +import { + formatEndOfProgramTimestamp, + formatMinimumRunningNotionalTakerVolume, + formatReferralDiscountFactor, + formatReferralRewardFactor, + formatMinimumStakedTokens, + formatReferralRewardMultiplier, + ProposalReferralProgramDetails, +} from './proposal-referral-program-details'; +import { generateProposal } from '../../test-helpers/generate-proposals'; + +jest.mock('../../../../contexts/app-state/app-state-context', () => ({ + useAppState: () => ({ + appState: { + decimals: 2, + }, + }), +})); + +describe('ProposalReferralProgramDetails helper functions', () => { + it('should format end of program timestamp correctly', () => { + const input = '2023-01-01T12:00:00Z'; + const formatted = formatEndOfProgramTimestamp(input); + expect(formatted).toBe('01 January 2023 12:00 (GMT)'); + }); + + it('should format minimum running notional taker volume correctly', () => { + const input = '1000'; + const formatted = formatMinimumRunningNotionalTakerVolume(input); + expect(formatted).toBe('1,000'); + }); + + it('should format referral discount factor correctly', () => { + const input = '0.05'; + const formatted = formatReferralDiscountFactor(input); + expect(formatted).toBe('5.00%'); + }); + + it('should format referral reward factor correctly', () => { + const input = '0.1'; + const formatted = formatReferralRewardFactor(input); + expect(formatted).toBe('10.00%'); + }); + + it('should format minimum staked tokens correctly', () => { + const input = '15'; + const decimals = 18; + const formatted = formatMinimumStakedTokens(input, decimals); + expect(formatted).toBe('0.000000000000000015'); + }); + + it('should format referral reward multiplier correctly', () => { + const input = '3'; + const formatted = formatReferralRewardMultiplier(input); + expect(formatted).toBe('3x'); + }); +}); + +const mockReferralProposal = generateProposal({ + terms: { + change: { + __typename: 'UpdateReferralProgram', + changes: { + id: '', + version: 0, + benefitTiers: [ + { + minimumEpochs: 6, + minimumRunningNotionalTakerVolume: '10000', + referralDiscountFactor: '0.001', + referralRewardFactor: '0.001', + }, + { + minimumEpochs: 24, + minimumRunningNotionalTakerVolume: '500000', + referralDiscountFactor: '0.005', + referralRewardFactor: '0.005', + }, + { + minimumEpochs: 48, + minimumRunningNotionalTakerVolume: '1000000', + referralDiscountFactor: '0.01', + referralRewardFactor: '0.01', + }, + ], + endOfProgramTimestamp: '2026-10-03T10:34:34Z', + windowLength: 3, + stakingTiers: [ + { + minimumStakedTokens: '1', + referralRewardMultiplier: '1', + }, + { + minimumStakedTokens: '2', + referralRewardMultiplier: '2', + }, + { + minimumStakedTokens: '5', + referralRewardMultiplier: '3', + }, + ], + }, + }, + }, +}); + +describe('', () => { + it('should not render if proposal is null', () => { + render(); + expect( + screen.queryByTestId('proposal-referral-program-details') + ).toBeNull(); + }); + + it('should not render if __typename is not UpdateReferralProgram', () => { + const updateMarketProposal = generateProposal({ + terms: { + change: { + __typename: 'UpdateMarket', + }, + }, + }); + render(); + expect( + screen.queryByTestId('proposal-referral-program-details') + ).toBeNull(); + }); + + it('should not render if there are no relevant fields', () => { + const incompleteProposal = generateProposal({ + terms: { + change: { + __typename: 'UpdateReferralProgram', + changes: { + id: '', + version: 0, + }, + }, + }, + }); + + render(); + expect( + screen.queryByTestId('proposal-referral-program-details') + ).toBeNull(); + }); + + it('should render relevant fields if present', () => { + render(); + expect( + screen.getByTestId('proposal-referral-program-window-length') + ).toBeInTheDocument(); + expect( + screen.getByTestId('proposal-referral-program-end-of-program-timestamp') + ).toBeInTheDocument(); + expect( + screen.getByTestId('proposal-referral-program-benefit-tiers') + ).toBeInTheDocument(); + expect( + screen.getByTestId('proposal-referral-program-benefit-tiers') + ).toBeInTheDocument(); + }); +}); diff --git a/apps/governance/src/routes/proposals/components/proposal-referral-program-details/proposal-referral-program-details.tsx b/apps/governance/src/routes/proposals/components/proposal-referral-program-details/proposal-referral-program-details.tsx new file mode 100644 index 000000000..e33133bbd --- /dev/null +++ b/apps/governance/src/routes/proposals/components/proposal-referral-program-details/proposal-referral-program-details.tsx @@ -0,0 +1,233 @@ +import { useTranslation } from 'react-i18next'; +import type { ProposalQuery } from '../../proposal/__generated__/Proposal'; +import { + KeyValueTable, + KeyValueTableRow, + RoundedWrapper, + Tooltip, +} from '@vegaprotocol/ui-toolkit'; +import { formatNumber } from '../../../../lib/format-number'; +import { + formatDateWithLocalTimezone, + formatNumberPercentage, + toBigNum, +} from '@vegaprotocol/utils'; +import BigNumber from 'bignumber.js'; +import { useAppState } from '../../../../contexts/app-state/app-state-context'; + +interface ProposalReferralProgramDetailsProps { + proposal: ProposalQuery['proposal']; +} + +export const formatEndOfProgramTimestamp = (value: string) => { + return formatDateWithLocalTimezone(new Date(value)); +}; + +export const formatMinimumRunningNotionalTakerVolume = (value: string) => { + return formatNumber(toBigNum(value, 0), 0); +}; + +export const formatReferralDiscountFactor = (value: string) => { + return formatNumberPercentage(new BigNumber(value).times(100)); +}; + +export const formatReferralRewardFactor = (value: string) => { + return formatNumberPercentage(new BigNumber(value).times(100)); +}; + +export const formatMinimumStakedTokens = (value: string, decimals: number) => { + return formatNumber(toBigNum(value, decimals)); +}; + +export const formatReferralRewardMultiplier = (value: string) => { + return `${value}x`; +}; + +export const ProposalReferralProgramDetails = ({ + proposal, +}: ProposalReferralProgramDetailsProps) => { + const { + appState: { decimals }, + } = useAppState(); + const { t } = useTranslation(); + if (proposal?.terms?.change?.__typename !== 'UpdateReferralProgram') { + return null; + } + + const benefitTiers = proposal?.terms?.change?.changes?.benefitTiers; + const stakingTiers = proposal?.terms?.change?.changes?.stakingTiers; + const windowLength = proposal?.terms?.change?.changes?.windowLength; + const endOfProgramTimestamp = + proposal?.terms?.change?.changes?.endOfProgramTimestamp; + + if ( + !benefitTiers && + !stakingTiers && + !windowLength && + !endOfProgramTimestamp + ) { + return null; + } + + return ( +
+ + {windowLength && ( +
+ + + + {t('WindowLength')} + + {windowLength} + + +
+ )} + + {endOfProgramTimestamp && ( +
+ + + + {t('EndOfProgramTimestamp')} + + {formatEndOfProgramTimestamp(endOfProgramTimestamp)} + + +
+ )} + + {benefitTiers && ( +
+

+ {t('BenefitTiers')} +

+ + {benefitTiers + .sort((a, b) => a.minimumEpochs - b.minimumEpochs) + .map((benefitTier, index) => ( +
+

+ Tier {index + 1} +

+ {benefitTier.minimumEpochs && ( + + + {t('BenefitTierMinimumEpochs')} + + {benefitTier.minimumEpochs} + + )} + {benefitTier.minimumRunningNotionalTakerVolume && ( + + + + {t('BenefitTierMinimumRunningNotionalTakerVolume')} + + + {formatMinimumRunningNotionalTakerVolume( + benefitTier.minimumRunningNotionalTakerVolume + )} + + )} + {benefitTier.referralDiscountFactor && ( + + + {t('BenefitTierReferralDiscountFactor')} + + {formatReferralDiscountFactor( + benefitTier.referralDiscountFactor + )} + + )} + {benefitTier.referralRewardFactor && ( + + + {t('BenefitTierReferralRewardFactor')} + + {formatReferralRewardFactor( + benefitTier.referralRewardFactor + )} + + )} +
+ ))} +
+
+ )} + + {stakingTiers && ( +
+

+ {t('StakingTiers')} +

+ + {stakingTiers + .sort( + (a, b) => + Number(a.minimumStakedTokens) - + Number(b.minimumStakedTokens) + ) + .map((stakingTier, index) => ( +
+ {stakingTier.referralRewardMultiplier && ( + + + + {t('StakingTierReferralRewardMultiplier')} + + + {formatReferralRewardMultiplier( + stakingTier.referralRewardMultiplier + )} + + )} + {stakingTier.minimumStakedTokens && ( + + + {t('StakingTierMinimumStakedTokens')} + + {formatMinimumStakedTokens( + stakingTier.minimumStakedTokens, + decimals + )} + + )} +
+ ))} +
+
+ )} +
+
+ ); +}; diff --git a/apps/governance/src/routes/proposals/components/proposal/proposal.tsx b/apps/governance/src/routes/proposals/components/proposal/proposal.tsx index 32df596c5..bc69fdba4 100644 --- a/apps/governance/src/routes/proposals/components/proposal/proposal.tsx +++ b/apps/governance/src/routes/proposals/components/proposal/proposal.tsx @@ -6,6 +6,7 @@ import { ProposalDescription } from '../proposal-description'; import { ProposalChangeTable } from '../proposal-change-table'; import { ProposalJson } from '../proposal-json'; import { ProposalAssetDetails } from '../proposal-asset-details'; +import { ProposalReferralProgramDetails } from '../proposal-referral-program-details'; import { UserVote } from '../vote-details'; import { ListAsset } from '../list-asset'; import Routes from '../../../routes'; @@ -99,6 +100,10 @@ export const Proposal = ({ minVoterBalance = networkParams.governance_proposal_freeform_minVoterBalance; break; + case 'UpdateReferralProgram': + minVoterBalance = + networkParams.governance_proposal_referralProgram_minVoterBalance; + break; } } @@ -187,6 +192,12 @@ export const Proposal = ({ )} + {proposal.terms.change.__typename === 'UpdateReferralProgram' && ( +
+ +
+ )} +
{ NetworkParams.governance_proposal_updateAsset_minVoterBalance, NetworkParams.governance_proposal_updateNetParam_minVoterBalance, NetworkParams.governance_proposal_freeform_minVoterBalance, + NetworkParams.governance_proposal_referralProgram_minVoterBalance, NetworkParams.spam_protection_voting_min_tokens, NetworkParams.governance_proposal_market_requiredMajority, NetworkParams.governance_proposal_updateMarket_requiredMajority, @@ -44,6 +45,7 @@ export const ProposalContainer = () => { NetworkParams.governance_proposal_updateAsset_requiredMajority, NetworkParams.governance_proposal_updateNetParam_requiredMajority, NetworkParams.governance_proposal_freeform_requiredMajority, + NetworkParams.governance_proposal_referralProgram_requiredMajority, ]); const { diff --git a/libs/network-parameters/src/use-network-params.ts b/libs/network-parameters/src/use-network-params.ts index 36caac818..a13e30b29 100644 --- a/libs/network-parameters/src/use-network-params.ts +++ b/libs/network-parameters/src/use-network-params.ts @@ -100,6 +100,18 @@ export const NetworkParams = { 'governance_proposal_freeform_requiredMajority', governance_proposal_freeform_minProposerBalance: 'governance_proposal_freeform_minProposerBalance', + governance_proposal_referralProgram_minClose: + 'governance_proposal_referralProgram_minClose', + governance_proposal_referralProgram_minEnact: + 'governance_proposal_referralProgram_minEnact', + governance_proposal_referralProgram_minProposerBalance: + 'governance_proposal_referralProgram_minProposerBalance', + governance_proposal_referralProgram_minVoterBalance: + 'governance_proposal_referralProgram_minVoterBalance', + governance_proposal_referralProgram_requiredMajority: + 'governance_proposal_referralProgram_requiredMajority', + governance_proposal_referralProgram_requiredParticipation: + 'governance_proposal_referralProgram_requiredParticipation', validators_delegation_minAmount: 'validators_delegation_minAmount', spam_protection_minimumWithdrawalQuantumMultiple: 'spam_protection_minimumWithdrawalQuantumMultiple',