Compare commits

...
Author SHA1 Message Date
Matthew Russell c13f7bdf38 chore: make sparkline use fixed width 2023-10-26 10:22:00 -07:00
Matthew Russell 957773d998 fix: a11y due to nested buttons 2023-10-26 10:19:53 -07:00
Matthew Russell 9c9dbdeb22 feat: make contract address muted 2023-10-26 10:19:53 -07:00
Matthew Russell 834999db37 feat: responsive styles for market cards 2023-10-26 10:19:52 -07:00
Matthew Russell 52ac168c2a feat: add usdt, fix up icons 2023-10-26 10:19:52 -07:00
Matthew Russell b207650edf feat: align market card design 2023-10-26 10:19:52 -07:00
Matthew Russell 7492bcd83d feat: adjust sparkline size and bg color of market card 2023-10-26 10:19:52 -07:00
Matthew Russell 1723e25706 feat: prompt to connect vega wallet for final step 2023-10-26 10:19:52 -07:00
Matthew Russell 4afb3d5104 feat: spacing adjustments 2023-10-26 10:19:52 -07:00
Matthew Russell c5d3e1a19c feat: add icon system for tokens 2023-10-26 10:19:52 -07:00
Matthew Russell 7d450a911e feat: make spacing consistent 2023-10-26 10:19:52 -07:00
Matthew Russell 7c9e399015 feat: align search box 2023-10-26 10:19:52 -07:00
Matthew Russell 75976be02f chore: set max width, add title text 2023-10-26 10:19:52 -07:00
Matthew Russell 3d71b20921 chore: remove unneeded component 2023-10-26 10:19:52 -07:00
Matthew Russell a1269bd76b chore: add comment for allow list 2023-10-26 10:19:52 -07:00
Matthew Russell 7278d1233a chore: re add bespoke get started content 2023-10-26 10:19:52 -07:00
Matthew Russell 8ebfb5b9e5 chore: remove steps, add balances, wire up final tx 2023-10-26 10:19:52 -07:00
Matthew Russell 4082d4b8a9 chore: make full width 2023-10-26 10:19:52 -07:00
Matthew Russell ab10ebf197 feat: only get balances if connected 2023-10-26 10:19:52 -07:00
Matthew Russell c2c8379a9a feat: add layout for market cards in asset list 2023-10-26 10:19:52 -07:00
Matthew Russell 71824d45d4 chore: move market cards to own file 2023-10-26 10:19:52 -07:00
Matthew Russell 4be4d089b4 feat: show balance and allowance 2023-10-26 10:19:52 -07:00
Matthew Russell 846a3d259d chore: check wallet is connected before fetching allowance 2023-10-26 10:19:52 -07:00
Matthew Russell 0d25fc62c5 feat: add market data 2023-10-26 10:19:52 -07:00
Matthew Russell 9039b0304c feat: handle approvals 2023-10-26 10:19:52 -07:00
Matthew Russell 10d435bdd1 feat: add poc for guided deposit flow 2023-10-26 10:19:52 -07: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
66 changed files with 2928 additions and 728 deletions
+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');
@@ -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');
});
}
);
+32 -5
View File
@@ -2,6 +2,9 @@ import { t } from '@vegaprotocol/i18n';
import { Links } from '../../lib/links';
import classNames from 'classnames';
import { NavLink, Outlet } from 'react-router-dom';
import { useWeb3React } from '@web3-react/core';
import { truncateByChars } from '@vegaprotocol/utils';
import { useWeb3ConnectStore, useWeb3Disconnect } from '@vegaprotocol/web3';
export const Assets = () => {
const linkClasses = ({ isActive }: { isActive: boolean }) => {
@@ -11,8 +14,8 @@ export const Assets = () => {
};
return (
<div className="max-w-[500px] px-4 mx-auto my-8">
<nav className="flex mb-6 text-lg gap-4">
<div className="container px-4 mx-auto my-4 max-w-[1200px]">
<nav className="flex items-end my-6 text-lg gap-4">
<NavLink to={Links.DEPOSIT()} className={linkClasses}>
{t('Deposit')}
</NavLink>
@@ -22,10 +25,34 @@ export const Assets = () => {
<NavLink to={Links.TRANSFER()} className={linkClasses}>
{t('Transfer')}
</NavLink>
<div className="ml-auto">
<EthereumConnect />
</div>
</nav>
<div className="pt-4 border-t md:p-6 md:border md:rounded-xl border-default">
<Outlet />
</div>
<Outlet />
</div>
);
};
const EthereumConnect = () => {
const { account, connector } = useWeb3React();
const disconnect = useWeb3Disconnect(connector);
const openDialog = useWeb3ConnectStore((store) => store.open);
return (
<div className="text-sm">
{account ? (
<div className="flex flex-col items-end">
<p>{truncateByChars(account)}</p>
<button onClick={disconnect} className="underline underline-offset-4">
{t('Disconnect')}
</button>
</div>
) : (
<button onClick={openDialog} className="underline underline-offset-4">
{t('Connect Ethereum wallet')}
</button>
)}
</div>
);
};
@@ -0,0 +1,28 @@
import { t } from '@vegaprotocol/i18n';
import BigNumber from 'bignumber.js';
export const Allowance = ({ allowance }: { allowance: BigNumber }) => {
let value = '';
const format = (divisor: string) => {
const result = allowance.dividedBy(divisor);
return result.isInteger() ? result.toString() : result.toFixed(1);
};
if (allowance.isGreaterThan(new BigNumber('1e14'))) {
value = t('>100t');
} else if (allowance.isGreaterThanOrEqualTo(new BigNumber('1e12'))) {
// Trillion
value = `${format('1e12')}t`;
} else if (allowance.isGreaterThanOrEqualTo(new BigNumber('1e9'))) {
// Billion
value = `${format('1e9')}b`;
} else if (allowance.isGreaterThanOrEqualTo(new BigNumber('1e6'))) {
// Million
value = `${format('1e6')}m`;
} else {
value = allowance.toString();
}
return <span>{value}</span>;
};
@@ -0,0 +1,45 @@
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import { Token } from '@vegaprotocol/smart-contracts';
import { addDecimal, isAssetTypeERC20 } from '@vegaprotocol/utils';
import { useWeb3React } from '@web3-react/core';
import BigNumber from 'bignumber.js';
import { useEffect, useState } from 'react';
export const Balance = ({
asset,
balance,
}: {
asset: AssetFieldsFragment;
balance?: BigNumber;
}) => {
const { provider, account } = useWeb3React();
const [fallback, setBalance] = useState<BigNumber>(new BigNumber(0));
useEffect(() => {
const run = async () => {
if (!isAssetTypeERC20(asset)) return;
if (provider && account) {
const signer = provider.getSigner();
const tokenContract = new Token(
asset.source.contractAddress,
signer || provider
);
const balanceRes = await tokenContract.balanceOf(account);
setBalance(
new BigNumber(addDecimal(balanceRes.toString(), asset.decimals))
);
}
};
run();
}, [asset, account, provider]);
return (
<p className="font-mono text-lg">
{balance ? balance.toString() : fallback.toString()}
<small className="ml-1 text-muted font-alpha">{asset.symbol}</small>
</p>
);
};
@@ -25,20 +25,18 @@ export const DepositGetStarted = () => {
}
return (
<div className="pt-6 border-t border-default">
<div className={wrapperClasses}>
<h3 className="text-lg">{t('Get started')}</h3>
<GetStartedCheckList />
{step > OnboardingStep.ONBOARDING_DEPOSIT_STEP && (
<TradingAnchorButton
href={Links.HOME()}
onClick={() => dismiss()}
intent={Intent.Info}
>
{t('Start trading')}
</TradingAnchorButton>
)}
</div>
<div className={wrapperClasses}>
<h3 className="text-lg">{t('Get started')}</h3>
<GetStartedCheckList />
{step > OnboardingStep.ONBOARDING_DEPOSIT_STEP && (
<TradingAnchorButton
href={Links.HOME()}
onClick={() => dismiss()}
intent={Intent.Info}
>
{t('Start trading')}
</TradingAnchorButton>
)}
</div>
);
};
+586 -3
View File
@@ -1,14 +1,597 @@
import { DepositContainer } from '@vegaprotocol/deposits';
import type { Dispatch, FormEvent, SetStateAction } from 'react';
import { useCallback } from 'react';
import { useEffect } from 'react';
import { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { DepositGetStarted } from './deposit-get-started';
import {
useAssetDetailsDialogStore,
useEnabledAssets,
} from '@vegaprotocol/assets';
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import {
addDecimal,
isAssetTypeERC20,
removeDecimal,
truncateByChars,
} from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n';
import {
Intent,
TokenIcon,
TradingButton,
TradingInput,
} from '@vegaprotocol/ui-toolkit';
import { useWeb3React } from '@web3-react/core';
import {
EthTxStatus,
useEthereumConfig,
useEthTransactionStore,
useWeb3ConnectStore,
} from '@vegaprotocol/web3';
import BigNumber from 'bignumber.js';
import {
CollateralBridge,
prepend0x,
Token,
} from '@vegaprotocol/smart-contracts';
import { MaxUint256 } from '@ethersproject/constants';
import { useTopTradedMarkets } from '../../lib/hooks/use-top-traded-markets';
import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/markets';
import { getAsset } from '@vegaprotocol/markets';
import classNames from 'classnames';
import { Networks, useEnvironment } from '@vegaprotocol/environment';
import { Markets } from './markets';
import { Balance } from './balance';
import { Allowance } from './allowance';
import { useVegaWallet, useVegaWalletDialogStore } from '@vegaprotocol/wallet';
import { Faucet } from './faucet';
export const Deposit = () => {
const { VEGA_ENV } = useEnvironment();
const [searchParams] = useSearchParams();
const assetId = searchParams.get('assetId') || undefined;
const { data: assets } = useEnabledAssets();
const { data: markets } = useTopTradedMarkets();
const { config } = useEthereumConfig();
if (!assets) return null;
if (!config) return null;
return (
<div className="flex flex-col gap-6">
<DepositContainer assetId={assetId} />
<div className="flex flex-col gap-4">
<DepositFlow
assets={assets}
assetId={assetId}
bridgeAddress={config.collateral_bridge_contract.address}
confirmations={config.confirmations}
markets={markets || []}
faucetEnabled={VEGA_ENV !== Networks.MAINNET}
/>
<DepositGetStarted />
</div>
);
};
interface DepositState {
asset: AssetFieldsFragment | undefined;
amount: string;
allowance: BigNumber | undefined;
balance: BigNumber | undefined;
}
type SetDepositState = Dispatch<SetStateAction<DepositState>>;
const DepositFlow = ({
assets,
assetId,
bridgeAddress,
confirmations,
markets,
faucetEnabled,
}: {
assets: AssetFieldsFragment[];
assetId?: string;
bridgeAddress: string;
confirmations: number;
markets: MarketMaybeWithDataAndCandles[];
faucetEnabled: boolean;
}) => {
const [search, setSearch] = useState('');
const { provider, account } = useWeb3React();
const [state, setState] = useState<DepositState>(() => {
const asset = assets.find((a) => a.id === assetId);
return {
asset,
amount: '',
allowance: undefined,
balance: undefined,
};
});
const handleAssetChanged = useCallback(
async (assetId: string | undefined) => {
const asset = assets.find((a) => a.id === assetId);
if (!asset) {
setState((curr) => ({
...curr,
asset: undefined,
allowance: undefined,
balance: undefined,
}));
return;
}
if (!isAssetTypeERC20(asset)) throw new Error('invalid asset');
let allowance = new BigNumber(0);
let balance = new BigNumber(0);
// if you have connected and therefore have a provider get
// balance and allowance
if (provider && account) {
const signer = provider.getSigner();
const tokenContract = new Token(
asset.source.contractAddress,
signer || provider
);
const allowanceRes = await tokenContract.allowance(
account,
bridgeAddress
);
allowance = new BigNumber(
addDecimal(allowanceRes.toString(), asset.decimals)
);
const balanceRes = await tokenContract.balanceOf(account);
balance = new BigNumber(
addDecimal(balanceRes.toString(), asset.decimals)
);
}
setState((curr) => ({
...curr,
asset,
allowance,
balance,
}));
},
[account, assets, bridgeAddress, provider]
);
const refetchBalances = useCallback(() => {
// refetch balance and allowance
if (state.asset) {
handleAssetChanged(state.asset.id);
}
}, [state.asset, handleAssetChanged]);
const getMarketsForAsset = (a: AssetFieldsFragment) => {
return markets
.filter((m) => {
const marketAsset = getAsset(m);
return marketAsset.id === a.id;
})
.slice(0, 4);
};
if (state.asset && isAssetTypeERC20(state.asset)) {
return (
<div className="flex flex-col gap-4">
<div>
{isApproved(state.allowance) ? (
<StepTitle step={3} title={t('Enter amount and deposit')} />
) : (
<StepTitle step={2} title={t('Approve deposits')} />
)}
</div>
<div
className={classNames(
'p-5 rounded flex flex-col gap-3',
'bg-vega-clight-700 dark:bg-vega-cdark-700'
)}
>
<div className="flex justify-between">
<TokenHeader asset={state.asset} />
<div className="text-right">
<Balance asset={state.asset} balance={state.balance} />
</div>
</div>
<Markets markets={getMarketsForAsset(state.asset)} />
<div className="relative">
<TransactionContainer
state={state}
setState={setState}
bridgeAddress={bridgeAddress}
confirmations={confirmations}
refetchBalances={refetchBalances}
faucetEnabled={faucetEnabled}
/>
<div className="absolute bottom-0 right-0">
<TradingButton
onClick={() => handleAssetChanged(undefined)}
size="small"
intent={Intent.Danger}
>
{t('Cancel')}
</TradingButton>
</div>
</div>
</div>
</div>
);
}
const filteredAssets = assets
.filter((a) => a.source.__typename === 'ERC20')
.filter((a) => a.symbol.toLowerCase().includes(search.toLowerCase()));
filteredAssets.sort((a, b) => {
if (a.name < b.name) return -1;
if (a.name > b.name) return 1;
return 0;
});
return (
<div className="flex flex-col gap-4">
<div className="flex items-end justify-between">
<StepTitle
step={1}
title={t('Choose an asset to deposit to the Vega network')}
/>
<div>
<TradingInput
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
/>
</div>
</div>
<div className="flex flex-col gap-4">
{filteredAssets.map((a) => {
if (!isAssetTypeERC20(a)) return null;
const marketsForAsset = getMarketsForAsset(a);
return (
<div
key={a.id}
onClick={() => {
handleAssetChanged(a.id);
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleAssetChanged(a.id);
}
}}
className={classNames(
'p-5 rounded text-left',
'bg-vega-clight-800 dark:bg-vega-cdark-800 hover:bg-vega-clight-700 dark:hover:bg-vega-cdark-700 cursor-pointer'
)}
>
<div className="flex flex-col flex-1 gap-3">
<div className="flex justify-between">
<TokenHeader asset={a} />
<div className="text-right">
<Balance asset={a} />
</div>
</div>
<Markets markets={marketsForAsset} />
</div>
</div>
);
})}
</div>
</div>
);
};
const TransactionContainer = ({
state,
setState,
bridgeAddress,
confirmations,
refetchBalances,
faucetEnabled,
}: {
state: DepositState;
setState: SetDepositState;
bridgeAddress: string;
confirmations: number;
refetchBalances: () => void;
faucetEnabled: boolean;
}) => {
return (
<>
<Approval
state={state}
bridgeAddress={bridgeAddress}
refetchBalances={refetchBalances}
/>
<SendDeposit
state={state}
amount={state.amount}
bridgeAddress={bridgeAddress}
confirmations={confirmations}
setAmount={(amount) => setState((curr) => ({ ...curr, amount }))}
faucetEnabled={faucetEnabled}
refetchBalances={refetchBalances}
/>
</>
);
};
const Approval = ({
state,
bridgeAddress,
refetchBalances,
}: {
state: DepositState;
bridgeAddress: string;
refetchBalances: () => void;
}) => {
const openDialog = useWeb3ConnectStore((store) => store.open);
const { account, provider } = useWeb3React();
const [id, setId] = useState<number | null>(null);
const tx = useEthTransactionStore((store) => {
return store.transactions.find((t) => t?.id == id);
});
const send = useEthTransactionStore((store) => store.create);
const submitApproval = () => {
if (!provider) throw new Error('no provider');
if (!isAssetTypeERC20(state.asset)) {
throw new Error('no provider');
}
const signer = provider.getSigner();
const contract = new Token(
state.asset.source.contractAddress,
signer || provider
);
const id = send(contract, 'approve', [
bridgeAddress,
MaxUint256.toString(),
]);
setId(id);
};
useEffect(() => {
if (!state.asset) return; // this can get triggered when you clear the selected asset
if (tx?.status === EthTxStatus.Confirmed) {
refetchBalances();
}
}, [tx?.status, state.asset, refetchBalances]);
if (!state.asset) return null;
// No asset selected, show generic approval title
if (!account) {
return (
<div className="pt-4 mt-4 border-t border-vega-clight-400 dark:border-vega-cdark-400">
<TradingButton onClick={openDialog} size="small">
{t('Connect Ethereum wallet')}
</TradingButton>
</div>
);
}
return (
<div className="pt-4 mt-4 border-t border-vega-clight-400 dark:border-vega-cdark-400">
{isApproved(state.allowance) ? (
<div className="flex justify-between">
<div className="flex flex-col items-start gap-2">
<p className="text-sm">{t('Approved for use')}</p>
{tx && tx.status === EthTxStatus.Pending ? (
<TradingButton disabled={true} size="small">
{t('Approving...')}
</TradingButton>
) : (
<TradingButton onClick={submitApproval} size="small">
{t('Re-approve')}
</TradingButton>
)}
</div>
<div className="text-right">
{state.allowance && (
<div className="font-mono text-lg">
<Allowance allowance={state.allowance} />
<small className="ml-1 text-xs text-muted font-alpha">
{t('Approved')}
</small>
</div>
)}
</div>
</div>
) : (
<div className="flex flex-col items-start gap-2">
<div>
<h3 className="text-sm">{t('Approve deposits')}</h3>
<p className="text-sm text-muted">
{t(
'Before you can make a deposit of %s you need to approve its use with Vega in your Ethereum wallet.',
state.asset.symbol
)}
</p>
</div>
{tx && tx.status === EthTxStatus.Pending ? (
<TradingButton intent={Intent.Success} disabled={true} size="small">
{t('Approving...')}
</TradingButton>
) : (
<TradingButton
onClick={submitApproval}
size="small"
intent={Intent.Success}
>
{t('Approve %s deposits', state.asset.symbol)}
</TradingButton>
)}
</div>
)}
</div>
);
};
const SendDeposit = ({
state,
amount,
setAmount,
bridgeAddress,
confirmations,
faucetEnabled,
refetchBalances,
}: {
state: DepositState;
amount: string;
bridgeAddress: string;
confirmations: number;
setAmount: (amount: string) => void;
faucetEnabled: boolean;
refetchBalances: () => void;
}) => {
const openVegaWalletDialog = useVegaWalletDialogStore(
(store) => store.openVegaWalletDialog
);
const { pubKey } = useVegaWallet();
const { provider, account } = useWeb3React();
const [id, setId] = useState<number | null>(null);
const send = useEthTransactionStore((store) => store.create);
const tx = useEthTransactionStore((store) => {
return store.transactions.find((t) => t?.id == id);
});
const submitDeposit = async (e: FormEvent) => {
e.preventDefault();
if (!provider) {
throw new Error('no provider');
}
if (!pubKey) {
throw new Error('no vega pubkey');
}
if (!isAssetTypeERC20(state.asset)) {
throw new Error('no asset selected');
}
const signer = provider.getSigner();
const contract = new CollateralBridge(bridgeAddress, signer || provider);
const id = send(
contract,
'deposit_asset',
[
state.asset.source.contractAddress,
removeDecimal(amount, state.asset.decimals),
prepend0x(pubKey),
],
state.asset.id,
confirmations ?? 1,
true
);
setId(id);
};
useEffect(() => {
if (!state.asset) return; // this can get triggered when you clear the selected asset
if (tx?.status === EthTxStatus.Confirmed) {
refetchBalances();
}
}, [tx?.status, state.asset, refetchBalances]);
if (!state.asset) {
return null;
}
if (!account) {
return null;
}
// Dont show deposit ui unless approved
if (!isApproved(state.allowance)) {
return null;
}
if (!pubKey) {
return (
<div className="flex flex-col items-start pt-4 mt-4 border-t gap-2 border-vega-clight-400 dark:border-vega-cdark-400">
<TradingButton onClick={openVegaWalletDialog} size="small">
{t('Connect Vega wallet')}
</TradingButton>
</div>
);
}
return (
<div className="flex flex-col items-start pt-4 mt-4 border-t gap-2 border-vega-clight-400 dark:border-vega-cdark-400">
<h3 className="text-sm">{t('Deposit')}</h3>
<form className="flex gap-2" onSubmit={submitDeposit}>
<TradingInput
name="amount"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="Enter amount"
className="min-w-[250px]"
/>
{state.balance && (
<TradingButton
size="small"
onClick={() =>
setAmount(state.balance ? state.balance.toString() : '')
}
>
<span className="whitespace-nowrap">{t('Use max')}</span>
</TradingButton>
)}
{faucetEnabled && (
<Faucet asset={state.asset} refetchBalances={refetchBalances} />
)}
<TradingButton type="submit" size="small" intent={Intent.Success}>
<span className="whitespace-nowrap">
{t('Deposit %s', state.asset.symbol)}
</span>
</TradingButton>
</form>
</div>
);
};
const TokenHeader = ({ asset }: { asset: AssetFieldsFragment }) => {
const isErc20 = isAssetTypeERC20(asset);
const openDialog = useAssetDetailsDialogStore((store) => store.open);
return (
<div className="flex items-center text-lg gap-2">
{isErc20 && <TokenIcon address={asset.source.contractAddress} />}
<h3 className="text-lg">
{asset.symbol}
{isErc20 && (
<button
className="ml-1 text-sm underline underline-offset-2 text-muted"
onClick={(e) => {
e.stopPropagation();
openDialog(asset.id);
}}
>
{truncateByChars(asset.source.contractAddress)}
</button>
)}
</h3>
</div>
);
};
const StepTitle = ({ step, title }: { step: number; title: string }) => {
return (
<h3 className="flex flex-col gap-0.5">
<small className="text-muted">{t('%s/3', step.toString())}</small>
<span>{title}</span>
</h3>
);
};
const isApproved = (allowance: BigNumber | undefined) => {
if (!allowance) return false;
return allowance.isGreaterThan(0);
};
@@ -0,0 +1,58 @@
import type { AssetFieldsFragment } from '@vegaprotocol/assets';
import { t } from '@vegaprotocol/i18n';
import { Token } from '@vegaprotocol/smart-contracts';
import { TradingButton } from '@vegaprotocol/ui-toolkit';
import { isAssetTypeERC20 } from '@vegaprotocol/utils';
import { EthTxStatus, useEthTransactionStore } from '@vegaprotocol/web3';
import { useWeb3React } from '@web3-react/core';
import { useEffect, useState } from 'react';
export const Faucet = ({
asset,
refetchBalances,
}: {
asset: AssetFieldsFragment;
refetchBalances: () => void;
}) => {
const { provider } = useWeb3React();
const [id, setId] = useState<number | null>(null);
const send = useEthTransactionStore((store) => store.create);
const tx = useEthTransactionStore((store) => {
return store.transactions.find((t) => t?.id == id);
});
const submitFaucet = async () => {
if (!provider) throw new Error('no provider');
if (!isAssetTypeERC20(asset)) {
throw new Error('no asset selected');
}
const signer = provider.getSigner();
const contract = new Token(
asset.source.contractAddress,
signer || provider
);
const id = send(contract, 'faucet', []);
setId(id);
};
useEffect(() => {
if (tx?.status === EthTxStatus.Confirmed) {
refetchBalances();
}
}, [tx?.status, refetchBalances]);
if (tx && tx.status === EthTxStatus.Pending) {
return (
<TradingButton size="small" disabled={true}>
{t('Fauceting...')}
</TradingButton>
);
}
return (
<TradingButton size="small" onClick={submitFaucet}>
<span className="whitespace-nowrap">{t('Faucet')}</span>
</TradingButton>
);
};
@@ -0,0 +1,90 @@
import { calcCandleVolume, useCandles } from '@vegaprotocol/markets';
import type { MarketMaybeWithDataAndCandles } from '@vegaprotocol/markets';
import { Sparkline } from '@vegaprotocol/ui-toolkit';
import {
addDecimalsFormatNumber,
formatNumberPercentage,
priceChangePercentage,
} from '@vegaprotocol/utils';
import { BigNumber } from 'bignumber.js';
import { t } from '@vegaprotocol/i18n';
import classNames from 'classnames';
export const Markets = ({
markets,
}: {
markets: MarketMaybeWithDataAndCandles[];
}) => {
return (
<div>
<p className="mb-2 text-xs text-muted">{t('Currently traded in')}</p>
<div className="grid grid-cols-4 gap-1 md:gap-4">
{markets.length ? (
markets.map((m) => {
return <MarketCard key={m.id} market={m} />;
})
) : (
<p className="text-xs">No markets</p>
)}
</div>
</div>
);
};
const MarketCard = ({ market }: { market: MarketMaybeWithDataAndCandles }) => {
const { oneDayCandles } = useCandles({ marketId: market.id });
const vol = oneDayCandles ? calcCandleVolume(oneDayCandles) : '0';
const volume =
vol && vol !== '0'
? addDecimalsFormatNumber(vol, market.positionDecimalPlaces)
: '0.00';
const change = new BigNumber(
priceChangePercentage(oneDayCandles?.map((c) => c.close) || [])
);
return (
<div className="flex flex-col p-2 rounded-lg md:p-4 col-span-2 lg:col-span-1 gap-1 lg:gap-2 bg-vega-clight-600 dark:bg-vega-cdark-600">
<div className="text-sm lg:text-base">
<h3 className="leading-none">
{market.tradableInstrument.instrument.code}
</h3>
<p className="text-muted">Vol {volume}</p>
</div>
<div className="items-end justify-between hidden md:flex gap-2 lg:gap-8">
<div>
{market.data?.markPrice && (
<>
<p className="text-sm leading-none lg:text-base">
{addDecimalsFormatNumber(
market.data.markPrice,
market.decimalPlaces
)}
</p>
<p
className={classNames(
'text-xs lg:text-sm leading-tight align-baseline',
{
'text-market-green': change.isGreaterThan(0),
'text-market-red': change.isLessThan(0),
'text-muted': change.isZero(),
}
)}
>
{formatNumberPercentage(change, 2)}
</p>
</>
)}
</div>
<div className="flex-1 max-w-[50%]">
{oneDayCandles && (
<Sparkline
width={100}
height={36}
data={oneDayCandles.map((c) => Number(c.close))}
/>
)}
</div>
</div>
</div>
);
};
+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>
);
};
@@ -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>
);
};
@@ -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 } 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={formatNumber(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,101 @@
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
*/
export const getAdjustedFee = (fees: BigNumber[], discounts: BigNumber[]) => {
const totalFee = fees.reduce((sum, f) => sum.plus(f), new BigNumber(0));
const totalDiscount = discounts.reduce(
(sum, d) => sum.plus(d),
new BigNumber(0)
);
return totalFee
.times(BigNumber.max(0, new BigNumber(1).minus(totalDiscount)))
.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,
})}
@@ -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);
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

