Compare commits

...
Author SHA1 Message Date
Matthew Russell eceb608318 chore: show balances in accounts and vesting accounts in breakdown 2023-11-01 15:24:51 -07:00
Matthew Russell 395f779c18 chore: add vesting accounts to data provider so they show in breakdown 2023-11-01 15:16:40 -07:00
Matthew Russell 3ddaf28b2c fix: transfer container not rendering if not connected 2023-11-01 14:06:49 -07:00
Matthew Russell f75a53c11a chore: remove quotes from from data node retention message 2023-11-01 13:44:39 -07:00
Matthew Russell 1c4975c619 chore: remove pagination from trades table 2023-11-01 13:42:42 -07:00
Matthew Russell 20233db706 fix(deal-ticket): fees breakdown rendering issues (#5173) 2023-11-01 13:32:51 -07:00
m.ray d4f50eb70c fix(trading): pagination limit for candles (#5169) 2023-11-01 12:21:36 -07:00
Matthew Russell e5d3f90d45 feat(trading): transfer to and from accounts on same key (#5161) 2023-11-01 11:11:17 +00:00
Bartłomiej Głownia 673c896e2f feat(deal-ticket): get discounts for estimation from previous epoch (#5163) 2023-11-01 10:09:56 +00:00
Matthew Russell deea63fa5e fix(trading): empty items loaded text (#5158) 2023-11-01 10:08:16 +00:00
Matthew Russell d31333538b chore(trading): ignore .next from lint (#5162) 2023-11-01 10:06:53 +00:00
Matthew Russell 4af9979a21 chore(trading): referrals stats tweaks (#5160) 2023-11-01 10:06:36 +00:00
m.ray 6421cf87c6 feat(trading): liquidity provision current and pending on update (#5152) 2023-10-31 11:24:18 +00:00
Matthew Russell 0ebfab64ff fix(ui-toolkit): sparkline appearing to go up when the price moved down (#5137) 2023-10-31 11:20:48 +00:00
Edd 142f08343b feat(governance): add update benefit tiers table (#5146) 2023-10-31 10:20:38 +00:00
m.ray 61aa45a9ed feat(trading): show fees paid across whole market (#5154) 2023-10-30 18:16:49 -07:00
Bartłomiej GłowniaandMatthew Russell 4c95db5fb3 feat(deal-ticket): calculate fees and discount base on volumeDiscountStats and referralSetStats (#5147)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2023-10-30 18:13:26 -07:00
ArtandMatthew Russell 3072b7824f fix(trading): referrer stats tiles (#5143)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2023-10-30 18:12:59 -07:00
Bartłomiej Głownia 0580e90171 feat(funding-payments): add load more button to the funding payments table (#5150) 2023-10-30 15:57:43 -07:00
Bartłomiej Głownia c440abc77d feat(trading): show full history of orders, fills and trades (#5105) 2023-10-27 14:21:26 +01:00
Matthew Russell fbafc726a4 feat(candles-chart): combine overlays and studies into indicators dropdown (#5129) 2023-10-27 14:21:14 +01:00
Joe Tsang dd1890d8c6 chore(cypress): fix failing assertions (#5132) 2023-10-27 14:21:02 +01:00
Matthew Russell c8e624eaba chore(trading): update fee factor with discount calculation (#5138) 2023-10-27 14:20:45 +01:00
Art cc6629ad27 fix(trading): referrals next tier tiles (#5139) 2023-10-27 13:48:43 +02:00
Art 9838efa00e feat(proposals): generic proposal toasts (#5134) 2023-10-27 13:17:51 +02:00
m.ray dac7142a98 feat(trading): add earmarked fees (#5123) 2023-10-27 13:09:01 +02:00
Matthew Russell 1397aafd25 fix(ui-toolkit): sparkline red shaded area color 2023-10-26 10:19:05 -07:00
Matthew Russell b192603e57 feat(ui-toolkit): sparkline shaded area (#5131) 2023-10-26 10:06:05 -07:00
Matthew Russell 8c2c8d987c feat(trading): add row click for fees table (#5130) 2023-10-26 08:38:52 -07:00
Matthew Russell 52c96794f7 feat(trading): fees page (#5055) 2023-10-25 14:59:30 -07:00
Ben 58ce016d4d chore(trading): suspend market test to console test (#5128) 2023-10-25 16:21:11 +01:00
Ben ac16eb06f5 chore(trading): delete fills cypress tests (#5122) 2023-10-25 14:02:43 +01:00
Ben 3319871617 chore(trading): delete dupe transfer tests (#5121) 2023-10-25 12:29:21 +00:00
Bartłomiej Głownia 37e850304a feat(trading): upgrade pennant@1.14.0 (#5120) 2023-10-25 12:44:23 +01:00
daro-maj 655131c744 test(trading): fix capsule test (#5116) 2023-10-25 12:39:18 +01:00
m.ray f1cb1b9408 fix(trading): quote base unit for size (#5124) 2023-10-25 12:36:57 +01:00
m.ray 891ac527a2 feat(trading): add tooltips for iceberg and post or reduce only (#5119) 2023-10-25 12:18:52 +01:00
Art 546087fb9f chore(trading): referrals with sidebar (#5117) 2023-10-25 11:14:00 +02:00
BenandMatthew Russell f7a82d33ba chore(trading): withdraw tests to jest (#5109)
Co-authored-by: Matthew Russell <mattrussell36@gmail.com>
2023-10-25 08:45:58 +01:00
Matthew Russell d85f413e41 chore(trading,positions): update estimatePosition query to use market decimals for liquidation price (#5005) 2023-10-24 17:30:17 -07:00
137 changed files with 4459 additions and 1574 deletions
@@ -39,7 +39,6 @@ context('Market page', { tags: '@regression' }, function () {
cy.contains('Test market 1').click();
cy.getByTestId(marketHeaders).should('have.text', 'Test market 1');
cy.validate_element_from_table('Name', 'Test market 1');
cy.validate_element_from_table('Market ID', this.createdMarketId);
cy.validate_element_from_table('Trading Mode', 'Opening auction');
cy.validate_element_from_table('Market Decimal Places', '5');
cy.validate_element_from_table('Position Decimal Places', '5');
@@ -537,10 +537,10 @@ describe(
cy.VegaWalletSubmitProposal(createGovernanceTransferProposalTxBody());
cy.reload();
getProposalFromTitle('Governance transfer proposal').within(() => {
cy.getByTestId(marketProposalType).should('have.text', 'NewTransfer');
cy.getByTestId(marketProposalType).should('have.text', 'New transfer');
cy.getByTestId(viewProposalButton).click();
});
cy.getByTestId(marketProposalType).should('have.text', 'NewTransfer');
cy.getByTestId(marketProposalType).should('have.text', 'New transfer');
cy.getByTestId(governanceTransferToggle).click();
cy.getByTestId('proposal-transfer-details-table').within(() => {
getProposalInformationFromTable('Source Type')
@@ -590,7 +590,7 @@ describe(
);
cy.getByTestId(viewProposalButton).click();
});
cy.getByTestId(marketProposalType).should('have.text', 'CancelTransfer');
cy.getByTestId(marketProposalType).should('have.text', 'Cancel transfer');
getProposalInformationFromTable('Error details')
.invoke('text')
.and('eq', 'Governance transfer invalid transfer id not found');
@@ -909,6 +909,14 @@
"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",
"BenefitTierMinimumActivityStreak": "Minimum activity streak",
"BenefitTierMinimumActivityStreakDescription": "The minimum number of times the party needs to have completed the activity",
"BenefitTierMinimumQuantumBalance": "Minimum quantum balance",
"BenefitTierMinimumQuantumBalanceDescription": "The minimum amount of the vesting token to qualify",
"BenefitTierVestingMultiplier": "Vesting multiplier",
"BenefitTierVestingMultiplierDescription": "Vesting multiplier for the tier",
"BenefitTierRewardMultiplier": "Reward multiplier",
"BenefitTierRewardMultiplierDescription": "The multiplier",
"StakingTiers": "Staking tiers",
"StakingTierMinimumStakedTokens": "Minimum staked tokens",
"StakingTierMinimumStakedTokensDescription": "Required number of governance tokens ($VEGA) a referrer must have staked to receive the multiplier",
@@ -36,13 +36,13 @@ describe('ProposalReferralProgramDetails helper functions', () => {
it('should format referral discount factor correctly', () => {
const input = '0.05';
const formatted = formatReferralDiscountFactor(input);
expect(formatted).toBe('5.00%');
expect(formatted).toBe('5%');
});
it('should format referral reward factor correctly', () => {
const input = '0.1';
const formatted = formatReferralRewardFactor(input);
expect(formatted).toBe('10.00%');
expect(formatted).toBe('10%');
});
it('should format minimum staked tokens correctly', () => {
@@ -0,0 +1 @@
export * from './proposal-update-benefit-tiers-details';
@@ -0,0 +1,158 @@
import { render, screen } from '@testing-library/react';
import { ProposalUpdateBenefitTiers } from './proposal-update-benefit-tiers-details';
import { generateProposal } from '../../test-helpers/generate-proposals';
jest.mock('../../../../contexts/app-state/app-state-context', () => ({
useAppState: () => ({
appState: {
decimals: 2,
},
}),
}));
const mockVestingBenefitTierProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_quantum_balance: '10000',
reward_multiplier: '0.05',
},
{
minimum_quantum_balance: '500000000000',
reward_multiplier: '0.1',
},
{
minimum_quantum_balance: '10000000000000',
reward_multiplier: '10',
},
],
}),
},
},
},
});
const mockActivityStreakBenefitTierProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({
tiers: [
{
minimum_activity_streak: '10000',
vesting_multiplier: '5',
reward_multiplier: '0.1',
},
{
minimum_activity_streak: '10000000000000',
vesting_multiplier: '100',
reward_multiplier: '10',
},
],
}),
},
},
},
});
describe('ProposalUpdateBenefitTiers', () => {
it('should not render if proposal is null', () => {
render(<ProposalUpdateBenefitTiers proposal={null} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if __typename is not UpdateNetworkParameter', () => {
const updateMarketProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateMarket',
},
},
});
render(<ProposalUpdateBenefitTiers proposal={updateMarketProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if there are no relevant fields', () => {
const incompleteProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
},
},
});
render(<ProposalUpdateBenefitTiers proposal={incompleteProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should not render if there are relevant fields that are empty', () => {
const incompleteProposal = generateProposal({
terms: {
change: {
__typename: 'UpdateNetworkParameter',
networkParameter: {
key: 'blah.blah.benefitTiers',
value: JSON.stringify({}),
},
},
},
});
render(<ProposalUpdateBenefitTiers proposal={incompleteProposal} />);
expect(screen.queryByTestId('proposal-update-benefit-tiers')).toBeNull();
});
it('should render a valid vesting benefit tier proposal', () => {
render(
<ProposalUpdateBenefitTiers proposal={mockVestingBenefitTierProposal} />
);
// 3 tiers in the sample data
expect(screen.getByText('Tier 1')).toBeInTheDocument();
expect(screen.getByText('Tier 2')).toBeInTheDocument();
expect(screen.getByText('Tier 3')).toBeInTheDocument();
expect(screen.getAllByText('Minimum quantum balance').length).toBe(3);
expect(screen.getAllByText('Reward multiplier').length).toBe(3);
expect(screen.getByText('0.00000000000001')).toBeInTheDocument();
expect(screen.getByText('0.05x')).toBeInTheDocument();
expect(screen.getByText('0.0000005')).toBeInTheDocument();
expect(screen.getByText('0.1x')).toBeInTheDocument();
expect(screen.getByText('0.00001')).toBeInTheDocument();
expect(screen.getByText('10x')).toBeInTheDocument();
});
it('should render a valid activity streak benefit tier proposal', () => {
render(
<ProposalUpdateBenefitTiers
proposal={mockActivityStreakBenefitTierProposal}
/>
);
// 3 tiers in the sample data
expect(screen.getByText('Tier 1')).toBeInTheDocument();
expect(screen.getByText('Tier 2')).toBeInTheDocument();
expect(screen.getAllByText('Minimum activity streak').length).toBe(2);
expect(screen.getAllByText('Vesting multiplier').length).toBe(2);
expect(screen.getAllByText('Reward multiplier').length).toBe(2);
expect(screen.getByText('10000')).toBeInTheDocument();
expect(screen.getByText('5x')).toBeInTheDocument();
expect(screen.getByText('0.1x')).toBeInTheDocument();
expect(screen.getByText('10000000000000')).toBeInTheDocument();
expect(screen.getByText('100x')).toBeInTheDocument();
expect(screen.getByText('10x')).toBeInTheDocument();
});
});
@@ -0,0 +1,164 @@
import { useTranslation } from 'react-i18next';
import type { ProposalQuery } from '../../proposal/__generated__/Proposal';
import {
KeyValueTable,
KeyValueTableRow,
RoundedWrapper,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import {
formatMinimumStakedTokens,
formatReferralRewardMultiplier,
} from '../proposal-referral-program-details';
import { formatNumberPercentage } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
// These types are not generated as it's not known how dynamic these are
type VestingBenefitTier = {
minimum_quantum_balance: string;
reward_multiplier: string;
};
type ActivityStreakBenefitTier = {
minimum_activity_streak: number;
reward_multiplier: string;
vesting_multiplier: string;
};
export type BenefitTiers =
| Array<ActivityStreakBenefitTier>
| Array<VestingBenefitTier>;
export function getBenefitTiers(json: string): BenefitTiers {
try {
const parsed = JSON.parse(json);
return parsed.tiers;
} catch (e) {
return [];
}
}
export const formatVolumeDiscountFactor = (value: string) => {
return formatNumberPercentage(new BigNumber(value).times(100));
};
interface ProposalReferralProgramDetailsProps {
proposal: ProposalQuery['proposal'];
}
/**
* Special rendered for network proposals that change any benefit tiers,
* which is detected by:
* 1) it being a network parameter change
* 2) the name of the field ending in `.benefitTiers`
*
* It only renders known fields so that they can be formatted correctly.
*/
export const ProposalUpdateBenefitTiers = ({
proposal,
}: ProposalReferralProgramDetailsProps) => {
const { t } = useTranslation();
if (
proposal?.terms?.change?.__typename !== 'UpdateNetworkParameter' ||
proposal?.terms?.change?.networkParameter.key.slice(-13) !== '.benefitTiers'
) {
return null;
}
const benefitTiersString = proposal?.terms?.change?.networkParameter.value;
const benefitTiers = getBenefitTiers(benefitTiersString);
if (!benefitTiers) {
return null;
}
return (
<div data-testid="proposal-update-benefit-tiers">
<RoundedWrapper paddingBottom={true}>
{benefitTiers && (
<div
className="mb-6"
data-testid="proposal-volume-discount-program-benefit-tiers"
>
<h3 className="mb-3 uppercase font-semibold text-lg">
{t('BenefitTiers')}
</h3>
<KeyValueTable>
{benefitTiers
.sort(
(a, b) =>
Number(a.reward_multiplier) - Number(b.reward_multiplier)
)
.map((benefitTier, index) => (
<div className="mb-4" key={index}>
<h4 className="font-semibold uppercase">
Tier {index + 1}
</h4>
{'minimum_activity_streak' in benefitTier && (
<KeyValueTableRow
data-testid={`mas-${benefitTier.reward_multiplier}`}
>
<Tooltip
description={t(
'BenefitTierMinimumActivityStreakDescription'
)}
>
<span>{t('BenefitTierMinimumActivityStreak')}</span>
</Tooltip>
{benefitTier.minimum_activity_streak}
</KeyValueTableRow>
)}
{'minimum_quantum_balance' in benefitTier && (
<KeyValueTableRow
data-testid={`mqb-${benefitTier.reward_multiplier}`}
>
<Tooltip
description={t(
'BenefitTierMinimumQuantumBalanceDescription'
)}
>
<span>{t('BenefitTierMinimumQuantumBalance')}</span>
</Tooltip>
{formatMinimumStakedTokens(
benefitTier.minimum_quantum_balance,
18
)}
</KeyValueTableRow>
)}
{'vesting_multiplier' in benefitTier && (
<KeyValueTableRow
data-testid={`vm-${benefitTier.reward_multiplier}`}
>
<Tooltip
description={t('BenefitTierVestingMultiplier')}
>
<span>{t('BenefitTierVestingMultiplier')}</span>
</Tooltip>
{formatReferralRewardMultiplier(
benefitTier.vesting_multiplier
)}
</KeyValueTableRow>
)}
{'reward_multiplier' in benefitTier && (
<KeyValueTableRow
data-testid={`rm-${benefitTier.reward_multiplier}`}
>
<Tooltip description={t('BenefitTierRewardMultiplier')}>
<span>{t('BenefitTierRewardMultiplier')}</span>
</Tooltip>
{formatReferralRewardMultiplier(
benefitTier.reward_multiplier
)}
</KeyValueTableRow>
)}
</div>
))}
</KeyValueTable>
</div>
)}
</RoundedWrapper>
</div>
);
};
@@ -27,6 +27,7 @@ import {
ProposalTransferDetails,
} from '../proposal-transfer';
import { FLAGS } from '@vegaprotocol/environment';
import { ProposalUpdateBenefitTiers } from '../proposal-update-benefit-tiers';
export interface ProposalProps {
proposal: ProposalQuery['proposal'];
@@ -243,6 +244,14 @@ export const Proposal = ({
</div>
)}
{proposal.terms.change.__typename === 'UpdateNetworkParameter' &&
proposal.terms.change.networkParameter.key.slice(-13) ===
'.benefitTiers' && (
<div className="mb-4">
<ProposalUpdateBenefitTiers proposal={proposal} />
</div>
)}
{governanceTransferDetails}
<div className="mb-10">
+13 -13
View File
@@ -48,8 +48,8 @@ describe('capsule - without MultiSign', { tags: '@slow' }, () => {
cy.get('@markets').then((markets) => {
cy.wrap(markets[0]).as('market');
});
cy.setOnBoardingViewed();
cy.visit('/#/portfolio');
cy.connectVegaWallet();
});
it('can deposit', function () {
@@ -70,6 +70,8 @@ describe('capsule - without MultiSign', { tags: '@slow' }, () => {
cy.getByTestId('deposit-button').click();
connectEthereumWallet('Unknown');
selectAsset(btcName);
cy.get('[data-testid="rich-select-option"]').eq(btcName).click();
cy.getByTestId('approve-default').should(
'contain.text',
`Before you can make a deposit of your chosen asset, ${btcSymbol}, you need to approve its use in your Ethereum wallet`
@@ -120,7 +122,7 @@ describe('capsule - without MultiSign', { tags: '@slow' }, () => {
cy.get('[data-testid="pathname-/portfolio"]').should('exist');
cy.getByTestId(collateralTab).click();
cy.getByTestId('open-transfer').click();
cy.getByTestId('open-transfer').eq(1).click();
cy.getByTestId('transfer-form').should('be.visible');
cy.getByTestId('transfer-form').find('[name="toAddress"]').select(1);
cy.get('select option')
@@ -147,7 +149,8 @@ describe('capsule - without MultiSign', { tags: '@slow' }, () => {
// 0003-WTXN-011
cy.getByTestId('Withdrawals').click();
cy.getByTestId('withdraw-dialog-button').click();
selectAsset(0);
selectAsset(btcName);
cy.get('[data-testid="rich-select-option"]').eq(btcName).click();
cy.get(amountField).focus();
cy.get(amountField).clear().type('1');
cy.getByTestId('submit-withdrawal').click();
@@ -180,24 +183,21 @@ describe('capsule', { tags: '@slow', testIsolation: true }, () => {
cy.get('@markets').then((markets) => {
cy.wrap(markets[0]).as('market');
});
cy.setOnBoardingViewed();
cy.setVegaWallet();
});
it('shows node health', function () {
// 0006-NETW-010
const regex = /^Operational\d+$/;
const market = this.market;
cy.visit(`/#/markets/${market.id}`);
cy.getByTestId('node-health-trigger').realHover();
cy.getByTestId('node-health')
.children()
.first()
.should('contain.text', 'Operational')
.then(($el) => {
const blockHeight = parseInt($el.text());
// block height will increase over the course of the test run so best
// we can do here is check that its showing something sensible
expect(blockHeight).to.be.greaterThan(0);
});
.invoke('text')
.should('match', regex);
cy.getByTestId('node-health')
.children()
.eq(1)
@@ -239,7 +239,6 @@ describe('capsule', { tags: '@slow', testIsolation: true }, () => {
.should('contain.text', order.size);
cy.getByTestId(openOrdersTab).click();
cy.getByTestId('edit', txTimeout).should('contain.text', 'Edit');
cy.getByTestId('tab-open-orders').within(() => {
cy.get('.ag-center-cols-container')
.children()
@@ -280,8 +279,7 @@ describe('capsule', { tags: '@slow', testIsolation: true }, () => {
cy.visit(`/#/markets/${market.id}`);
cy.getByTestId(toastCloseBtn, txTimeout).click();
cy.getByTestId(openOrdersTab).click();
cy.getByTestId('edit', txTimeout).should('be.visible');
cy.getByTestId('edit').first().should('be.visible').click();
cy.getByTestId('edit').first().click();
cy.getByTestId('dialog-title').should('contain.text', 'Edit order');
cy.get('#limitPrice').focus().clear().type(newPrice);
cy.getByTestId('edit-order').find('[type="submit"]').click();
@@ -350,6 +348,7 @@ describe('capsule', { tags: '@slow', testIsolation: true }, () => {
cy.getByTestId('withdraw-dialog-button').click();
connectEthereumWallet('Unknown');
selectAsset(btcName);
cy.get('[data-testid="rich-select-option"]').eq(btcName).click();
cy.get(amountField).clear().type('1');
cy.getByTestId('submit-withdrawal').click();
cy.getByTestId(toastContent, txTimeout).should(
@@ -437,6 +436,7 @@ describe('capsule', { tags: '@slow', testIsolation: true }, () => {
cy.getByTestId('deposit-button').click();
connectEthereumWallet('Unknown');
selectAsset(btcName);
cy.get('[data-testid="rich-select-option"]').eq(btcName).click();
cy.contains('Deposits of tBTC not approved').should('not.exist');
cy.contains('Use maximum').should('be.visible');
cy.get(amountField).clear().type('20000000');
@@ -28,9 +28,10 @@ const headers = [
'Adjusted stake share',
'Share',
'Live supplied liquidity',
'Live time fraction on book',
'Fees accrued this epoch',
'Live time on book',
'Live liquidity quality score (%)',
'Last time fraction on the book',
'Last time on the book',
'Last fee penalty',
'Last bond penalty',
'Status',
@@ -1,70 +0,0 @@
import * as Schema from '@vegaprotocol/types';
import {
TIFlist,
orderPriceField,
orderSizeField,
orderTIFDropDown,
placeOrderBtn,
toggleLimit,
toggleMarket,
} from '../support/deal-ticket';
import { aliasGQLQuery } from '@vegaprotocol/cypress';
import { accountsQuery } from '@vegaprotocol/mock';
describe('suspended market validation', { tags: '@regression' }, () => {
before(() => {
cy.setVegaWallet();
cy.mockTradingPage(
Schema.MarketState.STATE_SUSPENDED,
Schema.MarketTradingMode.TRADING_MODE_MONITORING_AUCTION,
Schema.AuctionTrigger.AUCTION_TRIGGER_LIQUIDITY_TARGET_NOT_MET
);
const accounts = accountsQuery();
cy.mockGQL((req) => {
aliasGQLQuery(req, 'Accounts', accounts);
});
cy.mockSubscription();
cy.visit('/#/markets/market-0');
cy.wait('@Markets');
});
beforeEach(() => {
cy.setVegaWallet();
});
it('should show warning for market order', function () {
cy.getByTestId(toggleMarket).click();
// 7002-SORD-060
cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-error-message-type').should(
'have.text',
'This market is in auction until it reaches sufficient liquidity. Only limit orders are permitted when market is in auction'
);
});
it('should show info for allowed TIF', function () {
cy.getByTestId(toggleLimit).click();
cy.getByTestId(orderPriceField).clear().type('0.1');
cy.getByTestId(orderSizeField).clear().type('1');
cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-warning-auction').should(
'have.text',
'Any orders placed now will not trade until the auction ends'
);
});
it('should show warning for not allowed TIF', function () {
cy.getByTestId(toggleLimit).click();
cy.getByTestId(orderTIFDropDown).select(
TIFlist.filter((item) => item.code === 'FOK')[0].value
);
cy.getByTestId(placeOrderBtn).should('be.enabled');
cy.getByTestId(placeOrderBtn).click();
cy.getByTestId('deal-ticket-error-message-tif').should(
'have.text',
'This market is in auction until it reaches sufficient liquidity. Until the auction ends, you can only place GFA, GTT, or GTC limit orders'
);
});
});
@@ -1,110 +0,0 @@
import { aliasGQLQuery } from '@vegaprotocol/cypress';
import { fillsQuery } from '@vegaprotocol/mock';
const tabFills = 'tab-fills';
describe('fills', { tags: '@regression' }, () => {
// 7005-FILL-001
// 7005-FILL-002
// 7005-FILL-003
// 7005-FILL-004
// 7005-FILL-005
// 7005-FILL-006
// 7005-FILL-007
// 7005-FILL-008
beforeEach(() => {
// Ensure page loads with correct key
cy.window().then((window) => {
cy.wrap(
window.localStorage.setItem(
'vega_wallet_key',
Cypress.env('VEGA_PUBLIC_KEY')
)
);
});
cy.setVegaWallet();
cy.mockTradingPage();
cy.mockGQL((req) => {
aliasGQLQuery(
req,
'Fills',
fillsQuery({}, Cypress.env('VEGA_PUBLIC_KEY'))
);
});
cy.mockSubscription();
});
it('renders fills on portfolio page', () => {
cy.visit('/#/portfolio');
cy.get('[data-testid="pathname-/portfolio"]').should('exist');
cy.getByTestId('Fills').click();
validateFillsDisplayed();
});
it('renders fills on trading tab', () => {
cy.visit('/#/markets/market-0');
cy.getByTestId('Fills').click();
validateFillsDisplayed();
});
function validateFillsDisplayed() {
cy.getByTestId(tabFills).should('be.visible');
cy.getByTestId(tabFills).contains('Market');
cy.getByTestId(tabFills)
.get(
'[role="gridcell"][col-id="market.tradableInstrument.instrument.code"]'
)
.each(($marketSymbol) => {
cy.wrap($marketSymbol).invoke('text').should('not.be.empty');
});
cy.getByTestId(tabFills).contains('Size');
cy.get(`[col-id='size']`).eq(1).should('contain.text', '+');
cy.get(`[col-id='size']`).eq(2).should('contain.text', '-');
cy.getByTestId(tabFills)
.get('[role="gridcell"][col-id="size"]')
.each(($amount) => {
cy.wrap($amount).invoke('text').should('not.be.empty');
});
cy.getByTestId(tabFills).contains('Price');
cy.getByTestId(tabFills)
.get('[role="gridcell"][col-id="price"]')
.each(($prices) => {
cy.wrap($prices).invoke('text').should('not.be.empty');
});
cy.getByTestId(tabFills).contains('Notional');
cy.getByTestId(tabFills)
.get('[role="gridcell"][col-id="price_1"]')
.each(($total) => {
cy.wrap($total).invoke('text').should('not.be.empty');
});
cy.getByTestId(tabFills).contains('Role');
cy.getByTestId(tabFills)
.get('[role="gridcell"][col-id="aggressor"]')
.each(($role) => {
cy.wrap($role)
.invoke('text')
.then((text) => {
const roles = ['Maker', 'Taker', '-'];
expect(roles.indexOf(text.trim())).to.be.greaterThan(-1);
});
});
cy.getByTestId(tabFills).contains('Fee');
cy.getByTestId(tabFills)
.get(
'[role="gridcell"][col-id="market.tradableInstrument.instrument.product"]'
)
.each(($fees) => {
cy.wrap($fees).invoke('text').should('not.be.empty');
});
cy.getByTestId(tabFills).contains('Date');
const dateTimeRegex =
/(\d{1,2})\/(\d{1,2})\/(\d{4}), (\d{1,2}):(\d{1,2}):(\d{1,2})/gm;
cy.get('[col-id="createdAt"]').each(($tradeDateTime, index) => {
if (index != 0) {
//ignore header
cy.wrap($tradeDateTime).invoke('text').should('match', dateTimeRegex);
}
});
}
});
@@ -1,119 +0,0 @@
import { selectAsset } from '../support/helpers';
const amountField = 'input[name="amount"]';
const includeTransferFeeRadioBtn = 'include-transfer-fee';
const manageVegaWallet = 'manage-vega-wallet';
const toAddressField = '[name="toAddress"]';
const totalTransferfee = 'total-transfer-fee';
const transferAmount = 'transfer-amount';
const transferForm = 'transfer-form';
const transferFee = 'transfer-fee';
const walletTransfer = 'wallet-transfer';
const ASSET_SEPOLIA_TBTC = 2;
describe.skip(
'transfer fees',
{ tags: '@regression', testIsolation: true },
() => {
beforeEach(() => {
cy.mockWeb3Provider();
cy.mockTradingPage();
cy.mockSubscription();
cy.setVegaWallet();
cy.visit('/');
cy.getByTestId(manageVegaWallet).click();
cy.getByTestId(walletTransfer).click();
cy.wait('@Assets');
cy.wait('@Accounts');
cy.mockVegaWalletTransaction();
});
it('transfer fees tooltips', () => {
// 1003-TRAN-015
// 1003-TRAN-016
// 1003-TRAN-017
// 1003-TRAN-018
// 1003-TRAN-019
cy.getByTestId(transferForm);
cy.contains('Enter manually').click();
cy.getByTestId(transferForm)
.find(toAddressField)
.type(
'7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535'
);
selectAsset(ASSET_SEPOLIA_TBTC);
cy.getByTestId(transferForm)
.find(amountField)
.type('1', { delay: 100, force: true });
/// Check Include Transfer Fee tooltip
cy.get('label[for="include-transfer-fee"] div').realHover();
cy.get('[data-side="bottom"] div')
.should('be.visible')
.should('not.be.empty');
//Check Transfer Fee tooltip
cy.contains('div', 'Transfer fee').realHover();
cy.get('[data-side="bottom"] div')
.should('be.visible')
.should('not.be.empty');
//Check Amount to be transferred tooltip
cy.contains('div', 'Amount to be transferred').realHover();
cy.get('[data-side="bottom"] div')
.should('be.visible')
.should('not.be.empty');
//Check Total amount (with fee) tooltip
cy.contains('div', 'Total amount (with fee)').realHover();
cy.get('[data-side="bottom"] div')
.should('be.visible')
.should('not.be.empty');
});
it('transfer fees', () => {
// 1003-TRAN-020
// 1003-TRAN-021
// 1003-TRAN-022
// 1003-TRAN-023
cy.getByTestId(transferForm);
cy.contains('Enter manually').click();
cy.getByTestId(transferForm)
.find(toAddressField)
.type(
'7f9cf07d3a9905b1a61a1069f7a758855da428bc0f4a97de87f48644bfc25535'
);
selectAsset(ASSET_SEPOLIA_TBTC);
cy.getByTestId(includeTransferFeeRadioBtn).should('be.disabled');
cy.getByTestId(transferForm)
.find(amountField)
.type('1', { delay: 100, force: true });
cy.getByTestId(transferFee)
.should('be.visible')
.should('contain.text', '0.01');
cy.getByTestId(transferAmount)
.should('be.visible')
.should('contain.text', '1.00');
cy.getByTestId(totalTransferfee)
.should('be.visible')
.should('contain.text', '1.01');
cy.getByTestId(includeTransferFeeRadioBtn).click();
cy.getByTestId(transferFee)
.should('be.visible')
.should('contain.text', '0.01');
cy.getByTestId(transferAmount)
.should('be.visible')
.should('contain.text', '0.99');
cy.getByTestId(totalTransferfee)
.should('be.visible')
.should('contain.text', '1.00');
});
}
);
@@ -1,131 +0,0 @@
import { connectEthereumWallet } from '../support/ethereum-wallet';
import { selectAsset } from '../support/helpers';
const formFieldError = 'input-error-text';
const toAddressField = 'input[name="to"]';
const amountField = 'input[name="amount"]';
const useMaximumAmount = 'use-maximum';
const submitWithdrawBtn = 'submit-withdrawal';
const ethAddressValue = Cypress.env('ETHEREUM_WALLET_ADDRESS');
const ASSET_SEPOLIA_TBTC = 2;
const ASSET_EURO = 1;
describe('withdraw form validation', { tags: '@smoke' }, () => {
before(() => {
cy.mockWeb3Provider();
cy.mockTradingPage();
cy.mockSubscription();
cy.setVegaWallet();
cy.visit('/#/portfolio');
cy.getByTestId('Withdrawals').click();
cy.getByTestId('Withdraw').click(); // sidebar item
// It also requires connection Ethereum wallet
connectEthereumWallet('MetaMask');
cy.wait('@Accounts');
cy.wait('@Assets');
});
it('empty fields', () => {
cy.getByTestId(submitWithdrawBtn).click();
cy.getByTestId(formFieldError).should('contain.text', 'Required');
// only 2 despite 3 fields because the ethereum address will be auto populated
cy.getByTestId(formFieldError).should('have.length', 2);
// Test for Ethereum address
cy.get(toAddressField).should('have.value', ethAddressValue);
});
it('min amount', () => {
// 1002-WITH-010
selectAsset(ASSET_SEPOLIA_TBTC);
cy.get(amountField).clear().type('0');
cy.getByTestId(submitWithdrawBtn).click();
cy.get('[data-testid="input-error-text"]').should(
'contain.text',
'Value is below minimum'
);
});
it('max amount', () => {
// 1002-WITH-005
// 1002-WITH-008
selectAsset(ASSET_EURO); // Will be above maximum because the vega wallet doesn't have any collateral
cy.get(amountField).clear().type('1001', { delay: 100 });
cy.getByTestId(submitWithdrawBtn).click();
cy.get('[data-testid="input-error-text"]').should(
'contain.text',
'Insufficient amount in account'
);
});
it('can set amount using use maximum button', () => {
// 1002-WITH-004
selectAsset(ASSET_SEPOLIA_TBTC);
cy.getByTestId(useMaximumAmount).click();
cy.get(amountField).should('have.value', '1000.00001');
});
});
describe(
'withdraw actions',
{ tags: '@regression', testIsolation: true },
() => {
// this is extremely ugly hack, but setting it properly in contract is too much effort for such simple validation
// 1002-WITH-018
const withdrawalThreshold =
Cypress.env('VEGA_ENV') === 'CUSTOM' ? '0.00' : '100.00';
before(() => {
cy.mockWeb3Provider();
cy.mockTradingPage();
cy.mockSubscription();
cy.setVegaWallet();
cy.visit('/#/portfolio');
cy.wait('@Accounts');
cy.wait('@Assets');
cy.getByTestId('Withdrawals').click();
cy.getByTestId('Withdraw').click();
// It also requires connection Ethereum wallet
connectEthereumWallet('MetaMask');
cy.mockVegaWalletTransaction();
});
it('triggers transaction when submitted', () => {
// 1002-WITH-002
// 1002-WITH-003
selectAsset(ASSET_SEPOLIA_TBTC);
cy.getByTestId('BALANCE_AVAILABLE_label').should(
'contain.text',
'Balance available'
);
cy.getByTestId('BALANCE_AVAILABLE_value').should(
'have.text',
'1,000.00001'
);
cy.getByTestId('WITHDRAWAL_THRESHOLD_label').should(
'contain.text',
'Delayed withdrawal threshold'
);
cy.getByTestId('WITHDRAWAL_THRESHOLD_value').should(
'contain.text',
withdrawalThreshold
);
cy.getByTestId('DELAY_TIME_label').should('contain.text', 'Delay time');
cy.getByTestId('DELAY_TIME_value').should('have.text', 'None');
cy.get(amountField).clear().type('10');
cy.getByTestId(submitWithdrawBtn).click();
cy.getByTestId('toast').should('contain.text', 'Awaiting confirmation');
});
}
);
+4 -8
View File
@@ -3,17 +3,16 @@ NX_ETHERSCAN_URL=https://sepolia.etherscan.io
NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions
NX_HOSTED_WALLET_URL=https://wallet.testnet.vega.xyz
NX_SENTRY_DSN=https://2ffce43721964aafa78277c50654ece4@o286262.ingest.sentry.io/6300613
NX_VEGA_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/networks-internal/main/fairground/vegawallet-fairground.toml
NX_VEGA_ENV=TESTNET
NX_VEGA_EXPLORER_URL=https://explorer.fairground.wtf
NX_VEGA_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/networks-internal/main/stagnet1/vegawallet-stagnet1.toml
NX_VEGA_ENV=STAGNET1
NX_VEGA_EXPLORER_URL=https://explorer.stagnet1.vega.rocks
NX_VEGA_NETWORKS={\"MAINNET\":\"https://console.vega.xyz\",\"TESTNET\":\"https://console.fairground.wtf\",\"STAGNET1\":\"https://trading.stagnet1.vega.rocks\"}
NX_VEGA_TOKEN_URL=https://governance.fairground.wtf
NX_VEGA_TOKEN_URL=https://governance.stagnet1.vega.rocks
NX_VEGA_WALLET_URL=http://localhost:1789
NX_VEGA_DOCS_URL=https://docs.vega.xyz/testnet
NX_VEGA_REPO_URL=https://github.com/vegaprotocol/vega/releases
NX_ANNOUNCEMENTS_CONFIG_URL=https://raw.githubusercontent.com/vegaprotocol/announcements/fairground/announcements.json
NX_VEGA_INCIDENT_URL=https://blog.vega.xyz/tagged/vega-incident-reports
NX_VEGA_CONSOLE_URL=https://console.fairground.wtf
NX_CHROME_EXTENSION_URL=https://chrome.google.com/webstore/detail/vega-wallet-fairground/nmmjkiafpmphlikhefgjbblebfgclikn
NX_MOZILLA_EXTENSION_URL=https://addons.mozilla.org/firefox/addon/vega-wallet-fairground
NX_ORACLE_PROOFS_URL=https://raw.githubusercontent.com/vegaprotocol/well-known/main/__generated__/oracle-proofs.json
@@ -26,6 +25,3 @@ NX_ICEBERG_ORDERS=true
# NX_PRODUCT_PERPETUALS
NX_METAMASK_SNAPS=true
NX_REFERRALS=true
NX_TENDERMINT_URL=https://tm.be.testnet.vega.xyz
NX_TENDERMINT_WEBSOCKET_URL=wss://be.testnet.vega.xyz/websocket
+1 -1
View File
@@ -5,7 +5,7 @@
"next",
"next/core-web-vitals"
],
"ignorePatterns": ["!**/*", "__generated__"],
"ignorePatterns": ["!**/*", "__generated__", ".next"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
+11
View File
@@ -0,0 +1,11 @@
import { t } from '@vegaprotocol/i18n';
import { FeesContainer } from '../../components/fees-container';
export const Fees = () => {
return (
<div className="container p-4 mx-auto">
<h1 className="px-4 pb-4 text-2xl">{t('Fees')}</h1>
<FeesContainer />
</div>
);
};
+1
View File
@@ -0,0 +1 @@
export { Fees } from './fees';
@@ -32,7 +32,7 @@ const WithdrawalsIndicator = () => {
return null;
}
return (
<span className="bg-vega-clight-500 dark:bg-vega-cdark-500 text-default rounded p-1 leading-none">
<span className="p-1 leading-none rounded bg-vega-clight-500 dark:bg-vega-cdark-500 text-default">
{ready.length}
</span>
);
@@ -128,7 +128,7 @@ interface PortfolioGridChildProps {
const PortfolioGridChild = ({ children }: PortfolioGridChildProps) => {
return (
<section className="h-full p-1">
<div className="border border-default h-full rounded-sm">{children}</div>
<div className="h-full border rounded-sm border-default">{children}</div>
</section>
);
};
@@ -18,6 +18,7 @@ import { Routes } from '../../lib/links';
import { useTransactionEventSubscription } from '@vegaprotocol/web3';
import { t } from '@vegaprotocol/i18n';
import { Statistics } from './referral-statistics';
import { useReferralProgram } from './hooks/use-referral-program';
const RELOAD_DELAY = 3000;
@@ -32,6 +33,7 @@ const validateCode = (value: string) => {
};
export const ApplyCodeForm = () => {
const program = useReferralProgram();
const navigate = useNavigate();
const openWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
@@ -237,7 +239,7 @@ export const ApplyCodeForm = () => {
{previewData ? (
<div className="mt-10">
<h2 className="text-2xl mb-5">{t('You are joining')}</h2>
<Statistics data={previewData} as="referee" />
<Statistics data={previewData} program={program} as="referee" />
</div>
) : null}
</>
@@ -1,5 +1,5 @@
query Referees($code: ID!, $aggregationDays: Int) {
referralSetReferees(id: $code, aggregationDays: $aggregationDays) {
query Referees($code: ID!, $aggregationEpochs: Int) {
referralSetReferees(id: $code, aggregationEpochs: $aggregationEpochs) {
edges {
node {
referralSetId
@@ -10,6 +10,7 @@ query ReferralSetStats($code: ID!, $epoch: Int) {
referralSetRunningNotionalTakerVolume
rewardsMultiplier
rewardsFactorMultiplier
referrerTakerVolume
}
}
}
@@ -5,7 +5,7 @@ import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type RefereesQueryVariables = Types.Exact<{
code: Types.Scalars['ID'];
aggregationDays?: Types.InputMaybe<Types.Scalars['Int']>;
aggregationEpochs?: Types.InputMaybe<Types.Scalars['Int']>;
}>;
@@ -13,8 +13,8 @@ export type RefereesQuery = { __typename?: 'Query', referralSetReferees: { __typ
export const RefereesDocument = gql`
query Referees($code: ID!, $aggregationDays: Int) {
referralSetReferees(id: $code, aggregationDays: $aggregationDays) {
query Referees($code: ID!, $aggregationEpochs: Int) {
referralSetReferees(id: $code, aggregationEpochs: $aggregationEpochs) {
edges {
node {
referralSetId
@@ -42,7 +42,7 @@ export const RefereesDocument = gql`
* const { data, loading, error } = useRefereesQuery({
* variables: {
* code: // value for 'code'
* aggregationDays: // value for 'aggregationDays'
* aggregationEpochs: // value for 'aggregationEpochs'
* },
* });
*/
@@ -9,7 +9,7 @@ export type ReferralSetStatsQueryVariables = Types.Exact<{
}>;
export type ReferralSetStatsQuery = { __typename?: 'Query', referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, partyId: string, discountFactor: string, rewardFactor: string, epochNotionalTakerVolume: string, referralSetRunningNotionalTakerVolume: string, rewardsMultiplier: string, rewardsFactorMultiplier: string } } | null> } };
export type ReferralSetStatsQuery = { __typename?: 'Query', referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, partyId: string, discountFactor: string, rewardFactor: string, epochNotionalTakerVolume: string, referralSetRunningNotionalTakerVolume: string, rewardsMultiplier: string, rewardsFactorMultiplier: string, referrerTakerVolume: string } } | null> } };
export const ReferralSetStatsDocument = gql`
@@ -25,6 +25,7 @@ export const ReferralSetStatsDocument = gql`
referralSetRunningNotionalTakerVolume
rewardsMultiplier
rewardsFactorMultiplier
referrerTakerVolume
}
}
}
@@ -5,14 +5,14 @@ import compact from 'lodash/compact';
import type { ReferralSetsQueryVariables } from './__generated__/ReferralSets';
import { useReferralSetsQuery } from './__generated__/ReferralSets';
const DEFAULT_AGGREGATION_DAYS = 30;
export const DEFAULT_AGGREGATION_DAYS = 30;
export type Role = 'referrer' | 'referee';
type UseReferralArgs = (
| { code: string }
| { pubKey: string | null; role: Role }
) & {
aggregationDays?: number;
aggregationEpochs?: number;
};
const prepareVariables = (
@@ -70,9 +70,9 @@ export const useReferral = (args: UseReferralArgs) => {
} = useRefereesQuery({
variables: {
code: referralSet?.id as string,
aggregationDays:
args.aggregationDays != null
? args.aggregationDays
aggregationEpochs:
args.aggregationEpochs !== null
? args.aggregationEpochs
: DEFAULT_AGGREGATION_DAYS,
},
skip: !referralSet?.id,
@@ -8,22 +8,17 @@ export const LandingBanner = () => {
<div className="">
<div
aria-hidden
className="absolute top-64 right-[220px] md:right-[340px] max-sm:hidden"
className="absolute top-20 right-[120px] md:right-[240px] max-sm:hidden"
>
<AnimatedDudeWithWire />
</div>
<div className="pt-32 sm:w-[50%]">
<div className="pt-20 sm:w-[50%]">
<h1 className="text-6xl font-alpha calt mb-10">
{t('Earn commission & stake rewards')}
</h1>
<p className="text-lg mb-10">
{t(
'Invite friends and earn commission in the form of Vega rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.'
)}
</p>
<p className="text-lg">
{t(
'Any friends that join using the code will receive discounts off trading fees.'
'Invite friends and earn rewards from the trading fees they pay. Stake those rewards to earn multipliers on future rewards.'
)}
</p>
</div>
@@ -2,6 +2,7 @@ import classNames from 'classnames';
import type { HTMLAttributes } from 'react';
import { SKY_BACKGROUND } from './constants';
import { Outlet } from 'react-router-dom';
import { TinyScroll } from '@vegaprotocol/ui-toolkit';
export const Layout = ({
className,
@@ -28,8 +29,10 @@ export const LayoutWithSky = ({
...props
}: HTMLAttributes<HTMLDivElement>) => {
return (
<div className={classNames('h-full overflow-auto', SKY_BACKGROUND)}>
<TinyScroll
className={classNames('max-h-full overflow-auto', SKY_BACKGROUND)}
>
<Layout className={className} {...props} />
</div>
</TinyScroll>
);
};
@@ -3,10 +3,12 @@ import {
VegaIcon,
VegaIconNames,
truncateMiddle,
ExternalLink,
Tooltip,
} from '@vegaprotocol/ui-toolkit';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useReferral } from './hooks/use-referral';
import { DEFAULT_AGGREGATION_DAYS, useReferral } from './hooks/use-referral';
import { CreateCodeContainer } from './create-code-form';
import classNames from 'classnames';
import { Table } from './table';
@@ -27,25 +29,31 @@ import { useLayoutEffect, useRef, useState } from 'react';
import { useCurrentEpochInfoQuery } from './hooks/__generated__/Epoch';
import BigNumber from 'bignumber.js';
import { t } from '@vegaprotocol/i18n';
import maxBy from 'lodash/maxBy';
import { DocsLinks } from '@vegaprotocol/environment';
export const ReferralStatistics = () => {
const { pubKey } = useVegaWallet();
const program = useReferralProgram();
const { data: referee } = useReferral({
pubKey,
role: 'referee',
aggregationEpochs: program.details?.windowLength,
});
const { data: referrer } = useReferral({
pubKey,
role: 'referrer',
aggregationEpochs: program.details?.windowLength,
});
if (referee?.code) {
return <Statistics data={referee} as="referee" />;
return <Statistics data={referee} program={program} as="referee" />;
}
if (referrer?.code) {
return <Statistics data={referrer} as="referrer" />;
return <Statistics data={referrer} program={program} as="referrer" />;
}
return <CreateCodeContainer />;
@@ -53,14 +61,16 @@ export const ReferralStatistics = () => {
export const Statistics = ({
data,
program,
as,
}: {
data: NonNullable<ReturnType<typeof useReferral>['data']>;
program: ReturnType<typeof useReferralProgram>;
as: 'referrer' | 'referee';
}) => {
const { benefitTiers, details } = program;
const { data: epochData } = useCurrentEpochInfoQuery();
const { stakeAvailable } = useStakeAvailable();
const { benefitTiers } = useReferralProgram();
const { data: statsData } = useReferralSetStatsQuery({
variables: {
code: data.code,
@@ -71,6 +81,13 @@ export const Statistics = ({
const currentEpoch = Number(epochData?.epoch.id);
const compactNumFormat = new Intl.NumberFormat(getUserLocale(), {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
notation: 'compact',
compactDisplay: 'short',
});
const stats =
statsData?.referralSetStats.edges &&
compact(removePaginationWrapper(statsData.referralSetStats.edges));
@@ -86,10 +103,13 @@ export const Statistics = ({
const runningVolumeValue = statsAvailable
? Number(statsAvailable.referralSetRunningNotionalTakerVolume)
: 0;
const referrerVolumeValue = statsAvailable
? Number(statsAvailable.referrerTakerVolume)
: 0;
const multiplier = statsAvailable
? Number(statsAvailable.rewardsMultiplier)
: 1;
const finalCommissionValue = !isNaN(multiplier)
const finalCommissionValue = isNaN(multiplier)
? baseCommissionValue
: multiplier * baseCommissionValue;
@@ -102,9 +122,9 @@ export const Statistics = ({
!isNaN(t.discountFactor) &&
t.discountFactor === discountFactorValue
);
const nextBenefitTierValue =
currentBenefitTierValue &&
benefitTiers.find((t) => t.tier === currentBenefitTierValue.tier - 1);
const nextBenefitTierValue = currentBenefitTierValue
? benefitTiers.find((t) => t.tier === currentBenefitTierValue.tier - 1)
: maxBy(benefitTiers, (bt) => bt.tier); // max tier number is lowest tier
const epochsValue =
!isNaN(currentEpoch) && refereeInfo?.atEpoch
? currentEpoch - refereeInfo?.atEpoch
@@ -117,7 +137,13 @@ export const Statistics = ({
: 0;
const baseCommissionTile = (
<StatTile title={t('Base commission rate')}>
<StatTile
title={t('Base commission rate')}
description={t('(Combined set volume %s over last %s epochs)', [
compactNumFormat.format(runningVolumeValue),
(details?.windowLength || DEFAULT_AGGREGATION_DAYS).toString(),
])}
>
{baseCommissionValue * 100}%
</StatTile>
);
@@ -133,7 +159,16 @@ export const Statistics = ({
</StatTile>
);
const finalCommissionTile = (
<StatTile title={t('Final commission rate')}>
<StatTile
title={t('Final commission rate')}
description={
!isNaN(multiplier)
? `(${baseCommissionValue * 100}% ⨉ ${multiplier} = ${
finalCommissionValue * 100
}%)`
: undefined
}
>
{finalCommissionValue * 100}%
</StatTile>
);
@@ -142,12 +177,21 @@ export const Statistics = ({
<StatTile title={t('Number of traders')}>{numberOfTradersValue}</StatTile>
);
const codeTile = <CodeTile code={data?.code} />;
const createdAtTile = (
<StatTile title={t('Created at')}>
<span className="text-3xl">
{getDateFormat().format(new Date(data.createdAt))}
</span>
const codeTile = (
<CodeTile
code={data?.code}
createdAt={getDateFormat().format(new Date(data.createdAt))}
/>
);
const referrerVolumeTile = (
<StatTile
title={t(
'My volume (last %s epochs)',
(details?.windowLength || DEFAULT_AGGREGATION_DAYS).toString()
)}
>
{compactNumFormat.format(referrerVolumeValue)}
</StatTile>
);
@@ -156,8 +200,11 @@ export const Statistics = ({
.reduce((all, r) => all.plus(r), new BigNumber(0));
const totalCommissionTile = (
<StatTile
title={t('Total commission (last 30 days)')}
description={t('(qUSD)')}
title={t(
'Total commission (last %s epochs)',
(details?.windowLength || DEFAULT_AGGREGATION_DAYS).toString()
)}
description={<QUSDTooltip />}
>
{getNumberFormat(0).format(Number(totalCommissionValue))}
</StatTile>
@@ -173,30 +220,28 @@ export const Statistics = ({
<div className="grid grid-rows-1 gap-5 grid-cols-1 sm:grid-cols-2 xl:grid-cols-4">
{codeTile}
{createdAtTile}
{referrerVolumeTile}
{numberOfTradersTile}
{totalCommissionTile}
</div>
</>
);
const compactNumFormat = new Intl.NumberFormat(getUserLocale(), {
minimumFractionDigits: 0,
maximumFractionDigits: 2,
notation: 'compact',
compactDisplay: 'short',
});
const currentBenefitTierTile = (
<StatTile title={t('Current tier')}>
{currentBenefitTierValue?.tier || '-'}
{currentBenefitTierValue?.tier || 'None'}
</StatTile>
);
const discountFactorTile = (
<StatTile title={t('Discount')}>{discountFactorValue * 100}%</StatTile>
);
const runningVolumeTile = (
<StatTile title={t('Combined volume')}>
<StatTile
title={t(
'Combined volume (last %s epochs)',
details?.windowLength.toString()
)}
>
{compactNumFormat.format(runningVolumeValue)}
</StatTile>
);
@@ -255,7 +300,7 @@ export const Statistics = ({
{/* Referees (only for referrer view) */}
{as === 'referrer' && data.referees.length > 0 && (
<div className="mt-20 mb-20">
<h2 className="text-2xl mb-5">{t('Referees')}</h2>
<h2 className="mb-5 text-2xl">{t('Referees')}</h2>
<div
className={classNames(
collapsed && [
@@ -281,10 +326,28 @@ export const Statistics = ({
columns={[
{ name: 'party', displayName: t('Trader') },
{ name: 'joined', displayName: t('Date Joined') },
{ name: 'volume', displayName: t('Volume (last 30 days)') },
{
name: 'volume',
displayName: t(
'Volume (last %s epochs)',
(
details?.windowLength || DEFAULT_AGGREGATION_DAYS
).toString()
),
},
{
name: 'commission',
displayName: t('Commission earned (last 30 days)'),
displayName: (
<>
{t('Commission earned in')} <QUSDTooltip />{' '}
{t(
'(last %s epochs)',
(
details?.windowLength || DEFAULT_AGGREGATION_DAYS
).toString()
)}
</>
),
},
]}
data={sortBy(
@@ -313,3 +376,25 @@ export const Statistics = ({
</>
);
};
export const QUSDTooltip = () => (
<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>
);
+11 -14
View File
@@ -1,10 +1,10 @@
import { Tooltip, VegaIcon, VegaIconNames } from '@vegaprotocol/ui-toolkit';
import classNames from 'classnames';
import { forwardRef, type HTMLAttributes } from 'react';
import { forwardRef, type ReactNode, type HTMLAttributes } from 'react';
import { BORDER_COLOR, GRADIENT } from './constants';
type TableColumnDefinition = {
displayName?: string;
displayName?: ReactNode;
name: string;
tooltip?: string;
className?: string;
@@ -46,7 +46,7 @@ export const Table = forwardRef<
INNER_BORDER_STYLE
)}
>
<span className="flex flex-row gap-2 items-center">
<span className="flex flex-row items-center gap-2">
<span>{displayName}</span>
{tooltip ? (
<Tooltip description={tooltip}>
@@ -102,17 +102,14 @@ export const Table = forwardRef<
key={`${i}-${name}`}
>
{/** display column name in mobile view */}
{!noCollapse &&
!noHeader &&
displayName &&
displayName.length > 0 && (
<span
aria-hidden
className="md:hidden font-mono text-xs px-0 text-vega-clight-100 dark:text-vega-cdark-100"
>
{displayName}
</span>
)}
{!noCollapse && !noHeader && displayName && (
<span
aria-hidden
className="px-0 font-mono text-xs md:hidden text-vega-clight-100 dark:text-vega-cdark-100"
>
{displayName}
</span>
)}
<span>{d[name]}</span>
</td>
))}
+12 -1
View File
@@ -109,6 +109,7 @@ export const TiersContainer = () => {
<Loading variant="large" />
) : (
<TiersTable
windowLength={details?.windowLength}
data={benefitTiers.map((bt) => ({
...bt,
tierElement: (
@@ -162,6 +163,7 @@ const StakingTiers = ({
const TiersTable = ({
data,
windowLength,
}: {
data: Array<{
tier: number;
@@ -170,6 +172,7 @@ const TiersTable = ({
discount: string;
volume: string;
}>;
windowLength?: number;
}) => {
return (
<Table
@@ -181,7 +184,15 @@ const TiersTable = ({
tooltip: t('A percentage of commission earned by the referrer'),
},
{ name: 'discount', displayName: t('Referrer trading discount') },
{ name: 'volume', displayName: t('Min. trading volume') },
{
name: 'volume',
displayName: t(
'Min. trading volume %s',
windowLength
? t('(last %s epochs)', windowLength.toString())
: undefined
),
},
{ name: 'epochs', displayName: t('Min. epochs') },
]}
data={data.map((d) => ({
+11 -5
View File
@@ -7,6 +7,7 @@ import {
import classNames from 'classnames';
import type { HTMLAttributes, ReactNode } from 'react';
import { Button } from './buttons';
import { t } from '@vegaprotocol/i18n';
export const Tile = ({
className,
@@ -28,7 +29,7 @@ export const Tile = ({
type StatTileProps = {
title: string;
description?: string;
description?: ReactNode;
children?: ReactNode;
};
export const StatTile = ({ title, description, children }: StatTileProps) => {
@@ -54,18 +55,23 @@ const FADE_OUT_STYLE = classNames(
export const CodeTile = ({
code,
createdAt,
className,
}: {
code: string;
createdAt?: string;
className?: string;
}) => {
return (
<StatTile title="Your referral code">
<div className="flex gap-2 items-center justify-between">
<StatTile
title={t('Your referral code')}
description={createdAt ? t('(Created at: %s)', createdAt) : undefined}
>
<div className="flex items-center justify-between gap-2">
<Tooltip
description={
<div className="break-all">
<span className="text-xl bg-rainbow bg-clip-text text-transparent">
<span className="text-xl text-transparent bg-rainbow bg-clip-text">
{code}
</span>
</div>
@@ -82,7 +88,7 @@ export const CodeTile = ({
</Tooltip>
<CopyWithTooltip text={code}>
<Button className="text-sm no-underline !py-0 !px-0 h-fit !bg-transparent">
<span className="sr-only">Copy</span>
<span className="sr-only">{t('Copy')}</span>
<VegaIcon size={24} name={VegaIconNames.COPY} />
</Button>
</CopyWithTooltip>
@@ -0,0 +1,58 @@
query DiscountPrograms {
currentReferralProgram {
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
}
windowLength
}
currentVolumeDiscountProgram {
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
windowLength
}
}
query Fees(
$partyId: ID!
$volumeDiscountEpochs: Int!
$referralDiscountEpochs: Int!
) {
epoch {
id
}
volumeDiscountStats(
partyId: $partyId
pagination: { last: $volumeDiscountEpochs }
) {
edges {
node {
atEpoch
discountFactor
runningVolume
}
}
}
referralSetReferees(referee: $partyId) {
edges {
node {
atEpoch
}
}
}
referralSetStats(
partyId: $partyId
pagination: { last: $referralDiscountEpochs }
) {
edges {
node {
atEpoch
discountFactor
referralSetRunningNotionalTakerVolume
}
}
}
}
@@ -0,0 +1,131 @@
import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type DiscountProgramsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type DiscountProgramsQuery = { __typename?: 'Query', currentReferralProgram?: { __typename?: 'CurrentReferralProgram', windowLength: number, benefitTiers: Array<{ __typename?: 'BenefitTier', minimumEpochs: number, minimumRunningNotionalTakerVolume: string, referralDiscountFactor: string }> } | null, currentVolumeDiscountProgram?: { __typename?: 'VolumeDiscountProgram', windowLength: number, benefitTiers: Array<{ __typename?: 'VolumeBenefitTier', minimumRunningNotionalTakerVolume: string, volumeDiscountFactor: string }> } | null };
export type FeesQueryVariables = Types.Exact<{
partyId: Types.Scalars['ID'];
volumeDiscountEpochs: Types.Scalars['Int'];
referralDiscountEpochs: Types.Scalars['Int'];
}>;
export type FeesQuery = { __typename?: 'Query', epoch: { __typename?: 'Epoch', id: string }, volumeDiscountStats: { __typename?: 'VolumeDiscountStatsConnection', edges: Array<{ __typename?: 'VolumeDiscountStatsEdge', node: { __typename?: 'VolumeDiscountStats', atEpoch: number, discountFactor: string, runningVolume: string } } | null> }, referralSetReferees: { __typename?: 'ReferralSetRefereeConnection', edges: Array<{ __typename?: 'ReferralSetRefereeEdge', node: { __typename?: 'ReferralSetReferee', atEpoch: number } } | null> }, referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, discountFactor: string, referralSetRunningNotionalTakerVolume: string } } | null> } };
export const DiscountProgramsDocument = gql`
query DiscountPrograms {
currentReferralProgram {
benefitTiers {
minimumEpochs
minimumRunningNotionalTakerVolume
referralDiscountFactor
}
windowLength
}
currentVolumeDiscountProgram {
benefitTiers {
minimumRunningNotionalTakerVolume
volumeDiscountFactor
}
windowLength
}
}
`;
/**
* __useDiscountProgramsQuery__
*
* To run a query within a React component, call `useDiscountProgramsQuery` and pass it any options that fit your needs.
* When your component renders, `useDiscountProgramsQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useDiscountProgramsQuery({
* variables: {
* },
* });
*/
export function useDiscountProgramsQuery(baseOptions?: Apollo.QueryHookOptions<DiscountProgramsQuery, DiscountProgramsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<DiscountProgramsQuery, DiscountProgramsQueryVariables>(DiscountProgramsDocument, options);
}
export function useDiscountProgramsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<DiscountProgramsQuery, DiscountProgramsQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<DiscountProgramsQuery, DiscountProgramsQueryVariables>(DiscountProgramsDocument, options);
}
export type DiscountProgramsQueryHookResult = ReturnType<typeof useDiscountProgramsQuery>;
export type DiscountProgramsLazyQueryHookResult = ReturnType<typeof useDiscountProgramsLazyQuery>;
export type DiscountProgramsQueryResult = Apollo.QueryResult<DiscountProgramsQuery, DiscountProgramsQueryVariables>;
export const FeesDocument = gql`
query Fees($partyId: ID!, $volumeDiscountEpochs: Int!, $referralDiscountEpochs: Int!) {
epoch {
id
}
volumeDiscountStats(
partyId: $partyId
pagination: {last: $volumeDiscountEpochs}
) {
edges {
node {
atEpoch
discountFactor
runningVolume
}
}
}
referralSetReferees(referee: $partyId) {
edges {
node {
atEpoch
}
}
}
referralSetStats(partyId: $partyId, pagination: {last: $referralDiscountEpochs}) {
edges {
node {
atEpoch
discountFactor
referralSetRunningNotionalTakerVolume
}
}
}
}
`;
/**
* __useFeesQuery__
*
* To run a query within a React component, call `useFeesQuery` and pass it any options that fit your needs.
* When your component renders, `useFeesQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = useFeesQuery({
* variables: {
* partyId: // value for 'partyId'
* volumeDiscountEpochs: // value for 'volumeDiscountEpochs'
* referralDiscountEpochs: // value for 'referralDiscountEpochs'
* },
* });
*/
export function useFeesQuery(baseOptions: Apollo.QueryHookOptions<FeesQuery, FeesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<FeesQuery, FeesQueryVariables>(FeesDocument, options);
}
export function useFeesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<FeesQuery, FeesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<FeesQuery, FeesQueryVariables>(FeesDocument, options);
}
export type FeesQueryHookResult = ReturnType<typeof useFeesQuery>;
export type FeesLazyQueryHookResult = ReturnType<typeof useFeesLazyQuery>;
export type FeesQueryResult = Apollo.QueryResult<FeesQuery, FeesQueryVariables>;
@@ -0,0 +1,36 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
export const FeeCard = ({
children,
title,
className,
loading = false,
}: {
children: ReactNode;
title: string;
className?: string;
loading?: boolean;
}) => {
return (
<div
className={classNames(
'p-4 bg-vega-clight-800 dark:bg-vega-cdark-800 col-span-full lg:col-auto',
'rounded-lg',
className
)}
>
<h2 className="mb-3">{title}</h2>
{loading ? <FeeCardLoader /> : children}
</div>
);
};
export const FeeCardLoader = () => {
return (
<div className="flex flex-col gap-2">
<div className="w-full h-5 bg-vega-clight-600 dark:bg-vega-cdark-600" />
<div className="w-3/4 h-6 bg-vega-clight-600 dark:bg-vega-cdark-600" />
</div>
);
};
@@ -0,0 +1,103 @@
import { render, screen } from '@testing-library/react';
import { formatNumber } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
import { CurrentVolume, TradingFees } from './fees-container';
import { formatPercentage, getAdjustedFee } from './utils';
describe('TradingFees', () => {
it('renders correct fee data', () => {
const makerFee = 0.01;
const infraFee = 0.01;
const minLiqFee = 0.1;
const maxLiqFee = 0.3;
const referralDiscount = 0.01;
const volumeDiscount = 0.01;
const makerBigNum = new BigNumber(makerFee);
const infraBigNum = new BigNumber(infraFee);
const minLiqBigNum = new BigNumber(minLiqFee);
const maxLiqBigNum = new BigNumber(maxLiqFee);
const referralBigNum = new BigNumber(referralDiscount);
const volumeBigNum = new BigNumber(volumeDiscount);
const props = {
params: {
market_fee_factors_makerFee: makerFee.toString(),
market_fee_factors_infrastructureFee: infraFee.toString(),
},
markets: [
{ fees: { factors: { liquidityFee: minLiqFee.toString() } } },
{ fees: { factors: { liquidityFee: '0.2' } } },
{ fees: { factors: { liquidityFee: maxLiqFee.toString() } } },
],
referralDiscount,
volumeDiscount,
};
render(<TradingFees {...props} />);
const minFee = formatPercentage(
makerBigNum.plus(infraFee).plus(minLiqFee).toNumber()
);
const maxFee = formatPercentage(
makerBigNum.plus(infraFee).plus(maxLiqFee).toNumber()
);
expect(
screen.getByText('Total fee before discount').nextElementSibling
).toHaveTextContent(`${minFee}%-${maxFee}%`);
expect(
screen.getByText('Infrastructure').nextElementSibling
).toHaveTextContent(formatPercentage(infraFee) + '%');
expect(screen.getByText('Maker').nextElementSibling).toHaveTextContent(
formatPercentage(makerFee) + '%'
);
const minAdjustedFees = formatPercentage(
getAdjustedFee(
[makerBigNum, infraBigNum, minLiqBigNum],
[referralBigNum, volumeBigNum]
)
);
const maxAdjustedFees = formatPercentage(
getAdjustedFee(
[makerBigNum, infraBigNum, maxLiqBigNum],
[referralBigNum, volumeBigNum]
)
);
expect(screen.getByTestId('adjusted-fees')).toHaveTextContent(
`${minAdjustedFees}%-${maxAdjustedFees}%`
);
});
});
describe('CurerntVolume', () => {
it('renders the required amount for the next tier', () => {
const windowLengthVolume = 1500;
const nextTierVolume = 2000;
const props = {
tiers: [
{ minimumRunningNotionalTakerVolume: '1000' },
{ minimumRunningNotionalTakerVolume: nextTierVolume.toString() },
{ minimumRunningNotionalTakerVolume: '3000' },
],
tierIndex: 0,
windowLengthVolume,
epochs: 5,
};
render(<CurrentVolume {...props} />);
expect(
screen.getByText(formatNumber(windowLengthVolume)).nextElementSibling
).toHaveTextContent(`Past ${props.epochs} epochs`);
expect(
screen.getByText(formatNumber(nextTierVolume - windowLengthVolume))
.nextElementSibling
).toHaveTextContent('Required for next tier');
});
});
@@ -0,0 +1,490 @@
import maxBy from 'lodash/maxBy';
import minBy from 'lodash/minBy';
import { t } from '@vegaprotocol/i18n';
import { useVegaWallet } from '@vegaprotocol/wallet';
import {
useNetworkParams,
NetworkParams,
} from '@vegaprotocol/network-parameters';
import { useMarketList } from '@vegaprotocol/markets';
import { formatNumber, formatNumberRounded } from '@vegaprotocol/utils';
import { useDiscountProgramsQuery, useFeesQuery } from './__generated__/Fees';
import { FeeCard } from './fees-card';
import { MarketFees } from './market-fees';
import { Stat } from './stat';
import { useVolumeStats } from './use-volume-stats';
import { useReferralStats } from './use-referral-stats';
import { formatPercentage, getAdjustedFee } from './utils';
import { Table, Td, Th, THead, Tr } from './table';
import BigNumber from 'bignumber.js';
export const FeesContainer = () => {
const { pubKey } = useVegaWallet();
const { params, loading: paramsLoading } = useNetworkParams([
NetworkParams.market_fee_factors_makerFee,
NetworkParams.market_fee_factors_infrastructureFee,
]);
const { data: markets, loading: marketsLoading } = useMarketList();
const { data: programData, loading: programLoading } =
useDiscountProgramsQuery();
const volumeDiscountEpochs =
programData?.currentVolumeDiscountProgram?.windowLength || 1;
const referralDiscountEpochs =
programData?.currentReferralProgram?.windowLength || 1;
const { data: feesData, loading: feesLoading } = useFeesQuery({
variables: {
partyId: pubKey || '',
volumeDiscountEpochs,
referralDiscountEpochs,
},
skip: !pubKey || !programData,
});
const { volumeDiscount, volumeTierIndex, volumeInWindow, volumeTiers } =
useVolumeStats(
feesData?.volumeDiscountStats,
programData?.currentVolumeDiscountProgram
);
const {
referralDiscount,
referralVolumeInWindow,
referralTierIndex,
referralTiers,
epochsInSet,
} = useReferralStats(
feesData?.referralSetStats,
feesData?.referralSetReferees,
programData?.currentReferralProgram,
feesData?.epoch
);
const loading = paramsLoading || feesLoading || programLoading;
const isConnected = Boolean(pubKey);
return (
<div className="grid auto-rows-min grid-cols-4 gap-3">
{isConnected && (
<>
<FeeCard
title={t('My trading fees')}
className="sm:col-span-2"
loading={loading}
>
<TradingFees
params={params}
markets={markets}
referralDiscount={referralDiscount}
volumeDiscount={volumeDiscount}
/>
</FeeCard>
<FeeCard
title={t('Total discount')}
className="sm:col-span-2"
loading={loading}
>
<TotalDiscount
referralDiscount={referralDiscount}
volumeDiscount={volumeDiscount}
/>
</FeeCard>
<FeeCard
title={t('My current volume')}
className="sm:col-span-2"
loading={loading}
>
<CurrentVolume
tiers={volumeTiers}
tierIndex={volumeTierIndex}
windowLengthVolume={volumeInWindow}
epochs={volumeDiscountEpochs}
/>
</FeeCard>
<FeeCard
title={t('Referral benefits')}
className="sm:col-span-2"
loading={loading}
>
<ReferralBenefits
setRunningNotionalTakerVolume={referralVolumeInWindow}
epochsInSet={epochsInSet}
epochs={referralDiscountEpochs}
/>
</FeeCard>
</>
)}
<FeeCard
title={t('Volume discount')}
className="lg:col-span-full xl:col-span-2"
loading={loading}
>
<VolumeTiers
tiers={volumeTiers}
tierIndex={volumeTierIndex}
lastEpochVolume={volumeInWindow}
/>
</FeeCard>
<FeeCard
title={t('Referral discount')}
className="lg:col-span-full xl:col-span-2"
loading={loading}
>
<ReferralTiers
tiers={referralTiers}
tierIndex={referralTierIndex}
epochsInSet={epochsInSet}
referralVolumeInWindow={referralVolumeInWindow}
/>
</FeeCard>
<FeeCard
title={t('Liquidity fees')}
className="lg:col-span-full"
loading={marketsLoading}
>
<MarketFees
markets={markets}
referralDiscount={referralDiscount}
volumeDiscount={volumeDiscount}
/>
</FeeCard>
</div>
);
};
export const TradingFees = ({
params,
markets,
referralDiscount,
volumeDiscount,
}: {
params: {
market_fee_factors_infrastructureFee: string;
market_fee_factors_makerFee: string;
};
markets: Array<{ fees: { factors: { liquidityFee: string } } }> | null;
referralDiscount: number;
volumeDiscount: number;
}) => {
const referralDiscountBigNum = new BigNumber(referralDiscount);
const volumeDiscountBigNum = new BigNumber(volumeDiscount);
// Show min and max liquidity fees from all markets
const minLiq = minBy(markets, (m) => Number(m.fees.factors.liquidityFee));
const maxLiq = maxBy(markets, (m) => Number(m.fees.factors.liquidityFee));
const total = new BigNumber(params.market_fee_factors_makerFee).plus(
new BigNumber(params.market_fee_factors_infrastructureFee)
);
const adjustedTotal = getAdjustedFee(
[total],
[referralDiscountBigNum, volumeDiscountBigNum]
);
let minTotal;
let maxTotal;
let minAdjustedTotal;
let maxAdjustedTotal;
if (minLiq && maxLiq) {
const minLiqFee = new BigNumber(minLiq.fees.factors.liquidityFee);
const maxLiqFee = new BigNumber(maxLiq.fees.factors.liquidityFee);
minTotal = total.plus(minLiqFee);
maxTotal = total.plus(maxLiqFee);
minAdjustedTotal = getAdjustedFee(
[total, minLiqFee],
[referralDiscountBigNum, volumeDiscountBigNum]
);
maxAdjustedTotal = getAdjustedFee(
[total, maxLiqFee],
[referralDiscountBigNum, volumeDiscountBigNum]
);
}
return (
<div>
<div className="pt-6 leading-none">
<p className="block text-3xl leading-none" data-testid="adjusted-fees">
{minAdjustedTotal !== undefined && maxAdjustedTotal !== undefined
? `${formatPercentage(minAdjustedTotal)}%-${formatPercentage(
maxAdjustedTotal
)}%`
: `${formatPercentage(adjustedTotal)}%`}
</p>
<table className="w-full mt-0.5 text-xs text-muted">
<tbody>
<tr>
<th className="font-normal text-left text-default">
{t('Total fee before discount')}
</th>
<td className="text-right text-default">
{minTotal !== undefined && maxTotal !== undefined
? `${formatPercentage(
minTotal.toNumber()
)}%-${formatPercentage(maxTotal.toNumber())}%`
: `${formatPercentage(total.toNumber())}%`}
</td>
</tr>
<tr>
<th className="font-normal text-left">{t('Infrastructure')}</th>
<td className="text-right">
{formatPercentage(
Number(params.market_fee_factors_infrastructureFee)
)}
%
</td>
</tr>
<tr>
<th className="font-normal text-left ">{t('Maker')}</th>
<td className="text-right">
{formatPercentage(Number(params.market_fee_factors_makerFee))}%
</td>
</tr>
{minLiq && maxLiq && (
<tr>
<th className="font-normal text-left ">{t('Liquidity')}</th>
<td className="text-right">
{formatPercentage(Number(minLiq.fees.factors.liquidityFee))}%
{'-'}
{formatPercentage(Number(maxLiq.fees.factors.liquidityFee))}%
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
};
export const CurrentVolume = ({
tiers,
tierIndex,
windowLengthVolume,
epochs,
}: {
tiers: Array<{ minimumRunningNotionalTakerVolume: string }>;
tierIndex: number;
windowLengthVolume: number;
epochs: number;
}) => {
const nextTier = tiers[tierIndex + 1];
const requiredForNextTier = nextTier
? Number(nextTier.minimumRunningNotionalTakerVolume) - windowLengthVolume
: 0;
return (
<div>
<Stat
value={formatNumberRounded(new BigNumber(windowLengthVolume))}
text={t('Past %s epochs', epochs.toString())}
/>
{requiredForNextTier > 0 && (
<Stat
value={formatNumber(requiredForNextTier)}
text={t('Required for next tier')}
/>
)}
</div>
);
};
const ReferralBenefits = ({
epochsInSet,
setRunningNotionalTakerVolume,
epochs,
}: {
epochsInSet: number;
setRunningNotionalTakerVolume: number;
epochs: number;
}) => {
return (
<div>
<Stat
// all sets volume (not just current party)
value={formatNumber(setRunningNotionalTakerVolume)}
text={t(
'Combined running notional over the %s epochs',
epochs.toString()
)}
/>
<Stat value={epochsInSet} text={t('epochs in referral set')} />
</div>
);
};
const TotalDiscount = ({
referralDiscount,
volumeDiscount,
}: {
referralDiscount: number;
volumeDiscount: number;
}) => {
return (
<div>
<Stat
value={formatPercentage(referralDiscount + volumeDiscount) + '%'}
highlight={true}
/>
<table className="w-full mt-0.5 text-xs text-muted">
<tbody>
<tr>
<th className="font-normal text-left">{t('Volume discount')}</th>
<td className="text-right">{formatPercentage(volumeDiscount)}%</td>
</tr>
<tr>
<th className="font-normal text-left ">{t('Referral discount')}</th>
<td className="text-right">
{formatPercentage(referralDiscount)}%
</td>
</tr>
</tbody>
</table>
</div>
);
};
const VolumeTiers = ({
tiers,
tierIndex,
lastEpochVolume,
}: {
tiers: Array<{
volumeDiscountFactor: string;
minimumRunningNotionalTakerVolume: string;
}>;
tierIndex: number;
lastEpochVolume: number;
}) => {
if (!tiers.length) {
return (
<p className="text-sm text-muted">
{t('No volume discount program active')}
</p>
);
}
return (
<div>
<Table>
<THead>
<tr>
<Th>{t('Tier')}</Th>
<Th>{t('Discount')}</Th>
<Th>{t('Min. trading volume')}</Th>
<Th>{t('My volume (last epoch)')}</Th>
<Th />
</tr>
</THead>
<tbody>
{Array.from(tiers)
.reverse()
.map((tier, i) => {
const isUserTier = tiers.length - 1 - tierIndex === i;
return (
<Tr key={i}>
<Td>{i + 1}</Td>
<Td>
{formatPercentage(Number(tier.volumeDiscountFactor))}%
</Td>
<Td>
{formatNumber(tier.minimumRunningNotionalTakerVolume)}
</Td>
<Td>{isUserTier ? formatNumber(lastEpochVolume) : ''}</Td>
<Td>{isUserTier ? <YourTier /> : null}</Td>
</Tr>
);
})}
</tbody>
</Table>
</div>
);
};
const ReferralTiers = ({
tiers,
tierIndex,
epochsInSet,
referralVolumeInWindow,
}: {
tiers: Array<{
referralDiscountFactor: string;
minimumRunningNotionalTakerVolume: string;
minimumEpochs: number;
}>;
tierIndex: number;
epochsInSet: number;
referralVolumeInWindow: number;
}) => {
if (!tiers.length) {
return (
<p className="text-sm text-muted">{t('No referral program active')}</p>
);
}
return (
<div>
<Table>
<THead>
<tr>
<Th>{t('Tier')}</Th>
<Th>{t('Discount')}</Th>
<Th>{t('Min. trading volume')}</Th>
<Th>{t('Required epochs')}</Th>
<Th />
</tr>
</THead>
<tbody>
{Array.from(tiers)
.reverse()
.map((t, i) => {
const isUserTier = tiers.length - 1 - tierIndex === i;
const requiredVolume = Number(
t.minimumRunningNotionalTakerVolume
);
let unlocksIn = null;
if (
referralVolumeInWindow >= requiredVolume &&
epochsInSet < t.minimumEpochs
) {
unlocksIn = (
<span className="text-muted">
Unlocks in {t.minimumEpochs - epochsInSet} epochs
</span>
);
}
return (
<Tr key={i}>
<Td>{i + 1}</Td>
<Td>{formatPercentage(Number(t.referralDiscountFactor))}%</Td>
<Td>{formatNumber(t.minimumRunningNotionalTakerVolume)}</Td>
<Td>{t.minimumEpochs}</Td>
<Td>{isUserTier ? <YourTier /> : unlocksIn}</Td>
</Tr>
);
})}
</tbody>
</Table>
</div>
);
};
const YourTier = () => {
return (
<span className="px-4 py-1.5 rounded-xl bg-rainbow whitespace-nowrap text-white">
{t('Your tier')}
</span>
);
};
@@ -0,0 +1 @@
export { FeesContainer } from './fees-container';
@@ -0,0 +1,104 @@
import compact from 'lodash/compact';
import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/markets';
import { AgGrid } from '@vegaprotocol/datagrid';
import { t } from '@vegaprotocol/i18n';
import { formatPercentage, getAdjustedFee } from './utils';
import { MarketCodeCell } from '../../client-pages/markets/market-code-cell';
import BigNumber from 'bignumber.js';
import { useNavigateWithMeta } from '../../lib/hooks/use-market-click-handler';
import { Links } from '../../lib/links';
const feesTableColumnDefs = [
{ field: 'code', cellRenderer: 'MarketCodeCell' },
{
field: 'feeAfterDiscount',
headerName: t('Total fee after discount'),
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'infraFee',
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'makerFee',
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'liquidityFee',
valueFormatter: ({ value }: { value: number }) => value + '%',
},
{
field: 'totalFee',
headerName: t('Total fee before discount'),
valueFormatter: ({ value }: { value: number }) => value + '%',
},
];
const feesTableDefaultColDef = {
flex: 1,
resizable: true,
sortable: true,
};
const components = {
MarketCodeCell,
};
export const MarketFees = ({
markets,
referralDiscount,
volumeDiscount,
}: {
markets: MarketMaybeWithDataAndCandles[] | null;
referralDiscount: number;
volumeDiscount: number;
}) => {
const navigateWithMeta = useNavigateWithMeta();
const rows = compact(markets || []).map((m) => {
const infraFee = new BigNumber(m.fees.factors.infrastructureFee);
const makerFee = new BigNumber(m.fees.factors.makerFee);
const liquidityFee = new BigNumber(m.fees.factors.liquidityFee);
const totalFee = infraFee.plus(makerFee).plus(liquidityFee);
const feeAfterDiscount = getAdjustedFee(
[infraFee, makerFee, liquidityFee],
[new BigNumber(referralDiscount), new BigNumber(volumeDiscount)]
);
return {
id: m.id,
code: m.tradableInstrument.instrument.code,
productType: m.tradableInstrument.instrument.product.__typename,
infraFee: formatPercentage(infraFee.toNumber()),
makerFee: formatPercentage(makerFee.toNumber()),
liquidityFee: formatPercentage(liquidityFee.toNumber()),
totalFee: formatPercentage(totalFee.toNumber()),
feeAfterDiscount: formatPercentage(feeAfterDiscount),
parentMarketID: m.parentMarketID,
successorMarketID: m.successorMarketID,
};
});
return (
<div className="border rounded-sm border-default">
<AgGrid
columnDefs={feesTableColumnDefs}
rowData={rows}
getRowId={({ data }) => data.id}
defaultColDef={feesTableDefaultColDef}
domLayout="autoHeight"
components={components}
rowHeight={45}
rowClass="cursor-pointer"
onRowClicked={({ data, event }) => {
navigateWithMeta(
Links.MARKET(data.id),
// @ts-ignore metaKey and ctrlKey exist
event.metaKey || event.ctrlKey
);
}}
/>
</div>
);
};
@@ -0,0 +1,26 @@
import classNames from 'classnames';
export const Stat = ({
value,
text,
highlight,
}: {
value: string | number;
text?: string;
highlight?: boolean;
}) => {
return (
<p className="pt-3 leading-none first:pt-6">
<span
className={classNames('inline-block text-3xl leading-none', {
'text-transparent bg-rainbow bg-clip-text': highlight,
})}
>
{value}
</span>
{text && (
<small className="block mt-0.5 text-xs text-muted">{text}</small>
)}
</p>
);
};
@@ -0,0 +1,40 @@
import classNames from 'classnames';
import type { ReactNode } from 'react';
const cellClass = 'px-4 py-2 text-xs font-normal text-left last:text-right';
export const Th = ({ children }: { children?: ReactNode }) => {
return (
<th className={classNames(cellClass, 'text-secondary leading-none py-3')}>
{children}
</th>
);
};
export const Td = ({ children }: { children?: ReactNode }) => {
return <th className={cellClass}>{children}</th>;
};
export const Tr = ({ children }: { children?: ReactNode }) => {
return (
<tr className="hover:bg-vega-clight-600 dark:hover:bg-vega-cdark-700">
{children}
</tr>
);
};
export const Table = ({ children }: { children: ReactNode }) => {
return (
<table className="w-full border border-separate rounded-sm border-spacing-0 border-vega-clight-600 dark:border-vega-cdark-600">
{children}
</table>
);
};
export const THead = ({ children }: { children: ReactNode }) => {
return (
<thead className="border-b bg-vega-clight-700 dark:bg-vega-cdark-700 border-vega-clight-600 dark:border-vega-cdark-600">
{children}
</thead>
);
};
@@ -0,0 +1,175 @@
import { renderHook } from '@testing-library/react';
import { useReferralStats } from './use-referral-stats';
describe('useReferralStats', () => {
const setStats = {
edges: [
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 9,
discountFactor: '0.2',
referralSetRunningNotionalTakerVolume: '100',
},
},
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 10,
discountFactor: '0.3',
referralSetRunningNotionalTakerVolume: '200',
},
},
],
};
const sets = {
edges: [
{
node: {
atEpoch: 3,
},
},
{
node: {
atEpoch: 4,
},
},
],
};
const epoch = {
id: '10',
};
const program = {
windowLength: 5,
benefitTiers: [
{
minimumEpochs: 4,
minimumRunningNotionalTakerVolume: '100',
referralDiscountFactor: '0.01',
},
{
minimumEpochs: 6,
minimumRunningNotionalTakerVolume: '200',
referralDiscountFactor: '0.05',
},
{
minimumEpochs: 8,
minimumRunningNotionalTakerVolume: '300',
referralDiscountFactor: '0.1',
},
],
};
it('returns correct default values', () => {
const { result } = renderHook(() => useReferralStats());
expect(result.current).toEqual({
referralDiscount: 0,
referralVolumeInWindow: 0,
referralTierIndex: -1,
referralTiers: [],
epochsInSet: 0,
});
});
it('returns formatted data and tiers', () => {
const { result } = renderHook(() =>
useReferralStats(setStats, sets, program, epoch)
);
// should use stats from latest epoch
const stats = setStats.edges[1].node;
const set = sets.edges[1].node;
expect(result.current).toEqual({
referralDiscount: Number(stats.discountFactor),
referralVolumeInWindow: Number(
stats.referralSetRunningNotionalTakerVolume
),
referralTierIndex: 1,
referralTiers: program.benefitTiers,
epochsInSet: Number(epoch.id) - set.atEpoch,
});
});
it.each([
{ joinedAt: 2, index: -1 },
{ joinedAt: 3, index: -1 },
{ joinedAt: 4, index: 0 },
{ joinedAt: 5, index: 0 },
{ joinedAt: 6, index: 1 },
{ joinedAt: 7, index: 1 },
{ joinedAt: 8, index: 2 },
{ joinedAt: 9, index: 2 },
])('joined at epoch: $joinedAt should be index: $index', (obj) => {
const statsA = {
edges: [
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 10,
discountFactor: '0.3',
referralSetRunningNotionalTakerVolume: '100000',
},
},
],
};
const setsA = {
edges: [
{
node: {
atEpoch: Number(epoch.id) - obj.joinedAt,
},
},
],
};
const { result } = renderHook(() =>
useReferralStats(statsA, setsA, program, epoch)
);
expect(result.current.referralTierIndex).toEqual(obj.index);
});
it.each([
{ volume: '50', index: -1 },
{ volume: '100', index: 0 },
{ volume: '150', index: 0 },
{ volume: '200', index: 1 },
{ volume: '250', index: 1 },
{ volume: '300', index: 2 },
{ volume: '999', index: 2 },
])('volume: $volume should be index: $index', (obj) => {
const statsA = {
edges: [
{
__typename: 'ReferralSetStatsEdge' as const,
node: {
__typename: 'ReferralSetStats' as const,
atEpoch: 10,
discountFactor: '0.3',
referralSetRunningNotionalTakerVolume: obj.volume,
},
},
],
};
const setsA = {
edges: [
{
node: {
atEpoch: 1,
},
},
],
};
const { result } = renderHook(() =>
useReferralStats(statsA, setsA, program, epoch)
);
expect(result.current.referralTierIndex).toEqual(obj.index);
});
});
@@ -0,0 +1,52 @@
import compact from 'lodash/compact';
import maxBy from 'lodash/maxBy';
import { getReferralBenefitTier } from './utils';
import type { DiscountProgramsQuery, FeesQuery } from './__generated__/Fees';
export const useReferralStats = (
setStats?: FeesQuery['referralSetStats'],
setReferees?: FeesQuery['referralSetReferees'],
program?: DiscountProgramsQuery['currentReferralProgram'],
epoch?: FeesQuery['epoch']
) => {
const referralTiers = program?.benefitTiers || [];
if (!setStats || !setReferees || !program || !epoch) {
return {
referralDiscount: 0,
referralVolumeInWindow: 0,
referralTierIndex: -1,
referralTiers,
epochsInSet: 0,
};
}
const referralSetsStats = compact(setStats.edges).map((e) => e.node);
const referralSets = compact(setReferees.edges).map((e) => e.node);
const referralSet = maxBy(referralSets, (s) => s.atEpoch);
const referralStats = maxBy(referralSetsStats, (s) => s.atEpoch);
const epochsInSet = referralSet ? Number(epoch.id) - referralSet.atEpoch : 0;
const referralDiscount = Number(referralStats?.discountFactor || 0);
const referralVolumeInWindow = Number(
referralStats?.referralSetRunningNotionalTakerVolume || 0
);
const referralTierIndex = referralStats
? getReferralBenefitTier(
epochsInSet,
Number(referralStats.referralSetRunningNotionalTakerVolume),
referralTiers
)
: -1;
return {
referralDiscount,
referralVolumeInWindow,
referralTierIndex,
referralTiers,
epochsInSet,
};
};
@@ -0,0 +1,95 @@
import { renderHook } from '@testing-library/react';
import { useVolumeStats } from './use-volume-stats';
describe('useReferralStats', () => {
const statsList = {
edges: [
{
__typename: 'VolumeDiscountStatsEdge' as const,
node: {
__typename: 'VolumeDiscountStats' as const,
atEpoch: 9,
discountFactor: '0.1',
runningVolume: '100',
},
},
{
__typename: 'VolumeDiscountStatsEdge' as const,
node: {
__typename: 'VolumeDiscountStats' as const,
atEpoch: 10,
discountFactor: '0.3',
runningVolume: '200',
},
},
],
};
const program = {
windowLength: 5,
benefitTiers: [
{
minimumRunningNotionalTakerVolume: '100',
volumeDiscountFactor: '0.01',
},
{
minimumRunningNotionalTakerVolume: '200',
volumeDiscountFactor: '0.05',
},
{
minimumRunningNotionalTakerVolume: '300',
volumeDiscountFactor: '0.1',
},
],
};
it('returns correct default values', () => {
const { result } = renderHook(() => useVolumeStats());
expect(result.current).toEqual({
volumeDiscount: 0,
volumeInWindow: 0,
volumeTierIndex: -1,
volumeTiers: [],
});
});
it('returns formatted data and tiers', () => {
const { result } = renderHook(() => useVolumeStats(statsList, program));
// should use stats from latest epoch
const stats = statsList.edges[1].node;
expect(result.current).toEqual({
volumeDiscount: Number(stats.discountFactor),
volumeInWindow: Number(stats.runningVolume),
volumeTierIndex: 1,
volumeTiers: program.benefitTiers,
});
});
it.each([
{ volume: '100', index: 0 },
{ volume: '150', index: 0 },
{ volume: '200', index: 1 },
{ volume: '250', index: 1 },
{ volume: '300', index: 2 },
{ volume: '350', index: 2 },
])('returns index: $index for the running volume: $volume', (obj) => {
const statsA = {
edges: [
{
__typename: 'VolumeDiscountStatsEdge' as const,
node: {
__typename: 'VolumeDiscountStats' as const,
atEpoch: 10,
discountFactor: '0.3',
runningVolume: obj.volume,
},
},
],
};
const { result } = renderHook(() => useVolumeStats(statsA, program));
expect(result.current.volumeTierIndex).toBe(obj.index);
});
});
@@ -0,0 +1,33 @@
import compact from 'lodash/compact';
import maxBy from 'lodash/maxBy';
import { getVolumeTier } from './utils';
import type { DiscountProgramsQuery, FeesQuery } from './__generated__/Fees';
export const useVolumeStats = (
stats?: FeesQuery['volumeDiscountStats'],
program?: DiscountProgramsQuery['currentVolumeDiscountProgram']
) => {
const volumeTiers = program?.benefitTiers || [];
if (!stats || !program) {
return {
volumeDiscount: 0,
volumeTierIndex: -1,
volumeInWindow: 0,
volumeTiers,
};
}
const volumeStats = compact(stats.edges).map((e) => e.node);
const lastEpochStats = maxBy(volumeStats, (s) => s.atEpoch);
const volumeDiscount = Number(lastEpochStats?.discountFactor || 0);
const volumeInWindow = Number(lastEpochStats?.runningVolume || 0);
const volumeTierIndex = getVolumeTier(volumeInWindow, volumeTiers);
return {
volumeDiscount,
volumeTierIndex,
volumeInWindow,
volumeTiers,
};
};
@@ -0,0 +1,74 @@
import BigNumber from 'bignumber.js';
import { getAdjustedFee } from './utils';
describe('getAdjustedFee', () => {
it('simple', () => {
const volumeDiscount = 0.5;
const referralDiscount = 0.5;
const infraFee = 0.1;
const makerFee = 0.1;
const liqFee = 0.1;
const fees = [
new BigNumber(infraFee),
new BigNumber(makerFee),
new BigNumber(liqFee),
];
const discounts = [
new BigNumber(volumeDiscount),
new BigNumber(referralDiscount),
];
// 1 - 0.5 - 0.5
const v = new BigNumber(1).minus(new BigNumber(volumeDiscount));
// 1 - 0.5 = 0.5
const r = new BigNumber(1).minus(new BigNumber(referralDiscount));
// 0.5 * 0.5 = 0.25
// 1 - 0.25 = 0.75
const factor = new BigNumber(1).minus(v.times(r));
// 0.1 + 0.1 + 0.1 = 0.3
const totalFees = fees.reduce((sum, x) => sum.plus(x), new BigNumber(0));
// 0.3 * 0.75 = 0.225
const expected = new BigNumber(totalFees).times(factor).toNumber();
expect(getAdjustedFee(fees, discounts)).toBe(expected);
});
it('combines discount factors multiplicativly', () => {
const volumeDiscount = 0.4;
const referralDiscount = 0.1;
const infraFee = 0.0005;
const makerFee = 0.0002;
const liqFee = 0.01;
const fees = [
new BigNumber(infraFee),
new BigNumber(makerFee),
new BigNumber(liqFee),
];
const discounts = [
new BigNumber(volumeDiscount),
new BigNumber(referralDiscount),
];
// formula for calculating adjusted fees
const v = new BigNumber(1).minus(new BigNumber(volumeDiscount));
const r = new BigNumber(1).minus(new BigNumber(referralDiscount));
const factor = new BigNumber(1).minus(v.times(r));
// summed fees
const totalFees = fees.reduce((sum, x) => sum.plus(x), new BigNumber(0));
const expected = new BigNumber(totalFees).times(factor).toNumber();
expect(getAdjustedFee(fees, discounts)).toBe(expected);
});
});
@@ -0,0 +1,105 @@
import { getUserLocale } from '@vegaprotocol/utils';
import BigNumber from 'bignumber.js';
/**
* Convert a number between 0-1 into a percentage value between 0-100
*
* Not using formatNumberPercentage from vegaprotocol/utils as this
* returns a string and includes extra 0s on the end. We need these
* values in aggrid as numbers for sorting
*/
export const formatPercentage = (num: number) => {
const pct = new BigNumber(num).times(100);
const dps = pct.decimalPlaces();
const formatter = new Intl.NumberFormat(getUserLocale(), {
minimumFractionDigits: dps || 0,
maximumFractionDigits: dps || 0,
});
return formatter.format(parseFloat(pct.toFixed(5)));
};
/**
* Return the index of the benefit tier for volume discounts. A user
* only needs to fulfill a minimum volume requirement for the tier
*/
export const getVolumeTier = (
volume: number,
tiers: Array<{
minimumRunningNotionalTakerVolume: string;
}>
) => {
return tiers.findIndex((tier, i) => {
const nextTier = tiers[i + 1];
const validVolume =
volume >= Number(tier.minimumRunningNotionalTakerVolume);
if (nextTier) {
return (
validVolume &&
volume < Number(nextTier.minimumRunningNotionalTakerVolume)
);
}
return validVolume;
});
};
/**
* Return the index of the benefit tiers for referrals. A user must
* fulfill both the minimum epochs in the referral set, and the set
* must reach the combined total volume
*/
export const getReferralBenefitTier = (
epochsInSet: number,
volume: number,
tiers: Array<{
minimumRunningNotionalTakerVolume: string;
minimumEpochs: number;
}>
) => {
const indexByEpoch = tiers.findIndex((tier, i) => {
const nextTier = tiers[i + 1];
const validEpochs = epochsInSet >= tier.minimumEpochs;
if (nextTier) {
return validEpochs && epochsInSet < nextTier.minimumEpochs;
}
return validEpochs;
});
const indexByVolume = tiers.findIndex((tier, i) => {
const nextTier = tiers[i + 1];
const validVolume =
volume >= Number(tier.minimumRunningNotionalTakerVolume);
if (nextTier) {
return (
validVolume &&
volume < Number(nextTier.minimumRunningNotionalTakerVolume)
);
}
return validVolume;
});
return Math.min(indexByEpoch, indexByVolume);
};
/**
* Given a set of fees and a set of discounts return
* the adjusted fee factor
*
* Formula for calculating the adjusted fees
* total_discount_factor = 1 - (1 - volumeDiscount) * (1 - referralDiscount)
*/
export const getAdjustedFee = (fees: BigNumber[], discounts: BigNumber[]) => {
const totalFee = fees.reduce((sum, f) => sum.plus(f), new BigNumber(0));
const combinedFactors = discounts.reduce((acc, d) => {
return acc.times(new BigNumber(1).minus(d));
}, new BigNumber(1));
const totalFactor = new BigNumber(1).minus(combinedFactors);
return totalFee.times(BigNumber.max(0, totalFactor)).toNumber();
};
@@ -27,7 +27,7 @@ export const LayoutWithSidebar = ({
<div className={gridClasses}>
<div className="col-span-full">{header}</div>
<main
className={classNames('col-start-1 col-end-1', {
className={classNames('col-start-1 col-end-1 overflow-y-auto', {
'lg:col-end-3': !sidebarOpen,
'hidden lg:block lg:col-end-2': sidebarOpen,
})}
@@ -10,15 +10,28 @@ import {
formatNumberPercentage,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import { ExternalLink, Indicator } from '@vegaprotocol/ui-toolkit';
import {
CopyWithTooltip,
ExternalLink,
Indicator,
VegaIcon,
VegaIconNames,
truncateMiddle,
} from '@vegaprotocol/ui-toolkit';
import { DocsLinks } from '@vegaprotocol/environment';
import { useCheckLiquidityStatus } from '@vegaprotocol/liquidity';
import {
useCheckLiquidityStatus,
usePaidFeesQuery,
} from '@vegaprotocol/liquidity';
import { useParams } from 'react-router-dom';
export const LiquidityHeader = () => {
const { marketId } = useParams();
const { data: market } = useMarket(marketId);
const { data: marketData } = useStaticMarketData(marketId);
const { data: feesPaidRes } = usePaidFeesQuery({
variables: { marketId: marketId || '' },
});
const targetStake = marketData?.targetStake;
const suppliedStake = marketData?.suppliedStake;
@@ -36,6 +49,10 @@ export const LiquidityHeader = () => {
triggeringRatio,
});
const feesObject = feesPaidRes?.paidLiquidityFees?.edges?.find(
(e) => e?.node.marketId === marketId
);
return (
<Header
title={
@@ -82,9 +99,40 @@ export const LiquidityHeader = () => {
<HeaderStat heading={t('Liquidity supplied')} testId="liquidity-supplied">
<Indicator variant={status} /> {formatNumberPercentage(percentage, 2)}
</HeaderStat>
<HeaderStat heading={t('Market ID')} testId="liquidity-market-id">
<div className="break-word">{marketId}</div>
<HeaderStat
heading={t('Fees paid')}
description={t(
'The amount of fees paid to liquidity providers across the whole market during the last epoch %s.',
feesObject?.node.epoch.toString() || '-'
)}
testId="fees-paid"
>
<div>
{feesObject?.node.totalFeesPaid
? `${addDecimalsFormatNumber(
feesObject?.node.totalFeesPaid,
assetDecimalPlaces ?? 0
)} ${symbol}`
: '-'}
</div>
</HeaderStat>
{marketId && (
<HeaderStat heading={t('Market ID')} testId="liquidity-market-id">
<div className="break-word">
<CopyWithTooltip text={marketId}>
<button
data-testid="copy-eth-oracle-address"
className="uppercase text-right"
>
<span className="flex gap-1">
{truncateMiddle(marketId)}
<VegaIcon name={VegaIconNames.COPY} size={16} />
</span>
</button>
</CopyWithTooltip>
</div>
</HeaderStat>
)}
<HeaderStat heading={t('Learn more')} testId="liquidity-learn-more">
{DocsLinks ? (
<ExternalLink href={DocsLinks.LIQUIDITY}>
@@ -67,6 +67,7 @@ describe('Navbar', () => {
[`/markets/${marketId}`, 'Trading'],
['/portfolio', 'Portfolio'],
['/referrals', 'Referrals'],
['/fees', 'Fees'],
[expect.stringContaining('governance'), 'Governance'],
];
@@ -100,6 +101,7 @@ describe('Navbar', () => {
[`/markets/${marketId}`, 'Trading'],
['/portfolio', 'Portfolio'],
['/referrals', 'Referrals'],
['/fees', 'Fees'],
[expect.stringContaining('governance'), 'Governance'],
];
const links = menu.getAllByRole('link');
@@ -187,6 +187,11 @@ const NavbarMenu = ({ onClick }: { onClick: () => void }) => {
</NavbarLink>
</NavbarItem>
)}
<NavbarItem>
<NavbarLink to={Links.FEES()} onClick={onClick}>
{t('Fees')}
</NavbarLink>
</NavbarItem>
<NavbarItem>
<NavbarLinkExternal to={useLinks(DApp.Governance)()}>
{t('Governance')}
+2
View File
@@ -17,6 +17,7 @@ export const Routes = {
REFERRALS_APPLY_CODE: '/referrals/apply-code',
REFERRALS_CREATE_CODE: '/referrals/create-code',
TEAMS: '/teams',
FEES: '/fees',
} as const;
type ConsoleLinks = {
@@ -40,4 +41,5 @@ export const Links: ConsoleLinks = {
REFERRALS_APPLY_CODE: () => Routes.REFERRALS_APPLY_CODE,
REFERRALS_CREATE_CODE: () => Routes.REFERRALS_CREATE_CODE,
TEAMS: () => Routes.TEAMS,
FEES: () => Routes.FEES,
};
+17 -2
View File
@@ -13,6 +13,7 @@ import { Assets } from '../client-pages/assets';
import { Deposit } from '../client-pages/deposit';
import { Withdraw } from '../client-pages/withdraw';
import { Transfer } from '../client-pages/transfer';
import { Fees } from '../client-pages/fees';
import { Routes as AppRoutes } from '../lib/links';
import { LayoutWithSky } from '../client-pages/referrals/layout';
import { Referrals } from '../client-pages/referrals/referrals';
@@ -55,10 +56,14 @@ export const routerConfig: RouteObject[] = compact([
FLAGS.REFERRALS
? {
path: AppRoutes.REFERRALS,
element: <LayoutWithSky />,
element: <LayoutWithSidebar sidebar={<PortfolioSidebar />} />,
children: [
{
element: <Referrals />,
element: (
<LayoutWithSky>
<Referrals />
</LayoutWithSky>
),
children: [
{
index: true,
@@ -81,6 +86,16 @@ export const routerConfig: RouteObject[] = compact([
],
}
: undefined,
{
path: 'fees/*',
element: <LayoutWithSidebar sidebar={<PortfolioSidebar />} />,
children: [
{
index: true,
element: <Fees />,
},
],
},
{
path: 'markets/*',
element: (
+4 -4
View File
@@ -184,10 +184,10 @@ html [data-theme='dark'] {
/* Light variables */
.ag-theme-balham {
--ag-background-color: theme(colors.white);
--ag-background-color: transparent;
--ag-border-color: theme(colors.vega.clight.600);
--ag-header-background-color: theme(colors.vega.clight.700);
--ag-odd-row-background-color: theme(colors.white);
--ag-odd-row-background-color: transparent;
--ag-header-column-separator-color: theme(colors.vega.clight.500);
--ag-row-border-color: theme(colors.vega.clight.600);
--ag-row-hover-color: theme(colors.vega.clight.800);
@@ -196,10 +196,10 @@ html [data-theme='dark'] {
/* Dark variables */
.ag-theme-balham-dark {
--ag-background-color: theme(colors.vega.cdark.900);
--ag-background-color: transparent;
--ag-border-color: theme(colors.vega.cdark.600);
--ag-header-background-color: theme(colors.vega.cdark.700);
--ag-odd-row-background-color: theme(colors.vega.cdark.900);
--ag-odd-row-background-color: transparent;
--ag-header-column-separator-color: theme(colors.vega.cdark.500);
--ag-row-border-color: theme(colors.vega.cdark.600);
--ag-row-hover-color: theme(colors.vega.cdark.800);
+2 -2
View File
@@ -1,5 +1,5 @@
import { ToastsContainer, useToasts } from '@vegaprotocol/ui-toolkit';
import { useUpdateNetworkParametersToasts } from '@vegaprotocol/proposals';
import { useProposalToasts } from '@vegaprotocol/proposals';
import { useVegaTransactionToasts } from '@vegaprotocol/web3';
import { useEthereumTransactionToasts } from '@vegaprotocol/web3';
import { useEthereumWithdrawApprovalsToasts } from '@vegaprotocol/web3';
@@ -7,7 +7,7 @@ import { useReadyToWithdrawalToasts } from '@vegaprotocol/withdraws';
import { Links } from '../lib/links';
export const ToastsManager = () => {
useUpdateNetworkParametersToasts();
useProposalToasts();
useVegaTransactionToasts();
useEthereumTransactionToasts();
useEthereumWithdrawApprovalsToasts();
Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

After

Width:  |  Height:  |  Size: 419 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 947 KiB

After

Width:  |  Height:  |  Size: 419 KiB

@@ -112,6 +112,8 @@ const USE_ACCOUNT_TYPES = [
AccountType.ACCOUNT_TYPE_FEES_LIQUIDITY,
AccountType.ACCOUNT_TYPE_FEES_MAKER,
AccountType.ACCOUNT_TYPE_PENDING_TRANSFERS,
AccountType.ACCOUNT_TYPE_VESTED_REWARDS,
AccountType.ACCOUNT_TYPE_VESTING_REWARDS,
];
const getAssetIds = (data: Account[]) =>
+4 -3
View File
@@ -33,7 +33,7 @@ import BigNumber from 'bignumber.js';
import classNames from 'classnames';
import { AccountsActionsDropdown } from './accounts-actions-dropdown';
const colorClass = (percentageUsed: number, neutral = false) => {
const colorClass = (percentageUsed: number) => {
return classNames('text-right', {
'text-vega-orange': percentageUsed >= 75 && percentageUsed < 90,
'text-vega-red': percentageUsed >= 90,
@@ -210,7 +210,7 @@ export const AccountTable = ({
},
cellClass: ({ data }) => {
const percentageUsed = percentageValue(data?.used, data?.total);
return colorClass(percentageUsed, true);
return colorClass(percentageUsed);
},
valueFormatter: ({
value,
@@ -270,7 +270,8 @@ export const AccountTable = ({
onClickDeposit && onClickDeposit(assetId);
}}
>
<VegaIcon name={VegaIconNames.DEPOSIT} /> {t('Deposit')}
<VegaIcon name={VegaIconNames.DEPOSIT} size={14} />{' '}
{t('Deposit')}
</TradingButton>
</CenteredGridCellWrapper>
);
+22 -15
View File
@@ -9,12 +9,17 @@ import { useDataProvider } from '@vegaprotocol/data-provider';
import type { Transfer } from '@vegaprotocol/wallet';
import { useVegaTransactionStore } from '@vegaprotocol/web3';
import { useVegaWallet } from '@vegaprotocol/wallet';
import { useCallback, useMemo } from 'react';
import { useCallback } from 'react';
import { accountsDataProvider } from './accounts-data-provider';
import { TransferForm } from './transfer-form';
import sortBy from 'lodash/sortBy';
import { Lozenge } from '@vegaprotocol/ui-toolkit';
export const ALLOWED_ACCOUNTS = [
Schema.AccountType.ACCOUNT_TYPE_GENERAL,
Schema.AccountType.ACCOUNT_TYPE_VESTED_REWARDS,
];
export const TransferContainer = ({ assetId }: { assetId?: string }) => {
const { pubKey, pubKeys } = useVegaWallet();
const { param } = useNetworkParam(NetworkParams.transfer_fee_factor);
@@ -33,20 +38,21 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => {
[create]
);
const assets = useMemo(() => {
if (!data) return [];
return data
.filter(
(account) => account.type === Schema.AccountType.ACCOUNT_TYPE_GENERAL
)
.map((account) => ({
id: account.asset.id,
symbol: account.asset.symbol,
name: account.asset.name,
decimals: account.asset.decimals,
balance: addDecimal(account.balance, account.asset.decimals),
}));
}, [data]);
const accounts = data
? data.filter((account) => ALLOWED_ACCOUNTS.includes(account.type))
: [];
const assets = accounts
// Theres only one general account for each asset, this will give us a list
// of assets the user has accounts for
.filter((a) => a.type === Schema.AccountType.ACCOUNT_TYPE_GENERAL)
.map((account) => ({
id: account.asset.id,
symbol: account.asset.symbol,
name: account.asset.name,
decimals: account.asset.decimals,
balance: addDecimal(account.balance, account.asset.decimals),
}));
return (
<>
@@ -69,6 +75,7 @@ export const TransferContainer = ({ assetId }: { assetId?: string }) => {
assetId={assetId}
feeFactor={param}
submitTransfer={transfer}
accounts={accounts}
/>
</>
);
+147 -161
View File
@@ -1,18 +1,38 @@
import {
act,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import BigNumber from 'bignumber.js';
import { AddressField, TransferFee, TransferForm } from './transfer-form';
import { AccountType } from '@vegaprotocol/types';
import { addDecimal, formatNumber, removeDecimal } from '@vegaprotocol/utils';
import userEvent from '@testing-library/user-event';
describe('TransferForm', () => {
const submit = () => fireEvent.submit(screen.getByTestId('transfer-form'));
const submit = async () => {
await userEvent.click(
screen.getByRole('button', { name: 'Confirm transfer' })
);
};
const selectAsset = async (asset: {
id: string;
balance: string;
name: string;
decimals: number;
}) => {
// Bypass RichSelect and target hidden native select
// eslint-disable-next-line
fireEvent.change(document.querySelector('select[name="asset"]')!, {
target: { value: asset.id },
});
// assert rich select as updated
expect(await screen.findByTestId('select-asset')).toHaveTextContent(
asset.name
);
expect(await screen.findByTestId('asset-balance')).toHaveTextContent(
formatNumber(asset.balance, asset.decimals)
);
};
const amount = '100';
const pubKey =
'70d14a321e02e71992fd115563df765000ccc4775cbe71a0e2f9ff5a3b9dc680';
@@ -32,6 +52,18 @@ describe('TransferForm', () => {
assets: [asset],
feeFactor: '0.001',
submitTransfer: jest.fn(),
accounts: [
{
type: AccountType.ACCOUNT_TYPE_GENERAL,
asset,
balance: '100',
},
{
type: AccountType.ACCOUNT_TYPE_VESTED_REWARDS,
asset,
balance: '100',
},
],
};
it('form tooltips correctly displayed', async () => {
@@ -42,49 +74,45 @@ describe('TransferForm', () => {
// 1003-TRAN-019
render(<TransferForm {...props} />);
// Select a pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: props.pubKeys[1] },
});
await userEvent.selectOptions(
screen.getByLabelText('Vega key'),
props.pubKeys[1]
);
// Select asset
fireEvent.change(
// Bypass RichSelect and target hidden native select
// eslint-disable-next-line
document.querySelector('select[name="asset"]')!,
{ target: { value: asset.id } }
);
await selectAsset(asset);
// set valid amount
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: amount },
});
const amountInput = screen.getByLabelText('Amount');
await userEvent.type(amountInput, amount);
expect(amountInput).toHaveValue(amount);
userEvent.hover(screen.getByText('Include transfer fee'));
const includeTransferLabel = screen.getByText('Include transfer fee');
await userEvent.hover(includeTransferLabel);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
'The fee will be taken from the amount you are transferring.'
);
await userEvent.unhover(screen.getByText('Include transfer fee'));
await waitFor(() => {
const tooltips = screen.getAllByTestId('tooltip-content');
expect(tooltips[0]).toBeVisible();
});
const transferFee = screen.getByText('Transfer fee');
await userEvent.hover(transferFee);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
/transfer.fee.factor/
);
await userEvent.unhover(transferFee);
userEvent.hover(screen.getByText('Transfer fee'));
const amountToBeTransferred = screen.getByText('Amount to be transferred');
await userEvent.hover(amountToBeTransferred);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
/without the fee/
);
await userEvent.unhover(amountToBeTransferred);
await waitFor(() => {
const tooltips = screen.getAllByTestId('tooltip-content');
expect(tooltips[0]).toBeVisible();
});
userEvent.hover(screen.getByText('Amount to be transferred'));
await waitFor(() => {
const tooltips = screen.getAllByTestId('tooltip-content');
expect(tooltips[0]).toBeVisible();
});
userEvent.hover(screen.getByText('Total amount (with fee)'));
await waitFor(() => {
const tooltips = screen.getAllByTestId('tooltip-content');
expect(tooltips[0]).toBeVisible();
});
const totalAmountWithFee = screen.getByText('Total amount (with fee)');
await userEvent.hover(totalAmountWithFee);
expect(await screen.findByRole('tooltip')).toHaveTextContent(
/total amount taken from your account/
);
});
it('validates a manually entered address', async () => {
@@ -92,30 +120,17 @@ describe('TransferForm', () => {
// 1003-TRAN-013
// 1003-TRAN-004
render(<TransferForm {...props} />);
submit();
expect(await screen.findAllByText('Required')).toHaveLength(3);
await submit();
expect(await screen.findAllByText('Required')).toHaveLength(4);
const toggle = screen.getByText('Enter manually');
fireEvent.click(toggle);
await userEvent.click(toggle);
// has switched to input
expect(toggle).toHaveTextContent('Select from wallet');
expect(screen.getByLabelText('Vega key')).toHaveAttribute('type', 'text');
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: 'invalid-address' },
});
await waitFor(() => {
const errors = screen.getAllByTestId('input-error-text');
expect(errors[0]).toHaveTextContent('Invalid Vega key');
});
// same pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: pubKey },
});
await waitFor(() => {
const errors = screen.getAllByTestId('input-error-text');
expect(errors[0]).toHaveTextContent('Vega key is the same');
});
await userEvent.type(screen.getByLabelText('Vega key'), 'invalid-address');
expect(screen.getAllByTestId('input-error-text')[0]).toHaveTextContent(
'Invalid Vega key'
);
});
it('validates fields and submits', async () => {
@@ -127,28 +142,25 @@ describe('TransferForm', () => {
render(<TransferForm {...props} />);
// check current pubkey not shown
const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key');
expect(keySelect.children).toHaveLength(2);
const keySelect = screen.getByLabelText<HTMLSelectElement>('Vega key');
expect(keySelect.children).toHaveLength(3);
expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([
'',
pubKey,
props.pubKeys[1],
]);
submit();
expect(await screen.findAllByText('Required')).toHaveLength(3);
await submit();
expect(await screen.findAllByText('Required')).toHaveLength(4);
// Select a pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: props.pubKeys[1] },
});
await userEvent.selectOptions(
screen.getByLabelText('Vega key'),
props.pubKeys[1]
);
// Select asset
fireEvent.change(
// Bypass RichSelect and target hidden native select
// eslint-disable-next-line
document.querySelector('select[name="asset"]')!,
{ target: { value: asset.id } }
);
await selectAsset(asset);
// assert rich select as updated
expect(await screen.findByTestId('select-asset')).toHaveTextContent(
@@ -158,37 +170,38 @@ describe('TransferForm', () => {
formatNumber(asset.balance, asset.decimals)
);
await userEvent.selectOptions(
screen.getByLabelText('From account'),
AccountType.ACCOUNT_TYPE_VESTED_REWARDS
);
const amountInput = screen.getByLabelText('Amount');
// Test amount validation
fireEvent.change(amountInput, {
target: { value: '0.00000001' },
});
await userEvent.type(amountInput, '0.00000001');
expect(
await screen.findByText('Value is below minimum')
).toBeInTheDocument();
fireEvent.change(amountInput, {
target: { value: '9999999' },
});
await userEvent.clear(amountInput);
await userEvent.type(amountInput, '9999999');
expect(
await screen.findByText(/cannot transfer more/i)
).toBeInTheDocument();
// set valid amount
fireEvent.change(amountInput, {
target: { value: amount },
});
await userEvent.clear(amountInput);
await userEvent.type(amountInput, amount);
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(
new BigNumber(props.feeFactor).times(amount).toFixed()
);
submit();
await submit();
await waitFor(() => {
expect(props.submitTransfer).toHaveBeenCalledTimes(1);
expect(props.submitTransfer).toHaveBeenCalledWith({
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: props.pubKeys[1],
asset: asset.id,
@@ -200,59 +213,50 @@ describe('TransferForm', () => {
describe('IncludeFeesCheckbox', () => {
it('validates fields and submits when checkbox is checked', async () => {
render(<TransferForm {...props} />);
const mockSubmit = jest.fn();
render(<TransferForm {...props} submitTransfer={mockSubmit} />);
// check current pubkey not shown
const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key');
expect(keySelect.children).toHaveLength(2);
expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([
'',
props.pubKeys[1],
]);
const keySelect = screen.getByLabelText<HTMLSelectElement>('Vega key');
const pubKeyOptions = ['', pubKey, props.pubKeys[1]];
expect(keySelect.children).toHaveLength(pubKeyOptions.length);
expect(Array.from(keySelect.options).map((o) => o.value)).toEqual(
pubKeyOptions
);
submit();
expect(await screen.findAllByText('Required')).toHaveLength(3);
await submit();
expect(await screen.findAllByText('Required')).toHaveLength(4);
// Select a pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: props.pubKeys[1] },
});
await userEvent.selectOptions(
screen.getByLabelText('Vega key'),
props.pubKeys[1]
);
// Select asset
fireEvent.change(
// Bypass RichSelect and target hidden native select
// eslint-disable-next-line
document.querySelector('select[name="asset"]')!,
{ target: { value: asset.id } }
);
await selectAsset(asset);
// assert rich select as updated
expect(await screen.findByTestId('select-asset')).toHaveTextContent(
asset.name
);
expect(await screen.findByTestId('asset-balance')).toHaveTextContent(
formatNumber(asset.balance, asset.decimals)
await userEvent.selectOptions(
screen.getByLabelText('From account'),
AccountType.ACCOUNT_TYPE_VESTED_REWARDS
);
const amountInput = screen.getByLabelText('Amount');
const checkbox = screen.getByTestId('include-transfer-fee');
// 1003-TRAN-022
expect(checkbox).not.toBeChecked();
act(() => {
/* fire events that update state */
// set valid amount
fireEvent.change(amountInput, {
target: { value: amount },
});
// check include fees checkbox
fireEvent.click(checkbox);
});
await userEvent.clear(amountInput);
await userEvent.type(amountInput, amount);
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
const expectedFee = new BigNumber(amount)
.times(props.feeFactor)
.toFixed();
const expectedAmount = new BigNumber(amount).minus(expectedFee).toFixed();
// 1003-TRAN-020
expect(screen.getByTestId('transfer-fee')).toHaveTextContent(expectedFee);
expect(screen.getByTestId('transfer-amount')).toHaveTextContent(
@@ -262,18 +266,17 @@ describe('TransferForm', () => {
amount
);
submit();
await submit();
await waitFor(() => {
// 1003-TRAN-023
expect(props.submitTransfer).toHaveBeenCalledTimes(1);
expect(props.submitTransfer).toHaveBeenCalledWith({
fromAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
expect(mockSubmit).toHaveBeenCalledTimes(1);
expect(mockSubmit).toHaveBeenCalledWith({
fromAccountType: AccountType.ACCOUNT_TYPE_VESTED_REWARDS,
toAccountType: AccountType.ACCOUNT_TYPE_GENERAL,
to: props.pubKeys[1],
asset: asset.id,
amount: removeDecimal(amount, asset.decimals),
amount: removeDecimal(expectedAmount, asset.decimals),
oneOff: {},
});
});
@@ -284,46 +287,29 @@ describe('TransferForm', () => {
// check current pubkey not shown
const keySelect: HTMLSelectElement = screen.getByLabelText('Vega key');
expect(keySelect.children).toHaveLength(2);
expect(Array.from(keySelect.options).map((o) => o.value)).toEqual([
'',
props.pubKeys[1],
]);
const pubKeyOptions = ['', pubKey, props.pubKeys[1]];
expect(keySelect.children).toHaveLength(pubKeyOptions.length);
expect(Array.from(keySelect.options).map((o) => o.value)).toEqual(
pubKeyOptions
);
submit();
expect(await screen.findAllByText('Required')).toHaveLength(3);
await submit();
expect(await screen.findAllByText('Required')).toHaveLength(4);
// Select a pubkey
fireEvent.change(screen.getByLabelText('Vega key'), {
target: { value: props.pubKeys[1] },
});
await userEvent.selectOptions(
screen.getByLabelText('Vega key'),
props.pubKeys[1]
);
// Select asset
fireEvent.change(
// Bypass RichSelect and target hidden native select
// eslint-disable-next-line
document.querySelector('select[name="asset"]')!,
{ target: { value: asset.id } }
);
// assert rich select as updated
expect(await screen.findByTestId('select-asset')).toHaveTextContent(
asset.name
);
expect(await screen.findByTestId('asset-balance')).toHaveTextContent(
formatNumber(asset.balance, asset.decimals)
);
await selectAsset(asset);
const amountInput = screen.getByLabelText('Amount');
const checkbox = screen.getByTestId('include-transfer-fee');
expect(checkbox).not.toBeChecked();
act(() => {
/* fire events that update state */
// set valid amount
fireEvent.change(amountInput, {
target: { value: amount },
});
});
await userEvent.type(amountInput, amount);
expect(checkbox).not.toBeChecked();
const expectedFee = new BigNumber(amount)
.times(props.feeFactor)
@@ -351,11 +337,11 @@ describe('TransferForm', () => {
// select should be shown as multiple pubkeys provided
expect(screen.getByText('select')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Enter manually'));
await userEvent.click(screen.getByText('Enter manually'));
expect(screen.queryByText('select')).not.toBeInTheDocument();
expect(screen.getByText('input')).toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByText('Select from wallet'));
await userEvent.click(screen.getByText('Select from wallet'));
expect(screen.getByText('select')).toBeInTheDocument();
expect(screen.queryByText('input')).not.toBeInTheDocument();
expect(mockOnChange).toHaveBeenCalledTimes(2);
+110 -30
View File
@@ -5,6 +5,7 @@ import {
vegaPublicKey,
addDecimal,
formatNumber,
addDecimalsFormatNumber,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import {
@@ -24,22 +25,31 @@ import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { AssetOption, Balance } from '@vegaprotocol/assets';
import { AccountType, AccountTypeMapping } from '@vegaprotocol/types';
interface FormFields {
toAddress: string;
asset: string;
amount: string;
fromAccount: AccountType;
}
interface Asset {
id: string;
symbol: string;
name: string;
decimals: number;
balance: string;
}
interface TransferFormProps {
pubKey: string | null;
pubKeys: string[] | null;
assets: Array<{
id: string;
symbol: string;
name: string;
decimals: number;
assets: Array<Asset>;
accounts: Array<{
type: AccountType;
balance: string;
asset: { id: string; symbol: string; decimals: number };
}>;
assetId?: string;
feeFactor: string | null;
@@ -53,6 +63,7 @@ export const TransferForm = ({
assetId: initialAssetId,
feeFactor,
submitTransfer,
accounts,
}: TransferFormProps) => {
const {
control,
@@ -67,8 +78,10 @@ export const TransferForm = ({
},
});
const selectedPubKey = watch('toAddress');
const amount = watch('amount');
const assetId = watch('asset');
const asset = assets.find((a) => a.id === assetId);
const [includeFee, setIncludeFee] = useState(false);
@@ -90,10 +103,6 @@ export const TransferForm = ({
);
}, [amount, includeFee, transferAmount, feeFactor]);
const asset = useMemo(() => {
return assets.find((a) => a.id === assetId);
}, [assets, assetId]);
const onSubmit = useCallback(
(fields: FormFields) => {
if (!asset) {
@@ -102,10 +111,16 @@ export const TransferForm = ({
if (!transferAmount) {
throw new Error('Submitted transfer with no amount selected');
}
const transfer = normalizeTransfer(fields.toAddress, transferAmount, {
id: asset.id,
decimals: asset.decimals,
});
const transfer = normalizeTransfer(
fields.toAddress,
transferAmount,
fields.fromAccount,
AccountType.ACCOUNT_TYPE_GENERAL, // field is readonly in the form
{
id: asset.id,
decimals: asset.decimals,
}
);
submitTransfer(transfer);
},
[asset, submitTransfer, transferAmount]
@@ -131,63 +146,66 @@ export const TransferForm = ({
}
}, [setValue, pubKey]);
// General account for the selected asset
const generalAccount = accounts.find((a) => {
return (
a.asset.id === assetId && a.type === AccountType.ACCOUNT_TYPE_GENERAL
);
});
return (
<form
onSubmit={handleSubmit(onSubmit)}
className="text-sm"
data-testid="transfer-form"
>
<TradingFormGroup label="Vega key" labelFor="to-address">
<TradingFormGroup label="Vega key" labelFor="toAddress">
<AddressField
pubKeys={pubKeys}
onChange={() => setValue('toAddress', '')}
select={
<TradingSelect
{...register('toAddress')}
id="to-address"
id="toAddress"
defaultValue=""
>
<option value="" disabled={true}>
{t('Please select')}
</option>
{pubKeys?.length &&
pubKeys
.filter((pk) => pk !== pubKey) // remove currently selected pubkey
.map((pk) => (
pubKeys.map((pk) => {
const text = pk === pubKey ? t('Current key: ') + pk : pk;
return (
<option key={pk} value={pk}>
{pk}
{text}
</option>
))}
);
})}
</TradingSelect>
}
input={
<TradingInput
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={true} // focus input immediately after is shown
id="to-address"
id="toAddress"
type="text"
{...register('toAddress', {
validate: {
required,
vegaPublicKey,
sameKey: (value) => {
if (value === pubKey) {
return t('Vega key is the same as current key');
}
return true;
},
},
})}
/>
}
/>
{errors.toAddress?.message && (
<TradingInputError forInput="to-address">
<TradingInputError forInput="toAddress">
{errors.toAddress.message}
</TradingInputError>
)}
</TradingFormGroup>
<TradingFormGroup label="Asset" labelFor="asset">
<TradingFormGroup label={t('Asset')} labelFor="asset">
<Controller
control={control}
name="asset"
@@ -228,6 +246,68 @@ export const TransferForm = ({
</TradingInputError>
)}
</TradingFormGroup>
<TradingFormGroup label={t('From account')} labelFor="fromAccount">
<TradingSelect
id="fromAccount"
defaultValue=""
{...register('fromAccount', {
validate: {
required,
sameAccount: (value) => {
if (
pubKey === selectedPubKey &&
value === AccountType.ACCOUNT_TYPE_GENERAL
) {
return t(
'Cannot transfer to the same account type for the connected key'
);
}
return true;
},
},
})}
>
<option value="" disabled={true}>
{t('Please select')}
</option>
{accounts
.filter((a) => {
if (!assetId) return true;
return assetId === a.asset.id;
})
.map((a) => {
return (
<option value={a.type} key={`${a.type}-${a.asset.id}`}>
{AccountTypeMapping[a.type]} (
{addDecimalsFormatNumber(a.balance, a.asset.decimals)}{' '}
{a.asset.symbol})
</option>
);
})}
</TradingSelect>
{errors.fromAccount?.message && (
<TradingInputError forInput="fromAccount">
{errors.fromAccount.message}
</TradingInputError>
)}
</TradingFormGroup>
<TradingFormGroup label={t('To account')} labelFor="toAccount">
<TradingSelect
id="toAccount"
defaultValue={AccountType.ACCOUNT_TYPE_GENERAL}
>
<option value={AccountType.ACCOUNT_TYPE_GENERAL}>
{generalAccount
? `${
AccountTypeMapping[AccountType.ACCOUNT_TYPE_GENERAL]
} (${addDecimalsFormatNumber(
generalAccount.balance,
generalAccount.asset.decimals
)} ${generalAccount.asset.symbol})`
: AccountTypeMapping[AccountType.ACCOUNT_TYPE_GENERAL]}
</option>
</TradingSelect>
</TradingFormGroup>
<TradingFormGroup label="Amount" labelFor="amount">
<TradingInput
id="amount"
@@ -391,7 +471,7 @@ export const AddressField = ({
setIsInput((curr) => !curr);
onChange();
}}
className="absolute top-0 right-0 ml-auto text-sm underline"
className="absolute top-0 right-0 ml-auto text-xs underline"
>
{isInput ? t('Select from wallet') : t('Enter manually')}
</button>
+5 -1
View File
@@ -20,7 +20,11 @@ query Candles($marketId: ID!, $interval: Interval!, $since: String!) {
code
}
}
candlesConnection(interval: $interval, since: $since) {
candlesConnection(
interval: $interval
since: $since
pagination: { last: 5000 }
) {
edges {
node {
...CandleFields
+1 -1
View File
@@ -46,7 +46,7 @@ export const CandlesDocument = gql`
code
}
}
candlesConnection(interval: $interval, since: $since) {
candlesConnection(interval: $interval, since: $since, pagination: {last: 5000}) {
edges {
node {
...CandleFields
@@ -1,31 +1,83 @@
import { render, screen } from '@testing-library/react';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { CandlesMenu } from './candles-menu';
import {
useCandlesChartSettingsStore,
DEFAULT_CHART_SETTINGS,
} from './use-candles-chart-settings';
import { Overlay, Study, overlayLabels, studyLabels } from 'pennant';
describe('CandlesMenu', () => {
it('should render with the correct default studies', async () => {
render(<CandlesMenu />);
const openDropdown = async () => {
await userEvent.click(
screen.getByRole('button', {
name: 'Studies',
name: 'Indicators',
})
);
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.getByText('Volume')).toHaveAttribute('data-state', 'checked');
expect(screen.getByText('MACD')).toHaveAttribute('data-state', 'checked');
};
beforeEach(() => {
// clear store each time to avoid conditional testing of defaults
useCandlesChartSettingsStore.setState({ overlays: [], studies: [] });
});
it('should render with the correct default overlays', async () => {
it.each(Object.values(Overlay))('can set %s overlay', async (overlay) => {
render(<CandlesMenu />);
await openDropdown();
const menu = within(await screen.findByRole('menu'));
await userEvent.click(menu.getByText(overlayLabels[overlay as Overlay]));
// re-open the dropdown
await openDropdown();
expect(screen.getByText(overlayLabels[overlay as Overlay])).toHaveAttribute(
'data-state',
'checked'
);
});
it.each(Object.values(Study))('can set %s study', async (study) => {
render(<CandlesMenu />);
await openDropdown();
const menu = within(await screen.findByRole('menu'));
await userEvent.click(menu.getByText(studyLabels[study as Study]));
// re-open the dropdown
await openDropdown();
expect(screen.getByText(studyLabels[study as Study])).toHaveAttribute(
'data-state',
'checked'
);
});
it('should render with the correct default studies and overlays', async () => {
useCandlesChartSettingsStore.setState(DEFAULT_CHART_SETTINGS);
render(<CandlesMenu />);
await userEvent.click(
screen.getByRole('button', {
name: 'Overlays',
name: 'Indicators',
})
);
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(screen.getByText('Moving average')).toHaveAttribute(
const menu = within(await screen.findByRole('menu'));
expect(menu.getByText(studyLabels.volume)).toHaveAttribute(
'data-state',
'checked'
);
expect(menu.getByText(studyLabels.macd)).toHaveAttribute(
'data-state',
'checked'
);
expect(menu.getByText(overlayLabels.movingAverage)).toHaveAttribute(
'data-state',
'checked'
);
+1 -13
View File
@@ -107,7 +107,7 @@ export const CandlesMenu = () => {
trigger={
<TradingDropdownTrigger className={triggerClasses}>
<TradingButton {...triggerButtonProps}>
{t('Overlays')}
{t('Indicators')}
</TradingButton>
</TradingDropdownTrigger>
}
@@ -132,18 +132,6 @@ export const CandlesMenu = () => {
<TradingDropdownItemIndicator />
</TradingDropdownCheckboxItem>
))}
</TradingDropdownContent>
</TradingDropdown>
<TradingDropdown
trigger={
<TradingDropdownTrigger className={triggerClasses}>
<TradingButton {...triggerButtonProps}>
{t('Studies')}
</TradingButton>
</TradingDropdownTrigger>
}
>
<TradingDropdownContent align={contentAlign}>
{Object.values(Study).map((study) => (
<TradingDropdownCheckboxItem
key={study}
@@ -24,7 +24,7 @@ const STUDY_ORDER: Study[] = [
Study.VOLUME,
];
const DEFAULT_CHART_SETTINGS = {
export const DEFAULT_CHART_SETTINGS = {
interval: Interval.I15M,
type: ChartType.CANDLE,
overlays: [Overlay.MOVING_AVERAGE],
+27 -10
View File
@@ -3,7 +3,12 @@ import throttle from 'lodash/throttle';
import isEqualWith from 'lodash/isEqualWith';
import { useApolloClient } from '@apollo/client';
import type { OperationVariables } from '@apollo/client';
import type { Subscribe, Load, UpdateCallback } from './generic-data-provider';
import type {
Subscribe,
Load,
UpdateCallback,
PageInfo,
} from './generic-data-provider';
import { variablesIsEqualCustomizer } from './generic-data-provider';
export interface useDataProviderParams<
@@ -12,13 +17,23 @@ export interface useDataProviderParams<
Variables extends OperationVariables | undefined = undefined
> {
dataProvider: Subscribe<Data, Delta, Variables>;
update?: ({ delta, data }: { delta?: Delta; data: Data | null }) => boolean;
update?: ({
delta,
data,
pageInfo,
}: {
delta?: Delta;
data: Data | null;
pageInfo: PageInfo | null;
}) => boolean;
insert?: ({
insertionData,
data,
pageInfo,
}: {
insertionData?: Data | null;
data: Data | null;
pageInfo: PageInfo | null;
}) => boolean;
variables: Variables;
skipUpdates?: boolean;
@@ -30,7 +45,7 @@ export interface useDataProviderParams<
* @param dataProvider subscribe function created by makeDataProvider
* @param update optional function called on each delta received in subscription, if returns true updated data will be not passed from hook (component handles updates internally)
* @param variables optional
* @returns state: data, loading, error, methods: flush (pass updated data to update function without delta), restart: () => void}};
* @returns state: data, loading, pageInfo, error, methods: flush (pass updated data to update function without delta), restart: () => void}};
*/
export const useDataProvider = <
Data,
@@ -48,6 +63,7 @@ export const useDataProvider = <
const [data, setData] = useState<Data | null>(null);
const [loading, setLoading] = useState<boolean>(!skip);
const [error, setError] = useState<Error | undefined>(undefined);
const [pageInfo, setPageInfo] = useState<PageInfo | null>(null);
const flushRef = useRef<(() => void) | undefined>(undefined);
const reloadRef = useRef<((force?: boolean) => void) | undefined>(undefined);
const loadRef = useRef<Load<Data> | undefined>(undefined);
@@ -93,6 +109,7 @@ export const useDataProvider = <
isInsert,
isUpdate,
loaded,
pageInfo,
} = args;
setError(error);
setLoading(!loaded && loading);
@@ -104,21 +121,22 @@ export const useDataProvider = <
(skipUpdatesRef.current ||
(!skipUpdatesRef.current &&
updateRef.current &&
updateRef.current({ delta, data })))
updateRef.current({ delta, data, pageInfo })))
) {
return;
}
if (
isInsert &&
insertRef.current &&
insertRef.current({ insertionData, data })
insertRef.current({ insertionData, data, pageInfo })
) {
return;
}
}
setData(data);
setPageInfo(pageInfo);
if (!loading && !isUpdate && updateRef.current) {
updateRef.current({ data });
updateRef.current({ data, pageInfo });
}
}, []);
@@ -136,15 +154,13 @@ export const useDataProvider = <
useEffect(() => {
setData(null);
setPageInfo(null);
setError(undefined);
if (updateRef.current) {
updateRef.current({ data: null });
updateRef.current({ data: null, pageInfo: null });
}
if (skip) {
setLoading(false);
if (updateRef.current) {
updateRef.current({ data: null });
}
return;
}
setLoading(true);
@@ -165,6 +181,7 @@ export const useDataProvider = <
}, [client, dataProvider, callback, variables, skip]);
return {
data,
pageInfo,
loading,
error,
flush,
+1
View File
@@ -23,3 +23,4 @@ export * from './lib/type-helpers';
export * from './lib/cells/grid-progress-bar';
export * from './lib/use-datagrid-events';
export * from './lib/pagination';
+51
View File
@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Pagination } from './pagination';
describe('Pagination', () => {
const props = {
pageInfo: {
hasNextPage: true,
},
count: 0,
onLoad: () => undefined,
showRetentionMessage: false,
hasDisplayedRows: false,
};
it('renders message for 0 rows', async () => {
const mockOnLoad = jest.fn();
render(<Pagination {...props} onLoad={mockOnLoad} />);
expect(screen.getByText('0 rows loaded')).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
expect(mockOnLoad).toHaveBeenCalled();
});
it('renders mesasge for multiple rows', async () => {
const mockOnLoad = jest.fn();
const count = 10;
render(<Pagination {...props} count={count} onLoad={mockOnLoad} />);
expect(screen.getByText(`${count} rows loaded`)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
expect(mockOnLoad).toHaveBeenCalled();
});
it('renders message for a single row', async () => {
const mockOnLoad = jest.fn();
const count = 1;
render(<Pagination {...props} count={count} onLoad={mockOnLoad} />);
expect(screen.getByText(`${count} row loaded`)).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Load more' }));
expect(mockOnLoad).toHaveBeenCalled();
});
it('renders the data rentention message', () => {
render(<Pagination {...props} showRetentionMessage={true} />);
expect(screen.getByText(/data node retention/)).toBeInTheDocument();
});
it('renders the row filter message', () => {
render(<Pagination {...props} count={1} hasDisplayedRows={false} />);
expect(screen.getByText(/No rows matching/)).toBeInTheDocument();
});
});
+53
View File
@@ -0,0 +1,53 @@
import { TradingButton as Button } from '@vegaprotocol/ui-toolkit';
import { t } from '@vegaprotocol/i18n';
export const Pagination = ({
count,
pageInfo,
onLoad,
hasDisplayedRows,
showRetentionMessage,
}: {
count: number;
pageInfo: { hasNextPage?: boolean } | null;
onLoad: () => void;
hasDisplayedRows: boolean;
showRetentionMessage: boolean;
}) => {
let rowMessage = '';
if (count && !pageInfo?.hasNextPage) {
rowMessage = t('all %s rows loaded', count.toString());
} else {
if (count === 1) {
rowMessage = t('%s row loaded', count.toString());
} else {
rowMessage = t('%s rows loaded', count.toString());
}
}
return (
<div className="flex items-center justify-between p-1 border-t border-default">
<div className="text-xs">
{false}
{showRetentionMessage &&
t(
'Depending on data node retention you may not be able see the full history'
)}
</div>
<div className="flex items-center text-xs">
<span>{rowMessage}</span>
{pageInfo?.hasNextPage ? (
<Button size="extra-small" className="ml-1" onClick={onLoad}>
{t('Load more')}
</Button>
) : null}
</div>
{count && hasDisplayedRows === false ? (
<div className="absolute text-xs top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
{t('No rows matching selected filters')}
</div>
) : null}
</div>
);
};
@@ -26,11 +26,7 @@ import {
EST_TOTAL_MARGIN_TOOLTIP_TEXT,
MARGIN_ACCOUNT_TOOLTIP_TEXT,
} from '../../constants';
import {
sumFees,
sumFeesDiscounts,
useEstimateFees,
} from '../../hooks/use-estimate-fees';
import { useEstimateFees } from '../../hooks/use-estimate-fees';
import { KeyValue } from './key-value';
import {
Accordion,
@@ -44,6 +40,7 @@ import {
import classNames from 'classnames';
import BigNumber from 'bignumber.js';
import { FeesBreakdown } from '../fees-breakdown';
import { getTotalDiscountFactor, getDiscountedFee } from '../discounts';
const emptyValue = '-';
@@ -63,54 +60,55 @@ export const DealTicketFeeDetails = ({
const feeEstimate = useEstimateFees(order, isMarketInAuction);
const asset = getAsset(market);
const { decimals: assetDecimals, quantum } = asset;
const totalFees = feeEstimate?.fees && sumFees(feeEstimate?.fees);
const feesDiscounts =
feeEstimate?.fees && sumFeesDiscounts(feeEstimate?.fees);
const totalPercentageDiscount =
feesDiscounts &&
totalFees &&
feesDiscounts.total !== '0' &&
totalFees !== '0' &&
new BigNumber(feesDiscounts.total)
.dividedBy(BigNumber.sum(totalFees, feesDiscounts.total))
.times(100);
const totalDiscountFactor = getTotalDiscountFactor(feeEstimate);
const totalDiscountedFeeAmount =
feeEstimate?.totalFeeAmount &&
getDiscountedFee(
feeEstimate.totalFeeAmount,
feeEstimate.referralDiscountFactor,
feeEstimate.volumeDiscountFactor
).discountedFee;
return (
<KeyValue
label={t('Fees')}
value={
feeEstimate?.totalFeeAmount &&
`~${formatValue(feeEstimate?.totalFeeAmount, assetDecimals)}`
totalDiscountedFeeAmount &&
`~${formatValue(totalDiscountedFeeAmount, assetDecimals)}`
}
formattedValue={
<>
{totalPercentageDiscount && (
{totalDiscountFactor ? (
<Pill size="xxs" intent={Intent.Warning} className="mr-1">
-{formatNumberPercentage(totalPercentageDiscount, 2)}
-
{formatNumberPercentage(
new BigNumber(totalDiscountFactor).multipliedBy(100),
2
)}
</Pill>
)}
{feeEstimate?.totalFeeAmount &&
`~${formatValue(
feeEstimate?.totalFeeAmount,
assetDecimals,
quantum
)}`}
) : null}
{totalDiscountedFeeAmount &&
`~${formatValue(totalDiscountedFeeAmount, assetDecimals, quantum)}`}
</>
}
labelDescription={
<>
<span>
<div className="flex flex-col gap-2">
<p>
{t(
`An estimate of the most you would be expected to pay in fees, in the market's settlement asset ${assetSymbol}. Fees estimated are "taker" fees and will only be payable if the order trades aggressively. Rebate equal to the maker portion will be paid to the trader if the order trades passively.`
)}
</span>
</p>
<FeesBreakdown
totalFeeAmount={feeEstimate?.totalFeeAmount}
referralDiscountFactor={feeEstimate?.referralDiscountFactor}
volumeDiscountFactor={feeEstimate?.volumeDiscountFactor}
fees={feeEstimate?.fees}
feeFactors={market.fees.factors}
symbol={assetSymbol}
decimals={assetDecimals}
/>
</>
</div>
}
symbol={assetSymbol}
/>
@@ -259,11 +257,9 @@ export const DealTicketMarginDetails = ({
? liquidationEstimateWorstCaseIncludingBuyOrders
: liquidationEstimateWorstCaseIncludingSellOrders;
// The estimate order query API gives us the liquidation price in formatted by asset decimals.
// We need to calculate it with asset decimals, but display it with market decimals precision until the API changes.
liquidationPriceEstimate = formatValue(
liquidationEstimateWorstCase.toString(),
assetDecimals,
market.decimalPlaces,
undefined,
market.decimalPlaces
);
@@ -276,7 +272,7 @@ export const DealTicketMarginDetails = ({
? liquidationEstimateBestCase
: liquidationEstimateWorstCase
).toString(),
assetDecimals,
market.decimalPlaces,
undefined,
market.decimalPlaces
);
@@ -308,7 +304,7 @@ export const DealTicketMarginDetails = ({
key={'value-dropdown'}
className="flex items-center justify-between w-full gap-2"
>
<div className="flex items-center gap-1 text-left">
<div className="flex items-center text-left gap-1">
<Tooltip description={MARGIN_DIFF_TOOLTIP_TEXT(assetSymbol)}>
<span className="text-muted">{t('Margin required')}</span>
</Tooltip>
@@ -109,7 +109,7 @@ describe('DealTicket', () => {
variables: {
partyId: 'pubKey',
filter: { liveOnly: true },
pagination: { first: 5000 },
pagination: { first: 1000 },
},
},
result: {
@@ -24,6 +24,7 @@ import {
Tooltip,
TradingButton as Button,
Pill,
ExternalLink,
} from '@vegaprotocol/ui-toolkit';
import { useOpenVolume } from '@vegaprotocol/positions';
@@ -77,6 +78,7 @@ import { DealTicketSizeIceberg } from './deal-ticket-size-iceberg';
import noop from 'lodash/noop';
import { isNonPersistentOrder } from '../../utils/time-in-force-persistance';
import { KeyValue } from './key-value';
import { DocsLinks } from '@vegaprotocol/environment';
export const REDUCE_ONLY_TOOLTIP =
'"Reduce only" will ensure that this order will not increase the size of an open position. When the order is matched, it will only trade enough volume to bring your open volume towards 0 but never change the direction of your position. If applied to a limit order that is not instantly filled, the order will be stopped.';
@@ -273,7 +275,9 @@ export const DealTicket = ({
const assetSymbol = getAsset(market).symbol;
const assetUnit = getQuoteName(market);
const baseQuote = getBaseQuoteUnit(
market.tradableInstrument.instrument.metadata.tags
);
const summaryError = useMemo(() => {
if (!pubKey) {
@@ -412,7 +416,7 @@ export const DealTicket = ({
id="order-size"
className="w-full"
type="number"
appendElement={assetUnit && <Pill size="xs">{assetUnit}</Pill>}
appendElement={baseQuote && <Pill size="xs">{baseQuote}</Pill>}
step={sizeStep}
min={sizeStep}
data-testid="order-size"
@@ -548,15 +552,20 @@ export const DealTicket = ({
render={({ field }) => (
<Tooltip
description={
<span>
{disablePostOnlyCheckbox
? t(
'"Post only" can not be used on "Fill or Kill" or "Immediate or Cancel" orders.'
)
: t(
'"Post only" will ensure the order is not filled immediately but is placed on the order book as a passive order. When the order is processed it is either stopped (if it would not be filled immediately), or placed in the order book as a passive order until the price taker matches with it.'
)}
</span>
<>
<span>
{disablePostOnlyCheckbox
? t(
'"Post only" can not be used on "Fill or Kill" or "Immediate or Cancel" orders.'
)
: t(
'"Post only" will ensure the order is not filled immediately but is placed on the order book as a passive order. When the order is processed it is either stopped (if it would not be filled immediately), or placed in the order book as a passive order until the price taker matches with it.'
)}
</span>{' '}
<ExternalLink href={DocsLinks?.POST_REDUCE_ONLY}>
{t('Find out more')}
</ExternalLink>
</>
}
>
<div>
@@ -580,13 +589,18 @@ export const DealTicket = ({
render={({ field }) => (
<Tooltip
description={
<span>
{disableReduceOnlyCheckbox
? t(
'"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".'
)
: t(REDUCE_ONLY_TOOLTIP)}
</span>
<>
<span>
{disableReduceOnlyCheckbox
? t(
'"Reduce only" can be used only with non-persistent orders, such as "Fill or Kill" or "Immediate or Cancel".'
)
: t(REDUCE_ONLY_TOOLTIP)}
</span>{' '}
<ExternalLink href={DocsLinks?.POST_REDUCE_ONLY}>
{t('Find out more')}
</ExternalLink>
</>
}
>
<div>
@@ -618,7 +632,10 @@ export const DealTicket = ({
{t(`Trade only a fraction of the order size at once.
After the peak size of the order has traded, the size is reset. This is repeated until the order is cancelled, expires, or its full volume trades away.
For example, an iceberg order with a size of 1000 and a peak size of 100 will effectively be split into 10 orders with a size of 100 each.
Note that the full volume of the order is not hidden and is still reflected in the order book.`)}
Note that the full volume of the order is not hidden and is still reflected in the order book.`)}{' '}
<ExternalLink href={DocsLinks?.ICEBERG_ORDERS}>
{t('Find out more')}
</ExternalLink>{' '}
</p>
}
>
@@ -668,7 +685,7 @@ export const DealTicket = ({
subLabel={`${formatValue(
normalizedOrder.size,
market.positionDecimalPlaces
)} ${assetUnit} @ ${
)} ${baseQuote} @ ${
type === Schema.OrderType.TYPE_MARKET
? 'market'
: `${formatValue(
@@ -0,0 +1,66 @@
import { getDiscountedFee, getTotalDiscountFactor } from './discounts';
describe('getDiscountedFee', () => {
it('calculates values if volumeDiscount or referralDiscount is undefined', () => {
expect(getDiscountedFee('100')).toEqual({
discountedFee: '100',
volumeDiscount: '0',
referralDiscount: '0',
});
expect(getDiscountedFee('100', undefined, '0.1')).toEqual({
discountedFee: '90',
volumeDiscount: '10',
referralDiscount: '0',
});
expect(getDiscountedFee('100', '0.1', undefined)).toEqual({
discountedFee: '90',
volumeDiscount: '0',
referralDiscount: '10',
});
});
it('calculates values using volumeDiscount or referralDiscount', () => {
expect(getDiscountedFee('', '0.1', '0.2')).toEqual({
discountedFee: '',
volumeDiscount: '0',
referralDiscount: '0',
});
});
});
describe('getTotalDiscountFactor', () => {
it('returns 0 if discounts are 0', () => {
expect(
getTotalDiscountFactor({
volumeDiscountFactor: '0',
referralDiscountFactor: '0',
})
).toEqual(0);
});
it('returns volumeDiscountFactor if referralDiscountFactor is 0', () => {
expect(
getTotalDiscountFactor({
volumeDiscountFactor: '0.1',
referralDiscountFactor: '0',
})
).toEqual(0.1);
});
it('returns referralDiscountFactor if volumeDiscountFactor is 0', () => {
expect(
getTotalDiscountFactor({
volumeDiscountFactor: '0',
referralDiscountFactor: '0.1',
})
).toEqual(0.1);
});
it('calculates discount using referralDiscountFactor and volumeDiscountFactor', () => {
expect(
getTotalDiscountFactor({
volumeDiscountFactor: '0.2',
referralDiscountFactor: '0.1',
})
).toBeCloseTo(0.28);
});
});
@@ -0,0 +1,54 @@
import BigNumber from 'bignumber.js';
export const getDiscountedFee = (
feeAmount: string,
referralDiscountFactor?: string,
volumeDiscountFactor?: string
) => {
if (
((!referralDiscountFactor || referralDiscountFactor === '0') &&
(!volumeDiscountFactor || volumeDiscountFactor === '0')) ||
!feeAmount ||
feeAmount === '0'
) {
return {
discountedFee: feeAmount,
volumeDiscount: '0',
referralDiscount: '0',
};
}
const referralDiscount = new BigNumber(referralDiscountFactor || '0')
.multipliedBy(feeAmount)
.toFixed(0, BigNumber.ROUND_FLOOR);
const volumeDiscount = new BigNumber(volumeDiscountFactor || '0')
.multipliedBy((BigInt(feeAmount) - BigInt(referralDiscount)).toString())
.toFixed(0, BigNumber.ROUND_FLOOR);
const discountedFee = (
BigInt(feeAmount || '0') -
BigInt(referralDiscount) -
BigInt(volumeDiscount)
).toString();
return {
referralDiscount,
volumeDiscount,
discountedFee,
};
};
export const getTotalDiscountFactor = (feeEstimate?: {
volumeDiscountFactor?: string;
referralDiscountFactor?: string;
}) => {
if (!feeEstimate) {
return 0;
}
const volumeFactor = Number(feeEstimate?.volumeDiscountFactor) || 0;
const referralFactor = Number(feeEstimate?.referralDiscountFactor) || 0;
if (!volumeFactor) {
return referralFactor;
}
if (!referralFactor) {
return volumeFactor;
}
return 1 - (1 - volumeFactor) * (1 - referralFactor);
};
@@ -0,0 +1,36 @@
import { render, screen } from '@testing-library/react';
import { FeesBreakdown } from './fees-breakdown';
describe('FeesBreakdown', () => {
it('formats fee factors correctly', () => {
const feeFactors = {
makerFee: '0.00005',
infrastructureFee: '0.001',
liquidityFee: '0.5',
};
const fees = {
makerFee: '100',
infrastructureFee: '100',
liquidityFee: '100',
};
const props = {
totalFeeAmount: '100',
fees,
feeFactors,
symbol: 'USD',
decimals: 2,
referralDiscountFactor: '0.01',
volumeDiscountFactor: '0.01',
};
render(<FeesBreakdown {...props} />);
expect(screen.getByText('Maker fee').nextElementSibling).toHaveTextContent(
'0.005%'
);
expect(
screen.getByText('Infrastructure fee').nextElementSibling
).toHaveTextContent('0.1%');
expect(
screen.getByText('Liquidity fee').nextElementSibling
).toHaveTextContent('50%');
});
});
@@ -6,7 +6,7 @@ import {
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import BigNumber from 'bignumber.js';
import { sumFees, sumFeesDiscounts } from '../../hooks';
import { getDiscountedFee } from '../discounts';
const formatValue = (
value: string | number | null | undefined,
@@ -24,7 +24,7 @@ const FeesBreakdownItem = ({
decimals,
}: {
label: string;
factor?: BigNumber;
factor?: string;
value: string;
symbol?: string;
decimals: number;
@@ -33,7 +33,7 @@ const FeesBreakdownItem = ({
<dt className="col-span-2">{label}</dt>
{factor && (
<dd className="text-right col-span-1">
{formatNumberPercentage(new BigNumber(factor).times(100), 2)}
{formatNumberPercentage(new BigNumber(factor).times(100))}
</dd>
)}
<dd className="text-right col-span-3">
@@ -43,76 +43,86 @@ const FeesBreakdownItem = ({
);
export const FeesBreakdown = ({
totalFeeAmount,
fees,
feeFactors,
symbol,
decimals,
referralDiscountFactor,
volumeDiscountFactor,
}: {
totalFeeAmount?: string;
fees?: TradeFee;
feeFactors?: FeeFactors;
symbol?: string;
decimals: number;
referralDiscountFactor?: string;
volumeDiscountFactor?: string;
}) => {
if (!fees) return null;
const totalFees = sumFees(fees);
const {
total: totalDiscount,
referral: referralDiscount,
volume: volumeDiscount,
} = sumFeesDiscounts(fees);
if (totalFees === '0') return null;
if (!fees || !totalFeeAmount || totalFeeAmount === '0') return null;
const { discountedFee: discountedInfrastructureFee } = getDiscountedFee(
fees.infrastructureFee,
referralDiscountFactor,
volumeDiscountFactor
);
const { discountedFee: discountedLiquidityFee } = getDiscountedFee(
fees.liquidityFee,
referralDiscountFactor,
volumeDiscountFactor
);
const { discountedFee: discountedMakerFee } = getDiscountedFee(
fees.makerFee,
referralDiscountFactor,
volumeDiscountFactor
);
const { volumeDiscount, referralDiscount } = getDiscountedFee(
totalFeeAmount,
referralDiscountFactor,
volumeDiscountFactor
);
return (
<dl className="grid grid-cols-6">
<FeesBreakdownItem
label={t('Infrastructure fee')}
factor={
feeFactors?.infrastructureFee
? new BigNumber(feeFactors?.infrastructureFee)
: undefined
}
value={fees.infrastructureFee}
factor={feeFactors?.infrastructureFee}
value={discountedInfrastructureFee}
symbol={symbol}
decimals={decimals}
/>
<FeesBreakdownItem
label={t('Liquidity fee')}
factor={
feeFactors?.liquidityFee
? new BigNumber(feeFactors?.liquidityFee)
: undefined
}
value={fees.liquidityFee}
factor={feeFactors?.liquidityFee}
value={discountedLiquidityFee}
symbol={symbol}
decimals={decimals}
/>
<FeesBreakdownItem
label={t('Maker fee')}
factor={
feeFactors?.makerFee ? new BigNumber(feeFactors?.makerFee) : undefined
}
value={fees.makerFee}
factor={feeFactors?.makerFee}
value={discountedMakerFee}
symbol={symbol}
decimals={decimals}
/>
{volumeDiscount && volumeDiscount !== '0' && (
{volumeDiscountFactor && volumeDiscount !== '0' && (
<FeesBreakdownItem
label={t('Volume discount')}
factor={new BigNumber(volumeDiscount).dividedBy(
BigNumber.sum(totalFees, totalDiscount)
)}
factor={volumeDiscountFactor}
value={volumeDiscount}
symbol={symbol}
decimals={decimals}
/>
)}
{referralDiscount && referralDiscount !== '0' && (
{referralDiscountFactor && referralDiscount !== '0' && (
<FeesBreakdownItem
label={t('Referral discount')}
factor={new BigNumber(referralDiscount).dividedBy(
BigNumber.sum(totalFees, totalDiscount)
)}
factor={referralDiscountFactor}
value={referralDiscount}
symbol={symbol}
decimals={decimals}
@@ -120,8 +130,8 @@ export const FeesBreakdown = ({
)}
<FeesBreakdownItem
label={t('Total fees')}
factor={feeFactors ? sumFeesFactors(feeFactors) : undefined}
value={totalFees}
factor={feeFactors ? sumFeesFactors(feeFactors)?.toString() : undefined}
value={totalFeeAmount}
symbol={symbol}
decimals={decimals}
/>
@@ -31,4 +31,25 @@ query EstimateFees(
}
totalFeeAmount
}
epoch {
id
}
volumeDiscountStats(partyId: $partyId, pagination: { last: 1 }) {
edges {
node {
atEpoch
discountFactor
runningVolume
}
}
}
referralSetStats(partyId: $partyId, pagination: { last: 1 }) {
edges {
node {
atEpoch
discountFactor
referralSetRunningNotionalTakerVolume
}
}
}
}
+22 -1
View File
@@ -15,7 +15,7 @@ export type EstimateFeesQueryVariables = Types.Exact<{
}>;
export type EstimateFeesQuery = { __typename?: 'Query', estimateFees: { __typename?: 'FeeEstimate', totalFeeAmount: string, fees: { __typename?: 'TradeFee', makerFee: string, infrastructureFee: string, liquidityFee: string, makerFeeReferralDiscount?: string | null, makerFeeVolumeDiscount?: string | null, infrastructureFeeReferralDiscount?: string | null, infrastructureFeeVolumeDiscount?: string | null, liquidityFeeReferralDiscount?: string | null, liquidityFeeVolumeDiscount?: string | null } } };
export type EstimateFeesQuery = { __typename?: 'Query', estimateFees: { __typename?: 'FeeEstimate', totalFeeAmount: string, fees: { __typename?: 'TradeFee', makerFee: string, infrastructureFee: string, liquidityFee: string, makerFeeReferralDiscount?: string | null, makerFeeVolumeDiscount?: string | null, infrastructureFeeReferralDiscount?: string | null, infrastructureFeeVolumeDiscount?: string | null, liquidityFeeReferralDiscount?: string | null, liquidityFeeVolumeDiscount?: string | null } }, epoch: { __typename?: 'Epoch', id: string }, volumeDiscountStats: { __typename?: 'VolumeDiscountStatsConnection', edges: Array<{ __typename?: 'VolumeDiscountStatsEdge', node: { __typename?: 'VolumeDiscountStats', atEpoch: number, discountFactor: string, runningVolume: string } } | null> }, referralSetStats: { __typename?: 'ReferralSetStatsConnection', edges: Array<{ __typename?: 'ReferralSetStatsEdge', node: { __typename?: 'ReferralSetStats', atEpoch: number, discountFactor: string, referralSetRunningNotionalTakerVolume: string } } | null> } };
export const EstimateFeesDocument = gql`
@@ -43,6 +43,27 @@ export const EstimateFeesDocument = gql`
}
totalFeeAmount
}
epoch {
id
}
volumeDiscountStats(partyId: $partyId, pagination: {last: 1}) {
edges {
node {
atEpoch
discountFactor
runningVolume
}
}
}
referralSetStats(partyId: $partyId, pagination: {last: 1}) {
edges {
node {
atEpoch
discountFactor
referralSetRunningNotionalTakerVolume
}
}
}
}
`;
@@ -6,6 +6,15 @@ export const estimateFeesQuery = (
override?: PartialDeep<EstimateFeesQuery>
): EstimateFeesQuery => {
const defaultResult: EstimateFeesQuery = {
epoch: {
id: '1',
},
referralSetStats: {
edges: [],
},
volumeDiscountStats: {
edges: [],
},
estimateFees: {
__typename: 'FeeEstimate',
totalFeeAmount: '0.0006',
@@ -5,6 +5,31 @@ import { Side, OrderTimeInForce, OrderType } from '@vegaprotocol/types';
import type { EstimateFeesQuery } from './__generated__/EstimateOrder';
const data: EstimateFeesQuery = {
epoch: {
id: '3',
},
volumeDiscountStats: {
edges: [
{
node: {
atEpoch: 1,
discountFactor: '0.1',
runningVolume: '100',
},
},
],
},
referralSetStats: {
edges: [
{
node: {
atEpoch: 1,
discountFactor: '0.2',
referralSetRunningNotionalTakerVolume: '100',
},
},
],
},
estimateFees: {
totalFeeAmount: '120',
fees: {
@@ -54,6 +79,8 @@ describe('useEstimateFees', () => {
liquidityFee: '0',
makerFee: '0',
},
referralDiscountFactor: '0',
volumeDiscountFactor: '0',
});
expect(mockUseEstimateFeesQuery.mock.lastCall?.[0].skip).toBeTruthy();
});
@@ -85,6 +112,46 @@ describe('useEstimateFees', () => {
makerFeeReferralDiscount: '5',
makerFeeVolumeDiscount: '6',
},
referralDiscountFactor: '0',
volumeDiscountFactor: '0',
});
});
it('returns 0 discounts if discount stats are not at the current epoch -1', () => {
const { result } = renderHook(() =>
useEstimateFees(
{
marketId: 'marketId',
side: Side.SIDE_BUY,
size: '1',
price: '1',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
type: OrderType.TYPE_LIMIT,
},
true
)
);
expect(result.current?.referralDiscountFactor).toEqual('0');
expect(result.current?.volumeDiscountFactor).toEqual('0');
});
it('returns discounts', () => {
data.epoch.id = '2';
const { result } = renderHook(() =>
useEstimateFees(
{
marketId: 'marketId',
side: Side.SIDE_BUY,
size: '1',
price: '1',
timeInForce: OrderTimeInForce.TIME_IN_FORCE_FOK,
type: OrderType.TYPE_LIMIT,
},
true
)
);
expect(result.current?.referralDiscountFactor).toEqual('0.2');
expect(result.current?.volumeDiscountFactor).toEqual('0.1');
});
});
@@ -5,39 +5,22 @@ import type { EstimateFeesQuery } from './__generated__/EstimateOrder';
import { useEstimateFeesQuery } from './__generated__/EstimateOrder';
const divideByTwo = (n: string) => (BigInt(n) / BigInt(2)).toString();
export const sumFeesDiscounts = (
fees: EstimateFeesQuery['estimateFees']['fees']
) => {
const volume = (
BigInt(fees.makerFeeVolumeDiscount || '0') +
BigInt(fees.infrastructureFeeVolumeDiscount || '0') +
BigInt(fees.liquidityFeeVolumeDiscount || '0')
).toString();
const referral = (
BigInt(fees.makerFeeReferralDiscount || '0') +
BigInt(fees.infrastructureFeeReferralDiscount || '0') +
BigInt(fees.liquidityFeeReferralDiscount || '0')
).toString();
return {
volume,
referral,
total: (BigInt(volume) + BigInt(referral)).toString(),
};
};
export const sumFees = (fees: EstimateFeesQuery['estimateFees']['fees']) =>
(
BigInt(fees.makerFee || '0') +
BigInt(fees.infrastructureFee || '0') +
BigInt(fees.liquidityFee || '0')
).toString();
export const useEstimateFees = (
order?: OrderSubmissionBody['orderSubmission'],
isMarketInAuction?: boolean
): EstimateFeesQuery['estimateFees'] | undefined => {
):
| (EstimateFeesQuery['estimateFees'] & {
referralDiscountFactor: string;
volumeDiscountFactor: string;
})
| undefined => {
const { pubKey } = useVegaWallet();
const { data } = useEstimateFeesQuery({
const {
data: currentData,
previousData,
loading,
} = useEstimateFeesQuery({
variables: order && {
marketId: order.marketId,
partyId: pubKey || '',
@@ -50,8 +33,20 @@ export const useEstimateFees = (
fetchPolicy: 'no-cache',
skip: !pubKey || !order?.size || !order?.price || order.postOnly,
});
const data = loading ? currentData || previousData : currentData;
const atEpoch = (Number(data?.epoch.id) || 0) - 1;
const volumeDiscountFactor =
(data?.volumeDiscountStats.edges[0]?.node.atEpoch === atEpoch &&
data?.volumeDiscountStats.edges[0]?.node.discountFactor) ||
'0';
const referralDiscountFactor =
(data?.referralSetStats.edges[0]?.node.atEpoch === atEpoch &&
data?.referralSetStats.edges[0]?.node.discountFactor) ||
'0';
if (order?.postOnly) {
return {
volumeDiscountFactor,
referralDiscountFactor,
totalFeeAmount: '0',
fees: {
infrastructureFee: '0',
@@ -60,8 +55,13 @@ export const useEstimateFees = (
},
};
}
return isMarketInAuction && data?.estimateFees
if (!data?.estimateFees) {
return undefined;
}
return isMarketInAuction
? {
volumeDiscountFactor,
referralDiscountFactor,
totalFeeAmount: divideByTwo(data.estimateFees.totalFeeAmount),
fees: {
infrastructureFee: divideByTwo(
@@ -91,5 +91,9 @@ export const useEstimateFees = (
divideByTwo(data.estimateFees.fees.makerFeeVolumeDiscount),
},
}
: data?.estimateFees;
: {
volumeDiscountFactor,
referralDiscountFactor,
...data.estimateFees,
};
};
+3
View File
@@ -82,6 +82,9 @@ export const DocsLinks = VEGA_DOCS_URL
VALIDATOR_SCORES_REWARDS: `${VEGA_DOCS_URL}/concepts/vega-chain/validator-scores-and-rewards`,
MARKET_LIFECYCLE: `${VEGA_DOCS_URL}/concepts/trading-on-vega/market-lifecycle`,
ETH_DATA_SOURCES: `${VEGA_DOCS_URL}/concepts/trading-on-vega/data-sources#ethereum-data-sources`,
ICEBERG_ORDERS: `${VEGA_DOCS_URL}/concepts/trading-on-vega/orders#iceberg-order`,
POST_REDUCE_ONLY: `${VEGA_DOCS_URL}/concepts/trading-on-vega/orders#conditional-order-parameters`,
QUANTUM: `${VEGA_DOCS_URL}/concepts/assets/asset-framework#quantum`,
}
: undefined;
+6 -2
View File
@@ -42,8 +42,12 @@ fragment FillEdge on TradeEdge {
cursor
}
query Fills($filter: TradesFilter, $pagination: Pagination) {
trades(filter: $filter, pagination: $pagination) {
query Fills(
$filter: TradesFilter
$pagination: Pagination
$dateRange: DateRange
) {
trades(filter: $filter, dateRange: $dateRange, pagination: $pagination) {
edges {
...FillEdge
}
+4 -2
View File
@@ -12,6 +12,7 @@ export type FillEdgeFragment = { __typename?: 'TradeEdge', cursor: string, node:
export type FillsQueryVariables = Types.Exact<{
filter?: Types.InputMaybe<Types.TradesFilter>;
pagination?: Types.InputMaybe<Types.Pagination>;
dateRange?: Types.InputMaybe<Types.DateRange>;
}>;
@@ -95,8 +96,8 @@ export const FillUpdateFieldsFragmentDoc = gql`
}
${TradeFeeFieldsFragmentDoc}`;
export const FillsDocument = gql`
query Fills($filter: TradesFilter, $pagination: Pagination) {
trades(filter: $filter, pagination: $pagination) {
query Fills($filter: TradesFilter, $pagination: Pagination, $dateRange: DateRange) {
trades(filter: $filter, dateRange: $dateRange, pagination: $pagination) {
edges {
...FillEdge
}
@@ -124,6 +125,7 @@ export const FillsDocument = gql`
* variables: {
* filter: // value for 'filter'
* pagination: // value for 'pagination'
* dateRange: // value for 'dateRange'
* },
* });
*/
+35 -18
View File
@@ -1,8 +1,9 @@
import type { AgGridReact } from 'ag-grid-react';
import { useRef } from 'react';
import { useCallback, useRef, useState } from 'react';
import { t } from '@vegaprotocol/i18n';
import { FillsTable } from './fills-table';
import type { useDataGridEvents } from '@vegaprotocol/datagrid';
import { Pagination } from '@vegaprotocol/datagrid';
import { useDataProvider } from '@vegaprotocol/data-provider';
import type * as Schema from '@vegaprotocol/types';
import { fillsWithMarketProvider } from './fills-data-provider';
@@ -22,26 +23,42 @@ export const FillsManager = ({
const filter: Schema.TradesFilter | Schema.TradesSubscriptionFilter = {
partyIds: [partyId],
};
const { data, error } = useDataProvider({
dataProvider: fillsWithMarketProvider,
update: ({ data }) => {
if (data?.length && gridRef.current?.api) {
gridRef.current?.api.setRowData(data);
return true;
}
return false;
const [hasDisplayedRow, setHasDisplayedRow] = useState<boolean | undefined>(
undefined
);
const { onFilterChanged, ...props } = gridProps || {};
const onRowDataUpdated = useCallback(
({ api }: { api: AgGridReact['api'] }) => {
setHasDisplayedRow(!!api.getDisplayedRowCount());
},
[]
);
const { data, error, load, pageInfo } = useDataProvider({
dataProvider: fillsWithMarketProvider,
variables: { filter },
});
return (
<FillsTable
ref={gridRef}
rowData={data}
partyId={partyId}
onMarketClick={onMarketClick}
overlayNoRowsTemplate={error ? error.message : t('No fills')}
{...gridProps}
/>
<div className="flex flex-col h-full">
<FillsTable
ref={gridRef}
rowData={data}
onFilterChanged={(event) => {
onRowDataUpdated(event);
onFilterChanged(event);
}}
onRowDataUpdated={onRowDataUpdated}
partyId={partyId}
onMarketClick={onMarketClick}
overlayNoRowsTemplate={error ? error.message : t('No fills')}
{...props}
/>
<Pagination
count={data?.length || 0}
pageInfo={pageInfo}
showRetentionMessage={true}
onLoad={load}
hasDisplayedRows={hasDisplayedRow || false}
/>
</div>
);
};
+8
View File
@@ -37,6 +37,14 @@ const defaultFill: PartialDeep<Trade> = {
};
describe('FillsTable', () => {
it('correct columns are rendered', async () => {
// 7005-FILL-001
// 7005-FILL-002
// 7005-FILL-003
// 7005-FILL-004
// 7005-FILL-005
// 7005-FILL-006
// 7005-FILL-007
// 7005-FILL-008
await act(async () => {
render(<FillsTable partyId="party-id" rowData={[generateFill()]} />);
});
+2
View File
@@ -20,6 +20,7 @@ import {
negativeClassNames,
MarketNameCell,
COL_DEFS,
DateRangeFilter,
} from '@vegaprotocol/datagrid';
import type {
VegaValueFormatterParams,
@@ -120,6 +121,7 @@ export const FillsTable = forwardRef<AgGridReact, Props>(
},
{
headerName: t('Date'),
filter: DateRangeFilter,
field: 'createdAt',
valueFormatter: ({
value,
@@ -43,7 +43,7 @@ export const fundingPaymentsProvider = makeDataProvider<
pagination: {
getPageInfo,
append,
first: 100,
first: 1000,
},
});
@@ -1,7 +1,8 @@
import type { AgGridReact } from 'ag-grid-react';
import { useRef } from 'react';
import { useCallback, useRef, useState } from 'react';
import { t } from '@vegaprotocol/i18n';
import { FundingPaymentsTable } from './funding-payments-table';
import { Pagination } from '@vegaprotocol/datagrid';
import type { useDataGridEvents } from '@vegaprotocol/datagrid';
import { useDataProvider } from '@vegaprotocol/data-provider';
import { fundingPaymentsWithMarketProvider } from './funding-payments-data-provider';
@@ -20,7 +21,17 @@ export const FundingPaymentsManager = ({
gridProps,
}: FundingPaymentsManagerProps) => {
const gridRef = useRef<AgGridReact | null>(null);
const { data, error } = useDataProvider({
const [hasDisplayedRow, setHasDisplayedRow] = useState<boolean | undefined>(
undefined
);
const { onFilterChanged, ...props } = gridProps || {};
const onRowDataUpdated = useCallback(
({ api }: { api: AgGridReact['api'] }) => {
setHasDisplayedRow(!!api.getDisplayedRowCount());
},
[]
);
const { data, error, load, pageInfo } = useDataProvider({
dataProvider: fundingPaymentsWithMarketProvider,
update: ({ data }) => {
if (data?.length && gridRef.current?.api) {
@@ -33,12 +44,26 @@ export const FundingPaymentsManager = ({
});
return (
<FundingPaymentsTable
ref={gridRef}
rowData={data}
onMarketClick={onMarketClick}
overlayNoRowsTemplate={error ? error.message : t('No funding payments')}
{...gridProps}
/>
<div className="flex flex-col h-full">
<FundingPaymentsTable
ref={gridRef}
rowData={data}
onMarketClick={onMarketClick}
onFilterChanged={(event) => {
onRowDataUpdated(event);
onFilterChanged(event);
}}
onRowDataUpdated={onRowDataUpdated}
overlayNoRowsTemplate={error ? error.message : t('No funding payments')}
{...props}
/>
<Pagination
count={data?.length || 0}
pageInfo={pageInfo}
onLoad={load}
hasDisplayedRows={hasDisplayedRow || false}
showRetentionMessage={true}
/>
</div>
);
};
+27 -3
View File
@@ -4,7 +4,7 @@ fragment LiquidityProvisionFields on LiquidityProvision {
id
party {
id
accountsConnection(marketId: $marketId, type: ACCOUNT_TYPE_BOND) {
accountsConnection(marketId: $marketId) {
edges {
node {
type
@@ -20,12 +20,36 @@ fragment LiquidityProvisionFields on LiquidityProvision {
status
}
query PaidFees($marketId: ID) {
paidLiquidityFees(marketId: $marketId) {
edges {
node {
marketId
assetId
epoch
totalFeesPaid
}
}
}
}
query LiquidityProvisions($marketId: ID!) {
market(id: $marketId) {
liquidityProvisionsConnection(live: true) {
liquiditySLAParameters {
priceRange
commitmentMinTimeFraction
performanceHysteresisEpochs
slaCompetitionFactor
}
liquidityProvisions(live: true) {
edges {
node {
...LiquidityProvisionFields
current {
...LiquidityProvisionFields
}
pending {
...LiquidityProvisionFields
}
}
}
}
@@ -1,10 +1,12 @@
fragment MarketNode on Market {
id
liquidityProvisionsConnection(live: true) {
liquidityProvisions(live: true) {
edges {
node {
commitmentAmount
fee
current {
commitmentAmount
fee
}
}
}
}
+64 -4
View File
@@ -5,12 +5,19 @@ import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type LiquidityProvisionFieldsFragment = { __typename?: 'LiquidityProvision', id: string, createdAt: any, updatedAt?: any | null, commitmentAmount: string, fee: string, status: Types.LiquidityProvisionStatus, party: { __typename?: 'Party', id: string, accountsConnection?: { __typename?: 'AccountsConnection', edges?: Array<{ __typename?: 'AccountEdge', node: { __typename?: 'AccountBalance', type: Types.AccountType, balance: string } } | null> | null } | null } };
export type PaidFeesQueryVariables = Types.Exact<{
marketId?: Types.InputMaybe<Types.Scalars['ID']>;
}>;
export type PaidFeesQuery = { __typename?: 'Query', paidLiquidityFees?: { __typename?: 'PaidLiquidityFeesConnection', edges: Array<{ __typename?: 'PaidLiquidityFeesEdge', node: { __typename?: 'PaidLiquidityFees', marketId: string, assetId: string, epoch: number, totalFeesPaid: string } } | null> } | null };
export type LiquidityProvisionsQueryVariables = Types.Exact<{
marketId: Types.Scalars['ID'];
}>;
export type LiquidityProvisionsQuery = { __typename?: 'Query', market?: { __typename?: 'Market', liquidityProvisionsConnection?: { __typename?: 'LiquidityProvisionsConnection', edges?: Array<{ __typename?: 'LiquidityProvisionsEdge', node: { __typename?: 'LiquidityProvision', id: string, createdAt: any, updatedAt?: any | null, commitmentAmount: string, fee: string, status: Types.LiquidityProvisionStatus, party: { __typename?: 'Party', id: string, accountsConnection?: { __typename?: 'AccountsConnection', edges?: Array<{ __typename?: 'AccountEdge', node: { __typename?: 'AccountBalance', type: Types.AccountType, balance: string } } | null> | null } | null } } } | null> | null } | null } | null };
export type LiquidityProvisionsQuery = { __typename?: 'Query', market?: { __typename?: 'Market', liquiditySLAParameters?: { __typename?: 'LiquiditySLAParameters', priceRange: string, commitmentMinTimeFraction: string, performanceHysteresisEpochs: number, slaCompetitionFactor: string } | null, liquidityProvisions?: { __typename?: 'LiquidityProvisionsWithPendingConnection', edges?: Array<{ __typename?: 'LiquidityProvisionWithPendingEdge', node: { __typename?: 'LiquidityProvisionWithPending', current: { __typename?: 'LiquidityProvision', id: string, createdAt: any, updatedAt?: any | null, commitmentAmount: string, fee: string, status: Types.LiquidityProvisionStatus, party: { __typename?: 'Party', id: string, accountsConnection?: { __typename?: 'AccountsConnection', edges?: Array<{ __typename?: 'AccountEdge', node: { __typename?: 'AccountBalance', type: Types.AccountType, balance: string } } | null> | null } | null } }, pending?: { __typename?: 'LiquidityProvision', id: string, createdAt: any, updatedAt?: any | null, commitmentAmount: string, fee: string, status: Types.LiquidityProvisionStatus, party: { __typename?: 'Party', id: string, accountsConnection?: { __typename?: 'AccountsConnection', edges?: Array<{ __typename?: 'AccountEdge', node: { __typename?: 'AccountBalance', type: Types.AccountType, balance: string } } | null> | null } | null } } | null } } | null> | null } | null } | null };
export type LiquidityProviderFeeShareFieldsFragment = { __typename?: 'LiquidityProviderFeeShare', equityLikeShare: string, averageEntryValuation: string, averageScore: string, virtualStake: string };
@@ -30,7 +37,7 @@ export const LiquidityProvisionFieldsFragmentDoc = gql`
id
party {
id
accountsConnection(marketId: $marketId, type: ACCOUNT_TYPE_BOND) {
accountsConnection(marketId: $marketId) {
edges {
node {
type
@@ -79,13 +86,66 @@ export const LiquidityProviderFieldsFragmentDoc = gql`
}
${LiquidityProviderFeeShareFieldsFragmentDoc}
${LiquidityProviderSLAFieldsFragmentDoc}`;
export const PaidFeesDocument = gql`
query PaidFees($marketId: ID) {
paidLiquidityFees(marketId: $marketId) {
edges {
node {
marketId
assetId
epoch
totalFeesPaid
}
}
}
}
`;
/**
* __usePaidFeesQuery__
*
* To run a query within a React component, call `usePaidFeesQuery` and pass it any options that fit your needs.
* When your component renders, `usePaidFeesQuery` returns an object from Apollo Client that contains loading, error, and data properties
* you can use to render your UI.
*
* @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options;
*
* @example
* const { data, loading, error } = usePaidFeesQuery({
* variables: {
* marketId: // value for 'marketId'
* },
* });
*/
export function usePaidFeesQuery(baseOptions?: Apollo.QueryHookOptions<PaidFeesQuery, PaidFeesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<PaidFeesQuery, PaidFeesQueryVariables>(PaidFeesDocument, options);
}
export function usePaidFeesLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<PaidFeesQuery, PaidFeesQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<PaidFeesQuery, PaidFeesQueryVariables>(PaidFeesDocument, options);
}
export type PaidFeesQueryHookResult = ReturnType<typeof usePaidFeesQuery>;
export type PaidFeesLazyQueryHookResult = ReturnType<typeof usePaidFeesLazyQuery>;
export type PaidFeesQueryResult = Apollo.QueryResult<PaidFeesQuery, PaidFeesQueryVariables>;
export const LiquidityProvisionsDocument = gql`
query LiquidityProvisions($marketId: ID!) {
market(id: $marketId) {
liquidityProvisionsConnection(live: true) {
liquiditySLAParameters {
priceRange
commitmentMinTimeFraction
performanceHysteresisEpochs
slaCompetitionFactor
}
liquidityProvisions(live: true) {
edges {
node {
...LiquidityProvisionFields
current {
...LiquidityProvisionFields
}
pending {
...LiquidityProvisionFields
}
}
}
}
+7 -5
View File
@@ -3,21 +3,23 @@ import * as Types from '@vegaprotocol/types';
import { gql } from '@apollo/client';
import * as Apollo from '@apollo/client';
const defaultOptions = {} as const;
export type MarketNodeFragment = { __typename?: 'Market', id: string, liquidityProvisionsConnection?: { __typename?: 'LiquidityProvisionsConnection', edges?: Array<{ __typename?: 'LiquidityProvisionsEdge', node: { __typename?: 'LiquidityProvision', commitmentAmount: string, fee: string } } | null> | null } | null, data?: { __typename?: 'MarketData', targetStake?: string | null } | null };
export type MarketNodeFragment = { __typename?: 'Market', id: string, liquidityProvisions?: { __typename?: 'LiquidityProvisionsWithPendingConnection', edges?: Array<{ __typename?: 'LiquidityProvisionWithPendingEdge', node: { __typename?: 'LiquidityProvisionWithPending', current: { __typename?: 'LiquidityProvision', commitmentAmount: string, fee: string } } } | null> | null } | null, data?: { __typename?: 'MarketData', targetStake?: string | null } | null };
export type LiquidityProvisionMarketsQueryVariables = Types.Exact<{ [key: string]: never; }>;
export type LiquidityProvisionMarketsQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', id: string, liquidityProvisionsConnection?: { __typename?: 'LiquidityProvisionsConnection', edges?: Array<{ __typename?: 'LiquidityProvisionsEdge', node: { __typename?: 'LiquidityProvision', commitmentAmount: string, fee: string } } | null> | null } | null, data?: { __typename?: 'MarketData', targetStake?: string | null } | null } }> } | null };
export type LiquidityProvisionMarketsQuery = { __typename?: 'Query', marketsConnection?: { __typename?: 'MarketConnection', edges: Array<{ __typename?: 'MarketEdge', node: { __typename?: 'Market', id: string, liquidityProvisions?: { __typename?: 'LiquidityProvisionsWithPendingConnection', edges?: Array<{ __typename?: 'LiquidityProvisionWithPendingEdge', node: { __typename?: 'LiquidityProvisionWithPending', current: { __typename?: 'LiquidityProvision', commitmentAmount: string, fee: string } } } | null> | null } | null, data?: { __typename?: 'MarketData', targetStake?: string | null } | null } }> } | null };
export const MarketNodeFragmentDoc = gql`
fragment MarketNode on Market {
id
liquidityProvisionsConnection(live: true) {
liquidityProvisions(live: true) {
edges {
node {
commitmentAmount
fee
current {
commitmentAmount
fee
}
}
}
}
@@ -1,13 +1,12 @@
import { AccountType } from '@vegaprotocol/types';
import type { LiquidityProvisionFields } from './liquidity-data-provider';
import { getLiquidityProvision } from './liquidity-data-provider';
import type {
LiquidityProviderFieldsFragment,
LiquidityProvisionFieldsFragment,
} from './__generated__/MarketLiquidity';
import type { LiquidityProviderFieldsFragment } from './__generated__/MarketLiquidity';
const input = {
liquidityProvisions: [
{
id: 'dde288688af2aeb5feb349dd72d3679a7a9be34c7375f6a4a48ef2f6140e7e59',
party: {
id: 'dde288688af2aeb5feb349dd72d3679a7a9be34c7375f6a4a48ef2f6140e7e59',
accountsConnection: {
@@ -31,7 +30,11 @@ const input = {
fee: '0.001',
status: 'STATUS_ACTIVE',
__typename: 'LiquidityProvision',
} as LiquidityProvisionFieldsFragment,
priceRange: '0',
commitmentMinTimeFraction: '0.5',
performanceHysteresisEpochs: 5678,
slaCompetitionFactor: '0',
} as unknown as LiquidityProvisionFields,
],
liquidityProviders: [
{
@@ -49,9 +52,12 @@ const input = {
const result = [
{
__typename: undefined,
balance: '1.8003328918633596575e+22',
balance: 1.8003328918633597e22,
earmarkedFees: 0,
commitmentAmount: '18003328918633596575000',
createdAt: '2022-12-16T09:28:29.071781Z',
commitmentMinTimeFraction: '0.5',
id: 'dde288688af2aeb5feb349dd72d3679a7a9be34c7375f6a4a48ef2f6140e7e59',
feeShare: {
equityLikeShare: '1',
__typename: 'LiquidityProviderFeeShare',
@@ -59,6 +65,9 @@ const result = [
},
fee: '0.001',
partyId: 'dde288688af2aeb5feb349dd72d3679a7a9be34c7375f6a4a48ef2f6140e7e59',
performanceHysteresisEpochs: 5678,
priceRange: '0',
slaCompetitionFactor: '0',
party: {
__typename: 'Party',
accountsConnection: {
@@ -106,7 +115,9 @@ describe('getLiquidityProvision', () => {
{
__typename: 'LiquidityProvision',
commitmentAmount: '18003328918633596575000',
commitmentMinTimeFraction: '0.5',
createdAt: '2022-12-16T09:28:29.071781Z',
id: 'dde288688af2aeb5feb349dd72d3679a7a9be34c7375f6a4a48ef2f6140e7e59',
fee: '0.001',
party: {
__typename: 'Party',
@@ -125,6 +136,9 @@ describe('getLiquidityProvision', () => {
},
id: 'dde288688af2aeb5feb349dd72d3679a7a9be34c7375f6a4a48ef2f6140e7e59',
},
performanceHysteresisEpochs: 5678,
priceRange: '0',
slaCompetitionFactor: '0',
status: 'STATUS_ACTIVE',
updatedAt: '2023-01-04T22:13:27.761985Z',
},
@@ -20,20 +20,45 @@ import type {
LiquidityProvisionsQueryVariables,
} from './__generated__/MarketLiquidity';
export type LiquidityProvisionFields = LiquidityProvisionFieldsFragment &
Schema.LiquiditySLAParameters & {
currentCommitmentAmount?: string;
currentFee?: string;
};
export const liquidityProvisionsDataProvider = makeDataProvider<
LiquidityProvisionsQuery,
LiquidityProvisionFieldsFragment[],
LiquidityProvisionFields[],
never,
never,
LiquidityProvisionsQueryVariables
>({
query: LiquidityProvisionsDocument,
getData: (responseData: LiquidityProvisionsQuery | null) => {
return (
responseData?.market?.liquidityProvisionsConnection?.edges?.map(
(e) => e?.node
) ?? []
).filter((n) => !!n) as LiquidityProvisionFieldsFragment[];
return (responseData?.market?.liquidityProvisions?.edges
?.filter((n) => !!n)
.map((e) => {
let node;
if (!e?.node.pending && e?.node.current) {
node = {
...e?.node.current,
...responseData.market?.liquiditySLAParameters,
};
} else if (!e?.node.current && e?.node.pending) {
node = {
...e?.node.pending,
...responseData.market?.liquiditySLAParameters,
};
} else {
node = {
...e?.node.pending,
currentCommitmentAmount: e?.node.current.commitmentAmount,
currentFee: e?.node.current.fee,
...responseData.market?.liquiditySLAParameters,
};
}
return node;
}) ?? []) as LiquidityProvisionFields[];
},
});
@@ -81,16 +106,14 @@ export const lpAggregatedDataProvider = makeDerivedDataProvider<
}
);
export const matchFilter = (
filter: Filter,
lp: LiquidityProvisionFieldsFragment
) => {
export const matchFilter = (filter: Filter, lp: LiquidityProvisionData) => {
if (filter.partyId && lp.party.id !== filter.partyId) {
return false;
}
if (
filter.active === true &&
lp.status !== Schema.LiquidityProvisionStatus.STATUS_ACTIVE
lp.status !== Schema.LiquidityProvisionStatus.STATUS_ACTIVE &&
lp.status !== Schema.LiquidityProvisionStatus.STATUS_PENDING
) {
return false;
}
@@ -104,16 +127,20 @@ export const matchFilter = (
};
export interface LiquidityProvisionData
extends Omit<LiquidityProvisionFieldsFragment, '__typename'> {
extends Omit<LiquidityProvisionFields, '__typename'>,
Partial<LiquidityProviderFieldsFragment>,
Omit<Schema.LiquiditySLAParameters, '__typename'> {
assetDecimalPlaces?: number;
balance?: string;
balance?: number;
averageEntryValuation?: string;
equityLikeShare?: string;
earmarkedFees?: number;
status: Schema.LiquidityProvisionStatus;
}
export const getLiquidityProvision = (
liquidityProvisions: LiquidityProvisionFieldsFragment[],
liquidityProvider: LiquidityProviderFieldsFragment[],
liquidityProvisions: LiquidityProvisionFields[],
liquidityProviders: LiquidityProviderFieldsFragment[],
filter?: Filter
): LiquidityProvisionData[] => {
return liquidityProvisions
@@ -132,26 +159,40 @@ export const getLiquidityProvision = (
}
return true;
})
.map((lp) => {
const lpObj = liquidityProvider.find((f) => lp.party.id === f.partyId);
if (!lpObj) return lp;
const accounts = compact(lp.party.accountsConnection?.edges).map(
(e) => e.node
.map((liquidityProvision) => {
const liquidityProvider = liquidityProviders.find(
(f) => liquidityProvision.party.id === f.partyId
);
if (!liquidityProvider) return liquidityProvision;
const accounts = compact(
liquidityProvision.party.accountsConnection?.edges
).map((e) => e.node);
const bondAccounts = accounts?.filter(
(a) => a?.type === Schema.AccountType.ACCOUNT_TYPE_BOND
);
const feeAccounts = accounts?.filter(
(a) => a?.type === Schema.AccountType.ACCOUNT_TYPE_LP_LIQUIDITY_FEES
);
const balance =
bondAccounts
?.reduce(
(acc, a) => acc.plus(new BigNumber(a.balance ?? 0)),
new BigNumber(0)
)
.toString() || '0';
.toNumber() ?? 0;
const earmarkedFees =
feeAccounts
?.reduce(
(acc, a) => acc.plus(new BigNumber(a.balance ?? 0)),
new BigNumber(0)
)
.toNumber() ?? 0;
return {
...lp,
...lpObj,
...liquidityProvision,
...liquidityProvider,
balance,
earmarkedFees,
__typename: undefined,
};
});
@@ -55,9 +55,10 @@ describe('LiquidityTable', () => {
'Adjusted stake share',
'Share',
'Live supplied liquidity',
'Live time fraction on book',
'Fees accrued this epoch',
'Live time on book',
'Live liquidity quality score (%)',
'Last time fraction on the book',
'Last time on the book',
'Last fee penalty',
'Last bond penalty',
'Status',
+149 -9
View File
@@ -6,7 +6,10 @@ import {
getDateTimeFormat,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import type { TypedDataAgGrid } from '@vegaprotocol/datagrid';
import type {
TypedDataAgGrid,
VegaValueFormatterParams,
} from '@vegaprotocol/datagrid';
import { AgGrid } from '@vegaprotocol/datagrid';
import {
CopyWithTooltip,
@@ -22,7 +25,7 @@ import type {
ValueFormatterParams,
} from 'ag-grid-community';
import BigNumber from 'bignumber.js';
import type { LiquidityProvisionStatus } from '@vegaprotocol/types';
import { LiquidityProvisionStatus } from '@vegaprotocol/types';
import { LiquidityProvisionStatusMapping } from '@vegaprotocol/types';
import type { LiquidityProvisionData } from './liquidity-data-provider';
@@ -96,6 +99,58 @@ export const LiquidityTable = ({
return `${addDecimalsFormatNumber(newValue, assetDecimalPlaces ?? 0)}`;
};
const feesAccruedTooltip = ({ value, data }: ITooltipParams) => {
if (!value) return '-';
const newValue = new BigNumber(value)
.times(Number(stakeToCcyVolume) || 1)
.toString();
let lessThanFull = false,
lessThanMinimum = false;
if (data.sla) {
lessThanFull =
data.sla &&
new BigNumber(data.sla.currentEpochFractionOfTimeOnBook).isLessThan(
1
);
lessThanMinimum =
data.sla &&
new BigNumber(data.sla.currentEpochFractionOfTimeOnBook).isLessThan(
data.commitmentMinTimeFraction
);
}
if (lessThanMinimum) {
return t(
`This LP's time on the book in the current epoch (%s) is less than the minimum required (%s), so they could lose all fee revenue for this epoch.`,
[
formatNumberPercentage(
new BigNumber(data.sla.currentEpochFractionOfTimeOnBook).times(
100
),
2
),
formatNumberPercentage(
new BigNumber(data.commitmentMinTimeFraction).times(100),
2
),
]
);
}
if (lessThanFull) {
return t(
`This LP's time on the book in the current epoch (%s) is less than 100%, so they could lose some fees to a better performing LP.`,
[
formatNumberPercentage(
new BigNumber(data.sla.currentEpochFractionOfTimeOnBook).times(
100
),
2
),
]
);
}
return addDecimalsFormatNumber(newValue, assetDecimalPlaces ?? 0);
};
const stakeToCcyVolumeQuantumFormatter = ({
value,
}: ValueFormatterParams) => {
@@ -135,7 +190,32 @@ export const LiquidityTable = ({
headerTooltip: t(
'The amount committed to the market by this liquidity provider.'
),
valueFormatter: assetDecimalsQuantumFormatter,
valueFormatter: ({
data,
value,
}: VegaValueFormatterParams<
LiquidityProvisionData,
'commitmentAmount'
>) => {
if (!value) return '-';
const formattedCommitmentAmount = addDecimalsFormatNumberQuantum(
value,
assetDecimalPlaces ?? 0,
quantum ?? 0
);
if (
data?.currentCommitmentAmount &&
data?.currentCommitmentAmount !== value
) {
return `${addDecimalsFormatNumberQuantum(
data.currentCommitmentAmount,
assetDecimalPlaces ?? 0,
quantum ?? 0
)}/${formattedCommitmentAmount}`;
} else {
return formattedCommitmentAmount;
}
},
tooltipValueGetter: assetDecimalsFormatter,
},
{
@@ -155,7 +235,22 @@ export const LiquidityTable = ({
),
field: 'fee',
type: 'rightAligned',
valueFormatter: percentageFormatter,
valueFormatter: ({
data,
value,
}: ValueFormatterParams<LiquidityProvisionData, 'fee'>) => {
if (!value) return '-';
const formattedValue =
formatNumberPercentage(new BigNumber(value).times(100), 2) ||
'-';
if (data?.currentFee && data?.currentFee !== value) {
return `${formatNumberPercentage(
new BigNumber(data.currentFee).times(100),
2
)}/${formattedValue}`;
}
return formattedValue;
},
},
{
headerName: t('Adjusted stake share'),
@@ -178,7 +273,7 @@ export const LiquidityTable = ({
],
},
{
headerName: t('Live liquidity details'),
headerName: t('Live liquidity data'),
marryChildren: true,
children: [
{
@@ -192,7 +287,41 @@ export const LiquidityTable = ({
tooltipValueGetter: stakeToCcyVolumeFormatter,
},
{
headerName: t(`Live time fraction on book`),
headerName: t('Fees accrued this epoch'),
field: 'earmarkedFees',
type: 'rightAligned',
headerTooltip: t(
`The liquidity fees accrued by each provider, which will be distributed at the end of the epoch after applying any penalties.`
),
valueFormatter: stakeToCcyVolumeQuantumFormatter,
tooltipValueGetter: feesAccruedTooltip,
cellClassRules: {
'text-warning': ({ data }: { data: LiquidityProvisionData }) => {
if (!data.sla) return false;
return (
new BigNumber(
data.sla.currentEpochFractionOfTimeOnBook
).isLessThan(1) &&
new BigNumber(
data.sla.currentEpochFractionOfTimeOnBook
).isGreaterThan(data.commitmentMinTimeFraction)
);
},
'text-red-500': ({ data }: { data: LiquidityProvisionData }) => {
if (!data.sla) return false;
return (
new BigNumber(
data.sla.currentEpochFractionOfTimeOnBook
).isLessThan(data.commitmentMinTimeFraction) &&
new BigNumber(
data.sla.currentEpochFractionOfTimeOnBook
).isGreaterThan(0)
);
},
},
},
{
headerName: t(`Live time on book`),
field: 'sla.currentEpochFractionOfTimeOnBook',
type: 'rightAligned',
headerTooltip: t('Current epoch fraction of time on the book.'),
@@ -212,7 +341,7 @@ export const LiquidityTable = ({
marryChildren: true,
children: [
{
headerName: t(`Last time fraction on the book`),
headerName: t(`Last time on the book`),
field: 'sla.lastEpochFractionOfTimeOnBook',
type: 'rightAligned',
headerTooltip: t('Last epoch fraction of time on the book.'),
@@ -242,8 +371,17 @@ export const LiquidityTable = ({
headerName: t('Status'),
headerTooltip: t('The current status of this liquidity provision.'),
field: 'status',
valueFormatter: ({ value }) => {
valueFormatter: ({
data,
value,
}: ValueFormatterParams<LiquidityProvisionData, 'status'>) => {
if (!value) return value;
if (
data?.status === LiquidityProvisionStatus.STATUS_PENDING &&
(data?.currentCommitmentAmount || data?.currentFee)
) {
return t('Updating next epoch');
}
return LiquidityProvisionStatusMapping[
value as LiquidityProvisionStatus
];
@@ -275,7 +413,9 @@ export const LiquidityTable = ({
return (
<AgGrid
overlayNoRowsTemplate={t('No liquidity provisions')}
getRowId={({ data }: { data: LiquidityProvisionData }) => data.id || ''}
getRowId={({ data }: { data: LiquidityProvisionData }) => {
return data.id || '';
}}
tooltipShowDelay={500}
defaultColDef={defaultColDef}
{...props}
+6 -4
View File
@@ -12,12 +12,14 @@ export const liquidityProvisionsQuery = (
): LiquidityProvisionsQuery => {
const defaultResult: LiquidityProvisionsQuery = {
market: {
liquidityProvisionsConnection: {
__typename: 'LiquidityProvisionsConnection',
liquidityProvisions: {
__typename: 'LiquidityProvisionsWithPendingConnection',
edges: liquidityFields.map((node) => {
return {
__typename: 'LiquidityProvisionsEdge',
node,
__typename: 'LiquidityProvisionWithPendingEdge',
node: {
current: node,
},
};
}),
},
@@ -106,10 +106,9 @@ export const getLiquidityForMarket = (
markets: LiquidityProvisionMarket[]
) => {
const liquidity =
markets.find((m) => m.id === marketId)?.liquidityProvisionsConnection
?.edges || [];
markets.find((m) => m.id === marketId)?.liquidityProvisions?.edges || [];
return liquidity.map((l) => l?.node);
return liquidity.map((l) => l?.node.current);
};
export const getTargetStake = (

Some files were not shown because too many files have changed in this diff Show More