From bfcf5a65ed29a51a20d390ca85a04b61a92db8ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20P=C3=A1nik?= Date: Tue, 19 Jul 2022 14:13:38 +0200 Subject: [PATCH] release v0.4 (#1071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: graph timeframe updates after website idle (#182) * Update yarn.lock * #41 | WIP resolver tests * feat: add wait-for-expect testing library * feat: remove unnecessary propertiy in gql schema * feat: add mocked polkadotJsContext for testing purposes * feat: update getBalancesByAddress implementation * feat: update gql schema * #41 | move render function to fix ESlint validation * refactor: mockApiPromise and mockUsePolkadotJsContext * test: add fail cases * feat: upgrade type def for polkadot/api v7 * #41 accounts resolver mock * #41 | clear VestingSchedule from account schema * #41 | selectedAccount => activeAccount * #41 | remove duplicated line * #41 | PR comment fixes * #41 | PR comment fixes * #41 | addressing PR review - refactored onAccountSelected handling * #41 | PR issue fix - use classNames FTW * #41 | PR comment fix - account is always populated * #41 | TODO comment * #41 | basic react-intl setup * #41 | Formatted messages for links * #41 | handle and propagate getAllAccounts loading state * #41 | moving files along to match desired dir structures * #41 | moving files along to match desired dir structures * #41 | further dir structure shaking and cleanup * #41 | accounts dir structure refactoring finished * #41 | Todo * #41 | add source field to Account GQL type * #41 | test update * #41 | add transformIgnorePatterns for polkadot to jest.config.js file * #41 | getAccounts lib function and tests * #41 | cleanup * #41 | cleanup * #41 | getAccounts refactoring * #41 | getAccounts tests finished * #41 | use waitForExpect * #41 | accounts resolver tests * #41 | pass ss58Format as an option to avoid manual address encoding * #41 | tests structured formatting * #41 | fix test description * #41 | shared withTypename helper * #41 | propper return type * #41 | extract callback to separate function * test: refactor getAccounts * #41 | basilisk ui app provider name moved to constants * #41 | update mocked web3Enable response format * #41 | update padding to result in whole px value * #41 | use skip instead of lazy query to avoid manual effects * #41 | post merge fixes * Create .nvmrc * #41 | post install yarn.lock update * #41 | fix storybook test snapshots * #41 | remove tests for deprecated component * #41 | Icon component refactoring * #41 | translations * Really remove husky: * account-selector redesign * Updates to tradeform * fixes to AssetBalanceInput + stories * Added default asset out bsx * remove trade chart * cleanup trade form * working towards trade info * reverse spot price * spot price display * submit trade * complete wallet * fix wallet * start balance input * fix balance fetching for accounts * Added last block fetching and trade info * added spot price display to trade form * mumbo jumbo with spot price * debugging last block * update polkadot dependencies, fix trade limit calc * change event listener for trade type changing * feat: subscription for last block * added historical dataset with latest data composition, added asset switcher * fix asset balance input + metric unit selector * page layout * fix ci? * Update styles * some chart fixes * small story fix * remove an unwanted dot * chart-style * correct loading statuses for accounts & pool by ids * Revive trade chart * reset dataset on asset id change * fix loading status for wallet & trade form * responsive something * better flex * polish * more swag * fix overlay * hide trade form settings instead of comment out * added pool asset list * disable lbp pool fetching * fix styles * add placeholder * xyk trade status handling * extract trade settings as portal * add updating of query params * fix trade settings form submit * settings * button n info * limit graph bounds * icons~WIP * icons something * asset styling + longname * swag, actually fix chart jail and rendering, disable tooltip logs * style fixes * trade balances before/after/% * fix unique assets for asset selector * no default values for calculated amount counterparts * graph fix * linechart x axis scale * Trade info style * moar space * asset selector * fix decimal input in BalanceInput * USDC metadata * use formattedbalance in chart & form * fix straight graph * fix clickable area for unit selector * trade chart precision fix * switchfix * header fix * trade form tweaks * jumpy * footer liveliness and spot price in graph fix * colors for footer * bring back notifs * notifs v1 * spinner * settings * trade info errors * swag * Uupdate E2E testing workflow * Update polkadot-dapp * Unlock e2e tests * warning styling * more swag for trade info * trade paddings * new test for trade page * clean console logs and remove graceful error handler from xyk.sell * center modal close button * add sha version to footer * remove unit if value is 0 * switch to short sha version * fix footer sha display * short git hash version * minor fixes * spicy validations * add small fix for calculated amounts * Error styling * trade balance fixes * loading status fixes * eerrrrs * added faucete mutation + fixed % trade balance change * add faucetg * remove loader from faucet * fix error timing * fix alignment in tradeinfo * fix % trade balances * units and balance input remove trailing zeroes * Adjust unit balance recalculation in inputs * timeout hack for tradeinfo errors * switch you get / pay with * fix switching of in/out in form * add help * Fix graph loading status * Fix chart loading text, fix block num loading status * fix asset ordering * Simplify wallet button logic (show connect account) * Added debug box" * fix debug box false * debug stuff * Moveup active account validation, move debugbox closer to root * add pretty json view to debug box * debug box details for trade form * fix rerender of debug box * debug box features * Fix trade balances loading state * Tweak trade balances display for none/some account connected + none/some amount input value * Add extension availability pseudo-polling * remove secondary account item address * unify account address triming between mini wallet and account list item * tweaks to account list / item * Add horizontal bar when no asset is selected for unit selector * Filter of trade form assets * Added payment info estimation for xyk sell * add multiple empty states with horizontal bar * Added tooltip to formatted balance * show spotprice in the tradeform as formattede balance * update URLs for new testnet * tokens * styling error fix + hide faucet * wallet genesis hash handling * fix unnecessary display of trade balance change * Align wallet correctly * shorter tooltip delay * adjust extension detection timeout * revalidate when allowed slippage changes * Add calculating of usable balance for the native asset + sufficient fee balance validation (#141) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * Added handling of usable balances for tokens, fixed sub-zero balances for native asset * added minor schema improvements to work with apollo vscode extension (#152) * Feat/usable balance + notEnoughFeeBalance (#151) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * feat: fetch all balances (multi) (#166) * refactor: remove underline for used variables * refactor: rm mockUsePolkadotJsContext;improve fn() mocking * test: update balances test * feat: update hook and error handler for balance resolver * feat: fetch non-native token balances without any assetId * feat: Add reporter workflow to issue comment (#117) * Add reporter workflow to issue comment * Fix github-script params * Fix reporter env vars * docs: update CI docs * docs: fix image in CI docs * refactor: refactor workflows titles and lables * Fix permission issue in script * refactor: Decorate steps names * refactor: Add comment into workflows * refactor: Remove redundant files * refactor: rename workflow * refactor: Rename job name * Refactor github scripts * docs: Update comments and docs in workflows * docs: Update Ci docs * Refactor steps params order * docs: Fix image in CI docs * dosc: Add comment into ggithub actions * docs: Update ci docs * docs: Improve CI docs * refactor: rename wf files, update wf comments * docs: Fix wf comments typos * docs: Refactor CI docs * Fix input name (#165) Co-authored-by: Max Kravchuk Co-authored-by: Istvan * feat: fetch all balances (#168) * feat: fetch all balances if no resolver args are provided * test: rm only * feat: claimable vesting amount (#169) * feat: claimable amount for vesting schedule * feat: return multiple vesting schedules and extract claim cal * feat: add balance transfer mutation (#170) * small build fixes * feat: add function to fetch locked balances for given lockId (#94) * feat: add function to fetch locked balance of native token * feat: add fetching of locked balances * feat: add lockedBalancesQueryResolver * feat: add address parameter to locked balance fetching * feat: Add reporter workflow to issue comment (#117) * Add reporter workflow to issue comment * Fix github-script params * Fix reporter env vars * docs: update CI docs * docs: fix image in CI docs * refactor: refactor workflows titles and lables * Fix permission issue in script * refactor: Decorate steps names * refactor: Add comment into workflows * refactor: Remove redundant files * refactor: rename workflow * refactor: Rename job name * Refactor github scripts * docs: Update comments and docs in workflows * docs: Update Ci docs * Refactor steps params order * docs: Fix image in CI docs * dosc: Add comment into ggithub actions * docs: Update ci docs * docs: Improve CI docs * refactor: rename wf files, update wf comments * docs: Fix wf comments typos * docs: Refactor CI docs * Fix input name (#165) * feat: use generated resolver args for lockedBalance * test: refactor Co-authored-by: Max Kravchuk Co-authored-by: Istvan * refactor: resolve merge conflict * feat: error handling for locked balance (#175) * feat: Fix initial installation (#176) * feat: fetch available non native balance, correct tests * fix: graph timeframe updates after website idle * fix: use lodash and add guard clause for refreshing * forgotten merge conflicts Co-authored-by: Václav Slavík Co-authored-by: dexterslabor Co-authored-by: Istvan Co-authored-by: Matej Sima Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: mckrava Co-authored-by: Jakub Vitek Co-authored-by: Ayoub Fakir Co-authored-by: Matej Šima * Fixed broken validation triggers for payment info (#162) * Revert "#41 | simplified the install extension scenario" This reverts commit 3144db9a8f6a5fadc8037be40307aaf4083e36f9. * feat: #54 update balances resolver * feat: #54 update imports * test: #54 add test for getBalancesByAddress * refactor: #54 constants * feat: #54 add implement. for getBalancesByAddress resolver * docs: format comments * feat: add assetIds type * refactor: #54 getBalancesByAddress * feat: #54 update implementation getBalancesByAddress * #41 | introduce patch for CRAP issue https://github.com/facebook/create-react-app/pull/11797 REMINDER: check for pull request #11797 release to remove the patch. Removing the patch is pretty straightforward. Remove patch-package postinstall-postinstall, remove the package.json script & delete the /patch dir (if you don't use patch-package for anything else). * #41 | fix postinstall script * Update yarn.lock * #41 | WIP resolver tests * feat: add wait-for-expect testing library * feat: remove unnecessary propertiy in gql schema * feat: add mocked polkadotJsContext for testing purposes * feat: update getBalancesByAddress implementation * feat: update gql schema * #41 | move render function to fix ESlint validation * refactor: mockApiPromise and mockUsePolkadotJsContext * test: add fail cases * feat: upgrade type def for polkadot/api v7 * #41 accounts resolver mock * #41 | clear VestingSchedule from account schema * #41 | selectedAccount => activeAccount * #41 | remove duplicated line * #41 | PR comment fixes * #41 | PR comment fixes * #41 | addressing PR review - refactored onAccountSelected handling * #41 | PR issue fix - use classNames FTW * #41 | PR comment fix - account is always populated * #41 | TODO comment * #41 | basic react-intl setup * #41 | Formatted messages for links * #41 | handle and propagate getAllAccounts loading state * #41 | moving files along to match desired dir structures * #41 | moving files along to match desired dir structures * #41 | further dir structure shaking and cleanup * #41 | accounts dir structure refactoring finished * #41 | Todo * #41 | add source field to Account GQL type * #41 | test update * #41 | add transformIgnorePatterns for polkadot to jest.config.js file * #41 | getAccounts lib function and tests * #41 | cleanup * #41 | cleanup * #41 | getAccounts refactoring * #41 | getAccounts tests finished * #41 | use waitForExpect * #41 | accounts resolver tests * #41 | pass ss58Format as an option to avoid manual address encoding * #41 | tests structured formatting * #41 | fix test description * #41 | shared withTypename helper * #41 | propper return type * #41 | extract callback to separate function * test: refactor getAccounts * #41 | basilisk ui app provider name moved to constants * #41 | update mocked web3Enable response format * #41 | update padding to result in whole px value * #41 | use skip instead of lazy query to avoid manual effects * #41 | post merge fixes * Create .nvmrc * #41 | post install yarn.lock update * #41 | fix storybook test snapshots * #41 | remove tests for deprecated component * #41 | Icon component refactoring * #41 | translations * Really remove husky: * account-selector redesign * Updates to tradeform * fixes to AssetBalanceInput + stories * Added default asset out bsx * remove trade chart * cleanup trade form * working towards trade info * reverse spot price * spot price display * submit trade * complete wallet * fix wallet * start balance input * fix balance fetching for accounts * Added last block fetching and trade info * added spot price display to trade form * mumbo jumbo with spot price * debugging last block * update polkadot dependencies, fix trade limit calc * change event listener for trade type changing * feat: subscription for last block * added historical dataset with latest data composition, added asset switcher * fix asset balance input + metric unit selector * page layout * fix ci? * Update styles * some chart fixes * small story fix * remove an unwanted dot * chart-style * correct loading statuses for accounts & pool by ids * Revive trade chart * reset dataset on asset id change * fix loading status for wallet & trade form * responsive something * better flex * polish * more swag * fix overlay * hide trade form settings instead of comment out * added pool asset list * disable lbp pool fetching * fix styles * add placeholder * xyk trade status handling * extract trade settings as portal * add updating of query params * fix trade settings form submit * settings * button n info * limit graph bounds * icons~WIP * icons something * asset styling + longname * swag, actually fix chart jail and rendering, disable tooltip logs * style fixes * trade balances before/after/% * fix unique assets for asset selector * no default values for calculated amount counterparts * graph fix * linechart x axis scale * Trade info style * moar space * asset selector * fix decimal input in BalanceInput * USDC metadata * use formattedbalance in chart & form * fix straight graph * fix clickable area for unit selector * trade chart precision fix * switchfix * header fix * trade form tweaks * jumpy * footer liveliness and spot price in graph fix * colors for footer * bring back notifs * notifs v1 * spinner * settings * trade info errors * swag * Uupdate E2E testing workflow * Update polkadot-dapp * Unlock e2e tests * warning styling * more swag for trade info * trade paddings * new test for trade page * clean console logs and remove graceful error handler from xyk.sell * center modal close button * add sha version to footer * remove unit if value is 0 * switch to short sha version * fix footer sha display * short git hash version * minor fixes * spicy validations * add small fix for calculated amounts * Error styling * trade balance fixes * loading status fixes * eerrrrs * added faucete mutation + fixed % trade balance change * add faucetg * remove loader from faucet * fix error timing * fix alignment in tradeinfo * fix % trade balances * units and balance input remove trailing zeroes * Adjust unit balance recalculation in inputs * timeout hack for tradeinfo errors * switch you get / pay with * fix switching of in/out in form * add help * Fix graph loading status * Fix chart loading text, fix block num loading status * fix asset ordering * Simplify wallet button logic (show connect account) * Added debug box" * fix debug box false * debug stuff * Moveup active account validation, move debugbox closer to root * add pretty json view to debug box * debug box details for trade form * fix rerender of debug box * debug box features * Fix trade balances loading state * Tweak trade balances display for none/some account connected + none/some amount input value * Add extension availability pseudo-polling * remove secondary account item address * unify account address triming between mini wallet and account list item * tweaks to account list / item * Add horizontal bar when no asset is selected for unit selector * Filter of trade form assets * Added payment info estimation for xyk sell * add multiple empty states with horizontal bar * Added tooltip to formatted balance * show spotprice in the tradeform as formattede balance * update URLs for new testnet * tokens * styling error fix + hide faucet * wallet genesis hash handling * fix unnecessary display of trade balance change * Align wallet correctly * shorter tooltip delay * adjust extension detection timeout * revalidate when allowed slippage changes * Add calculating of usable balance for the native asset + sufficient fee balance validation (#141) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * Added handling of usable balances for tokens, fixed sub-zero balances for native asset * added minor schema improvements to work with apollo vscode extension (#152) * Feat/usable balance + notEnoughFeeBalance (#151) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * Fixed broken validation triggers for payment info Co-authored-by: Václav Slavík Co-authored-by: dexterslabor Co-authored-by: Istvan Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: mckrava Co-authored-by: Jakub Vitek Co-authored-by: Ayoub Fakir * added max button (#153) * #41 | refactoring * #41 | Install polkadot extension CTA * #41 | Reload page link * #41 | simplified the install extension scenario * Revert "#41 | simplified the install extension scenario" This reverts commit 3144db9a8f6a5fadc8037be40307aaf4083e36f9. * feat: #54 update balances resolver * feat: #54 update imports * test: #54 add test for getBalancesByAddress * refactor: #54 constants * feat: #54 add implement. for getBalancesByAddress resolver * docs: format comments * feat: add assetIds type * refactor: #54 getBalancesByAddress * feat: #54 update implementation getBalancesByAddress * #41 | introduce patch for CRAP issue https://github.com/facebook/create-react-app/pull/11797 REMINDER: check for pull request #11797 release to remove the patch. Removing the patch is pretty straightforward. Remove patch-package postinstall-postinstall, remove the package.json script & delete the /patch dir (if you don't use patch-package for anything else). * #41 | fix postinstall script * Update yarn.lock * #41 | WIP resolver tests * feat: add wait-for-expect testing library * feat: remove unnecessary propertiy in gql schema * feat: add mocked polkadotJsContext for testing purposes * feat: update getBalancesByAddress implementation * feat: update gql schema * #41 | move render function to fix ESlint validation * refactor: mockApiPromise and mockUsePolkadotJsContext * test: add fail cases * feat: upgrade type def for polkadot/api v7 * #41 accounts resolver mock * #41 | clear VestingSchedule from account schema * #41 | selectedAccount => activeAccount * #41 | remove duplicated line * #41 | PR comment fixes * #41 | PR comment fixes * #41 | addressing PR review - refactored onAccountSelected handling * #41 | PR issue fix - use classNames FTW * #41 | PR comment fix - account is always populated * #41 | TODO comment * #41 | basic react-intl setup * #41 | Formatted messages for links * #41 | handle and propagate getAllAccounts loading state * #41 | moving files along to match desired dir structures * #41 | moving files along to match desired dir structures * #41 | further dir structure shaking and cleanup * #41 | accounts dir structure refactoring finished * #41 | Todo * #41 | add source field to Account GQL type * #41 | test update * #41 | add transformIgnorePatterns for polkadot to jest.config.js file * #41 | getAccounts lib function and tests * #41 | cleanup * #41 | cleanup * #41 | getAccounts refactoring * #41 | getAccounts tests finished * #41 | use waitForExpect * #41 | accounts resolver tests * #41 | pass ss58Format as an option to avoid manual address encoding * #41 | tests structured formatting * #41 | fix test description * #41 | shared withTypename helper * #41 | propper return type * #41 | extract callback to separate function * test: refactor getAccounts * #41 | basilisk ui app provider name moved to constants * #41 | update mocked web3Enable response format * #41 | update padding to result in whole px value * #41 | use skip instead of lazy query to avoid manual effects * #41 | post merge fixes * Create .nvmrc * #41 | post install yarn.lock update * #41 | fix storybook test snapshots * #41 | remove tests for deprecated component * #41 | Icon component refactoring * #41 | translations * Really remove husky: * account-selector redesign * Updates to tradeform * fixes to AssetBalanceInput + stories * Added default asset out bsx * remove trade chart * cleanup trade form * working towards trade info * reverse spot price * spot price display * submit trade * complete wallet * fix wallet * start balance input * fix balance fetching for accounts * Added last block fetching and trade info * added spot price display to trade form * mumbo jumbo with spot price * debugging last block * update polkadot dependencies, fix trade limit calc * change event listener for trade type changing * feat: subscription for last block * added historical dataset with latest data composition, added asset switcher * fix asset balance input + metric unit selector * page layout * fix ci? * Update styles * some chart fixes * small story fix * remove an unwanted dot * chart-style * correct loading statuses for accounts & pool by ids * Revive trade chart * reset dataset on asset id change * fix loading status for wallet & trade form * responsive something * better flex * polish * more swag * fix overlay * hide trade form settings instead of comment out * added pool asset list * disable lbp pool fetching * fix styles * add placeholder * xyk trade status handling * extract trade settings as portal * add updating of query params * fix trade settings form submit * settings * button n info * limit graph bounds * icons~WIP * icons something * asset styling + longname * swag, actually fix chart jail and rendering, disable tooltip logs * style fixes * trade balances before/after/% * fix unique assets for asset selector * no default values for calculated amount counterparts * graph fix * linechart x axis scale * Trade info style * moar space * asset selector * fix decimal input in BalanceInput * USDC metadata * use formattedbalance in chart & form * fix straight graph * fix clickable area for unit selector * trade chart precision fix * switchfix * header fix * trade form tweaks * jumpy * footer liveliness and spot price in graph fix * colors for footer * bring back notifs * notifs v1 * spinner * settings * trade info errors * swag * Uupdate E2E testing workflow * Update polkadot-dapp * Unlock e2e tests * warning styling * more swag for trade info * trade paddings * new test for trade page * clean console logs and remove graceful error handler from xyk.sell * center modal close button * add sha version to footer * remove unit if value is 0 * switch to short sha version * fix footer sha display * short git hash version * minor fixes * spicy validations * add small fix for calculated amounts * Error styling * trade balance fixes * loading status fixes * eerrrrs * added faucete mutation + fixed % trade balance change * add faucetg * remove loader from faucet * fix error timing * fix alignment in tradeinfo * fix % trade balances * units and balance input remove trailing zeroes * Adjust unit balance recalculation in inputs * timeout hack for tradeinfo errors * switch you get / pay with * fix switching of in/out in form * add help * Fix graph loading status * Fix chart loading text, fix block num loading status * fix asset ordering * Simplify wallet button logic (show connect account) * Added debug box" * fix debug box false * debug stuff * Moveup active account validation, move debugbox closer to root * add pretty json view to debug box * debug box details for trade form * fix rerender of debug box * debug box features * Fix trade balances loading state * Tweak trade balances display for none/some account connected + none/some amount input value * Add extension availability pseudo-polling * remove secondary account item address * unify account address triming between mini wallet and account list item * tweaks to account list / item * Add horizontal bar when no asset is selected for unit selector * Filter of trade form assets * Added payment info estimation for xyk sell * add multiple empty states with horizontal bar * Added tooltip to formatted balance * show spotprice in the tradeform as formattede balance * update URLs for new testnet * tokens * styling error fix + hide faucet * wallet genesis hash handling * fix unnecessary display of trade balance change * Align wallet correctly * shorter tooltip delay * adjust extension detection timeout * revalidate when allowed slippage changes * Add calculating of usable balance for the native asset + sufficient fee balance validation (#141) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * added max button * use payment info when calculating trade balances Co-authored-by: Václav Slavík Co-authored-by: dexterslabor Co-authored-by: Istvan Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: mckrava Co-authored-by: Jakub Vitek Co-authored-by: Ayoub Fakir * docs(readme.md): fix storybook invocation in README.md (#914) * feat: Add dummy sign and send helper (#108) * signAndSend #71 * sign and send extrinsic errors * signAndSend tests init * sign and send other error test * readActiveAccount from develop * Merged with latest develop Co-authored-by: Matej Sima * Feat: wallet page for lbp-v1 (#181) * #41 | clear VestingSchedule from account schema * #41 | selectedAccount => activeAccount * #41 | remove duplicated line * #41 | PR comment fixes * #41 | PR comment fixes * #41 | addressing PR review - refactored onAccountSelected handling * #41 | PR issue fix - use classNames FTW * #41 | PR comment fix - account is always populated * #41 | TODO comment * #41 | basic react-intl setup * #41 | Formatted messages for links * #41 | handle and propagate getAllAccounts loading state * #41 | moving files along to match desired dir structures * #41 | moving files along to match desired dir structures * #41 | further dir structure shaking and cleanup * #41 | accounts dir structure refactoring finished * #41 | Todo * #41 | add source field to Account GQL type * #41 | test update * #41 | add transformIgnorePatterns for polkadot to jest.config.js file * #41 | getAccounts lib function and tests * #41 | cleanup * #41 | cleanup * #41 | getAccounts refactoring * #41 | getAccounts tests finished * #41 | use waitForExpect * #41 | accounts resolver tests * #41 | pass ss58Format as an option to avoid manual address encoding * #41 | tests structured formatting * #41 | fix test description * #41 | shared withTypename helper * #41 | propper return type * #41 | extract callback to separate function * test: refactor getAccounts * #41 | basilisk ui app provider name moved to constants * #41 | update mocked web3Enable response format * #41 | update padding to result in whole px value * #41 | use skip instead of lazy query to avoid manual effects * #41 | post merge fixes * Create .nvmrc * #41 | post install yarn.lock update * #41 | fix storybook test snapshots * #41 | remove tests for deprecated component * #41 | Icon component refactoring * #41 | translations * Really remove husky: * account-selector redesign * Updates to tradeform * fixes to AssetBalanceInput + stories * Added default asset out bsx * remove trade chart * cleanup trade form * working towards trade info * reverse spot price * spot price display * submit trade * complete wallet * fix wallet * start balance input * fix balance fetching for accounts * Added last block fetching and trade info * added spot price display to trade form * mumbo jumbo with spot price * debugging last block * update polkadot dependencies, fix trade limit calc * change event listener for trade type changing * feat: subscription for last block * added historical dataset with latest data composition, added asset switcher * fix asset balance input + metric unit selector * page layout * fix ci? * Update styles * some chart fixes * small story fix * remove an unwanted dot * chart-style * correct loading statuses for accounts & pool by ids * Revive trade chart * reset dataset on asset id change * fix loading status for wallet & trade form * responsive something * better flex * polish * more swag * fix overlay * hide trade form settings instead of comment out * added pool asset list * disable lbp pool fetching * fix styles * add placeholder * xyk trade status handling * extract trade settings as portal * add updating of query params * fix trade settings form submit * settings * button n info * limit graph bounds * icons~WIP * icons something * asset styling + longname * swag, actually fix chart jail and rendering, disable tooltip logs * style fixes * trade balances before/after/% * fix unique assets for asset selector * no default values for calculated amount counterparts * graph fix * linechart x axis scale * Trade info style * moar space * asset selector * fix decimal input in BalanceInput * USDC metadata * use formattedbalance in chart & form * fix straight graph * fix clickable area for unit selector * trade chart precision fix * switchfix * header fix * trade form tweaks * jumpy * footer liveliness and spot price in graph fix * colors for footer * bring back notifs * notifs v1 * spinner * settings * trade info errors * swag * Uupdate E2E testing workflow * Update polkadot-dapp * Unlock e2e tests * warning styling * more swag for trade info * trade paddings * new test for trade page * clean console logs and remove graceful error handler from xyk.sell * center modal close button * add sha version to footer * remove unit if value is 0 * switch to short sha version * fix footer sha display * short git hash version * minor fixes * spicy validations * add small fix for calculated amounts * Error styling * trade balance fixes * loading status fixes * eerrrrs * added faucete mutation + fixed % trade balance change * add faucetg * remove loader from faucet * fix error timing * fix alignment in tradeinfo * fix % trade balances * units and balance input remove trailing zeroes * Adjust unit balance recalculation in inputs * timeout hack for tradeinfo errors * switch you get / pay with * fix switching of in/out in form * add help * Fix graph loading status * Fix chart loading text, fix block num loading status * fix asset ordering * Simplify wallet button logic (show connect account) * Added debug box" * fix debug box false * debug stuff * Moveup active account validation, move debugbox closer to root * add pretty json view to debug box * debug box details for trade form * fix rerender of debug box * debug box features * Fix trade balances loading state * Tweak trade balances display for none/some account connected + none/some amount input value * Add extension availability pseudo-polling * remove secondary account item address * unify account address triming between mini wallet and account list item * tweaks to account list / item * Add horizontal bar when no asset is selected for unit selector * Filter of trade form assets * Added payment info estimation for xyk sell * add multiple empty states with horizontal bar * Added tooltip to formatted balance * show spotprice in the tradeform as formattede balance * update URLs for new testnet * tokens * styling error fix + hide faucet * wallet genesis hash handling * fix unnecessary display of trade balance change * Align wallet correctly * shorter tooltip delay * adjust extension detection timeout * revalidate when allowed slippage changes * Add calculating of usable balance for the native asset + sufficient fee balance validation (#141) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * Added handling of usable balances for tokens, fixed sub-zero balances for native asset * added minor schema improvements to work with apollo vscode extension (#152) * Feat/usable balance + notEnoughFeeBalance (#151) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * feat: fetch all balances (multi) (#166) * refactor: remove underline for used variables * refactor: rm mockUsePolkadotJsContext;improve fn() mocking * test: update balances test * feat: update hook and error handler for balance resolver * feat: fetch non-native token balances without any assetId * feat: Add reporter workflow to issue comment (#117) * Add reporter workflow to issue comment * Fix github-script params * Fix reporter env vars * docs: update CI docs * docs: fix image in CI docs * refactor: refactor workflows titles and lables * Fix permission issue in script * refactor: Decorate steps names * refactor: Add comment into workflows * refactor: Remove redundant files * refactor: rename workflow * refactor: Rename job name * Refactor github scripts * docs: Update comments and docs in workflows * docs: Update Ci docs * Refactor steps params order * docs: Fix image in CI docs * dosc: Add comment into ggithub actions * docs: Update ci docs * docs: Improve CI docs * refactor: rename wf files, update wf comments * docs: Fix wf comments typos * docs: Refactor CI docs * Fix input name (#165) Co-authored-by: Max Kravchuk Co-authored-by: Istvan * feat: fetch all balances (#168) * feat: fetch all balances if no resolver args are provided * test: rm only * feat: claimable vesting amount (#169) * feat: claimable amount for vesting schedule * feat: return multiple vesting schedules and extract claim cal * feat: add balance transfer mutation (#170) * wallet * wip * small build fixes * add should calculate * feat: add function to fetch locked balances for given lockId (#94) * feat: add function to fetch locked balance of native token * feat: add fetching of locked balances * feat: add lockedBalancesQueryResolver * feat: add address parameter to locked balance fetching * feat: Add reporter workflow to issue comment (#117) * Add reporter workflow to issue comment * Fix github-script params * Fix reporter env vars * docs: update CI docs * docs: fix image in CI docs * refactor: refactor workflows titles and lables * Fix permission issue in script * refactor: Decorate steps names * refactor: Add comment into workflows * refactor: Remove redundant files * refactor: rename workflow * refactor: Rename job name * Refactor github scripts * docs: Update comments and docs in workflows * docs: Update Ci docs * Refactor steps params order * docs: Fix image in CI docs * dosc: Add comment into ggithub actions * docs: Update ci docs * docs: Improve CI docs * refactor: rename wf files, update wf comments * docs: Fix wf comments typos * docs: Refactor CI docs * Fix input name (#165) * feat: use generated resolver args for lockedBalance * test: refactor Co-authored-by: Max Kravchuk Co-authored-by: Istvan * refactor: resolve merge conflict * feat: error handling for locked balance (#175) * feat: Fix initial installation (#176) * move balances and vesting under active account container * feat: add vesting resolver (#173) * feat: add vesting resolver resolves vesting info to claimable amount, original lock and remaining lock * refactor: remove toBN() * feat: fetch available non native balance, correct tests * fixes * feat: rename vesting, fix lock calc, refactor * feat: rename vestingSchedule and vesting * fix: do not allow negative futureLock * basic transfer * Feat: estimate claim transfer (#171) * feat: add balance transfer mutation * feat: add estimate for transfer and vesting claim Co-authored-by: Istvan Co-authored-by: Matej Sima * Added contextual queries for active account & extension, reduced query duplicity * updated transfers in wallet page Co-authored-by: Václav Slavík Co-authored-by: Matej Sima Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: mckrava Co-authored-by: Jakub Vitek Co-authored-by: Ayoub Fakir Co-authored-by: Matej Šima Co-authored-by: Istvan Co-authored-by: Matehoo <55109377+Matehoo@users.noreply.github.com> * Feat/confirmation screen (#161) * feat: #54 update balances resolver * feat: #54 update imports * test: #54 add test for getBalancesByAddress * refactor: #54 constants * feat: #54 add implement. for getBalancesByAddress resolver * docs: format comments * feat: add assetIds type * refactor: #54 getBalancesByAddress * feat: #54 update implementation getBalancesByAddress * #41 | introduce patch for CRAP issue https://github.com/facebook/create-react-app/pull/11797 REMINDER: check for pull request #11797 release to remove the patch. Removing the patch is pretty straightforward. Remove patch-package postinstall-postinstall, remove the package.json script & delete the /patch dir (if you don't use patch-package for anything else). * #41 | fix postinstall script * Update yarn.lock * #41 | WIP resolver tests * feat: add wait-for-expect testing library * feat: remove unnecessary propertiy in gql schema * feat: add mocked polkadotJsContext for testing purposes * feat: update getBalancesByAddress implementation * feat: update gql schema * #41 | move render function to fix ESlint validation * refactor: mockApiPromise and mockUsePolkadotJsContext * test: add fail cases * feat: upgrade type def for polkadot/api v7 * #41 accounts resolver mock * #41 | clear VestingSchedule from account schema * #41 | selectedAccount => activeAccount * #41 | remove duplicated line * #41 | PR comment fixes * #41 | PR comment fixes * #41 | addressing PR review - refactored onAccountSelected handling * #41 | PR issue fix - use classNames FTW * #41 | PR comment fix - account is always populated * #41 | TODO comment * #41 | basic react-intl setup * #41 | Formatted messages for links * #41 | handle and propagate getAllAccounts loading state * #41 | moving files along to match desired dir structures * #41 | moving files along to match desired dir structures * #41 | further dir structure shaking and cleanup * #41 | accounts dir structure refactoring finished * #41 | Todo * #41 | add source field to Account GQL type * #41 | test update * #41 | add transformIgnorePatterns for polkadot to jest.config.js file * #41 | getAccounts lib function and tests * #41 | cleanup * #41 | cleanup * #41 | getAccounts refactoring * #41 | getAccounts tests finished * #41 | use waitForExpect * #41 | accounts resolver tests * #41 | pass ss58Format as an option to avoid manual address encoding * #41 | tests structured formatting * #41 | fix test description * #41 | shared withTypename helper * #41 | propper return type * #41 | extract callback to separate function * test: refactor getAccounts * #41 | basilisk ui app provider name moved to constants * #41 | update mocked web3Enable response format * #41 | update padding to result in whole px value * #41 | use skip instead of lazy query to avoid manual effects * #41 | post merge fixes * Create .nvmrc * #41 | post install yarn.lock update * #41 | fix storybook test snapshots * #41 | remove tests for deprecated component * #41 | Icon component refactoring * #41 | translations * Really remove husky: * account-selector redesign * Updates to tradeform * fixes to AssetBalanceInput + stories * Added default asset out bsx * remove trade chart * cleanup trade form * working towards trade info * reverse spot price * spot price display * submit trade * complete wallet * fix wallet * start balance input * fix balance fetching for accounts * Added last block fetching and trade info * added spot price display to trade form * mumbo jumbo with spot price * debugging last block * update polkadot dependencies, fix trade limit calc * change event listener for trade type changing * feat: subscription for last block * added historical dataset with latest data composition, added asset switcher * fix asset balance input + metric unit selector * page layout * fix ci? * Update styles * some chart fixes * small story fix * remove an unwanted dot * chart-style * correct loading statuses for accounts & pool by ids * Revive trade chart * reset dataset on asset id change * fix loading status for wallet & trade form * responsive something * better flex * polish * more swag * fix overlay * hide trade form settings instead of comment out * added pool asset list * disable lbp pool fetching * fix styles * add placeholder * xyk trade status handling * extract trade settings as portal * add updating of query params * fix trade settings form submit * settings * button n info * limit graph bounds * icons~WIP * icons something * asset styling + longname * swag, actually fix chart jail and rendering, disable tooltip logs * style fixes * trade balances before/after/% * fix unique assets for asset selector * no default values for calculated amount counterparts * graph fix * linechart x axis scale * Trade info style * moar space * asset selector * fix decimal input in BalanceInput * USDC metadata * use formattedbalance in chart & form * fix straight graph * fix clickable area for unit selector * trade chart precision fix * switchfix * header fix * trade form tweaks * jumpy * footer liveliness and spot price in graph fix * colors for footer * bring back notifs * notifs v1 * spinner * settings * trade info errors * swag * Uupdate E2E testing workflow * Update polkadot-dapp * Unlock e2e tests * warning styling * more swag for trade info * trade paddings * new test for trade page * clean console logs and remove graceful error handler from xyk.sell * center modal close button * add sha version to footer * remove unit if value is 0 * switch to short sha version * fix footer sha display * short git hash version * minor fixes * spicy validations * add small fix for calculated amounts * Error styling * trade balance fixes * loading status fixes * eerrrrs * added faucete mutation + fixed % trade balance change * add faucetg * remove loader from faucet * fix error timing * fix alignment in tradeinfo * fix % trade balances * units and balance input remove trailing zeroes * Adjust unit balance recalculation in inputs * timeout hack for tradeinfo errors * switch you get / pay with * fix switching of in/out in form * add help * Fix graph loading status * Fix chart loading text, fix block num loading status * fix asset ordering * Simplify wallet button logic (show connect account) * Added debug box" * fix debug box false * debug stuff * Moveup active account validation, move debugbox closer to root * add pretty json view to debug box * debug box details for trade form * fix rerender of debug box * debug box features * Fix trade balances loading state * Tweak trade balances display for none/some account connected + none/some amount input value * Add extension availability pseudo-polling * remove secondary account item address * unify account address triming between mini wallet and account list item * tweaks to account list / item * Add horizontal bar when no asset is selected for unit selector * Filter of trade form assets * Added payment info estimation for xyk sell * add multiple empty states with horizontal bar * Added tooltip to formatted balance * show spotprice in the tradeform as formattede balance * update URLs for new testnet * tokens * styling error fix + hide faucet * wallet genesis hash handling * fix unnecessary display of trade balance change * Align wallet correctly * shorter tooltip delay * adjust extension detection timeout * revalidate when allowed slippage changes * Add calculating of usable balance for the native asset + sufficient fee balance validation (#141) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * Added handling of usable balances for tokens, fixed sub-zero balances for native asset * added minor schema improvements to work with apollo vscode extension (#152) * Feat/usable balance + notEnoughFeeBalance (#151) * Add calculating of usable balance for the native asset * Added fee balance validation to trade form * unstyled confirmation screen Co-authored-by: dexterslabor Co-authored-by: Václav Slavík Co-authored-by: Istvan Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: mckrava Co-authored-by: Jakub Vitek Co-authored-by: Ayoub Fakir * update node url * Minor css tweaks, removed graph, removed balance change indicator % * added multi fee payment asset support to asset list * Added notifications for setting the fee payment asset * added tx fee estimate to transfer * css fixes * Added displaying of tx fees in the desired fee payment asset * added txfee/multi fee payment support to vesting and transfer * added some styles (#1045) * added some styles * Feat/swag (#1046) * feat: Add some styles for Wallet, still WIP * feat: Make Wallet table nicer * active account wip * update style * add font * account list updates * more swag * settings button * settings screen * fix gradient * wallet connect err * no wallet nicer style * feat: More polishing (#1047) Co-authored-by: Matehoo <55109377+Matehoo@users.noreply.github.com> * Fix/vesting schedule (#1050) * fix: vesting schedule change formula to use parachain block number; change how future lock is calculated * style: linter * test: add for calculateLock() * Feat/liquidity provisioning (#1051) * added liquidity provisioning basic skeleton * fix wrong component imports * feat: Inherit styles from trade page * wip add/remove liquidity * add/remove liquidity math * Added transactions for add/rm liquidity * feat: Add headers to wallet tables and style pool buttons * changed to public testnet * bring back old env config * feat: Restyle tabs * feat: Restyle tabs #2 * feat: some styles * remove max btn, add validations * feat: some styles #2 * feat: some styles #3 * Adjusted validations + small css * bsx/ksm remain as fee payment assets Co-authored-by: Matej Holicky <10matejholicky@gmail.com> * fixes for multi fee payment asset tx fee conversion * Fix/vesting calculation (#1053) * fix: vesting schedule change formula to use parachain block number; change how future lock is calculated * style: linter * test: add for calculateLock() * fix: vesting calculation * feat: Style notificationa as toast (#1052) * feat: Style notificationa as toast * notifications css + fixed tx status reporting Co-authored-by: Matej Sima * notification styles * Fix/vesting (#1054) * fix: vesting schedule change formula to use parachain block number; change how future lock is calculated * style: linter * test: add for calculateLock() * fix: vesting Co-authored-by: Istvan * fix LP math * fixed asset switcher (#1058) * feat: Center link, url will be added later (#1057) * Feat/remove si units (#1059) * feat: Center link, url will be added later * removed SI units * updated rules for formatting balances" * new formatting rules for balances Co-authored-by: Matej Holicky <10matejholicky@gmail.com> * fixed crashing FormattedBalance Co-authored-by: Matehoo <55109377+Matehoo@users.noreply.github.com> Co-authored-by: Václav Slavík Co-authored-by: dexterslabor Co-authored-by: Istvan Co-authored-by: Matej Sima Co-authored-by: Jakub Panik Co-authored-by: Jakub Panik Co-authored-by: mckrava Co-authored-by: Jakub Vitek Co-authored-by: Ayoub Fakir Co-authored-by: Matej Šima Co-authored-by: Felipe Co-authored-by: Jan Fabian Co-authored-by: Matej Holicky <10matejholicky@gmail.com> --- .env | 6 +- .github/workflows/app-e2e-testing-flow.yml | 206 +++ .vscode/snipsnap.code-snippets | 7 + README.md | 4 +- graphql.schema.json | 466 +++++- package.json | 4 +- src/App.scss | 122 +- src/compiled-lang/en.json | 6 +- .../AssetBalanceInput/AssetBalanceInput.scss | 16 +- .../AssetBalanceInput/AssetBalanceInput.tsx | 99 +- .../AssetSelector/AssetItem/AssetItem.tsx | 6 +- .../AssetSelector/AssetSelector.scss | 4 +- .../AssetSelector/AssetSelector.tsx | 29 +- .../hooks/useModalPortal.tsx | 62 +- .../hooks/useModalPortalElement.tsx | 2 +- .../Balance/BalanceInput/BalanceInput.scss | 1 + .../Balance/BalanceInput/BalanceInput.tsx | 7 +- .../MetricUnitSelector.scss | 9 +- .../FormattedBalance/FormattedBalance.scss | 2 +- .../FormattedBalance/FormattedBalance.tsx | 51 +- src/components/Button/Button.scss | 43 +- .../Chart/ChartHeader/ChartHeader.scss | 3 +- src/components/Confirmation/Confirmation.scss | 25 + src/components/Confirmation/Confirmation.tsx | 50 + src/components/Icon/Icon.tsx | 2 + .../Icon/assets/AssetSwitchIcon.svg | 18 +- src/components/Icon/assets/Back.svg | 19 + src/components/Navigation/ActionBar.tsx | 41 +- src/components/Pools/PoolsForm.scss | 327 +++++ src/components/Pools/PoolsForm.tsx | 1258 +++++++++++++++++ src/components/Pools/PoolsInfo/PoolsInfo.scss | 77 + src/components/Pools/PoolsInfo/PoolsInfo.tsx | 128 ++ src/components/Trade/TradeForm/TradeForm.scss | 133 +- src/components/Trade/TradeForm/TradeForm.tsx | 559 +++++--- .../Trade/TradeForm/TradeInfo/TradeInfo.scss | 41 +- .../Trade/TradeForm/TradeInfo/TradeInfo.tsx | 18 +- .../AccountItem/AccountItem.scss | 100 +- .../AccountItem/AccountItem.tsx | 80 +- .../AccountSelector/AccountSelector.scss | 42 +- .../AccountSelector/AccountSelector.tsx | 66 +- .../hooks/useModalPortalElement.tsx | 21 +- src/components/Wallet/Wallet.scss | 13 +- src/components/Wallet/Wallet.stories.tsx | 220 +-- src/components/Wallet/Wallet.tsx | 45 +- src/containers/MultiProvider.tsx | 109 +- src/containers/PageContainer.scss | 29 +- src/containers/PageContainer.tsx | 20 +- src/containers/Router.tsx | 5 +- src/containers/Wallet.tsx | 61 - src/containers/Wallet/Wallet.tsx | 46 + .../Wallet/hooks/useAccountSelectorModal.tsx | 61 + src/errors.tsx | 1 + src/generated/graphql.tsx | 67 +- src/hooks/accounts/graphql/Accounts.graphql | 8 +- .../graphql/GetActiveAccount.query.graphql | 7 +- src/hooks/accounts/lib/getAccounts.test.tsx | 6 +- src/hooks/accounts/lib/getAccounts.tsx | 5 +- .../accounts/queries/useGetAccountsQuery.tsx | 8 +- .../queries/useGetActiveAccountQuery.tsx | 16 +- .../accounts/resolvers/query/accounts.tsx | 2 - .../resolvers/query/activeAccount.tsx | 3 +- src/hooks/accounts/types.tsx | 2 +- src/hooks/actionLog/useIntentions.tsx | 1 - src/hooks/actionLog/useWithConfirmation.tsx | 54 + src/hooks/apollo/useApollo.tsx | 10 +- .../graphql/TransferBalance.mutation.graphql | 4 +- .../resolvers/mutation/balanceTransfer.tsx | 89 +- .../resolvers/useBalanceMutationResolvers.tsx | 2 +- .../resolvers/useTransferMutation.tsx | 13 +- .../config/useConfigMutationResolver.tsx | 28 +- src/hooks/config/useConfigQueryResolvers.tsx | 5 + src/hooks/config/useGetConfigQuery.tsx | 7 +- src/hooks/config/useSetConfigMutation.tsx | 2 +- src/hooks/extension/lib/getExtension.tsx | 2 - .../queries/useGetExtensionQuery.tsx | 6 +- src/hooks/math/useMath.tsx | 4 + src/hooks/misc/useLoading.tsx | 16 +- src/hooks/polkadotJs/signAndSend.test.tsx | 127 ++ src/hooks/polkadotJs/signAndSend.tsx | 76 + src/hooks/polkadotJs/usePolkadotJs.tsx | 6 +- .../graphql/AddLiquidity.mutation.graphql | 13 + .../graphql/GetPoolByAssets.query.graphql | 2 + src/hooks/pools/graphql/Pool.graphql | 4 +- .../graphql/RemoveLiquidity.mutation.graphql | 11 + src/hooks/pools/lbp/calculateInGivenOut.tsx | 107 +- src/hooks/pools/lbp/calculateOutGivenIn.tsx | 96 +- .../mutations/useAddLiquidityMutation.tsx | 23 + .../mutations/useRemoveLiquidityMutation.tsx | 22 + .../mutations/useSubmitTradeMutation.tsx | 31 +- .../useAddLiquidityMutationResolver.tsx | 47 + .../resolvers/usePoolsMutationResolvers.tsx | 8 +- .../useRemoveLiquidityMutationResolver.tsx | 46 + src/hooks/pools/useGetXykPool.tsx | 4 +- src/hooks/pools/useGetXykPools.tsx | 39 +- src/hooks/pools/xyk/removeLiquidity.tsx | 72 + .../vesting/calculateClaimableAmount.test.tsx | 53 +- .../vesting/calculateClaimableAmount.tsx | 104 +- src/hooks/vesting/graphql/Vesting.graphql | 29 + .../vesting/graphql/VestingSchedule.graphql | 13 - .../vesting/useClaimVestedAmountMutation.tsx | 9 +- src/hooks/vesting/useGetVestingByAddress.tsx | 92 ++ .../vesting/useVestingMutationResolvers.tsx | 76 +- .../vesting/useVestingQueryResolvers.tsx | 24 + src/lang/en.json | 2 +- src/misc/colors.module.scss | 2 +- src/misc/defaults.scss | 21 +- src/misc/fonts/Satoshi-Variable.ttf | Bin 0 -> 127420 bytes src/misc/misc.module.scss | 2 +- src/pages/PoolsPage/PoolsPage.scss | 15 + src/pages/PoolsPage/PoolsPage.tsx | 350 +++++ ...etActiveAccountTradeBalances.query.graphql | 13 + .../PoolsPage/hooks/useAssetIdsWithUrl.tsx | 31 + src/pages/PoolsPage/hooks/useDebugBox.tsx | 52 + .../useGetActiveAccountTradeBalances.tsx | 26 + src/pages/TradePage/TradePage.scss | 190 ++- src/pages/TradePage/TradePage.tsx | 125 +- .../TradePage/hooks/useAssetIdsWithUrl.tsx | 1 - src/pages/WalletPage.tsx | 88 -- src/pages/WalletPage/WalletPage.scss | 22 + src/pages/WalletPage/WalletPage.tsx | 189 +++ .../ActiveAccount/ActiveAccount.scss | 108 ++ .../ActiveAccount/ActiveAccount.tsx | 146 ++ .../WalletPage/BalanceList/BalanceList.scss | 41 + .../WalletPage/BalanceList/BalanceList.tsx | 66 + .../WalletPage/TransferForm/TransferForm.scss | 120 ++ .../WalletPage/TransferForm/TransferForm.tsx | 183 +++ .../hooks/useTransferFormModalPortal.tsx | 21 + .../WalletPage/VestingClaim/VestingClaim.scss | 36 + .../WalletPage/VestingClaim/VestingClaim.tsx | 127 ++ src/schema.graphql | 14 +- yarn.lock | 22 +- 131 files changed, 6936 insertions(+), 1348 deletions(-) create mode 100644 .github/workflows/app-e2e-testing-flow.yml create mode 100644 .vscode/snipsnap.code-snippets create mode 100644 src/components/Confirmation/Confirmation.scss create mode 100644 src/components/Confirmation/Confirmation.tsx create mode 100644 src/components/Icon/assets/Back.svg create mode 100644 src/components/Pools/PoolsForm.scss create mode 100644 src/components/Pools/PoolsForm.tsx create mode 100644 src/components/Pools/PoolsInfo/PoolsInfo.scss create mode 100644 src/components/Pools/PoolsInfo/PoolsInfo.tsx delete mode 100644 src/containers/Wallet.tsx create mode 100644 src/containers/Wallet/Wallet.tsx create mode 100644 src/containers/Wallet/hooks/useAccountSelectorModal.tsx delete mode 100644 src/hooks/actionLog/useIntentions.tsx create mode 100644 src/hooks/actionLog/useWithConfirmation.tsx create mode 100644 src/hooks/polkadotJs/signAndSend.test.tsx create mode 100644 src/hooks/polkadotJs/signAndSend.tsx create mode 100644 src/hooks/pools/graphql/AddLiquidity.mutation.graphql create mode 100644 src/hooks/pools/graphql/RemoveLiquidity.mutation.graphql create mode 100644 src/hooks/pools/mutations/useAddLiquidityMutation.tsx create mode 100644 src/hooks/pools/mutations/useRemoveLiquidityMutation.tsx create mode 100644 src/hooks/pools/resolvers/useAddLiquidityMutationResolver.tsx create mode 100644 src/hooks/pools/resolvers/useRemoveLiquidityMutationResolver.tsx create mode 100644 src/hooks/pools/xyk/removeLiquidity.tsx create mode 100644 src/hooks/vesting/graphql/Vesting.graphql delete mode 100644 src/hooks/vesting/graphql/VestingSchedule.graphql create mode 100644 src/hooks/vesting/useGetVestingByAddress.tsx create mode 100644 src/hooks/vesting/useVestingQueryResolvers.tsx create mode 100644 src/misc/fonts/Satoshi-Variable.ttf create mode 100644 src/pages/PoolsPage/PoolsPage.scss create mode 100644 src/pages/PoolsPage/PoolsPage.tsx create mode 100644 src/pages/PoolsPage/graphql/GetActiveAccountTradeBalances.query.graphql create mode 100644 src/pages/PoolsPage/hooks/useAssetIdsWithUrl.tsx create mode 100644 src/pages/PoolsPage/hooks/useDebugBox.tsx create mode 100644 src/pages/PoolsPage/queries/useGetActiveAccountTradeBalances.tsx delete mode 100644 src/pages/WalletPage.tsx create mode 100644 src/pages/WalletPage/WalletPage.scss create mode 100644 src/pages/WalletPage/WalletPage.tsx create mode 100644 src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.scss create mode 100644 src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.tsx create mode 100644 src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.scss create mode 100644 src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.tsx create mode 100644 src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.scss create mode 100644 src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.tsx create mode 100644 src/pages/WalletPage/containers/WalletPage/TransferForm/hooks/useTransferFormModalPortal.tsx create mode 100644 src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.scss create mode 100644 src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.tsx diff --git a/.env b/.env index a90a95a7..3dfe37f4 100644 --- a/.env +++ b/.env @@ -1,6 +1,10 @@ HTTPS=true # REACT_APP_NODE_URL='ws://localhost:9988' -REACT_APP_NODE_URL='wss://basilisk-rpc.hydration.cloud/' +# testnet +# REACT_APP_NODE_URL='wss://basilisk-testnet-rpc.bsx.fi/' +# rococo +REACT_APP_NODE_URL='wss://rpc-01.basilisk-rococo.hydradx.io/' +# REACT_APP_NODE_URL='wss://amsterdot.eu.ngrok.io' REACT_APP_PROCESSOR_URL='https://bsx-api-testnet.hydration.cloud/graphql' REACT_APP_APP_NAME='Basilisk UI' NODE_OPTIONS=--openssl-legacy-provider diff --git a/.github/workflows/app-e2e-testing-flow.yml b/.github/workflows/app-e2e-testing-flow.yml new file mode 100644 index 00000000..ae89a3a5 --- /dev/null +++ b/.github/workflows/app-e2e-testing-flow.yml @@ -0,0 +1,206 @@ +name: Application E2E Testing Flow +on: + pull_request: + branches: + - develop + push: + branches: + - 'feat/lbp-v1' + +jobs: + build_app: + name: Build UI application + runs-on: macos-11 + steps: + - uses: actions/checkout@v2 + + - name: Install Node.js + uses: actions/setup-node@v1 + with: + node-version: 17.3 + + - name: Cache Node Modules for ui-app + id: cache-node-modules-ui-app + uses: actions/cache@v2 + with: + path: node_modules + key: node-modules-ui-app-${{ hashFiles('yarn.lock') }} + + - name: Install Dependencies for ui-app + if: steps.cache-node-modules-ui-app.outputs.cache-hit != 'true' + run: rm -rf node_modules && yarn install --frozen-lockfile + + - name: Update browserslist + run: npx browserslist@latest --update-db + + - name: Build App + run: yarn run build:deployment + env: + CI: false + REACT_APP_GIT_BRANCH: ${{ steps.extract_branch.outputs.branch }} + NODE_OPTIONS: --openssl-legacy-provider + + - name: Upload script files + uses: actions/upload-artifact@v2 + with: + name: script-files + path: ./scripts + + - name: Upload production-ready App build files + uses: actions/upload-artifact@v2 + with: + name: app-build-files + path: ./build + + run_tests: + name: Run tests + runs-on: ubuntu-latest + needs: [build_app] + steps: + - uses: actions/setup-node@v2 + with: + node-version: 17.3 + + - name: Install Node.js HTTP-Server + run: yarn global add http-server + + - uses: actions/checkout@v2 + with: + path: 'ui-app' + + - name: Download artifact - UI app build + uses: actions/download-artifact@v2 + with: + name: app-build-files + path: ./ui-app/build + +# - name: Download artifact - Storybook build +# uses: actions/download-artifact@v2 +# with: +# name: sb-build-files +# path: ./ui-app/storybook-static + + # Prepare Basilisk-api ("develop" branch must be cloned) + - name: Clone Basilisk-api + run: git clone -b feature/dockerize-testnet https://github.com/galacticcouncil/Basilisk-api.git + + - name: Cache Node Modules for Basilisk-api + id: cache-node-modules-basilisk-api + uses: actions/cache@v2 + with: + path: Basilisk-api/node_modules + key: node-modules-basilisk-api-${{ hashFiles('Basilisk-api/yarn.lock') }} + + - name: Install Dependencies for Basilisk-api + if: steps.cache-node-modules-basilisk-api.outputs.cache-hit != 'true' + run: | + cd Basilisk-api + yarn install --frozen-lockfile + # Install NPM deps for running tests + - name: Cache Node Modules for ui-app + id: cache-node-modules-ui-app + uses: actions/cache@v2 + with: + path: ui-app/node_modules + key: node-modules-ui-app-${{ hashFiles('ui-app/yarn.lock') }} + + - name: Install Dependencies for ui-app + if: steps.cache-node-modules-ui-app.outputs.cache-hit != 'true' + run: | + cd ui-app + yarn install --frozen-lockfile + + # Update folders structure + - name: Change folders permissions + run: | + chmod -R 777 Basilisk-api + chmod -R 777 ui-app + + # Run testnet + - name: Run sandbox testnet + shell: bash + timeout-minutes: 10 + run: | + cd Basilisk-api + yarn fullruntime:clean-setup-start + # Double check of testnet status + - name: Wait for Basilisk Node port :9988 + shell: bash + timeout-minutes: 2 + run: . ./ui-app/scripts/gh-actions-wait-for-port.sh 9988 + + # Run UI App + - name: Run UI application + shell: bash + run: | + cd ui-app/build + http-server -s -p 3030 -a 127.0.0.1 & + # Check of UI app status + - name: Wait for UI app port :3030 + shell: bash + timeout-minutes: 2 + run: . ./ui-app/scripts/gh-actions-wait-for-port.sh 3030 + + # Prepare Playwright env + - name: Install OS dependencies for Playwright + run: npx playwright install-deps + + - name: Make e2e testing env vars file visible (required for falnyr/replace-env-vars-action@master) + run: mv ui-app/.env.test.e2e.ci ui-app/e2e-tests-vars.txt + shell: bash + + - name: Prepate E2E Tests Env Variables + uses: falnyr/replace-env-vars-action@master + env: + E2E_TEST_ACCOUNT_NAME_ALICE: ${{ secrets.E2E_TEST_ACCOUNT_NAME_ALICE }} + E2E_TEST_ACCOUNT_PASSWORD_ALICE: ${{ secrets.E2E_TEST_ACCOUNT_PASSWORD_ALICE }} + E2E_TEST_ACCOUNT_SEED_ALICE: ${{ secrets.E2E_TEST_ACCOUNT_SEED_ALICE }} + with: + filename: ui-app/e2e-tests-vars.txt + + - name: Make e2e testing env vars file hidden + run: mv ui-app/e2e-tests-vars.txt ui-app/.env.test.e2e.ci + shell: bash + + # For debug and monitoring purposes + - name: Check Docker containers and ports + if: always() + run: | + docker ps + docker network ls + sudo lsof -i -P -n | grep LISTEN + shell: bash + + # Run e2e tests + - name: Run e2e tests + shell: bash + run: | + cd ui-app + DEBUG=pw:browser* HEADFUL=true xvfb-run --auto-servernum -- yarn test:e2e-ci + env: + PLAYWRIGHT_TEST_BASE_URL: ${{ github.event.deployment_status.target_url }} + + - name: Sleep for 30 seconds (for compiling html reports) + run: sleep 30s + shell: bash + + - name: Upload trace files + if: always() + uses: actions/upload-artifact@v2 + with: + name: traces_screenshots + path: ./ui-app/traces + + - name: Upload e2e tests report file + if: always() + uses: actions/upload-artifact@v2 + with: + name: e2e_tests_report_html + path: ./ui-app/ui-app-e2e-results.html + + - name: Upload testnet logs + if: always() + uses: actions/upload-artifact@v2 + with: + name: testnet-sandbox-logs + path: ./Basilisk-api/testnet-sandbox-logs diff --git a/.vscode/snipsnap.code-snippets b/.vscode/snipsnap.code-snippets new file mode 100644 index 00000000..53ada59e --- /dev/null +++ b/.vscode/snipsnap.code-snippets @@ -0,0 +1,7 @@ + +404 Not Found + +

404 Not Found

+
nginx
+ + diff --git a/README.md b/README.md index 3a3e4154..0c236521 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ yarn install Start Storybook component development environment. ``` -yarn storybook +yarn storybook:start ``` Storybook can be opened at [:6006](http://localhost:6006) @@ -438,4 +438,4 @@ You have to use legacy openssl provider in node 17+. Set this to node options ```shell export NODE_OPTIONS=--openssl-legacy-provider -``` \ No newline at end of file +``` diff --git a/graphql.schema.json b/graphql.schema.json index 0bcf504f..eab66773 100644 --- a/graphql.schema.json +++ b/graphql.schema.json @@ -3,7 +3,9 @@ "queryType": { "name": "Query" }, - "mutationType": null, + "mutationType": { + "name": "Mutation" + }, "subscriptionType": null, "types": [ { @@ -14,7 +16,24 @@ { "name": "balances", "description": null, - "args": [], + "args": [ + { + "name": "assetIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], "type": { "kind": "NON_NULL", "name": null, @@ -86,10 +105,37 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "vesting", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Vesting", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, - "interfaces": [], + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Balances", + "ofType": null + }, + { + "kind": "INTERFACE", + "name": "IVesting", + "ofType": null + } + ], "enumValues": null, "possibleTypes": null }, @@ -214,6 +260,69 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INTERFACE", + "name": "Balances", + "description": null, + "fields": [ + { + "name": "balances", + "description": null, + "args": [ + { + "name": "assetIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Balance", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Account", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Query", + "ofType": null + } + ] + }, { "kind": "SCALAR", "name": "Boolean", @@ -224,6 +333,29 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "ENUM", + "name": "ChromeExtension", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "POLKADOTJS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TALISMAN", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, { "kind": "OBJECT", "name": "Config", @@ -305,8 +437,8 @@ "description": null, "args": [], "type": { - "kind": "OBJECT", - "name": "Extension", + "kind": "ENUM", + "name": "ChromeExtension", "ofType": null }, "isDeprecated": false, @@ -428,6 +560,40 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "INTERFACE", + "name": "IVesting", + "description": null, + "fields": [ + { + "name": "vesting", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Vesting", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Account", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Query", + "ofType": null + } + ] + }, { "kind": "OBJECT", "name": "LBPAssetWeights", @@ -784,6 +950,41 @@ "enumValues": null, "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "Mutation", + "description": null, + "fields": [ + { + "name": "_empty", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "setActiveAccount", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Account", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "UNION", "name": "Pool", @@ -846,6 +1047,18 @@ "isDeprecated": false, "deprecationReason": null }, + { + "name": "_vestingSchedule", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "VestingSchedule", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, { "name": "accounts", "description": null, @@ -875,13 +1088,9 @@ "description": null, "args": [], "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "OBJECT", - "name": "Account", - "ofType": null - } + "kind": "OBJECT", + "name": "Account", + "ofType": null }, "isDeprecated": false, "deprecationReason": null @@ -909,7 +1118,24 @@ { "name": "balances", "description": null, - "args": [], + "args": [ + { + "name": "assetIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], "type": { "kind": "NON_NULL", "name": null, @@ -1052,24 +1278,51 @@ "description": null, "args": [], "type": { - "kind": "LIST", + "kind": "NON_NULL", "name": null, "ofType": { - "kind": "NON_NULL", + "kind": "LIST", "name": null, "ofType": { - "kind": "UNION", - "name": "Pool", - "ofType": null + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "Pool", + "ofType": null + } } } }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "vesting", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Vesting", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, - "interfaces": [], + "interfaces": [ + { + "kind": "INTERFACE", + "name": "Balances", + "ofType": null + }, + { + "kind": "INTERFACE", + "name": "IVesting", + "ofType": null + } + ], "enumValues": null, "possibleTypes": null }, @@ -1106,6 +1359,140 @@ ], "possibleTypes": null }, + { + "kind": "OBJECT", + "name": "Vesting", + "description": null, + "fields": [ + { + "name": "claimableAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lockedVestingBalance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "originalLockBalance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "VestingSchedule", + "description": null, + "fields": [ + { + "name": "perPeriod", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "period", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "periodCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "start", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, { "kind": "OBJECT", "name": "XYKPool", @@ -1178,6 +1565,38 @@ }, "isDeprecated": false, "deprecationReason": null + }, + { + "name": "shareTokenId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalLiquidity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null } ], "inputFields": null, @@ -2094,15 +2513,6 @@ } ], "directives": [ - { - "name": "client", - "description": null, - "isRepeatable": false, - "locations": [ - "FIELD" - ], - "args": [] - }, { "name": "deprecated", "description": "Marks an element of a GraphQL schema as no longer supported.", diff --git a/package.json b/package.json index ed1e9722..49d03f17 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "graphql": "^16.3.0", "graphql.macro": "^1.4.2", "husky": "^7.0.4", - "hydra-dx-wasm": "https://github.com/galacticcouncil/HydraDX-wasm#main", + "hydra-dx-wasm": "https://github.com/galacticcouncil/HydraDX-wasm#0e3d625c22c32525a4619047223cac019c0cfa46", "jest-image-snapshot": "^4.5.1", "jest-junit": "^13.0.0", "lint-staged": "^12.1.2", @@ -72,6 +72,7 @@ "react-intl": "^5.20.12", "react-json-view": "^1.21.3", "react-multi-provider": "^0.1.5", + "react-page-visibility": "^6.4.0", "react-router-dom": "^6.0.2", "react-scripts": "5.0.0", "react-text-mask": "^5.4.3", @@ -209,6 +210,7 @@ "@storybook/react": "^6.3.11", "@testing-library/react-hooks": "^7.0.2", "@types/lodash": "^4.14.177", + "@types/react-page-visibility": "^6.4.1", "babel-plugin-formatjs": "^10.3.9", "bignumber.js": "^9.0.1", "chai": "^4.3.4", diff --git a/src/App.scss b/src/App.scss index d1ecdac9..8c1d4935 100644 --- a/src/App.scss +++ b/src/App.scss @@ -16,28 +16,125 @@ color: $red1; } +.trade-modal-component-wrapper { + position: relative; + display: flex; + flex-direction: column; + align-content: space-between; + padding: 0; + background: linear-gradient(180deg, #1c2527 0%, #14161a 80.73%, #121316 100%); + + .modal-component-heading { + display: flex; + justify-content: center; + width: 100%; + + color: $l-gray3; + text-transform: capitalize; + align-items: flex-start; + + padding: 24px 0; + + &__main-text { + font-size: 16px; + font-weight: 500; + width: fit-content; + } + } + + .close-modal-btn { + position: absolute; + left: 16px; + top: 20px; + display: flex; + align-items: center; + justify-content: center; + } + + .close-modal-btn:hover { + cursor: pointer; + + svg { + path { + fill: $orange1; + } + } + } +} + .modal-component-wrapper { position: relative; display: flex; flex-direction: column; align-content: space-between; padding-bottom: 8px; - gap: 16px; + gap: 24px; + + padding: 30px; + border: 1px solid #29292d; + + background: #211f24; + box-shadow: 0px 35px 71px -47px rgba(82, 255, 177, 0.37); + border-radius: 16px; - background-color: $d-gray6; overflow: hidden; + &::before { + content: ' '; + width: 100%; + height: 100%; + top: 0; + left: 0; + position: fixed; + backdrop-filter: blur(3px); + background: radial-gradient( + 70.22% 56.77% at 51.87% 101.05%, + rgba(79, 255, 176, 0.24) 0%, + rgba(79, 255, 176, 0) 100% + ), + rgba(7, 8, 14, 0.7); + z-index: -1; + } + .modal-component-heading { display: flex; justify-content: space-between; width: 100%; + padding-top: 4px; + color: $l-gray3; + text-transform: capitalize; + align-items: center; - background-color: $d-gray4; - font-size: 14px; - padding: 14px; - font-weight: 600; - text-transform: uppercase; - color: $green1; + &__main-text { + font-size: 22px; + font-weight: 500; + background: linear-gradient( + 90deg, + #4fffb0 1.27%, + #b3ff8f 48.96%, + #ff984e 104.14% + ), + linear-gradient(90deg, #4fffb0 1.27%, #a2ff76 53.24%, #ff984e 104.14%), + linear-gradient(90deg, #ffce4f 1.27%, #4fffb0 104.14%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; + width: fit-content; + + &__secondary { + font-size: 16px; + color: #d1dee8; + text-transform: none; + background: none; + + padding: 8px 0; + + -webkit-background-clip: none; + -webkit-text-fill-color: #d1dee8; + background-clip: none; + } + } } .close-modal-btn { @@ -59,13 +156,13 @@ .modal-component-content { display: flex; flex-direction: column; - gap: 8px; + gap: 16px; flex-grow: 1; + color: white; overflow-y: scroll; - margin: 0 8px; - padding: 0 8px; + padding: 0 16px 16px 0; &::-webkit-scrollbar { width: 6px; @@ -74,6 +171,7 @@ /* Track */ &::-webkit-scrollbar-track { background-color: $gray3; + margin-bottom: 30px; } /* Handle */ @@ -91,4 +189,4 @@ letter-spacing: 0.5px; line-height: 1.2em; -} \ No newline at end of file +} diff --git a/src/compiled-lang/en.json b/src/compiled-lang/en.json index 0babdb95..c92a76e1 100644 --- a/src/compiled-lang/en.json +++ b/src/compiled-lang/en.json @@ -9,9 +9,9 @@ "Wallet.InstallInstructions": "To connect your account, please {link} ", "Wallet.InstallLinkText": "install or enable", "Wallet.Loading": "Loading...", - "Wallet.NoAccountsAvailable": "You have no accounts available", + "Wallet.NoAccountsAvailable": "You have no accounts available. ", "Wallet.ReloadInstructions": "the polkadot.js extension. Once you're done with the installation, you can {link}", "Wallet.ReloadLinkText": "reload the page", - "Wallet.SelectAccount": "Select an account", - "Wallet.SelectAccountHelp": "Do you need help creating an account?" + "Wallet.SelectAccount": "Select account", + "Wallet.SelectAccountHelp": "Do you need help creating an account? {link}" } diff --git a/src/components/Balance/AssetBalanceInput/AssetBalanceInput.scss b/src/components/Balance/AssetBalanceInput/AssetBalanceInput.scss index c9dd3bc7..92961a6e 100644 --- a/src/components/Balance/AssetBalanceInput/AssetBalanceInput.scss +++ b/src/components/Balance/AssetBalanceInput/AssetBalanceInput.scss @@ -6,21 +6,22 @@ position: relative; align-items: center; - height: 60px; + width: 100%; - background-color: $d-gray2; border-radius: $border-radius; .balance-input { height: 20px; border-radius: 0; + + input { + background: transparent; + } } &__asset-info { height: 100%; - padding: 16px 12px; - display: flex; align-items: center; gap: 8px; @@ -76,13 +77,16 @@ } } } - - box-shadow: 1px 0 $d-gray4; } + box-shadow: 1px 0 $d-gray4; + &__input-wrapper { flex-grow: 1; padding: 16px 12px; + background-color: rgba(218, 255, 238, 0.06); + border-radius: $border-radius; + box-shadow: 0 0 0 1px rgba(255, 255, 238, 0.3); &__unit-selector { display: flex; diff --git a/src/components/Balance/AssetBalanceInput/AssetBalanceInput.tsx b/src/components/Balance/AssetBalanceInput/AssetBalanceInput.tsx index 3f0ad6c0..5cfefda6 100644 --- a/src/components/Balance/AssetBalanceInput/AssetBalanceInput.tsx +++ b/src/components/Balance/AssetBalanceInput/AssetBalanceInput.tsx @@ -1,6 +1,6 @@ import classNames from 'classnames'; -import { MutableRefObject, useCallback } from 'react'; -import { Asset } from '../../../generated/graphql'; +import { MutableRefObject, useCallback, useEffect } from 'react'; +import { Asset, Maybe } from '../../../generated/graphql'; import { BalanceInput, BalanceInputProps } from '../BalanceInput/BalanceInput'; import { useModalPortal } from './hooks/useModalPortal'; import { useModalPortalElement } from './hooks/useModalPortalElement'; @@ -24,7 +24,9 @@ export interface AssetBalanceInputProps { isAssetSelectable?: boolean; // onAssetSelected: (asset: Asset) => void, balanceInputRef?: MutableRefObject; - required?: boolean + required?: boolean; + disabled?: boolean; + maxBalanceLoading?: boolean, } export const AssetBalanceInput = ({ @@ -38,6 +40,8 @@ export const AssetBalanceInput = ({ // onAssetSelected, balanceInputRef, required, + disabled, + maxBalanceLoading, }: AssetBalanceInputProps) => { const modalPortalElement = useModalPortalElement({ assets, @@ -58,44 +62,64 @@ export const AssetBalanceInput = ({ const methods = useFormContext(); return ( -
+
{/* This portal will be rendered at it's container ref as defined above */} {modalPortal} -
handleAssetSelectorClick()} - data-modal-portal-toggle={toggleId} - > -
-
-
- {idToAsset(methods.getValues(assetInputName))?.fullName || - 'Select asset'} -
-
- {idToAsset(methods.getValues(assetInputName))?.symbol || - methods.getValues(assetInputName) || - '---'} - + {isAssetSelectable + ? ( +
handleAssetSelectorClick()} + data-modal-portal-toggle={toggleId} + > +
+
+
+ {isAssetSelectable + ? (idToAsset(methods.getValues(assetInputName))?.fullName || + 'Select asset') + : 'No asset' + } +
+
+ {idToAsset(methods.getValues(assetInputName))?.symbol || + methods.getValues(assetInputName) || + '---'} + {isAssetSelectable && } +
+
-
-
+ ) + : <> + }
-
- -
- {idToAsset(methods.getValues(assetInputName))?.symbol || `${horizontalBar}`} -
-
+
+
+ +
+ {idToAsset(methods.getValues(assetInputName))?.symbol || + `${horizontalBar}`} +
+
+
diff --git a/src/components/Balance/AssetBalanceInput/AssetSelector/AssetItem/AssetItem.tsx b/src/components/Balance/AssetBalanceInput/AssetSelector/AssetItem/AssetItem.tsx index 784d28ed..bca6941a 100644 --- a/src/components/Balance/AssetBalanceInput/AssetSelector/AssetItem/AssetItem.tsx +++ b/src/components/Balance/AssetBalanceInput/AssetSelector/AssetItem/AssetItem.tsx @@ -1,6 +1,8 @@ import { Asset } from '../../../../../generated/graphql'; import classNames from 'classnames'; import { idToAsset } from '../../../../../pages/TradePage/TradePage'; +import { horizontalBar } from '../../../../Chart/ChartHeader/ChartHeader'; +import Unknown from '../../../../../misc/icons/assets/Unknown.svg'; export interface AssetItemProps { asset: Asset; onClick: () => void; @@ -21,12 +23,12 @@ export const AssetItem = ({ asset, onClick, active }: AssetItemProps) => (
- {idToAsset(asset.id)?.fullName || ''} + {idToAsset(asset.id)?.fullName}
{idToAsset(asset.id)?.symbol} diff --git a/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.scss b/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.scss index 376e4bc6..436ffbbc 100644 --- a/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.scss +++ b/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.scss @@ -18,8 +18,6 @@ height: 100%; &__asset-item { - height: 50px; - border: 1px solid transparent; border-radius: $border-radius; @@ -39,7 +37,7 @@ } cursor: pointer; - padding: 0.5em 0; + padding: 16px; &:hover { color: $green1; diff --git a/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.tsx b/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.tsx index 9b0a2cf7..07b9bd00 100644 --- a/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.tsx +++ b/src/components/Balance/AssetBalanceInput/AssetSelector/AssetSelector.tsx @@ -31,28 +31,25 @@ export const AssetSelector = ({
-
Select an asset
{' '} +
Select asset
{' '}
- {assets?.length - ? ( - assets?.map((asset, i) => ( - onAssetSelected(asset)} - active={asset.id === activeAsset?.id} - asset={asset} - /> - )) - ) - : ( -

No other assets available

- ) - } + {assets?.length ? ( + assets?.map((asset, i) => ( + onAssetSelected(asset)} + active={asset.id === activeAsset?.id} + asset={asset} + /> + )) + ) : ( +

No other assets available

+ )}
diff --git a/src/components/Balance/AssetBalanceInput/hooks/useModalPortal.tsx b/src/components/Balance/AssetBalanceInput/hooks/useModalPortal.tsx index 5d824b8a..c7c8537f 100644 --- a/src/components/Balance/AssetBalanceInput/hooks/useModalPortal.tsx +++ b/src/components/Balance/AssetBalanceInput/hooks/useModalPortal.tsx @@ -2,35 +2,80 @@ import { MutableRefObject, ReactNode, ReactPortal, useCallback, useEffect, useMe import { createPortal } from 'react-dom'; import { useOnClickOutside } from 'use-hooks'; import { v4 as uuidv4 } from 'uuid'; -export interface ModalPortalElementFactoryArgs { +export interface ModalPortalElementFactoryArgs { openModal: () => void, closeModal: () => void, toggleModal: () => void, + resolve: (value?: any) => void, + reject: (value?: any) => void, + cancel: (value?: any) => void, elementRef: MutableRefObject, isModalOpen: boolean, + state?: T } -export type ModalPortalElementFactory = (args: ModalPortalElementFactoryArgs) => ReactNode; +export type ModalPortalElementFactory = (args: ModalPortalElementFactoryArgs) => ReactNode; -export const useModalPortal = ( - elementFactory: ModalPortalElementFactory, +export const useModalPortal = ( + elementFactory: ModalPortalElementFactory, container: MutableRefObject, closeOnClickOutside: boolean = true, ) => { const [modalPortal, setModalPortal] = useState(); const [isModalOpen, setIsModalOpen] = useState(false); + const [status, setStatus] = useState<'pending' | 'success' | 'failure' | 'cancelled'>('pending'); + const resolve = useCallback(() => { + setStatus('success'); + setIsModalOpen(false); + }, []); + const reject = useCallback(() => { + setStatus('failure'); + setIsModalOpen(false); + }, []); + const cancel = useCallback(() => { + setStatus('cancelled'); + setIsModalOpen(false); + }, []); + + // old components might still use toggle/open/close API const toggleModal = useCallback(() => setIsModalOpen(isModalOpen => !isModalOpen), [setIsModalOpen]); - const openModal = useCallback(() => setIsModalOpen(true), [setIsModalOpen]); - const closeModal = useCallback(() => setIsModalOpen(false), [setIsModalOpen]); + + const closeModal = useCallback(() => { + setIsModalOpen(false); + setStatus('cancelled'); + }, []); + + const [state, setState] = useState(); + + const openModal = useCallback((state?: any) => { + state && setState(state); + setStatus('pending'); + + setIsModalOpen(true) + }, [setIsModalOpen, setState]); const elementRef = useRef(null); const toggleId = useMemo(() => uuidv4(), []); const element = useMemo(() => { - return elementFactory({ toggleModal, openModal, closeModal, elementRef, isModalOpen }) - }, [elementFactory, toggleModal, openModal, closeModal, isModalOpen, elementRef]); +// <<<<<<< HEAD +// return elementFactory({ +// toggleModal, +// openModal, +// closeModal, +// elementRef, +// isModalOpen, +// resolve, +// reject, +// cancel +// }) +// }, [elementFactory, toggleModal, openModal, closeModal, isModalOpen, elementRef, resolve, reject, cancel]); +// ======= + return elementFactory({ toggleModal, openModal, closeModal, elementRef, isModalOpen, state, resolve, reject, cancel }) + }, [elementFactory, toggleModal, openModal, closeModal, isModalOpen, elementRef, state]); +// >>>>>>> 28bc535d48f7808dcf3e723f0952d438799a3209 useEffect(() => { if (!container.current || !element) return; @@ -50,6 +95,7 @@ export const useModalPortal = ( closeModal, isModalOpen, toggleId, + status, modalPortal: modalPortal }; } \ No newline at end of file diff --git a/src/components/Balance/AssetBalanceInput/hooks/useModalPortalElement.tsx b/src/components/Balance/AssetBalanceInput/hooks/useModalPortalElement.tsx index dda0cd71..ec088ec1 100644 --- a/src/components/Balance/AssetBalanceInput/hooks/useModalPortalElement.tsx +++ b/src/components/Balance/AssetBalanceInput/hooks/useModalPortalElement.tsx @@ -16,7 +16,7 @@ export type ModalPortalElement = ({ AssetBalanceInputProps, 'assets' | 'defaultAsset' | 'assetInputName' >) => ModalPortalElementFactory; -export type CloseModal = ModalPortalElementFactoryArgs['closeModal']; +export type CloseModal = ModalPortalElementFactoryArgs['closeModal']; export const useModalPortalElement: ModalPortalElement = ({ assets, diff --git a/src/components/Balance/BalanceInput/BalanceInput.scss b/src/components/Balance/BalanceInput/BalanceInput.scss index 1e8130bd..f788ea2b 100644 --- a/src/components/Balance/BalanceInput/BalanceInput.scss +++ b/src/components/Balance/BalanceInput/BalanceInput.scss @@ -22,6 +22,7 @@ position: absolute; font-size: 1em; line-height: 1em; + font-weight: 500; padding: 0; text-align: right; diff --git a/src/components/Balance/BalanceInput/BalanceInput.tsx b/src/components/Balance/BalanceInput/BalanceInput.tsx index 4ef42457..0c596724 100644 --- a/src/components/Balance/BalanceInput/BalanceInput.tsx +++ b/src/components/Balance/BalanceInput/BalanceInput.tsx @@ -34,7 +34,8 @@ export interface BalanceInputProps { * retrieve the actual input element from the form state */ inputRef?: MutableRefObject; - required?: boolean + required?: boolean; + disabled?: boolean } const MaskedInputWithRef = React.forwardRef( @@ -80,7 +81,8 @@ export const BalanceInput = ({ defaultUnit = MetricUnit.NONE, showMetricUnitSelector = true, inputRef, - required + required, + disabled }: BalanceInputProps) => { const { control, register, setValue, getValues, watch } = useFormContext(); const { unit, setUnit } = useDefaultUnit(defaultUnit); @@ -123,6 +125,7 @@ export const BalanceInput = ({ required={required} onChange={(e) => handleOnChange(field, e)} placeholder="0.00" + disabled={disabled} /> )} diff --git a/src/components/Balance/BalanceInput/MetricUnitSelector/MetricUnitSelector.scss b/src/components/Balance/BalanceInput/MetricUnitSelector/MetricUnitSelector.scss index fc44e571..4341379c 100644 --- a/src/components/Balance/BalanceInput/MetricUnitSelector/MetricUnitSelector.scss +++ b/src/components/Balance/BalanceInput/MetricUnitSelector/MetricUnitSelector.scss @@ -4,18 +4,19 @@ .metric-unit-selector { position: relative; - display: flex; + display: none; flex-shrink: 1; justify-content: right; font-weight: 600; letter-spacing: 0.5px; + font-size: 10px; min-width: 100px; &:hover { cursor: pointer; - + svg { path { fill: $green1; @@ -25,7 +26,7 @@ &__select { display: flex; - font-size: 0.65em; + font-size: 10px; justify-content: center; align-items: center; @@ -66,7 +67,7 @@ z-index: 1; - font-size: 0.65em; + font-size: 10px; overflow: hidden; diff --git a/src/components/Balance/FormattedBalance/FormattedBalance.scss b/src/components/Balance/FormattedBalance/FormattedBalance.scss index 3f9fe644..e00c0f9d 100644 --- a/src/components/Balance/FormattedBalance/FormattedBalance.scss +++ b/src/components/Balance/FormattedBalance/FormattedBalance.scss @@ -7,7 +7,7 @@ justify-self: end; justify-content: right; - font-weight: 600; + font-weight: 400; letter-spacing: 0.5px; line-height: 1.2em; diff --git a/src/components/Balance/FormattedBalance/FormattedBalance.tsx b/src/components/Balance/FormattedBalance/FormattedBalance.tsx index 49bab7b1..54ffcac5 100644 --- a/src/components/Balance/FormattedBalance/FormattedBalance.tsx +++ b/src/components/Balance/FormattedBalance/FormattedBalance.tsx @@ -7,6 +7,8 @@ import { useFormatSI } from './hooks/useFormatSI'; import { idToAsset } from '../../../pages/TradePage/TradePage'; import ReactTooltip from 'react-tooltip'; import { fromPrecision12 } from '../../../hooks/math/useFromPrecision'; +import { horizontalBar } from '../../Chart/ChartHeader/ChartHeader'; +import BigNumber from 'bignumber.js'; export interface FormattedBalanceProps { balance: Balance; @@ -16,44 +18,67 @@ export interface FormattedBalanceProps { export const FormattedBalance = ({ balance, - precision = 2, + precision = 3, unitStyle = UnitStyle.LONG, }: FormattedBalanceProps) => { const assetSymbol = useMemo(() => idToAsset(balance.assetId)?.symbol, [ balance.assetId, ]); - const formattedBalance = useFormatSI(precision, unitStyle, balance.balance); + // const formattedBalance = useFormatSI(precision, unitStyle, balance.balance); + let formattedBalance = fromPrecision12(balance.balance); + + const decimalPlacesCount = formattedBalance?.split('.')[1]?.length || 0; + console.log('formattedBalance', decimalPlacesCount, formattedBalance ) + + if (formattedBalance && new BigNumber(formattedBalance).gte(1)) { + formattedBalance = new BigNumber(formattedBalance).toFixed( + decimalPlacesCount > 4 ? 4 : decimalPlacesCount + ); + } else if (formattedBalance) { + formattedBalance = new BigNumber(formattedBalance).toFixed( + decimalPlacesCount <= 4 ? 4 : decimalPlacesCount + ); + } + const tooltipText = useMemo(() => { // TODO: get rid of raw html return ` ${fromPrecision12(balance.balance)} ${assetSymbol} - ` + `; }, [balance, assetSymbol]); useEffect(() => { ReactTooltip.rebuild(); }, [tooltipText]); - log.debug( - 'FormattedBalance', - formattedBalance?.value, - formattedBalance?.unit, - formattedBalance?.numberOfDecimalPlaces - ); + // log.debug( + // 'FormattedBalance', + // formattedBalance?.value, + // formattedBalance?.unit, + // formattedBalance?.numberOfDecimalPlaces + // ); // We don't need to use the currency input here // because when there is more than 3 significant digits, the formatter // moves one notch up/down and keeps a fixed precision return ( // WARNING POSSIBLY UNSAFE?? -
-
{formattedBalance.value}
-
+
+ {/*
{formattedBalance.value}
*/} +
{formattedBalance}
+ {/*
{formattedBalance.suffix} +
*/} +
+ {assetSymbol || horizontalBar}
-
{assetSymbol}
); }; diff --git a/src/components/Button/Button.scss b/src/components/Button/Button.scss index c486a1a7..e4d5096c 100644 --- a/src/components/Button/Button.scss +++ b/src/components/Button/Button.scss @@ -13,20 +13,47 @@ width: 100%; &--primary { - padding: 0.875rem; - background: $green1; - text-transform: uppercase; + height: 40px; + user-select: none; + border-radius: 9999px; + width: fit-content; + background-color: #4fffb0; + color: #26282f; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border: none; + &:hover { - background: darken($color: $green1, $amount: 5); + background-color: #41db96; + } + + .label { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 8px 16px; + font-size: 16px; + line-height: 16px; + font-weight: 600; } } &--secondary { - padding: 0.3125rem; - color: $white1; - background: $gray3; + height: 40px; + user-select: none; + border-radius: 9999px; + width: fit-content; + padding: 8px 16px; + color: $gray4; + background: none; + display: flex; + justify-content: center; + &:hover { - background: darken($color: $green1, $amount: 5); + color: $green1; } } diff --git a/src/components/Chart/ChartHeader/ChartHeader.scss b/src/components/Chart/ChartHeader/ChartHeader.scss index fef53765..25d80f62 100644 --- a/src/components/Chart/ChartHeader/ChartHeader.scss +++ b/src/components/Chart/ChartHeader/ChartHeader.scss @@ -59,7 +59,8 @@ $disabledOpacity: 0.3; &__pool-info { display: flex; - justify-content: space-between; + justify-content: start; + padding-top: 36px; &__assets { display: flex; diff --git a/src/components/Confirmation/Confirmation.scss b/src/components/Confirmation/Confirmation.scss new file mode 100644 index 00000000..aeeb0ff9 --- /dev/null +++ b/src/components/Confirmation/Confirmation.scss @@ -0,0 +1,25 @@ +.confirmation-screen { + position: fixed; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + + background: rgba(50, 50, 50, 0.5); + + z-index: 3; + + .modal-component-wrapper { + width: 400px; + min-height: 300px; + max-height: 500px; + } + + .buttons { + display: flex; + justify-content: space-between; + } +} diff --git a/src/components/Confirmation/Confirmation.tsx b/src/components/Confirmation/Confirmation.tsx new file mode 100644 index 00000000..0133d120 --- /dev/null +++ b/src/components/Confirmation/Confirmation.tsx @@ -0,0 +1,50 @@ +import { ConfirmationType } from '../../hooks/actionLog/useWithConfirmation'; +import { SubmitTradeMutationVariables } from '../../hooks/pools/mutations/useSubmitTradeMutation'; +import { ModalPortalElementFactoryArgs } from '../Balance/AssetBalanceInput/hooks/useModalPortal'; +import './Confirmation.scss'; + +export const Confirmation = ({ + isModalOpen, + options, + resolve, + reject, + confirmationType, +}: ModalPortalElementFactoryArgs & { + options?: SubmitTradeMutationVariables; // or any other type that might be handled through confirmations + confirmationType: ConfirmationType; +}) => { + + console.log('options', options); + + return isModalOpen ? ( +
+
+
+
+ Confirm transaction +
+
+
+

Trade type: {options?.tradeType}

+

+ Assets: {options?.assetInId} / {options?.assetOutId} +

+

+ Amounts: {options?.assetInAmount} / {options?.assetOutAmount} +

+

Limit: {options?.amountWithSlippage}

+
+
+ + +
+
+
+ ) : ( + <> + ); +}; diff --git a/src/components/Icon/Icon.tsx b/src/components/Icon/Icon.tsx index bf0f3291..36c26e5a 100644 --- a/src/components/Icon/Icon.tsx +++ b/src/components/Icon/Icon.tsx @@ -4,6 +4,7 @@ import { ReactComponent as NotificationActiveIcon } from './assets/NotificationA import { ReactComponent as NotificationInactiveIcon } from './assets/NotificationInactiveIcon.svg'; import { ReactComponent as DropdownArrowIcon } from './assets/DropdownArrowIcon.svg'; import { ReactComponent as CancelIcon } from './assets/Cancel.svg'; +import { ReactComponent as BackIcon } from './assets/Back.svg'; import { ReactComponent as BasiliskLogoFull } from './assets/BasiliskLogoFull.svg'; import { ReactComponent as AssetSwitchIcon } from './assets/AssetSwitchIcon.svg'; import { ReactComponent as SettingsIcon } from './assets/Settings.svg'; @@ -15,6 +16,7 @@ const Icons = { NotificationInactive: () => , DropdownArrow: () => , Cancel: () => , + Back: () => , BasiliskLogoFull: () => , AssetSwitch: () => , Settings: () => , diff --git a/src/components/Icon/assets/AssetSwitchIcon.svg b/src/components/Icon/assets/AssetSwitchIcon.svg index 06067778..95e1c092 100644 --- a/src/components/Icon/assets/AssetSwitchIcon.svg +++ b/src/components/Icon/assets/AssetSwitchIcon.svg @@ -1,16 +1,20 @@ diff --git a/src/components/Icon/assets/Back.svg b/src/components/Icon/assets/Back.svg new file mode 100644 index 00000000..10eb8514 --- /dev/null +++ b/src/components/Icon/assets/Back.svg @@ -0,0 +1,19 @@ + + + + diff --git a/src/components/Navigation/ActionBar.tsx b/src/components/Navigation/ActionBar.tsx index e994b23a..ee4b3e1e 100644 --- a/src/components/Navigation/ActionBar.tsx +++ b/src/components/Navigation/ActionBar.tsx @@ -2,13 +2,13 @@ import './ActionBar.scss'; import { Link } from 'react-router-dom'; export interface ActionBarProps { - isExtensionAvailable: boolean; + isExtensionAvailable: boolean; extensionLoading: boolean; activeAccountLoading: boolean; accountData?: { - name?: string; - address?: string; - nativeAssetBalance?: string; + name?: string; + address?: string; + nativeAssetBalance?: string; }; } @@ -20,34 +20,41 @@ export const ActionBar = ({ }: ActionBarProps) => { return (
-
+
?
!
{extensionLoading || activeAccountLoading ? ( -
loading...
+
loading...
) : isExtensionAvailable ? ( <> {accountData?.name ? ( -
-
- {accountData?.nativeAssetBalance} BSX -
- {/* TODO! Acc name / address + Icon component*/} -
- {accountData?.name} -
+
+
+ {accountData?.nativeAssetBalance} BSX
+ {/* TODO! Acc name / address + Icon component*/} +
+ {accountData?.name} +
+
) : ( - select an account + + select account + )} ) : ( -
Extension unavailable
+
+ Extension unavailable +
)}
-
v
+
v
); }; diff --git a/src/components/Pools/PoolsForm.scss b/src/components/Pools/PoolsForm.scss new file mode 100644 index 00000000..a73951e5 --- /dev/null +++ b/src/components/Pools/PoolsForm.scss @@ -0,0 +1,327 @@ +@import './../../misc/colors.module.scss'; +@import './../../misc/misc.module.scss'; +@import '../Button/Button.scss'; + +.pools-form-wrapper { + position: relative; + flex-basis: 350px; + flex-grow: 1; + + padding: 22px; + min-width: 350px; + max-width: 610px; + margin: 0 auto; + + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05); + background: linear-gradient(180deg, #1c2527 0%, #14161a 80.73%, #121316 100%); + overflow: hidden; + border-radius: 10px; + + position: relative; + + color: white; + + .settings-button-wrapper { + position: absolute; + display: flex; + flex-direction: row; + justify-content: left; + right: 10px; + top: 10px; + gap: 10px; + padding: 10px; + + .pool-settings-button { + display: flex; + padding: 10px 8px; + width: fit-content; + height: fit-content; + border-radius: 50%; + background-color: rgba(162, 176, 187, 0.1); + + svg { + width: 24px; + } + + &:hover { + cursor: pointer; + + svg { + path { + fill: $green1; + } + } + } + } + + .pool-page-tabs { + display: flex; + flex-direction: row; + justify-content: right; + width: 90%; + + .tab { + @extend .button--primary; + width: 100px; + border-radius: $border-radius 0px 0px $border-radius; + color: $gray4; + background-color: rgba(162, 176, 187, 0.1); + + &:hover { + color: rgba(79, 255, 176, 1); + background-color: rgba(162, 176, 187, 0.15); + } + + &:disabled { + color: rgba(79, 255, 176, 1); + background-color: rgba(162, 176, 187, 0.2); + } + + &:first-child { + border-radius: $border-radius 0px 0px $border-radius; + } + + &:last-child { + border-radius: 0px $border-radius $border-radius 0px; + } + + &:not(:last-child) { + border-right: 1px solid rgba(162, 176, 187, 0.1); + } + } + } + } + + .pools-form { + height: 100%; + min-height: 400px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 14px; + + .pools-form-heading { + width: fit-content; + padding-top: 4px; + color: $l-gray3; + font-size: 22px; + font-weight: 500; + background: linear-gradient( + 90deg, + #4fffb0 1.27%, + #b3ff8f 48.96%, + #ff984e 104.14% + ), + linear-gradient(90deg, #4fffb0 1.27%, #a2ff76 53.24%, #ff984e 104.14%), + linear-gradient(90deg, #ffce4f 1.27%, #4fffb0 104.14%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; + } + + .divider-wrapper { + display: flex; + align-items: center; + height: 1px; + width: 100%; + } + + .divider { + position: absolute; + width: 100%; + height: 1px; + background-color: rgba(76, 243, 168, 0.12); + opacity: 1; + border: 0; + left: 0; + } + + .balance-wrapper { + display: flex; + flex-direction: column-reverse; + align-items: end; + background: rgba(162, 176, 187, 0.1); + padding: 12px; + padding-top: 24px; + border-radius: 10px; + gap: 6px; + padding-bottom: 16px; + } + + .balance-wrapper-share-tokens { + @extend .balance-wrapper; + margin-top: 8px; + margin-bottom: 8px; + } + + .submit-button { + background: $green1; + text-transform: uppercase; + border-radius: 36px; + height: 50px; + + color: $d-gray4; + + &:hover { + background-color: $green2; + } + + &:disabled { + background-color: $l-gray5; + } + } + } +} + +// SHOULD BE EXTRACTED TO COMPONENTS + +.balance-info { + display: flex; + align-items: center; + justify-content: right; + width: 100%; + gap: 4px; + + height: 16px; + margin-top: 4px; + font-size: 12px; + line-height: 12px; + position: relative; + + .balance-info-type { + position: absolute; + left: 0; + top: -7px; + font-weight: 600; + font-size: 16px; + color: $green1; + padding: 6px; + } +} + +.asset-switch { + display: flex; + height: 43px; + justify-content: space-between; + align-items: center; + width: 100%; + + .asset-switch-icon { + position: absolute; + left: 24px; + + display: flex; + align-items: center; + justify-content: center; + + overflow: hidden; + background: #192022; + border-radius: 50%; + + transition: transform 500ms ease; + + &:hover { + cursor: pointer; + + transform: rotate(180deg); + + svg { + path { + fill: $green1; + } + } + } + } + + .asset-switch-price { + position: absolute; + right: 24px; + background: #192022; + + &__wrapper { + display: flex; + align-items: center; + gap: 4px; + + padding: 4px 14px; + font-size: 11px; + font-weight: 500; + + background: rgba(218, 255, 238, 0.06); + border-radius: 7px; + } + } +} + +.trade-settings-wrapper { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + + z-index: 1; + + .trade-settings { + height: 100%; + } + + .settings-section { + padding: 12px 24px; + background: linear-gradient(0deg, #171518, #171518), #1c1a1f; + } + + .settings-field { + padding: 12px 24px; + display: flex; + justify-content: space-between; + align-items: center; + + &__label { + flex-grow: 10; + } + + input { + flex-shrink: 10; + flex-basis: 50px; + width: 50px; + text-align: center; + + border-radius: $border-radius; + } + } + + &.hidden { + display: none; + } +} + +.debug-box { + position: fixed; + padding: 16px; + right: 0; + top: 0; + + height: 100%; + + overflow-y: scroll; + + background-color: rgba(0, 0, 0, 0.8); +} + +.max-button { + font-size: 12px; + font-weight: 400; + color: $white1; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + text-transform: capitalize; + cursor: pointer; + + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } +} diff --git a/src/components/Pools/PoolsForm.tsx b/src/components/Pools/PoolsForm.tsx new file mode 100644 index 00000000..60a12326 --- /dev/null +++ b/src/components/Pools/PoolsForm.tsx @@ -0,0 +1,1258 @@ +import BigNumber from 'bignumber.js'; +import classNames from 'classnames'; +import { every, find, times } from 'lodash'; +import { + MutableRefObject, + useCallback, + useDebugValue, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { Control, FormProvider, useForm } from 'react-hook-form'; +import { Account, Balance, Maybe, Pool } from '../../generated/graphql'; +import { fromPrecision12 } from '../../hooks/math/useFromPrecision'; +import { useMath } from '../../hooks/math/useMath'; +import { percentageChange } from '../../hooks/math/usePercentageChange'; +import { toPrecision12 } from '../../hooks/math/useToPrecision'; +import { SubmitTradeMutationVariables } from '../../hooks/pools/mutations/useSubmitTradeMutation'; +import { idToAsset, TradeAssetIds } from '../../pages/TradePage/TradePage'; +import { AssetBalanceInput } from '../Balance/AssetBalanceInput/AssetBalanceInput'; +import { PoolType } from '../Chart/shared'; +import { PoolsInfo } from './PoolsInfo/PoolsInfo'; +import './PoolsForm.scss'; +import Icon from '../Icon/Icon'; +import { useModalPortal } from '../Balance/AssetBalanceInput/hooks/useModalPortal'; +import { FormattedBalance } from '../Balance/FormattedBalance/FormattedBalance'; +import { useDebugBoxContext } from '../../pages/TradePage/hooks/useDebugBox'; +import { horizontalBar } from '../Chart/ChartHeader/ChartHeader'; +import { usePolkadotJsContext } from '../../hooks/polkadotJs/usePolkadotJs'; +import { useApolloClient } from '@apollo/client'; +import { estimateBuy } from '../../hooks/pools/xyk/buy'; +import { estimateSell } from '../../hooks/pools/xyk/sell'; +import { payment } from '@polkadot/types/interfaces/definitions'; +import { useMultiFeePaymentConversionContext } from '../../containers/MultiProvider'; + +export interface PoolsFormSettingsProps { + allowedSlippage: string | null; + onAllowedSlippageChange: (allowedSlippage: string | null) => void; + closeModal: any; +} + +export enum ProvisioningType { + Add, + Remove, +} + +export interface PoolsFormSettingsFormFields { + allowedSlippage: string | null; + autoSlippage: boolean; +} + +export const PoolsFormSettings = ({ + allowedSlippage, + onAllowedSlippageChange, + closeModal, +}: PoolsFormSettingsProps) => { + const { register, watch, getValues, setValue, handleSubmit } = + useForm({ + defaultValues: { + allowedSlippage, + autoSlippage: true, + }, + }); + + // propagate allowed slippage to the parent + useEffect(() => { + onAllowedSlippageChange(getValues('allowedSlippage')); + }, watch(['allowedSlippage'])); + + // if you want automatic slippage, override the previous user's input + useEffect(() => { + if (getValues('autoSlippage')) { + // default is 3% + setValue('allowedSlippage', '3'); + } + }, watch(['autoSlippage'])); + + return ( +
{})} + > +
+
Settings
+
+ +
+
+
+
Slippage
+ + +
+
+ ); +}; + +export const useModalPortalElement = ({ + allowedSlippage, + setAllowedSlippage, +}: any) => { + return useCallback( + ({ closeModal, elementRef, isModalOpen }) => { + return ( +
+ { + setAllowedSlippage(allowedSlippage); + }} + /> +
+ ); + }, + [allowedSlippage] + ); +}; + +export interface PoolsFormProps { + assets?: { id: string }[]; + assetIds: TradeAssetIds; + onAssetIdsChange: (assetIds: TradeAssetIds) => void; + isActiveAccountConnected?: boolean; + pool?: Pool; + assetInLiquidity?: string; + assetOutLiquidity?: string; + spotPrice?: { + outIn?: string; + inOut?: string; + }; + isPoolLoading: boolean; + onSubmit: (form: PoolsFormFields & { amountBMaxLimit?: string }) => void; + tradeLoading: boolean; + activeAccountTradeBalances?: { + outBalance?: Balance; + inBalance?: Balance; + shareBalance?: Balance; + }; + activeAccountTradeBalancesLoading: boolean; + activeAccount?: Maybe; +} + +export interface PoolsFormFields { + assetIn: string | null; + assetOut: string | null; + assetInAmount: string | null; + assetOutAmount: string | null; + shareAssetAmount: string | null; + submit: void; + warnings: any; + provisioningType: ProvisioningType; +} + +/** + * Trigger a state update each time the given input changes (via the `input` event) + * @param control + * @param field + * @returns + */ +export const useListenForInput = ( + inputRef: MutableRefObject +) => { + const [state, setState] = useState(); + + useEffect(() => { + if (!inputRef) return; + // TODO: figure out why using the 'input' broke the mask + // 'keydown' also doesnt work bcs its triggered by copy/paste, which then + // changes the trade type (which this hook is primarily) + const listener = inputRef.current?.addEventListener('keydown', () => + setState((state) => !state) + ); + + return () => + listener && inputRef.current?.removeEventListener('keydown', listener); + }, [inputRef]); + + return state; +}; + +export const PoolsForm = ({ + assetIds, + onAssetIdsChange, + isActiveAccountConnected, + pool, + isPoolLoading, + assetInLiquidity, + assetOutLiquidity, + spotPrice, + onSubmit, + tradeLoading, + assets, + activeAccountTradeBalances, + activeAccountTradeBalancesLoading, + activeAccount, +}: PoolsFormProps) => { + // TODO: include math into loading form state + const { math, loading: mathLoading } = useMath(); + const [provisioningType, setProvisioningType] = useState( + ProvisioningType.Add + ); + const [allowedSlippage, setAllowedSlippage] = useState(null); + + const form = useForm({ + reValidateMode: 'onChange', + mode: 'all', + defaultValues: { + assetIn: assetIds.assetIn, + assetOut: assetIds.assetOut, + }, + }); + const { + register, + handleSubmit, + watch, + getValues, + setValue, + trigger, + control, + formState, + } = form; + + const { isValid, isDirty, errors } = formState; + + const assetOutAmountInputRef = useRef(null); + const assetInAmountInputRef = useRef(null); + const shareAmountInputRef = useRef(null); + + // trigger form field validation right away + useEffect(() => { + trigger('submit'); + }, []); + + useEffect(() => { + // must provide input name otherwise it does not validate appropriately + trigger('submit'); + }, [ + isActiveAccountConnected, + pool, + isPoolLoading, + activeAccountTradeBalances, + assetInLiquidity, + assetOutLiquidity, + allowedSlippage, + ...watch(['assetInAmount', 'assetOutAmount']), + ]); + + // when the assetIds change, propagate the change to the parent + useEffect(() => { + const { assetIn, assetOut } = getValues(); + onAssetIdsChange({ assetIn, assetOut }); + }, watch(['assetIn', 'assetOut'])); + + const assetInAmountInput = useListenForInput(assetInAmountInputRef); + const assetOutAmountInput = useListenForInput(assetOutAmountInputRef); + const shareAssetAmountInput = useListenForInput(shareAmountInputRef); + + useEffect( + () => setValue('provisioningType', provisioningType), + [setValue, provisioningType] + ); + + const [lastAssetInteractedWith, setLastAssetInteractedWith] = useState< + string | null + >(); + + const calculateAssetIn = useCallback(() => { + setTimeout(() => { + const [assetOutAmount, shareAssetAmount, assetIn, assetOut] = getValues([ + 'assetOutAmount', + 'shareAssetAmount', + 'assetIn', + 'assetOut', + ]); + if ( + !pool || + !math || + !assetInLiquidity || + !assetOutLiquidity || + !activeAccountTradeBalances || + !assetIn || + !assetOut || + !shareAssetAmount + ) + return; + // if (provisioningType !== ProvisioningType.Add) return; + + // if (!assetOutAmount) return setValue('assetInAmount', null); + + // const amount = math.xyk.calculate_in_given_out( + // // which combination is correct? + // // assetOutLiquidity, + // // assetInLiquidity, + // assetInLiquidity, + // assetOutLiquidity, + // assetOutAmount + // ); + + if (provisioningType === ProvisioningType.Add && assetOutAmount) { + const amount = math.xyk.calculate_liquidity_in( + assetOutLiquidity, + assetInLiquidity, + assetOutAmount + ); + + console.log('calculateAssetIn2', { + assetOutLiquidity, assetInLiquidity, assetOutAmount, amount + }) + + // do nothing deliberately, because the math library returns '0' as calculated value, as oppossed to calculate_out_given_in + if (amount === '0' && assetOutAmount !== '0') return; + setValue('assetInAmount', amount || null); + } else { + const amountA = math.xyk.calculate_liquidity_out_asset_a( + assetInLiquidity, + assetOutLiquidity, + shareAssetAmount, + pool.totalLiquidity + ); + + console.log('calculateAssetIn1', { + assetOutLiquidity, assetInLiquidity, assetOutAmount, amountA + }) + + // do nothing deliberately, because the math library returns '0' as calculated value, as oppossed to calculate_out_given_in + // if (amountA === '0' && amountB !== '0') return; + setValue('assetInAmount', amountA || null); + } + }, 0); + }, [ + math, + getValues, + setValue, + pool, + assetInLiquidity, + assetOutLiquidity, + provisioningType, + activeAccountTradeBalances, + ]); + + const calculateAssetOut = useCallback(() => { + setTimeout(() => { + const [assetInAmount, shareAssetAmount, assetIn, assetOut] = getValues([ + 'assetInAmount', + 'shareAssetAmount', + 'assetIn', + 'assetOut', + ]); + + console.log('calculateAssetOut1', [assetInAmount, shareAssetAmount, assetIn, assetOut]); + + if ( + !pool || + !math || + !assetInLiquidity || + !assetOutLiquidity || + !activeAccountTradeBalances || + !assetIn || + !assetOut || + !shareAssetAmount + ) + return; + // if (provisioningType !== ProvisioningType.Remove) return; + + // if (!assetInAmount) return setValue('assetOutAmount', null); + + // const amount = math.xyk.calculate_out_given_in( + // assetInLiquidity, + // assetOutLiquidity, + // assetInAmount + // ); + // if (amount === '0' && assetInAmount !== '0') + // return setValue('assetOutAmount', null); + // setValue('assetOutAmount', amount || null); + + if (provisioningType === ProvisioningType.Add && assetInAmount) { + const amount = math.xyk.calculate_liquidity_in( + assetInLiquidity, + assetOutLiquidity, + assetInAmount + ); + + // do nothing deliberately, because the math library returns '0' as calculated value, as oppossed to calculate_out_given_in + if (amount === '0' && assetInAmount !== '0' ) return; + setValue('assetOutAmount', amount || null); + } else { + const amountB = math.xyk.calculate_liquidity_out_asset_b( + assetInLiquidity, + assetOutLiquidity, + shareAssetAmount, + pool.totalLiquidity + ); + + + // do nothing deliberately, because the math library returns '0' as calculated value, as oppossed to calculate_out_given_in + // if (amountB === '0' && assetInAmount !== '0') return; + setValue('assetOutAmount', amountB || null); + } + }, 0); + }, [ + math, + getValues, + setValue, + pool, + assetInLiquidity, + assetOutLiquidity, + provisioningType, + activeAccountTradeBalances, + ]); + + useEffect(() => { + if (lastAssetInteractedWith === assetIds.assetIn) return; + calculateAssetIn(); + }, [ + calculateAssetIn, + lastAssetInteractedWith, + assetOutAmountInput, + assetIds + ]); + + useEffect(() => { + if (lastAssetInteractedWith === assetIds.assetOut) return; + calculateAssetOut(); + }, [ + calculateAssetOut, + lastAssetInteractedWith, + assetInAmountInput, + assetIds, + ]); + + useEffect(() => { + if (provisioningType === ProvisioningType.Remove) return; + const [assetInAmount, assetOutAmount, assetIn, assetOut] = getValues([ + 'assetInAmount', + 'assetOutAmount', + 'assetIn', + 'assetOut', + ]); + if (!assetIn || !assetOut || !assetInLiquidity || !assetInAmount || !pool) return; + + const shareAmount = math?.xyk.calculate_shares(assetInLiquidity, assetInAmount, pool?.totalLiquidity); + shareAmount && setValue('shareAssetAmount', shareAmount); + // assetIn > assetOut + // ? setValue('shareAssetAmount', assetOutAmount) + // : setValue('shareAssetAmount', assetInAmount); + }, [ + ...watch(['assetInAmount', 'assetOutAmount', 'assetIn', 'assetOut']), + math, + assetInLiquidity, + provisioningType, + getValues, + pool + ]); + + useEffect(() => { + setTimeout(() => { + if (provisioningType === ProvisioningType.Add) return; + const [ + assetInAmount, + assetOutAmount, + assetIn, + assetOut, + shareAssetAmount, + ] = getValues([ + 'assetInAmount', + 'assetOutAmount', + 'assetIn', + 'assetOut', + 'shareAssetAmount', + ]); + if (!assetIn || !assetOut) return; + console.log('calc', assetIn, assetOut) + calculateAssetIn(); + calculateAssetOut(); + }, 0); + }, [shareAssetAmountInput, calculateAssetIn, calculateAssetOut, provisioningType]); + + const getSubmitText = useCallback(() => { + if (isPoolLoading) return 'loading'; + + // TODO: change to 'input amounts'? + // if (!isDirty) return 'Swap'; + + switch (errors.submit?.type) { + case 'activeAccount': + return 'Select account'; + case 'poolDoesNotExist': + return 'Select tokens'; + } + + if (errors.assetInAmount || errors.assetOutAmount) return 'invalid amount'; + + return provisioningType === ProvisioningType.Add + ? 'Add Liquidity' + : 'Remove Liquidity'; + }, [isPoolLoading, errors, isDirty, provisioningType]); + + const modalContainerRef = useRef(null); + + const modalPortalElement = useModalPortalElement({ + allowedSlippage, + setAllowedSlippage, + }); + const { toggleModal, modalPortal, toggleId } = useModalPortal( + modalPortalElement, + modalContainerRef, + false + ); + + const tradeLimit = useMemo(() => { + // convert from precision, otherwise the math doesnt work + const assetInAmount = fromPrecision12( + getValues('assetInAmount') || undefined + ); + const assetOutAmount = fromPrecision12( + getValues('assetOutAmount') || undefined + ); + const assetIn = getValues('assetIn'); + const assetOut = getValues('assetOut'); + + if ( + !assetInAmount || + !assetOutAmount || + !spotPrice?.inOut || + !spotPrice?.outIn || + !assetIn || + !assetOut || + !allowedSlippage + ) + return; + + console.log('limit', { + assetInAmount, + spotPrice, + allowedSlippage + }) + + switch (lastAssetInteractedWith) { + case assetIds.assetIn: + return { + balance: new BigNumber(assetInAmount) + .multipliedBy(spotPrice?.inOut) + .multipliedBy(new BigNumber('1').plus(allowedSlippage)) + .toFixed(0), + assetId: assetOut, + }; + case assetIds.assetOut: + return { + balance: new BigNumber(assetOutAmount) + .multipliedBy(spotPrice?.outIn) + .multipliedBy(new BigNumber('1').plus(allowedSlippage)) + .toFixed(0), + assetId: assetIn, + }; + } + }, [ + spotPrice, + provisioningType, + allowedSlippage, + getValues, + assetIds, + lastAssetInteractedWith, + ...watch(['assetInAmount', 'assetOutAmount']), + ]); + + const slippage = useMemo(() => { + const assetInAmount = getValues('assetInAmount'); + const assetOutAmount = getValues('assetOutAmount'); + + if (!assetInAmount || !assetOutAmount || !spotPrice || !allowedSlippage) + return; + + switch (provisioningType) { + case ProvisioningType.Remove: + return percentageChange( + new BigNumber(assetInAmount).multipliedBy( + fromPrecision12(spotPrice.inOut) || '1' + ), + assetOutAmount + )?.abs(); + case ProvisioningType.Add: + return percentageChange( + new BigNumber(assetOutAmount).multipliedBy( + fromPrecision12(spotPrice.outIn) || '1' + ), + assetInAmount + )?.abs(); + } + }, [ + provisioningType, + getValues, + spotPrice, + ...watch(['assetInAmount', 'assetOutAmount']), + ]); + + useEffect(() => { + setLastAssetInteractedWith(assetIds.assetIn); + }, [assetInAmountInput, assetIds.assetIn]); + + useEffect(() => { + setLastAssetInteractedWith(assetIds.assetOut); + }, [assetOutAmountInput, assetIds.assetOut]); + + // handle submit of the form + const _handleSubmit = useCallback( + (data: PoolsFormFields) => { + if (!lastAssetInteractedWith) return; + onSubmit({ + ...data, + assetIn: lastAssetInteractedWith, + assetOut: + lastAssetInteractedWith === data.assetOut + ? data.assetIn + : data.assetOut, + assetInAmount: + lastAssetInteractedWith === data.assetOut + ? data.assetOutAmount + : data.assetInAmount, + assetOutAmount: + lastAssetInteractedWith === data.assetOut + ? data.assetInAmount + : data.assetOutAmount, + amountBMaxLimit: tradeLimit?.balance, + }); + }, + [ + provisioningType, + tradeLimit, + lastAssetInteractedWith, + assetIds, + tradeLimit, + ] + ); + + const handleSwitchAssets = useCallback( + (event: any) => { + onAssetIdsChange({ + assetIn: assetIds.assetOut, + assetOut: assetIds.assetIn, + }); + + // prevent form submit + event.preventDefault(); + if (lastAssetInteractedWith === assetIds.assetOut) { + setLastAssetInteractedWith(assetIds.assetIn) + const assetOutAmount = getValues('assetOutAmount'); + setValue('assetInAmount', assetOutAmount); + } else { + setLastAssetInteractedWith(assetIds.assetOut); + const assetInAmount = getValues('assetInAmount'); + setValue('assetOutAmount', assetInAmount); + } + + setTimeout(() => { + + }, 0); + }, + [assetIds, setValue, getValues, lastAssetInteractedWith] + ); + + const { apiInstance } = usePolkadotJsContext(); + const { cache } = useApolloClient(); + const [paymentInfo, setPaymentInfo] = useState(); + const { convertToFeePaymentAsset } = useMultiFeePaymentConversionContext(); + const calculatePaymentInfo = useCallback(async () => { + if (!apiInstance) return; + let [assetIn, assetOut, assetInAmount, assetOutAmount] = getValues([ + 'assetIn', + 'assetOut', + 'assetInAmount', + 'assetOutAmount', + ]); + + if ( + !assetIn || + !assetOut || + !assetInAmount || + !assetOutAmount || + !tradeLimit + ) + return; + + switch (provisioningType) { + case ProvisioningType.Add: { + const estimate = await estimateBuy( + cache, + apiInstance, + assetOut, + assetIn, + assetOutAmount, + tradeLimit.balance + ); + const partialFee = estimate?.partialFee.toString(); + return convertToFeePaymentAsset(partialFee); + } + case ProvisioningType.Remove: { + const estimate = await estimateSell( + cache, + apiInstance, + assetIn, + assetOut, + assetInAmount, + tradeLimit.balance + ); + const partialFee = estimate?.partialFee.toString(); + return convertToFeePaymentAsset(partialFee); + } + default: + return; + } + }, [ + apiInstance, + cache, + ...watch(['assetInAmount', 'assetOutAmount', 'assetIn']), + tradeLimit, + provisioningType, + convertToFeePaymentAsset, + ]); + + useEffect(() => { + (async () => { + const paymentInfo = await calculatePaymentInfo(); + if (!paymentInfo) return; + setPaymentInfo(paymentInfo); + })(); + }, [ + apiInstance, + cache, + ...watch(['assetInAmount', 'assetOutAmount']), + tradeLimit, + provisioningType, + calculatePaymentInfo + ]); + + useEffect(() => { + setValue('assetIn', assetIds.assetIn); + setValue('assetOut', assetIds.assetOut); + }, [assetIds]); + + const tradeBalances = useMemo(() => { + const assetOutAmount = getValues('assetOutAmount'); + const outBeforeTrade = activeAccountTradeBalances?.outBalance?.balance; + const outAfterTrade = + (outBeforeTrade && + assetOutAmount && + new BigNumber(outBeforeTrade).plus(assetOutAmount).toFixed(0)) || + undefined; + const outTradeChange = + outBeforeTrade !== '0' + ? percentageChange( + fromPrecision12(outBeforeTrade), + fromPrecision12(outAfterTrade) + )?.multipliedBy(100) + : new BigNumber( + outAfterTrade && outAfterTrade !== '0' ? '100.000' : '0' + ); + + const assetInAmount = getValues('assetInAmount'); + const inBeforeTrade = activeAccountTradeBalances?.inBalance?.balance; + let inAfterTrade = + (inBeforeTrade && + assetInAmount && + new BigNumber(inBeforeTrade).minus(assetInAmount).toFixed(0)) || + undefined; + + inAfterTrade = + getValues('assetIn') !== '0' + ? inAfterTrade + : paymentInfo && + inAfterTrade && + new BigNumber(inAfterTrade).minus(paymentInfo).toFixed(0); + + const inTradeChange = + inBeforeTrade !== '0' + ? percentageChange( + fromPrecision12(inBeforeTrade), + fromPrecision12(inAfterTrade) + )?.multipliedBy(100) + : new BigNumber( + inAfterTrade && inAfterTrade !== '0' ? '-100.000' : '0' + ); + + return { + outBeforeTrade, + outAfterTrade, + outTradeChange, + + inBeforeTrade, + inAfterTrade, + inTradeChange, + }; + }, [ + activeAccountTradeBalances, + ...watch(['assetOutAmount', 'assetInAmount', 'assetIn']), + paymentInfo, + ]); + + const { debugComponent } = useDebugBoxContext(); + + useEffect(() => { + debugComponent('PoolsForm', { + ...getValues(), + spotPrice, + tradeLimit, + assetInLiquidity, + assetOutLiquidity, + tradeBalances: { + ...tradeBalances, + inTradeChange: tradeBalances.inTradeChange?.toString(), + outTradeChange: tradeBalances.outTradeChange?.toString(), + }, + provisioningType, + slippage: slippage?.toString(), + errors: Object.keys(errors).reduce((reducedErrors, error) => { + return { + ...reducedErrors, + [error]: (errors as any)[error].type, + }; + }, {}), + }); + }, [ + Object.values(getValues()).toString(), + spotPrice, + tradeBalances, + tradeBalances, + provisioningType, + errors, + assetInLiquidity, + assetOutLiquidity, + slippage, + formState.isDirty, + ]); + + const minTradeLimitIn = useCallback( + (assetInAmount?: Maybe) => { + if (!assetInAmount || assetInAmount === '0') return false; + return new BigNumber(assetInLiquidity || '0') + .dividedBy(3) + .gte(assetInAmount); + }, + [assetInLiquidity] + ); + + const [maxAmountInLoading, setMaxAmountInLoading] = useState(false); + + const calculateMaxAmountIn = useCallback(async () => { + const [assetIn, assetOut] = getValues(['assetIn', 'assetOut']); + console.log( + 'calculateMaxAmountIn1', + tradeBalances.inBeforeTrade, + cache, + apiInstance, + assetIn, + assetOut + ); + if ( + !tradeBalances.inBeforeTrade || + !cache || + !apiInstance || + !assetIn || + !assetOut + ) + return; + console.log('calculateMaxAmountIn11'); + const maxAmount = tradeBalances.inBeforeTrade; + const estimate = await estimateSell( + cache, + apiInstance, + assetIn, + assetOut, + maxAmount, + '0' + ); + console.log('calculateMaxAmountIn11 estimate done', estimate); + const paymentInfo = estimate?.partialFee.toString(); + const maxAmountWithoutFee = new BigNumber(maxAmount).minus( + paymentInfo || '0' + ); + console.log('calculateMaxAmountIn12', { + inBeforeTrade: tradeBalances.inBeforeTrade, + estimate, + paymentInfo, + maxAmount, + maxAmountWithoutFee: maxAmountWithoutFee.toFixed(10), + }); + + return getValues('assetIn') === '0' + ? // max amount changed when all fields are filled out since that allows + // us to calculate paymentInfo + maxAmountWithoutFee.gt('0') + ? maxAmountWithoutFee.toFixed(10) + : undefined + : maxAmount; + }, [ + tradeBalances.inBeforeTrade, + paymentInfo, + cache, + apiInstance, + ...watch(['assetIn']), + ]); + + const maxButtonDisabled = useMemo(() => { + return ( + maxAmountInLoading || activeAccountTradeBalancesLoading || isPoolLoading + ); + }, [maxAmountInLoading, activeAccountTradeBalancesLoading, isPoolLoading]); + + const handleMaxButtonOnClick = useCallback(async () => { + setMaxAmountInLoading(true); + const maxAmountIn = await calculateMaxAmountIn(); + console.log('setting max amount in', maxAmountIn); + if (maxAmountIn) + setValue('assetInAmount', maxAmountIn, { + shouldDirty: true, + shouldValidate: true, + }); + setMaxAmountInLoading(false); + }, [calculateMaxAmountIn]); + + return ( +
+
+ {modalPortal} + + +
+
+
+ + +
+
{ + e.preventDefault(); + toggleModal(); + }} + > + +
+
+ +
+ {provisioningType === ProvisioningType.Add ? 'Add' : 'Remove'}{' '} + Liquidity +
+
+ !Object.values(assetIds).includes(asset.id) + )} + disabled={provisioningType === ProvisioningType.Remove} + maxBalanceLoading={maxAmountInLoading} + /> +
+
First token
+ {activeAccountTradeBalancesLoading || isPoolLoading ? ( + 'Your balance: loading' + ) : ( + <> + Your balance: + {assetIds.assetIn ? ( + tradeBalances.inBeforeTrade !== undefined ? ( + + ) : ( + <> {horizontalBar} + ) + ) : ( + <> {horizontalBar} + )} + + )} + {/*
handleMaxButtonOnClick()} + > + Max +
*/} +
+
+ +
+
+ {/*
+ +
*/} +
+
+ {(() => { + const assetOut = getValues('assetOut'); + const assetIn = getValues('assetIn'); + switch (provisioningType) { + case ProvisioningType.Remove: + // return `1 ${ + // idToAsset(getValues('assetIn'))?.symbol || + // getValues('assetIn') + // } = ${fromPrecision12(spotPrice?.inOut)} ${ + // idToAsset(getValues('assetOut'))?.symbol || + // getValues('assetOut') + // }`; + return spotPrice?.inOut && assetOut ? ( + <> + + = + + + ) : ( + <>- + ); + case ProvisioningType.Add: + // return `1 ${ + // idToAsset(getValues('assetOut'))?.symbol || + // getValues('assetOut') + // } = ${fromPrecision12(spotPrice?.outIn)} ${ + // idToAsset(getValues('assetIn'))?.symbol || + // getValues('assetIn') + // }`; + return spotPrice?.outIn && assetIn ? ( + <> + + = + + + ) : ( + <>- + ); + } + })()} +
+
+
+ +
+ {' '} + !Object.values(assetIds).includes(asset.id) + )} + disabled={provisioningType === ProvisioningType.Remove} + />{' '} +
+
Second token
+ {activeAccountTradeBalancesLoading || isPoolLoading ? ( + 'Your balance: loading' + ) : ( + // : `${fromPrecision12(tradeBalances.outBeforeTrade)} -> ${fromPrecision12(tradeBalances.outAfterTrade)}` + <> + Your balance: + {assetIds.assetOut ? ( + tradeBalances.outBeforeTrade !== undefined ? ( + + ) : ( + <> {horizontalBar} + ) + ) : ( + <> {horizontalBar} + )} + + )} +
+
+
+ {' '} + !Object.values(assetIds).includes(asset.id) + )} + disabled={provisioningType === ProvisioningType.Add} + />{' '} +
+
Share token
+ {activeAccountTradeBalancesLoading || isPoolLoading ? ( + 'Your balance: loading' + ) : ( + // : `${fromPrecision12(tradeBalances.outBeforeTrade)} -> ${fromPrecision12(tradeBalances.outAfterTrade)}` + <> + Your balance: + {activeAccountTradeBalances?.shareBalance ? ( + + ) : ( + <> {horizontalBar} + )} + + )} +
+
+ + + isActiveAccountConnected, + poolDoesNotExist: () => !isPoolLoading && !!pool, + notEnoughBalanceInA: () => { + if (provisioningType === ProvisioningType.Remove) return true; + const assetInAmount = getValues('assetInAmount'); + if ( + !activeAccountTradeBalances?.inBalance?.balance || + !assetInAmount + ) + return false; + return new BigNumber( + activeAccountTradeBalances.inBalance.balance + ).gte(assetInAmount); + }, + notEnoughBalanceInB: () => { + if (provisioningType === ProvisioningType.Remove) return true; + const assetInAmount = getValues('assetOutAmount'); + if ( + !activeAccountTradeBalances?.outBalance?.balance || + !assetInAmount + ) + return false; + return new BigNumber( + activeAccountTradeBalances.outBalance.balance + ).gte(assetInAmount); + }, + notEnoughBalanceInShare: () => { + if (provisioningType === ProvisioningType.Add) return true; + const shareAssetAmount = getValues('shareAssetAmount'); + if ( + !activeAccountTradeBalances?.shareBalance?.balance || + !shareAssetAmount + ) + return false; + return new BigNumber( + activeAccountTradeBalances.shareBalance.balance + ).gte(shareAssetAmount); + }, + // notEnoughFeeBalance: () => { + // const assetIn = getValues('assetIn'); + // const assetInAmount = getValues('assetInAmount'); + + // let nativeAssetBalance = find(activeAccount?.balances, { + // assetId: '0', + // })?.balance; + + // let balanceForFee = nativeAssetBalance; + + // if (assetIn === '0' && assetInAmount && nativeAssetBalance) { + // balanceForFee = new BigNumber(nativeAssetBalance) + // .minus(assetInAmount) + // .toString(); + // } + + // if (!paymentInfo) return true; + // if (!balanceForFee) return false; + + // return new BigNumber(balanceForFee).gte(paymentInfo); + // }, + }, + })} + disabled={!isValid || tradeLoading || !isDirty} + value={getSubmitText()} + /> + +
+
+ ); +}; diff --git a/src/components/Pools/PoolsInfo/PoolsInfo.scss b/src/components/Pools/PoolsInfo/PoolsInfo.scss new file mode 100644 index 00000000..986bf06a --- /dev/null +++ b/src/components/Pools/PoolsInfo/PoolsInfo.scss @@ -0,0 +1,77 @@ +@import './../../../misc/colors.module.scss'; +@import './../../../misc/misc.module.scss'; + +.pools-info { + display: flex; + flex-direction: column; + justify-content: start; + gap: 4px; + + font-size: 15px; + font-weight: 400; + margin-top: 4px; + margin-bottom: 4px; + + &__data { + display: flex; + flex-direction: column; + + justify-content: center; + + max-height: 120px; + opacity: 1; + + transition: max-height 0.3s ease, opacity 0.15s ease; + + &.hidden { + max-height: 0px; + opacity: 0; + } + + .data-piece { + padding: 2px 0 4px 0; + display: flex; + justify-content: space-between; + align-items: center; + &__label { + color: #9ea9b1; + } + position: relative; + + &:not(:last-child):after { + content: ' '; + position: absolute; + width: 100%; + height: 1px; + background-color: #26282f; + bottom: 0; + } + } + } + + .validation { + opacity: 0; + line-height: 16px; + padding: 0 16px; + height: 100%; + max-height: 0px; + overflow: hidden; + + transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease; + border-radius: 8px; + + &.visible { + max-height: 80px; + padding: 16px; + opacity: 1; + } + + &.error { + background: rgba(255, 104, 104, 0.3); + } + + &.warning { + color: $orange1; + } + } +} diff --git a/src/components/Pools/PoolsInfo/PoolsInfo.tsx b/src/components/Pools/PoolsInfo/PoolsInfo.tsx new file mode 100644 index 00000000..71b6fa49 --- /dev/null +++ b/src/components/Pools/PoolsInfo/PoolsInfo.tsx @@ -0,0 +1,128 @@ +import BigNumber from 'bignumber.js'; +import { debounce, delay, throttle } from 'lodash'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { FieldErrors } from 'react-hook-form'; +import { useMultiFeePaymentConversionContext } from '../../../containers/MultiProvider'; +import { Balance, Fee } from '../../../generated/graphql'; +import { FormattedBalance } from '../../Balance/FormattedBalance/FormattedBalance'; +import { horizontalBar } from '../../Chart/ChartHeader/ChartHeader'; +import { PoolsFormFields, ProvisioningType } from '../PoolsForm'; +import constants from '../../../constants'; +import './PoolsInfo.scss'; + +export interface PoolsInfoProps { + transactionFee?: string; + tradeLimit?: Balance; + isDirty?: boolean; + errors?: FieldErrors; + paymentInfo?: string; + provisioningType: ProvisioningType; +} + +export const PoolsInfo = ({ + errors, + tradeLimit, + provisioningType, + isDirty, + paymentInfo, +}: PoolsInfoProps) => { + const [displayError, setDisplayError] = useState(); + const isError = useMemo(() => !!errors?.submit?.type, [errors?.submit]); + const formError = useMemo(() => { + switch (errors?.submit?.type) { + case 'slippageHigherThanTolerance': + return 'Slippage higher than tolerance'; + case 'notEnoughBalanceInA': + return 'Insufficient Token A balance'; + case 'notEnoughBalanceInB': + return 'Insufficient Token B balance'; + case 'notEnoughBalanceInShare': + return 'Insufficient Share token balance'; + case 'notEnoughFeeBalance': + return 'Insufficient fee balance'; + case 'poolDoesNotExist': + return 'Please select valid pool'; + case 'activeAccount': + return 'Please connect a wallet to continue'; + } + return; + }, [errors?.submit]); + + useEffect(() => { + if (formError) { + const timeoutId = setTimeout(() => setDisplayError(formError), 50); + return () => timeoutId && clearTimeout(timeoutId); + } + const timeoutId = setTimeout(() => setDisplayError(formError), 300); + return () => timeoutId && clearTimeout(timeoutId); + }, [formError]); + + const { feePaymentAsset } = useMultiFeePaymentConversionContext(); + + return ( +
+
+ {/*
+ Current slippage +
+ {!expectedSlippage || expectedSlippage?.isNaN() + ? horizontalBar + : `${expectedSlippage?.multipliedBy(100).toFixed(2)}%`} +
+
*/} + {provisioningType === ProvisioningType.Add + ? ( +
+ Provisioning limit +
+ {tradeLimit?.balance ? ( + + ) : ( + <>{horizontalBar} + )} +
+
+ ) + : <> + } +
+ Transaction fee +
+ {paymentInfo ? ( + + ) : ( + <>{horizontalBar} + )} +
+
+ {/*
+ Trade fee +
+ {new BigNumber(tradeFee.numerator) + .dividedBy(tradeFee.denominator) + .multipliedBy(100) + .toFixed(2)} + % +
+
*/} +
+ {/* TODO Error message */} + +
+ {displayError} +
+
+ ); +}; diff --git a/src/components/Trade/TradeForm/TradeForm.scss b/src/components/Trade/TradeForm/TradeForm.scss index 591cb865..4fa1854e 100644 --- a/src/components/Trade/TradeForm/TradeForm.scss +++ b/src/components/Trade/TradeForm/TradeForm.scss @@ -6,52 +6,83 @@ flex-basis: 350px; flex-grow: 1; - padding: 14px; + padding: 22px; min-width: 350px; + max-width: 610px; + margin: 0 auto; - background-color: $d-gray4; + box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05); + background: linear-gradient(180deg, #1c2527 0%, #14161a 80.73%, #121316 100%); overflow: hidden; + border-radius: 10px; position: relative; + color: white; + .trade-form { display: flex; flex-direction: column; justify-content: space-between; - gap: 8px; + gap: 14px; height: 100%; min-height: 400px; .trade-form-heading { + width: fit-content; padding-top: 4px; color: $l-gray3; - font-size: 18px; + font-size: 22px; font-weight: 500; + background: linear-gradient( + 90deg, + #4fffb0 1.27%, + #b3ff8f 48.96%, + #ff984e 104.14% + ), + linear-gradient(90deg, #4fffb0 1.27%, #a2ff76 53.24%, #ff984e 104.14%), + linear-gradient(90deg, #ffce4f 1.27%, #4fffb0 104.14%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; } .divider-wrapper { display: flex; align-items: center; - height: 2px; + height: 1px; width: 100%; } .divider { position: absolute; width: 100%; - height: 2px; - background-color: $d-gray5; + height: 1px; + background-color: rgba(76, 243, 168, 0.12); opacity: 1; border: 0; left: 0; } + .balance-wrapper { + display: flex; + flex-direction: column-reverse; + align-items: end; + background: rgba(162, 176, 187, 0.1); + padding: 12px; + padding-top: 18px; + border-radius: 10px; + gap: 6px; + padding-bottom: 16px; + } + .submit-button { background: $green1; text-transform: uppercase; - border-radius: $border-radius; + border-radius: 36px; height: 50px; color: $d-gray4; @@ -72,37 +103,44 @@ .balance-info { display: flex; align-items: center; + justify-content: right; + width: 100%; gap: 4px; height: 16px; margin-top: 4px; - font-size: 10px; - line-height: 10px; + font-size: 12px; + line-height: 12px; + position: relative; + + .balance-info-type { + position: absolute; + left: 0; + font-weight: 600; + font-size: 16px; + color: $green1; + padding: 6px; + } } .asset-switch { display: flex; - height: 55px; + height: 43px; justify-content: space-between; align-items: center; width: 100%; .asset-switch-icon { position: absolute; - left: 16px; + left: 24px; display: flex; align-items: center; justify-content: center; - width: 55px; - height: 55px; - - border-radius: 55px; - border: 4px solid $d-gray3; - background-color: $d-gray5; - overflow: hidden; + background: #192022; + border-radius: 50%; transition: transform 500ms ease; @@ -120,26 +158,37 @@ } .asset-switch-price { - display: flex; - align-items: center; - gap: 4px; - height: 18px; - - right: 0; - padding: 0 16px; - font-size: 12px; - font-weight: 500; position: absolute; + right: 24px; + background: #192022; - background-color: $d-gray5; + &__wrapper { + display: flex; + align-items: center; + gap: 4px; - border-radius: 4px 0 0 4px; + padding: 4px 14px; + font-size: 11px; + font-weight: 500; + + background: rgba(218, 255, 238, 0.06); + border-radius: 7px; + } } } .settings-button { position: absolute; - right: 16px; + display: flex; + right: 24px; + top: 20px; + padding: 10px 8px; + border-radius: 50%; + background-color: rgba(162, 176, 187, 0.1); + + svg { + width: 24px; + } &:hover { cursor: pointer; @@ -165,7 +214,13 @@ height: 100%; } + .settings-section { + padding: 12px 24px; + background: linear-gradient(0deg, #171518, #171518), #1c1a1f; + } + .settings-field { + padding: 12px 24px; display: flex; justify-content: space-between; align-items: center; @@ -201,3 +256,19 @@ background-color: rgba(0, 0, 0, 0.8); } + +.max-button { + font-size: 12px; + font-weight: 400; + color: $white1; + padding: 4px 10px; + background: rgba(255, 255, 255, 0.06); + border-radius: 12px; + text-transform: capitalize; + cursor: pointer; + + &.disabled { + cursor: not-allowed; + opacity: 0.5; + } +} diff --git a/src/components/Trade/TradeForm/TradeForm.tsx b/src/components/Trade/TradeForm/TradeForm.tsx index 02098da7..918b5b7b 100644 --- a/src/components/Trade/TradeForm/TradeForm.tsx +++ b/src/components/Trade/TradeForm/TradeForm.tsx @@ -11,7 +11,13 @@ import { useState, } from 'react'; import { Control, FormProvider, useForm } from 'react-hook-form'; -import { Account, Balance, Maybe, Pool, TradeType } from '../../../generated/graphql'; +import { + Account, + Balance, + Maybe, + Pool, + TradeType, +} from '../../../generated/graphql'; import { fromPrecision12 } from '../../../hooks/math/useFromPrecision'; import { useMath } from '../../../hooks/math/useMath'; import { percentageChange } from '../../../hooks/math/usePercentageChange'; @@ -31,6 +37,8 @@ import { usePolkadotJsContext } from '../../../hooks/polkadotJs/usePolkadotJs'; import { useApolloClient } from '@apollo/client'; import { estimateBuy } from '../../../hooks/pools/xyk/buy'; import { estimateSell } from '../../../hooks/pools/xyk/sell'; +import { payment } from '@polkadot/types/interfaces/definitions'; +import { useMultiFeePaymentConversionContext } from '../../../containers/MultiProvider'; export interface TradeFormSettingsProps { allowedSlippage: string | null; @@ -48,13 +56,14 @@ export const TradeFormSettings = ({ onAllowedSlippageChange, closeModal, }: TradeFormSettingsProps) => { - const { register, watch, getValues, setValue, handleSubmit } = - useForm({ - defaultValues: { - allowedSlippage, - autoSlippage: true, - }, - }); + const { register, watch, getValues, setValue, handleSubmit } = useForm< + TradeFormSettingsFormFields + >({ + defaultValues: { + allowedSlippage, + autoSlippage: true, + }, + }); // propagate allowed slippage to the parent useEffect(() => { @@ -71,16 +80,17 @@ export const TradeFormSettings = ({ return (
{})} >
- Settings +
Settings
- +
+
Slippage
- {(() => { - const assetOut = getValues('assetOut'); - const assetIn = getValues('assetIn'); - switch (tradeType) { - case TradeType.Sell: - // return `1 ${ - // idToAsset(getValues('assetIn'))?.symbol || - // getValues('assetIn') - // } = ${fromPrecision12(spotPrice?.inOut)} ${ - // idToAsset(getValues('assetOut'))?.symbol || - // getValues('assetOut') - // }`; - return spotPrice?.inOut && assetOut ? ( - <> - - = - - - ) : ( - <>- - ); - case TradeType.Buy: - // return `1 ${ - // idToAsset(getValues('assetOut'))?.symbol || - // getValues('assetOut') - // } = ${fromPrecision12(spotPrice?.outIn)} ${ - // idToAsset(getValues('assetIn'))?.symbol || - // getValues('assetIn') - // }`; - return spotPrice?.outIn && assetIn ? ( - <> - - = - - - ) : ( - <>- - ); - } - })()} +
+ {(() => { + const assetOut = getValues('assetOut'); + const assetIn = getValues('assetIn'); + switch (tradeType) { + case TradeType.Sell: + // return `1 ${ + // idToAsset(getValues('assetIn'))?.symbol || + // getValues('assetIn') + // } = ${fromPrecision12(spotPrice?.inOut)} ${ + // idToAsset(getValues('assetOut'))?.symbol || + // getValues('assetOut') + // }`; + return spotPrice?.inOut && assetOut ? ( + <> + + = + + + ) : ( + <>- + ); + case TradeType.Buy: + // return `1 ${ + // idToAsset(getValues('assetOut'))?.symbol || + // getValues('assetOut') + // } = ${fromPrecision12(spotPrice?.outIn)} ${ + // idToAsset(getValues('assetIn'))?.symbol || + // getValues('assetIn') + // }`; + return spotPrice?.outIn && assetIn ? ( + <> + + = + + + ) : ( + <>- + ); + } + })()} +
-
You get
{' '} !Object.values(assetIds).includes(asset.id))} + assets={assets?.filter( + (asset) => !Object.values(assetIds).includes(asset.id) + )} />{' '}
- {activeAccountTradeBalancesLoading || - isPoolLoading - ? ( +
You get
+ {activeAccountTradeBalancesLoading || isPoolLoading ? ( 'Your balance: loading' ) : ( // : `${fromPrecision12(tradeBalances.outBeforeTrade)} -> ${fromPrecision12(tradeBalances.outAfterTrade)}` <> Your balance: {assetIds.assetOut ? ( - tradeBalances.outBeforeTrade !== undefined - ? ( - - ) - : <> {horizontalBar} - ) : ( - <> {horizontalBar} - )} - {assetIds.assetOut && tradeBalances.outBeforeTrade !== undefined && tradeBalances.outAfterTrade !== undefined ? ( - <> - + tradeBalances.outBeforeTrade !== undefined ? ( - + ) : ( + <> {horizontalBar} + ) ) : ( - <> + <> {horizontalBar} )} - {tradeBalances.outTradeChange && - !tradeBalances.outTradeChange.isZero() && ( -
- ( - {tradeBalances.outTradeChange?.lt('0.01') - ? `< 0.01` - : tradeBalances.outTradeChange?.gt('1000') - ? `> 1000` - : tradeBalances.outTradeChange.toFixed(2)} - %) -
- )} )}
-
-
-
{ const assetOutAmount = getValues('assetOutAmount'); @@ -825,10 +964,7 @@ export const TradeForm = ({ }, maxTradeLimitIn: () => { const assetInAmount = getValues('assetInAmount'); - if (!assetInAmount || assetInAmount === '0') return false; - return new BigNumber(assetInLiquidity || '0') - .dividedBy(3) - .gte(assetInAmount); + return minTradeLimitIn(assetInAmount); }, slippageHigherThanTolerance: () => { if (!allowedSlippage) return false; @@ -838,24 +974,25 @@ export const TradeForm = ({ const assetIn = getValues('assetIn'); const assetInAmount = getValues('assetInAmount'); - let nativeAssetBalance = find(activeAccount?.balances, { - assetId: '0' - })?.balance; + if (!feePaymentAsset) return false; - let balanceForFee = nativeAssetBalance; + let feePaymentAssetBalance = find(activeAccount?.balances, { + assetId: feePaymentAsset, + })?.balance - if (assetIn === '0' && assetInAmount && nativeAssetBalance) { - balanceForFee = new BigNumber(nativeAssetBalance) + let balanceForFee = feePaymentAssetBalance; + + if (assetIn === feePaymentAsset && assetInAmount && feePaymentAssetBalance) { + balanceForFee = new BigNumber(feePaymentAssetBalance) .minus(assetInAmount) .toString(); } if (!paymentInfo) return true; if (!balanceForFee) return false; - - return new BigNumber(balanceForFee) - .gte(paymentInfo); - } + console.log('balance for free', balanceForFee, paymentInfo); + return new BigNumber(balanceForFee).gte(paymentInfo); + }, }, })} disabled={!isValid || tradeLoading || !isDirty} diff --git a/src/components/Trade/TradeForm/TradeInfo/TradeInfo.scss b/src/components/Trade/TradeForm/TradeInfo/TradeInfo.scss index 56c4cb98..399468a0 100644 --- a/src/components/Trade/TradeForm/TradeInfo/TradeInfo.scss +++ b/src/components/Trade/TradeForm/TradeInfo/TradeInfo.scss @@ -7,9 +7,9 @@ justify-content: center; gap: 4px; - min-height: 90px; - font-size: 12px; - font-weight: 600; + min-height: 100px; + font-size: 15px; + font-weight: 400; &__data { display: flex; @@ -17,7 +17,7 @@ justify-content: center; - max-height: 65px; + max-height: 120px; opacity: 1; transition: max-height 0.3s ease, opacity 0.15s ease; @@ -28,41 +28,48 @@ } .data-piece { + padding: 2px 0 4px 0; display: flex; justify-content: space-between; align-items: center; &__label { - color: #bdccd4; - font-weight: 700; + color: #9ea9b1; + } + position: relative; + + &:not(:last-child):after { + content: ' '; + position: absolute; + width: 100%; + height: 1px; + background-color: #26282f; + bottom: 0; } } } .validation { - opacity: 0.3; + opacity: 0; line-height: 16px; - height: 16px; + padding: 0 16px; + height: 100%; max-height: 0px; - // max-height: 30px; overflow: hidden; - transition: max-height 0.3s ease, opacity 0.3s ease; + transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease; + border-radius: 8px; &.visible { - max-height: 30px; + max-height: 80px; + padding: 16px; opacity: 1; } &.error { - font-size: 14px; - color: $red1; + background: rgba(255, 104, 104, 0.3); } &.warning { - max-height: 30px; - opacity: 1; - - font-size: 14px; color: $orange1; } } diff --git a/src/components/Trade/TradeForm/TradeInfo/TradeInfo.tsx b/src/components/Trade/TradeForm/TradeInfo/TradeInfo.tsx index ec1cd39b..db9a711f 100644 --- a/src/components/Trade/TradeForm/TradeInfo/TradeInfo.tsx +++ b/src/components/Trade/TradeForm/TradeInfo/TradeInfo.tsx @@ -2,6 +2,7 @@ import BigNumber from 'bignumber.js'; import { debounce, delay, throttle } from 'lodash'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { FieldErrors } from 'react-hook-form'; +import { useMultiFeePaymentConversionContext } from '../../../../containers/MultiProvider'; import { Balance, Fee } from '../../../../generated/graphql'; import { FormattedBalance } from '../../../Balance/FormattedBalance/FormattedBalance'; import { horizontalBar } from '../../../Chart/ChartHeader/ChartHeader'; @@ -16,7 +17,7 @@ export interface TradeInfoProps { isDirty?: boolean; expectedSlippage?: BigNumber; errors?: FieldErrors; - paymentInfo?: string, + paymentInfo?: string; } export const TradeInfo = ({ @@ -25,7 +26,7 @@ export const TradeInfo = ({ tradeLimit, isDirty, tradeFee = constants.xykFee, - paymentInfo + paymentInfo, }: TradeInfoProps) => { const [displayError, setDisplayError] = useState(); const isError = useMemo(() => !!errors?.submit?.type, [errors?.submit]); @@ -44,7 +45,11 @@ export const TradeInfo = ({ case 'notEnoughBalanceIn': return 'Insufficient balance'; case 'notEnoughFeeBalance': - return 'Insufficient fee balance' + return 'Insufficient fee balance'; + case 'poolDoesNotExist': + return 'Please select valid pool'; + case 'activeAccount': + return 'Please connect a wallet to continue'; } return; }, [errors?.submit]); @@ -58,6 +63,8 @@ export const TradeInfo = ({ return () => timeoutId && clearTimeout(timeoutId); }, [formError]); + const { feePaymentAsset } = useMultiFeePaymentConversionContext(); + return (
@@ -66,8 +73,7 @@ export const TradeInfo = ({
{!expectedSlippage || expectedSlippage?.isNaN() ? horizontalBar - : `${expectedSlippage?.multipliedBy(100).toFixed(2)}%` - } + : `${expectedSlippage?.multipliedBy(100).toFixed(2)}%`}
@@ -92,7 +98,7 @@ export const TradeInfo = ({ ) : ( diff --git a/src/components/Wallet/AccountSelector/AccountItem/AccountItem.scss b/src/components/Wallet/AccountSelector/AccountItem/AccountItem.scss index f963a678..ce24f29c 100644 --- a/src/components/Wallet/AccountSelector/AccountItem/AccountItem.scss +++ b/src/components/Wallet/AccountSelector/AccountItem/AccountItem.scss @@ -2,22 +2,61 @@ @import '../../../../misc/misc.module.scss'; .account-item { - display: flex; - flex-direction: column; position: relative; - gap: 4px; - cursor: pointer; - padding: 8px 16px; - - border: 1px solid transparent; - border-radius: $border-radius; + border-radius: 12px; background-color: $gray3; - &--active { - background-color: $gray5; + padding: 1px; + + &--active, + &:hover { + background: linear-gradient(90deg, #4fffb0, #b3ff8f, #ff984e); + .account-item__wrapper { + &:before { + content: ' '; + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; + + background: linear-gradient( + 285.92deg, + rgba(73, 228, 159, 0) 25.46%, + rgba(228, 175, 73, 0.2) 98.29% + ), + rgba(76, 243, 168, 0.12); + + border-radius: 12px; + z-index: -1; + + &__chain-name { + color: $green1; + } + } + + background-color: #211f24; + z-index: 1; + } + } + + &__wrapper { + display: flex; + flex-direction: column; + gap: 16px; + + width: 100%; + height: 100%; + position: relative; + padding: 16px 16px; + + top: 0; + left: 0; + + border-radius: 12px; } &__address-entry { @@ -28,7 +67,7 @@ } &__address-info { - gap: 4px; + gap: 16px; display: flex; flex-direction: column; } @@ -53,12 +92,12 @@ align-items: center; &__name { - flex-shrink: 100px; + font-size: 16px; + font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0; - font-weight: 800; } &__source { @@ -73,44 +112,45 @@ } &:hover { - border-color: $green1; - - .account-item { - &__heading { - &__left { - &__source { - opacity: 1; - } - } + .account-item__wrapper { + &:before { + background: #26282f; + z-index: -1; } } } .account-item &__identicon { display: flex; - width: 40px; - height: 40px; + width: 32px; + height: 32px; line-height: 32px; flex-shrink: 0; justify-content: center; align-items: center; + background-color: black; + border-radius: 50%; + svg { + circle:first-child { + fill: black; + } + position: relative; - left: -2px; } } &__chain-name { font-size: 12px; - line-height: 1.1em; - font-weight: 500; + line-height: 1.2em; + font-weight: 400; } &__chain-address { - font-weight: normal; - font-size: 18px; - line-height: 1.1em; + font-weight: 600; + font-size: 14px; + line-height: 1.2em; overflow: hidden; text-overflow: ellipsis; } diff --git a/src/components/Wallet/AccountSelector/AccountItem/AccountItem.tsx b/src/components/Wallet/AccountSelector/AccountItem/AccountItem.tsx index 64507593..472f33eb 100644 --- a/src/components/Wallet/AccountSelector/AccountItem/AccountItem.tsx +++ b/src/components/Wallet/AccountSelector/AccountItem/AccountItem.tsx @@ -66,59 +66,61 @@ export const AccountItem = ({ account, onClick, active }: AccountItemProps) => { onClick(account); }} > -
-
-
- {account.name} -
-
- {sourceToHuman(account.source)} -
-
-
- {} -
-
-
-
- -
-
Basilisk
-
- {trimAddress(account.id, 24)} +
+
+
+
+ {account.name} +
+
+ {sourceToHuman(account.source)}
+
+ {} +
- {genesisHashToChain(account.genesisHash).network !== 'basilisk' ? ( +
-
- {genesisHashToChain(account.genesisHash).displayName} -
+
Basilisk
- {trimAddress( - encodeAddress( - decodeAddress(account.id), - genesisHashToChain(account.genesisHash)?.prefix - ), - 24 - )} + {trimAddress(account.id, 24)}
- ) : ( - <> - )} + {genesisHashToChain(account.genesisHash).network !== 'basilisk' ? ( +
+ +
+
+ {genesisHashToChain(account.genesisHash).displayName} +
+
+ {trimAddress( + encodeAddress( + decodeAddress(account.id), + genesisHashToChain(account.genesisHash)?.prefix + ), + 24 + )} +
+
+
+ ) : ( + <> + )} +
); diff --git a/src/components/Wallet/AccountSelector/AccountSelector.scss b/src/components/Wallet/AccountSelector/AccountSelector.scss index cf235964..b56880ce 100644 --- a/src/components/Wallet/AccountSelector/AccountSelector.scss +++ b/src/components/Wallet/AccountSelector/AccountSelector.scss @@ -7,34 +7,50 @@ justify-content: center; align-items: center; - padding: 16px; width: 100%; height: 100%; top: 0; left: 0; - background: rgba(50, 50, 50, 0.5); + color: white; z-index: 3; &__content-wrapper { - width: 460px; + width: 100%; + max-width: 460px; min-height: 500px; - max-height: 85vh; + max-height: 690px; border-radius: $border-radius; + + &__create-account-link { + text-decoration: none; + font-weight: normal; + color: $orange1; + } + + .account-selector__message { + padding: 16px; + + text-align: center; + + .account-selector__create-account-link { + display: inline; + } + } } &__clear-button { display: flex; justify-content: center; - padding-bottom: 8px; button { width: auto; color: $gray4; background: none; line-height: 16px; - padding-bottom: 8px; + padding: 16px; + width: 100%; &:hover { background: none; @@ -42,18 +58,4 @@ } } } - - &__create-account-link { - text-decoration: none; - font-weight: normal; - color: $orange1; - } - - &__message { - padding: 16px; - - text-align: center; - justify-content: center; - align-items: center; - } } diff --git a/src/components/Wallet/AccountSelector/AccountSelector.tsx b/src/components/Wallet/AccountSelector/AccountSelector.tsx index 277700b2..1e219c3d 100644 --- a/src/components/Wallet/AccountSelector/AccountSelector.tsx +++ b/src/components/Wallet/AccountSelector/AccountSelector.tsx @@ -1,5 +1,5 @@ import { MutableRefObject, useMemo } from 'react'; -import { Account } from '../../../generated/graphql'; +import { Account, Maybe } from '../../../generated/graphql'; import { AccountItem } from './AccountItem/AccountItem'; import { Button, ButtonKind } from '../../Button/Button'; import './AccountSelector.scss'; @@ -9,7 +9,7 @@ import Icon from '../../Icon/Icon'; export interface AccountSelectorProps { accounts?: Account[]; accountsLoading: boolean; - account?: Account; + account?: Maybe; onAccountSelected: (account: Account) => void; onAccountCleared: () => void; innerRef: MutableRefObject; @@ -38,15 +38,24 @@ export const AccountSelector = ({
{isExtensionAvailable ? ( - + <> +
+ +
+
+ Pick one of your accounts to connect to Basilisk +
+ ) : ( - +
+ +
)}
closeModal()}> @@ -76,24 +85,29 @@ export const AccountSelector = ({ ))}
) : ( - //TODO update href param when we know where to send user + //TODO update href param when we know where to send user
-
- -
-
- -
- - - -
+ + + + ), + }} + defaultMessage="Need help creating an account? {link}" + />
)} {account && ( diff --git a/src/components/Wallet/AccountSelector/hooks/useModalPortalElement.tsx b/src/components/Wallet/AccountSelector/hooks/useModalPortalElement.tsx index d5042fb0..6d9f93cb 100644 --- a/src/components/Wallet/AccountSelector/hooks/useModalPortalElement.tsx +++ b/src/components/Wallet/AccountSelector/hooks/useModalPortalElement.tsx @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { Account } from '../../../../generated/graphql'; +import { Account, Maybe } from '../../../../generated/graphql'; import { AccountSelector } from './../AccountSelector'; import { ModalPortalElementFactory, @@ -14,16 +14,15 @@ export type ModalPortalElement = ({ onAccountCleared, account, isExtensionAvailable, -}: Pick< - WalletProps, - | 'accounts' - | 'accountsLoading' - | 'onAccountSelected' - | 'onAccountCleared' - | 'account' - | 'isExtensionAvailable' ->) => ModalPortalElementFactory; -export type CloseModal = ModalPortalElementFactoryArgs['closeModal']; +}: { + accounts?: Account[], + accountsLoading: boolean, + account?: Maybe, + isExtensionAvailable: boolean, + onAccountSelected: (account: Account) => void, + onAccountCleared: () => void +}) => ModalPortalElementFactory; +export type CloseModal = ModalPortalElementFactoryArgs['closeModal']; export const useModalPortalElement: ModalPortalElement = ({ accounts, diff --git a/src/components/Wallet/Wallet.scss b/src/components/Wallet/Wallet.scss index 9026c826..6ded6ee4 100644 --- a/src/components/Wallet/Wallet.scss +++ b/src/components/Wallet/Wallet.scss @@ -3,17 +3,12 @@ .wallet { width: auto; - max-width: 550px; min-width: 350px; - - // flex-basis: 350px; - gap: 12px; - - background-color: $d-gray4; - border-radius: $border-radius; + border-radius: 8px; padding: 16px; + color: white; &__info { display: flex; @@ -50,7 +45,7 @@ &__account-btn { min-width: 90px; - padding: 8px 12px; + padding: 12px 16px; font-weight: 600; line-height: 16px; @@ -58,7 +53,7 @@ color: $l-gray2; background-color: $d-gray5; - border-radius: $border-radius; + border-radius: 9999px; text-align: center; diff --git a/src/components/Wallet/Wallet.stories.tsx b/src/components/Wallet/Wallet.stories.tsx index 9ddbbf9f..61c259e0 100644 --- a/src/components/Wallet/Wallet.stories.tsx +++ b/src/components/Wallet/Wallet.stories.tsx @@ -4,117 +4,117 @@ import { StorybookWrapper } from '../../misc/StorybookWrapper'; import { Wallet } from './Wallet'; import { toPrecision12 } from '../../hooks/math/useToPrecision'; -export default { - title: 'components/Wallet', - component: Wallet, - args: { - extensionLoading: false, - isExtensionAvailable: true, - account: { - name: 'LOOOOOOOONG snekmaster sdkaoskaodkosadkassdksadkoajdjdaosdjasoj', - balances: [ - { assetId: '0', balance: toPrecision12('100213') }, - { assetId: '1', balance: toPrecision12('300213') }, - ], - id: 'E7ncQKp4xayUoUdpraxBjT7NzLoayLJA4TuPcKKboBkJ5GH', - isActive: true, - vestingSchedule: {}, - source: 'polkadot-js', - }, - accounts: [ - { - name: 'Alice 1', - balances: [{ assetId: '0', balance: toPrecision12('100213') }], - id: 'E7ncQKp4xayUoUdpraxBjT7NzLoayLJA4TuPcKKboBkJ5GH', - isActive: true, - vestingSchedule: {}, - source: 'polkadot-js', - }, - { - name: 'Kusama snekmaster', - balances: [], - id: 'E7ncQKp4xayUoUdpraxBjT7NzxaayLJA4TuPcKKboBkJ5GH', - isActive: false, - vestingSchedule: {}, - source: 'polkadot-js', - }, - { - name: 'Kusama snekmaster', - balances: [{ assetId: '2', balance: toPrecision12('1') }], - id: 'E7ncQKp4xayUoUdpraxBjT7NzxaayLJA4TuPcKKboBkJ5GH', - isActive: false, - vestingSchedule: {}, - source: 'polkadot-js', - }, - { - name: 'LOOOOOOOONG snekmaster sdkaoskaodkosadkassdksadkoajdjdaosdjasoj', - balances: [ - { - assetId: '0', - balance: toPrecision12('10010101001000003203302023'), - }, - ], - id: 'E7ncQKp4xayUoUdpraxBjT7NzxaayLJA4TuPcKKboBkJ5GH', - isActive: false, - vestingSchedule: {}, - source: 'polkadot-js', - }, - ], - accountsLoading: false, - onAccountSelected: () => { - return Promise.resolve(); - }, - onAccountCleared: () => { - return Promise.resolve(); - }, - setAccountSelectorOpen: () => { - console.log('toggle modal open'); - }, - }, -} as ComponentMeta; +// export default { +// title: 'components/Wallet', +// component: Wallet, +// args: { +// extensionLoading: false, +// isExtensionAvailable: true, +// account: { +// name: 'LOOOOOOOONG snekmaster sdkaoskaodkosadkassdksadkoajdjdaosdjasoj', +// balances: [ +// { assetId: '0', balance: toPrecision12('100213') }, +// { assetId: '1', balance: toPrecision12('300213') }, +// ], +// id: 'E7ncQKp4xayUoUdpraxBjT7NzLoayLJA4TuPcKKboBkJ5GH', +// isActive: true, +// vestingSchedule: {}, +// source: 'polkadot-js', +// }, +// accounts: [ +// { +// name: 'Alice 1', +// balances: [{ assetId: '0', balance: toPrecision12('100213') }], +// id: 'E7ncQKp4xayUoUdpraxBjT7NzLoayLJA4TuPcKKboBkJ5GH', +// isActive: true, +// vestingSchedule: {}, +// source: 'polkadot-js', +// }, +// { +// name: 'Kusama snekmaster', +// balances: [], +// id: 'E7ncQKp4xayUoUdpraxBjT7NzxaayLJA4TuPcKKboBkJ5GH', +// isActive: false, +// vestingSchedule: {}, +// source: 'polkadot-js', +// }, +// { +// name: 'Kusama snekmaster', +// balances: [{ assetId: '2', balance: toPrecision12('1') }], +// id: 'E7ncQKp4xayUoUdpraxBjT7NzxaayLJA4TuPcKKboBkJ5GH', +// isActive: false, +// vestingSchedule: {}, +// source: 'polkadot-js', +// }, +// { +// name: 'LOOOOOOOONG snekmaster sdkaoskaodkosadkassdksadkoajdjdaosdjasoj', +// balances: [ +// { +// assetId: '0', +// balance: toPrecision12('10010101001000003203302023'), +// }, +// ], +// id: 'E7ncQKp4xayUoUdpraxBjT7NzxaayLJA4TuPcKKboBkJ5GH', +// isActive: false, +// vestingSchedule: {}, +// source: 'polkadot-js', +// }, +// ], +// accountsLoading: false, +// onAccountSelected: () => { +// return Promise.resolve(); +// }, +// onAccountCleared: () => { +// return Promise.resolve(); +// }, +// setAccountSelectorOpen: () => { +// console.log('toggle modal open'); +// }, +// }, +// } as ComponentMeta; -const Template: ComponentStory = (args) => { - const modalContainerRef = useRef(null); +// const Template: ComponentStory = (args) => { +// const modalContainerRef = useRef(null); - return ( - -
- {/* This is where the underlying modal should be rendered */} -
+// return ( +// +//
+// {/* This is where the underlying modal should be rendered */} +//
- {/* - Pass the ref to the element above, so that the Wallet - can render the modal there. - */} -
- -
-
- - ); -}; +// {/* +// Pass the ref to the element above, so that the Wallet +// can render the modal there. +// */} +//
+// +//
+//
+//
+// ); +// }; -export const Default = Template.bind({}); -export const NoAccountConnected = Template.bind({}); -NoAccountConnected.args = { - account: undefined, -}; -export const AccountsLoading = Template.bind({}); -AccountsLoading.args = { - accountsLoading: true, -}; -export const NoAccountsAvailable = Template.bind({}); -NoAccountsAvailable.args = { - account: undefined, - accounts: [], -}; -export const ExtensionUnavailable = Template.bind({}); -ExtensionUnavailable.args = { - isExtensionAvailable: false, - account: undefined, - accounts: [], -}; -export const LoadingData = Template.bind({}); -LoadingData.args = { - extensionLoading: true, -}; +// export const Default = Template.bind({}); +// export const NoAccountConnected = Template.bind({}); +// NoAccountConnected.args = { +// account: undefined, +// }; +// export const AccountsLoading = Template.bind({}); +// AccountsLoading.args = { +// accountsLoading: true, +// }; +// export const NoAccountsAvailable = Template.bind({}); +// NoAccountsAvailable.args = { +// account: undefined, +// accounts: [], +// }; +// export const ExtensionUnavailable = Template.bind({}); +// ExtensionUnavailable.args = { +// isExtensionAvailable: false, +// account: undefined, +// accounts: [], +// }; +// export const LoadingData = Template.bind({}); +// LoadingData.args = { +// extensionLoading: true, +// }; diff --git a/src/components/Wallet/Wallet.tsx b/src/components/Wallet/Wallet.tsx index c8043398..3d331f8b 100644 --- a/src/components/Wallet/Wallet.tsx +++ b/src/components/Wallet/Wallet.tsx @@ -1,6 +1,6 @@ -import { MutableRefObject, useCallback, useEffect } from 'react'; +import { Dispatch, MutableRefObject, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { FormattedBalance } from '../Balance/FormattedBalance/FormattedBalance'; -import { Account } from '../../generated/graphql'; +import { Account, Maybe } from '../../generated/graphql'; import Icon from '../Icon/Icon'; import Identicon from '@polkadot/react-identicon'; import './Wallet.scss'; @@ -21,14 +21,10 @@ export const trimAddress = (address: string, length: number) => { export interface WalletProps { modalContainerRef: MutableRefObject; - accounts?: Account[]; - accountsLoading: boolean; - account?: Account; - onAccountSelected: (account: Account) => void; - onAccountCleared: () => void; + account?: Maybe; extensionLoading: boolean; isExtensionAvailable: boolean; - setAccountSelectorOpen: (isModalOpen: boolean) => void; + onToggleAccountSelector: () => void, activeAccountLoading: boolean; faucetMint: () => void; faucetMintLoading?: boolean; @@ -36,43 +32,20 @@ export interface WalletProps { export const Wallet = ({ modalContainerRef, - accounts, - accountsLoading, account, - onAccountSelected, - onAccountCleared, extensionLoading, isExtensionAvailable, - setAccountSelectorOpen, + onToggleAccountSelector, activeAccountLoading, faucetMint, faucetMintLoading, }: WalletProps) => { - const modalPortalElement = useModalPortalElement({ - accounts, - accountsLoading, - onAccountSelected, - onAccountCleared, - account, - isExtensionAvailable, - }); - const { isModalOpen, toggleModal, modalPortal, toggleId } = useModalPortal( - modalPortalElement, - modalContainerRef, - false // don't auto close when clicking outside the modalPortalElement - ); - const handleAccountSelectorClick = useCallback(() => toggleModal(), [ - toggleModal, - ]); - - useEffect(() => { - setAccountSelectorOpen(isModalOpen); - }, [isModalOpen, setAccountSelectorOpen]); + const handleAccountSelectorClick = useMemo(() => ( + onToggleAccountSelector + ),[onToggleAccountSelector]); return (
- {/* This portal will be rendered at it's container ref as defined above */} - {modalPortal} {/*
{account ? ( @@ -91,7 +64,7 @@ export const Wallet = ({ <> )}
*/} -
+
{extensionLoading || activeAccountLoading ? (
{ + return useRef(null); +}; + +export const [BodyContainerRefProvider, useBodyContainerRefContext] = constate(useBodyContainerRef); + +export const BodyContainer = ({ children }: { children: React.ReactNode }) => { + const bodyContainerRef = useBodyContainerRefContext() + return
{children}
+}; + +// const [BodyContainerProvider, useBodyContainerContext] = constate(useBodyContainer); + +export const useMultiFeePaymentConversion = () => { + const { data: activeAccount } = useGetActiveAccountQueryContext() + const { data } = useGetConfigQuery({ + skip: !activeAccount?.activeAccount?.id + }); + + const feePaymentAsset = useMemo(() => data?.config.feePaymentAsset, [data]); + + const depsLoading = useLoading(); + const { + data: poolData, + loading: poolLoading, + networkStatus: poolNetworkStatus, + } = useGetPoolByAssetsQuery( + { + assetInId: '0', + assetOutId: feePaymentAsset || undefined, + }, + !activeAccount?.activeAccount?.id + ); + + const { math } = useMath() + + const convertToFeePaymentAsset = useCallback((txFee?: string) => { + console.log('convertToFeePaymentAsset', txFee, feePaymentAsset); + if (!txFee || poolLoading || !math) return; + if (feePaymentAsset === '0') return txFee; + + const liquidityAssetIn = poolData?.pool.balances?.find(balance => balance.assetId == '0')?.balance + const liquidityAssetOut = poolData?.pool.balances?.find(balance => balance.assetId == feePaymentAsset)?.balance + + if (!liquidityAssetIn || !liquidityAssetOut) return; + + const spotPrice = math?.xyk.get_spot_price( + liquidityAssetIn, + liquidityAssetOut, + '1000000000000' + ) + + if (!spotPrice) return; + + return new BigNumber(spotPrice) + .dividedBy( + new BigNumber(10).pow(12) + ) + .multipliedBy(txFee) + .toFixed(2) + }, [poolData, poolLoading, feePaymentAsset, math]); + + return { convertToFeePaymentAsset, feePaymentAsset } +} + +export const [MultiFeePaymentConversionProvider, useMultiFeePaymentConversionContext] = constate(useMultiFeePaymentConversion); + + export const QueryProvider = ({ children }: { children: React.ReactNode }) => ( - <>{children} + + + + <>{children} + + + ); // TODO: use react-multi-provider instead of ugly nesting export const MultiProvider = ({ children }: { children: React.ReactNode }) => { return ( - - - - - {children} - - - - + + + + + + + {children} + + + + + + ); }; diff --git a/src/containers/PageContainer.scss b/src/containers/PageContainer.scss index a95153e3..6dc8d318 100644 --- a/src/containers/PageContainer.scss +++ b/src/containers/PageContainer.scss @@ -7,12 +7,9 @@ position: relative; width: 100%; - padding: 16px; gap: 36px; - max-width: 1100px; - left: 0; right: 0; margin: auto; @@ -24,6 +21,10 @@ gap: 10px; + background: rgba(28, 26, 31, 0.2); + padding: 0 36px; + width: 100%; + &__wallet-wrapper { display: flex; align-items: center; @@ -63,6 +64,27 @@ } } } + + &__menu-wrapper { + display: flex; + flex-grow: 1; + gap: 20px; + padding-left: 24px; + &__menu-item { + a { + cursor: pointer; + font-weight: 700; + color: $l-gray2; + text-decoration: none; + &:visited { + color: $l-gray2; + } + &:hover { + color: $green1; + } + } + } + } } .footer { @@ -70,6 +92,7 @@ justify-content: center; align-items: center; flex-direction: column; + padding-bottom: 50px; color: #bdccd4; font-weight: 400; diff --git a/src/containers/PageContainer.tsx b/src/containers/PageContainer.tsx index 67ba5fd9..5686d8a4 100644 --- a/src/containers/PageContainer.tsx +++ b/src/containers/PageContainer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useLastBlockQuery } from '../hooks/lastBlock/useLastBlockQuery'; -import { Wallet } from './Wallet'; +import { Wallet } from './Wallet/Wallet'; import Icon from '../components/Icon/Icon'; import './PageContainer.scss'; import moment from 'moment'; @@ -8,6 +8,7 @@ import classNames from 'classnames'; import { NetworkStatus } from '@apollo/client'; import { horizontalBar } from '../components/Chart/ChartHeader/ChartHeader'; import { useDebugBoxContext } from '../pages/TradePage/hooks/useDebugBox'; +import { Link } from 'react-router-dom'; export const PageContainer = ({ children }: { children: React.ReactNode }) => { const { data: lastBlockData } = useLastBlockQuery(); @@ -38,6 +39,23 @@ export const PageContainer = ({ children }: { children: React.ReactNode }) => {
+
+
+ + Trade + +
+
+ + Wallet + +
+
+ + Pools + +
+
{ return ( } /> + } /> + } /> } /> ); diff --git a/src/containers/Wallet.tsx b/src/containers/Wallet.tsx deleted file mode 100644 index 692b3687..00000000 --- a/src/containers/Wallet.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Wallet as WalletComponent } from '../components/Wallet/Wallet'; -import { useCallback, useRef, useState } from 'react'; -import { useGetAccountsQuery } from '../hooks/accounts/queries/useGetAccountsQuery'; -import { useGetExtensionQuery } from '../hooks/extension/queries/useGetExtensionQuery'; -import { useSetActiveAccountMutation } from '../hooks/accounts/mutations/useSetActiveAccountMutation'; -import { useGetActiveAccountQuery } from '../hooks/accounts/queries/useGetActiveAccountQuery'; -import { Account } from '../generated/graphql'; -import { NetworkStatus } from '@apollo/client'; -import { useLoading } from '../hooks/misc/useLoading'; -import { useFaucetMintMutation } from '../hooks/faucet/mutations/useFaucetMintMutation'; - -export const Wallet = () => { - const { data: extensionData, loading: extensionLoading } = - useGetExtensionQuery(); - const [setActiveAccount] = useSetActiveAccountMutation(); - const depsLoading = useLoading(); - const { data: activeAccountData, networkStatus: activeAccountNetworkStatus } = useGetActiveAccountQuery({ - skip: depsLoading - }); - const [isAccountSelectorOpen, setAccountSelectorOpen] = useState(false); - const { data: accountsData, loading: accountsLoading, networkStatus: accountsNetworkStatus } = useGetAccountsQuery( - !(extensionData?.extension.isAvailable && isAccountSelectorOpen) || depsLoading - ); - - const modalContainerRef = useRef(null); - - const onAccountSelected = useCallback( - (account: Account) => { - setActiveAccount({ variables: { id: account.id } }); - }, - [setActiveAccount] - ); - - const onAccountCleared = useCallback(() => { - setActiveAccount({ variables: { id: undefined } }); - }, [setActiveAccount]); - - const [faucetMint, { loading: faucetMintLoading }] = useFaucetMintMutation(); - - // request data from the data layer - // render the component with the provided data - return ( - <> -
- faucetMint()} - faucetMintLoading={faucetMintLoading} - /> - - ); -}; diff --git a/src/containers/Wallet/Wallet.tsx b/src/containers/Wallet/Wallet.tsx new file mode 100644 index 00000000..9db34980 --- /dev/null +++ b/src/containers/Wallet/Wallet.tsx @@ -0,0 +1,46 @@ +import { Wallet as WalletComponent } from '../../components/Wallet/Wallet'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useGetAccountsQuery } from '../../hooks/accounts/queries/useGetAccountsQuery'; +import { useGetExtensionQuery, useGetExtensionQueryContext } from '../../hooks/extension/queries/useGetExtensionQuery'; +import { useSetActiveAccountMutation } from '../../hooks/accounts/mutations/useSetActiveAccountMutation'; +import { useGetActiveAccountQuery, useGetActiveAccountQueryContext } from '../../hooks/accounts/queries/useGetActiveAccountQuery'; +import { Account } from '../../generated/graphql'; +import { NetworkStatus } from '@apollo/client'; +import { useLoading } from '../../hooks/misc/useLoading'; +import { useFaucetMintMutation } from '../../hooks/faucet/mutations/useFaucetMintMutation'; +import { useAccountSelectorModal } from './hooks/useAccountSelectorModal'; + +export const Wallet = () => { + const { data: extensionData, loading: extensionLoading } = + useGetExtensionQueryContext(); + const [setActiveAccount] = useSetActiveAccountMutation(); + const depsLoading = useLoading(); + const { data: activeAccountData, networkStatus: activeAccountNetworkStatus } = useGetActiveAccountQueryContext(); + + const modalContainerRef = useRef(null); + + const [faucetMint, { loading: faucetMintLoading }] = useFaucetMintMutation(); + + const { modalPortal, toggleModal, isModalOpen } = useAccountSelectorModal({ + modalContainerRef + }); + + // request data from the data layer + // render the component with the provided data + return ( + <> +
+ {modalPortal} + faucetMint()} + faucetMintLoading={faucetMintLoading} + /> + + ); +}; diff --git a/src/containers/Wallet/hooks/useAccountSelectorModal.tsx b/src/containers/Wallet/hooks/useAccountSelectorModal.tsx new file mode 100644 index 00000000..d63015b2 --- /dev/null +++ b/src/containers/Wallet/hooks/useAccountSelectorModal.tsx @@ -0,0 +1,61 @@ +import { NetworkStatus } from "@apollo/client"; +import { MutableRefObject, useCallback, useEffect, useState } from "react"; +import { useModalPortal } from "../../../components/Balance/AssetBalanceInput/hooks/useModalPortal"; +import { useModalPortalElement } from "../../../components/Wallet/AccountSelector/hooks/useModalPortalElement"; +import { Account } from "../../../generated/graphql"; +import { useSetActiveAccountMutation } from "../../../hooks/accounts/mutations/useSetActiveAccountMutation"; +import { useGetAccountsLazyQuery, useGetAccountsQuery } from "../../../hooks/accounts/queries/useGetAccountsQuery"; +import { useGetActiveAccountQuery, useGetActiveAccountQueryContext } from "../../../hooks/accounts/queries/useGetActiveAccountQuery"; +import { useGetExtensionQuery, useGetExtensionQueryContext } from "../../../hooks/extension/queries/useGetExtensionQuery"; +import { useLoading } from "../../../hooks/misc/useLoading"; + +export const useAccountSelectorModal = ({ + modalContainerRef, +}: { + modalContainerRef: MutableRefObject, +}) => { + const { data: extensionData, loading: extensionLoading } = + useGetExtensionQueryContext(); + const [setActiveAccount] = useSetActiveAccountMutation(); + const depsLoading = useLoading(); + const { data: activeAccountData, networkStatus: activeAccountNetworkStatus } = + useGetActiveAccountQueryContext() + const [getAccounts, { + data: accountsData, + networkStatus: accountsNetworkStatus, + }] = useGetAccountsLazyQuery(); + + const onAccountSelected = useCallback( + (account: Account) => { + setActiveAccount({ variables: { id: account.id } }); + }, + [setActiveAccount] + ); + + const onAccountCleared = useCallback(() => { + setActiveAccount({ variables: { id: undefined } }); + }, [setActiveAccount]); + + const modalPortalElement = useModalPortalElement({ + accounts: accountsData?.accounts, + accountsLoading: accountsNetworkStatus === NetworkStatus.loading, + onAccountSelected, + onAccountCleared, + account: activeAccountData?.activeAccount, + isExtensionAvailable: !!extensionData?.extension.isAvailable, + }); + + const modal = useModalPortal( + modalPortalElement, + modalContainerRef, + // TODO: this doesnt work anyhow due to the backdrop + // being included in the outside-click detection + false // don't auto close when clicking outside the modalPortalElement + ); + + useEffect(() => { + extensionData?.extension.isAvailable && !depsLoading && modal.isModalOpen && getAccounts(); + }, [modal.isModalOpen, extensionData, depsLoading, getAccounts]) + + return modal; +}; diff --git a/src/errors.tsx b/src/errors.tsx index 9f3d0975..31848b86 100644 --- a/src/errors.tsx +++ b/src/errors.tsx @@ -7,4 +7,5 @@ export default { 'One or more arguments missing to the locked balance query', invalidTransferVariables: 'Invalid transfer parameters provided', usableBalanceNotAvailable: 'Unable to determine usable balance', + vestingScheduleIncomplete: 'Vesting schedule has at least one undefined property' }; diff --git a/src/generated/graphql.tsx b/src/generated/graphql.tsx index 99fdbec0..30c8f914 100644 --- a/src/generated/graphql.tsx +++ b/src/generated/graphql.tsx @@ -13,13 +13,19 @@ export type Scalars = { Float: number; }; -export type Account = { +export type Account = Balances & IVesting & { __typename?: 'Account'; balances: Array; genesisHash?: Maybe; id: Scalars['String']; name?: Maybe; source?: Maybe; + vesting: Vesting; +}; + + +export type AccountBalancesArgs = { + assetIds?: InputMaybe>>; }; export type Asset = { @@ -40,6 +46,20 @@ export type Balance = { id?: Maybe; }; +export type Balances = { + balances: Array; +}; + + +export type BalancesBalancesArgs = { + assetIds?: InputMaybe>>; +}; + +export enum ChromeExtension { + Polkadotjs = 'POLKADOTJS', + Talisman = 'TALISMAN' +} + export type Config = { __typename?: 'Config'; appName: Scalars['String']; @@ -50,7 +70,7 @@ export type Config = { export type Extension = { __typename?: 'Extension'; - extension?: Maybe; + extension?: Maybe; id: Scalars['String']; isAvailable: Scalars['Boolean']; }; @@ -67,6 +87,10 @@ export type FeePaymentAsset = { fallbackPrice?: Maybe; }; +export type IVesting = { + vesting?: Maybe; +}; + export type LbpAssetWeights = { __typename?: 'LBPAssetWeights'; current: Scalars['String']; @@ -103,15 +127,22 @@ export type LockedBalance = { lockId: Scalars['String']; }; -export type Pool = LbpPool | XykPool; +export type Mutation = { + __typename?: 'Mutation'; + _empty?: Maybe; + setActiveAccount?: Maybe; +}; + +export type Pool = XykPool; -export type Query = { +export type Query = Balances & IVesting & { __typename?: 'Query'; _assetIds?: Maybe; _empty?: Maybe; _tradeType?: Maybe; + _vestingSchedule?: Maybe; accounts: Array; - activeAccount: Account; + activeAccount?: Maybe; assets?: Maybe>; balances: Array; config: Config; @@ -119,9 +150,16 @@ export type Query = { feePaymentAssets?: Maybe>; lastBlock?: Maybe; lockedBalances: Array; - pools?: Maybe>; + pools: Array; + vesting?: Maybe; +}; + + +export type QueryBalancesArgs = { + assetIds?: InputMaybe>>; }; + export type QueryLockedBalancesArgs = { address?: InputMaybe; lockId: Scalars['String']; @@ -132,10 +170,27 @@ export enum TradeType { Sell = 'Sell' } +export type Vesting = { + __typename?: 'Vesting'; + claimableAmount: Scalars['String']; + lockedVestingBalance: Scalars['String']; + originalLockBalance: Scalars['String']; +}; + +export type VestingSchedule = { + __typename?: 'VestingSchedule'; + perPeriod: Scalars['String']; + period: Scalars['String']; + periodCount: Scalars['String']; + start: Scalars['String']; +}; + export type XykPool = { __typename?: 'XYKPool'; assetInId: Scalars['String']; assetOutId: Scalars['String']; balances?: Maybe>; id: Scalars['String']; + shareTokenId: Scalars['String']; + totalLiquidity: Scalars['String']; }; diff --git a/src/hooks/accounts/graphql/Accounts.graphql b/src/hooks/accounts/graphql/Accounts.graphql index fafee6d3..9b6957df 100644 --- a/src/hooks/accounts/graphql/Accounts.graphql +++ b/src/hooks/accounts/graphql/Accounts.graphql @@ -1,18 +1,18 @@ #import "./../../balances/graphql/Balance.graphql" -#import './../../vesting/graphql/VestingSchedule.graphql' +#import './../../vesting/graphql/Vesting.graphql' -type Account implements Balances { +type Account implements Balances & IVesting { id: String! name: String source: String genesisHash: String - # TODO: Can the balances query definition be re-used here? balances(assetIds: [String]): [Balance!]! + vesting: Vesting! } extend type Query { accounts: [Account!]! - activeAccount: Account! + activeAccount: Account } extend type Mutation { diff --git a/src/hooks/accounts/graphql/GetActiveAccount.query.graphql b/src/hooks/accounts/graphql/GetActiveAccount.query.graphql index 5ab05497..4a0c06c4 100644 --- a/src/hooks/accounts/graphql/GetActiveAccount.query.graphql +++ b/src/hooks/accounts/graphql/GetActiveAccount.query.graphql @@ -9,9 +9,14 @@ query GetActiveAccount { id name source - balances(assetIds: ["0"]) { + balances { assetId balance + }, + vesting { + claimableAmount, + originalLockBalance, + lockedVestingBalance } } } diff --git a/src/hooks/accounts/lib/getAccounts.test.tsx b/src/hooks/accounts/lib/getAccounts.test.tsx index 6acd942d..bc228f8e 100644 --- a/src/hooks/accounts/lib/getAccounts.test.tsx +++ b/src/hooks/accounts/lib/getAccounts.test.tsx @@ -40,7 +40,7 @@ describe('getAccounts', () => { }); it('can retrieve one account', async () => { - const accounts: Account[] = await getAccounts(); + const accounts: Partial[] = await getAccounts(); expect(accounts).toEqual([ { @@ -76,7 +76,7 @@ describe('getAccounts', () => { }); it('can retrieve multiple accounts', async () => { - const accounts: Account[] = await getAccounts(); + const accounts: Partial[] = await getAccounts(); expect(accounts).toEqual([ { @@ -104,7 +104,7 @@ describe('getAccounts', () => { }); it('returns an empty array when no accounts are returned from wallet', async () => { - const accounts: Account[] = await getAccounts(); + const accounts: Partial[] = await getAccounts(); expect(accounts).toEqual([]); expect(mockWeb3Accounts).toHaveBeenCalledTimes(1); diff --git a/src/hooks/accounts/lib/getAccounts.tsx b/src/hooks/accounts/lib/getAccounts.tsx index 28b6cdf4..d49cc38e 100644 --- a/src/hooks/accounts/lib/getAccounts.tsx +++ b/src/hooks/accounts/lib/getAccounts.tsx @@ -7,7 +7,7 @@ import { encodeAddress, decodeAddress } from '@polkadot/util-crypto'; * Used to fetch all accounts * @returns an array of accounts in required format */ -export const getAccounts = async (): Promise => { +export const getAccounts = async (): Promise[]> => { // ensure we're connected to the polkadot.js extension await web3Enable(constants.basiliskWeb3ProviderName); @@ -15,8 +15,6 @@ export const getAccounts = async (): Promise => { // return all retrieved accounts const accounts = await web3Accounts(); - console.log('accounts', accounts); - // transform the returned accounts into the required entity format return accounts.map((account) => { return { @@ -24,7 +22,6 @@ export const getAccounts = async (): Promise => { name: account.meta.name, source: account.meta.source, genesisHash: account.meta.genesisHash || null, - balances: [], }; }); }; diff --git a/src/hooks/accounts/queries/useGetAccountsQuery.tsx b/src/hooks/accounts/queries/useGetAccountsQuery.tsx index 6974cc26..5c439bf1 100644 --- a/src/hooks/accounts/queries/useGetAccountsQuery.tsx +++ b/src/hooks/accounts/queries/useGetAccountsQuery.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@apollo/client'; +import { useLazyQuery, useQuery } from '@apollo/client'; import { Query } from '../../../generated/graphql'; import { loader } from 'graphql.macro'; @@ -13,3 +13,9 @@ export const useGetAccountsQuery = (skip: boolean = false) => notifyOnNetworkStatusChange: true, skip: skip, }); + +export const useGetAccountsLazyQuery = () => + useLazyQuery(GET_ACCOUNTS, { + notifyOnNetworkStatusChange: true, + fetchPolicy: 'cache-only' + }); diff --git a/src/hooks/accounts/queries/useGetActiveAccountQuery.tsx b/src/hooks/accounts/queries/useGetActiveAccountQuery.tsx index 51db4f1f..007e5a10 100644 --- a/src/hooks/accounts/queries/useGetActiveAccountQuery.tsx +++ b/src/hooks/accounts/queries/useGetActiveAccountQuery.tsx @@ -1,6 +1,9 @@ import { QueryHookOptions, useQuery } from '@apollo/client'; +import constate from 'constate'; import { loader } from 'graphql.macro'; -import { Query } from '../../../generated/graphql'; +import { Query, Vesting } from '../../../generated/graphql'; +import { useGetExtensionQuery, useGetExtensionQueryContext } from '../../extension/queries/useGetExtensionQuery'; +import { useLoading } from '../../misc/useLoading'; // graphql query export const GET_ACTIVE_ACCOUNT = loader( @@ -9,7 +12,7 @@ export const GET_ACTIVE_ACCOUNT = loader( // data shape returned from the query export interface GetActiveAccountQueryResponse { - activeAccount: Query['activeAccount']; + activeAccount: Query['activeAccount'] } // hook wrapping the built-in apollo useQuery hook with proper types & configuration @@ -18,3 +21,12 @@ export const useGetActiveAccountQuery = (options?: QueryHookOptions) => notifyOnNetworkStatusChange: true, ...options }); + + +export const [GetActiveAccountQueryProvider, useGetActiveAccountQueryContext] = constate(() => { + const depsLoading = useLoading(); + const { loading: extensionLoading } = useGetExtensionQueryContext(); + return useGetActiveAccountQuery({ + skip: depsLoading || extensionLoading, + }) +}); \ No newline at end of file diff --git a/src/hooks/accounts/resolvers/query/accounts.tsx b/src/hooks/accounts/resolvers/query/accounts.tsx index 2b7ae87a..5a648889 100644 --- a/src/hooks/accounts/resolvers/query/accounts.tsx +++ b/src/hooks/accounts/resolvers/query/accounts.tsx @@ -9,8 +9,6 @@ export const useAccountsQueryResolver = () => { useCallback(async (_obj) => { const accounts = await getAccounts(); - console.log('got accounts', accounts); - // if no results were found, return undefined/null // this is useful when un-setting the active account if (!accounts) { diff --git a/src/hooks/accounts/resolvers/query/activeAccount.tsx b/src/hooks/accounts/resolvers/query/activeAccount.tsx index f53c1cdc..733a152c 100644 --- a/src/hooks/accounts/resolvers/query/activeAccount.tsx +++ b/src/hooks/accounts/resolvers/query/activeAccount.tsx @@ -10,6 +10,7 @@ import { withErrorHandler } from '../../../apollo/withErrorHandler'; import { withTypename } from '../../types'; import { Account } from '../../../../generated/graphql'; +// TODO: turn the active account into a cache ref to Account export const activeAccountQueryResolverFactory = (persistedActiveAccount?: PersistedAccount) => /** @@ -26,7 +27,7 @@ export const activeAccountQueryResolverFactory = _obj: any, _args: any, { client }: { client: ApolloClient } - ): Promise => { + ): Promise | null> => { if (persistedActiveAccount?.id) { const { data: accountsData } = await client.query({ query: GET_ACCOUNTS, diff --git a/src/hooks/accounts/types.tsx b/src/hooks/accounts/types.tsx index 56bf615e..58d1de37 100644 --- a/src/hooks/accounts/types.tsx +++ b/src/hooks/accounts/types.tsx @@ -4,7 +4,7 @@ import { Account } from '../../generated/graphql'; const __typename: Account['__typename'] = 'Account'; // helper function to decorate the extension entity for normalised caching -export const withTypename = (account: Account) => ({ +export const withTypename = (account: Partial) => ({ __typename, ...account, }); diff --git a/src/hooks/actionLog/useIntentions.tsx b/src/hooks/actionLog/useIntentions.tsx deleted file mode 100644 index 83efa12b..00000000 --- a/src/hooks/actionLog/useIntentions.tsx +++ /dev/null @@ -1 +0,0 @@ -export const useIntentions = () => {}; \ No newline at end of file diff --git a/src/hooks/actionLog/useWithConfirmation.tsx b/src/hooks/actionLog/useWithConfirmation.tsx new file mode 100644 index 00000000..0d0a5e78 --- /dev/null +++ b/src/hooks/actionLog/useWithConfirmation.tsx @@ -0,0 +1,54 @@ +import { MutationTuple } from '@apollo/client'; +import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'; +import { ModalPortalElementFactoryArgs, useModalPortal } from '../../components/Balance/AssetBalanceInput/hooks/useModalPortal'; +import { Confirmation } from '../../components/Confirmation/Confirmation'; +import { useBodyContainerRefContext } from '../../containers/MultiProvider'; + +export enum ConfirmationType { + Trade +} + +export const useWithConfirmation = < + TData extends unknown, + TVariables extends unknown +>( + mutationTuple: MutationTuple, + confirmationType: ConfirmationType +): { + mutation: MutationTuple; + confirmationScreen: ReactNode; +} => { + const [submit] = mutationTuple; + // TODO: figure out a way to type this properly + const [options, setOptions] = useState(); + const bodyContainerRef = useBodyContainerRefContext(); + + const { openModal, closeModal, modalPortal, status } = useModalPortal( + useCallback((args: ModalPortalElementFactoryArgs) => { + console.log('options', options); + return + }, [options, confirmationType]), + bodyContainerRef + ); + + useEffect(() => { + status === 'success' && submit(options) + }, [status, submit, options]); + + const submitWithConfirmation = useCallback( + async (options: Parameters[0]) => { + openModal(); + setOptions(options); + }, + [] + ); + + return { + mutation: [submitWithConfirmation as any, mutationTuple[1]], + confirmationScreen: modalPortal, + }; +}; diff --git a/src/hooks/apollo/useApollo.tsx b/src/hooks/apollo/useApollo.tsx index bcf4a9a4..5d380900 100644 --- a/src/hooks/apollo/useApollo.tsx +++ b/src/hooks/apollo/useApollo.tsx @@ -13,6 +13,9 @@ import { usePoolsMutationResolvers } from '../pools/resolvers/usePoolsMutationRe import { useExtensionResolvers } from '../extension/resolvers/useExtensionResolvers'; import { usePersistentConfig } from '../config/usePersistentConfig'; import { useFaucetResolvers } from '../faucet/resolvers/useFaucetResolvers'; +import { useVestingQueryResolvers } from '../vesting/useVestingQueryResolvers'; +import { useBalanceMutationsResolvers } from '../balances/resolvers/mutation/balanceTransfer'; +import { useConfigQueryResolvers } from '../config/useConfigQueryResolvers'; /** * Add all local gql resolvers here @@ -35,18 +38,21 @@ export const useResolvers: () => Resolvers = () => { ...useBalanceQueryResolvers(), ...PoolsQueryResolver, ...useAssetsQueryResolvers(), + ...useConfigQueryResolvers(), }, Mutation: { ...AccountsMutationResolvers, ...useVestingMutationResolvers(), ...useConfigMutationResolvers(), ...usePoolsMutationResolvers(), - ...useFaucetResolvers().Mutation + ...useFaucetResolvers().Mutation, + ...useBalanceMutationsResolvers() }, XYKPool, LBPPool, Account: { - ...useBalanceQueryResolvers() + ...useBalanceQueryResolvers(), + ...useVestingQueryResolvers() } }; }; diff --git a/src/hooks/balances/graphql/TransferBalance.mutation.graphql b/src/hooks/balances/graphql/TransferBalance.mutation.graphql index 8bb3d14c..f047254f 100644 --- a/src/hooks/balances/graphql/TransferBalance.mutation.graphql +++ b/src/hooks/balances/graphql/TransferBalance.mutation.graphql @@ -1,3 +1,3 @@ -mutation TransferBalance($from: String!, $to: String!, $currencyId: String!, $amount: String) { - transferBalance(from: $from, to: $to, currencyId: $currencyId, amount: $amount) @client +mutation TransferBalance($to: String!, $currencyId: String!, $amount: String) { + transferBalance(to: $to, currencyId: $currencyId, amount: $amount) @client } \ No newline at end of file diff --git a/src/hooks/balances/resolvers/mutation/balanceTransfer.tsx b/src/hooks/balances/resolvers/mutation/balanceTransfer.tsx index e0ee7b81..40dcf585 100644 --- a/src/hooks/balances/resolvers/mutation/balanceTransfer.tsx +++ b/src/hooks/balances/resolvers/mutation/balanceTransfer.tsx @@ -1,20 +1,23 @@ import { ApiPromise } from '@polkadot/api'; import { usePolkadotJsContext } from '../../../polkadotJs/usePolkadotJs'; import errors from '../../../../errors'; -import { useMutation } from '@apollo/client'; +import { ApolloCache, useMutation } from '@apollo/client'; import { loader } from 'graphql.macro'; import { withGracefulErrors, gracefulExtensionCancelationErrorHandler as gracefulExtensionCancellationErrorHandler, + vestingClaimHandler, } from '../../../vesting/useVestingMutationResolvers'; import { web3FromAddress } from '@polkadot/extension-dapp'; import { DispatchError, ExtrinsicStatus } from '@polkadot/types/interfaces'; import log from 'loglevel'; import { withErrorHandler } from '../../../apollo/withErrorHandler'; import { useMemo } from 'react'; +import { readActiveAccount } from '../../../accounts/lib/readActiveAccount'; +import { add } from 'lodash'; export const TRANSFER_BALANCE = loader( - './graphql/TransferBalance.mutation.graphql' + './../../graphql/TransferBalance.mutation.graphql' ); export interface TransferBalanceMutationVariables { @@ -36,63 +39,61 @@ export type reject = (error?: any) => void; // TODO: use handler from #71 export const transferBalanceHandler = - (apiInstance: ApiPromise, resolve: resolve, reject: reject) => - ({ - status, - dispatchError, - }: { - status: ExtrinsicStatus; - dispatchError?: DispatchError; - }) => { - if (status.isFinalized) log.info('operation finalized'); + (apiInstance: ApiPromise, resolve: resolve, reject: reject) => { + return vestingClaimHandler(resolve, reject, apiInstance); + } - // TODO: handle status via the action log / notification stack - if (status.isInBlock) { - if (dispatchError?.isModule) { - return log.error( - 'transfer unsuccessful', - apiInstance.registry.findMetaError(dispatchError.asModule) - ); - } +const transferBalanceExtrinsic = (apiInstance: ApiPromise) => + apiInstance.tx.currencies.transfer; - return log.info('transfer successful'); - } +export const estimateBalanceTransfer = async ( + cache: ApolloCache, + apiInstance: ApiPromise, + args: TransferBalanceMutationVariables +) => { + const activeAccount = readActiveAccount(cache); + const address = activeAccount?.id; - // if the operation has been broadcast, finish the mutation - if (status.isBroadcast) { - log.info('transaction has been broadcast'); - return resolve(); - } - if (dispatchError) { - log.error( - 'There was a dispatch error', - apiInstance.registry.findMetaError(dispatchError.asModule) - ); - return reject(); - } - }; + if (!address) + throw new Error(`Can't retrieve sender's address for estimation`); + if (!args.from || !args.to || !args.currencyId || !args.amount) + throw new Error(errors.invalidTransferVariables); + + return transferBalanceExtrinsic(apiInstance) + .apply(apiInstance, [args.to, args.currencyId, args.amount]) + .paymentInfo(address); +}; const balanceTransferMutationResolverFactory = (apiInstance?: ApiPromise) => - async (_obj: any, args: TransferBalanceMutationVariables) => { - if (!args.from || !args.to || !args.currencyId || !args.amount) + async (_obj: any, args: TransferBalanceMutationVariables, { cache }: { cache: ApolloCache }) => { + if (!args.to || !args.currencyId || !args.amount) throw new Error(errors.invalidTransferVariables); if (!apiInstance) throw new Error(errors.apiInstanceNotInitialized); - return withGracefulErrors( - async (resolve, reject) => { - const { signer } = await web3FromAddress(args.from!); + // return withGracefulErrors( + // , + // [gracefulExtensionCancellationErrorHandler] + // ); + + await new Promise(async (resolve, reject) => { + try { + const activeAccount = readActiveAccount(cache); + const address = activeAccount?.id; + if (!address) return reject(new Error('No active account found!')); + const { signer } = await web3FromAddress(address); - await apiInstance.tx.currencies.transfer + await transferBalanceExtrinsic(apiInstance) .apply(apiInstance, [args.to, args.currencyId, args.amount]) .signAndSend( - args.from!, + address, { signer }, transferBalanceHandler(apiInstance, resolve, reject) ); - }, - [gracefulExtensionCancellationErrorHandler] - ); + } catch (e) { + reject(e) + } + }) }; export const useBalanceMutationsResolvers = () => { diff --git a/src/hooks/balances/resolvers/useBalanceMutationResolvers.tsx b/src/hooks/balances/resolvers/useBalanceMutationResolvers.tsx index 0017bc3b..eeec46ac 100644 --- a/src/hooks/balances/resolvers/useBalanceMutationResolvers.tsx +++ b/src/hooks/balances/resolvers/useBalanceMutationResolvers.tsx @@ -54,7 +54,7 @@ export const balanceMutationResolverFactory = (apiInstance?: ApiPromise) => async (_obj: any, args: TransferBalanceMutationVariables) => { if (!apiInstance) throw Error(errors.apiInstanceNotInitialized); - if (!args.from || !args.to || !args.currencyId || !args.amount) + if (!args.to || !args.currencyId || !args.amount) throw new Error(errors.invalidTransferVariables); return withGracefulErrors( diff --git a/src/hooks/balances/resolvers/useTransferMutation.tsx b/src/hooks/balances/resolvers/useTransferMutation.tsx index 45221a7d..49abe490 100644 --- a/src/hooks/balances/resolvers/useTransferMutation.tsx +++ b/src/hooks/balances/resolvers/useTransferMutation.tsx @@ -1,20 +1,15 @@ -import { useMutation } from '@apollo/client'; +import { MutationHookOptions, useMutation } from '@apollo/client'; import { loader } from 'graphql.macro'; export const TRANSFER_BALANCE = loader( - './graphql/TransferBalance.mutation.graphql' + './../graphql/TransferBalance.mutation.graphql' ); export interface TransferBalanceMutationVariables { - from?: string; to?: string; currencyId?: string; amount?: string; } -export const useTransferBalanceMutation = ( - variables: TransferBalanceMutationVariables -) => - useMutation(TRANSFER_BALANCE, { - variables, - }); +export const useTransferBalanceMutation = (options?: MutationHookOptions) => + useMutation(TRANSFER_BALANCE, options); diff --git a/src/hooks/config/useConfigMutationResolver.tsx b/src/hooks/config/useConfigMutationResolver.tsx index 1ca90f70..2bf8fb10 100644 --- a/src/hooks/config/useConfigMutationResolver.tsx +++ b/src/hooks/config/useConfigMutationResolver.tsx @@ -16,6 +16,7 @@ import { } from '../vesting/useVestingMutationResolvers'; import { defaultConfigValue, usePersistentConfig } from './usePersistentConfig'; import { SetConfigMutationVariables } from './useSetConfigMutation'; +import { xykBuyHandler } from '../pools/xyk/buy'; export const defaultAssetId = '0'; @@ -38,13 +39,14 @@ export const useConfigMutationResolvers = () => { if (!apiInstance || loading) return; // TODO: return an optimistic update to the cache with the new config - await withGracefulErrors( - async (resolve, reject) => { - const address = cache.readQuery({ - query: GET_ACTIVE_ACCOUNT, - })?.activeAccount?.id; + // await withGracefulErrors( + await new Promise(async (resolve, reject) => { + const address = cache.readQuery({ + query: GET_ACTIVE_ACCOUNT, + })?.activeAccount?.id; - if (!address) return resolve(); + try { + if (!address) return reject(); const { signer } = await web3FromAddress(address); @@ -53,18 +55,22 @@ export const useConfigMutationResolvers = () => { .signAndSend( address, { signer }, - setCurrencyHandler(resolve, reject) + xykBuyHandler(resolve, reject, apiInstance) ); - }, - [gracefulExtensionCancelationErrorHandler] - ); + } catch (e) { + reject(e) + } + }) + // [gracefulExtensionCancelationErrorHandler] + // [] + // ); const persistableConfig = args.config; // there's no point in persisting the feePaymentAsset since it will // be refetched from the node anyways delete persistableConfig?.feePaymentAsset; - setPersistedConfig(persistableConfig || defaultConfigValue); + // setPersistedConfig(persistableConfig || defaultConfigValue); }, [apiInstance, loading, setPersistedConfig] ) diff --git a/src/hooks/config/useConfigQueryResolvers.tsx b/src/hooks/config/useConfigQueryResolvers.tsx index 514000aa..a0f89f11 100644 --- a/src/hooks/config/useConfigQueryResolvers.tsx +++ b/src/hooks/config/useConfigQueryResolvers.tsx @@ -26,6 +26,7 @@ export const useConfigQueryResolvers = () => { _variables, { cache }: { cache: ApolloCache } ) => { + console.log('config query resolver') if (!apiInstance || loading) return; // TODO: evict config from the cache after active account changes @@ -33,6 +34,8 @@ export const useConfigQueryResolvers = () => { query: GET_ACTIVE_ACCOUNT, })?.activeAccount?.id; + if (!address) return; + let feePaymentAsset = address ? apiInstance .createType( @@ -44,6 +47,8 @@ export const useConfigQueryResolvers = () => { ?.toHuman() : null; + console.log('found fee payment asset', feePaymentAsset); + feePaymentAsset = feePaymentAsset ? feePaymentAsset : nativeAssetId; return { diff --git a/src/hooks/config/useGetConfigQuery.tsx b/src/hooks/config/useGetConfigQuery.tsx index f15f6430..de126c08 100644 --- a/src/hooks/config/useGetConfigQuery.tsx +++ b/src/hooks/config/useGetConfigQuery.tsx @@ -1,4 +1,4 @@ -import { useQuery } from '@apollo/client'; +import { QueryHookOptions, useQuery } from '@apollo/client'; import { loader } from 'graphql.macro'; import { Query } from '../../generated/graphql'; @@ -8,6 +8,7 @@ export interface GetConfigQueryResponse { config: Query['config'] } -export const useGetConfigQuery = () => useQuery(GET_CONFIG, { - notifyOnNetworkStatusChange: true +export const useGetConfigQuery = (options?: QueryHookOptions) => useQuery(GET_CONFIG, { + notifyOnNetworkStatusChange: true, + ...options }); \ No newline at end of file diff --git a/src/hooks/config/useSetConfigMutation.tsx b/src/hooks/config/useSetConfigMutation.tsx index c265bfb3..c54298ee 100644 --- a/src/hooks/config/useSetConfigMutation.tsx +++ b/src/hooks/config/useSetConfigMutation.tsx @@ -6,7 +6,7 @@ import { GET_CONFIG } from './useGetConfigQuery'; export const SET_CONFIG = loader('./graphql/SetConfig.mutation.graphql'); export interface SetConfigMutationVariables { - config: Config | undefined + config: Partial | undefined } export const useSetConfigMutation = (onCompleted?: () => void) => useMutation(SET_CONFIG, { diff --git a/src/hooks/extension/lib/getExtension.tsx b/src/hooks/extension/lib/getExtension.tsx index 0585f9ca..d74abbbb 100644 --- a/src/hooks/extension/lib/getExtension.tsx +++ b/src/hooks/extension/lib/getExtension.tsx @@ -12,9 +12,7 @@ export const getExtension = async (): Promise => { const { isAvailable }: Pick = await new Promise( (resolve, reject) => { promiseRetry(async (retry, attempt) => { - console.log('attempt', attempt); const isAvailable = !!(window as any).injectedWeb3?.['polkadot-js']; - console.log('getExtension attempt: #', attempt, isAvailable); isAvailable ? ( resolve({ diff --git a/src/hooks/extension/queries/useGetExtensionQuery.tsx b/src/hooks/extension/queries/useGetExtensionQuery.tsx index 462cec50..69f35f9b 100644 --- a/src/hooks/extension/queries/useGetExtensionQuery.tsx +++ b/src/hooks/extension/queries/useGetExtensionQuery.tsx @@ -1,4 +1,5 @@ import { QueryHookOptions, useQuery } from '@apollo/client'; +import constate from 'constate'; import { loader } from 'graphql.macro'; import { Extension } from '../../../generated/graphql'; @@ -11,8 +12,9 @@ export interface GetExtensionQueryResponse { } // hook wrapping the built-in apollo useQuery hook with proper types & configuration -export const useGetExtensionQuery = (options?: QueryHookOptions) => +export const useGetExtensionQuery = () => useQuery(GET_EXTENSION, { notifyOnNetworkStatusChange: true, - ...options, }); + +export const [GetExtensionQueryProvider, useGetExtensionQueryContext] = constate(useGetExtensionQuery) diff --git a/src/hooks/math/useMath.tsx b/src/hooks/math/useMath.tsx index 2c1ea61e..7d78b790 100644 --- a/src/hooks/math/useMath.tsx +++ b/src/hooks/math/useMath.tsx @@ -6,6 +6,10 @@ export interface HydraDxMathXyk { get_spot_price: (a: string, b: string, c: string) => string | undefined, calculate_in_given_out: (a: string, b: string, c: string) => string | undefined, calculate_out_given_in: (a: string, b: string, c: string) => string | undefined + calculate_liquidity_in: (a: string, b: string, c: string) => string | undefined, + calculate_liquidity_out_asset_a: (a: string, b: string, c: string, d: string) => string | undefined, + calculate_liquidity_out_asset_b: (a: string, b: string, c: string, d: string) => string | undefined, + calculate_shares: (a: string, b: string, c: string) => string | undefined; } export interface HydraDxMathLbp { diff --git a/src/hooks/misc/useLoading.tsx b/src/hooks/misc/useLoading.tsx index 71cd5bbf..b61b55e8 100644 --- a/src/hooks/misc/useLoading.tsx +++ b/src/hooks/misc/useLoading.tsx @@ -1,8 +1,20 @@ +import { useEffect, useState } from 'react'; +import { useLastBlockContext } from '../lastBlock/useSubscribeNewBlockNumber'; import { useMathContext } from '../math/useMath'; import { usePolkadotJsContext } from '../polkadotJs/usePolkadotJs'; export const useLoading = () => { - const { loading } = usePolkadotJsContext(); + const { loading: polkadotJsLoading } = usePolkadotJsContext(); const { math } = useMathContext(); - return loading || !math; + const lastBlock = useLastBlockContext() + + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!polkadotJsLoading && math && lastBlock) { + setLoading(false); + } + }, [polkadotJsLoading, math, lastBlock]); + + return loading; }; diff --git a/src/hooks/polkadotJs/signAndSend.test.tsx b/src/hooks/polkadotJs/signAndSend.test.tsx new file mode 100644 index 00000000..360248cc --- /dev/null +++ b/src/hooks/polkadotJs/signAndSend.test.tsx @@ -0,0 +1,127 @@ +import { ApiPromise } from '@polkadot/api'; +import { signAndSend } from './signAndSend'; +import { web3FromAddress } from '@polkadot/extension-dapp'; +import { InMemoryCache } from '@apollo/client'; +import { SubmittableExtrinsic } from '@polkadot/api/types'; +import { ISubmittableResult } from '@polkadot/types/types'; +import { readActiveAccount } from '../accounts/lib/readActiveAccount'; + +const web3FromAddressMocked = web3FromAddress as jest.Mock; +const readActiveAccountMocked = readActiveAccount as jest.Mock; + +jest.mock('@polkadot/extension-dapp', () => { + return { web3FromAddress: jest.fn() }; +}); +jest.mock('../accounts/readActiveAccount', () => { + return { readActiveAccount: jest.fn() }; +}); + +const extrinsicFailedIsMock = jest.fn(); +const findMetaErrorMock = jest.fn(); + +export const getMockApiPromise = (): jest.Mocked => + ({ + events: { system: { ExtrinsicFailed: { is: extrinsicFailedIsMock } } }, + registry: { findMetaError: findMetaErrorMock }, + } as unknown as jest.Mocked); + +describe('signAndSend', () => { + let mockApiInstance: jest.Mocked; + let apolloCache = new InMemoryCache(); + let transactionSignAndSendMock = jest.fn(); + let transaction = { + signAndSend: transactionSignAndSendMock, + } as unknown as SubmittableExtrinsic<'promise', ISubmittableResult>; + let signer = {}; + let unsubscribe = jest.fn(); + let address = { + id: 'address-id', + }; + + const setupTransactionSignAndSendMock = (data: object) => { + transactionSignAndSendMock.mockImplementation( + async (_addressId, _signer, callback) => { + setTimeout(() => callback(data), 0); + + return unsubscribe; + } + ); + }; + + beforeEach(() => { + jest.resetAllMocks(); + mockApiInstance = getMockApiPromise(); + findMetaErrorMock.mockImplementation((arg) => arg); + web3FromAddressMocked.mockResolvedValue({ signer }); + readActiveAccountMocked.mockReturnValue(address); + }); + + it('throws error if no active account is selected', async () => { + readActiveAccountMocked.mockReturnValue(null); + + await expect( + signAndSend(apolloCache, transaction, mockApiInstance) + ).rejects.toThrow(); + }); + + it('resolves if there are no errors in signAndSend', async () => { + setupTransactionSignAndSendMock({ + status: { isInBlock: true }, + events: [], + }); + + await expect( + signAndSend(apolloCache, transaction, mockApiInstance) + ).resolves.toBeNull(); + expect(web3FromAddressMocked).toBeCalledTimes(1); + expect(web3FromAddressMocked).toBeCalledWith(address.id); + expect(unsubscribe).toBeCalledTimes(1); + }); + + describe('extrinsic errors', () => { + beforeEach(() => { + extrinsicFailedIsMock.mockReturnValue(true); + }); + + it('rejects with catalog meta error', async () => { + const mockedError = { + error: 'mocked-error', + isModule: true, + asModule: { + docs: ['mocked', 'docs'], + method: 'mocked-method', + section: 'mocked-section', + }, + }; + setupTransactionSignAndSendMock({ + status: { isInBlock: true }, + events: [{ event: { data: [mockedError] } }], + }); + + await expect( + signAndSend(apolloCache, transaction, mockApiInstance) + ).rejects.toEqual({ + errors: expect.arrayContaining([mockedError.asModule]), + }); + expect(unsubscribe).toBeCalledTimes(1); + }); + + it('rejects with other error', async () => { + const mockedError = { + error: 'mocked-error', + isModule: false, + }; + setupTransactionSignAndSendMock({ + status: { isInBlock: true }, + events: [{ event: { data: [mockedError] } }], + }); + + await expect( + signAndSend(apolloCache, transaction, mockApiInstance) + ).rejects.toEqual({ + errors: expect.arrayContaining([mockedError]), + }); + expect(unsubscribe).toBeCalledTimes(1); + }); + }); +}); diff --git a/src/hooks/polkadotJs/signAndSend.tsx b/src/hooks/polkadotJs/signAndSend.tsx new file mode 100644 index 00000000..2f13bda9 --- /dev/null +++ b/src/hooks/polkadotJs/signAndSend.tsx @@ -0,0 +1,76 @@ +import { ApolloCache } from '@apollo/client'; +import { ApiPromise } from '@polkadot/api'; +import { SubmittableExtrinsic } from '@polkadot/api/types'; +import { web3FromAddress } from '@polkadot/extension-dapp'; +import { DispatchError, EventRecord } from '@polkadot/types/interfaces'; +import { + Callback, + ISubmittableResult, + RegistryError, +} from '@polkadot/types/types'; +import { readActiveAccount } from '../accounts/lib/readActiveAccount'; + +export type ExtrinsicErrors = RegistryError | DispatchError; + +export const parseExtrinsicErrors = ( + events: EventRecord[], + apiInstance: ApiPromise +): ExtrinsicErrors[] => + events + .filter(({ event }) => apiInstance.events.system.ExtrinsicFailed.is(event)) + // we know that data for system.ExtrinsicFailed is + // (DispatchError, DispatchInfo) + .reduce((acc: ExtrinsicErrors[], { event: { data } }) => { + const error: DispatchError = data[0] as DispatchError; + if (error.isModule) { + // for module errors, we have the section indexed, lookup + const decoded = apiInstance.registry.findMetaError(error.asModule); + acc.push(decoded); + } else { + // Other, CannotLookup, BadOrigin, no extra info + acc.push(error); + } + + return acc; + }, []); + +export const signAndSend = async ( + cache: ApolloCache, + transaction: SubmittableExtrinsic<'promise', ISubmittableResult>, + apiInstance: ApiPromise +) => { + const address = readActiveAccount(cache); + // if for some reason the UI tries to send a transaction, and there is no active account selected + if (!address) { + throw new Error('No active account found'); + } + const { signer } = await web3FromAddress(address.id); + + return new Promise(async (resolve, reject) => { + const statusHandler: Callback = ({ + status, + events, + }) => { + if (!status.isInBlock) { + return; + } + const errors = parseExtrinsicErrors(events, apiInstance); + + if (errors.length > 0) { + reject({ errors }); + } else { + resolve(null); + } + + if (unsub) { + unsub(); + } + }; + + const unsub = await transaction.signAndSend( + address.id, + { signer }, + statusHandler + ); + }); +}; diff --git a/src/hooks/polkadotJs/usePolkadotJs.tsx b/src/hooks/polkadotJs/usePolkadotJs.tsx index 801866d0..4269813b 100644 --- a/src/hooks/polkadotJs/usePolkadotJs.tsx +++ b/src/hooks/polkadotJs/usePolkadotJs.tsx @@ -25,7 +25,7 @@ const getPoolAccount = { ], type: 'AccountId', }; -const rpc = { +export const rpc = { xyk: { getPoolAccount, }, @@ -34,12 +34,12 @@ const rpc = { }, }; -const types = { +export const types = { ...typesConfig.types[0], ...ormlTypes, }; -const typesAlias = { +export const typesAlias = { ...typesConfig.alias, ...ormlTypesAlias, }; diff --git a/src/hooks/pools/graphql/AddLiquidity.mutation.graphql b/src/hooks/pools/graphql/AddLiquidity.mutation.graphql new file mode 100644 index 00000000..2c481705 --- /dev/null +++ b/src/hooks/pools/graphql/AddLiquidity.mutation.graphql @@ -0,0 +1,13 @@ +mutation AddLiquidity( + $assetA: String!, + $assetB: String!, + $amountA: String!, + $amountBMaxLimit: String! +) { + addLiquidity( + assetA: $assetA, + assetB: $assetB, + amountA: $amountA, + amountBMaxLimit: $amountBMaxLimit + ) @client +} \ No newline at end of file diff --git a/src/hooks/pools/graphql/GetPoolByAssets.query.graphql b/src/hooks/pools/graphql/GetPoolByAssets.query.graphql index 5fd5b6c7..20df8aa6 100644 --- a/src/hooks/pools/graphql/GetPoolByAssets.query.graphql +++ b/src/hooks/pools/graphql/GetPoolByAssets.query.graphql @@ -12,6 +12,8 @@ query GetPoolByAssets($assetInId: String!, $assetOutId: String!) { assetId, balance }, + shareTokenId, + totalLiquidity # TODO: investigate how caching works when these fields are missing for XYK pools # lbp fields, diff --git a/src/hooks/pools/graphql/Pool.graphql b/src/hooks/pools/graphql/Pool.graphql index 71e7cb97..89f5f1bf 100644 --- a/src/hooks/pools/graphql/Pool.graphql +++ b/src/hooks/pools/graphql/Pool.graphql @@ -40,9 +40,11 @@ type XYKPool { assetInId: String! assetOutId: String! balances: [Balance!] + totalLiquidity: String!, + shareTokenId: String! } -union Pool = LBPPool | XYKPool +union Pool = XYKPool | LBPPool extend type Query { pools: [Pool!]! diff --git a/src/hooks/pools/graphql/RemoveLiquidity.mutation.graphql b/src/hooks/pools/graphql/RemoveLiquidity.mutation.graphql new file mode 100644 index 00000000..26ff0de9 --- /dev/null +++ b/src/hooks/pools/graphql/RemoveLiquidity.mutation.graphql @@ -0,0 +1,11 @@ +mutation RemoveLiquidity( + $assetA: String!, + $assetB: String!, + $amount: String! +) { + removeLiquidity( + assetA: $assetA, + assetB: $assetB, + amount: $amount + ) @client +} \ No newline at end of file diff --git a/src/hooks/pools/lbp/calculateInGivenOut.tsx b/src/hooks/pools/lbp/calculateInGivenOut.tsx index 43159f0c..027846d4 100644 --- a/src/hooks/pools/lbp/calculateInGivenOut.tsx +++ b/src/hooks/pools/lbp/calculateInGivenOut.tsx @@ -1,27 +1,28 @@ import { find } from 'lodash'; -import { LbpPool, Pool } from '../../../generated/graphql'; +// import { LbpPool, Pool } from '../../../generated/graphql'; import { HydraDxMath } from '../../math/useMath'; +import { Pool } from '../../../generated/graphql'; -/** - * Wrapper for `math.lbp.calculate_in_given_out` - * @param math - * @param inReserve - * @param outReserve - * @param inWeight - * @param outWeight - * @param amount - * @returns - */ -export const calculateInGivenOut = ( - math: HydraDxMath, - inReserve: string, - outReserve: string, - inWeight: string, - outWeight: string, - amount: string -) => { - return math.lbp.calculate_in_given_out(inReserve, outReserve, inWeight, outWeight, amount); -} +// /** +// * Wrapper for `math.lbp.calculate_in_given_out` +// * @param math +// * @param inReserve +// * @param outReserve +// * @param inWeight +// * @param outWeight +// * @param amount +// * @returns +// */ +// export const calculateInGivenOut = ( +// math: HydraDxMath, +// inReserve: string, +// outReserve: string, +// inWeight: string, +// outWeight: string, +// amount: string +// ) => { +// return math.lbp.calculate_in_given_out(inReserve, outReserve, inWeight, outWeight, amount); +// } export const getPoolBalances = (pool: Pool, assetInId: string, assetOutId: string) => { const assetABalance = find(pool.balances, { assetId: assetInId })?.balance; @@ -30,41 +31,41 @@ export const getPoolBalances = (pool: Pool, assetInId: string, assetOutId: strin return { assetABalance, assetBBalance } } -export const getInAndOutWeights = (pool: LbpPool, assetInId: string, assetOutId: string) => { - const assetInWeight = assetInId === pool.assetInId - ? pool.assetAWeights.current - : pool.assetBWeights.current +// export const getInAndOutWeights = (pool: LbpPool, assetInId: string, assetOutId: string) => { +// const assetInWeight = assetInId === pool.assetInId +// ? pool.assetAWeights.current +// : pool.assetBWeights.current - const assetOutWeight = assetOutId === pool.assetOutId - ? pool.assetBWeights.current - : pool.assetAWeights.current; +// const assetOutWeight = assetOutId === pool.assetOutId +// ? pool.assetBWeights.current +// : pool.assetAWeights.current; - return { assetInWeight, assetOutWeight }; -} +// return { assetInWeight, assetOutWeight }; +// } -export const calculateInGivenOutFromPool = ( - math: HydraDxMath, - pool: LbpPool, - assetInId: string, - assetOutId: string, - amountOut: string, -) => { - const { assetABalance: assetInBalance, assetBBalance: assetOutBalance } = getPoolBalances( - pool, - assetInId, - assetOutId, - ) +// export const calculateInGivenOutFromPool = ( +// math: HydraDxMath, +// pool: LbpPool, +// assetInId: string, +// assetOutId: string, +// amountOut: string, +// ) => { +// const { assetABalance: assetInBalance, assetBBalance: assetOutBalance } = getPoolBalances( +// pool, +// assetInId, +// assetOutId, +// ) - if (!assetInBalance || !assetOutBalance) throw new Error(`Can't find the required balances in the pool`); +// if (!assetInBalance || !assetOutBalance) throw new Error(`Can't find the required balances in the pool`); - const { assetInWeight, assetOutWeight } = getInAndOutWeights(pool, assetInId, assetOutId); +// const { assetInWeight, assetOutWeight } = getInAndOutWeights(pool, assetInId, assetOutId); - return calculateInGivenOut( - math, - assetInBalance, - assetOutBalance, - assetInWeight, - assetOutWeight, - amountOut - ); -} \ No newline at end of file +// return calculateInGivenOut( +// math, +// assetInBalance, +// assetOutBalance, +// assetInWeight, +// assetOutWeight, +// amountOut +// ); +// } \ No newline at end of file diff --git a/src/hooks/pools/lbp/calculateOutGivenIn.tsx b/src/hooks/pools/lbp/calculateOutGivenIn.tsx index 9a1c0dc7..a16f5d05 100644 --- a/src/hooks/pools/lbp/calculateOutGivenIn.tsx +++ b/src/hooks/pools/lbp/calculateOutGivenIn.tsx @@ -1,52 +1,54 @@ -import { find } from 'lodash'; -import { LbpPool } from '../../../generated/graphql'; -import { HydraDxMath } from '../../math/useMath'; -import { getInAndOutWeights, getPoolBalances } from './calculateInGivenOut'; +// import { find } from 'lodash'; +// import { LbpPool } from '../../../generated/graphql'; +// import { HydraDxMath } from '../../math/useMath'; +// import { getInAndOutWeights, getPoolBalances } from './calculateInGivenOut'; -/** - * Wrapper for `math.lbp.calculate_out_given_in` - * @param math - * @param inReserve - * @param outReserve - * @param inWeight - * @param outWeight - * @param amount - * @returns - */ -export const calculateOutGivenIn = ( - math: HydraDxMath, - inReserve: string, - outReserve: string, - inWeight: string, - outWeight: string, - amount: string, -) => { - return math.lbp.calculate_out_given_in(inReserve, outReserve, inWeight, outWeight, amount); -} +// /** +// * Wrapper for `math.lbp.calculate_out_given_in` +// * @param math +// * @param inReserve +// * @param outReserve +// * @param inWeight +// * @param outWeight +// * @param amount +// * @returns +// */ +// export const calculateOutGivenIn = ( +// math: HydraDxMath, +// inReserve: string, +// outReserve: string, +// inWeight: string, +// outWeight: string, +// amount: string, +// ) => { +// return math.lbp.calculate_out_given_in(inReserve, outReserve, inWeight, outWeight, amount); +// } -export const calculateOutGivenInFromPool = ( - math: HydraDxMath, - pool: LbpPool, - assetInId: string, - assetOutId: string, - amountIn: string, -) => { - const { assetABalance: assetInBalance, assetBBalance: assetOutBalance } = getPoolBalances( - pool, - assetInId, - assetOutId, - ) +// export const calculateOutGivenInFromPool = ( +// math: HydraDxMath, +// pool: LbpPool, +// assetInId: string, +// assetOutId: string, +// amountIn: string, +// ) => { +// const { assetABalance: assetInBalance, assetBBalance: assetOutBalance } = getPoolBalances( +// pool, +// assetInId, +// assetOutId, +// ) - if (!assetInBalance || !assetOutBalance) throw new Error(`Can't find the required balances in the pool`); +// if (!assetInBalance || !assetOutBalance) throw new Error(`Can't find the required balances in the pool`); - const { assetInWeight, assetOutWeight } = getInAndOutWeights(pool, assetInId, assetOutId); +// const { assetInWeight, assetOutWeight } = getInAndOutWeights(pool, assetInId, assetOutId); - return calculateOutGivenIn( - math, - assetInBalance, - assetOutBalance, - assetInWeight, - assetOutWeight, - amountIn - ); -} \ No newline at end of file +// return calculateOutGivenIn( +// math, +// assetInBalance, +// assetOutBalance, +// assetInWeight, +// assetOutWeight, +// amountIn +// ); +// } + +export default {}; \ No newline at end of file diff --git a/src/hooks/pools/mutations/useAddLiquidityMutation.tsx b/src/hooks/pools/mutations/useAddLiquidityMutation.tsx new file mode 100644 index 00000000..16cf5819 --- /dev/null +++ b/src/hooks/pools/mutations/useAddLiquidityMutation.tsx @@ -0,0 +1,23 @@ +import { MutationHookOptions, useMutation } from '@apollo/client'; +import { loader } from 'graphql.macro'; +import { PoolType } from '../../../components/Chart/shared'; +import { TradeType } from '../../../generated/graphql'; + +const REMOVE_LIQUIDITY = loader('./../graphql/AddLiquidity.mutation.graphql'); + +export interface AddLiquidityMutationVariables { + assetA: string; + assetB: string; + amountA: string; + amountBMaxLimit: string; +} + +export const useAddLiquidityMutation = ( + options?: MutationHookOptions +) => + useMutation(REMOVE_LIQUIDITY, { + notifyOnNetworkStatusChange: true, + ...options, + }); + + \ No newline at end of file diff --git a/src/hooks/pools/mutations/useRemoveLiquidityMutation.tsx b/src/hooks/pools/mutations/useRemoveLiquidityMutation.tsx new file mode 100644 index 00000000..631c662f --- /dev/null +++ b/src/hooks/pools/mutations/useRemoveLiquidityMutation.tsx @@ -0,0 +1,22 @@ +import { MutationHookOptions, useMutation } from '@apollo/client'; +import { loader } from 'graphql.macro'; +import { PoolType } from '../../../components/Chart/shared'; +import { TradeType } from '../../../generated/graphql'; + +const REMOVE_LIQUIDITY = loader('./../graphql/RemoveLiquidity.mutation.graphql'); + +export interface RemoveLiquidityMutationVariables { + assetA: string; + assetB: string; + amount: string +} + +export const useRemoveLiquidityMutation = ( + options?: MutationHookOptions +) => + useMutation(REMOVE_LIQUIDITY, { + notifyOnNetworkStatusChange: true, + ...options, + }); + + \ No newline at end of file diff --git a/src/hooks/pools/mutations/useSubmitTradeMutation.tsx b/src/hooks/pools/mutations/useSubmitTradeMutation.tsx index 09a35030..fe151edf 100644 --- a/src/hooks/pools/mutations/useSubmitTradeMutation.tsx +++ b/src/hooks/pools/mutations/useSubmitTradeMutation.tsx @@ -6,28 +6,27 @@ import { TradeType } from '../../../generated/graphql'; const SUBMIT_TRADE = loader('./../graphql/SubmitTrade.mutation.graphql'); export interface SubmitTradeMutationVariables { - assetInId: string, - assetOutId: string, - assetInAmount: string, - assetOutAmount: string, - poolType: PoolType, - tradeType: TradeType, - amountWithSlippage: string + assetInId: string; + assetOutId: string; + assetInAmount: string; + assetOutAmount: string; + poolType: PoolType; + tradeType: TradeType; + amountWithSlippage: string; } -export const useSubmitTradeMutation = (options?: MutationHookOptions) => useMutation( - SUBMIT_TRADE, - { - notifyOnNetworkStatusChange: true, - ...options - } -) +export const useSubmitTradeMutation = ( + options?: MutationHookOptions +) => + useMutation(SUBMIT_TRADE, { + notifyOnNetworkStatusChange: true, + ...options, + }); /** * lbp.buy(assetOut, assetIn, amount, maxLimit) * lbp.sell(assetIn, assetOut, amount, maxLimit) - * + * * exchange.buy(assetBuy, assetSell, amountBuy, maxSold, discount) * exchange.sell(assetSell, assetBuy, amountSell, minBought, discount) */ - diff --git a/src/hooks/pools/resolvers/useAddLiquidityMutationResolver.tsx b/src/hooks/pools/resolvers/useAddLiquidityMutationResolver.tsx new file mode 100644 index 00000000..2f74f915 --- /dev/null +++ b/src/hooks/pools/resolvers/useAddLiquidityMutationResolver.tsx @@ -0,0 +1,47 @@ +import { ApolloCache, NormalizedCacheObject } from "@apollo/client"; +import { web3FromAddress } from "@polkadot/extension-dapp"; +import { Maybe } from "graphql/jsutils/Maybe"; +import { useCallback } from "react"; +import { readActiveAccount } from "../../accounts/lib/readActiveAccount"; +import { usePolkadotJsContext } from "../../polkadotJs/usePolkadotJs"; +import { AddLiquidityMutationVariables } from "../mutations/useAddLiquidityMutation"; +import { RemoveLiquidityMutationVariables } from "../mutations/useRemoveLiquidityMutation"; +import { SubmitTradeMutationVariables } from "../mutations/useSubmitTradeMutation"; +import { xykBuyHandler } from "../xyk/buy"; + +export const useAddLiquidityMutationResolver = () => { + const { apiInstance } = usePolkadotJsContext(); + + // return withErrorHandler( + return useCallback( + async ( + _obj, + args: Maybe, + { cache }: { cache: ApolloCache } + ) => { + + await new Promise(async (resolve, reject) => { + const activeAccount = readActiveAccount(cache); + const address = activeAccount?.id; + + // TODO: extract this error + try { + if (!address) return reject(new Error('No active account found!')); + + const { signer } = await web3FromAddress(address); + + await apiInstance?.tx.xyk.addLiquidity(args?.assetA, args?.assetB, args?.amountA, args?.amountBMaxLimit) + .signAndSend( + address, + { signer }, + xykBuyHandler(resolve, reject, apiInstance) + ); + } catch (e) { + reject(e) + } + }) + }, + [apiInstance] + ) + // ); +}; \ No newline at end of file diff --git a/src/hooks/pools/resolvers/usePoolsMutationResolvers.tsx b/src/hooks/pools/resolvers/usePoolsMutationResolvers.tsx index fd88616b..06a09740 100644 --- a/src/hooks/pools/resolvers/usePoolsMutationResolvers.tsx +++ b/src/hooks/pools/resolvers/usePoolsMutationResolvers.tsx @@ -1,9 +1,11 @@ +import { useAddLiquidityMutationResolver } from './useAddLiquidityMutationResolver'; +import { useRemoveLiquidityMutationResolver } from './useRemoveLiquidityMutationResolver'; import { useSubmitTradeMutationResolver } from './useSubmitTradeMutationResolvers' export const usePoolsMutationResolvers = () => { - const submitTrade = useSubmitTradeMutationResolver(); - return { - submitTrade + submitTrade: useSubmitTradeMutationResolver(), + removeLiquidity: useRemoveLiquidityMutationResolver(), + addLiquidity: useAddLiquidityMutationResolver() } } \ No newline at end of file diff --git a/src/hooks/pools/resolvers/useRemoveLiquidityMutationResolver.tsx b/src/hooks/pools/resolvers/useRemoveLiquidityMutationResolver.tsx new file mode 100644 index 00000000..260a8064 --- /dev/null +++ b/src/hooks/pools/resolvers/useRemoveLiquidityMutationResolver.tsx @@ -0,0 +1,46 @@ +import { ApolloCache, NormalizedCacheObject } from "@apollo/client"; +import { web3FromAddress } from "@polkadot/extension-dapp"; +import { Maybe } from "graphql/jsutils/Maybe"; +import { useCallback } from "react"; +import { readActiveAccount } from "../../accounts/lib/readActiveAccount"; +import { usePolkadotJsContext } from "../../polkadotJs/usePolkadotJs"; +import { RemoveLiquidityMutationVariables } from "../mutations/useRemoveLiquidityMutation"; +import { SubmitTradeMutationVariables } from "../mutations/useSubmitTradeMutation"; +import { xykBuyHandler } from "../xyk/buy"; + +export const useRemoveLiquidityMutationResolver = () => { + const { apiInstance } = usePolkadotJsContext(); + + // return withErrorHandler( + return useCallback( + async ( + _obj, + args: Maybe, + { cache }: { cache: ApolloCache } + ) => { + + await new Promise(async (resolve, reject) => { + const activeAccount = readActiveAccount(cache); + const address = activeAccount?.id; + + // TODO: extract this error + try { + if (!address) return reject(new Error('No active account found!')); + + const { signer } = await web3FromAddress(address); + + await apiInstance?.tx.xyk.removeLiquidity(args?.assetA, args?.assetB, args?.amount) + .signAndSend( + address, + { signer }, + xykBuyHandler(resolve, reject, apiInstance) + ); + } catch (e) { + reject(e) + } + }) + }, + [apiInstance] + ) + // ); +}; \ No newline at end of file diff --git a/src/hooks/pools/useGetXykPool.tsx b/src/hooks/pools/useGetXykPool.tsx index 6e406863..1eca58e4 100644 --- a/src/hooks/pools/useGetXykPool.tsx +++ b/src/hooks/pools/useGetXykPool.tsx @@ -10,7 +10,9 @@ export const useGetXykPool = () => { return mapToPool(apiInstance)([ poolId, - await apiInstance.query.xyk.poolAssets(poolId) + await apiInstance.query.xyk.poolAssets(poolId), + await apiInstance.query.xyk.shareToken(poolId), + await apiInstance.query.xyk.totalLiquidity(poolId) ]); }, [ apiInstance, diff --git a/src/hooks/pools/useGetXykPools.tsx b/src/hooks/pools/useGetXykPools.tsx index 19d2a0a1..228cd718 100644 --- a/src/hooks/pools/useGetXykPools.tsx +++ b/src/hooks/pools/useGetXykPools.tsx @@ -13,15 +13,19 @@ export const mapToPoolId = ([storageKey, codec]: [StorageKey, Codec]): return [id, codec]; } -export const mapToPool = (apiInstance: ApiPromise) => ([id, codec]: [string, Codec]) => { +export const mapToPool = (apiInstance: ApiPromise) => ([id, codec, shareTokenId, totalLiquidity]: [string, Codec, Codec, Codec]) => { const poolAssets = codec.toHuman() as PoolAssets; + console.log('mapToPool', id, codec.toHuman(), shareTokenId.toHuman(), totalLiquidity.toHuman()) + if (!poolAssets) return; return { id, assetInId: poolAssets[0], assetOutId: poolAssets[1], + totalLiquidity: totalLiquidity.toString(), + shareTokenId: shareTokenId.toString() } as XykPool } @@ -30,16 +34,31 @@ export const useGetXykPools = () => { return useCallback(async (poolId?: string, assetIds?: string[]) => { if (!apiInstance || loading) return []; + console.log('getting pools'); + const pools = (await apiInstance.query.xyk.poolAssets.entries()) + .map(async (data) => { + const pool = mapToPoolId(data); + + return { + id: pool[0], + data: [ + pool[1], // assets + await apiInstance.query.xyk.shareToken(poolId || pool[0]), + await apiInstance.query.xyk.totalLiquidity(poolId || pool[0]) + ] + } + }) + .map(async (data) => { + const d = await data + return mapToPool(apiInstance)([ + d.id, + d.data[0], + d.data[1], + d.data[2] + ]) + }) || [] - if (poolId) { - return [(await apiInstance.query.xyk.poolAssets(poolId))] - .map(pool => [poolId, pool] as [string, Codec]) - .map(mapToPool(apiInstance)) - } - - return (await apiInstance.query.xyk.poolAssets.entries()) - .map(mapToPoolId) - .map(mapToPool(apiInstance)) || [] + return await Promise.all(pools); }, [ apiInstance, loading diff --git a/src/hooks/pools/xyk/removeLiquidity.tsx b/src/hooks/pools/xyk/removeLiquidity.tsx new file mode 100644 index 00000000..7785b658 --- /dev/null +++ b/src/hooks/pools/xyk/removeLiquidity.tsx @@ -0,0 +1,72 @@ +import { ApolloCache, NormalizedCacheObject } from '@apollo/client'; +import { ApiPromise } from '@polkadot/api'; +import { web3FromAddress } from '@polkadot/extension-dapp'; +import { readActiveAccount } from '../../accounts/lib/readActiveAccount'; +import { + withGracefulErrors, + gracefulExtensionCancelationErrorHandler, + vestingClaimHandler, + resolve, + reject, +} from '../../vesting/useVestingMutationResolvers'; + +export const xykRemoveLiquidityHandler = ( + resolve: resolve, + reject: reject, + apiInstance: ApiPromise +) => { + return vestingClaimHandler(resolve, reject, apiInstance); +}; + +export const discount = false; + +export const estimateRemoveLiquidity = async ( + cache: ApolloCache, + apiInstance: ApiPromise, + assetA: string, + assetB: string, + amount: string, +) => { + const activeAccount = readActiveAccount(cache); + const address = activeAccount?.id; + + if (!address) return; + + return apiInstance.tx.xyk + .removeLiquidity(assetA, assetB, amount) + .paymentInfo(address); +} + +export const removeLiquidity = async ( + cache: ApolloCache, + apiInstance: ApiPromise, + assetA: string, + assetB: string, + amount: string, +) => { + // await withGracefulErrors( + // async (resolve, reject) => { + await new Promise(async (resolve, reject) => { + const activeAccount = readActiveAccount(cache); + const address = activeAccount?.id; + + // TODO: extract this error + try { + if (!address) return reject(new Error('No active account found!')); + + const { signer } = await web3FromAddress(address); + + await apiInstance.tx.xyk + .removeLiquidity(assetA, assetB, amount) + .signAndSend( + address, + { signer }, + xykRemoveLiquidityHandler(resolve, reject, apiInstance) + ); + } catch (e) { + reject(e) + } + }) + // [gracefulExtensionCancelationErrorHandler] + // ); +}; diff --git a/src/hooks/vesting/calculateClaimableAmount.test.tsx b/src/hooks/vesting/calculateClaimableAmount.test.tsx index 5dd8b7dd..0f590d23 100644 --- a/src/hooks/vesting/calculateClaimableAmount.test.tsx +++ b/src/hooks/vesting/calculateClaimableAmount.test.tsx @@ -1,47 +1,26 @@ import BigNumber from 'bignumber.js'; -import constants from '../../constants'; -import { - calculateClaimableAmount, - calculateFutureLock, - toBN, -} from './calculateClaimableAmount'; +import { calculateLock } from './calculateClaimableAmount'; describe('calculateClaimableAmount', () => { - const vestingSchedule = { - start: '10', - period: '10', - periodCount: '30', - perPeriod: '100', - }; - const currentBlock = new BigNumber(30); - const lockedTokens = { id: 'ormlvest', amount: '10000' }; + describe('calculateLock', () => { + const vestingSchedule = { + start: '10', + period: '10', + periodCount: '30', + perPeriod: '100', + }; + const currentBlock = '30'; + const expectedOriginalLock = new BigNumber(3000); + const expectedFutureLock = new BigNumber(2800); - describe('toBN', () => { - it('returns default value for undefined', () => { - const value = toBN(undefined); - expect(value).toEqual(new BigNumber(constants.defaultValue)); - }); - }); - - describe('calculateFutureLock', () => { - it('can calculate future lock for one vesting schedule', () => { - const futureLock = calculateFutureLock(vestingSchedule, currentBlock); - - expect(futureLock).toEqual(new BigNumber(2800)); - }); - }); - - describe('calculateClaimableAmount', () => { - it('can calculate claimable amount', () => { - const claimableAmount = calculateClaimableAmount( - [vestingSchedule, vestingSchedule], - lockedTokens, + it('can calculate original- and future-lock for one vesting schedule', () => { + const [originalLock, futureLock] = calculateLock( + vestingSchedule, currentBlock ); - expect(claimableAmount).toEqual( - toBN(lockedTokens.amount).minus(toBN('2800').multipliedBy(2)) - ); + expect(originalLock).toEqual(expectedOriginalLock); + expect(futureLock).toEqual(expectedFutureLock); }); }); }); diff --git a/src/hooks/vesting/calculateClaimableAmount.tsx b/src/hooks/vesting/calculateClaimableAmount.tsx index 3a3fe821..859adbe7 100644 --- a/src/hooks/vesting/calculateClaimableAmount.tsx +++ b/src/hooks/vesting/calculateClaimableAmount.tsx @@ -1,10 +1,8 @@ import { ApiPromise } from '@polkadot/api'; -import { BalanceLock } from '@polkadot/types/interfaces'; import { Codec } from '@polkadot/types/types'; import BigNumber from 'bignumber.js'; import { find } from 'lodash'; -import constants from '../../constants'; -import { VestingSchedule } from './useGetVestingScheduleByAddress'; +import { VestingSchedule } from '../../generated/graphql'; export const balanceLockDataType = 'Vec'; export const tokensLockDataType = balanceLockDataType; @@ -33,9 +31,7 @@ export const getLockedBalanceByAddressAndLockId = async ( balanceLockDataType, await apiInstance.query.balances.locks(address) ), - (lockedAmount) => - // lockedAmount.id.eq(lockId) - false + (lockedAmount) => lockedAmount.id.eq(lockId) ); const tokenBalanceLocks = ( @@ -63,58 +59,70 @@ export const getLockedBalanceByAddressAndLockId = async ( }; /** - * This function casts a number in string representation - * to a BigNumber. If the input is undefined, it returns - * a default value. + * Calculates original and future lock for given VestingSchedule. + * https://gist.github.com/maht0rz/53466af0aefba004d5a4baad23f8ce26 + * + * returns [originalLock, futureLock] */ -export const toBN = (numberAsString: string | undefined) => { - // TODO: check if it is any good to use default values - // on undefined VestingSchedule properties! - if (!numberAsString) return new BigNumber(constants.defaultValue); - return new BigNumber(numberAsString); -}; +export const calculateLock = ( + vesting: VestingSchedule, + currentBlockNumber: string +): [BigNumber, BigNumber] => { + const startPeriod = new BigNumber(vesting.start); + const period = new BigNumber(vesting.period); -// https://gist.github.com/maht0rz/53466af0aefba004d5a4baad23f8ce26 -// TODO: check if calc makes sense for undefined VestingSchedule properties -export const calculateFutureLock = ( - vestingSchedule: VestingSchedule, - currentBlockNumber: BigNumber -) => { - const startPeriod = toBN(vestingSchedule.start); - const period = toBN(vestingSchedule.period); - const numberOfPeriods = currentBlockNumber + // if the vesting has not started, number of periods is 0 + let numberOfPeriods = new BigNumber(currentBlockNumber) .minus(startPeriod) .dividedBy(period); + numberOfPeriods = numberOfPeriods.isNegative() + ? new BigNumber('0') + : numberOfPeriods; - const perPeriod = toBN(vestingSchedule.perPeriod); + const perPeriod = new BigNumber(vesting.perPeriod); const vestedOverPeriods = numberOfPeriods.multipliedBy(perPeriod); - const periodCount = toBN(vestingSchedule.periodCount); + const periodCount = new BigNumber(vesting.periodCount); const originalLock = periodCount.multipliedBy(perPeriod); - const futureLock = originalLock.minus(vestedOverPeriods); - return futureLock; + const unlocked = vestedOverPeriods.gte(originalLock) + ? originalLock + : vestedOverPeriods; + const futureLock = originalLock.minus(unlocked); + + return [originalLock, futureLock]; }; -// get lockedVestingAmount from function getLockedBalanceByAddressAndLockId -export const calculateClaimableAmount = ( +/** + * Calculates originalLock and futureLock for every vesting schedule and + * sums it to total. + */ +export const calculateTotalLocks = ( vestingSchedules: VestingSchedule[], - lockedVestingAmount: BalanceLock | LockedTokens, - currentBlockNumber: BigNumber -): BigNumber => { - // calculate futureLock for every vesting schedule and sum to total - const totalFutureLocks = vestingSchedules.reduce(function ( - total, - vestingSchedule - ) { - const futureLock = calculateFutureLock(vestingSchedule, currentBlockNumber); - return total.plus(futureLock); - }, - new BigNumber(0)); - - // calculate claimable amount - const remainingVestingAmount = toBN(lockedVestingAmount?.amount?.toString()); - const claimableAmount = remainingVestingAmount.minus(totalFutureLocks); - - return claimableAmount; + currentBlockNumber: string +) => { + /** + * .reduce did not play well with an object that has multiple BigNumbers + * that's why the summation runs twice. + */ + const sumOriginalLock = vestingSchedules.reduce( + (accumulator, vestingSchedule) => { + const [originalLock] = calculateLock(vestingSchedule, currentBlockNumber); + return accumulator.plus(originalLock); + }, + new BigNumber(0) + ); + + const sumFutureLock = vestingSchedules.reduce( + (accumulator, vestingSchedule) => { + const [, futureLock] = calculateLock(vestingSchedule, currentBlockNumber); + return accumulator.plus(futureLock); + }, + new BigNumber(0) + ); + + return { + original: sumOriginalLock.toString(), + future: sumFutureLock.toString(), + }; }; diff --git a/src/hooks/vesting/graphql/Vesting.graphql b/src/hooks/vesting/graphql/Vesting.graphql new file mode 100644 index 00000000..1315c19e --- /dev/null +++ b/src/hooks/vesting/graphql/Vesting.graphql @@ -0,0 +1,29 @@ +# https://github.com/open-web3-stack/open-runtime-module-library/blob/master/vesting/src/lib.rs#L11 +type VestingSchedule { + # since this block + start: String! + # every `period` blocks + period: String! + # for number of periods + periodCount: String! + # claimable amount per period + perPeriod: String! +} + +extend type Query { + _vestingSchedule: VestingSchedule +} + +type Vesting { + claimableAmount: String! + originalLockBalance: String! + lockedVestingBalance: String! +} + +interface IVesting { + vesting: Vesting +} + +extend type Query implements IVesting { + vesting: Vesting +} diff --git a/src/hooks/vesting/graphql/VestingSchedule.graphql b/src/hooks/vesting/graphql/VestingSchedule.graphql deleted file mode 100644 index 7417a25f..00000000 --- a/src/hooks/vesting/graphql/VestingSchedule.graphql +++ /dev/null @@ -1,13 +0,0 @@ -# https://github.com/open-web3-stack/open-runtime-module-library/blob/master/vesting/src/lib.rs#L11 -type VestingSchedule { - # total locked amoount left to eventually be claimed - remainingVestingAmount: String, - # since this block - start: String, - # every `period` blocks - period: String, - # for number of periods - periodCount: String, - # claimable amount per period - perPeriod: String -} \ No newline at end of file diff --git a/src/hooks/vesting/useClaimVestedAmountMutation.tsx b/src/hooks/vesting/useClaimVestedAmountMutation.tsx index 41e37eac..e7950210 100644 --- a/src/hooks/vesting/useClaimVestedAmountMutation.tsx +++ b/src/hooks/vesting/useClaimVestedAmountMutation.tsx @@ -1,18 +1,15 @@ -import { useMutation } from '@apollo/client'; +import { MutationHookOptions, useMutation } from '@apollo/client'; import { loader } from 'graphql.macro'; export const CLAIM_VESTED_AMOUNT = loader('./graphql/ClaimVestedAmount.mutation.graphql'); export type ClaimVestedAmountMutationResponse = void; -export interface ClaimVestedAmountMutationVariables { - address?: string -} // no need to refetch queries, active account will refetch with every new block anyways -export const useClaimVestedAmountMutation = (variables?: ClaimVestedAmountMutationVariables) => useMutation( +export const useClaimVestedAmountMutation = (options?: MutationHookOptions) => useMutation( CLAIM_VESTED_AMOUNT, { - variables, notifyOnNetworkStatusChange: true, + ...options, } ) \ No newline at end of file diff --git a/src/hooks/vesting/useGetVestingByAddress.tsx b/src/hooks/vesting/useGetVestingByAddress.tsx new file mode 100644 index 00000000..894afa7e --- /dev/null +++ b/src/hooks/vesting/useGetVestingByAddress.tsx @@ -0,0 +1,92 @@ +import { useMemo } from 'react'; +import { usePolkadotJsContext } from '../polkadotJs/usePolkadotJs'; +import { Vec } from '@polkadot/types'; +import { VestingScheduleOf } from '@open-web3/orml-types/interfaces'; +import { ApiPromise } from '@polkadot/api'; +import { + calculateTotalLocks, + getLockedBalanceByAddressAndLockId, + vestingBalanceLockId, +} from './calculateClaimableAmount'; +import { readLastBlock } from '../lastBlock/readLastBlock'; +import { ApolloClient } from '@apollo/client'; +import BigNumber from 'bignumber.js'; +import { Query, Vesting, VestingSchedule } from '../../generated/graphql'; + +export const vestingScheduleDataType = 'Vec'; + +export const getVestingByAddressFactory = + (apiInstance?: ApiPromise) => + async ( + client: ApolloClient, + address?: string + ): Promise => { + if (!apiInstance || !address) return; + const currentBlockNumber = + readLastBlock(client)?.lastBlock?.relaychainBlockNumber; + if (!currentBlockNumber) + throw Error(`Can't calculate locks without current block number.`); + + // TODO: instead of multiple .createType calls, use the following + // https://github.com/AcalaNetwork/acala.js/blob/9634e2291f1723a84980b3087c55573763c8e82e/packages/sdk-core/src/functions/getSubscribeOrAtQuery.ts#L4 + const vestingSchedulesData = apiInstance.createType( + vestingScheduleDataType, + await apiInstance.query.vesting.vestingSchedules(address) + ) as Vec; + + const vestingSchedules = vestingSchedulesData.map((vestingSchedule) => { + return { + start: vestingSchedule?.start.toString(), + period: vestingSchedule?.period.toString(), + periodCount: vestingSchedule?.periodCount.toString(), + perPeriod: vestingSchedule?.perPeriod.toString(), + } as VestingSchedule; + }); + + const totalLocks = calculateTotalLocks( + vestingSchedules, + currentBlockNumber! + ); + + // 'ormlvest' is being fetched + const currentLockedVestingBalanceOrmlvest = ( + await getLockedBalanceByAddressAndLockId( + apiInstance, + address, + vestingBalanceLockId + ) + )?.amount?.toString(); + + if (!currentLockedVestingBalanceOrmlvest) + return { + claimableAmount: '0', + originalLockBalance: '0', + lockedVestingBalance: '0', + }; + + // TODO: add support for lockIds other than ormlvest + const currentLockedVestingOrmlvest = new BigNumber( + currentLockedVestingBalanceOrmlvest + ); + // claimable = currentRemainingVesting - all future locks + const claimableAmount = currentLockedVestingOrmlvest.minus( + new BigNumber(totalLocks.future) + ); + + return { + claimableAmount: claimableAmount.toString(), + originalLockBalance: totalLocks.original, // totalLocks.original == originalOrmlvestVesting + lockedVestingBalance: currentLockedVestingOrmlvest.toString(), + } as Vesting; + }; + +export const useGetVestingByAddress = () => { + const { apiInstance } = usePolkadotJsContext(); + + const getVestingByAddress = useMemo( + () => getVestingByAddressFactory(apiInstance), + [apiInstance] + ); + + return getVestingByAddress; +}; diff --git a/src/hooks/vesting/useVestingMutationResolvers.tsx b/src/hooks/vesting/useVestingMutationResolvers.tsx index 1775f18e..f9c02a89 100644 --- a/src/hooks/vesting/useVestingMutationResolvers.tsx +++ b/src/hooks/vesting/useVestingMutationResolvers.tsx @@ -2,7 +2,6 @@ import { useCallback } from 'react'; import { withErrorHandler } from '../apollo/withErrorHandler'; import { usePolkadotJsContext } from '../polkadotJs/usePolkadotJs'; import { web3FromAddress } from '@polkadot/extension-dapp'; -import { ClaimVestedAmountMutationVariables } from './useClaimVestedAmountMutation'; import { ExtrinsicStatus } from '@polkadot/types/interfaces/author'; import { DispatchError, EventRecord } from '@polkadot/types/interfaces/system'; import log from 'loglevel'; @@ -12,6 +11,7 @@ import { GET_ACTIVE_ACCOUNT, } from '../accounts/queries/useGetActiveAccountQuery'; import { ApiPromise } from '@polkadot/api'; +import { reject } from 'lodash'; /** * Run an async function and handle the thrown errors @@ -116,6 +116,38 @@ export const vestingClaimHandler = export const noAccountSelectedError = 'No Account selected'; export const polkadotJsNotReadyYetError = 'Polkadot.js is not ready yet'; +const claimVestingExtrinsic = (apiInstance: ApiPromise) => + apiInstance.tx.vesting.claim; + +// TODO: this should be generated with graphql +export interface ClaimVestedAmountMutationVariables { + address?: string +} + +const getAddress = ( + cache: ApolloCache, + args: ClaimVestedAmountMutationVariables +) => { + return args?.address + ? args.address + : cache.readQuery({ + query: GET_ACTIVE_ACCOUNT, + })?.activeAccount?.id; +}; + +export const estimateClaimVesting = async ( + cache: ApolloCache, + apiInstance: ApiPromise, + args: ClaimVestedAmountMutationVariables +) => { + const address = getAddress(cache, args); + + if (!address) + throw new Error(`Can't retrieve vesting address for estimation`); + + return claimVestingExtrinsic(apiInstance)().paymentInfo(address); +}; + export const useVestingMutationResolvers = () => { const { apiInstance, loading } = usePolkadotJsContext(); @@ -123,14 +155,10 @@ export const useVestingMutationResolvers = () => { useCallback( async ( _obj, - variables: ClaimVestedAmountMutationVariables, + args: ClaimVestedAmountMutationVariables, { cache }: { cache: ApolloCache } ) => { - const address = variables?.address - ? variables.address - : cache.readQuery({ - query: GET_ACTIVE_ACCOUNT, - })?.activeAccount?.id; + const address = getAddress(cache, args); // TODO: error handling? if (!address) throw new Error(noAccountSelectedError); @@ -139,29 +167,33 @@ export const useVestingMutationResolvers = () => { // // TODO: why does this not return a tx hash? // return await withGracefulErrors( - // async (resolve, reject) => { - // const { signer } = await web3FromAddress(address); - // await apiInstance.tx.vesting - // .claim() - // .signAndSend( - // address, - // { signer }, - // vestingClaimHandler(resolve, reject) - // ); - // }, - // [gracefulExtensionCancelationErrorHandler] + // async (resolve, reject) => { + // const { signer } = await web3FromAddress(address); + // await apiInstance.tx.vesting + // .claim() + // .signAndSend( + // address, + // { signer }, + // vestingClaimHandler(resolve, reject) + // ); + // }, + // [gracefulExtensionCancelationErrorHandler] // ); - return new Promise(async (resolve, reject) => { + await new Promise(async (resolve, reject) => { const { signer } = await web3FromAddress(address); - await apiInstance.tx.vesting - .claim() - .signAndSend( + try { + await claimVestingExtrinsic(apiInstance)().signAndSend( address, { signer }, vestingClaimHandler(resolve, reject) ); + } catch(e) { + reject(e) + } }); + + }, [loading, apiInstance] ), diff --git a/src/hooks/vesting/useVestingQueryResolvers.tsx b/src/hooks/vesting/useVestingQueryResolvers.tsx new file mode 100644 index 00000000..3e29fe52 --- /dev/null +++ b/src/hooks/vesting/useVestingQueryResolvers.tsx @@ -0,0 +1,24 @@ +import { ApolloClient } from '@apollo/client'; +import { useCallback } from 'react'; +import { Account } from '../../generated/graphql'; +import { withErrorHandler } from '../apollo/withErrorHandler'; +import { useGetVestingByAddress } from './useGetVestingByAddress'; + +export const useVestingQueryResolvers = () => { + const getVestingByAddress = useGetVestingByAddress(); + const vesting = withErrorHandler( + useCallback( + async ( + account: Account, + _args: any, + { client }: { client: ApolloClient } + ) => await getVestingByAddress(client, account.id), + [getVestingByAddress] + ), + 'vesting' + ); + + return { + vesting, + }; +}; diff --git a/src/lang/en.json b/src/lang/en.json index fb74cba7..0dc93ab9 100644 --- a/src/lang/en.json +++ b/src/lang/en.json @@ -18,7 +18,7 @@ "defaultMessage": "Clear account" }, "Wallet.SelectAccount": { - "defaultMessage": "Select an account" + "defaultMessage": "Select account" }, "Wallet.InstallInstructions": { "defaultMessage": "To connect your account, please {link}." diff --git a/src/misc/colors.module.scss b/src/misc/colors.module.scss index 10860a96..5a466f39 100644 --- a/src/misc/colors.module.scss +++ b/src/misc/colors.module.scss @@ -19,7 +19,7 @@ $white: #ffffff; $gray1: #424250; $gray2: #211f24; $gray3: #26282f; -$gray4: #bdccd4; +$gray4: #a2b0bb; $gray5: #3b3a49; $gray6: #abb4c1; diff --git a/src/misc/defaults.scss b/src/misc/defaults.scss index 9301531c..7bf01c14 100644 --- a/src/misc/defaults.scss +++ b/src/misc/defaults.scss @@ -1,23 +1,32 @@ @import './colors.module.scss'; @import './misc.module.scss'; +@font-face { + font-family: 'Satoshi'; + src: url('./fonts/Satoshi-Variable.ttf') format('truetype-variations'); + font-weight: 300 900; +} + html, body { - font-family: 'Roboto', sans-serif; + font-family: 'Satoshi', sans-serif; /* Better Font Rendering */ text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; - background: $d-gray3; - color: $white1; + background: radial-gradient( + 89.2% 89.2% at 50.07% 87.94%, + #008a69 0%, + #262f31 88.52% + ), + #2c3335; + + background-attachment: fixed; padding: 0; min-width: 360px; min-height: 400px; height: 100%; - - /* */ - background: linear-gradient(0deg, #2d2a32 0%, #34333e 100%); } div { diff --git a/src/misc/fonts/Satoshi-Variable.ttf b/src/misc/fonts/Satoshi-Variable.ttf new file mode 100644 index 0000000000000000000000000000000000000000..976e85cb58307b184289e3cdb11ddfd3a530a3e3 GIT binary patch literal 127420 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 literal 0 HcmV?d00001 diff --git a/src/misc/misc.module.scss b/src/misc/misc.module.scss index eaa4ef52..341bc5e7 100644 --- a/src/misc/misc.module.scss +++ b/src/misc/misc.module.scss @@ -1,4 +1,4 @@ -$border-radius: 8px; +$border-radius: 16px; :export { border-radius: $border-radius; diff --git a/src/pages/PoolsPage/PoolsPage.scss b/src/pages/PoolsPage/PoolsPage.scss new file mode 100644 index 00000000..192a8cc1 --- /dev/null +++ b/src/pages/PoolsPage/PoolsPage.scss @@ -0,0 +1,15 @@ +@import '../../misc/misc.module.scss'; +@import '../../misc/colors.module.scss'; +@import '../TradePage/TradePage.scss'; + +.pools-page-wrapper { + @extend .trade-page-wrapper; +} + +.pools-page { + @extend .pools-page; +} + +.notifications-bar { + @extend .notifications-bar; +} diff --git a/src/pages/PoolsPage/PoolsPage.tsx b/src/pages/PoolsPage/PoolsPage.tsx new file mode 100644 index 00000000..2c16897b --- /dev/null +++ b/src/pages/PoolsPage/PoolsPage.tsx @@ -0,0 +1,350 @@ +import { NetworkStatus, useApolloClient } from '@apollo/client'; +import classNames from 'classnames'; +import { find, uniq, last } from 'lodash'; +import moment from 'moment'; +import { usePageVisibility } from 'react-page-visibility'; +import { + Dispatch, + SetStateAction, + useState, + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; +import { Control, useForm, UseFormReturn } from 'react-hook-form'; +import { useSearchParams } from 'react-router-dom'; +import { AssetIds, Balance, Pool, TradeType } from '../../generated/graphql'; +import { readActiveAccount } from '../../hooks/accounts/lib/readActiveAccount'; +import { useGetActiveAccountQuery } from '../../hooks/accounts/queries/useGetActiveAccountQuery'; +import { useGetHistoricalBalancesQuery } from '../../hooks/balances/queries/useGetHistoricalBalancesQuery'; +import { useMath } from '../../hooks/math/useMath'; +import { useSubmitTradeMutation } from '../../hooks/pools/mutations/useSubmitTradeMutation'; +import { useGetPoolByAssetsQuery } from '../../hooks/pools/queries/useGetPoolByAssetsQuery'; +import { useAssetIdsWithUrl } from './hooks/useAssetIdsWithUrl'; +import { Line } from 'react-chartjs-2'; +import { fromPrecision12 } from '../../hooks/math/useFromPrecision'; +import { TradeChart as TradeChartComponent } from '../../components/Chart/TradeChart/TradeChart'; +import './PoolsPage.scss'; +import { + ChartGranularity, + ChartType, + PoolType, +} from '../../components/Chart/shared'; +import BigNumber from 'bignumber.js'; +import { useLoading } from '../../hooks/misc/useLoading'; +import { useGetPoolsQuery } from '../../hooks/pools/queries/useGetPoolsQuery'; + +import KSM from '../../misc/icons/assets/KSM.svg'; +import BSX from '../../misc/icons/assets/BSX.svg'; +import DAI from '../../misc/icons/assets/DAI.svg'; +import Unknown from '../../misc/icons/assets/Unknown.svg'; + +import { useGetActiveAccountTradeBalances } from './queries/useGetActiveAccountTradeBalances'; +// import { ConfirmationType, useWithConfirmation } from '../../hooks/actionLog/useWithConfirmation'; +import { horizontalBar } from '../../components/Chart/ChartHeader/ChartHeader'; +import { PoolsForm, PoolsFormFields, ProvisioningType } from '../../components/Pools/PoolsForm'; +import { idToAsset } from '../TradePage/TradePage'; +import { useRemoveLiquidityMutation } from '../../hooks/pools/mutations/useRemoveLiquidityMutation'; +import { useAddLiquidityMutation } from '../../hooks/pools/mutations/useAddLiquidityMutation'; +import Icon from '../../components/Icon/Icon'; + +export interface TradeAssetIds { + assetIn: string | null; + assetOut: string | null; +} + +export interface TradeChartProps { + pool?: Pool; + isPoolLoading?: boolean; + assetIds: TradeAssetIds; + spotPrice?: { + outIn?: string; + inOut?: string; + }; +} + +export const PoolsPage = () => { + // taking assetIn/assetOut from search params / query url + const [assetIds, setAssetIds] = useAssetIdsWithUrl(); + const { data: activeAccountData } = useGetActiveAccountQuery({ + fetchPolicy: 'cache-only', + }); + const { math } = useMath(); + // progress, not broadcast because we dont wait for broadcast to happen here + const [notification, setNotification] = useState< + 'standby' | 'pending' | 'success' | 'failed' + >('standby'); + + const depsLoading = useLoading(); + const { + data: poolData, + loading: poolLoading, + networkStatus: poolNetworkStatus, + } = useGetPoolByAssetsQuery( + { + assetInId: + (assetIds.assetIn! > assetIds.assetOut! + ? assetIds.assetIn + : assetIds.assetOut) || undefined, + assetOutId: + (assetIds.assetIn! > assetIds.assetOut! + ? assetIds.assetOut + : assetIds.assetIn) || undefined, + }, + depsLoading + ); + + const { + data: poolsData, + networkStatus: poolsNetworkStatus, + } = useGetPoolsQuery({ + skip: depsLoading, + }); + + const assets = useMemo(() => { + const assets = poolsData?.pools + ?.map((pool) => { + return [pool.assetInId, pool.assetOutId]; + }) + .reduce((assets, poolAssets) => { + return assets.concat(poolAssets); + }, []) + .map((id) => id); + + return uniq(assets).map((id) => ({ id })); + }, [poolsData]); + + const pool = useMemo(() => poolData?.pool, [poolData]); + + const isActiveAccountConnected = useMemo(() => { + return !!activeAccountData?.activeAccount; + }, [activeAccountData]); + + const clearNotificationIntervalRef = useRef(); + + // const { + // mutation: [ + // submitTrade, + // { loading: tradeLoading, error: tradeError }, + // ], + // confirmationScreen + // } = useWithConfirmation( + // useSubmitTradeMutation({ + // onCompleted: () => { + // setNotification('success'); + // clearNotificationIntervalRef.current = setTimeout(() => { + // setNotification('standby'); + // }, 4000); + // }, + // onError: () => { + // setNotification('failed'); + // clearNotificationIntervalRef.current = setTimeout(() => { + // setNotification('standby'); + // }, 4000); + // }, + // }), + // ConfirmationType.Trade + // ); + + const [ + removeLiquidity, + { loading: removeLiquidityLoading, error: removeLiquidityError }, + ] = useRemoveLiquidityMutation({ + onCompleted: () => { + setNotification('success'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + onError: () => { + setNotification('failed'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + }); + + const [ + addLiquidity, + { loading: addLiquidityLoading, error: addLiquidityLError }, + ] = useAddLiquidityMutation({ + onCompleted: () => { + setNotification('success'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + onError: () => { + setNotification('failed'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + }); + + console.log('removeLiquidityError', removeLiquidityError) + + useEffect(() => { + if (removeLiquidityLoading || addLiquidityLoading) setNotification('pending'); + }, [removeLiquidityLoading, addLiquidityLoading]); + + const handleSubmit = useCallback( + (variables: PoolsFormFields & { amountBMaxLimit?: string }) => { + clearNotificationIntervalRef.current && + clearTimeout(clearNotificationIntervalRef.current); + clearNotificationIntervalRef.current = null; + if (variables.provisioningType === ProvisioningType.Remove) { + console.log('removing liquidity', variables); + if (!variables.assetIn || !variables.assetOut || !variables.shareAssetAmount) return; + removeLiquidity({ + variables: { + assetA: variables.assetIn, + assetB: variables.assetOut, + amount: variables.shareAssetAmount + } + }); + } else { + console.log('adding liquidity', variables); + if (!variables.assetIn || !variables.assetOut || !variables.assetInAmount || !variables.amountBMaxLimit) return; + + addLiquidity({ + variables: { + assetA: variables.assetIn, + assetB: variables.assetOut, + amountA: variables.assetInAmount, + amountBMaxLimit: variables.amountBMaxLimit + } + }) + } + }, + [removeLiquidity] + ); + + const assetOutLiquidity = useMemo(() => { + const assetId = assetIds.assetOut || undefined; + return find(pool?.balances, { assetId })?.balance; + }, [pool, assetIds]); + + const assetInLiquidity = useMemo(() => { + const assetId = assetIds.assetIn || undefined; + return find(pool?.balances, { assetId })?.balance; + }, [pool, assetIds]); + + const spotPrice = useMemo(() => { + if (!assetOutLiquidity || !assetInLiquidity || !math) return; + let spotPrice = { + outIn: math.xyk.get_spot_price( + assetOutLiquidity, + assetInLiquidity, + '1000000000000' + ), + inOut: math.xyk.get_spot_price( + assetInLiquidity, + assetOutLiquidity, + '1000000000000' + ), + }; + + // spotPrice = { + // outIn: new BigNumber(spotPrice.outIn!).dividedBy(1000).toFixed(3), + // inOut: new BigNumber(spotPrice.inOut!).dividedBy(1000).toFixed(3) + // } + + console.log('limit spotPrice', spotPrice) + + return spotPrice; + }, [assetOutLiquidity, assetInLiquidity, math]); + + const { + data: activeAccountTradeBalancesData, + networkStatus: activeAccountTradeBalancesNetworkStatus, + } = useGetActiveAccountTradeBalances({ + variables: { + assetInId: + (assetIds.assetIn! > assetIds.assetOut! + ? assetIds.assetIn + : assetIds.assetOut) || undefined, + assetOutId: + (assetIds.assetIn! > assetIds.assetOut! + ? assetIds.assetOut + : assetIds.assetIn) || undefined, + shareTokenId: pool?.shareTokenId || undefined + }, + }); + + const tradeBalances = useMemo(() => { + const balances = activeAccountTradeBalancesData?.activeAccount?.balances; + + const outBalance = find(balances, { + assetId: assetIds.assetOut, + }) as Balance | undefined; + + const inBalance = find(balances, { + assetId: assetIds.assetIn, + }) as Balance | undefined; + + const shareBalance = find(balances, { + assetId: pool?.shareTokenId, + }) as Balance | undefined; + + console.log('share balance', balances, shareBalance); + + return { outBalance, inBalance, shareBalance }; + }, [activeAccountTradeBalancesData, assetIds, pool]); + + return ( +
+ {/* {confirmationScreen} */} +
+
Transaction {notification}
+
+ +
+
+
+ {/* */} + setAssetIds(assetIds)} + isActiveAccountConnected={isActiveAccountConnected} + pool={pool} + // first load and each time the asset ids (variables) change + isPoolLoading={ + poolNetworkStatus === NetworkStatus.loading || + poolNetworkStatus === NetworkStatus.setVariables || + depsLoading + } + assetInLiquidity={assetInLiquidity} + assetOutLiquidity={assetOutLiquidity} + spotPrice={spotPrice} + onSubmit={handleSubmit} + tradeLoading={removeLiquidityLoading || addLiquidityLoading} + assets={assets} + activeAccount={activeAccountData?.activeAccount} + activeAccountTradeBalances={tradeBalances} + activeAccountTradeBalancesLoading={ + activeAccountTradeBalancesNetworkStatus === NetworkStatus.loading || + activeAccountTradeBalancesNetworkStatus === + NetworkStatus.setVariables || + depsLoading + } + /> +
+
+ ); +}; diff --git a/src/pages/PoolsPage/graphql/GetActiveAccountTradeBalances.query.graphql b/src/pages/PoolsPage/graphql/GetActiveAccountTradeBalances.query.graphql new file mode 100644 index 00000000..667a372e --- /dev/null +++ b/src/pages/PoolsPage/graphql/GetActiveAccountTradeBalances.query.graphql @@ -0,0 +1,13 @@ +query GetActiveAccountTradeBalances($assetInId: String, $assetOutId: String, $shareTokenId: String) { + lastBlock @client { + parachainBlockNumber + relaychainBlockNumber + } + + activeAccount @client { + balances(assetIds: [$assetInId, $assetOutId, $shareTokenId]) { + assetId, + balance + } + } +} \ No newline at end of file diff --git a/src/pages/PoolsPage/hooks/useAssetIdsWithUrl.tsx b/src/pages/PoolsPage/hooks/useAssetIdsWithUrl.tsx new file mode 100644 index 00000000..372cab75 --- /dev/null +++ b/src/pages/PoolsPage/hooks/useAssetIdsWithUrl.tsx @@ -0,0 +1,31 @@ +import { Dispatch, SetStateAction, useEffect, useState } from "react"; +import { useSearchParams, useNavigate, createSearchParams } from "react-router-dom"; +import { TradeAssetIds } from "../PoolsPage"; +import { useDebugBoxContext } from "./useDebugBox"; +import { idToAsset } from "../../TradePage/TradePage"; + +export const useAssetIdsWithUrl = (): [TradeAssetIds, Dispatch>] => { + const [searchParams] = useSearchParams(); + const assetOut = idToAsset(searchParams.get('assetOut')); + const assetIn = idToAsset(searchParams.get('assetIn')); + const [assetIds, setAssetIds] = useState({ + // with default values if the router params are empty + assetIn: assetIn?.id, + assetOut: assetOut?.id || '0' + }); + + const navigate = useNavigate(); + const { debugBoxEnabled } = useDebugBoxContext(); + + useEffect(() => { + assetIds.assetIn && assetIds.assetOut && navigate({ + search: `?${createSearchParams({ + assetIn: assetIds.assetIn, + assetOut: assetIds.assetOut, + ...(debugBoxEnabled ? { debug: 'true' } : null) + })}` + }); + }, [assetIds, searchParams, debugBoxEnabled]); + + return [assetIds, setAssetIds]; + } \ No newline at end of file diff --git a/src/pages/PoolsPage/hooks/useDebugBox.tsx b/src/pages/PoolsPage/hooks/useDebugBox.tsx new file mode 100644 index 00000000..6598198b --- /dev/null +++ b/src/pages/PoolsPage/hooks/useDebugBox.tsx @@ -0,0 +1,52 @@ +import constate from 'constate'; +import log from 'loglevel'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import ReactJson from 'react-json-view' +import classNames from 'classnames'; + +export const useDebugBox = () => { + const [searchParams] = useSearchParams(); + const debugBoxEnabled = !!searchParams.get('debug'); + const [debugData, setDebugData] = useState({}); + + const debugComponent = useCallback( + (component: string, data: any) => { + // setTimeout(() => {}) + setDebugData((debugData: any) => ({ + ...debugData, + [component]: data, + })); + }, + [setDebugData] + ); + + useEffect(() => { + if (debugBoxEnabled) log.setLevel('info'); + }, [debugBoxEnabled]); + + const [position, setPosition] = useState<'right' | 'left' | 'bottom'>('bottom'); + const [visible, setVisible] = useState(debugBoxEnabled); + + const debugBox = useMemo(() => { + if (!debugBoxEnabled) return <>; + return ( + //
{JSON.stringify(debugData, undefined, 2)}
+
+ + + + +
+ +
+
+ ); + }, [debugData, debugBoxEnabled, position, visible]); + + return { debugComponent, debugBox, debugBoxEnabled }; +}; + +export const [DebugBoxProvider, useDebugBoxContext] = constate(useDebugBox); diff --git a/src/pages/PoolsPage/queries/useGetActiveAccountTradeBalances.tsx b/src/pages/PoolsPage/queries/useGetActiveAccountTradeBalances.tsx new file mode 100644 index 00000000..48465baa --- /dev/null +++ b/src/pages/PoolsPage/queries/useGetActiveAccountTradeBalances.tsx @@ -0,0 +1,26 @@ +import { QueryHookOptions, useQuery } from '@apollo/client'; +import { loader } from 'graphql.macro'; +import { Balance } from '../../../generated/graphql'; +const GET_ACTIVE_ACCOUNT_TRADE_BALANCES = loader( + './../graphql/GetActiveAccountTradeBalances.query.graphql' +); + +export interface GetActiveAccountTradeBalancesQueryVariables { + assetInId?: string; + assetOutId?: string; + shareTokenId?: string; +} + +export interface GetActiveAccountTradeBalancesQueryResponse { + activeAccount?: { + balances: Balance[] + } +} + +export const useGetActiveAccountTradeBalances = ( + options: QueryHookOptions +) => + useQuery(GET_ACTIVE_ACCOUNT_TRADE_BALANCES, { + notifyOnNetworkStatusChange: true, + ...options + }); diff --git a/src/pages/TradePage/TradePage.scss b/src/pages/TradePage/TradePage.scss index 40a95fa8..4226ba4b 100644 --- a/src/pages/TradePage/TradePage.scss +++ b/src/pages/TradePage/TradePage.scss @@ -12,75 +12,171 @@ width: 100%; border-radius: $border-radius; - overflow: hidden; } .notifications-bar { display: flex; - justify-content: center; + flex-direction: row; + justify-content: space-between; + align-items: center; - position: absolute; - right: 0; - top: 0; - font-size: 14px; - font-weight: 600; - height: 50px; - padding: 0 16px; - width: 200px; + font-size: 16px; + line-height: 16px; + font-weight: 500; + // height: 64px; + padding: 24px 24px; + padding-left: 29px; + width: 330px; + z-index: 10; + color: $l-gray2; + // color: $white1; + + margin: 0 auto; background-color: $d-gray5; - border-radius: $border-radius; + border-radius: 7px; + // border-top-left-radius: 0px; + // border-bottom-left-radius: 0px;; + + transition: right 200ms ease, background-color, 200ms ease; + + top: 90px; + right: 20px; + position: fixed; + + .notification-cancel-wrapper { + width: fit-content; + + .notification-cancel-button { + border: none; + outline: none; + padding: 0; + background: none; + user-select: none; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + + svg { + // fill: $gray5; + path { + fill: $gray1; + } + + &:hover { + path { + fill: $gray4; + } + } + width: 10px; + height: 10px; + } + } + } - transition: top 200ms ease, background-color, 200ms ease; + opacity: 1; + visibility: visible; &.transaction-standby { top: 0; - background-color: $d-gray5; + background-color: transparent; + opacity: 0; + visibility: hidden; + transition: none; - .notification { - visibility: hidden; - } + // .notification { + // visibility: hidden; + // } + // .notification-cancel-wrapper { + // visibility: hidden; + // } } &.transaction-success { - top: -24px; - background-color: $green2; - color: $black; + // color: $green2; + &:before { + position: absolute; + content: ''; + left: 0px; + top: 0; + width: 7px; + height: 100%; + background: $green1; + border-top-left-radius: 7px; + border-bottom-left-radius: 7px; + opacity: 0.9; + } } &.transaction-failed { - top: -24px; - background-color: $red1; - color: $black; + // color: $red1; + + &:before { + position: absolute; + content: ''; + left: 0px; + top: 0; + width: 7px; + height: 100%; + background: $red1; + border-top-left-radius: 7px; + border-bottom-left-radius: 7px; + opacity: 0.9; + } } &.transaction-pending { - top: -24px; - background-color: $orange1; - color: $black; + display: flex; + // color: $orange1; - .notification { - display: flex; - line-height: 20px; - &:before { - content: ' '; - display: block; - width: 14px; - height: 14px; - margin: 4px 4px 0 0; - border-radius: 50%; - border: 2px solid $black; - border-color: $black transparent $black transparent; - animation: loader 1.2s linear infinite; - } - @keyframes loader { - 0% { - transform: rotate(0deg); - } - 100% { - transform: rotate(360deg); - } - } + &:before { + position: absolute; + content: ''; + left: 0px; + top: 0; + width: 7px; + height: 100%; + background: $orange1; + border-top-left-radius: 7px; + border-bottom-left-radius: 7px; + opacity: 0.9; + + background: linear-gradient( + 0deg, + #4fffb0 1.27%, + #b3ff8f 48.96%, + #ff984e 104.14% + ), + linear-gradient(90deg, #4fffb0 1.27%, #a2ff76 53.24%, #ff984e 104.14%), + linear-gradient(90deg, #ffce4f 1.27%, #4fffb0 104.14%); } + + // .notification { + // display: flex; + // flex-direction: row; + // justify-content: center; + // align-items: center; + + // &:before { + // content: ' '; + // display: block; + // width: 14px; + // height: 14px; + // margin: 4px 4px 0 0; + // border-radius: 50%; + // border: 2px solid $black; + // border-color: $orange1 transparent $orange1 transparent; + // animation: loader 1.2s linear infinite; + // } + // @keyframes loader { + // 0% { + // transform: rotate(0deg); + // } + // 100% { + // transform: rotate(360deg); + // } + // } + // } } } diff --git a/src/pages/TradePage/TradePage.tsx b/src/pages/TradePage/TradePage.tsx index 93dae559..5cb721bf 100644 --- a/src/pages/TradePage/TradePage.tsx +++ b/src/pages/TradePage/TradePage.tsx @@ -1,7 +1,8 @@ import { NetworkStatus, useApolloClient } from '@apollo/client'; import classNames from 'classnames'; -import { find, uniq } from 'lodash'; +import { find, uniq, last } from 'lodash'; import moment from 'moment'; +import { usePageVisibility } from 'react-page-visibility'; import { Dispatch, SetStateAction, @@ -41,6 +42,9 @@ import DAI from '../../misc/icons/assets/DAI.svg'; import Unknown from '../../misc/icons/assets/Unknown.svg'; import { useGetActiveAccountTradeBalances } from './queries/useGetActiveAccountTradeBalances'; +// import { ConfirmationType, useWithConfirmation } from '../../hooks/actionLog/useWithConfirmation'; +import { horizontalBar } from '../../components/Chart/ChartHeader/ChartHeader'; +import Icon from '../../components/Icon/Icon'; export interface TradeAssetIds { assetIn: string | null; @@ -80,13 +84,18 @@ export const idToAsset = (id: string | null) => { }, '3': { id: '3', - symbol: 'DAI', - fullName: 'DAI Stablecoin', + symbol: 'LP BSX/KSM', + fullName: 'BSX/KSM Share token', icon: DAI, }, }; - return assetMetadata[id!] as any; + return assetMetadata[id!] as any || id && { + id, + symbol: horizontalBar, + fullName: `Unknown asset ${id}`, + icon: Unknown + }; }; export const TradeChart = ({ @@ -95,14 +104,18 @@ export const TradeChart = ({ spotPrice, isPoolLoading, }: TradeChartProps) => { + const isVisible = usePageVisibility(); + const [historicalBalancesRange, setHistoricalBalancesRange] = useState({ + from: moment().subtract(1, 'days').toISOString(), + to: moment().toISOString(), + }); const { math } = useMath(); const { data: historicalBalancesData, networkStatus: historicalBalancesNetworkStatus, } = useGetHistoricalBalancesQuery( { - from: useMemo(() => moment().subtract(1, 'days').toISOString(), []), - to: useMemo(() => moment().toISOString(), []), + ...historicalBalancesRange, quantity: 100, // defaulting to an empty string like this is bad, if we want to use skip we should type the variables differently poolId: pool?.id || '', @@ -121,6 +134,7 @@ export const TradeChart = ({ const [dataset, setDataset] = useState>(); const [datasetLoading, setDatasetLoading] = useState(true); + const [datasetRefreshing, setDatasetRefreshing] = useState(false); const assetOutLiquidity = useMemo(() => { const assetId = assetIds.assetOut || undefined; @@ -196,6 +210,7 @@ export const TradeChart = ({ }); setDataset(dataset); + setDatasetRefreshing(false); setDatasetLoading(false); }, [ historicalBalancesData?.historicalBalances, @@ -205,6 +220,43 @@ export const TradeChart = ({ assetIds, ]); + useEffect(() => { + const lastRecordOutdatedBy = 60000; + + if ( + !isVisible || + historicalBalancesLoading || + datasetRefreshing + ) + return; + + const refetchHistoricalBalancesData = () => { + if ( + isVisible && !historicalBalancesLoading && !datasetRefreshing && + (!dataset?.length || last(dataset).x <= new Date().getTime() - lastRecordOutdatedBy) + ) { + setDatasetRefreshing(true); + setHistoricalBalancesRange({ + from: moment().subtract(1, 'days').toISOString(), + to: moment().toISOString(), + }); + } + }; + + refetchHistoricalBalancesData(); + + const refetchData = setInterval(() => { + refetchHistoricalBalancesData(); + }, lastRecordOutdatedBy) + + return () => clearInterval(refetchData) + }, [ + dataset, + isVisible, + historicalBalancesLoading, + datasetRefreshing, + ]); + // useEffect(() => { // setDataset(dataset => { // if (!spotPrice || !dataset) return dataset; @@ -220,12 +272,16 @@ export const TradeChart = ({ // }) // }, [pool, spotPrice,]) - const _isPoolLoading = useMemo( - () => isPoolLoading || historicalBalancesLoading || datasetLoading, - [datasetLoading, isPoolLoading, historicalBalancesLoading] - ); + const _isPoolLoading = useMemo(() => { + if (!isPoolLoading || datasetRefreshing) return false; - console.log('graph loading status _isPoolLoading', _isPoolLoading); + return isPoolLoading || historicalBalancesLoading || datasetLoading; + }, [ + datasetRefreshing, + datasetLoading, + isPoolLoading, + historicalBalancesLoading, + ]); return ( { const clearNotificationIntervalRef = useRef(); - const [ - submitTrade, - { loading: tradeLoading, error: tradeError }, - ] = useSubmitTradeMutation({ - onCompleted: () => { - setNotification('success'); - clearNotificationIntervalRef.current = setTimeout(() => { - setNotification('standby'); - }, 4000); - }, - onError: () => { - setNotification('failed'); - clearNotificationIntervalRef.current = setTimeout(() => { - setNotification('standby'); - }, 4000); - }, - }); + const [submitTrade, { loading: tradeLoading, error: tradeError }] = + useSubmitTradeMutation({ + onCompleted: () => { + setNotification('success'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + onError: () => { + setNotification('failed'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + }); useEffect(() => { if (tradeLoading) setNotification('pending'); @@ -393,11 +447,20 @@ export const TradePage = () => { return (
+ {/* {confirmationScreen} */}
-
transaction {notification}
+
Transaction {notification}
+
+ +
- { poolNetworkStatus === NetworkStatus.setVariables || depsLoading } - /> + /> */} setAssetIds(assetIds)} diff --git a/src/pages/TradePage/hooks/useAssetIdsWithUrl.tsx b/src/pages/TradePage/hooks/useAssetIdsWithUrl.tsx index 3b322780..0a09299b 100644 --- a/src/pages/TradePage/hooks/useAssetIdsWithUrl.tsx +++ b/src/pages/TradePage/hooks/useAssetIdsWithUrl.tsx @@ -16,7 +16,6 @@ export const useAssetIdsWithUrl = (): [TradeAssetIds, Dispatch { assetIds.assetIn && assetIds.assetOut && navigate({ search: `?${createSearchParams({ diff --git a/src/pages/WalletPage.tsx b/src/pages/WalletPage.tsx deleted file mode 100644 index 32c9a9a5..00000000 --- a/src/pages/WalletPage.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { useMemo } from 'react'; -import { Account as AccountModel } from '../generated/graphql'; -import { useSetActiveAccountMutation } from '../hooks/accounts/mutations/useSetActiveAccountMutation'; -import { useGetAccountsQuery } from '../hooks/accounts/queries/useGetAccountsQuery'; -import { usePersistActiveAccount } from '../hooks/accounts/lib/usePersistActiveAccount'; - -export const Account = ({ account }: { account?: AccountModel }) => { - // TODO: you can get the loading state of the mutation here as well - // but it probably needs to be turned into a contextual mutation - // in order to share the loading state accross multiple mutation hook calls - const [setActiveAccount] = useSetActiveAccountMutation(); - - const { persistedActiveAccount } = usePersistActiveAccount(); - - return ( -
-

- {account?.name} - {account?.id === persistedActiveAccount?.id ? ' [active]' : <>} -

-

- Address: - {account?.id} -

-
- Balances: - {account?.balances.map((balance, i) => ( -

- {balance.assetId}: {balance.balance} -

- ))} -
- -
- ); -}; - -export const WalletPage = () => { - const { data: accountsData, loading: accountsLoading } = - useGetAccountsQuery(false); - - const loading = useMemo(() => { - return accountsLoading; - }, [accountsLoading]); - - return ( -
-

Accounts

- - {loading ? ( - [WalletPage] Loading accounts... - ) : ( - [WalletPage] Everything is up to date - )} - -
-
- - { -
- {accountsData?.accounts?.map((account, i) => ( - - ))} -
- } -
- ); -}; diff --git a/src/pages/WalletPage/WalletPage.scss b/src/pages/WalletPage/WalletPage.scss new file mode 100644 index 00000000..eafd2dbd --- /dev/null +++ b/src/pages/WalletPage/WalletPage.scss @@ -0,0 +1,22 @@ +@import '../../misc/colors.module.scss'; +@import '../../misc/misc.module.scss'; +@import '../TradePage/TradePage.scss'; + +.wallet-page { + min-width: 800px; + max-width: 1200px; + padding: 32px; + color: white; + margin: 0px auto; + color: white; + + .modal-button-container { + width: 100%; + display: flex; + justify-content: center; + } + + .notifications-bar { + @extend .notifications-bar; + } +} diff --git a/src/pages/WalletPage/WalletPage.tsx b/src/pages/WalletPage/WalletPage.tsx new file mode 100644 index 00000000..03e31167 --- /dev/null +++ b/src/pages/WalletPage/WalletPage.tsx @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + Account, + Account as AccountModel, + Balance, + Maybe, + Vesting, + VestingSchedule, +} from '../../generated/graphql'; +import { useSetActiveAccountMutation } from '../../hooks/accounts/mutations/useSetActiveAccountMutation'; +import { useGetAccountsQuery } from '../../hooks/accounts/queries/useGetAccountsQuery'; +import { usePersistActiveAccount } from '../../hooks/accounts/lib/usePersistActiveAccount'; +import { + useGetActiveAccountQuery, + useGetActiveAccountQueryContext, +} from '../../hooks/accounts/queries/useGetActiveAccountQuery'; +import { NetworkStatus } from '@apollo/client'; +import { useLoading } from '../../hooks/misc/useLoading'; +import { + useGetExtensionQuery, + useGetExtensionQueryContext, +} from '../../hooks/extension/queries/useGetExtensionQuery'; +import { useModalPortalElement } from '../../components/Wallet/AccountSelector/hooks/useModalPortalElement'; +import { useAccountSelectorModal } from '../../containers/Wallet/hooks/useAccountSelectorModal'; +import { FormattedBalance } from '../../components/Balance/FormattedBalance/FormattedBalance'; +import BigNumber from 'bignumber.js'; +import { fromPrecision12 } from '../../hooks/math/useFromPrecision'; +import { useClaimVestedAmountMutation } from '../../hooks/vesting/useClaimVestedAmountMutation'; +import { BalanceList } from './containers/WalletPage/BalanceList/BalanceList'; +import { VestingClaim } from './containers/WalletPage/VestingClaim/VestingClaim'; +import { ActiveAccount } from './containers/WalletPage/ActiveAccount/ActiveAccount'; +import { useTransferFormModalPortal } from './containers/WalletPage/TransferForm/hooks/useTransferFormModalPortal'; +import { useSetConfigMutation } from '../../hooks/config/useSetConfigMutation'; +import { useGetConfigQuery } from '../../hooks/config/useGetConfigQuery'; +import './WalletPage.scss'; +import Icon from '../../components/Icon/Icon'; + +export type Notification = 'standby' | 'pending' | 'success' | 'failed'; + +export const WalletPage = () => { + const [notification, setNotification] = useState< + 'standby' | 'pending' | 'success' | 'failed' + >('standby'); + + const { data: extensionData, loading: extensionLoading } = + useGetExtensionQueryContext(); + + const depsLoading = useLoading(); + const { data: activeAccountData, networkStatus: activeAccountNetworkStatus } = + useGetActiveAccountQueryContext(); + + const activeAccount = useMemo( + () => activeAccountData?.activeAccount, + [activeAccountData] + ); + const activeAccountLoading = useMemo( + () => depsLoading || activeAccountNetworkStatus === NetworkStatus.loading, + [depsLoading, activeAccountNetworkStatus] + ); + + const { data: configData, networkStatus: configNetworkStatus } = + useGetConfigQuery({ + skip: activeAccountLoading, + }); + + const configLoading = useMemo(() => { + return depsLoading || configNetworkStatus == NetworkStatus.loading; + }, [configNetworkStatus, depsLoading]); + + // couldnt really quickly figure out how to use just activeAccount + extension loading states + // so depsLoading is reused here as well + const loading = useMemo( + () => + activeAccountLoading || extensionLoading || depsLoading || configLoading, + [activeAccountLoading, extensionLoading, depsLoading, configLoading] + ); + + const modalContainerRef = useRef(null); + const { modalPortal, openModal } = useAccountSelectorModal({ + modalContainerRef, + }); + + const assets = useMemo(() => { + return activeAccount?.balances.map((balance) => ({ id: balance.assetId })); + }, [activeAccount]); + + const { + modalPortal: transferFormModalPortal, + openModal: openTransferFormModalPortal, + } = useTransferFormModalPortal(modalContainerRef, setNotification, assets); + + const handleOpenTransformForm = useCallback( + (assetId: string) => { + console.log('asset id', assetId); + openTransferFormModalPortal({ assetId }); + }, + [openTransferFormModalPortal] + ); + + const [setConfigMutation, { loading: setConfigLoading }] = + useSetConfigMutation(); + const clearNotificationIntervalRef = useRef(); + + useEffect(() => { + if (setConfigLoading) setNotification('pending'); + }, [setConfigLoading]); + + const onSetAsFeePaymentAsset = useCallback( + (feePaymentAsset: string) => { + clearNotificationIntervalRef.current && + clearTimeout(clearNotificationIntervalRef.current); + clearNotificationIntervalRef.current = null; + + console.log('setting fee payment asset', feePaymentAsset); + setConfigMutation({ + onCompleted: () => { + setNotification('success'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + onError: () => { + setNotification('failed'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + variables: { + config: { + feePaymentAsset, + }, + }, + }); + }, + [setConfigMutation] + ); + + return ( +
+
+ {modalPortal} + {transferFormModalPortal} +
+
Transaction {notification}
+
+ +
+
+
+ {loading ? ( +
+
+
Wallet loading...
+
+
+ ) : ( +
+ {activeAccount ? ( + <> + + + ) : ( +
+
openModal()}> +
+ Click here to connect an account +
+
+
+ )} +
+ )} +
+
+ ); +}; diff --git a/src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.scss b/src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.scss new file mode 100644 index 00000000..e4e8909d --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.scss @@ -0,0 +1,108 @@ +@import '../../../../../misc/colors.module.scss'; +@import '../../../../../misc/misc.module.scss'; + +.active-account { + min-width: 800px; + max-width: 1200px; + border-radius: $border-radius; + background: linear-gradient(180deg, #1c2527 0%, #14161a 80.73%, #121316 100%); + padding: 32px 0px 0px 0px; + color: white; + margin: 0px 0px 50px 0px; + display: flex; + flex-direction: column; + position: relative; + + &__title { + width: fit-content; + color: $l-gray3; + font-size: 22px; + font-weight: 500; + margin: 0px 32px 24px 32px; + background: linear-gradient( + 90deg, + #4fffb0 1.27%, + #b3ff8f 48.96%, + #ff984e 104.14% + ), + linear-gradient(90deg, #4fffb0 1.27%, #a2ff76 53.24%, #ff984e 104.14%), + linear-gradient(90deg, #ffce4f 1.27%, #4fffb0 104.14%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; + } + + &-wrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 20px 32px; + font-size: 18px; + font-weight: 500; + &:nth-of-type(1) { + border-top: 1px solid rgb(41, 41, 45); + } + + &:nth-child(odd) { + background: rgba(255, 255, 255, 0.06); + } + &:last-child { + border-radius: 0px 0px $border-radius $border-radius; + } + + .item { + width: 50%; + display: flex; + flex-direction: row; + justify-content: left; + align-items: center; + gap: 10px; + + &:first-child { + width: 20%; + } + &:last-child { + width: 30%; + justify-content: right; + } + } + } + + &-button { + height: 40px; + user-select: none; + border-radius: 9999px; + background-color: rgba(76, 243, 168, 0.12); + color: #4fffb0; + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + border: none; + + &:hover { + background: rgba(76, 243, 168, 0.3); + } + + &__label { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 8px 16px; + font-size: 16px; + line-height: 16px; + font-weight: 600; + } + } + + &-actions { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 10px; + } +} diff --git a/src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.tsx b/src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.tsx new file mode 100644 index 00000000..e066bb45 --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/ActiveAccount/ActiveAccount.tsx @@ -0,0 +1,146 @@ +import Identicon from '@polkadot/react-identicon'; +import { encodeAddress, decodeAddress } from '@polkadot/util-crypto'; +import { useCallback } from 'react'; +import { + genesisHashToChain, + sourceToHuman, +} from '../../../../../components/Wallet/AccountSelector/AccountItem/AccountItem'; +import { Account, Maybe } from '../../../../../generated/graphql'; +import { useSetActiveAccountMutation } from '../../../../../hooks/accounts/mutations/useSetActiveAccountMutation'; +import { Notification } from '../../../WalletPage'; +import { BalanceList } from '../BalanceList/BalanceList'; +import { VestingClaim } from '../VestingClaim/VestingClaim'; +import './ActiveAccount.scss'; + +export const ActiveAccount = ({ + account, + loading, + onOpenAccountSelector, + onOpenTransferForm, + onSetAsFeePaymentAsset, + feePaymentAssetId, + setNotification, +}: { + account?: Maybe; + loading: boolean; + feePaymentAssetId?: Maybe; + onOpenAccountSelector: () => void; + onOpenTransferForm: (assetId: string) => void; + onSetAsFeePaymentAsset: (assetId: string) => void; + setNotification: (notification: Notification) => void; +}) => { + const [setActiveAccount] = useSetActiveAccountMutation(); + + const handleClearAccount = useCallback(() => { + setActiveAccount({ variables: { id: undefined } }); + }, [setActiveAccount]); + + return ( + <> + {loading ? ( +
Loading...
+ ) : account ? ( + <> +
+

Active account

+
+
+ Name +
+
Address
+
+
+
+
+
+
+
+ {account.name} +
+
+ {sourceToHuman(account.source)} +
+
+
+
+
+
+ +
+
Basilisk
+
+ {account.id} +
+
+
+ {genesisHashToChain(account.genesisHash).network !== + 'basilisk' ? ( +
+ +
+
+ {genesisHashToChain(account.genesisHash).displayName} +
+
+ {encodeAddress( + decodeAddress(account.id), + genesisHashToChain(account.genesisHash)?.prefix + )} +
+
+
+ ) : ( + <> + )} +
+
+
+
onOpenAccountSelector()} + > +
+ Change account +
+
+
handleClearAccount()} + > +
+ Clear account +
+
+
+
+
+ + {account?.vesting && ( + + )} + + + + ) : ( +
Please connect a wallet first
+ )} + + ); +}; diff --git a/src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.scss b/src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.scss new file mode 100644 index 00000000..49e66776 --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.scss @@ -0,0 +1,41 @@ +@import '../../../../../misc/colors.module.scss'; +@import '../../../../../misc/misc.module.scss'; +@import '../ActiveAccount/ActiveAccount.scss'; + +.balance-list { + @extend .active-account; + + &__title { + @extend .active-account__title; + } + + &-wrapper { + @extend .active-account-wrapper; + + .item { + width: 30%; + display: flex; + flex-direction: row; + justify-content: left; + align-items: center; + gap: 10px; + + &:last-child { + width: 40%; + justify-content: right; + } + } + } + + &-button { + @extend .active-account-button; + &__label { + @extend .active-account-button__label; + } + } + + &-actions { + @extend .active-account-actions; + justify-content: right; + } +} diff --git a/src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.tsx b/src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.tsx new file mode 100644 index 00000000..0741a721 --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/BalanceList/BalanceList.tsx @@ -0,0 +1,66 @@ +import { Balance, Maybe } from '../../../../../generated/graphql'; +import { FormattedBalance } from '../../../../../components/Balance/FormattedBalance/FormattedBalance'; +import { idToAsset } from '../../../../TradePage/TradePage'; +import { horizontalBar } from '../../../../../components/Chart/ChartHeader/ChartHeader'; +import './BalanceList.scss'; + +export const availableFeePaymentAssetIds = ['0', '1']; + +export const BalanceList = ({ + balances, + onOpenTransferForm, + onSetAsFeePaymentAsset, + feePaymentAssetId, +}: { + balances?: Array; + feePaymentAssetId?: Maybe; + onOpenTransferForm: (assetId: string) => void; + onSetAsFeePaymentAsset: (assetId: string) => void; +}) => { + return ( +
+

Balance

+
+
Asset Name
+
Balance
+
+
+ {/* TODO: ordere by assetId? */} + {balances?.map((balance) => ( +
+
+ {idToAsset(balance.assetId || null)?.fullName || + `Unknown asset (ID: ${balance.assetId})`} + + {feePaymentAssetId === balance.assetId ? ' - fee asset' : ''} +
+ +
+ {/* TODO: how to deal with unknown assets? (not knowing the metadata e.g. symbol/fullname) */} + +
+
+ {availableFeePaymentAssetIds.includes(balance.assetId) ? ( + + ) : ( + <> + )} + +
+
+ ))} +
+ ); +}; diff --git a/src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.scss b/src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.scss new file mode 100644 index 00000000..2accc589 --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.scss @@ -0,0 +1,120 @@ +@import '../../../../../misc/colors.module.scss'; +@import '../../../../../misc/misc.module.scss'; + +.transfer-form { + position: fixed; + top: 0; + left: 0; + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: 100%; + + background: rgba(50, 50, 50, 0.5); + color: white; + + z-index: 3; + + .transfer-form-container { + width: 460px; + } + + &__content-wrapper { + min-height: fit-content; + max-height: 85vh; + padding: 16px; + + border-radius: $border-radius; + background: linear-gradient( + 180deg, + #1c2527 0%, + #14161a 80.73%, + #121316 100% + ); + position: relative; + } + + .transfer-form-heading { + display: flex; + justify-content: space-between; + width: 100%; + padding: 8px 16px 0px 16px; + } + + .transfer-form-title { + width: fit-content; + padding-top: 4px; + color: $l-gray3; + font-size: 22px; + font-weight: 500; + background: linear-gradient( + 90deg, + #4fffb0 1.27%, + #b3ff8f 48.96%, + #ff984e 104.14% + ), + linear-gradient(90deg, #4fffb0 1.27%, #a2ff76 53.24%, #ff984e 104.14%), + linear-gradient(90deg, #ffce4f 1.27%, #4fffb0 104.14%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-fill-color: transparent; + } + + &__transfer-form-parent { + display: flex; + flex-direction: column; + gap: 16px; + padding: 16px; + } + + &__transfer-form-fee { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 10px; + color: #bdccd4; + font-size: 15px; + font-weight: 400; + } + + &__transfer-form-address-input { + border-radius: $border-radius; + height: 52px; + flex-grow: 1; + font-weight: 600; + padding: 16px 12px; + background-color: rgba(218, 255, 238, 0.06); + box-shadow: 0 0 0 1px rgb(255 255 238 / 30%); + } + + &__submit-button { + user-select: none; + border-radius: 9999px; + width: 100%; + height: 50px; + background-color: #4fffb0; + color: #26282f; + + &:hover { + background-color: #41db96; + } + } + + &__submit-button:disabled { + background-color: rgba(255, 255, 255, 0.2); + color: #a2b0b8; + } + + &__transfer-form-asset-input-container { + display: flex; + flex-direction: column; + background: rgba(162, 176, 187, 0.1); + color: $green1; + padding: 12px; + border-radius: 10px; + gap: 6px; + } +} diff --git a/src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.tsx b/src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.tsx new file mode 100644 index 00000000..36c80920 --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/TransferForm/TransferForm.tsx @@ -0,0 +1,183 @@ +import { useApolloClient } from '@apollo/client'; +import { watch } from 'fs'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { AssetBalanceInput } from '../../../../../components/Balance/AssetBalanceInput/AssetBalanceInput'; +import { FormattedBalance } from '../../../../../components/Balance/FormattedBalance/FormattedBalance'; +import Icon from '../../../../../components/Icon/Icon'; +import { useMultiFeePaymentConversionContext } from '../../../../../containers/MultiProvider'; +import { Asset } from '../../../../../generated/graphql'; +import { estimateBalanceTransfer } from '../../../../../hooks/balances/resolvers/mutation/balanceTransfer'; +import { useTransferBalanceMutation } from '../../../../../hooks/balances/resolvers/useTransferMutation'; +import { usePolkadotJsContext } from '../../../../../hooks/polkadotJs/usePolkadotJs'; +import { Notification } from '../../../WalletPage'; +import './TransferForm.scss'; + +export const TransferForm = ({ + closeModal, + assetId = '0', + setNotification, + assets, +}: { + closeModal: () => void; + assetId?: string; + setNotification: (notification: Notification) => void; + assets?: Asset[]; +}) => { + const modalContainerRef = useRef(null); + const form = useForm({ + // mode: 'all', + defaultValues: { + asset: assetId, + to: undefined, + amount: undefined, + submit: undefined, + }, + }); + + const [transferBalance] = useTransferBalanceMutation(); + + const clearNotificationIntervalRef = useRef(); + const handleSubmit = useCallback( + (data: any) => { + // this is not ideal, but we want to show the pending status + // which is hidden behind the modal currently + closeModal(); + setNotification('pending'); + transferBalance({ + variables: { + currencyId: data.asset, + amount: data.amount, + to: data.to, + }, + onCompleted: () => { + setNotification('success'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + onError: () => { + setNotification('failed'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + }); + }, + [closeModal, setNotification, transferBalance] + ); + + console.log('form state', form.formState); + + useEffect(() => { + form.trigger('submit'); + }, [form.watch(['submit', 'amount', 'to', 'asset'])]); + + const [txFee, setTxFee] = useState(); + const { apiInstance, loading: apiInstanceLoading } = usePolkadotJsContext(); + const client = useApolloClient(); + const { convertToFeePaymentAsset, feePaymentAsset } = + useMultiFeePaymentConversionContext(); + + useEffect(() => { + if (!apiInstance || apiInstanceLoading) return; + (async () => { + console.log('reestimating', { + from: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + to: + form.getValues('to') || + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + currencyId: form.getValues('asset') || '0', + amount: form.getValues('amount') || '0', + }); + const estimate = await estimateBalanceTransfer( + client.cache, + apiInstance, + { + from: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + to: + form.getValues('to') || + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + currencyId: form.getValues('asset') || '0', + amount: form.getValues('amount') || '0', + } + ); + setTxFee(estimate.partialFee.toString()); + })(); + }, [ + apiInstance, + apiInstanceLoading, + client, + form.watch(['amount', 'asset', 'to']), + ]); + + return ( + <> +
+
+
+
+
Transfer
+
closeModal()}> + +
+
+ + + +
+
+ + {/* TODO: validate address */} + +
+ +
+ + +
+ {/* Form state: {form.formState.isDirty ? 'dirty': 'clean'}, {form.formState.isValid ? 'valid' : 'invalid'} */} +
+ Tx fee:{' '} + {txFee && feePaymentAsset ? ( + + ) : ( + <>- + )} +
+
+
+ form.getValues('asset') !== undefined, + amount: () => form.getValues('amount') !== undefined, + }, + })} + /> +
+ +
+
+
+ + ); +}; diff --git a/src/pages/WalletPage/containers/WalletPage/TransferForm/hooks/useTransferFormModalPortal.tsx b/src/pages/WalletPage/containers/WalletPage/TransferForm/hooks/useTransferFormModalPortal.tsx new file mode 100644 index 00000000..f7b74155 --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/TransferForm/hooks/useTransferFormModalPortal.tsx @@ -0,0 +1,21 @@ +import { useCallback, useRef } from "react" +import { ModalPortalElementFactory, useModalPortal } from "../../../../../../components/Balance/AssetBalanceInput/hooks/useModalPortal" +import { Asset } from "../../../../../../generated/graphql" +import { Notification } from "../../../../WalletPage" +import { TransferForm } from "../TransferForm" + +export const useModalPortalElement = (setNotification: (notification: Notification) => void, assets?: Asset[]) => { + return useCallback>(({ isModalOpen, closeModal, state }) => { + return isModalOpen + ? + : <> + }, [assets, setNotification]) +} + +export const useTransferFormModalPortal = (container: any, setNotification: (notification: Notification) => void, assets?: Asset[]) => { + + return useModalPortal( + useModalPortalElement(setNotification, assets), + container + ) +} \ No newline at end of file diff --git a/src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.scss b/src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.scss new file mode 100644 index 00000000..d4c19eaf --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.scss @@ -0,0 +1,36 @@ +@import '../../../../../misc/colors.module.scss'; +@import '../../../../../misc/misc.module.scss'; +@import '../ActiveAccount/ActiveAccount.scss'; + +.vesting-claim { + @extend .active-account; + + &__title { + @extend .active-account__title; + } + + &-wrapper { + @extend .active-account-wrapper; + + .item { + width: 22.5%; + &:last-child { + width: 10%; + justify-content: right; + } + } + } + + &-button { + @extend .active-account-button; + &__label { + @extend .active-account-button__label; + } + } + + &__fee { + width: 100px; + display: flex; + justify-content: left; + } +} diff --git a/src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.tsx b/src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.tsx new file mode 100644 index 00000000..98d70bb6 --- /dev/null +++ b/src/pages/WalletPage/containers/WalletPage/VestingClaim/VestingClaim.tsx @@ -0,0 +1,127 @@ +import { useApolloClient } from '@apollo/client'; +import BigNumber from 'bignumber.js'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { FormattedBalance } from '../../../../../components/Balance/FormattedBalance/FormattedBalance'; +import { useMultiFeePaymentConversionContext } from '../../../../../containers/MultiProvider'; +import { Maybe, Vesting } from '../../../../../generated/graphql'; +import { fromPrecision12 } from '../../../../../hooks/math/useFromPrecision'; +import { usePolkadotJsContext } from '../../../../../hooks/polkadotJs/usePolkadotJs'; +import { useClaimVestedAmountMutation } from '../../../../../hooks/vesting/useClaimVestedAmountMutation'; +import { estimateClaimVesting } from '../../../../../hooks/vesting/useVestingMutationResolvers'; +import { Notification } from '../../../WalletPage'; +import './VestingClaim.scss'; + +export const VestingClaim = ({ + vesting, + setNotification, +}: { + vesting?: Maybe; + setNotification: (notification: Notification) => void; +}) => { + const isVestingAvailable = useMemo(() => { + return ( + vesting?.originalLockBalance && + new BigNumber(vesting?.originalLockBalance).gt('0') + ); + }, [vesting]); + const clearNotificationIntervalRef = useRef(); + const [claimVestedAmount] = useClaimVestedAmountMutation({ + onCompleted: () => { + setNotification('success'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + onError: () => { + setNotification('failed'); + clearNotificationIntervalRef.current = setTimeout(() => { + setNotification('standby'); + }, 4000); + }, + }); + + // TODO: run mutation with confirmation + const handleClaimClick = useCallback(() => { + setNotification('pending'); + claimVestedAmount(); + }, []); + + const { apiInstance, loading: apiInstanceLoading } = usePolkadotJsContext(); + const client = useApolloClient(); + const { feePaymentAsset, convertToFeePaymentAsset } = + useMultiFeePaymentConversionContext(); + + const [txFee, setTxFee] = useState(); + useEffect(() => { + if (!apiInstance || apiInstanceLoading) return; + (async () => { + const txFee = await estimateClaimVesting( + client.cache as any, + apiInstance, + {} + ); + console.log( + 'claim tx fee', + convertToFeePaymentAsset(txFee.partialFee.toString()) + ); + setTxFee(convertToFeePaymentAsset(txFee.partialFee.toString())); + })(); + }, [ + apiInstance, + apiInstanceLoading, + estimateClaimVesting, + client, + convertToFeePaymentAsset, + feePaymentAsset + ]); + + return ( +
+

Vesting

+
+
Claimable
+
Original vesting
+
Remaining vesting
+
Tx fee
+
+
+ {isVestingAvailable ? ( +
+
+ {fromPrecision12(vesting?.claimableAmount)} BSX +
+
+ {fromPrecision12(vesting?.originalLockBalance)} BSX +
+
+ {fromPrecision12(vesting?.lockedVestingBalance)} BSX +
+
+ {txFee ? ( + + ) : ( + <>- + )} +
+
+ +
+
+ ) : ( +
+ <>No vesting available +
+ )} +
+ ); +}; diff --git a/src/schema.graphql b/src/schema.graphql index 4cebd5fd..e52d212e 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -1,20 +1,18 @@ #import './hooks/accounts/graphql/Accounts.graphql' #import './hooks/lastBlock/graphql/LastBlock.graphql' #import './hooks/config/graphql/Config.graphql' -#import './hooks/vesting/graphql/VestingSchedule.graphql' +#import './hooks/vesting/graphql/Vesting.graphql' #import './hooks/extension/graphql/Extension.graphql' #import './hooks/feePaymentAssets/graphql/FeePaymentAssets.graphql' #import './hooks/pools/graphql/Pool.graphql' #import './hooks/assets/graphql/Asset.graphql' #import './hooks/balances/graphql/LockedBalance.graphql' -# directive @client on FIELD - -# type Query { -# # just a placeholder to make the codegen not complain about -# # root query not being defined -# _empty: String -# } +type Query { + # just a placeholder to make the codegen not complain about + # root query not being defined + _empty: String +} type Mutation { # just a placeholder to make the codegen not complain about diff --git a/yarn.lock b/yarn.lock index 661d4220..8d33e9a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4795,6 +4795,13 @@ dependencies: "@types/react" "*" +"@types/react-page-visibility@^6.4.1": + version "6.4.1" + resolved "https://registry.yarnpkg.com/@types/react-page-visibility/-/react-page-visibility-6.4.1.tgz#21c3bc4a3f310d38d188916cadc55f2bde65f27d" + integrity sha512-vNlYAqKhB2SU1HmF9ARFTFZN0NSPzWn8HSjBpFqYuQlJhsb/aSYeIZdygeqfSjAg0PZ70id2IFWHGULJwe59Aw== + dependencies: + "@types/react" "*" + "@types/react-syntax-highlighter@11.0.5": version "11.0.5" resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.5.tgz#0d546261b4021e1f9d85b50401c0a42acb106087" @@ -11226,9 +11233,13 @@ husky@^7.0.4: resolved "https://registry.yarnpkg.com/husky/-/husky-7.0.4.tgz#242048245dc49c8fb1bf0cc7cfb98dd722531535" integrity sha512-vbaCKN2QLtP/vD4yvs6iz6hBEo6wkSzs8HpRah1Z6aGmF2KW5PdYuAd7uX5a+OyBZHBhd+TFLqgjUgytQr4RvQ== -"hydra-dx-wasm@https://github.com/galacticcouncil/HydraDX-wasm#main": +"hydra-dx-wasm@https://github.com/galacticcouncil/HydraDX-wasm#0e3d625c22c32525a4619047223cac019c0cfa46": + version "3.0.0" + resolved "https://github.com/galacticcouncil/HydraDX-wasm#0e3d625c22c32525a4619047223cac019c0cfa46" + +"hydra-dx-wasm@https://github.com/galacticcouncil/HydraDX-wasm#3cf219bcd74bff72d0810ab6e596e3d2c91a9646": version "3.0.0" - resolved "https://github.com/galacticcouncil/HydraDX-wasm#4451b1dfdef924aab07de97e639b0adf28b5109e" + resolved "https://github.com/galacticcouncil/HydraDX-wasm#3cf219bcd74bff72d0810ab6e596e3d2c91a9646" hyphenate-style-name@^1.0.2: version "1.0.4" @@ -16261,6 +16272,13 @@ react-multi-provider@^0.1.5: resolved "https://registry.yarnpkg.com/react-multi-provider/-/react-multi-provider-0.1.5.tgz#fd712b2340eca3311273fdd9e8e8efd3ac31d7e2" integrity sha512-eJWrjtSPIXZKQxN6ieNPb1TaZHlHSqWFBrwAgvCrhRd2GIFVJqZz08/atQSY421kMVeravzFZv1HfiLbrltiZA== +react-page-visibility@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/react-page-visibility/-/react-page-visibility-6.4.0.tgz#0684fe80338e716c9ed2d34169fa3cbb3882096b" + integrity sha512-5vQ0zQU2DvKCQAxle9l5V6uxw2m180Lk7Jem+obmTeQ503fvMJLSUzFgWtTEgUVynhUx2pd+RzafnuMAG8uD6A== + dependencies: + prop-types "^15.7.2" + react-popper-tooltip@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/react-popper-tooltip/-/react-popper-tooltip-3.1.1.tgz#329569eb7b287008f04fcbddb6370452ad3f9eac"