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',