Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51c426ef4b | ||
|
|
fa28d31ef3 | ||
|
|
201a586b05 | ||
|
|
7d96d9bcd1 | ||
|
|
345be81142 | ||
|
|
2221e9faca | ||
|
|
7c0139e107 |
@@ -1,5 +1,8 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Auto-format all files
|
||||
yarn nx format:write
|
||||
|
||||
# Lint all staged files
|
||||
yarn lint-staged
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
# Lint all staged files
|
||||
yarn nx format:check
|
||||
# Lint all staged files - this brings more value as pre-commit
|
||||
# yarn nx format:check
|
||||
|
||||
# Test all projects with changes
|
||||
yarn nx affected -t test --exclude trading
|
||||
# yarn nx affected -t test --exclude trading
|
||||
|
||||
@@ -22,10 +22,9 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
|
||||
NX_SUCCESSOR_MARKETS=true
|
||||
NX_STOP_ORDERS=true
|
||||
NX_ICEBERG_ORDERS=true
|
||||
# NX_PRODUCT_PERPETUALS
|
||||
NX_METAMASK_SNAPS=true
|
||||
NX_REFERRALS=false
|
||||
NX_REFERRALS=true
|
||||
# NX_DISABLE_CLOSE_POSITION=false
|
||||
|
||||
NX_TENDERMINT_URL=https://be.vega.community
|
||||
NX_TENDERMINT_WEBSOCKET_URL=wss://be.vega.community/websocket
|
||||
NX_DISABLE_CLOSE_POSITION=true
|
||||
|
||||
@@ -23,7 +23,6 @@ NX_WALLETCONNECT_PROJECT_ID=fe8091dc35738863e509fc4947525c72
|
||||
NX_SUCCESSOR_MARKETS=true
|
||||
NX_STOP_ORDERS=true
|
||||
NX_ICEBERG_ORDERS=true
|
||||
# NX_PRODUCT_PERPETUALS
|
||||
NX_METAMASK_SNAPS=true
|
||||
NX_REFERRALS=true
|
||||
|
||||
|
||||
@@ -18,13 +18,35 @@ import { Routes } from '../../lib/links';
|
||||
import { useTransactionEventSubscription } from '@vegaprotocol/web3';
|
||||
import { Statistics, useStats } from './referral-statistics';
|
||||
import { useReferralProgram } from './hooks/use-referral-program';
|
||||
import { useT } from '../../lib/use-t';
|
||||
import { ns, useT } from '../../lib/use-t';
|
||||
import { useFundsAvailable } from './hooks/use-funds-available';
|
||||
import { ViewType, useSidebar } from '../../components/sidebar';
|
||||
import { useGetCurrentRouteId } from '../../lib/hooks/use-get-current-route-id';
|
||||
import { QUSDTooltip } from './qusd-tooltip';
|
||||
import { Trans } from 'react-i18next';
|
||||
|
||||
const RELOAD_DELAY = 3000;
|
||||
|
||||
const SPAM_PROTECTION_ERR = 'SPAM_PROTECTION_ERR';
|
||||
const SpamProtectionErr = ({
|
||||
requiredFunds,
|
||||
}: {
|
||||
requiredFunds?: string | number | bigint;
|
||||
}) => {
|
||||
if (!requiredFunds) return null;
|
||||
// eslint-disable-next-line react/jsx-no-undef
|
||||
return (
|
||||
<Trans
|
||||
defaults="To protect the network from spam, you must have at least {{requiredFunds}} <0>qUSD</0> of any asset on the network to proceed."
|
||||
values={{
|
||||
requiredFunds,
|
||||
}}
|
||||
components={[<QUSDTooltip key="qusd" />]}
|
||||
ns={ns}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const validateCode = (value: string, t: ReturnType<typeof useT>) => {
|
||||
const number = +`0x${value}`;
|
||||
if (!value || value.length !== 64) {
|
||||
@@ -76,7 +98,6 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
setValue,
|
||||
setError,
|
||||
watch,
|
||||
clearErrors,
|
||||
} = useForm();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
@@ -90,30 +111,11 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
*/
|
||||
const validateFundsAvailable = useCallback(() => {
|
||||
if (requiredFunds && !isEligible) {
|
||||
const err = t(
|
||||
'Require minimum of {{requiredFunds}} to join a referral set to protect the network from spam.',
|
||||
{ replace: { requiredFunds } }
|
||||
);
|
||||
const err = SPAM_PROTECTION_ERR;
|
||||
return err;
|
||||
}
|
||||
return true;
|
||||
}, [isEligible, requiredFunds, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (codeField) {
|
||||
const err = validateFundsAvailable();
|
||||
if (err !== true) {
|
||||
setStatus('no-funds');
|
||||
setError('code', {
|
||||
type: 'required',
|
||||
message: err,
|
||||
});
|
||||
} else {
|
||||
setStatus(null);
|
||||
clearErrors('code');
|
||||
}
|
||||
}
|
||||
}, [clearErrors, codeField, isEligible, setError, validateFundsAvailable]);
|
||||
}, [isEligible, requiredFunds]);
|
||||
|
||||
/**
|
||||
* Validates the set a user tries to apply to.
|
||||
@@ -138,6 +140,15 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
if (code) setValue('code', code);
|
||||
}, [params, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
const err = validateFundsAvailable();
|
||||
if (err !== true) {
|
||||
setStatus('no-funds');
|
||||
} else {
|
||||
setStatus(null);
|
||||
}
|
||||
}, [isEligible, validateFundsAvailable]);
|
||||
|
||||
const onSubmit = ({ code }: FieldValues) => {
|
||||
if (isReadOnly || !pubKey || !code || code.length === 0) {
|
||||
return;
|
||||
@@ -321,10 +332,26 @@ export const ApplyCodeForm = ({ onSuccess }: { onSuccess?: () => void }) => {
|
||||
</label>
|
||||
<RainbowButton variant="border" {...getButtonProps()} />
|
||||
</form>
|
||||
{errors.code && (
|
||||
<InputError className="overflow-auto break-words">
|
||||
{errors.code.message?.toString()}
|
||||
{status === 'no-funds' ? (
|
||||
<InputError intent="warning" className="overflow-auto break-words">
|
||||
<span>
|
||||
<SpamProtectionErr requiredFunds={requiredFunds?.toString()} />
|
||||
</span>
|
||||
</InputError>
|
||||
) : (
|
||||
errors.code && (
|
||||
<InputError intent="warning" className="overflow-auto break-words">
|
||||
{errors.code.message === SPAM_PROTECTION_ERR ? (
|
||||
<span>
|
||||
<SpamProtectionErr
|
||||
requiredFunds={requiredFunds?.toString()}
|
||||
/>
|
||||
</span>
|
||||
) : (
|
||||
errors.code.message?.toString()
|
||||
)}
|
||||
</InputError>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{validateCode(codeField, t) === true && previewLoading && !previewData ? (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||
import { useFundsAvailableQuery } from './__generated__/FundsAvailable';
|
||||
import compact from 'lodash/compact';
|
||||
import sum from 'lodash/sum';
|
||||
import BigNumber from 'bignumber.js';
|
||||
|
||||
/**
|
||||
* Gets the funds for given public key and required min for
|
||||
@@ -24,14 +24,16 @@ export const useFundsAvailable = (pubKey?: string) => {
|
||||
? compact(data.party?.accountsConnection?.edges?.map((e) => e?.node))
|
||||
: undefined;
|
||||
const requiredFunds = data
|
||||
? BigInt(data.networkParameter?.value || '0')
|
||||
? BigNumber(data.networkParameter?.value || '0')
|
||||
: undefined;
|
||||
|
||||
const sumOfFunds = sum(
|
||||
fundsAvailable?.filter((fa) => fa.balance).map((fa) => BigInt(fa.balance))
|
||||
);
|
||||
const sumOfFunds =
|
||||
fundsAvailable
|
||||
?.filter((fa) => fa.balance)
|
||||
.reduce((sum, fa) => sum.plus(BigNumber(fa.balance)), BigNumber(0)) ||
|
||||
BigNumber(0);
|
||||
|
||||
if (requiredFunds && sumOfFunds >= requiredFunds) {
|
||||
if (requiredFunds && sumOfFunds.isGreaterThanOrEqualTo(requiredFunds)) {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
@@ -41,6 +43,6 @@ export const useFundsAvailable = (pubKey?: string) => {
|
||||
isEligible:
|
||||
fundsAvailable != null &&
|
||||
requiredFunds != null &&
|
||||
sumOfFunds >= requiredFunds,
|
||||
sumOfFunds.isGreaterThanOrEqualTo(requiredFunds),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export const LandingBanner = () => {
|
||||
</div>
|
||||
<div className="pt-20 sm:w-[50%]">
|
||||
<h1 className="text-6xl font-alpha calt mb-10">
|
||||
{t('Vega community referral program')}
|
||||
{t('Vega community referrals')}
|
||||
</h1>
|
||||
<p className="text-lg mb-1">
|
||||
{t(
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { DocsLinks } from '@vegaprotocol/environment';
|
||||
import { ExternalLink, Tooltip } from '@vegaprotocol/ui-toolkit';
|
||||
import { useT } from '../../lib/use-t';
|
||||
|
||||
export const QUSDTooltip = () => {
|
||||
const t = useT();
|
||||
return (
|
||||
<Tooltip
|
||||
description={
|
||||
<>
|
||||
<p className="mb-1">
|
||||
{t(
|
||||
'qUSD provides a rough USD equivalent of balances across all assets using the value of "Quantum" for that asset'
|
||||
)}
|
||||
</p>
|
||||
{DocsLinks && (
|
||||
<ExternalLink href={DocsLinks.QUANTUM}>
|
||||
{t('Find out more')}
|
||||
</ExternalLink>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
underline={true}
|
||||
>
|
||||
<span>{t('qUSD')}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
VegaIcon,
|
||||
VegaIconNames,
|
||||
truncateMiddle,
|
||||
ExternalLink,
|
||||
Tooltip,
|
||||
} from '@vegaprotocol/ui-toolkit';
|
||||
|
||||
import { useVegaWallet } from '@vegaprotocol/wallet';
|
||||
@@ -28,10 +26,10 @@ import sortBy from 'lodash/sortBy';
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCurrentEpochInfoQuery } from './hooks/__generated__/Epoch';
|
||||
import BigNumber from 'bignumber.js';
|
||||
import { DocsLinks } from '@vegaprotocol/environment';
|
||||
import { useT, ns } from '../../lib/use-t';
|
||||
import { Trans } from 'react-i18next';
|
||||
import { ApplyCodeForm, ApplyCodeFormContainer } from './apply-code-form';
|
||||
import { QUSDTooltip } from './qusd-tooltip';
|
||||
|
||||
export const ReferralStatistics = () => {
|
||||
const { pubKey } = useVegaWallet();
|
||||
@@ -121,7 +119,7 @@ export const useStats = ({
|
||||
: 1;
|
||||
const finalCommissionValue = isNaN(multiplier)
|
||||
? baseCommissionValue
|
||||
: multiplier * baseCommissionValue;
|
||||
: new BigNumber(multiplier).times(baseCommissionValue).toNumber();
|
||||
|
||||
const discountFactorValue = refereeStats?.discountFactor
|
||||
? Number(refereeStats.discountFactor)
|
||||
@@ -214,6 +212,7 @@ export const Statistics = ({
|
||||
).toString(),
|
||||
}
|
||||
)}
|
||||
testId="base-commission-rate"
|
||||
overrideWithNoProgram={!details}
|
||||
>
|
||||
{baseCommissionValue * 100}%
|
||||
@@ -223,6 +222,7 @@ export const Statistics = ({
|
||||
const stakingMultiplierTile = (
|
||||
<StatTile
|
||||
title={t('Staking multiplier')}
|
||||
testId="staking-multiplier"
|
||||
description={
|
||||
<span
|
||||
className={classNames({
|
||||
@@ -242,24 +242,31 @@ export const Statistics = ({
|
||||
{multiplier || t('None')}
|
||||
</StatTile>
|
||||
);
|
||||
const baseCommissionFormatted = BigNumber(baseCommissionValue)
|
||||
.times(100)
|
||||
.toString();
|
||||
const finalCommissionFormatted = new BigNumber(finalCommissionValue)
|
||||
.times(100)
|
||||
.toString();
|
||||
const finalCommissionTile = (
|
||||
<StatTile
|
||||
title={t('Final commission rate')}
|
||||
description={
|
||||
!isNaN(multiplier)
|
||||
? `(${baseCommissionValue * 100}% ⨉ ${multiplier} = ${
|
||||
finalCommissionValue * 100
|
||||
}%)`
|
||||
? `(${baseCommissionFormatted}% ⨉ ${multiplier} = ${finalCommissionFormatted}%)`
|
||||
: undefined
|
||||
}
|
||||
testId="final-commission-rate"
|
||||
overrideWithNoProgram={!details}
|
||||
>
|
||||
{finalCommissionValue * 100}%
|
||||
{finalCommissionFormatted}%
|
||||
</StatTile>
|
||||
);
|
||||
const numberOfTradersValue = data.referees.length;
|
||||
const numberOfTradersTile = (
|
||||
<StatTile title={t('Number of traders')}>{numberOfTradersValue}</StatTile>
|
||||
<StatTile title={t('Number of traders')} testId="number-of-traders">
|
||||
{numberOfTradersValue}
|
||||
</StatTile>
|
||||
);
|
||||
|
||||
const codeTile = (
|
||||
@@ -274,6 +281,7 @@ export const Statistics = ({
|
||||
title={t('myVolume', 'My volume (last {{count}} epochs)', {
|
||||
count: details?.windowLength || DEFAULT_AGGREGATION_DAYS,
|
||||
})}
|
||||
testId="my-volume"
|
||||
overrideWithNoProgram={!details}
|
||||
>
|
||||
{compactNumFormat.format(referrerVolumeValue)}
|
||||
@@ -289,6 +297,7 @@ export const Statistics = ({
|
||||
count: details?.windowLength || DEFAULT_AGGREGATION_DAYS,
|
||||
})}
|
||||
description={<QUSDTooltip />}
|
||||
testId="total-commission"
|
||||
>
|
||||
{getNumberFormat(0).format(Number(totalCommissionValue))}
|
||||
</StatTile>
|
||||
@@ -314,6 +323,7 @@ export const Statistics = ({
|
||||
const currentBenefitTierTile = (
|
||||
<StatTile
|
||||
title={t('Current tier')}
|
||||
testId="current-tier"
|
||||
description={
|
||||
nextBenefitTierValue?.tier
|
||||
? t('(Next tier: {{nextTier}})', {
|
||||
@@ -329,7 +339,11 @@ export const Statistics = ({
|
||||
</StatTile>
|
||||
);
|
||||
const discountFactorTile = (
|
||||
<StatTile title={t('Discount')} overrideWithNoProgram={!details}>
|
||||
<StatTile
|
||||
title={t('Discount')}
|
||||
testId="discount"
|
||||
overrideWithNoProgram={!details}
|
||||
>
|
||||
{isApplyCodePreview && benefitTiers.length >= 1
|
||||
? benefitTiers[0].discountFactor * 100
|
||||
: discountFactorValue * 100}
|
||||
@@ -345,23 +359,34 @@ export const Statistics = ({
|
||||
count: details?.windowLength,
|
||||
}
|
||||
)}
|
||||
testId="combined-volume"
|
||||
overrideWithNoProgram={!details}
|
||||
>
|
||||
{compactNumFormat.format(runningVolumeValue)}
|
||||
</StatTile>
|
||||
);
|
||||
const epochsTile = (
|
||||
<StatTile title={t('Epochs in set')}>{epochsValue}</StatTile>
|
||||
<StatTile title={t('Epochs in set')} testId="epochs-in-set">
|
||||
{epochsValue}
|
||||
</StatTile>
|
||||
);
|
||||
const nextTierVolumeTile = (
|
||||
<StatTile title={t('Volume to next tier')} overrideWithNoProgram={!details}>
|
||||
<StatTile
|
||||
title={t('Volume to next tier')}
|
||||
testId="vol-to-next-tier"
|
||||
overrideWithNoProgram={!details}
|
||||
>
|
||||
{nextBenefitTierVolumeValue <= 0
|
||||
? '0'
|
||||
: compactNumFormat.format(nextBenefitTierVolumeValue)}
|
||||
</StatTile>
|
||||
);
|
||||
const nextTierEpochsTile = (
|
||||
<StatTile title={t('Epochs to next tier')} overrideWithNoProgram={!details}>
|
||||
<StatTile
|
||||
title={t('Epochs to next tier')}
|
||||
testId="epochs-to-next-tier"
|
||||
overrideWithNoProgram={!details}
|
||||
>
|
||||
{nextBenefitTierEpochsValue <= 0 ? '0' : nextBenefitTierEpochsValue}
|
||||
</StatTile>
|
||||
);
|
||||
@@ -515,28 +540,3 @@ export const RefereesTable = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const QUSDTooltip = () => {
|
||||
const t = useT();
|
||||
return (
|
||||
<Tooltip
|
||||
description={
|
||||
<>
|
||||
<p className="mb-1">
|
||||
{t(
|
||||
'qUSD provides a rough USD equivalent of balances across all assets using the value of "Quantum" for that asset'
|
||||
)}
|
||||
</p>
|
||||
{DocsLinks && (
|
||||
<ExternalLink href={DocsLinks.QUANTUM}>
|
||||
{t('Find out more')}
|
||||
</ExternalLink>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
underline={true}
|
||||
>
|
||||
<span>{t('qUSD')}</span>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -159,11 +159,11 @@ export const TiersContainer = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="text-3xl mt-10">{t('Current Program Details')}</h2>
|
||||
<h2 className="text-3xl mt-10">{t('Current program details')}</h2>
|
||||
{details?.id && (
|
||||
<p>
|
||||
<Trans
|
||||
defaults="As a result of <0>{{proposal}}</0> the program below is currently active on the Vega network."
|
||||
defaults="As a result of governance proposal <0>{{proposal}}</0> the program below is currently active on the Vega network."
|
||||
values={{ proposal: truncateMiddle(details.id) }}
|
||||
components={[
|
||||
<ExternalLink
|
||||
|
||||
@@ -32,6 +32,7 @@ export const Tile = ({
|
||||
|
||||
type StatTileProps = {
|
||||
title: string;
|
||||
testId?: string;
|
||||
description?: ReactNode;
|
||||
children?: ReactNode;
|
||||
overrideWithNoProgram?: boolean;
|
||||
@@ -40,6 +41,7 @@ export const StatTile = ({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
testId,
|
||||
overrideWithNoProgram = false,
|
||||
}: StatTileProps) => {
|
||||
if (overrideWithNoProgram) {
|
||||
@@ -47,10 +49,15 @@ export const StatTile = ({
|
||||
}
|
||||
return (
|
||||
<Tile>
|
||||
<h3 className="mb-1 text-sm text-vega-clight-100 dark:text-vega-cdark-100 calt">
|
||||
<h3
|
||||
data-testid={testId}
|
||||
className="mb-1 text-sm text-vega-clight-100 dark:text-vega-cdark-100 calt"
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
<div className="text-5xl text-left">{children}</div>
|
||||
<div data-testid={`${testId}-value`} className="text-5xl text-left">
|
||||
{children}
|
||||
</div>
|
||||
{description && (
|
||||
<div className="text-sm text-left text-vega-clight-100 dark:text-vega-cdark-100">
|
||||
{description}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { positionsDataProvider } from '@vegaprotocol/positions';
|
||||
import { useGlobalStore } from '../../stores';
|
||||
|
||||
const ONBOARDING_STORAGE_KEY = 'vega_onboarding';
|
||||
|
||||
export const useOnboardingStore = create<{
|
||||
dialogOpen: boolean;
|
||||
walletDialogOpen: boolean;
|
||||
@@ -20,7 +21,7 @@ export const useOnboardingStore = create<{
|
||||
}>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
dialogOpen: true,
|
||||
dialogOpen: false,
|
||||
walletDialogOpen: false,
|
||||
dismissed: false,
|
||||
dismiss: () => set({ dismissed: true }),
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { useEffect } from 'react';
|
||||
import { matchPath, useLocation } from 'react-router-dom';
|
||||
import { Dialog, Intent } from '@vegaprotocol/ui-toolkit';
|
||||
import { useEnvironment } from '@vegaprotocol/environment';
|
||||
import { WelcomeDialogContent } from './welcome-dialog-content';
|
||||
import { useOnboardingStore } from './use-get-onboarding-step';
|
||||
import { VegaConnectDialog } from '@vegaprotocol/wallet';
|
||||
import { Connectors } from '../../lib/vega-connectors';
|
||||
import { RiskMessage } from './risk-message';
|
||||
import { useT } from '../../lib/use-t';
|
||||
import { Routes } from '../../lib/links';
|
||||
import { RiskMessage } from './risk-message';
|
||||
import { WelcomeDialogContent } from './welcome-dialog-content';
|
||||
import { useOnboardingStore } from './use-get-onboarding-step';
|
||||
import { ensureSuffix } from '@vegaprotocol/utils';
|
||||
|
||||
/**
|
||||
* A list of paths on which the welcome dialog should be omitted.
|
||||
*/
|
||||
const OMIT_ON_LIST = [ensureSuffix(Routes.REFERRALS, '/*')];
|
||||
|
||||
export const WelcomeDialog = () => {
|
||||
const { pathname } = useLocation();
|
||||
const t = useT();
|
||||
const { VEGA_ENV } = useEnvironment();
|
||||
const dismissed = useOnboardingStore((store) => store.dismissed);
|
||||
const dialogOpen = useOnboardingStore((store) => store.dialogOpen);
|
||||
const dismiss = useOnboardingStore((store) => store.dismiss);
|
||||
const setDialogOpen = useOnboardingStore((store) => store.setDialogOpen);
|
||||
const walletDialogOpen = useOnboardingStore(
|
||||
(store) => store.walletDialogOpen
|
||||
);
|
||||
@@ -20,6 +31,16 @@ export const WelcomeDialog = () => {
|
||||
(store) => store.setWalletDialogOpen
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const shouldOmit = OMIT_ON_LIST.map((path) =>
|
||||
matchPath(path, pathname)
|
||||
).some((m) => !!m);
|
||||
|
||||
if (dismissed || shouldOmit) return;
|
||||
|
||||
setDialogOpen(true);
|
||||
}, [dismissed, pathname, setDialogOpen]);
|
||||
|
||||
const content = walletDialogOpen ? (
|
||||
<VegaConnectDialog
|
||||
connectors={Connectors}
|
||||
@@ -31,7 +52,12 @@ export const WelcomeDialog = () => {
|
||||
<WelcomeDialogContent />
|
||||
);
|
||||
|
||||
const onClose = walletDialogOpen ? () => setWalletDialogOpen(false) : dismiss;
|
||||
const onClose = walletDialogOpen
|
||||
? () => setWalletDialogOpen(false)
|
||||
: () => {
|
||||
setDialogOpen(false);
|
||||
dismiss();
|
||||
};
|
||||
|
||||
const title = walletDialogOpen ? null : (
|
||||
<span className="font-alpha calt" data-testid="welcome-title">
|
||||
|
||||
@@ -6,23 +6,27 @@ from typing import Optional
|
||||
WalletConfig = namedtuple("WalletConfig", ["name", "passphrase"])
|
||||
ASSET_NAME = "tDAI"
|
||||
|
||||
|
||||
def wait_for_toast_confirmation(page: Page, timeout: int = 30000):
|
||||
page.wait_for_function("""
|
||||
document.querySelector('[data-testid="toast-content"]') &&
|
||||
document.querySelector('[data-testid="toast-content"]').innerText.includes('AWAITING CONFIRMATION')
|
||||
""", timeout=timeout)
|
||||
|
||||
|
||||
def create_and_faucet_wallet(
|
||||
vega: VegaServiceNull,
|
||||
wallet: WalletConfig,
|
||||
symbol: Optional[str] = None,
|
||||
amount: float = 1e4,
|
||||
|
||||
|
||||
):
|
||||
asset_id = vega.find_asset_id(symbol=symbol if symbol is not None else ASSET_NAME)
|
||||
asset_id = vega.find_asset_id(
|
||||
symbol=symbol if symbol is not None else ASSET_NAME)
|
||||
vega.create_key(wallet.name)
|
||||
vega.mint(wallet.name, asset_id, amount)
|
||||
|
||||
|
||||
def next_epoch(vega: VegaServiceNull):
|
||||
forwards = 0
|
||||
epoch_seq = vega.statistics().epoch_seq
|
||||
@@ -36,13 +40,34 @@ def next_epoch(vega: VegaServiceNull):
|
||||
vega.wait_fn(1)
|
||||
vega.wait_for_total_catchup()
|
||||
|
||||
|
||||
def truncate_middle(market_id, start=6, end=4):
|
||||
if len(market_id) < 11:
|
||||
return market_id
|
||||
return market_id[:start] + '\u2026' + market_id[-end:]
|
||||
|
||||
def change_keys(page: Page, vega:VegaServiceNull, key_name):
|
||||
|
||||
def change_keys(page: Page, vega: VegaServiceNull, key_name):
|
||||
page.get_by_test_id("manage-vega-wallet").click()
|
||||
page.get_by_test_id("key-" + vega.wallet.public_key(key_name)).click()
|
||||
page.click(f'data-testid=key-{vega.wallet.public_key(key_name)} >> .inline-flex')
|
||||
page.click(
|
||||
f'data-testid=key-{vega.wallet.public_key(key_name)} >> .inline-flex')
|
||||
page.reload()
|
||||
|
||||
|
||||
def forward_time(vega: VegaServiceNull, forward_epoch: bool = False):
|
||||
vega.wait_fn(1)
|
||||
vega.wait_for_total_catchup()
|
||||
|
||||
if forward_epoch:
|
||||
next_epoch(vega)
|
||||
|
||||
|
||||
# This is for when the element will initially load but contain an outdated value. It will wait for the element to contain the expected text, returning False after a timeout or exception
|
||||
def selector_contains_text(page: Page, selector, expected_text, timeout=5000):
|
||||
try:
|
||||
page.wait_for_selector(
|
||||
f'{selector} >> text={expected_text}', timeout=timeout)
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from typing import List, Tuple, Optional
|
||||
from vega_sim.service import VegaService, PeggedOrder
|
||||
|
||||
|
||||
def submit_order(
|
||||
vega: VegaService,
|
||||
wallet_name: str,
|
||||
@@ -35,7 +36,7 @@ def submit_multiple_orders(
|
||||
submit_order(vega, wallet_name, market_id, side, volume, price)
|
||||
|
||||
|
||||
def submit_liquidity(vega: VegaService, wallet_name: str, market_id: str):
|
||||
def submit_liquidity(vega: VegaService, wallet_name: str, market_id: str, buy_vol=99, sell_vol=99, custom_price=None):
|
||||
vega.submit_simple_liquidity(
|
||||
key_name=wallet_name,
|
||||
market_id=market_id,
|
||||
@@ -51,7 +52,7 @@ def submit_liquidity(vega: VegaService, wallet_name: str, market_id: str):
|
||||
pegged_order=PeggedOrder(reference="PEGGED_REFERENCE_MID", offset=1),
|
||||
wait=False,
|
||||
time_in_force="TIME_IN_FORCE_GTC",
|
||||
volume=99,
|
||||
volume=buy_vol,
|
||||
)
|
||||
vega.submit_order(
|
||||
market_id=market_id,
|
||||
@@ -61,5 +62,5 @@ def submit_liquidity(vega: VegaService, wallet_name: str, market_id: str):
|
||||
pegged_order=PeggedOrder(reference="PEGGED_REFERENCE_MID", offset=1),
|
||||
wait=False,
|
||||
time_in_force="TIME_IN_FORCE_GTC",
|
||||
volume=99,
|
||||
)
|
||||
volume=sell_vol,
|
||||
)
|
||||
|
||||
@@ -8,12 +8,17 @@ logger = logging.getLogger()
|
||||
mint_amount: float = 10e5
|
||||
market_name = "BTC:DAI_2023"
|
||||
|
||||
default_sell_orders = [[1, 110], [1, 105]]
|
||||
default_buy_orders = [[1, 90], [1, 95]]
|
||||
|
||||
|
||||
def setup_simple_market(
|
||||
vega: VegaService,
|
||||
approve_proposal=True,
|
||||
custom_market_name=market_name,
|
||||
custom_asset_name="tDAI",
|
||||
custom_asset_symbol="tDAI",
|
||||
custom_quantum=1
|
||||
):
|
||||
for wallet in wallets:
|
||||
vega.create_key(wallet.name)
|
||||
@@ -37,6 +42,7 @@ def setup_simple_market(
|
||||
symbol=custom_asset_symbol,
|
||||
decimals=5,
|
||||
max_faucet_amount=1e10,
|
||||
quantum=custom_quantum,
|
||||
)
|
||||
vega.wait_fn(1)
|
||||
vega.wait_for_total_catchup()
|
||||
@@ -111,16 +117,17 @@ def setup_simple_successor_market(
|
||||
return market_id
|
||||
|
||||
|
||||
def setup_opening_auction_market(vega: VegaService, market_id: str = None, **kwargs):
|
||||
if market_id is None or market_id not in vega.all_markets():
|
||||
def setup_opening_auction_market(vega: VegaService, market_id: str = None, buy_orders=default_buy_orders, sell_orders=default_sell_orders, add_liquidity=True, **kwargs):
|
||||
if not market_exists(vega, market_id):
|
||||
market_id = setup_simple_market(vega, **kwargs)
|
||||
|
||||
submit_liquidity(vega, MM_WALLET.name, market_id)
|
||||
if add_liquidity:
|
||||
submit_liquidity(vega, MM_WALLET.name, market_id)
|
||||
submit_multiple_orders(
|
||||
vega, MM_WALLET.name, market_id, "SIDE_SELL", [[1, 110], [1, 105]]
|
||||
vega, MM_WALLET.name, market_id, "SIDE_SELL", sell_orders
|
||||
)
|
||||
submit_multiple_orders(
|
||||
vega, MM_WALLET2.name, market_id, "SIDE_BUY", [[1, 90], [1, 95]]
|
||||
vega, MM_WALLET2.name, market_id, "SIDE_BUY", buy_orders
|
||||
)
|
||||
|
||||
vega.forward("10s")
|
||||
@@ -130,11 +137,22 @@ def setup_opening_auction_market(vega: VegaService, market_id: str = None, **kwa
|
||||
return market_id
|
||||
|
||||
|
||||
def setup_continuous_market(vega: VegaService, market_id: str = None, **kwargs):
|
||||
if market_id is None or market_id not in vega.all_markets():
|
||||
market_id = setup_opening_auction_market(vega, **kwargs)
|
||||
def market_exists(vega: VegaService, market_id: str):
|
||||
if market_id is None:
|
||||
return False
|
||||
all_markets = vega.all_markets()
|
||||
market_ids = [market.id for market in all_markets]
|
||||
return market_id in market_ids
|
||||
|
||||
submit_order(vega, "Key 1", market_id, "SIDE_BUY", 1, 110)
|
||||
|
||||
# Add sell orders and buy orders to put on the book
|
||||
def setup_continuous_market(vega: VegaService, market_id: str = None, buy_orders=default_buy_orders, sell_orders=default_sell_orders, add_liquidity=True, **kwargs):
|
||||
if not market_exists(vega, market_id) or buy_orders != default_buy_orders or sell_orders != default_sell_orders:
|
||||
market_id = setup_opening_auction_market(
|
||||
vega, market_id, buy_orders, sell_orders, add_liquidity, **kwargs)
|
||||
|
||||
submit_order(vega, "Key 1", market_id, "SIDE_BUY",
|
||||
sell_orders[0][0], sell_orders[0][1])
|
||||
|
||||
vega.forward("10s")
|
||||
vega.wait_fn(1)
|
||||
@@ -142,6 +160,7 @@ def setup_continuous_market(vega: VegaService, market_id: str = None, **kwargs):
|
||||
|
||||
return market_id
|
||||
|
||||
|
||||
def setup_perps_market(
|
||||
vega: VegaService,
|
||||
custom_asset_name="tDAI",
|
||||
@@ -210,7 +229,7 @@ def setup_perps_market(
|
||||
settlement_data_key=TERMINATE_WALLET.name,
|
||||
funding_payment_frequency_in_seconds=10,
|
||||
market_decimals=5,
|
||||
)
|
||||
)
|
||||
vega.wait_for_total_catchup()
|
||||
|
||||
submit_liquidity(vega, MM_WALLET.name, market_id)
|
||||
@@ -225,4 +244,4 @@ def setup_perps_market(
|
||||
vega.wait_fn(1)
|
||||
vega.wait_for_total_catchup()
|
||||
|
||||
return market_id
|
||||
return market_id
|
||||
|
||||
@@ -59,7 +59,7 @@ def test_market_selector_filter(continuous_market, page: Page):
|
||||
page.get_by_test_id("search-term").fill("btc")
|
||||
expect(page.locator('[data-testid="market-selector-list"] a')).to_have_count(1)
|
||||
expect(page.locator('[data-testid="market-selector-list"] a').nth(0)).to_have_text(
|
||||
"BTC:DAI_2023107.50 tDAI1"
|
||||
"BTC:DAI_2023107.50 tDAI0.00"
|
||||
)
|
||||
|
||||
page.get_by_test_id("search-term").clear()
|
||||
@@ -84,5 +84,5 @@ def test_market_selector_filter(continuous_market, page: Page):
|
||||
page.get_by_role("menuitemcheckbox").nth(0).get_by_text("tDAI").click()
|
||||
expect(page.locator('[data-testid="market-selector-list"] a')).to_have_count(1)
|
||||
expect(page.locator('[data-testid="market-selector-list"] a').nth(0)).to_have_text(
|
||||
"BTC:DAI_2023107.50 tDAI1"
|
||||
"BTC:DAI_2023107.50 tDAI0.00"
|
||||
)
|
||||
|
||||
@@ -137,6 +137,6 @@ def test_perps_market_terminated(page: Page, vega: VegaService):
|
||||
expect(page.get_by_test_id("market-trading-mode")).to_have_text("Trading modeNo trading")
|
||||
expect(page.get_by_test_id("market-state")).to_have_text("StatusClosed")
|
||||
expect(page.get_by_test_id("liquidity-supplied")).to_have_text("Liquidity supplied 0.00 (0.00%)")
|
||||
expect(page.get_by_test_id("market-funding")).to_have_text("Funding Rate / Countdown-Unknown")
|
||||
expect(page.get_by_test_id("index-price")).to_have_text("Index Price-")
|
||||
expect(page.get_by_test_id("market-funding")).to_contain_text("Funding Rate / Countdown")
|
||||
expect(page.get_by_test_id("index-price")).to_contain_text("Index Price")
|
||||
expect(page.get_by_test_id("deal-ticket-error-message-summary")).to_have_text("This market is closed and not accepting orders")
|
||||
@@ -0,0 +1,175 @@
|
||||
import pytest
|
||||
from playwright.sync_api import Page, expect
|
||||
from vega_sim.service import VegaService
|
||||
from conftest import init_vega
|
||||
from fixtures.market import setup_continuous_market, setup_simple_market
|
||||
from actions.utils import change_keys, create_and_faucet_wallet, forward_time, selector_contains_text
|
||||
from actions.vega import submit_order, submit_liquidity
|
||||
from wallet_config import MM_WALLET, PARTY_A, PARTY_B
|
||||
|
||||
SELL_ORDERS = [[1, 111], [1, 111], [1, 112], [1, 112], [
|
||||
1, 113], [1, 113], [1, 114], [1, 114], [1, 115], [1, 115]]
|
||||
BUY_ORDERS = [[1, 106], [1, 107], [1, 108]]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def vega(request):
|
||||
with init_vega(request) as vega:
|
||||
yield vega
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def continuous_market(vega):
|
||||
market = setup_simple_market(vega, custom_quantum=100000)
|
||||
return setup_continuous_market(vega, market, BUY_ORDERS, SELL_ORDERS, add_liquidity=False)
|
||||
|
||||
|
||||
def generate_referrer_expected_value_dic(expected_base_commission, expected_staking_multiplier, expected_final_commission_rate, expected_volume, expected_num_traders, expected_total_commission):
|
||||
return {
|
||||
'[data-testid=my-volume-value]': expected_volume,
|
||||
'[data-testid=total-commission-value]': expected_total_commission,
|
||||
'[data-testid=base-commission-rate-value]': expected_base_commission,
|
||||
'[data-testid=number-of-traders-value]': expected_num_traders,
|
||||
'[data-testid=final-commission-rate-value]': expected_final_commission_rate,
|
||||
'[data-testid=staking-multiplier-value]': expected_staking_multiplier
|
||||
}
|
||||
|
||||
|
||||
def generate_referral_expected_value_dic(expected_volume, expected_tier, expected_discount, expected_epochs, expected_epochs_to_next_tier):
|
||||
return {
|
||||
'[data-testid=combined-volume-value]': expected_volume,
|
||||
'[data-testid=current-tier-value]': expected_tier,
|
||||
'[data-testid=discount-value]': expected_discount,
|
||||
'[data-testid=epochs-in-set-value]': expected_epochs,
|
||||
'[data-testid=epochs-to-next-tier-value]': expected_epochs_to_next_tier
|
||||
}
|
||||
|
||||
|
||||
def check_tile_values(page: Page, expected_results: dict):
|
||||
if "referrals" in page.url:
|
||||
page.reload()
|
||||
else:
|
||||
page.goto("/#/referrals/")
|
||||
|
||||
for selector, expected_text in expected_results.items():
|
||||
assert selector_contains_text(
|
||||
page, selector, expected_text), f"Expected text '{expected_text}' not found in selector '{selector}'"
|
||||
|
||||
|
||||
def create_benefit_tier(minimum_running_notional_taker_volume, minimum_epochs, referral_reward_factor, referral_discount_factor):
|
||||
return {
|
||||
"minimum_running_notional_taker_volume": minimum_running_notional_taker_volume,
|
||||
"minimum_epochs": minimum_epochs,
|
||||
"referral_reward_factor": referral_reward_factor,
|
||||
"referral_discount_factor": referral_discount_factor,
|
||||
}
|
||||
|
||||
|
||||
def create_staking_tier(minimum_staked_tokens, referral_reward_multiplier):
|
||||
return {
|
||||
"minimum_staked_tokens": minimum_staked_tokens,
|
||||
"referral_reward_multiplier": referral_reward_multiplier,
|
||||
}
|
||||
|
||||
|
||||
def setup_market_and_referral_scheme(vega: VegaService, continuous_market: str, page: Page):
|
||||
page.goto(f"/#/markets/{continuous_market}")
|
||||
|
||||
create_and_faucet_wallet(vega=vega, wallet=PARTY_A)
|
||||
create_and_faucet_wallet(vega=vega, wallet=PARTY_B)
|
||||
forward_time(vega)
|
||||
|
||||
benefit_tiers = []
|
||||
staking_tiers = []
|
||||
for i in range(1, 4):
|
||||
benefit_tiers.append(create_benefit_tier(
|
||||
i * 100, i, i * 0.01, i * 0.01))
|
||||
staking_tiers.append(create_staking_tier(
|
||||
i * 100, i))
|
||||
|
||||
vega.update_referral_program(
|
||||
proposal_key=MM_WALLET.name,
|
||||
benefit_tiers=benefit_tiers,
|
||||
staking_tiers=staking_tiers,
|
||||
window_length=1,
|
||||
)
|
||||
forward_time(vega, True)
|
||||
|
||||
vega.create_referral_set(key_name=PARTY_A.name)
|
||||
forward_time(vega, True)
|
||||
|
||||
referral_set_id = list(vega.list_referral_sets().keys())[0]
|
||||
vega.apply_referral_code(key_name=PARTY_B.name, id=referral_set_id)
|
||||
|
||||
tdai_id = vega.find_asset_id(symbol="tDAI")
|
||||
vega.mint(
|
||||
"Key 1",
|
||||
asset=tdai_id,
|
||||
amount=10e6,
|
||||
)
|
||||
vega.mint(
|
||||
PARTY_B.name,
|
||||
asset=tdai_id,
|
||||
amount=10e6,
|
||||
)
|
||||
|
||||
submit_liquidity(vega, MM_WALLET.name, continuous_market, 100, 100)
|
||||
forward_time(vega)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("page", "auth", "risk_accepted")
|
||||
def test_can_traverse_up_and_down_through_tiers(continuous_market, vega: VegaService, page: Page):
|
||||
setup_market_and_referral_scheme(vega, continuous_market, page)
|
||||
change_keys(page, vega, PARTY_B.name)
|
||||
submit_order(vega, PARTY_B.name, continuous_market, "SIDE_BUY", 1, 115)
|
||||
forward_time(vega, True)
|
||||
check_tile_values(page, generate_referral_expected_value_dic(
|
||||
"110", "1", "1%", "1", "1"))
|
||||
|
||||
change_keys(page, vega, PARTY_A.name)
|
||||
check_tile_values(page, generate_referrer_expected_value_dic(
|
||||
"1%", "1", "1%", "0", "1", "0"))
|
||||
|
||||
change_keys(page, vega, PARTY_B.name)
|
||||
submit_order(vega, PARTY_B.name, continuous_market, "SIDE_BUY", 2, 115)
|
||||
forward_time(vega, True)
|
||||
check_tile_values(page, generate_referral_expected_value_dic(
|
||||
"221", "2", "2%", "2", "1"))
|
||||
|
||||
change_keys(page, vega, PARTY_A.name)
|
||||
check_tile_values(page, generate_referrer_expected_value_dic(
|
||||
"2%", "1", "2%", "0", "1", "0"))
|
||||
|
||||
change_keys(page, vega, PARTY_B.name)
|
||||
submit_order(vega, PARTY_B.name, continuous_market, "SIDE_BUY", 3, 115)
|
||||
forward_time(vega, True)
|
||||
check_tile_values(page, generate_referral_expected_value_dic(
|
||||
"331", "3", "3%", "3", "0"))
|
||||
|
||||
change_keys(page, vega, PARTY_A.name)
|
||||
check_tile_values(page, generate_referrer_expected_value_dic(
|
||||
"3%", "1", "3%", "0", "1", "1"))
|
||||
|
||||
change_keys(page, vega, PARTY_B.name)
|
||||
submit_order(vega, PARTY_B.name, continuous_market, "SIDE_BUY", 1, 115)
|
||||
forward_time(vega, True)
|
||||
check_tile_values(page, generate_referral_expected_value_dic(
|
||||
"110", "1", "1%", "4", "0"))
|
||||
|
||||
change_keys(page, vega, PARTY_A.name)
|
||||
check_tile_values(page, generate_referrer_expected_value_dic(
|
||||
"1%", "1", "1%", "0", "1", "1"))
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("page", "auth", "risk_accepted")
|
||||
def test_does_not_move_up_tiers_when_not_enough_epochs(continuous_market, vega: VegaService, page: Page):
|
||||
setup_market_and_referral_scheme(vega, continuous_market, page)
|
||||
change_keys(page, vega, PARTY_B.name)
|
||||
submit_order(vega, PARTY_B.name, continuous_market, "SIDE_BUY", 2, 115)
|
||||
forward_time(vega, True)
|
||||
check_tile_values(page, generate_referral_expected_value_dic(
|
||||
"221", "1", "1%", "1", "1"))
|
||||
|
||||
change_keys(page, vega, PARTY_A.name)
|
||||
check_tile_values(page, generate_referrer_expected_value_dic(
|
||||
"2%", "1", "2%", "0", "1", "0"))
|
||||
@@ -7,6 +7,9 @@ WalletConfig = namedtuple("WalletConfig", ["name", "passphrase"])
|
||||
MM_WALLET = WalletConfig("market_maker", "pin")
|
||||
MM_WALLET2 = WalletConfig("market_maker_2", "pin2")
|
||||
TERMINATE_WALLET = WalletConfig("FJMKnwfZdd48C8NqvYrG", "bY3DxwtsCstMIIZdNpKs")
|
||||
GOVERNANCE_WALLET = WalletConfig("FJMKnwfZdd48C8NqvYrG", "bY3DxwtsCstMIIZdNpKs")
|
||||
GOVERNANCE_WALLET = WalletConfig(
|
||||
"FJMKnwfZdd48C8NqvYrG", "bY3DxwtsCstMIIZdNpKs")
|
||||
PARTY_A = WalletConfig("party_a", "party_a")
|
||||
PARTY_B = WalletConfig("party_b", "party_b")
|
||||
|
||||
wallets = [MM_WALLET, MM_WALLET2, TERMINATE_WALLET, GOVERNANCE_WALLET]
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"This timestamp is user curated metadata and does not drive any on-chain functionality.": "This timestamp is user curated metadata and does not drive any on-chain functionality.",
|
||||
"Tier": "Tier",
|
||||
"to": "to",
|
||||
"To protect the network from spam, you must have at least {{requiredFunds}} qUSD of any asset on the network to proceed.": "To protect the network from spam, you must have at least {{requiredFunds}} qUSD of any asset on the network to proceed.",
|
||||
"Toast location": "Toast location",
|
||||
"Total discount": "Total discount",
|
||||
"Total distributed": "Total distributed",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
shorten,
|
||||
titlefy,
|
||||
stripFullStops,
|
||||
ensureSuffix,
|
||||
} from './strings';
|
||||
|
||||
describe('truncateByChars', () => {
|
||||
@@ -88,3 +89,15 @@ describe('stripFullStops', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureSuffix', () => {
|
||||
it.each([
|
||||
['', 'abc', 'abc'],
|
||||
['abc', '', 'abc'],
|
||||
['def', 'abc', 'abcdef'],
|
||||
['ąę', 'ae', 'aeąę'],
|
||||
['🥪', '🍞+🔪=', '🍞+🔪=🥪'],
|
||||
])('ensures "%s" at the end of "%s": "%s"', (suffix, input, expected) => {
|
||||
expect(ensureSuffix(input, suffix)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,3 +33,9 @@ export function titlefy(words: (string | null | undefined)[]) {
|
||||
export function stripFullStops(input: string) {
|
||||
return input.replace(/\./g, '');
|
||||
}
|
||||
|
||||
export function ensureSuffix(input: string, suffix: string) {
|
||||
const maybeSuffix = input.substring(input.length - suffix.length);
|
||||
if (maybeSuffix === suffix) return input;
|
||||
return input + suffix;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user