+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>
);
+1
View File
@@ -1 +1,2 @@
export * from './lib';
export { TESTNET_ASSET_ALLOWLIST } from './testnet-allowed-assets';
+21
View File
@@ -0,0 +1,21 @@
// Lots of assets on testnet are no longer used this is an allow
// list, only the assets listed below should be used
export const TESTNET_ASSET_ALLOWLIST = [
'deadbeef00000000000000000000000000000000000000000000000000000001', // XYZalpha
'deadbeef00000000000000000000000000000000000000000000000000000002', // XYZbeta
'deadbeef00000000000000000000000000000000000000000000000000000003', // XYZdelta
'deadbeef00000000000000000000000000000000000000000000000000000004', // XYZepsilon
'deadbeef00000000000000000000000000000000000000000000000000000005', // XYZgamma
'fc7fd956078fb1fc9db5c19b88f0874c4299b2a7639ad05a47a28c0aef291b55', // VEGA
'e1cc8e2598d11c4c3ccc4521f0fc06f4b6d940a8607ca38b72bec138600f0525', // tBTC (fairground)
'cee709223217281d7893b650850ae8ee8a18b7539b5658f9b4cc24de95dd18ad', // tBTC (test)
'c9fe6fc24fce121b2cc72680543a886055abb560043fda394ba5376203b7527d', // tUSDC (test)
'c398a73a07a9b51dfa0f20302be3003d2d3fc09f2523d71547ac2b8ccb074665', // DAI
'b340c130096819428a62e5df407fd6abe66e444b89ad64f670beb98621c9c663', // tDAI (test)
'a7cb2a2855b992b08fa612e3189c317ad54c3934b9e9ba3be4ba33457c1152a2', // USDT
'XYZalpha',
'XYZbeta',
'XYZDelta',
'XYZepsilon',
'XYZgamma',
];
@@ -259,11 +259,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 +274,7 @@ export const DealTicketMarginDetails = ({
? liquidationEstimateBestCase
: liquidationEstimateWorstCase
).toString(),
assetDecimals,
market.decimalPlaces,
undefined,
market.decimalPlaces
);
@@ -308,7 +306,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>
@@ -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(
+2
View File
@@ -82,6 +82,8 @@ 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`,
}
: undefined;
+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()]} />);
});
@@ -153,6 +153,8 @@ export const NetworkParams = {
'spam_protection_minimumWithdrawalQuantumMultiple',
spam_protection_voting_min_tokens: 'spam_protection_voting_min_tokens',
spam_protection_proposal_min_tokens: 'spam_protection_proposal_min_tokens',
market_fee_factors_infrastructureFee: 'market_fee_factors_infrastructureFee',
market_fee_factors_makerFee: 'market_fee_factors_makerFee',
market_liquidity_targetstake_triggering_ratio:
'market_liquidity_targetstake_triggering_ratio',
market_liquidity_bondPenaltyParameter:
+5
View File
@@ -49,6 +49,11 @@ query EstimatePosition(
openVolume: $openVolume
orders: $orders
collateralAvailable: $collateralAvailable
# Everywhere in the codebase we expect price values of the underlying to have the right
# number of digits for formatting with market.decimalPlaces. By default the estimatePosition
# query will return a full value requiring formatting using asset.decimals. For consistency
# we can set this variable to true so that we can format with market.decimalPlaces
scaleLiquidationPriceToMarketDecimals: true
) {
margin {
worstCase {
+1
View File
@@ -130,6 +130,7 @@ export const EstimatePositionDocument = gql`
openVolume: $openVolume
orders: $orders
collateralAvailable: $collateralAvailable
scaleLiquidationPriceToMarketDecimals: true
) {
margin {
worstCase {
@@ -0,0 +1,85 @@
import { MockedProvider } from '@apollo/client/testing';
import type { MockedResponse } from '@apollo/client/testing';
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { EstimatePositionDocument } from './__generated__/Positions';
import type { EstimatePositionQuery } from './__generated__/Positions';
import { LiquidationPrice } from './liquidation-price';
import { addDecimalsFormatNumber } from '@vegaprotocol/utils';
describe('LiquidationPrice', () => {
const props = {
marketId: 'market-id',
openVolume: '100',
collateralAvailable: '1000',
decimalPlaces: 2,
};
const worstCaseOpenVolume = '200';
const bestCaseOpenVolume = '100';
const mock: MockedResponse<EstimatePositionQuery> = {
request: {
query: EstimatePositionDocument,
variables: {
marketId: props.marketId,
openVolume: props.openVolume,
collateralAvailable: props.collateralAvailable,
},
},
result: {
data: {
estimatePosition: {
margin: {
worstCase: {
maintenanceLevel: '100',
searchLevel: '100',
initialLevel: '100',
collateralReleaseLevel: '100',
},
bestCase: {
maintenanceLevel: '100',
searchLevel: '100',
initialLevel: '100',
collateralReleaseLevel: '100',
},
},
liquidation: {
worstCase: {
open_volume_only: worstCaseOpenVolume,
including_buy_orders: '100',
including_sell_orders: '100',
},
bestCase: {
open_volume_only: bestCaseOpenVolume,
including_buy_orders: '100',
including_sell_orders: '100',
},
},
},
},
},
};
it('correctly formats best and worst case values for the tooltip', async () => {
render(
<MockedProvider mocks={[mock]}>
<LiquidationPrice {...props} />
</MockedProvider>
);
expect(screen.getByText('-')).toBeInTheDocument();
const el = await screen.findByTestId('liquidation-price');
expect(el).toHaveTextContent(
addDecimalsFormatNumber(worstCaseOpenVolume, props.decimalPlaces)
);
await userEvent.hover(el);
const tooltip = within(await screen.findByRole('tooltip'));
expect(
tooltip.getByText('Worst case').nextElementSibling
).toHaveTextContent(
addDecimalsFormatNumber(worstCaseOpenVolume, props.decimalPlaces)
);
expect(tooltip.getByText('Best case').nextElementSibling).toHaveTextContent(
addDecimalsFormatNumber(bestCaseOpenVolume, props.decimalPlaces)
);
});
});
+4 -22
View File
@@ -8,20 +8,12 @@ export const LiquidationPrice = ({
openVolume,
collateralAvailable,
decimalPlaces,
formatDecimals,
}: {
marketId: string;
openVolume: string;
collateralAvailable: string;
decimalPlaces: number;
formatDecimals: number;
}) => {
// NOTE!
//
// The estimate order query API gives us the liquidation price unformatted but expecting to be converted
// using asset decimal placse.
//
// We need to convert it with asset decimals, but display it formatted with market decimals precision until the API changes.
const { data: currentData, previousData } = useEstimatePositionQuery({
variables: {
marketId,
@@ -38,21 +30,11 @@ export const LiquidationPrice = ({
return <span>-</span>;
}
let bestCase = '-';
let worstCase = '-';
let bestCase = data.estimatePosition.liquidation.bestCase.open_volume_only;
let worstCase = data.estimatePosition.liquidation.worstCase.open_volume_only;
bestCase =
data.estimatePosition?.liquidation?.bestCase.open_volume_only.replace(
/\..*/,
''
);
worstCase =
data.estimatePosition?.liquidation?.worstCase.open_volume_only.replace(
/\..*/,
''
);
worstCase = addDecimalsFormatNumber(worstCase, decimalPlaces, formatDecimals);
bestCase = addDecimalsFormatNumber(bestCase, decimalPlaces, formatDecimals);
worstCase = addDecimalsFormatNumber(worstCase, decimalPlaces, decimalPlaces);
bestCase = addDecimalsFormatNumber(bestCase, decimalPlaces, decimalPlaces);
return (
<Tooltip
+1 -5
View File
@@ -339,16 +339,12 @@ export const PositionsTable = ({
if (!data) {
return '-';
}
// The estimate order query API gives us the liquidation price unformatted but expecting
// conversion using asset decimals. We need to convert it with asset decimals, but format
// it with market decimals precision until the API changes.
return (
<LiquidationPrice
marketId={data.marketId}
openVolume={data.openVolume}
collateralAvailable={data.totalBalance}
decimalPlaces={data.assetDecimals}
formatDecimals={data.marketDecimalPlaces}
decimalPlaces={data.marketDecimalPlaces}
/>
);
},
+1
View File
@@ -46,6 +46,7 @@ export * from './theme-switcher';
export * from './thumbs';
export * from './tiny-scroll';
export * from './toast';
export * from './token-icons';
export * from './toggle';
export * from './tooltip';
export * from './traffic-light';
@@ -1,77 +1,68 @@
import { render, screen } from '@testing-library/react';
import { Sparkline } from './sparkline';
import type { SparklineProps } from './sparkline';
const props = {
data: [
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
10, 11, 12,
],
muted: true,
};
describe('Sparkline', () => {
let props: SparklineProps = {
data: [],
};
it('Renders an svg with a single path', () => {
render(<Sparkline {...props} />);
expect(screen.getByTestId('sparkline-svg')).toBeInTheDocument();
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toBeInTheDocument();
expect(path).toHaveAttribute('d', expect.any(String));
expect(path).toHaveAttribute('stroke', expect.any(String));
expect(path).toHaveAttribute('stroke-width', '1');
expect(path).toHaveAttribute('fill', 'transparent');
});
beforeEach(() => {
props = {
data: [
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8,
9, 10, 11, 12,
],
};
});
it('Requires a data prop but width and height are optional', () => {
render(<Sparkline {...props} />);
const svg = screen.getByTestId('sparkline-svg');
expect(svg).toHaveAttribute('width', '60');
expect(svg).toHaveAttribute('height', '15');
});
it('Renders an svg with a single path', () => {
render(<Sparkline {...props} />);
expect(screen.getByTestId('sparkline-svg')).toBeInTheDocument();
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toBeInTheDocument();
expect(path).toHaveAttribute('d', expect.any(String));
expect(path).toHaveAttribute('stroke-width', '1');
});
it('Renders a red line if the last value is less than the first', () => {
props.data[0] = 10;
props.data[props.data.length - 1] = 5;
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-market-red dark:stroke-market-red'
);
});
it('Requires a data prop but width and height are optional', () => {
render(<Sparkline {...props} />);
const svg = screen.getByTestId('sparkline-svg');
expect(svg).toHaveAttribute('width', '60');
expect(svg).toHaveAttribute('height', '15');
});
it('Renders a green line if the last value is greater than the first', () => {
props.data[0] = 5;
props.data[props.data.length - 1] = 10;
props.muted = true;
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-market-green-600 dark:stroke-market-green'
);
});
it('Renders a red line if the last value is less than the first', () => {
props.data[0] = 10;
props.data[props.data.length - 1] = 5;
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-market-red dark:stroke-market-red'
);
});
it('Renders a white line if the first and last values are equal', () => {
props.data[0] = 5;
props.data[props.data.length - 1] = 5;
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-black/40 dark:stroke-white/40'
);
});
it('Renders a green line if the last value is greater than the first', () => {
props.data[0] = 5;
props.data[props.data.length - 1] = 10;
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-market-green-600 dark:stroke-market-green'
);
});
it('Renders a gray line if there are not 24 values', () => {
props.data = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
22, 23,
];
render(<Sparkline {...props} />);
const paths = screen.queryAllByTestId('sparkline-path');
expect(paths).toHaveLength(2);
expect(paths[0]).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-black/40 dark:stroke-white/40'
);
it('Renders a white line if the first and last values are equal', () => {
props.data[0] = 5;
props.data[props.data.length - 1] = 5;
render(<Sparkline {...props} />);
const paths = screen.getAllByTestId('sparkline-path');
const path = paths[0];
expect(path).toHaveClass(
'[vector-effect:non-scaling-stroke] stroke-black/40 dark:stroke-white/40'
);
});
});
@@ -8,39 +8,49 @@ export default {
const Template: Story = (args) => <Sparkline data={args['data']} {...args} />;
export const Grey = Template.bind({});
Grey.args = {
data: [
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8,
],
width: 60,
height: 30,
points: 25,
className: 'w-[113px]',
};
export const Equal = Template.bind({});
Equal.args = {
data: [
12, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
10, 11, 12,
],
width: 60,
width: 110,
height: 30,
points: 25,
className: 'w-[113px]',
};
export const Increase = Template.bind({});
Increase.args = {
data: [
1, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
10, 11, 12,
22,
22,
22, // extra values should be ignored, this should still render an increase
0,
2,
3,
4,
5,
13,
14,
15,
16,
17,
18,
19,
20,
21,
22,
23,
24,
6,
7,
8,
9,
10,
11,
12,
],
width: 60,
width: 110,
height: 30,
points: 25,
className: 'w-[113px]',
};
export const Decrease = Template.bind({});
@@ -49,8 +59,27 @@ Decrease.args = {
12, 2, 3, 4, 5, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 6, 7, 8, 9,
10, 11, 1,
],
width: 60,
width: 110,
height: 30,
};
export const LessThan24HoursIncrease = Template.bind({});
LessThan24HoursIncrease.args = {
data: [20, 21, 22, 25, 24, 24, 22, 19, 20, 22, 23, 27],
width: 110,
height: 30,
};
export const LessThan24HoursDecrease = Template.bind({});
LessThan24HoursDecrease.args = {
data: [20, 21, 22, 23, 24, 6, 7, 9, 11, 13, 11, 9],
width: 110,
height: 30,
};
export const NoData = Template.bind({});
NoData.args = {
data: [],
width: 110,
height: 30,
points: 25,
className: 'w-[113px]',
};
@@ -1,15 +1,25 @@
import { extent } from 'd3-array';
import { scaleLinear } from 'd3-scale';
import { line } from 'd3-shape';
import { area, line } from 'd3-shape';
import isEqual from 'lodash/isEqual';
import React from 'react';
function colorByChange(a: number, b: number) {
return a === b
? 'stroke-black/40 dark:stroke-white/40'
: a < b
? 'stroke-market-green-600 dark:stroke-market-green'
: 'stroke-market-red dark:stroke-market-red';
if (a < b) {
return 'stroke-market-green-600 dark:stroke-market-green';
} else if (a > b) {
return 'stroke-market-red dark:stroke-market-red';
}
return 'stroke-black/40 dark:stroke-white/40';
}
function shadedColor(a: number, b: number) {
if (a < b) {
return 'fill-market-green-600';
} else if (a > b) {
return 'fill-market-red';
}
return 'fill-black dark:fill-white';
}
export interface SparklineProps {
@@ -18,75 +28,60 @@ export interface SparklineProps {
height?: number;
points?: number;
className?: string;
muted?: boolean;
}
export const SparklineView = ({
data,
width = 60,
height = 15,
points = 25,
muted = false,
points = 24,
className,
}: SparklineProps) => {
// How many points are missing. If market is 12 hours old the 25 - 12
const preMarketLength = points - data.length;
// Create two dimensional array for sparkline points [x, y]
const marketData: [number, number][] = data.map((d, i) => [
preMarketLength + i,
d,
]);
// Empty two dimensional array for gray, 'no data' line
let preMarketData: [number, number][] = [];
// Get the extent for our y value
const [min, max] = extent(marketData, (d) => d[1]);
const [min, max] = extent(data, (d) => d);
if (typeof min !== 'number' || typeof max !== 'number') {
return null;
}
// Create a second set of data to render a gray line for any
// missing points if the market is less than 24 hours old
if (marketData.length < points) {
// Populate preMarketData with the average of our extents
// so that the line renders centered vertically
const fillValue = (min + max) / 2;
preMarketData = new Array(points - marketData.length)
.fill(fillValue)
.map((d: number, i) => [i, d] as [number, number]);
const midValue = (min + max) / 2;
// Add the first point of or market data so that the two
// lines join up
preMarketData.push(marketData[0] as [number, number]);
}
// Market may be less than 24hr old so padd the data array
// with values that is the mid value (avg of min and max).
// This will rendera horizontal line until the real data shifts the line
const padCount = data.length < points ? points - data.length : 0;
const padArr = new Array(padCount).fill(midValue);
const trimmedData = data.slice(-points);
const xScale = scaleLinear().domain([0, points]).range([0, 100]);
const yScale = scaleLinear().domain([min, max]).range([100, 0]);
// Get the last 24 values if data has more than needed
const lineData: [number, number][] = [...padArr, ...trimmedData].map(
(d, i) => {
return [i, d];
}
);
const xScale = scaleLinear().domain([0, points]).range([0, width]);
const yScale = scaleLinear().domain([min, max]).range([height, 0]);
const lineSeries = line()
.x((d) => xScale(d[0]))
.y((d) => yScale(d[1]));
// Get the color of the marketData line
const [firstVal, lastVal] = [data[0], data[data.length - 1]];
const strokeClassName = muted
? data.length >= 24
? colorByChange(firstVal, lastVal)
: 'stroke-black/40 dark:stroke-white/40'
: colorByChange(firstVal, lastVal);
const areaSeries = area()
.x((d) => xScale(d[0]))
.y0(height)
.y1((d) => yScale(d[1]));
const firstVal = trimmedData[0];
const lastVal = trimmedData[trimmedData.length - 1];
// Get the color of the marketData line depending on market movement
const strokeClassName = colorByChange(firstVal, lastVal);
const areaClassName = shadedColor(firstVal, lastVal);
// Create paths
const preMarketCreationPath = lineSeries(preMarketData);
const mainPath = lineSeries(marketData);
const pathProps = {
'data-testid': 'sparkline-path',
className: `[vector-effect:non-scaling-stroke] ${strokeClassName}`,
stroke: 'strokeCurrent',
strokeWidth: 1,
fill: 'transparent',
};
const linePath = lineSeries(lineData);
const areaPath = areaSeries(lineData);
return (
<svg
@@ -94,13 +89,25 @@ export const SparklineView = ({
className={className}
width={width}
height={height}
viewBox="0 0 100 100"
viewBox={`0 0 ${width} ${height}`}
preserveAspectRatio="none"
>
{preMarketCreationPath && (
<path {...pathProps} d={preMarketCreationPath} />
{linePath && (
<path
d={linePath}
data-testid="sparkline-path"
className={`[vector-effect:non-scaling-stroke] fill-transparent ${strokeClassName}`}
strokeWidth={1}
/>
)}
{areaPath && (
<path
className={areaClassName}
fillOpacity={0.2}
stroke="none"
d={areaPath}
/>
)}
{mainPath && <path {...pathProps} d={mainPath} />}
</svg>
);
};
@@ -0,0 +1,45 @@
export const Ethereum = ({ size = 16 }: { size?: number }) => {
return (
<svg
width={size}
height={size}
shape-rendering="geometricPrecision"
text-rendering="geometricPrecision"
image-rendering="optimizeQuality"
fill-rule="evenodd"
clip-rule="evenodd"
viewBox="0 0 784.37 1277.39"
>
<polygon
fill="#343434"
fill-rule="nonzero"
points="392.07,0 383.5,29.11 383.5,873.74 392.07,882.29 784.13,650.54 "
/>
<polygon
fill="#8C8C8C"
fill-rule="nonzero"
points="392.07,0 -0,650.54 392.07,882.29 392.07,472.33 "
/>
<polygon
fill="#3C3C3B"
fill-rule="nonzero"
points="392.07,956.52 387.24,962.41 387.24,1263.28 392.07,1277.38 784.37,724.89 "
/>
<polygon
fill="#8C8C8C"
fill-rule="nonzero"
points="392.07,1277.38 392.07,956.52 -0,724.89 "
/>
<polygon
fill="#141414"
fill-rule="nonzero"
points="392.07,882.29 784.13,650.54 392.07,472.33 "
/>
<polygon
fill="#393939"
fill-rule="nonzero"
points="0,650.54 392.07,882.29 392.07,472.33 "
/>
</svg>
);
};
@@ -0,0 +1 @@
export * from './token-icon';
@@ -0,0 +1,21 @@
import { Vega } from './vega';
import { USDc } from './usdc';
import { USDt } from './usdt';
export const TokenIconMap: Record<
string,
({ size }: { size: number }) => JSX.Element
> = {
// Vega
'0xcB84d72e61e383767C4DFEb2d8ff7f4FB89abc6e': Vega, // mainnet
'0xdf1B0F223cb8c7aB3Ef8469e529fe81E73089BD9': Vega, // testnet
'0x62720E92FB7d77699A5D3754c429a3116900a84b': Vega, // stagnet
// USDc
'0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48': USDc, // mainnet
'0xdBa6373d0DAAAA44bfAd663Ff93B1bF34cE054E9': USDc, // testnet
// USDt
'0xdAC17F958D2ee523a2206206994597C13D831ec7': USDt, // mainnet
'0x607c609C21171578847d7c31f29C235850f47Bb7': USDt, // tesnet
};
@@ -0,0 +1,29 @@
import classNames from 'classnames';
import { TokenIconMap } from './token-icon-record';
interface TokenIconProps {
address: string;
size?: number;
}
export const TokenIcon = ({ address, size = 28 }: TokenIconProps) => {
const Element = TokenIconMap[address];
return (
<span className={classNames('inline-block', 'align-text-bottom')}>
{Element ? (
<Element size={size} />
) : (
<span
style={{ width: size, height: size }}
className="flex items-center justify-center rounded-full bg-vega-clight-500 dark:bg-vega-cdark-500"
>
<span className="block text-[9px] font-mono leading-[0.9]">
ERC
<br />
20
</span>
</span>
)}
</span>
);
};
@@ -0,0 +1,18 @@
export const USDc = ({ size = 16 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 2000 2000">
<path
d="M1000 2000c554.17 0 1000-445.83 1000-1000S1554.17 0 1000 0 0 445.83 0 1000s445.83 1000 1000 1000z"
fill="#2775ca"
/>
<path
d="M1275 1158.33c0-145.83-87.5-195.83-262.5-216.66-125-16.67-150-50-150-108.34s41.67-95.83 125-95.83c75 0 116.67 25 137.5 87.5 4.17 12.5 16.67 20.83 29.17 20.83h66.66c16.67 0 29.17-12.5 29.17-29.16v-4.17c-16.67-91.67-91.67-162.5-187.5-170.83v-100c0-16.67-12.5-29.17-33.33-33.34h-62.5c-16.67 0-29.17 12.5-33.34 33.34v95.83c-125 16.67-204.16 100-204.16 204.17 0 137.5 83.33 191.66 258.33 212.5 116.67 20.83 154.17 45.83 154.17 112.5s-58.34 112.5-137.5 112.5c-108.34 0-145.84-45.84-158.34-108.34-4.16-16.66-16.66-25-29.16-25h-70.84c-16.66 0-29.16 12.5-29.16 29.17v4.17c16.66 104.16 83.33 179.16 220.83 200v100c0 16.66 12.5 29.16 33.33 33.33h62.5c16.67 0 29.17-12.5 33.34-33.33v-100c125-20.84 208.33-108.34 208.33-220.84z"
fill="#fff"
/>
<path
d="M787.5 1595.83c-325-116.66-491.67-479.16-370.83-800 62.5-175 200-308.33 370.83-370.83 16.67-8.33 25-20.83 25-41.67V325c0-16.67-8.33-29.17-25-33.33-4.17 0-12.5 0-16.67 4.16-395.83 125-612.5 545.84-487.5 941.67 75 233.33 254.17 412.5 487.5 487.5 16.67 8.33 33.34 0 37.5-16.67 4.17-4.16 4.17-8.33 4.17-16.66v-58.34c0-12.5-12.5-29.16-25-37.5zM1229.17 295.83c-16.67-8.33-33.34 0-37.5 16.67-4.17 4.17-4.17 8.33-4.17 16.67v58.33c0 16.67 12.5 33.33 25 41.67 325 116.66 491.67 479.16 370.83 800-62.5 175-200 308.33-370.83 370.83-16.67 8.33-25 20.83-25 41.67V1700c0 16.67 8.33 29.17 25 33.33 4.17 0 12.5 0 16.67-4.16 395.83-125 612.5-545.84 487.5-941.67-75-237.5-258.34-416.67-487.5-491.67z"
fill="#fff"
/>
</svg>
);
};
@@ -0,0 +1,14 @@
export const USDt = ({ size = 16 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 339.43 295.27">
<path
fill="#50af95"
d="M62.15,1.45l-61.89,130a2.52,2.52,0,0,0,.54,2.94L167.95,294.56a2.55,2.55,0,0,0,3.53,0L338.63,134.4a2.52,2.52,0,0,0,.54-2.94l-61.89-130A2.5,2.5,0,0,0,275,0H64.45a2.5,2.5,0,0,0-2.3,1.45h0Z"
/>
<path
fill="#fff"
d="M191.19,144.8v0c-1.2.09-7.4,0.46-21.23,0.46-11,0-18.81-.33-21.55-0.46v0c-42.51-1.87-74.24-9.27-74.24-18.13s31.73-16.25,74.24-18.15v28.91c2.78,0.2,10.74.67,21.74,0.67,13.2,0,19.81-.55,21-0.66v-28.9c42.42,1.89,74.08,9.29,74.08,18.13s-31.65,16.24-74.08,18.12h0Zm0-39.25V79.68h59.2V40.23H89.21V79.68H148.4v25.86c-48.11,2.21-84.29,11.74-84.29,23.16s36.18,20.94,84.29,23.16v82.9h42.78V151.83c48-2.21,84.12-11.73,84.12-23.14s-36.09-20.93-84.12-23.15h0Zm0,0h0Z"
/>
</svg>
);
};
@@ -0,0 +1,43 @@
export const Vega = ({ size = 16 }: { size?: number }) => {
return (
<svg width={size} height={size} viewBox="0 0 42 42">
<rect width="42" height="42" rx="21" fill="black" />
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M13 27.2726H16.4545V10H13V27.2726Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M25.667 23.8181H29.1215V10H25.667V23.8181Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M19.333 33.6059H22.7875V30.1514H19.333V33.6059Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M22.7871 30.7271H26.2416V27.2726H22.7871V30.7271Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M29.1211 27.2726H31.9999V23.8181H29.1211V27.2726Z"
fill="white"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M16.4551 30.7271H19.3339V27.2726H16.4551V30.7271Z"
fill="white"
/>
</svg>
);
};
@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { generateAccount, generateAsset } from './test-helpers';
import type { WithdrawManagerProps } from './withdraw-manager';
@@ -57,10 +57,18 @@ describe('WithdrawManager', () => {
);
it('calls submit if valid form submission', async () => {
// 1002-WITH-002
// 1002-WITH-003
const { container } = render(generateJsx(props));
await act(async () => {
await submitValid(container);
});
const select = container.querySelector('select[name="asset"]') as Element;
await userEvent.selectOptions(select, props.assets[0].id);
await userEvent.clear(screen.getByLabelText('To (Ethereum address)'));
await userEvent.type(
screen.getByLabelText('To (Ethereum address)'),
ethereumAddress
);
await userEvent.type(screen.getByLabelText('Amount'), '0.01');
await userEvent.click(screen.getByTestId('submit-withdrawal'));
expect(props.submit).toHaveBeenCalledWith({
amount: '1000',
asset: props.assets[0].id,
@@ -70,58 +78,56 @@ describe('WithdrawManager', () => {
});
it('validates correctly', async () => {
render(generateJsx(props));
// 1002-WITH-010
// 1002-WITH-005
// 1002-WITH-008
// 1002-WITH-018
const { container } = render(generateJsx(props));
// Set other fields to be valid
fireEvent.change(screen.getByLabelText('Asset'), {
target: { value: props.assets[0].id },
});
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
const select = container.querySelector('select[name="asset"]') as Element;
await userEvent.selectOptions(select, props.assets[0].id);
expect(screen.getByTestId('connect-eth-wallet-btn')).toBeInTheDocument();
await userEvent.type(
screen.getByLabelText('To (Ethereum address)'),
ethereumAddress
);
// Min amount
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00000001' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
await userEvent.clear(screen.getByLabelText('Amount'));
await userEvent.type(screen.getByLabelText('Amount'), '0.00000001');
await userEvent.click(screen.getByTestId('submit-withdrawal'));
expect(
await screen.findByText('Value is below minimum')
).toBeInTheDocument();
expect(props.submit).not.toBeCalled();
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.00001' },
});
await userEvent.clear(screen.getByLabelText('Amount'));
await userEvent.type(screen.getByLabelText('Amount'), '0.00001');
// Max amount (balance is 1)
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '2' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
await userEvent.clear(screen.getByLabelText('Amount'));
await userEvent.type(screen.getByLabelText('Amount'), '2');
await userEvent.click(screen.getByTestId('submit-withdrawal'));
expect(
await screen.findByText('Insufficient amount in account')
).toBeInTheDocument();
expect(props.submit).not.toBeCalled();
});
it('can set amount using use maximum button', async () => {
// 1002-WITH-004
render(generateJsx(props));
const submitValid = async (container: HTMLElement) => {
const select = container.querySelector('select[name="asset"]') as Element;
await userEvent.selectOptions(select, props.assets[0].id);
fireEvent.change(screen.getByLabelText('To (Ethereum address)'), {
target: { value: ethereumAddress },
});
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.01' },
});
fireEvent.submit(screen.getByTestId('withdraw-form'));
};
await userEvent.click(screen.getByTestId('use-maximum'));
expect(screen.getByTestId('amount-input')).toHaveValue(1);
});
it('shows withdraw delay notification if amount greater than threshold', async () => {
render(generateJsx(props));
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '1001' },
});
await userEvent.type(screen.getByLabelText('Amount'), '1001');
expect(
await screen.findByTestId('amount-withdrawal-delay-notification')
).toBeInTheDocument();
@@ -130,9 +136,7 @@ describe('WithdrawManager', () => {
it('shows withdraw delay notification if threshold is 0', async () => {
withdrawAsset.threshold = new BigNumber(0);
render(generateJsx(props));
fireEvent.change(screen.getByLabelText('Amount'), {
target: { value: '0.01' },
});
await userEvent.type(screen.getByLabelText('Amount'), '0.01');
expect(
await screen.findByTestId('withdrawals-delay-notification')
).toBeInTheDocument();
+1 -1
View File
@@ -71,7 +71,7 @@
"jsondiffpatch": "^0.4.1",
"lodash": "^4.17.21",
"next": "13.3.0",
"pennant": "1.13.4",
"pennant": "1.14.0",
"react": "18.2.0",
"react-copy-to-clipboard": "^5.0.4",
"react-dom": "18.2.0",
+4 -4
View File
@@ -20500,10 +20500,10 @@ pend@~1.2.0:
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
integrity sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==
pennant@1.13.4:
version "1.13.4"
resolved "https://registry.yarnpkg.com/pennant/-/pennant-1.13.4.tgz#33a5f3413634a2341a7b91c917f023c59ecc44c7"
integrity sha512-sqwkUiYHxmS97RY8jToMfgR9ePcEr5PWVQu9BPrhdUIa1Q/NztE36SWM5tVIsPPTx3pPIHYTNEVJm02Ubl8cZQ==
pennant@1.14.0:
version "1.14.0"
resolved "https://registry.yarnpkg.com/pennant/-/pennant-1.14.0.tgz#4100c25a6d836d6f0ff425181fb6f812f9fe5778"
integrity sha512-9H0zWzFUSbD1BlDXnHFmKwkAxXGb1xTxjkUD+RwaMygtSwPXzQEyk2ScVyMqxdcz0RuJmI5HCVmZTOjdr1NwuA==
dependencies:
"@babel/runtime" "^7.13.10"
"@d3fc/d3fc-technical-indicator" "^8.0.1"