From c0ab6a2f87a458febf7a1254387388cb2d209375 Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 26 Jul 2023 22:05:57 +0700 Subject: [PATCH] feat: init commit --- .dockerignore | 47 + .editorconfig | 6 + .env | 61 + .eslintignore | 15 + .eslintrc.json | 46 + .github/CODEOWNERS | 2 + .github/dependabot.yml | 7 + .github/pull_request_template.md | 24 + .github/workflows/checks.yml | 11 + .github/workflows/ci-dev.yml | 39 + .github/workflows/ci-preview-demolish.yml | 27 + .github/workflows/ci-preview-deploy.yml | 77 + .github/workflows/ci-prod.yml | 25 + .github/workflows/ci-staging.yml | 40 + .github/workflows/prepare-release-draft.yml | 14 + .github/workflows/tests.yml | 58 + .gitignore | 46 + .husky/.gitignore | 1 + .husky/commit-msg | 4 + .husky/pre-commit | 4 + .prettierignore | 15 + .prettierrc | 6 + CHANGELOG.md | 223 + Dockerfile | 37 + LICENSE | 201 + README.md | 79 + abi/aggregator.abi.json | 325 + abi/aggregatorEthUsdPriceFeed.abi.json | 509 + abi/oracle.abi.json | 528 + abi/steth.abi.json | 726 ++ assets/icons/balancer-banner-icon.svg | 6 + assets/icons/coin98.svg | 1 + assets/icons/coinbase.svg | 1 + assets/icons/cookie.svg | 4 + assets/icons/cookieInverse.svg | 4 + assets/icons/cowswap-circle.svg | 5 + assets/icons/curve.svg | 1523 +++ assets/icons/external-link-icon.svg | 3 + assets/icons/imtoken.svg | 1 + assets/icons/l2-banner-icons.svg | 12 + assets/icons/ledger.svg | 1 + assets/icons/lido.svg | 30 + assets/icons/metamask.svg | 1 + assets/icons/oneinch-circle.svg | 23 + assets/icons/oneinch-info-bg.svg | 21 + assets/icons/oneinch.svg | 10 + assets/icons/paraswap-circle.svg | 4 + assets/icons/request-info.svg | 12 + assets/icons/request-pending.svg | 3 + assets/icons/request-ready.svg | 3 + assets/icons/trust.svg | 1 + assets/icons/walletconnect.svg | 1 + assets/icons/warning.svg | 3 + assets/logo.svg | 1 + assets/nft-example.png | Bin 0 -> 49428 bytes build-info.json | 5 + commitlint.config.cjs | 6 + config/aggregator.ts | 38 + config/api.ts | 22 + config/cache.ts | 42 + config/dynamics.ts | 12 + config/estimate.ts | 13 + config/external-links.ts | 2 + config/index.ts | 19 + config/locale.ts | 1 + config/matomoClickEvents.ts | 310 + config/matomoWalletsEvents.ts | 366 + config/metrics.ts | 7 + config/oracle.ts | 19 + config/rateLimit.ts | 7 + config/rpc.ts | 11 + config/steth.ts | 1 + config/storage.ts | 4 + config/text.ts | 2 + config/trackMatomoEvent.ts | 11 + config/tx.ts | 8 + config/units.ts | 3 + env-dynamics.mjs | 34 + features/home/hooks.tsx | 53 + features/home/index.ts | 5 + features/home/lido-stats/lido-stats.tsx | 79 + features/home/lido-stats/styles.tsx | 8 + features/home/oneinch-info/oneinch-info.tsx | 56 + features/home/oneinch-info/styles.ts | 84 + .../stake-faq/list/how-can-i-get-steth.tsx | 36 + .../list/how-can-i-unstake-steth.tsx | 40 + .../stake-faq/list/how-can-i-use-steth.tsx | 20 + .../stake-faq/list/how-does-lido-work.tsx | 18 + features/home/stake-faq/list/index.ts | 11 + features/home/stake-faq/list/lido-eth-apr.tsx | 37 + features/home/stake-faq/list/lido-fee.tsx | 26 + .../list/risks-of-staking-with-lido.tsx | 67 + .../stake-faq/list/safe-work-with-lido.tsx | 40 + features/home/stake-faq/list/what-is-lido.tsx | 17 + .../home/stake-faq/list/what-is-steth.tsx | 15 + .../list/where-can-i-cover-my-steth.tsx | 61 + features/home/stake-faq/stake-faq.tsx | 37 + features/home/stake-form/hooks.ts | 54 + features/home/stake-form/stake-form.tsx | 255 + features/home/stake-form/styles.tsx | 11 + .../home/stake-form/useStakingLimitWarn.ts | 22 + features/home/stake-form/utils.ts | 186 + .../home/wallet/limit-meter/components.tsx | 109 + features/home/wallet/limit-meter/icons.tsx | 19 + features/home/wallet/limit-meter/index.tsx | 1 + .../home/wallet/limit-meter/limit-meter.tsx | 10 + features/home/wallet/limit-meter/styles.ts | 64 + features/home/wallet/limit-meter/types.ts | 4 + features/home/wallet/styles.tsx | 15 + features/home/wallet/wallet.tsx | 86 + features/referral/banner/banner.tsx | 41 + features/referral/banner/index.ts | 1 + features/referral/banner/styles.ts | 24 + features/referral/index.ts | 1 + .../rewards/components/CopyAddressUrl.tsx | 23 + .../rewards/components/CurrencySelector.tsx | 64 + features/rewards/components/Date.tsx | 23 + features/rewards/components/EthSymbol.tsx | 11 + features/rewards/components/IndexerLink.tsx | 23 + features/rewards/components/NumberFormat.tsx | 73 + .../components/addressInput/AddressInput.tsx | 29 + .../rewards/components/addressInput/index.ts | 1 + .../rewards/components/addressInput/types.ts | 6 + .../components/errorBlocks/ErrorBlockBase.tsx | 16 + .../errorBlocks/ErrorBlockNoSteth.tsx | 22 + .../errorBlocks/ErrorBlockServer.tsx | 8 + features/rewards/components/export/Export.tsx | 49 + .../rewards/components/export/Exportstyled.ts | 11 + features/rewards/components/export/index.ts | 1 + .../inputDescription/InputDescription.tsx | 7 + .../InputDescriptionStyles.ts | 10 + .../components/inputDescription/index.ts | 1 + .../components/inputWrapper/InputWrapper.tsx | 8 + .../inputWrapper/InputWrapperStyles.ts | 6 + .../rewards/components/inputWrapper/index.ts | 1 + .../rewardsListContent/RewardsListContent.tsx | 51 + .../RewardsListContentStyles.ts | 10 + .../RewardsListErrorMessage.tsx | 19 + .../rewardsListContent/RewardsListsEmpty.tsx | 15 + .../RewardsListsEmptyStyles.ts | 5 + .../components/rewardsListContent/index.ts | 1 + .../rewardsListHeader/LeftOptions.tsx | 43 + .../rewardsListHeader/RewardsListHeader.tsx | 18 + .../rewardsListHeader/RightOptions.tsx | 27 + .../components/rewardsListHeader/index.ts | 1 + .../components/rewardsListHeader/styles.ts | 52 + .../RewardListWrapperStyles.ts | 6 + .../rewardsListWrapper/RewardsListWrapper.tsx | 6 + .../components/rewardsListWrapper/index.ts | 1 + .../components/rewardsTable/RewardsTable.tsx | 50 + .../rewardsTable/RewardsTableCell.tsx | 39 + .../RewardsTableCells/AprCell.tsx | 15 + .../RewardsTableCells/BalanceCell.tsx | 17 + .../RewardsTableCells/CellStyles.ts | 29 + .../RewardsTableCells/ChangeCell.tsx | 28 + .../RewardsTableCells/CurrencyChangeCell.tsx | 20 + .../RewardsTableCells/DateCell.tsx | 15 + .../RewardsTableCells/DefaultCell.tsx | 14 + .../RewardsTableCells/TypeCell.tsx | 25 + .../rewardsTable/RewardsTableCells/index.ts | 7 + .../rewardsTable/RewardsTableHeader.tsx | 28 + .../rewardsTable/RewardsTableHeaderCell.tsx | 22 + .../rewardsTable/RewardsTableLoader.tsx | 17 + .../rewardsTable/RewardsTableLoaderStyles.ts | 51 + .../rewardsTable/RewardsTablePagination.tsx | 12 + .../rewardsTable/RewardsTableRow.tsx | 26 + .../rewardsTable/RewardsTableStyles.ts | 47 + .../components/rewardsTable/contsnats.ts | 60 + .../rewards/components/rewardsTable/index.ts | 1 + .../rewards/components/rewardsTable/types.ts | 62 + features/rewards/components/stats/Item.tsx | 16 + features/rewards/components/stats/Stat.tsx | 20 + features/rewards/components/stats/Stats.tsx | 142 + features/rewards/components/stats/Title.tsx | 22 + features/rewards/components/stats/index.ts | 4 + features/rewards/components/stats/types.ts | 9 + .../components/statsWrapper/StatsWrapper.tsx | 11 + .../statsWrapper/StatsWrapperStyles.ts | 11 + .../rewards/components/statsWrapper/index.ts | 1 + features/rewards/constants.ts | 27 + features/rewards/features/index.ts | 2 + .../rewards/features/rewards-list/index.ts | 1 + .../features/rewards-list/rewards-list.tsx | 14 + features/rewards/features/top-card/index.ts | 1 + .../rewards/features/top-card/top-card.tsx | 47 + features/rewards/fetchers/requesters/index.ts | 2 + .../fetchers/requesters/json/backend.ts | 23 + .../rewards/fetchers/requesters/json/index.ts | 1 + .../rewards/fetchers/requesters/rpc/index.ts | 1 + .../fetchers/requesters/rpc/stEthEth.ts | 17 + features/rewards/fetchers/rpcFetch.ts | 145 + features/rewards/helpers/big.ts | 47 + features/rewards/helpers/index.ts | 1 + features/rewards/hooks/index.ts | 3 + .../rewards/hooks/useGetCurrentAddress.ts | 76 + features/rewards/hooks/useRewardsDataLoad.ts | 69 + features/rewards/hooks/useRewardsHistory.ts | 9 + features/rewards/types/Backend.ts | 15 + features/rewards/types/Event.ts | 18 + features/rewards/types/LidoSubmission.ts | 24 + features/rewards/types/LidoTransfer.ts | 28 + features/rewards/types/TotalRewards.ts | 13 + features/rewards/types/TotalRewardsItem.ts | 12 + features/rewards/types/index.ts | 5 + features/rewards/utils/addressValidation.ts | 11 + features/rewards/utils/genExportData.ts | 20 + features/rewards/utils/index.ts | 6 + features/rewards/utils/numberFormatting.ts | 89 + features/rewards/utils/resolveEns.ts | 10 + features/rewards/utils/saveAsCSV.ts | 35 + features/rewards/utils/stringFormatting.ts | 2 + .../withdrawals/claim/form/bunker-info.tsx | 11 + .../claim/form/claim-form-footer-sticky.tsx | 161 + .../withdrawals/claim/form/claim-form.tsx | 100 + features/withdrawals/claim/form/index.ts | 1 + features/withdrawals/claim/form/styles.ts | 102 + features/withdrawals/claim/index.ts | 2 + .../withdrawals/claim/requests-list/index.ts | 1 + .../requests-list/request-item-status.tsx | 39 + .../claim/requests-list/request-item.tsx | 69 + .../claim/requests-list/requests-empty.tsx | 21 + .../claim/requests-list/requests-list.tsx | 33 + .../claim/requests-list/requests-loader.tsx | 22 + .../withdrawals/claim/requests-list/styles.ts | 143 + features/withdrawals/claim/tx-modal/index.ts | 1 + .../claim/tx-modal/tx-claim-modal.tsx | 99 + features/withdrawals/claim/wallet/index.ts | 1 + .../claim/wallet/wallet-availale-amount.tsx | 24 + .../claim/wallet/wallet-pending-amount.tsx | 24 + features/withdrawals/claim/wallet/wallet.tsx | 39 + .../contexts/claim-data-context/index.tsx | 70 + .../claim-data-context/useClaimSelection.ts | 106 + .../contexts/transaction-modal-context.tsx | 200 + .../contexts/withdrawals-context.tsx | 78 + features/withdrawals/hooks/contract/index.ts | 5 + .../withdrawals/hooks/contract/useClaim.ts | 97 + .../hooks/contract/useLidoShareRate.ts | 25 + .../withdrawals/hooks/contract/useRequest.ts | 419 + .../hooks/contract/useUnfinalizedSteth.ts | 12 + .../hooks/contract/useWithdrawalsBaseData.ts | 39 + .../hooks/contract/useWithdrawalsContract.ts | 14 + .../hooks/contract/useWithdrawalsData.ts | 130 + features/withdrawals/hooks/index.ts | 4 + .../hooks/useEthAmountByStethWsteth.ts | 29 + .../withdrawals/hooks/useNftDataByTxHash.ts | 55 + features/withdrawals/hooks/useTvlMessage.tsx | 44 + features/withdrawals/hooks/useWaitingTime.ts | 72 + .../withdrawals/hooks/useWithdrawTxPrice.ts | 176 + .../withdrawals/hooks/useWithdrawalRates.ts | 268 + features/withdrawals/index.ts | 1 + .../withdrawals/request/form/bunker-info.tsx | 14 + features/withdrawals/request/form/index.ts | 1 + .../request/form/inputs/amount-input.tsx | 42 + .../request/form/inputs/input-group.tsx | 17 + .../request/form/inputs/mode-input.tsx | 18 + .../request/form/inputs/token-input.tsx | 51 + .../request/form/options/dex-options.tsx | 146 + .../request/form/options/lido-option.tsx | 72 + .../request/form/options/options-picker.tsx | 128 + .../request/form/options/styles.ts | 260 + .../withdrawals/request/form/paused-info.tsx | 13 + .../withdrawals/request/form/request-form.tsx | 56 + .../request/form/requests-info.tsx | 44 + features/withdrawals/request/form/styles.ts | 27 + .../request/form/submit-button.tsx | 35 + .../request/form/transaction-info.tsx | 71 + features/withdrawals/request/index.ts | 1 + .../request/request-form-context/index.tsx | 2 + .../request-form-context.tsx | 132 + .../request/request-form-context/types.ts | 40 + .../use-request-form-data-context-value.ts | 84 + .../use-validation-context.ts | 64 + .../request-form-context/validators.ts | 277 + features/withdrawals/request/request.tsx | 19 + .../withdrawals/request/tx-modal/index.ts | 1 + .../withdrawals/request/tx-modal/styles.ts | 61 + .../request/tx-modal/tx-request-modal.tsx | 114 + .../tx-modal/tx-request-stage-success.tsx | 99 + features/withdrawals/request/wallet/index.ts | 1 + features/withdrawals/request/wallet/styles.ts | 19 + .../request/wallet/wallet-mode.tsx | 35 + .../request/wallet/wallet-queue-tooltip.tsx | 57 + .../request/wallet/wallet-steth-balance.tsx | 20 + .../request/wallet/wallet-wsteth-balance.tsx | 29 + .../withdrawals/request/wallet/wallet.tsx | 40 + features/withdrawals/shared/index.ts | 4 + features/withdrawals/shared/info-box/index.ts | 1 + .../withdrawals/shared/info-box/styles.ts | 6 + .../shared/input-decorator-tvl-stake/index.ts | 1 + .../input-decorator-tvl-stake.tsx | 25 + features/withdrawals/shared/status/index.ts | 1 + features/withdrawals/shared/status/status.tsx | 16 + features/withdrawals/shared/status/styles.ts | 49 + .../shared/tx-stage-modal/index.ts | 3 + .../shared/tx-stage-modal/stages/icons.tsx | 76 + .../tx-stage-modal/stages/iconsStyles.ts | 40 + .../shared/tx-stage-modal/stages/index.ts | 6 + .../shared/tx-stage-modal/stages/styles.ts | 27 + .../tx-stage-modal/stages/tx-stage-bunker.tsx | 37 + .../tx-stage-modal/stages/tx-stage-fail.tsx | 32 + .../stages/tx-stage-pending.tsx | 27 + .../tx-stage-modal/stages/tx-stage-permit.tsx | 19 + .../tx-stage-modal/stages/tx-stage-sign.tsx | 25 + .../stages/tx-stage-success.tsx | 40 + .../shared/tx-stage-modal/tx-stage-modal.tsx | 29 + .../shared/tx-stage-modal/types.ts | 10 + .../shared/wallet-my-requests/index.ts | 1 + .../shared/wallet-my-requests/styles.ts | 23 + .../wallet-my-requests/wallet-my-requests.tsx | 47 + .../shared/wallet-wrapper/index.ts | 1 + .../shared/wallet-wrapper/styles.ts | 12 + features/withdrawals/types/request-status.ts | 26 + .../withdrawals/types/tokens-withdrawable.ts | 3 + .../utils/calc-expected-request-eth.ts | 21 + features/withdrawals/utils/calc-share-rate.ts | 17 + .../withdrawals-constants/index.ts | 13 + .../withdrawals/withdrawals-faq/claim-faq.tsx | 37 + .../withdrawals-faq/list/add-nft.tsx | 27 + .../list/bunker-mode-reasons.tsx | 27 + .../withdrawals-faq/list/bunker-mode.tsx | 18 + .../list/bunker-while-request-ongoing.tsx | 14 + .../list/claimable-amount-difference.tsx | 19 + .../list/convert-steth-to-eth.tsx | 19 + .../list/convert-wsteth-to-eth.tsx | 21 + .../list/how-does-withdrawals-work.tsx | 24 + .../list/how-long-to-withdraw.tsx | 17 + .../withdrawals-faq/list/how-to-withdraw.tsx | 22 + .../withdrawals-faq/list/lido-nft.tsx | 15 + .../withdrawals-faq/list/nft-not-change.tsx | 14 + .../list/rewards-after-withdraw.tsx | 13 + .../withdrawals-faq/list/separate-claim.tsx | 14 + .../withdrawals-faq/list/turbo-mode.tsx | 13 + .../list/unstake-amount-boundaries.tsx | 28 + .../list/what-are-withdrawals.tsx | 14 + .../withdrawals-faq/list/what-is-slashing.tsx | 34 + .../withdrawals-faq/list/why-steth.tsx | 14 + .../withdrawals-faq/list/withdrawaal-fee.tsx | 13 + .../list/withdrawal-period-circumstances.tsx | 17 + .../withdrawals-faq/request-faq.tsx | 67 + .../withdrawals/withdrawals-faq/styles.ts | 11 + features/withdrawals/withdrawals-tabs.tsx | 46 + features/wrap/features/unwrap-form/hooks.ts | 45 + .../wrap/features/unwrap-form/unwrap-form.tsx | 211 + features/wrap/features/wallet/styles.tsx | 6 + features/wrap/features/wallet/wallet.tsx | 90 + .../list/do-i-get-my-staking-rewards.tsx | 16 + .../do-i-need-to-claim-my-staking-rewards.tsx | 10 + .../list/do_i_need_to_unwrap_my_wsteth.tsx | 29 + .../wrap-faq/list/how-can-i-get-wsteth.tsx | 35 + .../wrap-faq/list/how-can-i-use-wsteth.tsx | 29 + .../how-could-i-unwrap-wsteth-to-steth.tsx | 30 + features/wrap/features/wrap-faq/list/index.ts | 7 + .../features/wrap-faq/list/what-is-wsteth.tsx | 15 + features/wrap/features/wrap-faq/wrap-faq.tsx | 29 + features/wrap/features/wrap-form/form.tsx | 257 + features/wrap/features/wrap-form/hooks.tsx | 119 + .../wrap/features/wrap-form/wrap-form.tsx | 217 + features/wrap/index.ts | 4 + features/wrap/styles.tsx | 35 + features/wrap/utils.ts | 272 + global.d.ts | 4 + jest.config.cjs | 7 + lib/faqList.ts | 33 + middleware.ts | 37 + next-env.d.ts | 5 + next-logger.config.cjs | 42 + next.config.mjs | 131 + package.json | 130 + pages/404.tsx | 14 + pages/500.tsx | 14 + pages/_app.tsx | 52 + pages/_document.tsx | 119 + pages/api/csp-report.ts | 13 + pages/api/eth-apr.ts | 35 + pages/api/eth-price.ts | 42 + pages/api/health.ts | 3 + pages/api/ldo-stats.ts | 36 + pages/api/lido-stats.ts | 35 + pages/api/lidostats.ts | 36 + pages/api/metrics.ts | 20 + pages/api/oneinch-rate.ts | 50 + pages/api/rewards.ts | 61 + pages/api/rpc.ts | 45 + pages/api/short-lido-stats.ts | 63 + pages/api/sma-steth-apr.ts | 37 + pages/api/totalsupply.ts | 42 + pages/index.tsx | 32 + pages/referral.tsx | 13 + pages/rewards.tsx | 36 + pages/withdrawals/[[...mode]].tsx | 65 + pages/wrap/[[...mode]].tsx | 68 + playwright.config.ts | 114 + providers/app-flag.tsx | 33 + providers/index.tsx | 22 + providers/modals.tsx | 58 + providers/rewardsHistory.tsx | 125 + providers/theme.tsx | 102 + providers/web3.tsx | 61 + public/apple-touch-icon.png | Bin 0 -> 5017 bytes public/favicon-1080x1080.svg | 11 + public/favicon-16x16.png | Bin 0 -> 496 bytes public/favicon-192x192.png | Bin 0 -> 5443 bytes public/favicon-32x32.png | Bin 0 -> 973 bytes public/favicon.ico | Bin 0 -> 111916 bytes public/lido-preview.png | Bin 0 -> 686302 bytes public/manifest.json | 28 + renovate.json | 4 + scripts/build-dynamics.mjs | 22 + shared/banners/balancer/balancer.tsx | 44 + shared/banners/balancer/index.ts | 1 + shared/banners/balancer/styles.ts | 21 + shared/banners/balancer/types.ts | 40 + shared/banners/balancer/useBalancer.ts | 13 + shared/banners/curve/curve.tsx | 48 + shared/banners/curve/index.ts | 1 + shared/banners/curve/styles.ts | 36 + shared/banners/curve/types.ts | 40 + shared/banners/curve/useCurve.ts | 13 + shared/banners/index.ts | 3 + shared/banners/modal-pool-banners/index.ts | 1 + .../modal-pool-banners/modal-pool-banners.tsx | 36 + shared/banners/modal-pool-banners/styles.ts | 32 + .../abuse-warning/abuse-warning.tsx | 17 + shared/components/abuse-warning/styles.tsx | 30 + .../background-gradient.tsx | 32 + .../components/background-gradient/styles.tsx | 23 + shared/components/banner/banner.tsx | 21 + shared/components/banner/styles.ts | 45 + .../data-table-row-steth-by-wsteth.tsx | 31 + .../data-table-row-steth-by-wsteth/index.ts | 1 + shared/components/faq/faq.tsx | 65 + shared/components/footer/footer.tsx | 19 + shared/components/footer/styles.tsx | 66 + .../header/components/header-wallet.tsx | 34 + .../components/navigation/navigation.tsx | 52 + .../header/components/navigation/styles.tsx | 84 + .../components/navigation/withdraw-icon.tsx | 5 + shared/components/header/header.tsx | 16 + shared/components/header/styles.tsx | 39 + shared/components/index.ts | 17 + shared/components/info-box/index.ts | 1 + shared/components/info-box/info-box.tsx | 12 + shared/components/info-box/styled.ts | 15 + .../layout-effect-ssr-delayed/index.ts | 1 + .../layout-effect-ssr-delayed.tsx | 23 + shared/components/layout/layout.tsx | 27 + shared/components/layout/styles.tsx | 27 + shared/components/local-link/index.tsx | 20 + shared/components/logos/logos.tsx | 23 + shared/components/logos/styles.tsx | 22 + shared/components/main/main.tsx | 10 + shared/components/main/styles.tsx | 8 + shared/components/matomo-link/matomo-link.tsx | 18 + shared/components/no-ssr-wrapper.tsx | 10 + .../popup-wrapper/popup-wrapper.tsx | 73 + shared/components/popup-wrapper/styles.tsx | 105 + shared/components/section/section.tsx | 36 + shared/components/section/styles.tsx | 26 + shared/components/switch/index.ts | 1 + shared/components/switch/styles.tsx | 55 + shared/components/switch/switch-item.tsx | 16 + shared/components/switch/switch.tsx | 17 + shared/components/switch/types.ts | 8 + shared/components/token-to-wallet/styles.tsx | 35 + .../token-to-wallet/token-to-wallet.tsx | 20 + shared/components/tooltip-hoverable/styles.ts | 23 + .../tooltip-hoverable/tooltip-hoverable.tsx | 70 + shared/components/tooltip-hoverable/types.ts | 10 + shared/components/tx-link-etherscan/index.tsx | 1 + .../tx-link-etherscan/tx-link-etherscan.tsx | 22 + .../tx-stage-modal-content/icons-styles.ts | 35 + .../tx-stage-modal-content/icons.tsx | 66 + .../tx-stage-modal-content/index.ts | 1 + .../tx-stage-modal-content/styles.ts | 34 + .../tx-stage-modal-content.tsx | 24 + shared/components/tx-stage-modal/index.ts | 2 + shared/components/tx-stage-modal/styles.tsx | 61 + .../components/tx-stage-modal/text-utils.tsx | 70 + .../tx-stage-modal/tx-stage-modal-shape.tsx | 21 + .../tx-stage-modal/tx-stage-modal.tsx | 220 + shared/components/tx-stage-modal/types.ts | 15 + shared/formatters/format-date.tsx | 21 + shared/formatters/format-percent.tsx | 20 + shared/formatters/format-price.tsx | 16 + shared/formatters/format-token.tsx | 59 + shared/formatters/index.ts | 4 + .../forms/components/input-amount/index.tsx | 1 + .../components/input-amount/input-amount.tsx | 96 + .../input-decorator-locked/index.ts | 1 + .../input-decorator-locked.tsx | 11 + .../input-decorator-locked/styles.tsx | 29 + .../input-decorator-max-button/index.ts | 1 + .../input-decorator-max-button.tsx | 22 + .../input-decorator-max-button/styled.ts | 6 + shared/forms/components/input-number/index.ts | 1 + .../components/input-number/input-number.tsx | 27 + .../forms/hooks/useCurrencyAmountValidator.ts | 53 + shared/forms/hooks/useCurrencyInput.ts | 102 + shared/forms/hooks/useCurrencyMaxAmount.ts | 44 + shared/forms/hooks/useInputValidate.ts | 42 + shared/forms/types/validation-fn.ts | 1 + shared/hooks/index.ts | 21 + shared/hooks/txCost.ts | 24 + shared/hooks/use-awaiter.ts | 48 + shared/hooks/useApprove.ts | 123 + shared/hooks/useAsyncMemo.ts | 29 + shared/hooks/useCopyToClipboard.ts | 10 + shared/hooks/useDebouncedValue.ts | 15 + shared/hooks/useENSAddress.ts | 33 + shared/hooks/useERC20PermitSignature.ts | 126 + shared/hooks/useElementResize.ts | 32 + shared/hooks/useEthApr.ts | 13 + shared/hooks/useForceUpdate.ts | 7 + shared/hooks/useGasPrice.ts | 28 + shared/hooks/useIsContract.ts | 21 + shared/hooks/useIsLedgerLive.ts | 9 + shared/hooks/useIsMultisig.ts | 8 + shared/hooks/useLidoApr.ts | 60 + shared/hooks/useLidoStats.ts | 46 + shared/hooks/useLidoSwr.ts | 18 + shared/hooks/useMatomoEventHandle.ts | 20 + shared/hooks/useMaxGasPrice.ts | 37 + shared/hooks/useModal.ts | 20 + shared/hooks/usePrevious.ts | 12 + shared/hooks/useSafeQueryString.ts | 16 + shared/hooks/useSsrMode.ts | 7 + shared/hooks/useStakingLimitInfo.ts | 77 + shared/hooks/useStakingLimitLevel.ts | 13 + shared/hooks/useStethByWsteth.ts | 31 + shared/hooks/useWstethBySteth.ts | 31 + shared/l2-banner/index.ts | 1 + shared/l2-banner/l2-banner.tsx | 46 + shared/l2-banner/styles.ts | 86 + shared/wallet/button/button.tsx | 45 + shared/wallet/button/styles.tsx | 31 + shared/wallet/card/card.tsx | 75 + shared/wallet/card/styles.tsx | 86 + shared/wallet/card/types.ts | 18 + .../address-badge/address-badge.tsx | 21 + .../components/address-badge/styles.tsx | 22 + shared/wallet/connect/connect.tsx | 22 + shared/wallet/fallback/fallback.tsx | 13 + shared/wallet/fallback/styles.tsx | 8 + shared/wallet/fallback/useErrorMessage.ts | 23 + shared/wallet/index.ts | 5 + shared/wallet/modal/modal.tsx | 92 + shared/wallet/modal/styles.tsx | 53 + shared/wallet/types.ts | 3 + styles/global.ts | 50 + styles/index.ts | 1 + test/README.md | 34 + test/config.ts | 44 + test/consts.ts | 427 + test/pages/widget.page.ts | 24 + test/smoke.spec.ts | 46 + test/widget.spec.ts | 10 + tsconfig.json | 21 + types/api.ts | 46 + types/components.ts | 19 + types/hooks.ts | 1 + types/index.ts | 4 + types/limit.ts | 5 + types/subgraph.ts | 3 + utils/__tests__/bigNumber.test.ts | 22 + utils/__tests__/encodeURLQuery.test.ts | 21 + utils/__tests__/extractErrorMessage.test.ts | 23 + utils/__tests__/formatBalance.test.ts | 174 + utils/__tests__/getErrorMessage.test.ts | 85 + utils/__tests__/maxNumberValidation.test.ts | 19 + utils/__tests__/replaceAll.test.ts | 14 + utils/__tests__/shortenTokenValue.test.ts | 19 + utils/appCookies.ts | 57 + utils/assert.ts | 16 + utils/bigNumber.ts | 5 + utils/chains.ts | 4 + utils/encodeURLQuery.ts | 9 + utils/extractErrorMessage.ts | 7 + utils/fetcherError.ts | 8 + utils/formatBalance.ts | 65 + utils/formatBalanceString.ts | 16 + utils/getErrorMessage.ts | 89 + utils/getNFTUrl.ts | 17 + utils/getScreenSize.ts | 10 + utils/getTokenDisplayName.ts | 10 + utils/index.ts | 19 + utils/isContract.ts | 10 + utils/isValidEtherValue.ts | 11 + utils/logger.ts | 54 + utils/maxNumberValidation.ts | 8 + utils/nprogress.ts | 22 + utils/parallelizePromises.ts | 11 + utils/prependBasePath.ts | 8 + utils/qa.ts | 3 + utils/replaceAll.ts | 13 + utils/rpcProviders.ts | 8 + utils/shortenTokenValue.ts | 13 + utils/stakingLimit.ts | 14 + utils/standardFetcher.ts | 33 + utils/swrAbortableMiddleware.ts | 22 + utils/swrStrategies.ts | 41 + utils/weiToEth.ts | 7 + utilsApi/apiHelpers.ts | 9 + utilsApi/fetchApiWrapper.ts | 52 + utilsApi/fetchRPC.ts | 8 + utilsApi/getEthApr.ts | 97 + utilsApi/getEthPrice.ts | 27 + utilsApi/getLdoStats.ts | 31 + utilsApi/getLidoHoldersViaSubgraphs.ts | 99 + utilsApi/getLidoStats.ts | 29 + utilsApi/getOneInchRate.ts | 47 + utilsApi/getSmaStethApr.ts | 66 + utilsApi/getStEthPrice.ts | 32 + utilsApi/getSubgraphUrl.ts | 13 + utilsApi/getTotalStaked.ts | 38 + utilsApi/index.ts | 15 + utilsApi/metrics/index.ts | 1 + utilsApi/metrics/metrics.ts | 33 + utilsApi/metrics/request.ts | 39 + utilsApi/metrics/subgraph.ts | 22 + utilsApi/nextApiWrappers.ts | 86 + utilsApi/rpcProviders.ts | 15 + utilsApi/rpcUrls.ts | 16 + utilsApi/withCsp.ts | 63 + yarn.lock | 10653 ++++++++++++++++ 624 files changed, 36620 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100644 .env create mode 100644 .eslintignore create mode 100644 .eslintrc.json create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/checks.yml create mode 100644 .github/workflows/ci-dev.yml create mode 100644 .github/workflows/ci-preview-demolish.yml create mode 100644 .github/workflows/ci-preview-deploy.yml create mode 100644 .github/workflows/ci-prod.yml create mode 100644 .github/workflows/ci-staging.yml create mode 100644 .github/workflows/prepare-release-draft.yml create mode 100644 .github/workflows/tests.yml create mode 100644 .gitignore create mode 100644 .husky/.gitignore create mode 100755 .husky/commit-msg create mode 100755 .husky/pre-commit create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 CHANGELOG.md create mode 100644 Dockerfile create mode 100644 LICENSE create mode 100644 README.md create mode 100644 abi/aggregator.abi.json create mode 100644 abi/aggregatorEthUsdPriceFeed.abi.json create mode 100644 abi/oracle.abi.json create mode 100644 abi/steth.abi.json create mode 100644 assets/icons/balancer-banner-icon.svg create mode 100644 assets/icons/coin98.svg create mode 100644 assets/icons/coinbase.svg create mode 100644 assets/icons/cookie.svg create mode 100644 assets/icons/cookieInverse.svg create mode 100644 assets/icons/cowswap-circle.svg create mode 100644 assets/icons/curve.svg create mode 100644 assets/icons/external-link-icon.svg create mode 100644 assets/icons/imtoken.svg create mode 100644 assets/icons/l2-banner-icons.svg create mode 100644 assets/icons/ledger.svg create mode 100644 assets/icons/lido.svg create mode 100644 assets/icons/metamask.svg create mode 100644 assets/icons/oneinch-circle.svg create mode 100644 assets/icons/oneinch-info-bg.svg create mode 100644 assets/icons/oneinch.svg create mode 100644 assets/icons/paraswap-circle.svg create mode 100644 assets/icons/request-info.svg create mode 100644 assets/icons/request-pending.svg create mode 100644 assets/icons/request-ready.svg create mode 100644 assets/icons/trust.svg create mode 100644 assets/icons/walletconnect.svg create mode 100644 assets/icons/warning.svg create mode 100644 assets/logo.svg create mode 100644 assets/nft-example.png create mode 100644 build-info.json create mode 100644 commitlint.config.cjs create mode 100644 config/aggregator.ts create mode 100644 config/api.ts create mode 100644 config/cache.ts create mode 100644 config/dynamics.ts create mode 100644 config/estimate.ts create mode 100644 config/external-links.ts create mode 100644 config/index.ts create mode 100644 config/locale.ts create mode 100644 config/matomoClickEvents.ts create mode 100644 config/matomoWalletsEvents.ts create mode 100644 config/metrics.ts create mode 100644 config/oracle.ts create mode 100644 config/rateLimit.ts create mode 100644 config/rpc.ts create mode 100644 config/steth.ts create mode 100644 config/storage.ts create mode 100644 config/text.ts create mode 100644 config/trackMatomoEvent.ts create mode 100644 config/tx.ts create mode 100644 config/units.ts create mode 100644 env-dynamics.mjs create mode 100644 features/home/hooks.tsx create mode 100644 features/home/index.ts create mode 100644 features/home/lido-stats/lido-stats.tsx create mode 100644 features/home/lido-stats/styles.tsx create mode 100644 features/home/oneinch-info/oneinch-info.tsx create mode 100644 features/home/oneinch-info/styles.ts create mode 100644 features/home/stake-faq/list/how-can-i-get-steth.tsx create mode 100644 features/home/stake-faq/list/how-can-i-unstake-steth.tsx create mode 100644 features/home/stake-faq/list/how-can-i-use-steth.tsx create mode 100644 features/home/stake-faq/list/how-does-lido-work.tsx create mode 100644 features/home/stake-faq/list/index.ts create mode 100644 features/home/stake-faq/list/lido-eth-apr.tsx create mode 100644 features/home/stake-faq/list/lido-fee.tsx create mode 100644 features/home/stake-faq/list/risks-of-staking-with-lido.tsx create mode 100644 features/home/stake-faq/list/safe-work-with-lido.tsx create mode 100644 features/home/stake-faq/list/what-is-lido.tsx create mode 100644 features/home/stake-faq/list/what-is-steth.tsx create mode 100644 features/home/stake-faq/list/where-can-i-cover-my-steth.tsx create mode 100644 features/home/stake-faq/stake-faq.tsx create mode 100644 features/home/stake-form/hooks.ts create mode 100644 features/home/stake-form/stake-form.tsx create mode 100644 features/home/stake-form/styles.tsx create mode 100644 features/home/stake-form/useStakingLimitWarn.ts create mode 100644 features/home/stake-form/utils.ts create mode 100644 features/home/wallet/limit-meter/components.tsx create mode 100644 features/home/wallet/limit-meter/icons.tsx create mode 100644 features/home/wallet/limit-meter/index.tsx create mode 100644 features/home/wallet/limit-meter/limit-meter.tsx create mode 100644 features/home/wallet/limit-meter/styles.ts create mode 100644 features/home/wallet/limit-meter/types.ts create mode 100644 features/home/wallet/styles.tsx create mode 100644 features/home/wallet/wallet.tsx create mode 100644 features/referral/banner/banner.tsx create mode 100644 features/referral/banner/index.ts create mode 100644 features/referral/banner/styles.ts create mode 100644 features/referral/index.ts create mode 100644 features/rewards/components/CopyAddressUrl.tsx create mode 100644 features/rewards/components/CurrencySelector.tsx create mode 100644 features/rewards/components/Date.tsx create mode 100644 features/rewards/components/EthSymbol.tsx create mode 100644 features/rewards/components/IndexerLink.tsx create mode 100644 features/rewards/components/NumberFormat.tsx create mode 100644 features/rewards/components/addressInput/AddressInput.tsx create mode 100644 features/rewards/components/addressInput/index.ts create mode 100644 features/rewards/components/addressInput/types.ts create mode 100644 features/rewards/components/errorBlocks/ErrorBlockBase.tsx create mode 100644 features/rewards/components/errorBlocks/ErrorBlockNoSteth.tsx create mode 100644 features/rewards/components/errorBlocks/ErrorBlockServer.tsx create mode 100644 features/rewards/components/export/Export.tsx create mode 100644 features/rewards/components/export/Exportstyled.ts create mode 100644 features/rewards/components/export/index.ts create mode 100644 features/rewards/components/inputDescription/InputDescription.tsx create mode 100644 features/rewards/components/inputDescription/InputDescriptionStyles.ts create mode 100644 features/rewards/components/inputDescription/index.ts create mode 100644 features/rewards/components/inputWrapper/InputWrapper.tsx create mode 100644 features/rewards/components/inputWrapper/InputWrapperStyles.ts create mode 100644 features/rewards/components/inputWrapper/index.ts create mode 100644 features/rewards/components/rewardsListContent/RewardsListContent.tsx create mode 100644 features/rewards/components/rewardsListContent/RewardsListContentStyles.ts create mode 100644 features/rewards/components/rewardsListContent/RewardsListErrorMessage.tsx create mode 100644 features/rewards/components/rewardsListContent/RewardsListsEmpty.tsx create mode 100644 features/rewards/components/rewardsListContent/RewardsListsEmptyStyles.ts create mode 100644 features/rewards/components/rewardsListContent/index.ts create mode 100644 features/rewards/components/rewardsListHeader/LeftOptions.tsx create mode 100644 features/rewards/components/rewardsListHeader/RewardsListHeader.tsx create mode 100644 features/rewards/components/rewardsListHeader/RightOptions.tsx create mode 100644 features/rewards/components/rewardsListHeader/index.ts create mode 100644 features/rewards/components/rewardsListHeader/styles.ts create mode 100644 features/rewards/components/rewardsListWrapper/RewardListWrapperStyles.ts create mode 100644 features/rewards/components/rewardsListWrapper/RewardsListWrapper.tsx create mode 100644 features/rewards/components/rewardsListWrapper/index.ts create mode 100644 features/rewards/components/rewardsTable/RewardsTable.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/AprCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/BalanceCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/CellStyles.ts create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/ChangeCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/CurrencyChangeCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/DateCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/DefaultCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/TypeCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableCells/index.ts create mode 100644 features/rewards/components/rewardsTable/RewardsTableHeader.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableHeaderCell.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableLoader.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableLoaderStyles.ts create mode 100644 features/rewards/components/rewardsTable/RewardsTablePagination.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableRow.tsx create mode 100644 features/rewards/components/rewardsTable/RewardsTableStyles.ts create mode 100644 features/rewards/components/rewardsTable/contsnats.ts create mode 100644 features/rewards/components/rewardsTable/index.ts create mode 100644 features/rewards/components/rewardsTable/types.ts create mode 100644 features/rewards/components/stats/Item.tsx create mode 100644 features/rewards/components/stats/Stat.tsx create mode 100644 features/rewards/components/stats/Stats.tsx create mode 100644 features/rewards/components/stats/Title.tsx create mode 100644 features/rewards/components/stats/index.ts create mode 100644 features/rewards/components/stats/types.ts create mode 100644 features/rewards/components/statsWrapper/StatsWrapper.tsx create mode 100644 features/rewards/components/statsWrapper/StatsWrapperStyles.ts create mode 100644 features/rewards/components/statsWrapper/index.ts create mode 100644 features/rewards/constants.ts create mode 100644 features/rewards/features/index.ts create mode 100644 features/rewards/features/rewards-list/index.ts create mode 100644 features/rewards/features/rewards-list/rewards-list.tsx create mode 100644 features/rewards/features/top-card/index.ts create mode 100644 features/rewards/features/top-card/top-card.tsx create mode 100644 features/rewards/fetchers/requesters/index.ts create mode 100644 features/rewards/fetchers/requesters/json/backend.ts create mode 100644 features/rewards/fetchers/requesters/json/index.ts create mode 100644 features/rewards/fetchers/requesters/rpc/index.ts create mode 100644 features/rewards/fetchers/requesters/rpc/stEthEth.ts create mode 100644 features/rewards/fetchers/rpcFetch.ts create mode 100644 features/rewards/helpers/big.ts create mode 100644 features/rewards/helpers/index.ts create mode 100644 features/rewards/hooks/index.ts create mode 100644 features/rewards/hooks/useGetCurrentAddress.ts create mode 100644 features/rewards/hooks/useRewardsDataLoad.ts create mode 100644 features/rewards/hooks/useRewardsHistory.ts create mode 100644 features/rewards/types/Backend.ts create mode 100644 features/rewards/types/Event.ts create mode 100644 features/rewards/types/LidoSubmission.ts create mode 100644 features/rewards/types/LidoTransfer.ts create mode 100644 features/rewards/types/TotalRewards.ts create mode 100644 features/rewards/types/TotalRewardsItem.ts create mode 100644 features/rewards/types/index.ts create mode 100644 features/rewards/utils/addressValidation.ts create mode 100644 features/rewards/utils/genExportData.ts create mode 100644 features/rewards/utils/index.ts create mode 100644 features/rewards/utils/numberFormatting.ts create mode 100644 features/rewards/utils/resolveEns.ts create mode 100644 features/rewards/utils/saveAsCSV.ts create mode 100644 features/rewards/utils/stringFormatting.ts create mode 100644 features/withdrawals/claim/form/bunker-info.tsx create mode 100644 features/withdrawals/claim/form/claim-form-footer-sticky.tsx create mode 100644 features/withdrawals/claim/form/claim-form.tsx create mode 100644 features/withdrawals/claim/form/index.ts create mode 100644 features/withdrawals/claim/form/styles.ts create mode 100644 features/withdrawals/claim/index.ts create mode 100644 features/withdrawals/claim/requests-list/index.ts create mode 100644 features/withdrawals/claim/requests-list/request-item-status.tsx create mode 100644 features/withdrawals/claim/requests-list/request-item.tsx create mode 100644 features/withdrawals/claim/requests-list/requests-empty.tsx create mode 100644 features/withdrawals/claim/requests-list/requests-list.tsx create mode 100644 features/withdrawals/claim/requests-list/requests-loader.tsx create mode 100644 features/withdrawals/claim/requests-list/styles.ts create mode 100644 features/withdrawals/claim/tx-modal/index.ts create mode 100644 features/withdrawals/claim/tx-modal/tx-claim-modal.tsx create mode 100644 features/withdrawals/claim/wallet/index.ts create mode 100644 features/withdrawals/claim/wallet/wallet-availale-amount.tsx create mode 100644 features/withdrawals/claim/wallet/wallet-pending-amount.tsx create mode 100644 features/withdrawals/claim/wallet/wallet.tsx create mode 100644 features/withdrawals/contexts/claim-data-context/index.tsx create mode 100644 features/withdrawals/contexts/claim-data-context/useClaimSelection.ts create mode 100644 features/withdrawals/contexts/transaction-modal-context.tsx create mode 100644 features/withdrawals/contexts/withdrawals-context.tsx create mode 100644 features/withdrawals/hooks/contract/index.ts create mode 100644 features/withdrawals/hooks/contract/useClaim.ts create mode 100644 features/withdrawals/hooks/contract/useLidoShareRate.ts create mode 100644 features/withdrawals/hooks/contract/useRequest.ts create mode 100644 features/withdrawals/hooks/contract/useUnfinalizedSteth.ts create mode 100644 features/withdrawals/hooks/contract/useWithdrawalsBaseData.ts create mode 100644 features/withdrawals/hooks/contract/useWithdrawalsContract.ts create mode 100644 features/withdrawals/hooks/contract/useWithdrawalsData.ts create mode 100644 features/withdrawals/hooks/index.ts create mode 100644 features/withdrawals/hooks/useEthAmountByStethWsteth.ts create mode 100644 features/withdrawals/hooks/useNftDataByTxHash.ts create mode 100644 features/withdrawals/hooks/useTvlMessage.tsx create mode 100644 features/withdrawals/hooks/useWaitingTime.ts create mode 100644 features/withdrawals/hooks/useWithdrawTxPrice.ts create mode 100644 features/withdrawals/hooks/useWithdrawalRates.ts create mode 100644 features/withdrawals/index.ts create mode 100644 features/withdrawals/request/form/bunker-info.tsx create mode 100644 features/withdrawals/request/form/index.ts create mode 100644 features/withdrawals/request/form/inputs/amount-input.tsx create mode 100644 features/withdrawals/request/form/inputs/input-group.tsx create mode 100644 features/withdrawals/request/form/inputs/mode-input.tsx create mode 100644 features/withdrawals/request/form/inputs/token-input.tsx create mode 100644 features/withdrawals/request/form/options/dex-options.tsx create mode 100644 features/withdrawals/request/form/options/lido-option.tsx create mode 100644 features/withdrawals/request/form/options/options-picker.tsx create mode 100644 features/withdrawals/request/form/options/styles.ts create mode 100644 features/withdrawals/request/form/paused-info.tsx create mode 100644 features/withdrawals/request/form/request-form.tsx create mode 100644 features/withdrawals/request/form/requests-info.tsx create mode 100644 features/withdrawals/request/form/styles.ts create mode 100644 features/withdrawals/request/form/submit-button.tsx create mode 100644 features/withdrawals/request/form/transaction-info.tsx create mode 100644 features/withdrawals/request/index.ts create mode 100644 features/withdrawals/request/request-form-context/index.tsx create mode 100644 features/withdrawals/request/request-form-context/request-form-context.tsx create mode 100644 features/withdrawals/request/request-form-context/types.ts create mode 100644 features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts create mode 100644 features/withdrawals/request/request-form-context/use-validation-context.ts create mode 100644 features/withdrawals/request/request-form-context/validators.ts create mode 100644 features/withdrawals/request/request.tsx create mode 100644 features/withdrawals/request/tx-modal/index.ts create mode 100644 features/withdrawals/request/tx-modal/styles.ts create mode 100644 features/withdrawals/request/tx-modal/tx-request-modal.tsx create mode 100644 features/withdrawals/request/tx-modal/tx-request-stage-success.tsx create mode 100644 features/withdrawals/request/wallet/index.ts create mode 100644 features/withdrawals/request/wallet/styles.ts create mode 100644 features/withdrawals/request/wallet/wallet-mode.tsx create mode 100644 features/withdrawals/request/wallet/wallet-queue-tooltip.tsx create mode 100644 features/withdrawals/request/wallet/wallet-steth-balance.tsx create mode 100644 features/withdrawals/request/wallet/wallet-wsteth-balance.tsx create mode 100644 features/withdrawals/request/wallet/wallet.tsx create mode 100644 features/withdrawals/shared/index.ts create mode 100644 features/withdrawals/shared/info-box/index.ts create mode 100644 features/withdrawals/shared/info-box/styles.ts create mode 100644 features/withdrawals/shared/input-decorator-tvl-stake/index.ts create mode 100644 features/withdrawals/shared/input-decorator-tvl-stake/input-decorator-tvl-stake.tsx create mode 100644 features/withdrawals/shared/status/index.ts create mode 100644 features/withdrawals/shared/status/status.tsx create mode 100644 features/withdrawals/shared/status/styles.ts create mode 100644 features/withdrawals/shared/tx-stage-modal/index.ts create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/icons.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/iconsStyles.ts create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/index.ts create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/styles.ts create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/tx-stage-bunker.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/tx-stage-pending.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/tx-stage-permit.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/tx-stage-sign.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/stages/tx-stage-success.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/tx-stage-modal.tsx create mode 100644 features/withdrawals/shared/tx-stage-modal/types.ts create mode 100644 features/withdrawals/shared/wallet-my-requests/index.ts create mode 100644 features/withdrawals/shared/wallet-my-requests/styles.ts create mode 100644 features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx create mode 100644 features/withdrawals/shared/wallet-wrapper/index.ts create mode 100644 features/withdrawals/shared/wallet-wrapper/styles.ts create mode 100644 features/withdrawals/types/request-status.ts create mode 100644 features/withdrawals/types/tokens-withdrawable.ts create mode 100644 features/withdrawals/utils/calc-expected-request-eth.ts create mode 100644 features/withdrawals/utils/calc-share-rate.ts create mode 100644 features/withdrawals/withdrawals-constants/index.ts create mode 100644 features/withdrawals/withdrawals-faq/claim-faq.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/add-nft.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/bunker-mode-reasons.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/bunker-mode.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/bunker-while-request-ongoing.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/claimable-amount-difference.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/convert-steth-to-eth.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/convert-wsteth-to-eth.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/how-does-withdrawals-work.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/how-long-to-withdraw.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/how-to-withdraw.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/lido-nft.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/nft-not-change.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/rewards-after-withdraw.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/separate-claim.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/turbo-mode.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/unstake-amount-boundaries.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/what-are-withdrawals.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/what-is-slashing.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/why-steth.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/withdrawaal-fee.tsx create mode 100644 features/withdrawals/withdrawals-faq/list/withdrawal-period-circumstances.tsx create mode 100644 features/withdrawals/withdrawals-faq/request-faq.tsx create mode 100644 features/withdrawals/withdrawals-faq/styles.ts create mode 100644 features/withdrawals/withdrawals-tabs.tsx create mode 100644 features/wrap/features/unwrap-form/hooks.ts create mode 100644 features/wrap/features/unwrap-form/unwrap-form.tsx create mode 100644 features/wrap/features/wallet/styles.tsx create mode 100644 features/wrap/features/wallet/wallet.tsx create mode 100644 features/wrap/features/wrap-faq/list/do-i-get-my-staking-rewards.tsx create mode 100644 features/wrap/features/wrap-faq/list/do-i-need-to-claim-my-staking-rewards.tsx create mode 100644 features/wrap/features/wrap-faq/list/do_i_need_to_unwrap_my_wsteth.tsx create mode 100644 features/wrap/features/wrap-faq/list/how-can-i-get-wsteth.tsx create mode 100644 features/wrap/features/wrap-faq/list/how-can-i-use-wsteth.tsx create mode 100644 features/wrap/features/wrap-faq/list/how-could-i-unwrap-wsteth-to-steth.tsx create mode 100644 features/wrap/features/wrap-faq/list/index.ts create mode 100644 features/wrap/features/wrap-faq/list/what-is-wsteth.tsx create mode 100644 features/wrap/features/wrap-faq/wrap-faq.tsx create mode 100644 features/wrap/features/wrap-form/form.tsx create mode 100644 features/wrap/features/wrap-form/hooks.tsx create mode 100644 features/wrap/features/wrap-form/wrap-form.tsx create mode 100644 features/wrap/index.ts create mode 100644 features/wrap/styles.tsx create mode 100644 features/wrap/utils.ts create mode 100644 global.d.ts create mode 100644 jest.config.cjs create mode 100644 lib/faqList.ts create mode 100644 middleware.ts create mode 100644 next-env.d.ts create mode 100644 next-logger.config.cjs create mode 100644 next.config.mjs create mode 100644 package.json create mode 100644 pages/404.tsx create mode 100644 pages/500.tsx create mode 100644 pages/_app.tsx create mode 100644 pages/_document.tsx create mode 100644 pages/api/csp-report.ts create mode 100644 pages/api/eth-apr.ts create mode 100644 pages/api/eth-price.ts create mode 100644 pages/api/health.ts create mode 100644 pages/api/ldo-stats.ts create mode 100644 pages/api/lido-stats.ts create mode 100644 pages/api/lidostats.ts create mode 100644 pages/api/metrics.ts create mode 100644 pages/api/oneinch-rate.ts create mode 100644 pages/api/rewards.ts create mode 100644 pages/api/rpc.ts create mode 100644 pages/api/short-lido-stats.ts create mode 100644 pages/api/sma-steth-apr.ts create mode 100644 pages/api/totalsupply.ts create mode 100644 pages/index.tsx create mode 100644 pages/referral.tsx create mode 100644 pages/rewards.tsx create mode 100644 pages/withdrawals/[[...mode]].tsx create mode 100644 pages/wrap/[[...mode]].tsx create mode 100644 playwright.config.ts create mode 100644 providers/app-flag.tsx create mode 100644 providers/index.tsx create mode 100644 providers/modals.tsx create mode 100644 providers/rewardsHistory.tsx create mode 100644 providers/theme.tsx create mode 100644 providers/web3.tsx create mode 100644 public/apple-touch-icon.png create mode 100644 public/favicon-1080x1080.svg create mode 100644 public/favicon-16x16.png create mode 100644 public/favicon-192x192.png create mode 100644 public/favicon-32x32.png create mode 100644 public/favicon.ico create mode 100644 public/lido-preview.png create mode 100644 public/manifest.json create mode 100644 renovate.json create mode 100644 scripts/build-dynamics.mjs create mode 100644 shared/banners/balancer/balancer.tsx create mode 100644 shared/banners/balancer/index.ts create mode 100644 shared/banners/balancer/styles.ts create mode 100644 shared/banners/balancer/types.ts create mode 100644 shared/banners/balancer/useBalancer.ts create mode 100644 shared/banners/curve/curve.tsx create mode 100644 shared/banners/curve/index.ts create mode 100644 shared/banners/curve/styles.ts create mode 100644 shared/banners/curve/types.ts create mode 100644 shared/banners/curve/useCurve.ts create mode 100644 shared/banners/index.ts create mode 100644 shared/banners/modal-pool-banners/index.ts create mode 100644 shared/banners/modal-pool-banners/modal-pool-banners.tsx create mode 100644 shared/banners/modal-pool-banners/styles.ts create mode 100644 shared/components/abuse-warning/abuse-warning.tsx create mode 100644 shared/components/abuse-warning/styles.tsx create mode 100644 shared/components/background-gradient/background-gradient.tsx create mode 100644 shared/components/background-gradient/styles.tsx create mode 100644 shared/components/banner/banner.tsx create mode 100644 shared/components/banner/styles.ts create mode 100644 shared/components/data-table-row-steth-by-wsteth/data-table-row-steth-by-wsteth.tsx create mode 100644 shared/components/data-table-row-steth-by-wsteth/index.ts create mode 100644 shared/components/faq/faq.tsx create mode 100644 shared/components/footer/footer.tsx create mode 100644 shared/components/footer/styles.tsx create mode 100644 shared/components/header/components/header-wallet.tsx create mode 100644 shared/components/header/components/navigation/navigation.tsx create mode 100644 shared/components/header/components/navigation/styles.tsx create mode 100644 shared/components/header/components/navigation/withdraw-icon.tsx create mode 100644 shared/components/header/header.tsx create mode 100644 shared/components/header/styles.tsx create mode 100644 shared/components/index.ts create mode 100644 shared/components/info-box/index.ts create mode 100644 shared/components/info-box/info-box.tsx create mode 100644 shared/components/info-box/styled.ts create mode 100644 shared/components/layout-effect-ssr-delayed/index.ts create mode 100644 shared/components/layout-effect-ssr-delayed/layout-effect-ssr-delayed.tsx create mode 100644 shared/components/layout/layout.tsx create mode 100644 shared/components/layout/styles.tsx create mode 100644 shared/components/local-link/index.tsx create mode 100644 shared/components/logos/logos.tsx create mode 100644 shared/components/logos/styles.tsx create mode 100644 shared/components/main/main.tsx create mode 100644 shared/components/main/styles.tsx create mode 100644 shared/components/matomo-link/matomo-link.tsx create mode 100644 shared/components/no-ssr-wrapper.tsx create mode 100644 shared/components/popup-wrapper/popup-wrapper.tsx create mode 100644 shared/components/popup-wrapper/styles.tsx create mode 100644 shared/components/section/section.tsx create mode 100644 shared/components/section/styles.tsx create mode 100644 shared/components/switch/index.ts create mode 100644 shared/components/switch/styles.tsx create mode 100644 shared/components/switch/switch-item.tsx create mode 100644 shared/components/switch/switch.tsx create mode 100644 shared/components/switch/types.ts create mode 100644 shared/components/token-to-wallet/styles.tsx create mode 100644 shared/components/token-to-wallet/token-to-wallet.tsx create mode 100644 shared/components/tooltip-hoverable/styles.ts create mode 100644 shared/components/tooltip-hoverable/tooltip-hoverable.tsx create mode 100644 shared/components/tooltip-hoverable/types.ts create mode 100644 shared/components/tx-link-etherscan/index.tsx create mode 100644 shared/components/tx-link-etherscan/tx-link-etherscan.tsx create mode 100644 shared/components/tx-stage-modal-content/icons-styles.ts create mode 100644 shared/components/tx-stage-modal-content/icons.tsx create mode 100644 shared/components/tx-stage-modal-content/index.ts create mode 100644 shared/components/tx-stage-modal-content/styles.ts create mode 100644 shared/components/tx-stage-modal-content/tx-stage-modal-content.tsx create mode 100644 shared/components/tx-stage-modal/index.ts create mode 100644 shared/components/tx-stage-modal/styles.tsx create mode 100644 shared/components/tx-stage-modal/text-utils.tsx create mode 100644 shared/components/tx-stage-modal/tx-stage-modal-shape.tsx create mode 100644 shared/components/tx-stage-modal/tx-stage-modal.tsx create mode 100644 shared/components/tx-stage-modal/types.ts create mode 100644 shared/formatters/format-date.tsx create mode 100644 shared/formatters/format-percent.tsx create mode 100644 shared/formatters/format-price.tsx create mode 100644 shared/formatters/format-token.tsx create mode 100644 shared/formatters/index.ts create mode 100644 shared/forms/components/input-amount/index.tsx create mode 100644 shared/forms/components/input-amount/input-amount.tsx create mode 100644 shared/forms/components/input-decorator-locked/index.ts create mode 100644 shared/forms/components/input-decorator-locked/input-decorator-locked.tsx create mode 100644 shared/forms/components/input-decorator-locked/styles.tsx create mode 100644 shared/forms/components/input-decorator-max-button/index.ts create mode 100644 shared/forms/components/input-decorator-max-button/input-decorator-max-button.tsx create mode 100644 shared/forms/components/input-decorator-max-button/styled.ts create mode 100644 shared/forms/components/input-number/index.ts create mode 100644 shared/forms/components/input-number/input-number.tsx create mode 100644 shared/forms/hooks/useCurrencyAmountValidator.ts create mode 100644 shared/forms/hooks/useCurrencyInput.ts create mode 100644 shared/forms/hooks/useCurrencyMaxAmount.ts create mode 100644 shared/forms/hooks/useInputValidate.ts create mode 100644 shared/forms/types/validation-fn.ts create mode 100644 shared/hooks/index.ts create mode 100644 shared/hooks/txCost.ts create mode 100644 shared/hooks/use-awaiter.ts create mode 100644 shared/hooks/useApprove.ts create mode 100644 shared/hooks/useAsyncMemo.ts create mode 100644 shared/hooks/useCopyToClipboard.ts create mode 100644 shared/hooks/useDebouncedValue.ts create mode 100644 shared/hooks/useENSAddress.ts create mode 100644 shared/hooks/useERC20PermitSignature.ts create mode 100644 shared/hooks/useElementResize.ts create mode 100644 shared/hooks/useEthApr.ts create mode 100644 shared/hooks/useForceUpdate.ts create mode 100644 shared/hooks/useGasPrice.ts create mode 100644 shared/hooks/useIsContract.ts create mode 100644 shared/hooks/useIsLedgerLive.ts create mode 100644 shared/hooks/useIsMultisig.ts create mode 100644 shared/hooks/useLidoApr.ts create mode 100644 shared/hooks/useLidoStats.ts create mode 100644 shared/hooks/useLidoSwr.ts create mode 100644 shared/hooks/useMatomoEventHandle.ts create mode 100644 shared/hooks/useMaxGasPrice.ts create mode 100644 shared/hooks/useModal.ts create mode 100644 shared/hooks/usePrevious.ts create mode 100644 shared/hooks/useSafeQueryString.ts create mode 100644 shared/hooks/useSsrMode.ts create mode 100644 shared/hooks/useStakingLimitInfo.ts create mode 100644 shared/hooks/useStakingLimitLevel.ts create mode 100644 shared/hooks/useStethByWsteth.ts create mode 100644 shared/hooks/useWstethBySteth.ts create mode 100644 shared/l2-banner/index.ts create mode 100644 shared/l2-banner/l2-banner.tsx create mode 100644 shared/l2-banner/styles.ts create mode 100644 shared/wallet/button/button.tsx create mode 100644 shared/wallet/button/styles.tsx create mode 100644 shared/wallet/card/card.tsx create mode 100644 shared/wallet/card/styles.tsx create mode 100644 shared/wallet/card/types.ts create mode 100644 shared/wallet/components/address-badge/address-badge.tsx create mode 100644 shared/wallet/components/address-badge/styles.tsx create mode 100644 shared/wallet/connect/connect.tsx create mode 100644 shared/wallet/fallback/fallback.tsx create mode 100644 shared/wallet/fallback/styles.tsx create mode 100644 shared/wallet/fallback/useErrorMessage.ts create mode 100644 shared/wallet/index.ts create mode 100644 shared/wallet/modal/modal.tsx create mode 100644 shared/wallet/modal/styles.tsx create mode 100644 shared/wallet/types.ts create mode 100644 styles/global.ts create mode 100644 styles/index.ts create mode 100644 test/README.md create mode 100644 test/config.ts create mode 100644 test/consts.ts create mode 100644 test/pages/widget.page.ts create mode 100644 test/smoke.spec.ts create mode 100644 test/widget.spec.ts create mode 100644 tsconfig.json create mode 100644 types/api.ts create mode 100644 types/components.ts create mode 100644 types/hooks.ts create mode 100644 types/index.ts create mode 100644 types/limit.ts create mode 100644 types/subgraph.ts create mode 100644 utils/__tests__/bigNumber.test.ts create mode 100644 utils/__tests__/encodeURLQuery.test.ts create mode 100644 utils/__tests__/extractErrorMessage.test.ts create mode 100644 utils/__tests__/formatBalance.test.ts create mode 100644 utils/__tests__/getErrorMessage.test.ts create mode 100644 utils/__tests__/maxNumberValidation.test.ts create mode 100644 utils/__tests__/replaceAll.test.ts create mode 100644 utils/__tests__/shortenTokenValue.test.ts create mode 100644 utils/appCookies.ts create mode 100644 utils/assert.ts create mode 100644 utils/bigNumber.ts create mode 100644 utils/chains.ts create mode 100644 utils/encodeURLQuery.ts create mode 100644 utils/extractErrorMessage.ts create mode 100644 utils/fetcherError.ts create mode 100644 utils/formatBalance.ts create mode 100644 utils/formatBalanceString.ts create mode 100644 utils/getErrorMessage.ts create mode 100644 utils/getNFTUrl.ts create mode 100644 utils/getScreenSize.ts create mode 100644 utils/getTokenDisplayName.ts create mode 100644 utils/index.ts create mode 100644 utils/isContract.ts create mode 100644 utils/isValidEtherValue.ts create mode 100644 utils/logger.ts create mode 100644 utils/maxNumberValidation.ts create mode 100644 utils/nprogress.ts create mode 100644 utils/parallelizePromises.ts create mode 100644 utils/prependBasePath.ts create mode 100644 utils/qa.ts create mode 100644 utils/replaceAll.ts create mode 100644 utils/rpcProviders.ts create mode 100644 utils/shortenTokenValue.ts create mode 100644 utils/stakingLimit.ts create mode 100644 utils/standardFetcher.ts create mode 100644 utils/swrAbortableMiddleware.ts create mode 100644 utils/swrStrategies.ts create mode 100644 utils/weiToEth.ts create mode 100644 utilsApi/apiHelpers.ts create mode 100644 utilsApi/fetchApiWrapper.ts create mode 100644 utilsApi/fetchRPC.ts create mode 100644 utilsApi/getEthApr.ts create mode 100644 utilsApi/getEthPrice.ts create mode 100644 utilsApi/getLdoStats.ts create mode 100644 utilsApi/getLidoHoldersViaSubgraphs.ts create mode 100644 utilsApi/getLidoStats.ts create mode 100644 utilsApi/getOneInchRate.ts create mode 100644 utilsApi/getSmaStethApr.ts create mode 100644 utilsApi/getStEthPrice.ts create mode 100644 utilsApi/getSubgraphUrl.ts create mode 100644 utilsApi/getTotalStaked.ts create mode 100644 utilsApi/index.ts create mode 100644 utilsApi/metrics/index.ts create mode 100644 utilsApi/metrics/metrics.ts create mode 100644 utilsApi/metrics/request.ts create mode 100644 utilsApi/metrics/subgraph.ts create mode 100644 utilsApi/nextApiWrappers.ts create mode 100644 utilsApi/rpcProviders.ts create mode 100644 utilsApi/rpcUrls.ts create mode 100644 utilsApi/withCsp.ts create mode 100644 yarn.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b5ca68e9f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,47 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# generated +/generated + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env.local +.env.development.local +.env.test.local +.env.production.local + +# vercel +.vercel + +# git +.git +**/.github + +#ide +.vscode + +public/runtime/ +Dockerfile diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..c3bc4aa7b --- /dev/null +++ b/.editorconfig @@ -0,0 +1,6 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +insert_final_newline = true \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 000000000..3522ddbc3 --- /dev/null +++ b/.env @@ -0,0 +1,61 @@ +# https://{NETWORK}.infura.io/v3/{INFURA_API_KEY} +INFURA_API_KEY= + +# https://eth-{NETWORK}.alchemyapi.io/v2/{ALCHEMY_API_KEY} +ALCHEMY_API_KEY= + +# supported networks for connecting wallet +SUPPORTED_CHAINS=1,4,5 + +# this chain uses when a wallet is not connected +DEFAULT_CHAIN=1 + +# api key for ethplorer for token data +ETHPLORER_API_KEY=freekey + +# Variables to read/update staking apr stats on cloudflare vk storage +# Not necessary for development +CLOUDFLARE_API_TOKEN= +CLOUDFLARE_ACCOUNT_ID= +CLOUDFLARE_KV_NAMESPACE_ID= + +# comma-separated trusted hosts for Content Security Policy +# e.g. http://localhost:PORT for local development +CSP_TRUSTED_HOSTS=https://*.lido.fi + +# put "true" enable report only mode for CSP +CSP_REPORT_ONLY=true + +# api endpoint for reporting csp violations +CSP_REPORT_URI=https://stake.lido.fi/api/csp-report + +# Subgraph endpoint +SUBGRAPH_MAINNET=https://api.thegraph.com/subgraphs/name/lidofinance/lido +SUBGRAPH_ROPSTEN= +SUBGRAPH_RINKEBY= +SUBGRAPH_GOERLI= +SUBGRAPH_KOVAN= +SUBGRAPH_KINTSUGI= + +SUBGRAPH_REQUEST_TIMEOUT=5000 + +# allow some state overrides from browser console for QA +ENABLE_QA_HELPERS=false + +REWARDS_BACKEND=http://127.0.0.1:4000 + +# rate limit +RATE_LIMIT=60 +RATE_LIMIT_TIME_FRAME=60 + +# ETH API +ETH_API_BASE_PATH= + +# Withdrawals AI +WQ_API_BASE_PATH= + +# Matomo analytics (in future will be MATOMO_HOST) +MATOMO_URL= + +# WalletConnect project ID +WALLETCONNECT_PROJECT_ID= diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 000000000..f92ffc84b --- /dev/null +++ b/.eslintignore @@ -0,0 +1,15 @@ +# dependencies +/node_modules +/.pnp + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +/public diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..6960b265a --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,46 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:jsx-a11y/recommended", + "plugin:prettier/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": ["@typescript-eslint", "react"], + "rules": { + "prettier/prettier": ["error", {}, { "usePrettierrc": true }], + "react/react-in-jsx-scope": "off", + "react/prop-types": "off", + "react/display-name": "off", + "@typescript-eslint/no-empty-interface": "off", + "@typescript-eslint/no-unused-vars": [ + "warn", + { + "ignoreRestSiblings": true, + "argsIgnorePattern": "^_" + } + ], + "jsx-a11y/no-autofocus": "off", + "jsx-a11y/anchor-is-valid": "off", + "func-style": ["error", "expression"] + }, + "settings": { + "react": { + "version": "detect" + } + } +} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..413c58c4e --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +* @lidofinance/lido-eth-ui @lidofinance/lido-qa +.github @lidofinance/review-gh-workflows diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..583decfd1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + # Maintain dependencies for GitHub Actions + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..eb9824145 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,24 @@ + + +### Description + + + +### Demo + + + +### Code review notes + + + +### Testing notes + + + +### Checklist: + +- [ ] Checked the changes locally. +- [ ] Created / updated analytics events. +- [ ] Created / updated the technical documentation (README.md / [docs](https://docs.lido.fi/) / etc.). +- [ ] Affects / requires changes in other services (Matomo / Sentry / CloudFlare / etc.). diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 000000000..b43ab60f1 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,11 @@ +name: Tests and Checks + +on: pull_request + +jobs: + security: + uses: lidofinance/linters/.github/workflows/security.yml@master + docker: + uses: lidofinance/linters/.github/workflows/docker.yml@master + actions: + uses: lidofinance/linters/.github/workflows/actions.yml@master diff --git a/.github/workflows/ci-dev.yml b/.github/workflows/ci-dev.yml new file mode 100644 index 000000000..d564a95f7 --- /dev/null +++ b/.github/workflows/ci-dev.yml @@ -0,0 +1,39 @@ +name: CI Dev + +on: + workflow_dispatch: + push: + branches: + - develop + paths-ignore: + - ".github/**" + - "test/**" + +permissions: + contents: read + +jobs: + # test: + # ... + + deploy: + runs-on: ubuntu-latest + # needs: test + name: Build and deploy + steps: + - name: Testnet deploy + uses: lidofinance/dispatch-workflow@v1 + env: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + TARGET_REPO: "lidofinance/infra-mainnet" + TARGET_WORKFLOW: "deploy_testnet_staking_widget_ts.yaml" + TARGET: "develop" + + tests: + needs: deploy + if: ${{ github.event.pull_request.draft == false }} + uses: ./.github/workflows/tests.yml + secrets: inherit + with: + stand_url: https://stake.testnet.fi diff --git a/.github/workflows/ci-preview-demolish.yml b/.github/workflows/ci-preview-demolish.yml new file mode 100644 index 000000000..0056c15f2 --- /dev/null +++ b/.github/workflows/ci-preview-demolish.yml @@ -0,0 +1,27 @@ +name: CI Preview stand demolish + +on: + workflow_dispatch: + pull_request: + types: + [converted_to_draft, closed] + branches-ignore: + - main + +permissions: {} + +jobs: + deploy: + runs-on: ubuntu-latest + name: Build and deploy + steps: + - name: Preview stand deploying + uses: lidofinance/dispatch-workflow@v1 + env: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + TARGET_REPO: "lidofinance/infra-mainnet" + TARGET: ${{ github.head_ref }} + TARGET_WORKFLOW: "preview_stand_demolish.yaml" + INPUTS_REPO_NAME: ${{ github.repository }} + INPUTS_PR_ID: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/ci-preview-deploy.yml b/.github/workflows/ci-preview-deploy.yml new file mode 100644 index 000000000..2a9950fcb --- /dev/null +++ b/.github/workflows/ci-preview-deploy.yml @@ -0,0 +1,77 @@ +name: CI Preview stand deploy + +on: + workflow_dispatch: + inputs: + inventory: + description: inventory to be used for preview stand deploying + default: testnet + required: false + type: choice + options: + - staging-critical + - testnet + + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + branches-ignore: + - main + +permissions: + contents: read + pull-requests: read + +jobs: + deploy: + runs-on: ubuntu-latest + if: ${{ github.event.pull_request.draft == false }} + name: Build and deploy + outputs: + stand_url: ${{ steps.stand.outputs.url }} + steps: + - uses: lidofinance/gh-find-current-pr@v1 + id: pr + + - name: Set ref + id: ref + run: echo "short_ref=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT + + - name: Preview stand deploying + uses: lidofinance/dispatch-workflow@v1 + env: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + TARGET_REPO: 'lidofinance/infra-mainnet' + TARGET: ${{ github.head_ref || steps.ref.outputs.short_ref }} + TARGET_WORKFLOW: 'preview_stand_deploy.yaml' + INPUTS_REPO_NAME: ${{ github.repository }} + INPUTS_PR_ID: ${{ steps.pr.outputs.number }} + INPUTS_INVENTORY: "${{ inputs.inventory || 'testnet' }}" + + - name: Define repo short name + run: echo "short_name=$(echo ${{ github.repository }} | cut -d "/" -f 2)" >> $GITHUB_OUTPUT + id: repo + + - name: Define branch hash + run: echo "hash=$(echo "$HEAD_REF" | shasum -a 256 | cut -c -10)" >> $GITHUB_OUTPUT + id: branch + env: + HEAD_REF: ${{ github.head_ref || steps.ref.outputs.short_ref }} + + - name: Extract stand url + if: always() + run: echo "url=https://$SHORT_NAME-$BRANCH_HASH.branch-preview.org" >> $GITHUB_OUTPUT + id: stand + env: + SHORT_NAME: ${{ steps.repo.outputs.short_name }} + BRANCH_HASH: ${{ steps.branch.outputs.hash }} + + # tests: + # needs: deploy + # if: ${{ github.event.pull_request.draft == false }} + # uses: ./.github/workflows/tests.yml + # secrets: inherit + # with: + # stand_url: ${{ needs.deploy.outputs.stand_url }} + # stand_type: "${{ inputs.inventory || 'testnet' }}" + # on_preview_stand: true diff --git a/.github/workflows/ci-prod.yml b/.github/workflows/ci-prod.yml new file mode 100644 index 000000000..f0a935d50 --- /dev/null +++ b/.github/workflows/ci-prod.yml @@ -0,0 +1,25 @@ +name: CI Build prod image + +on: + release: + types: [released] + +permissions: {} + +jobs: + # test: + # ... + + deploy: + runs-on: ubuntu-latest + # needs: test + name: Build and deploy + steps: + - name: Build prod image + uses: lidofinance/dispatch-workflow@v1 + env: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + TARGET_REPO: "lidofinance/infra-mainnet" + TAG: "${{ github.event.release.tag_name }}" + TARGET_WORKFLOW: "build_critical_staking_widget_ts.yaml" diff --git a/.github/workflows/ci-staging.yml b/.github/workflows/ci-staging.yml new file mode 100644 index 000000000..dd92cab67 --- /dev/null +++ b/.github/workflows/ci-staging.yml @@ -0,0 +1,40 @@ +name: CI Staging + +on: + workflow_dispatch: + push: + branches: + - main + paths-ignore: + - ".github/**" + - "test/**" + +permissions: + contents: read + +jobs: + # test: + # ... + + deploy: + runs-on: ubuntu-latest + # needs: test + name: Build and deploy + steps: + - name: Staging deploy + uses: lidofinance/dispatch-workflow@v1 + env: + APP_ID: ${{ secrets.APP_ID }} + APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }} + TARGET_REPO: "lidofinance/infra-mainnet" + TARGET_WORKFLOW: "deploy_staging_critical_staking_widget_ts.yaml" + TARGET: "main" + + tests: + needs: deploy + if: ${{ github.event.pull_request.draft == false }} + uses: ./.github/workflows/tests.yml + secrets: inherit + with: + stand_url: https://stake.infra-staging.org + stand_type: staging diff --git a/.github/workflows/prepare-release-draft.yml b/.github/workflows/prepare-release-draft.yml new file mode 100644 index 000000000..4de99e1ae --- /dev/null +++ b/.github/workflows/prepare-release-draft.yml @@ -0,0 +1,14 @@ +name: Prepare release draft +on: + push: + branches: + - main + +permissions: + contents: write + +jobs: + prepare-release-draft: + uses: lidofinance/actions/.github/workflows/prepare-release-draft.yml@main + with: + target: main \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..749e20084 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,58 @@ +name: Tests + +on: + workflow_call: + inputs: + stand_url: + required: false + default: '' + type: string + stand_type: + default: 'testnet' + required: false + type: string + on_preview_stand: + required: false + default: false + type: boolean + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/setup-node@v3 + with: + node-version: '18' + + - name: Install dependencies + run: yarn install --immutable + + - name: Install Playwright Browsers + run: yarn playwright install --with-deps + + - name: Run tests on preview stand + if: ${{ inputs.on_preview_stand }} + run: yarn test + env: + STAND_URL: ${{ inputs.stand_url }} + STAND_TYPE: ${{ inputs.stand_type }} + STAND_USER: ${{ secrets.PREVIEW_STAND_HTTP_AUTH_USER }} + STAND_PASSWORD: ${{ secrets.PREVIEW_STAND_HTTP_AUTH_PASSWORD }} + + - name: Run tests on testnet/staging + if: ${{ !inputs.on_preview_stand }} + run: yarn test + env: + STAND_URL: ${{ inputs.stand_url }} + STAND_TYPE: ${{ inputs.stand_type }} + + - uses: actions/upload-artifact@v3 + if: always() + with: + name: playwright-report + path: test/playwright-report/ + retention-days: 30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..842040602 --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yalc +yalc.lock +tsconfig.tsbuildinfo + +# playwright +/test-results/ +/playwright-report/ +/playwright/.cache/ + +# testing +/coverage + +# generated +/generated + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store + +# env +.env.local +.env.production +.env.development + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +#ide +.vscode +.idea + +/public/runtime/ diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 000000000..31354ec13 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 000000000..d71a03b9f --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 000000000..d2ae35e84 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +yarn lint-staged diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..b87ad4294 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +# dependencies +/node_modules +/.pnp + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +CHANGELOG.md diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..b8f95aec6 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "useTabs": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "all" +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..37141f73b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,223 @@ +# This file is no longer used by semantic-release +For the latest release notes, please see the [GitHub releases page](https://github.com/lidofinance/staking-widget-ts/releases). + + +## [1.60.1](https://github.com/lidofinance/staking-widget-ts/compare/1.60.0...1.60.1) (2023-04-19) + + +### Bug Fixes + +* info box removed from claim page ([3f593a0](https://github.com/lidofinance/staking-widget-ts/commit/3f593a038cea8f48bc8caa06c73477f5cf260c9e)) + + + +# [1.60.0](https://github.com/lidofinance/staking-widget-ts/compare/1.59.1...1.60.0) (2023-04-18) + + +### Bug Fixes + +* change 'participant' to 'participants' in request modal ([ae9befc](https://github.com/lidofinance/staking-widget-ts/commit/ae9befc2802c22ba3909010686bd095c23dbf903)) +* clear code ([0acfde7](https://github.com/lidofinance/staking-widget-ts/commit/0acfde7e56f77f13d22ca20794d6d79b0879c5eb)) +* clear withdrawal input after request ([e6624cf](https://github.com/lidofinance/staking-widget-ts/commit/e6624cf73c11a702fec8dff4adf1d2ad7021527d)) +* createConnectors ([51966af](https://github.com/lidofinance/staking-widget-ts/commit/51966af60e423e043643eb1ea62640bad3a896de)) +* enable next-logger and rename config to .cjs ([40bedcc](https://github.com/lidofinance/staking-widget-ts/commit/40bedccfb9e52e71db652772488d6075b88f658e)) +* error when trying to enter very big number on withdrawal page ([9d315b0](https://github.com/lidofinance/staking-widget-ts/commit/9d315b0973d04f93dd6929927ea443530a185885)) +* faq typo ([53b8543](https://github.com/lidofinance/staking-widget-ts/commit/53b8543f11c51049edf1e94a2fe081f22e917c8b)) +* fix app error for small input value ([3f82e6a](https://github.com/lidofinance/staking-widget-ts/commit/3f82e6a6e4d083ea885d19dabe8625799c26ab40)) +* fix calc requests, fix showing message ([77cfd74](https://github.com/lidofinance/staking-widget-ts/commit/77cfd74a8b81ba47f279bb5ce4022fa8ce8b93a4)) +* fix request tx ([23e3cfb](https://github.com/lidofinance/staking-widget-ts/commit/23e3cfbf0a38165a069679ecafee2dda0960754a)) +* maximum requests notif text ([dd41da5](https://github.com/lidofinance/staking-widget-ts/commit/dd41da5aff68f38a10b2fa6ab0b03f3ffee817ac)) +* playwright in ESM mode ([602e473](https://github.com/lidofinance/staking-widget-ts/commit/602e4733f2737e6932b298560f63d5981ce37120)) +* remove nft button from faq ([c7fd770](https://github.com/lidofinance/staking-widget-ts/commit/c7fd77007f4c298e953c3923d1c72d5558c299c5)) +* remove one text ([fd9a20d](https://github.com/lidofinance/staking-widget-ts/commit/fd9a20d8c1e247b35a51b467f1a2b4a4c18262a9)) +* remove prefilled value on unwrap page ([7d1f0e5](https://github.com/lidofinance/staking-widget-ts/commit/7d1f0e59616e15061ce65d0eaecfc3c1abaa9662)) +* reset wrap amount on token change instead of max value, also no amount pre-fill on page load ([2c9f9ca](https://github.com/lidofinance/staking-widget-ts/commit/2c9f9caa3d7d0a3693e49dd7b8e48d5a693f80c4)) +* show add nft button only for wallets that supports this action ([f9157af](https://github.com/lidofinance/staking-widget-ts/commit/f9157afc6022087e45830bc29b39ac99db8e4da6)) +* temporary disable next-logger until fix ([89cc73c](https://github.com/lidofinance/staking-widget-ts/commit/89cc73c44a04cdf62c261d1209b08c387ffb7489)) +* temporary use NoSSRWrapper to fix ReefKnot SSR issue ([6b1d3f4](https://github.com/lidofinance/staking-widget-ts/commit/6b1d3f43314dd5d692f24ed9b2a707f783c84446)) +* use direct imports from reef-knot/web3-react ([01d2f5f](https://github.com/lidofinance/staking-widget-ts/commit/01d2f5fe4d589976b8db8686b1be0f4e28740689)) +* use reef-knot 1.0.8 ([6d8c535](https://github.com/lidofinance/staking-widget-ts/commit/6d8c5355022da682a1308929914c15b813753e57)) +* use reef-knot 1.0.9 ([724cf12](https://github.com/lidofinance/staking-widget-ts/commit/724cf12bb58d8c0ea65362099a190d61e9a18079)) +* withdrawal request hook params cleanup ([07f056d](https://github.com/lidofinance/staking-widget-ts/commit/07f056d9975e180e11a61557f356b042f8139ae4)) + + +### Features + +* config next.js to use ESM, update packages ([d8ea9af](https://github.com/lidofinance/staking-widget-ts/commit/d8ea9af5a8122550e96582b0db63ca96d9037a79)) +* config wagmi ([8655c75](https://github.com/lidofinance/staking-widget-ts/commit/8655c751b42514fe546847785add34cc951d51eb)) +* get supportedChains for wagmi from env ([39aa581](https://github.com/lidofinance/staking-widget-ts/commit/39aa581141674e0f659958b8fc7aa1922679eacd)) +* remove fiat amount from withdrawal option banner ([c2b9ed0](https://github.com/lidofinance/staking-widget-ts/commit/c2b9ed004328482adae22049823833d1a94f11cd)) +* remove requests count from the tooltip, fix infinity requests when choosing wsteth ([2609f76](https://github.com/lidofinance/staking-widget-ts/commit/2609f760be1af599249eb267c1fb155243dbf07f)) +* steth-wsteth/eth-steth exchange rates for request page ([5aa6d1f](https://github.com/lidofinance/staking-widget-ts/commit/5aa6d1fa758456e6c7257188ff6d6d22ce49dab0)) +* update faq texts ([e61fd44](https://github.com/lidofinance/staking-widget-ts/commit/e61fd440135c3baae3275a301369acd256cc5ca0)) +* use reef-knot 1.0.5 ([4fe7629](https://github.com/lidofinance/staking-widget-ts/commit/4fe7629cdc14a0d2ebe0667a954a42a1232fbe97)) +* use reef-knot 1.0.6 ([16d0a57](https://github.com/lidofinance/staking-widget-ts/commit/16d0a573776076aba5f84d74af7ec223c96bc9d2)) +* use reef-knot 1.0.7 ([eecd76f](https://github.com/lidofinance/staking-widget-ts/commit/eecd76f965d01da0f49eb086d02bdc329c199603)) +* useDisconnect wagmi ([4447fd0](https://github.com/lidofinance/staking-widget-ts/commit/4447fd051a210520864b4718201f6621b1e65560)) + + + +## [1.59.1](https://github.com/lidofinance/staking-widget-ts/compare/1.59.0...1.59.1) (2023-04-13) + + +### Bug Fixes + +* fix infinity gas price loader ([e34a55e](https://github.com/lidofinance/staking-widget-ts/commit/e34a55ed9d311757a24f3373e81e119e66da4d96)) +* fix steth token label on wrap page ([28bf79e](https://github.com/lidofinance/staking-widget-ts/commit/28bf79ea7680457b4337011fdaa9f63b33fb9132)) + + + +# [1.59.0](https://github.com/lidofinance/staking-widget-ts/compare/1.58.0...1.59.0) (2023-04-12) + + +### Bug Fixes + +* add temp error handler ([61d0a6d](https://github.com/lidofinance/staking-widget-ts/commit/61d0a6d62057fb522b1df6cb65f7e85d860cbebe)) +* change handle server error status ([755d1bb](https://github.com/lidofinance/staking-widget-ts/commit/755d1bb606781dda97961154a195a1ea8b9d8b32)) +* fix amount for stake ([dc72609](https://github.com/lidofinance/staking-widget-ts/commit/dc726092bbe55b943affb3f009c573a1084db36c)) +* refactoring ([108a6a5](https://github.com/lidofinance/staking-widget-ts/commit/108a6a54ccfca1a178146b18e8147f6a81e74bec)) +* remove hook ([d45aec0](https://github.com/lidofinance/staking-widget-ts/commit/d45aec061cb7475a5e306a0180a56a30ed035157)) +* update l2 text banner ([c052427](https://github.com/lidofinance/staking-widget-ts/commit/c0524278995ddb9497e156219c59bb6e004f7c84)) + + +### Features + +* add bundle analyzer ([28d9967](https://github.com/lidofinance/staking-widget-ts/commit/28d996701b447e943ff1ab1e940e99dd400d9a7e)) +* add random texts ([b3f4b44](https://github.com/lidofinance/staking-widget-ts/commit/b3f4b440a984ed7ea23f7a0a56b9f299e0701802)) +* add removing amount from query ([18ebd13](https://github.com/lidofinance/staking-widget-ts/commit/18ebd13fd84f3e62a7c7abd724dd35b0fa23bae2)) +* add server error ([b5ebdc8](https://github.com/lidofinance/staking-widget-ts/commit/b5ebdc8ad59beec0b667d1de5ae87e6321befecd)) +* add set diff amount to query url ([ade2d8c](https://github.com/lidofinance/staking-widget-ts/commit/ade2d8c48d791a9429509c4b4ac011d3682550e0)) +* add tvl message ([1bf5106](https://github.com/lidofinance/staking-widget-ts/commit/1bf51062d55e7a2fe6eb9baab14576aeac10badb)) +* change error handler for rewards page ([13889bc](https://github.com/lidofinance/staking-widget-ts/commit/13889bca1e6172fc2bac44d9f5735432f64db009)) +* more detailed subgraph errors ([57c328c](https://github.com/lidofinance/staking-widget-ts/commit/57c328ce62dc97c7d72823f51f42922675fd0b69)) +* update text on l2 banner ([1deef8b](https://github.com/lidofinance/staking-widget-ts/commit/1deef8bbabaff09a3a656814dc3d2af4b1e0872e)) + + + +# [1.58.0](https://github.com/lidofinance/staking-widget-ts/compare/1.57.0...1.58.0) (2023-04-05) + + +### Bug Fixes + +* change useSDK to useWeb3 for chainId ([4ff65c2](https://github.com/lidofinance/staking-widget-ts/commit/4ff65c2bbaaeff7d65cb9824715bd8b6ddc0bf31)) +* delete inline style ([d6d04ed](https://github.com/lidofinance/staking-widget-ts/commit/d6d04ed8316878f30dc554a34120f392e8214ef3)) +* fix bunker modal mobile style ([83e530c](https://github.com/lidofinance/staking-widget-ts/commit/83e530c8b3f7ef7677b6238b21ba8d50eb06354c)) +* fix navigation links ([cb1877b](https://github.com/lidofinance/staking-widget-ts/commit/cb1877b12aa1b6957d15713c0c9274ca28c2c6c4)) +* fix not connected wallet in header ([f102c4e](https://github.com/lidofinance/staking-widget-ts/commit/f102c4ecc0e362bd1e289d7e260158ab2c96a072)) +* fix text ([32ff562](https://github.com/lidofinance/staking-widget-ts/commit/32ff562cfeecffb3bf2cdecbf6597659743c7a5b)) +* fix text ([69d8e69](https://github.com/lidofinance/staking-widget-ts/commit/69d8e694aa479e8706e7b43e23f341bb0e19d346)) +* fix text ([6dcf68a](https://github.com/lidofinance/staking-widget-ts/commit/6dcf68ae0e346d89f2cd9e1c29ddaf29971fe583)) +* fix tooltip amount, fix input token name ([e357c00](https://github.com/lidofinance/staking-widget-ts/commit/e357c00d8e69cb3e88c9f2435c675b6befe3ee20)) +* fixes after regression tests ([ff01e94](https://github.com/lidofinance/staking-widget-ts/commit/ff01e947bf5fa3ea426c57637ceae1eab7f500f7)) +* hide eth amount symbol ([31dc34f](https://github.com/lidofinance/staking-widget-ts/commit/31dc34f8889d58a46604f4bdae74a2e5ec3d6821)) +* refactoring wsteth calc hook ([6be5510](https://github.com/lidofinance/staking-widget-ts/commit/6be5510214c65761855e52459bd0b85bce38f437)) +* remove zhejiang fallback ([c16239c](https://github.com/lidofinance/staking-widget-ts/commit/c16239c78235f58da3e9242f563db744650a8c1d)) +* request prop type ([efebad9](https://github.com/lidofinance/staking-widget-ts/commit/efebad9b3c129f0c3628c1912224b9d2fe86da11)) +* update text, request style ([55a5aeb](https://github.com/lidofinance/staking-widget-ts/commit/55a5aeb59a6e715c86f37077eb4401ab655d6195)) +* withdrawals faq improvements ([3b904f0](https://github.com/lidofinance/staking-widget-ts/commit/3b904f0d474de338ba68325b799f9fd3f7ccf555)) +* withdrawals faq text ([9ebc785](https://github.com/lidofinance/staking-widget-ts/commit/9ebc78548777d7492d10dd09712740a288b817c6)) + + +### Features + +* add calc fiat price, validate max input number, info message for requests count ([906619e](https://github.com/lidofinance/staking-widget-ts/commit/906619e1a6bfe4f322f04e54a83ab652e6bf203a)) +* add calc wsteth ([5e5873a](https://github.com/lidofinance/staking-widget-ts/commit/5e5873af808033646d333e309f685ee0c29fd348)) +* add links to FAQ ([9a1700f](https://github.com/lidofinance/staking-widget-ts/commit/9a1700fe5cbc18431503d3915085adce3d37b597)) +* add show error if wallet connected ([3c50b74](https://github.com/lidofinance/staking-widget-ts/commit/3c50b74f8d51c957edefd2f17cdd42ea0f144d47)) +* change nft banner ([b1b4c0b](https://github.com/lidofinance/staking-widget-ts/commit/b1b4c0be49f8a520b0ea0d2fad5cc2dcc3977149)) +* change request tab info text ([4fbb2d8](https://github.com/lidofinance/staking-widget-ts/commit/4fbb2d8543c099085b81eff470251d190eb54fc1)) +* claim faq ([21a0991](https://github.com/lidofinance/staking-widget-ts/commit/21a099156cd5e7206b83f8d4699bf92054d338f6)) +* hide requests list on request tab ([da6dda8](https://github.com/lidofinance/staking-widget-ts/commit/da6dda8d7fb321e00ac3ac2483cae2dd5fbe1eaf)) +* show calc data for any input value ([d20e53d](https://github.com/lidofinance/staking-widget-ts/commit/d20e53d9583627e371ad77f582948e8c4477c646)) +* update bunker mode text ([2d0c50e](https://github.com/lidofinance/staking-widget-ts/commit/2d0c50e61321ee009c20c1493b25a6c43427696e)) +* update queu tooltip style ([4a52fc3](https://github.com/lidofinance/staking-widget-ts/commit/4a52fc3a744ebf4b67300b8f357be50839262e31)) +* update text, change nft banner ([7ba5e9e](https://github.com/lidofinance/staking-widget-ts/commit/7ba5e9e9dc2210482aeab164a260afce31984437)) +* withdrawals faq ([6b70369](https://github.com/lidofinance/staking-widget-ts/commit/6b703690ba53ca495f96df7ded40971a236759d6)) +* withdrawals faq links and dynamic data ([5acc97e](https://github.com/lidofinance/staking-widget-ts/commit/5acc97e928c7b1aebb4dd17cbd71a6b133857df9)) +* withdrawals faq texts update ([a5128e5](https://github.com/lidofinance/staking-widget-ts/commit/a5128e5dee56d50e04aeadae27a449f95ff5c1bc)) + + + +# [1.57.0](https://github.com/lidofinance/staking-widget-ts/compare/1.56.0...1.57.0) (2023-03-14) + + +### Bug Fixes + +* date order ([b95ad3f](https://github.com/lidofinance/staking-widget-ts/commit/b95ad3fb7c027209b593d9059ca8e38955adeef0)) +* date visibility ([45b9d4d](https://github.com/lidofinance/staking-widget-ts/commit/45b9d4dd028ac71658307be28953d4bbd5cbae67)) +* header border ([5e3b9eb](https://github.com/lidofinance/staking-widget-ts/commit/5e3b9eb2e3e0c8c30cce4841925d81624ee195f6)) + + +### Features + +* mobile reward list header ([ad94fa0](https://github.com/lidofinance/staking-widget-ts/commit/ad94fa0be85435763a539a30ef03dd3ea46cf1f9)) +* mobile rewards table ([c69fc21](https://github.com/lidofinance/staking-widget-ts/commit/c69fc21a8d4d3f820c81329927fa60843be09822)) + + + +# [1.56.0](https://github.com/lidofinance/staking-widget-ts/compare/1.55.0...1.56.0) (2023-03-13) + + +### Bug Fixes + +* add etherscan link for unwrap ([84db2ec](https://github.com/lidofinance/staking-widget-ts/commit/84db2eca5fdbf426e3467f1f76be8d0dae6c7eb1)) +* unwrap disable on error ([9adf620](https://github.com/lidofinance/staking-widget-ts/commit/9adf6200c59a53c49fbf6106576fe0337b99bfb8)) + + +### Features + +* disable buttons on validation error ([929c405](https://github.com/lidofinance/staking-widget-ts/commit/929c4053f15f91d046b326e17c3d71b1a637f34e)) + + + +# [1.55.0](https://github.com/lidofinance/staking-widget-ts/compare/1.54.0...1.55.0) (2023-03-06) + + +### Bug Fixes + +* add actual gas limit for calc max amount ([2cf394d](https://github.com/lidofinance/staking-widget-ts/commit/2cf394df58ca6e45b998c96e156e03f8dd276748)) +* error object nesting ([b3a20ed](https://github.com/lidofinance/staking-widget-ts/commit/b3a20ed30d5e132195eff7f42d5898e6f1cd9250)) + + +### Features + +* add calc maxFeePerGas for max amount ([fdb4f10](https://github.com/lidofinance/staking-widget-ts/commit/fdb4f10d4f2eef9d1be89b85321ec5f916185f49)) +* change tx const to max tx cost for all operations ([0eb5db1](https://github.com/lidofinance/staking-widget-ts/commit/0eb5db1259308e21e69a089f5103e186be927e44)) + + + +# [1.54.0](https://github.com/lidofinance/staking-widget-ts/compare/1.53.0...1.54.0) (2023-02-22) + + +### Bug Fixes + +* fix button hover color ([0402fc1](https://github.com/lidofinance/staking-widget-ts/commit/0402fc1bd8387b0c211e9315150f8bf15ab4ec0d)) + + +### Features + +* add explore defi buuton in succes modal ([e29528d](https://github.com/lidofinance/staking-widget-ts/commit/e29528d346d146b6c4de52d40e14bd1108d1f2f2)) + + + +# [1.53.0](https://github.com/lidofinance/staking-widget-ts/compare/1.52.0...1.53.0) (2023-02-21) + + +### Bug Fixes + +* added missing folder ([2b49276](https://github.com/lidofinance/staking-widget-ts/commit/2b4927650a7ad2f35ff9fb8f7ec887d209ec5137)) +* fix bff for rewards ([ea2c005](https://github.com/lidofinance/staking-widget-ts/commit/ea2c005686b29d6c8fb55ea9db2bd36bd83618d1)) +* phrasing ([97ddde5](https://github.com/lidofinance/staking-widget-ts/commit/97ddde5696f7ba41fca5e2fcdd4329f07405b6f0)) +* remove console log ([1a83047](https://github.com/lidofinance/staking-widget-ts/commit/1a83047b54fa590bd5a21b5f8a56e45de774f3c4)) +* wording, spelling, escaped chars ([8a0682d](https://github.com/lidofinance/staking-widget-ts/commit/8a0682d56019840663fe4b4ba6dc6506d67f2d5b)) + + +### Features + +* add isMaxDisabled to all forms ([39fdfa5](https://github.com/lidofinance/staking-widget-ts/commit/39fdfa5c607f02f036966a2cf0cbd09dfd1c8c8a)) +* add matomo event to token select on wrap ([8538bbf](https://github.com/lidofinance/staking-widget-ts/commit/8538bbfaa69ed9baf32d108be6ae5b54c5e80b9d)) +* bump ts & moved playwright to the root ([d3f965f](https://github.com/lidofinance/staking-widget-ts/commit/d3f965f3caa071080ad70d20deb42cf1ba6f2360)) +* disable button on zero balance ([5435ead](https://github.com/lidofinance/staking-widget-ts/commit/5435ead3eb383bf7cb459af99f7a778e4171c0ae)) + + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..ea71773a6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# build env +FROM node:16-alpine as build + +WORKDIR /app + +RUN apk add --no-cache git=~2 +COPY package.json yarn.lock ./ + +RUN yarn install --frozen-lockfile --non-interactive --ignore-scripts && yarn cache clean +COPY . . +RUN NODE_NO_BUILD_DYNAMICS=true yarn typechain && yarn build +# public/runtime is used to inject runtime vars; it should exist and user node should have write access there for it +RUN rm -rf /app/public/runtime && mkdir /app/public/runtime && chown node /app/public/runtime + +# final image +FROM node:16-alpine as base + +ARG BASE_PATH="" +ARG SUPPORTED_CHAINS="1" +ARG DEFAULT_CHAIN="1" + +ENV NEXT_TELEMETRY_DISABLED=1 \ + BASE_PATH=$BASE_PATH \ + SUPPORTED_CHAINS=$SUPPORTED_CHAINS \ + DEFAULT_CHAIN=$DEFAULT_CHAIN + +WORKDIR /app +RUN apk add --no-cache curl=~8 +COPY --from=build /app /app + +USER node +EXPOSE 3000 + +HEALTHCHECK --interval=10s --timeout=3s \ + CMD curl -f http://localhost:3000/api/health || exit 1 + +CMD ["yarn", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..261eeb9e9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 000000000..4520a8d98 --- /dev/null +++ b/README.md @@ -0,0 +1,79 @@ +# Lido Ethereum Liquid Staking Widget + +A widget for submitting Ether to the pool based on [Lido Frontend Template](https://github.com/lidofinance/lido-frontend-template). + +### Pre-requisites + +- Node.js v16+ +- Yarn package manager + +This project requires an .env file which is distributed via private communication channels. A sample can be found in .env. + +### Development + +Step 1. Copy the contents of `sample.env` to `.env.local` + +```bash +cp sample.env .env.local +``` + +Step 2. Fill out the `.env.local`. You may need to sign up for [Infura](https://infura.io/) or [Alchemy](https://www.alchemy.com/), if you haven't already, to be able to use Ethereum JSON RPC connection. + +Step 3. Install dependencies + +```bash +yarn install +``` + +Step 4. Start the development server + +```bash +yarn dev +``` + +### Environment variables + +Note! Avoid using `NEXT_PUBLIC_` environment variables as it hinders our CI pipeline. Please use server-side environment variables and pass them to the client using `getInitialProps` in `_app.js`. + +### Automatic versioning + +Note! This repo uses automatic versioning, please follow the [commit message conventions](https://www.conventionalcommits.org/en/v1.0.0/). + +e.g. + +``` +git commit -m "fix: a bug in calculation" +git commit -m "feat: dark theme" +``` + +## Production + +```bash +yarn build && yarn start +``` + +## Adding a new route API + +- create a new file in `pages/api/` folder +- use `wrapRequest` function from `utilsApi/apiWrappers.ts` +- use default wrappers from `utilsApi/apiWrappers.ts` if needed (e.g. `defaultErrorHandler` for handle errors) + +**Example:** + +```ts +const someRequest: API = async (req, res) => await fetch(); + +export default wrapRequest([defaultErrorHandler])(someRequest); +``` + +## Release flow + +To create a new release: + +1. Merge all changes to the `main` branch. +1. After the merge, the `Prepare release draft` action will run automatically. When the action is complete, a release draft is created. +1. When you need to release, go to Repo → Releases. +1. Publish the desired release draft manually by clicking the edit button - this release is now the `Latest Published`. +1. After publication, the action to create a release bump will be triggered automatically. + +Learn more about [App Release Flow](https://www.notion.so/App-Release-Flow-f8a3484deecb40cb9d8da4d82c1afe96). diff --git a/abi/aggregator.abi.json b/abi/aggregator.abi.json new file mode 100644 index 000000000..dbe72bfc5 --- /dev/null +++ b/abi/aggregator.abi.json @@ -0,0 +1,325 @@ +[ + { + "inputs": [ + { "internalType": "address", "name": "_aggregator", "type": "address" }, + { + "internalType": "address", + "name": "_accessController", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "int256", + "name": "current", + "type": "int256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "roundId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "updatedAt", + "type": "uint256" + } + ], + "name": "AnswerUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "roundId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "startedBy", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + } + ], + "name": "NewRound", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "OwnershipTransferRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "accessController", + "outputs": [ + { + "internalType": "contract AccessControllerInterface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "aggregator", + "outputs": [{ "internalType": "address", "name": "", "type": "address" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "_aggregator", "type": "address" } + ], + "name": "confirmAggregator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [{ "internalType": "uint8", "name": "", "type": "uint8" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "description", + "outputs": [{ "internalType": "string", "name": "", "type": "string" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_roundId", "type": "uint256" } + ], + "name": "getAnswer", + "outputs": [{ "internalType": "int256", "name": "", "type": "int256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint80", "name": "_roundId", "type": "uint80" } + ], + "name": "getRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint256", "name": "_roundId", "type": "uint256" } + ], + "name": "getTimestamp", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestAnswer", + "outputs": [{ "internalType": "int256", "name": "", "type": "int256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestRound", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestTimestamp", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { "internalType": "address payable", "name": "", "type": "address" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [{ "internalType": "uint16", "name": "", "type": "uint16" }], + "name": "phaseAggregators", + "outputs": [ + { + "internalType": "contract AggregatorV2V3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "phaseId", + "outputs": [{ "internalType": "uint16", "name": "", "type": "uint16" }], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "address", "name": "_aggregator", "type": "address" } + ], + "name": "proposeAggregator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "proposedAggregator", + "outputs": [ + { + "internalType": "contract AggregatorV2V3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { "internalType": "uint80", "name": "_roundId", "type": "uint80" } + ], + "name": "proposedGetRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proposedLatestRoundData", + "outputs": [ + { "internalType": "uint80", "name": "roundId", "type": "uint80" }, + { "internalType": "int256", "name": "answer", "type": "int256" }, + { "internalType": "uint256", "name": "startedAt", "type": "uint256" }, + { "internalType": "uint256", "name": "updatedAt", "type": "uint256" }, + { "internalType": "uint80", "name": "answeredInRound", "type": "uint80" } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_accessController", + "type": "address" + } + ], + "name": "setController", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [{ "internalType": "address", "name": "_to", "type": "address" }], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [{ "internalType": "uint256", "name": "", "type": "uint256" }], + "stateMutability": "view", + "type": "function" + } +] diff --git a/abi/aggregatorEthUsdPriceFeed.abi.json b/abi/aggregatorEthUsdPriceFeed.abi.json new file mode 100644 index 000000000..bca8a8414 --- /dev/null +++ b/abi/aggregatorEthUsdPriceFeed.abi.json @@ -0,0 +1,509 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_aggregator", + "type": "address" + }, + { + "internalType": "address", + "name": "_accessController", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "int256", + "name": "current", + "type": "int256" + }, + { + "indexed": true, + "internalType": "uint256", + "name": "roundId", + "type": "uint256" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "updatedAt", + "type": "uint256" + } + ], + "name": "AnswerUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "uint256", + "name": "roundId", + "type": "uint256" + }, + { + "indexed": true, + "internalType": "address", + "name": "startedBy", + "type": "address" + }, + { + "indexed": false, + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + } + ], + "name": "NewRound", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "OwnershipTransferRequested", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "address", + "name": "from", + "type": "address" + }, + { + "indexed": true, + "internalType": "address", + "name": "to", + "type": "address" + } + ], + "name": "OwnershipTransferred", + "type": "event" + }, + { + "inputs": [], + "name": "acceptOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "accessController", + "outputs": [ + { + "internalType": "contract AccessControllerInterface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "aggregator", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_aggregator", + "type": "address" + } + ], + "name": "confirmAggregator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "decimals", + "outputs": [ + { + "internalType": "uint8", + "name": "", + "type": "uint8" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "description", + "outputs": [ + { + "internalType": "string", + "name": "", + "type": "string" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_roundId", + "type": "uint256" + } + ], + "name": "getAnswer", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint80", + "name": "_roundId", + "type": "uint80" + } + ], + "name": "getRoundData", + "outputs": [ + { + "internalType": "uint80", + "name": "roundId", + "type": "uint80" + }, + { + "internalType": "int256", + "name": "answer", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "updatedAt", + "type": "uint256" + }, + { + "internalType": "uint80", + "name": "answeredInRound", + "type": "uint80" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint256", + "name": "_roundId", + "type": "uint256" + } + ], + "name": "getTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestAnswer", + "outputs": [ + { + "internalType": "int256", + "name": "", + "type": "int256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestRound", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestRoundData", + "outputs": [ + { + "internalType": "uint80", + "name": "roundId", + "type": "uint80" + }, + { + "internalType": "int256", + "name": "answer", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "updatedAt", + "type": "uint256" + }, + { + "internalType": "uint80", + "name": "answeredInRound", + "type": "uint80" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "latestTimestamp", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "owner", + "outputs": [ + { + "internalType": "address", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "name": "phaseAggregators", + "outputs": [ + { + "internalType": "contract AggregatorV2V3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "phaseId", + "outputs": [ + { + "internalType": "uint16", + "name": "", + "type": "uint16" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_aggregator", + "type": "address" + } + ], + "name": "proposeAggregator", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "proposedAggregator", + "outputs": [ + { + "internalType": "contract AggregatorV2V3Interface", + "name": "", + "type": "address" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "uint80", + "name": "_roundId", + "type": "uint80" + } + ], + "name": "proposedGetRoundData", + "outputs": [ + { + "internalType": "uint80", + "name": "roundId", + "type": "uint80" + }, + { + "internalType": "int256", + "name": "answer", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "updatedAt", + "type": "uint256" + }, + { + "internalType": "uint80", + "name": "answeredInRound", + "type": "uint80" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [], + "name": "proposedLatestRoundData", + "outputs": [ + { + "internalType": "uint80", + "name": "roundId", + "type": "uint80" + }, + { + "internalType": "int256", + "name": "answer", + "type": "int256" + }, + { + "internalType": "uint256", + "name": "startedAt", + "type": "uint256" + }, + { + "internalType": "uint256", + "name": "updatedAt", + "type": "uint256" + }, + { + "internalType": "uint80", + "name": "answeredInRound", + "type": "uint80" + } + ], + "stateMutability": "view", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_accessController", + "type": "address" + } + ], + "name": "setController", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "_to", + "type": "address" + } + ], + "name": "transferOwnership", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [], + "name": "version", + "outputs": [ + { + "internalType": "uint256", + "name": "", + "type": "uint256" + } + ], + "stateMutability": "view", + "type": "function" + } +] diff --git a/abi/oracle.abi.json b/abi/oracle.abi.json new file mode 100644 index 000000000..968daabea --- /dev/null +++ b/abi/oracle.abi.json @@ -0,0 +1,528 @@ +[ + { + "constant": true, + "inputs": [], + "name": "getCurrentOraclesReportStatus", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_value", "type": "uint256" }], + "name": "setAllowedBeaconBalanceAnnualRelativeIncrease", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "hasInitialized", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getVersion", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_script", "type": "bytes" }], + "name": "getEVMScriptExecutor", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "MANAGE_QUORUM", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_epochId", "type": "uint256" }, + { "name": "_beaconBalance", "type": "uint64" }, + { "name": "_beaconValidators", "type": "uint32" } + ], + "name": "reportBeacon", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getRecoveryVault", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getAllowedBeaconBalanceAnnualRelativeIncrease", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getAllowedBeaconBalanceRelativeDecrease", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getExpectedEpochId", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getLastCompletedReportDelta", + "outputs": [ + { "name": "postTotalPooledEther", "type": "uint256" }, + { "name": "preTotalPooledEther", "type": "uint256" }, + { "name": "timeElapsed", "type": "uint256" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getLido", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "SET_BEACON_REPORT_RECEIVER", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "MANAGE_MEMBERS", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getCurrentFrame", + "outputs": [ + { "name": "frameEpochId", "type": "uint256" }, + { "name": "frameStartTime", "type": "uint256" }, + { "name": "frameEndTime", "type": "uint256" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "token", "type": "address" }], + "name": "allowRecoverability", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_index", "type": "uint256" }], + "name": "getCurrentReportVariant", + "outputs": [ + { "name": "beaconBalance", "type": "uint64" }, + { "name": "beaconValidators", "type": "uint32" }, + { "name": "count", "type": "uint16" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "appId", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getLastCompletedEpochId", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getInitializationBlock", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { + "name": "_allowedBeaconBalanceAnnualRelativeIncrease", + "type": "uint256" + }, + { "name": "_allowedBeaconBalanceRelativeDecrease", "type": "uint256" } + ], + "name": "initialize_v2", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_addr", "type": "address" }], + "name": "setBeaconReportReceiver", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_token", "type": "address" }], + "name": "transferToVault", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "SET_BEACON_SPEC", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { "name": "_sender", "type": "address" }, + { "name": "_role", "type": "bytes32" }, + { "name": "_params", "type": "uint256[]" } + ], + "name": "canPerform", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getCurrentEpochId", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getEVMScriptRegistry", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_member", "type": "address" }], + "name": "addOracleMember", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getBeaconReportReceiver", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "SET_REPORT_BOUNDARIES", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_quorum", "type": "uint256" }], + "name": "setQuorum", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getQuorum", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "kernel", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getOracleMembers", + "outputs": [{ "name": "", "type": "address[]" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "isPetrified", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_value", "type": "uint256" }], + "name": "setAllowedBeaconBalanceRelativeDecrease", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getBeaconSpec", + "outputs": [ + { "name": "epochsPerFrame", "type": "uint64" }, + { "name": "slotsPerEpoch", "type": "uint64" }, + { "name": "secondsPerSlot", "type": "uint64" }, + { "name": "genesisTime", "type": "uint64" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_epochsPerFrame", "type": "uint64" }, + { "name": "_slotsPerEpoch", "type": "uint64" }, + { "name": "_secondsPerSlot", "type": "uint64" }, + { "name": "_genesisTime", "type": "uint64" } + ], + "name": "setBeaconSpec", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "MAX_MEMBERS", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getCurrentReportVariantsSize", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_member", "type": "address" }], + "name": "removeOracleMember", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "executor", "type": "address" }, + { "indexed": false, "name": "script", "type": "bytes" }, + { "indexed": false, "name": "input", "type": "bytes" }, + { "indexed": false, "name": "returnData", "type": "bytes" } + ], + "name": "ScriptResult", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "vault", "type": "address" }, + { "indexed": true, "name": "token", "type": "address" }, + { "indexed": false, "name": "amount", "type": "uint256" } + ], + "name": "RecoverToVault", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "value", "type": "uint256" }], + "name": "AllowedBeaconBalanceAnnualRelativeIncreaseSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "value", "type": "uint256" }], + "name": "AllowedBeaconBalanceRelativeDecreaseSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "callback", "type": "address" }], + "name": "BeaconReportReceiverSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "member", "type": "address" }], + "name": "MemberAdded", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "member", "type": "address" }], + "name": "MemberRemoved", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "quorum", "type": "uint256" }], + "name": "QuorumChanged", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "epochId", "type": "uint256" }], + "name": "ExpectedEpochIdUpdated", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "name": "epochsPerFrame", "type": "uint64" }, + { "indexed": false, "name": "slotsPerEpoch", "type": "uint64" }, + { "indexed": false, "name": "secondsPerSlot", "type": "uint64" }, + { "indexed": false, "name": "genesisTime", "type": "uint64" } + ], + "name": "BeaconSpecSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "name": "epochId", "type": "uint256" }, + { "indexed": false, "name": "beaconBalance", "type": "uint128" }, + { "indexed": false, "name": "beaconValidators", "type": "uint128" }, + { "indexed": false, "name": "caller", "type": "address" } + ], + "name": "BeaconReported", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "name": "epochId", "type": "uint256" }, + { "indexed": false, "name": "beaconBalance", "type": "uint128" }, + { "indexed": false, "name": "beaconValidators", "type": "uint128" } + ], + "name": "Completed", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "name": "postTotalPooledEther", "type": "uint256" }, + { "indexed": false, "name": "preTotalPooledEther", "type": "uint256" }, + { "indexed": false, "name": "timeElapsed", "type": "uint256" }, + { "indexed": false, "name": "totalShares", "type": "uint256" } + ], + "name": "PostTotalShares", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "version", "type": "uint256" }], + "name": "ContractVersionSet", + "type": "event" + } +] diff --git a/abi/steth.abi.json b/abi/steth.abi.json new file mode 100644 index 000000000..292a45375 --- /dev/null +++ b/abi/steth.abi.json @@ -0,0 +1,726 @@ +[ + { + "constant": false, + "inputs": [], + "name": "resume", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "name", + "outputs": [{ "name": "", "type": "string" }], + "payable": false, + "stateMutability": "pure", + "type": "function" + }, + { + "constant": false, + "inputs": [], + "name": "stop", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "hasInitialized", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_spender", "type": "address" }, + { "name": "_amount", "type": "uint256" } + ], + "name": "approve", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "depositContract", "type": "address" }, + { "name": "_oracle", "type": "address" }, + { "name": "_operators", "type": "address" }, + { "name": "_treasury", "type": "address" }, + { "name": "_insuranceFund", "type": "address" } + ], + "name": "initialize", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getInsuranceFund", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "totalSupply", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_ethAmount", "type": "uint256" }], + "name": "getSharesByPooledEth", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_sender", "type": "address" }, + { "name": "_recipient", "type": "address" }, + { "name": "_amount", "type": "uint256" } + ], + "name": "transferFrom", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getOperators", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_script", "type": "bytes" }], + "name": "getEVMScriptExecutor", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "decimals", + "outputs": [{ "name": "", "type": "uint8" }], + "payable": false, + "stateMutability": "pure", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getRecoveryVault", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "DEPOSIT_SIZE", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getTotalPooledEther", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "PAUSE_ROLE", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_spender", "type": "address" }, + { "name": "_addedValue", "type": "uint256" } + ], + "name": "increaseAllowance", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getTreasury", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "SET_ORACLE", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "isStopped", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "MANAGE_WITHDRAWAL_KEY", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getBufferedEther", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "SIGNATURE_LENGTH", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getWithdrawalCredentials", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_account", "type": "address" }], + "name": "balanceOf", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getFeeDistribution", + "outputs": [ + { "name": "treasuryFeeBasisPoints", "type": "uint16" }, + { "name": "insuranceFeeBasisPoints", "type": "uint16" }, + { "name": "operatorsFeeBasisPoints", "type": "uint16" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_sharesAmount", "type": "uint256" }], + "name": "getPooledEthByShares", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_oracle", "type": "address" }], + "name": "setOracle", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "token", "type": "address" }], + "name": "allowRecoverability", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "appId", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getOracle", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getInitializationBlock", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_treasuryFeeBasisPoints", "type": "uint16" }, + { "name": "_insuranceFeeBasisPoints", "type": "uint16" }, + { "name": "_operatorsFeeBasisPoints", "type": "uint16" } + ], + "name": "setFeeDistribution", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_feeBasisPoints", "type": "uint16" }], + "name": "setFee", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_maxDeposits", "type": "uint256" }], + "name": "depositBufferedEther", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "symbol", + "outputs": [{ "name": "", "type": "string" }], + "payable": false, + "stateMutability": "pure", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "MANAGE_FEE", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_token", "type": "address" }], + "name": "transferToVault", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "SET_TREASURY", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { "name": "_sender", "type": "address" }, + { "name": "_role", "type": "bytes32" }, + { "name": "_params", "type": "uint256[]" } + ], + "name": "canPerform", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_referral", "type": "address" }], + "name": "submit", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": true, + "stateMutability": "payable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "WITHDRAWAL_CREDENTIALS_LENGTH", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_spender", "type": "address" }, + { "name": "_subtractedValue", "type": "uint256" } + ], + "name": "decreaseAllowance", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getEVMScriptRegistry", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "PUBKEY_LENGTH", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_amount", "type": "uint256" }, + { "name": "_pubkeyHash", "type": "bytes32" } + ], + "name": "withdraw", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_recipient", "type": "address" }, + { "name": "_amount", "type": "uint256" } + ], + "name": "transfer", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getDepositContract", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getBeaconStat", + "outputs": [ + { "name": "depositedValidators", "type": "uint256" }, + { "name": "beaconValidators", "type": "uint256" }, + { "name": "beaconBalance", "type": "uint256" } + ], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "BURN_ROLE", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_insuranceFund", "type": "address" }], + "name": "setInsuranceFund", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getFee", + "outputs": [{ "name": "feeBasisPoints", "type": "uint16" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "SET_INSURANCE_FUND", + "outputs": [{ "name": "", "type": "bytes32" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "kernel", + "outputs": [{ "name": "", "type": "address" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "getTotalShares", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [ + { "name": "_owner", "type": "address" }, + { "name": "_spender", "type": "address" } + ], + "name": "allowance", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": true, + "inputs": [], + "name": "isPetrified", + "outputs": [{ "name": "", "type": "bool" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_withdrawalCredentials", "type": "bytes32" }], + "name": "setWithdrawalCredentials", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [], + "name": "depositBufferedEther", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_account", "type": "address" }, + { "name": "_sharesAmount", "type": "uint256" } + ], + "name": "burnShares", + "outputs": [{ "name": "newTotalShares", "type": "uint256" }], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [{ "name": "_treasury", "type": "address" }], + "name": "setTreasury", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": false, + "inputs": [ + { "name": "_beaconValidators", "type": "uint256" }, + { "name": "_beaconBalance", "type": "uint256" } + ], + "name": "pushBeacon", + "outputs": [], + "payable": false, + "stateMutability": "nonpayable", + "type": "function" + }, + { + "constant": true, + "inputs": [{ "name": "_account", "type": "address" }], + "name": "sharesOf", + "outputs": [{ "name": "", "type": "uint256" }], + "payable": false, + "stateMutability": "view", + "type": "function" + }, + { "payable": true, "stateMutability": "payable", "type": "fallback" }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "executor", "type": "address" }, + { "indexed": false, "name": "script", "type": "bytes" }, + { "indexed": false, "name": "input", "type": "bytes" }, + { "indexed": false, "name": "returnData", "type": "bytes" } + ], + "name": "ScriptResult", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "vault", "type": "address" }, + { "indexed": true, "name": "token", "type": "address" }, + { "indexed": false, "name": "amount", "type": "uint256" } + ], + "name": "RecoverToVault", + "type": "event" + }, + { "anonymous": false, "inputs": [], "name": "Stopped", "type": "event" }, + { "anonymous": false, "inputs": [], "name": "Resumed", "type": "event" }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "from", "type": "address" }, + { "indexed": true, "name": "to", "type": "address" }, + { "indexed": false, "name": "value", "type": "uint256" } + ], + "name": "Transfer", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "owner", "type": "address" }, + { "indexed": true, "name": "spender", "type": "address" }, + { "indexed": false, "name": "value", "type": "uint256" } + ], + "name": "Approval", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "name": "feeBasisPoints", "type": "uint16" } + ], + "name": "FeeSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "name": "treasuryFeeBasisPoints", "type": "uint16" }, + { "indexed": false, "name": "insuranceFeeBasisPoints", "type": "uint16" }, + { "indexed": false, "name": "operatorsFeeBasisPoints", "type": "uint16" } + ], + "name": "FeeDistributionSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": false, "name": "withdrawalCredentials", "type": "bytes32" } + ], + "name": "WithdrawalCredentialsSet", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "sender", "type": "address" }, + { "indexed": false, "name": "amount", "type": "uint256" }, + { "indexed": false, "name": "referral", "type": "address" } + ], + "name": "Submitted", + "type": "event" + }, + { + "anonymous": false, + "inputs": [{ "indexed": false, "name": "amount", "type": "uint256" }], + "name": "Unbuffered", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { "indexed": true, "name": "sender", "type": "address" }, + { "indexed": false, "name": "tokenAmount", "type": "uint256" }, + { "indexed": false, "name": "sentFromBuffer", "type": "uint256" }, + { "indexed": true, "name": "pubkeyHash", "type": "bytes32" }, + { "indexed": false, "name": "etherAmount", "type": "uint256" } + ], + "name": "Withdrawal", + "type": "event" + } +] diff --git a/assets/icons/balancer-banner-icon.svg b/assets/icons/balancer-banner-icon.svg new file mode 100644 index 000000000..5f6da086b --- /dev/null +++ b/assets/icons/balancer-banner-icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/icons/coin98.svg b/assets/icons/coin98.svg new file mode 100644 index 000000000..da1985800 --- /dev/null +++ b/assets/icons/coin98.svg @@ -0,0 +1 @@ +Coin98 diff --git a/assets/icons/coinbase.svg b/assets/icons/coinbase.svg new file mode 100644 index 000000000..ae09a028e --- /dev/null +++ b/assets/icons/coinbase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/cookie.svg b/assets/icons/cookie.svg new file mode 100644 index 000000000..6cbca621f --- /dev/null +++ b/assets/icons/cookie.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/cookieInverse.svg b/assets/icons/cookieInverse.svg new file mode 100644 index 000000000..8d633e60b --- /dev/null +++ b/assets/icons/cookieInverse.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/cowswap-circle.svg b/assets/icons/cowswap-circle.svg new file mode 100644 index 000000000..a7906a9ec --- /dev/null +++ b/assets/icons/cowswap-circle.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/curve.svg b/assets/icons/curve.svg new file mode 100644 index 000000000..f4e6f5f53 --- /dev/null +++ b/assets/icons/curve.svg @@ -0,0 +1,1523 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/external-link-icon.svg b/assets/icons/external-link-icon.svg new file mode 100644 index 000000000..577f464d4 --- /dev/null +++ b/assets/icons/external-link-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/imtoken.svg b/assets/icons/imtoken.svg new file mode 100644 index 000000000..9d1ef5fc7 --- /dev/null +++ b/assets/icons/imtoken.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/l2-banner-icons.svg b/assets/icons/l2-banner-icons.svg new file mode 100644 index 000000000..0f96851c6 --- /dev/null +++ b/assets/icons/l2-banner-icons.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/ledger.svg b/assets/icons/ledger.svg new file mode 100644 index 000000000..1f830466e --- /dev/null +++ b/assets/icons/ledger.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/lido.svg b/assets/icons/lido.svg new file mode 100644 index 000000000..e69b6ca5b --- /dev/null +++ b/assets/icons/lido.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/metamask.svg b/assets/icons/metamask.svg new file mode 100644 index 000000000..8e8462593 --- /dev/null +++ b/assets/icons/metamask.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/oneinch-circle.svg b/assets/icons/oneinch-circle.svg new file mode 100644 index 000000000..d1d960f9b --- /dev/null +++ b/assets/icons/oneinch-circle.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/oneinch-info-bg.svg b/assets/icons/oneinch-info-bg.svg new file mode 100644 index 000000000..111eee036 --- /dev/null +++ b/assets/icons/oneinch-info-bg.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/icons/oneinch.svg b/assets/icons/oneinch.svg new file mode 100644 index 000000000..cebddc08d --- /dev/null +++ b/assets/icons/oneinch.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/paraswap-circle.svg b/assets/icons/paraswap-circle.svg new file mode 100644 index 000000000..087243ac3 --- /dev/null +++ b/assets/icons/paraswap-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/request-info.svg b/assets/icons/request-info.svg new file mode 100644 index 000000000..c1d26cc43 --- /dev/null +++ b/assets/icons/request-info.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/icons/request-pending.svg b/assets/icons/request-pending.svg new file mode 100644 index 000000000..a3d6d7de8 --- /dev/null +++ b/assets/icons/request-pending.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/request-ready.svg b/assets/icons/request-ready.svg new file mode 100644 index 000000000..69840685f --- /dev/null +++ b/assets/icons/request-ready.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/trust.svg b/assets/icons/trust.svg new file mode 100644 index 000000000..e9e2a1fac --- /dev/null +++ b/assets/icons/trust.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/walletconnect.svg b/assets/icons/walletconnect.svg new file mode 100644 index 000000000..9a561684c --- /dev/null +++ b/assets/icons/walletconnect.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/icons/warning.svg b/assets/icons/warning.svg new file mode 100644 index 000000000..bf166f777 --- /dev/null +++ b/assets/icons/warning.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/logo.svg b/assets/logo.svg new file mode 100644 index 000000000..96c4f64ad --- /dev/null +++ b/assets/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/nft-example.png b/assets/nft-example.png new file mode 100644 index 0000000000000000000000000000000000000000..770c2648d52fd38ec9bdf52fa05d3591a6729347 GIT binary patch literal 49428 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 w000001pR;Q2_pai00000000000001d0 { + return AGGREGATOR_BY_NETWORK[chainId]; +}; + +// Chainlink: STETH/USD Price Feed +// https://data.chain.link/ethereum/mainnet/crypto-usd/steth-usd +// https://etherscan.io/address/0xcfe54b5cd566ab89272946f602d76ea879cab4a8 +export const AGGREGATOR_STETH_USD_PRICE_FEED_BY_NETWORK: { + [key in CHAINS]: string; +} = { + [CHAINS.Mainnet]: '0xcfe54b5cd566ab89272946f602d76ea879cab4a8', + [CHAINS.Goerli]: '0x0000000000000000000000000000000000000000', +}; + +export const getAggregatorStEthUsdPriceFeedAddress = ( + chainId: CHAINS, +): string => { + return AGGREGATOR_STETH_USD_PRICE_FEED_BY_NETWORK[chainId]; +}; + +export type ContractAggregator = typeof AggregatorAbi__factory; + +export const getAggregatorContractFactory = (): ContractAggregator => { + return AggregatorAbi__factory; +}; diff --git a/config/api.ts b/config/api.ts new file mode 100644 index 000000000..149c1df35 --- /dev/null +++ b/config/api.ts @@ -0,0 +1,22 @@ +export const DEFAULT_API_ERROR_MESSAGE = + 'Something went wrong. Sorry, try again later :('; + +export const ETHPLORER_TOKEN_ENDPOINT = + 'https://api.ethplorer.io/getTokenInfo/'; + +export const HEALTHY_RPC_SERVICES_ARE_OVER = 'Healthy RPC services are over!'; + +export const enum API_ROUTES { + ETH_APR = 'api/eth-apr', + ETH_PRICE = 'api/eth-price', + LDO_STATS = 'api/ldo-stats', + LIDO_STATS = 'api/lido-stats', + LIDOSTATS = 'api/lidostats', + ONEINCH_RATE = 'api/oneinch-rate', + SHORT_LIDO_STATS = 'api/short-lido-stats', + SMA_STETH_APR = 'api/sma-steth-apr', + TOTALSUPPLY = 'api/totalsupply', + RPC = 'api/rpc', + METRICS = 'api/metrics', + REWARDS = 'api/rewards', +} diff --git a/config/cache.ts b/config/cache.ts new file mode 100644 index 000000000..9d0a050e5 --- /dev/null +++ b/config/cache.ts @@ -0,0 +1,42 @@ +import ms from 'ms'; + +export const CACHE_STETH_APR_KEY = 'cache-steth-apr'; +export const CACHE_STETH_APR_TTL = ms('1h'); + +export const CACHE_SMA_STETH_APR_KEY = 'cache-sma-steth-apr'; +export const CACHE_SMA_STETH_APR_TTL = ms('30m'); + +export const CACHE_ETH_APR_KEY = 'cache-eth-apr'; +export const CACHE_ETH_APR_TTL = ms('1h'); + +export const CACHE_LIDO_STATS_KEY = 'cache-lido-stats'; +export const CACHE_LIDO_STATS_TTL = ms('1h'); + +export const CACHE_LIDO_SHORT_STATS_KEY = 'cache-short-lido-stats'; +export const CACHE_LIDO_SHORT_STATS_TTL = ms('1h'); + +export const CACHE_LIDO_HOLDERS_VIA_SUBGRAPHS_KEY = + 'cache-lido-holders-via-subgraphs'; +export const CACHE_LIDO_HOLDERS_VIA_SUBGRAPHS_TTL = ms('7d'); + +export const CACHE_LDO_STATS_KEY = 'cache-ldo-stats'; +export const CACHE_LDO_STATS_TTL = ms('1h'); + +export const CACHE_ETH_PRICE_KEY = 'cache-eth-price'; +export const CACHE_ETH_PRICE_TTL = ms('1m'); +export const CACHE_ETH_PRICE_HEADERS = + 'public, max-age=60, stale-if-error=1200, stale-while-revalidate=30'; + +export const CACHE_ONE_INCH_RATE_KEY = 'oneinch-rate'; +export const CACHE_ONE_INCH_RATE_TTL = ms('5m'); + +export const CACHE_TOTAL_SUPPLY_KEY = 'cache-total-supply'; +export const CACHE_TOTAL_SUPPLY_TTL = ms('1m'); +export const CACHE_TOTAL_SUPPLY_HEADERS = + 'public, max-age=60, stale-if-error=1200, stale-while-revalidate=30'; + +export const CACHE_DEFAULT_HEADERS = + 'public, max-age=180, stale-if-error=1200, stale-while-revalidate=60'; +export const CACHE_REWARDS_HEADERS = + 'public, max-age=30, stale-if-error=1200, stale-while-revalidate=30'; +export const CACHE_DEFAULT_ERROR_HEADERS = 'no-store, must-revalidate'; diff --git a/config/dynamics.ts b/config/dynamics.ts new file mode 100644 index 000000000..4bbc22b41 --- /dev/null +++ b/config/dynamics.ts @@ -0,0 +1,12 @@ +import * as dynamics from '../env-dynamics.mjs'; +// We're making dynamic env variables +// so we can inject selected envs from Docker runtime too, +// not only during build-time for static pages + +declare global { + interface Window { + __env__: typeof dynamics; + } +} + +export default typeof window !== 'undefined' ? window.__env__ : dynamics; diff --git a/config/estimate.ts b/config/estimate.ts new file mode 100644 index 000000000..818c24c00 --- /dev/null +++ b/config/estimate.ts @@ -0,0 +1,13 @@ +// account for gas estimation +// will always have >=0.001 ether, >=0.001 stETH, >=0.001 wstETH +// on Mainnet, Rinkeby, Goerli +export const ESTIMATE_ACCOUNT = '0x87c0e047F4e4D3e289A56a36570D4CB957A37Ef1'; + +// fallback gas limits per 1 withdraw request +export const WITHDRAWAL_QUEUE_REQUEST_STETH_PERMIT_GAS_LIMIT_DEFAULT = 255350; +export const WITHDRAWAL_QUEUE_REQUEST_WSTETH_PERMIT_GAS_LIMIT_DEFAULT = 312626; + +export const WITHDRAWAL_QUEUE_REQUEST_STETH_APPROVED_GAS_LIMIT_DEFAULT = 228163; +export const WITHDRAWAL_QUEUE_REQUEST_WSTETH_APPROVED_GAS_LIMIT_DEFAULT = 280096; + +export const WITHDRAWAL_QUEUE_CLAIM_GAS_LIMIT_DEFAULT = 89818; diff --git a/config/external-links.ts b/config/external-links.ts new file mode 100644 index 000000000..10bcdf486 --- /dev/null +++ b/config/external-links.ts @@ -0,0 +1,2 @@ +export const LINK_ADD_NFT_GUIDE = + 'https://help.lido.fi/en/articles/7858367-how-do-i-add-the-lido-nft-to-metamask'; diff --git a/config/index.ts b/config/index.ts new file mode 100644 index 000000000..40dc96d3a --- /dev/null +++ b/config/index.ts @@ -0,0 +1,19 @@ +import getConfig from 'next/config'; +export const { serverRuntimeConfig } = getConfig(); +export { default as dynamics } from './dynamics'; +export * from './aggregator'; +export * from './api'; +export * from './cache'; +export * from './estimate'; +export * from './locale'; +export * from './metrics'; +export * from './oracle'; +export * from './rpc'; +export * from './steth'; +export * from './storage'; +export * from './text'; +export * from './tx'; +export * from './units'; +export * from './metrics'; +export * from './rateLimit'; +export * from './matomoClickEvents'; diff --git a/config/locale.ts b/config/locale.ts new file mode 100644 index 000000000..9430a3259 --- /dev/null +++ b/config/locale.ts @@ -0,0 +1 @@ +export const LOCALE = 'en-US'; diff --git a/config/matomoClickEvents.ts b/config/matomoClickEvents.ts new file mode 100644 index 000000000..1143cdcda --- /dev/null +++ b/config/matomoClickEvents.ts @@ -0,0 +1,310 @@ +import { MatomoEventType } from '@lidofinance/analytics-matomo'; + +export const enum MATOMO_CLICK_EVENTS_TYPES { + // Global + connectWallet = 'connectWallet', + clickCurvePool = 'clickCurvePool', + clickBalancerPool = 'clickBalancerPool', + clickExploreDeFi = 'clickExploreDeFi', + // / page + oneInchDiscount = 'oneInchDiscount', + viewEtherscanOnStakePage = 'viewEtherscanOnStakePage', + l2BannerStake = 'l2BannerStake', + // FAQ + faqSafeWorkWithLidoAudits = 'faqSafeWorkWithLidoAudits', + faqLidoEthAprEthLandingPage = 'faqLidoEthAprEthLandingPage', + faqLidoEthAprDocs = 'faqLidoEthAprDocs', + faqHowCanIGetStEthWidget = 'faqHowCanIGetStEthWidget', + faqHowCanIGetStEthIntegrations = 'faqHowCanIGetStEthIntegrations', + faqHowCanIUseSteth = 'faqHowCanIUseSteth', + faqWhereCanICoverBridgeMutual = 'faqWhereCanICoverBridgeMutual', + faqWhereCanICoverIdleFinance = 'faqWhereCanICoverIdleFinance', + faqWhereCanICoverNexusMutual = 'faqWhereCanICoverNexusMutual', + faqWhereCanICoverRibbonFinance = 'faqWhereCanICoverRibbonFinance', + faqWhereCanICoverChainproof = 'faqWhereCanICoverChainproof', + faqRisksOfStakingReports = 'faqRisksOfStakingReports', + faqRisksOfStakingImmunefiBugBounty = 'faqRisksOfStakingImmunefiBugBounty', + faqHowCanIUnstakeStEthWithdrawals = 'faqHowCanIUnstakeStEthWithdrawals', + faqHowCanIUnstakeStEthIntegrations = 'faqHowCanIUnstakeStEthIntegrations', + faqHowCanIGetWstethWrapLink = 'faqHowCanIGetWstethWrapLink', + faqHowCanIGetWstethIntegrationsLink = 'faqHowCanIGetWstethIntegrationsLink', + faqHowDoIUnwrapWstethUnwrapLink = 'faqHowDoIUnwrapWstethUnwrapLink', + faqHowCanIUseWstethL2 = 'faqHowCanIUseWstethL2', + faqHowCanIUseWstethDefiProtocols = 'faqHowCanIUseWstethDefiProtocols', + faqDoINeedToUnwrapMyWstethWithdrawalsTabs = 'faqDoINeedToUnwrapMyWstethWithdrawalsTabs', + // /wrap page + l2BannerWrap = 'l2BannerWrap', + wrapTokenSelectSTETH = 'wrapTokenSelectSteth', + wrapTokenSelectETH = 'wrapTokenSelectEth', + // Unwrap tab + l2BannerUnwrap = 'l2BannerUnwrap', + // /rewards page + calculateRewards = 'calculateRewards', + + // /withdrawal page + withdrawalUseLido = 'withdrawalUseLido', + withdrawalUseAggregators = 'withdrawalUseAggregators', + withdrawalMaxInput = 'withdrawalMaxInput', + withdrawalOtherFactorsTooltipMode = 'withdrawalOtherFactorsTooltipMode', + withdrawalFAQtooltipEthAmount = 'withdrawalFAQtooltipEthAmount', + withdrawalGoTo1inch = 'withdrawalGoTo1inch', + withdrawalGoToCowSwap = 'withdrawalGoToCowSwap', + withdrawalGoToParaswap = 'withdrawalGoToParaswap', + withdrawalEtherscanSuccessTemplate = 'withdrawalEtherscanSuccessTemplate', + withdrawalGuideSuccessTemplate = 'withdrawalGuideSuccessTemplate', + + // /withdrawal/claim page + claimViewOnEtherscanSuccessTemplate = 'claimViewOnEtherscanSuccessTemplate', + + // /withdrawal/request and /withdrawal/claim shared events + withdrawalWhatAreStakingPenaltiesFAQ = 'withdrawalWhatAreStakingPenaltiesFAQ', + withdrawalNFTGuideFAQ = 'withdrawalNFTGuideFAQ', +} + +export const MATOMO_CLICK_EVENTS: Record< + MATOMO_CLICK_EVENTS_TYPES, + MatomoEventType +> = { + // Global + [MATOMO_CLICK_EVENTS_TYPES.connectWallet]: [ + 'Ethereum_Staking_Widget', + 'Push "Connect wallet" button', + 'eth_widget_connect_wallet', + ], + [MATOMO_CLICK_EVENTS_TYPES.clickCurvePool]: [ + 'Ethereum_Staking_Widget', + 'Push «Explore» in Curve section on Transaction success banner', + 'eth_widget_banner_curve_explore', + ], + [MATOMO_CLICK_EVENTS_TYPES.clickBalancerPool]: [ + 'Ethereum_Staking_Widget', + 'Push «Explore» in Balancer section on Transaction success banner', + 'eth_widget_banner_balancer_explore', + ], + [MATOMO_CLICK_EVENTS_TYPES.clickExploreDeFi]: [ + 'Ethereum_Staking_Widget', + 'Push «Explore more DeFi options» on Transaction success banner', + 'eth_widget_banner_defi_explore', + ], + // / page + [MATOMO_CLICK_EVENTS_TYPES.oneInchDiscount]: [ + 'Ethereum_Staking_Widget', + 'Push "Get discount" on 1inch banner on widget', + 'eth_widget_oneinch_discount', + ], + [MATOMO_CLICK_EVENTS_TYPES.viewEtherscanOnStakePage]: [ + 'Ethereum_Staking_Widget', + 'Push «View on Etherscan» on the right side of Lido Statistics', + 'eth_widget_etherscan_stakePage', + ], + [MATOMO_CLICK_EVENTS_TYPES.l2BannerStake]: [ + 'Ethereum_Staking_Widget', + 'Push "Learn more" at the L2 banner on "Stake" tab', + 'eth_widget_banner_l2_stake', + ], + // FAQ + [MATOMO_CLICK_EVENTS_TYPES.faqSafeWorkWithLidoAudits]: [ + 'Ethereum_Staking_Widget', + 'Push «here» in FAQ Is it safe to work with Lido', + 'eth_widget_faq_safeWorkWithLido_here', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqLidoEthAprEthLandingPage]: [ + 'Ethereum_Staking_Widget', + 'Push «Ethereum landing page» in FAQ What is Lido staking APR for Ethereum? on stake widget', + 'eth_widget_faq_lidoEthApr_ethereumLandingPage', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqLidoEthAprDocs]: [ + 'Ethereum_Staking_Widget', + 'Push «Docs» in FAQ What is Lido staking APR for Ethereum? on stake widget', + 'eth_widget_faq_lidoEthApr_docs', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIGetStEthWidget]: [ + 'Ethereum_Staking_Widget', + 'Push «Lido Ethereum staking widget» in FAQ How can I get stETH? on stake widget', + 'eth_widget_faq_howCanIGetStEth_lidoEthereumStakingWidget', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIGetStEthIntegrations]: [ + 'Ethereum_Staking_Widget', + 'Push «DEX Lido integrations» in FAQ How can I get stETH? on stake widget', + 'eth_widget_faq_howCanIGetStEth_dexLidoIntegrations', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIUseSteth]: [ + 'Ethereum_Staking_Widget', + 'Push «more» in FAQ How can I use stETH? on stake widget', + 'eth_widget_faq_howCanIUseSteth_more', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqWhereCanICoverBridgeMutual]: [ + 'Ethereum_Staking_Widget', + 'Push «Bridge Mutual» in FAQ Where can I cover my stETH? on stake widget', + 'eth_widget_faq_wherecanicover_bridgemutual', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqWhereCanICoverIdleFinance]: [ + 'Ethereum_Staking_Widget', + 'Push «Idle Finance» in FAQ Where can I cover my stETH? on stake widget', + 'eth_widget_faq_wherecanicover_idlefinance', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqWhereCanICoverNexusMutual]: [ + 'Ethereum_Staking_Widget', + 'Push «Nexus Mutual» in FAQ Where can I cover my stETH? on stake widget', + 'eth_widget_faq_wherecanicover_nexusmutual', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqWhereCanICoverRibbonFinance]: [ + 'Ethereum_Staking_Widget', + 'Push «Ribbon Finance» in FAQ Where can I cover my stETH? on stake widget', + 'eth_widget_faq_wherecanicover_ribbonfinance', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqWhereCanICoverChainproof]: [ + 'Ethereum_Staking_Widget', + 'Push «Chainproof» in FAQ Where can I cover my stETH? on stake widget', + 'eth_widget_faq_wherecanicover_сhainproof', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqRisksOfStakingReports]: [ + 'Ethereum_Staking_Widget', + 'Push "here" in FAQ What are the risks of staking with Lido? on stake widget', + 'eth_widget_faq_risksofstaking_reports', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqRisksOfStakingImmunefiBugBounty]: [ + 'Ethereum_Staking_Widget', + 'Push "Immunefi bug bounty program" in FAQ What are the risks of staking with Lido? on stake widget', + 'eth_widget_faq_risksofstaking_immunefibugbounty', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIUnstakeStEthWithdrawals]: [ + 'Ethereum_Staking_Widget', + 'Push «Withdrawals Request and Claim tabs» in FAQ How can I unstake stETH? on stake widget', + 'eth_widget_faq_howCanIUnstakeStEth_withdrawalsRequestAndClaimTabs', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIUnstakeStEthIntegrations]: [ + 'Ethereum_Staking_Widget', + 'Push «DEX Lido integrations» in FAQ How can I unstake stETH? on stake widget', + 'eth_widget_faq_howCanIUnstakeStEth_dexLidoIntegrations', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIGetWstethWrapLink]: [ + 'Ethereum_Staking_Widget', + 'Push «Wrap & Unwrap staking widget» in FAQ How can I get wstETH', + 'eth_widget_faq_howgetwsteth_wrap', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIGetWstethIntegrationsLink]: [ + 'Ethereum_Staking_Widget', + 'Push «DEX Lido integrations» in FAQ How can I get wstETH', + 'eth_widget_faq_howgetwsteth_dexLidoIntegrations', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowDoIUnwrapWstethUnwrapLink]: [ + 'Ethereum_Staking_Widget', + 'Push «stake.lido.fi/wrap/unwrap» How do I unwrap wstETH back to stETH?', + 'eth_widget_faq_howunwrapwsteth_unwrap', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIUseWstethL2]: [ + 'Ethereum_Staking_Widget', + 'Push «L2» How can I use wstETH?', + 'eth_widget_faq_howCanIUseWstETH_l2', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqHowCanIUseWstethDefiProtocols]: [ + 'Ethereum_Staking_Widget', + 'Push «DeFi protocols» How can I use wstETH?', + 'eth_widget_faq_howCanIUseWstETH_defiProtocols', + ], + [MATOMO_CLICK_EVENTS_TYPES.faqDoINeedToUnwrapMyWstethWithdrawalsTabs]: [ + 'Ethereum_Staking_Widget', + 'Push «Withdrawals Request and Claim tabs» Do I need to unwrap my wstETH before requesting withdrawals?', + 'eth_widget_faq_doINeedToUnwrapMyWsteth_withdrawalsRequestAndClaimTabs', + ], + // /wrap page + [MATOMO_CLICK_EVENTS_TYPES.l2BannerWrap]: [ + 'Ethereum_Staking_Widget', + 'Push "Learn more" at the L2 banner on "Wrap" tab', + 'eth_widget_banner_l2_wrap', + ], + [MATOMO_CLICK_EVENTS_TYPES.wrapTokenSelectETH]: [ + 'Ethereum_Staking_Widget', + 'Select ETH to wrap to wsteth on wrap page', + 'eth_widget_wrap_select_token_eth', + ], + [MATOMO_CLICK_EVENTS_TYPES.wrapTokenSelectSTETH]: [ + 'Ethereum_Staking_Widget', + 'Select STETH to wrap to wsteth on wrap page', + 'eth_widget_wrap_select_token_steth', + ], + // Unwrap tab + [MATOMO_CLICK_EVENTS_TYPES.l2BannerUnwrap]: [ + 'Ethereum_Staking_Widget', + 'Push "Learn more" at the L2 banner on "Unwrap" tab', + 'eth_widget_banner_l2_unwrap', + ], + // /rewards page + [MATOMO_CLICK_EVENTS_TYPES.calculateRewards]: [ + 'Ethereum_Staking_Widget', + 'Push calculate reward button" ', + 'eth_widget_calculate_reward', + ], + + // /withdrawal page + [MATOMO_CLICK_EVENTS_TYPES.withdrawalUseLido]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on «Use Lido» on Request tab', + 'eth_withdrawals_request_use_lido', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalUseAggregators]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on «Use aggregators» on Request tab', + 'eth_withdrawals_request_use_aggregators', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalMaxInput]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on "Max" in input on Request tab', + 'eth_withdrawals_request_max_input', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalOtherFactorsTooltipMode]: [ + 'Ethereum_Withdrawals_Widget', + 'Push «other factors in tooltip near Withdrawals mode on Request tab', + 'eth_withdrawals_request_other_reasons_tooltip_mode', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalFAQtooltipEthAmount]: [ + 'Ethereum_Withdrawals_Widget', + 'Push «FAQ» in tooltip near ETH amount on Request tab', + 'eth_withdrawals_request_FAQ_tooltip_eth_amount', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalGoTo1inch]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on «Go to 1inch» in aggregators list on Request tab', + 'eth_withdrawals_request_go_to_1inch', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToCowSwap]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on «Go to CowSwap» in aggregators list on Request tab', + 'eth_withdrawals_request_go_to_CowSwap', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToParaswap]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on «Go to Paraswap» in aggregators list on Request tab', + 'eth_withdrawals_request_go_to_Paraswap', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalEtherscanSuccessTemplate]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on "Etherscan" on success template after withdrawal request', + 'eth_withdrawals_request_etherscan_success_template', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalGuideSuccessTemplate]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on "This guide will help you to do this" on success template after withdrawal request', + 'eth_withdrawals_request_guide_success_template', + ], + + // /withdrawal?tab=claim page + [MATOMO_CLICK_EVENTS_TYPES.claimViewOnEtherscanSuccessTemplate]: [ + 'Ethereum_Withdrawals_Widget', + 'Click on "View on Etherscan" on success template after claim', + 'eth_withdrawals_claim_view_on_etherscan_success_template', + ], + + // /withdrawal and /withdrawal?tab=claim shared events + [MATOMO_CLICK_EVENTS_TYPES.withdrawalWhatAreStakingPenaltiesFAQ]: [ + 'Ethereum_Withdrawals_Widget', + 'Push on "What Are Staking/Validator Penalties" in FAQ', + 'eth_withdrawals_what_are_staking_penalties_FAQ', + ], + [MATOMO_CLICK_EVENTS_TYPES.withdrawalNFTGuideFAQ]: [ + 'Ethereum_Withdrawals_Widget', + 'Push on "How do I add the Lido NFT to my wallet" guide link in FAQ', + 'eth_withdrawals_how_to_add_nft_guide_FAQ', + ], +}; diff --git a/config/matomoWalletsEvents.ts b/config/matomoWalletsEvents.ts new file mode 100644 index 000000000..0cdee7909 --- /dev/null +++ b/config/matomoWalletsEvents.ts @@ -0,0 +1,366 @@ +import { MatomoEventType, trackEvent } from '@lidofinance/analytics-matomo'; +import { Metrics as WalletsMetrics } from 'reef-knot/connect-wallet-modal'; + +export const enum MATOMO_WALLETS_EVENTS_TYPES { + onClickAmbire = 'onClickAmbire', + onConnectAmbire = 'onConnectAmbire', + onClickBlockchaincom = 'onClickBlockchaincom', + onConnectBlockchaincom = 'onConnectBlockchaincom', + onClickBrave = 'onClickBrave', + onConnectBrave = 'onConnectBrave', + onClickCoin98 = 'onClickCoin98', + onConnectCoin98 = 'onConnectCoin98', + onClickCoinbase = 'onClickCoinbase', + onConnectCoinbase = 'onConnectCoinbase', + onClickExodus = 'onClickExodus', + onConnectExodus = 'onConnectExodus', + onClickGamestop = 'onClickGamestop', + onConnectGamestop = 'onConnectGamestop', + onClickImToken = 'onClickImToken', + onConnectImToken = 'onConnectImToken', + onClickLedger = 'onClickLedger', + onConnectLedger = 'onConnectLedger', + onClickMathWallet = 'onClickMathWallet', + onConnectMathWallet = 'onConnectMathWallet', + onClickMetamask = 'onClickMetamask', + onConnectMetamask = 'onConnectMetamask', + onClickOperaWallet = 'onClickOperaWallet', + onConnectOperaWallet = 'onConnectOperaWallet', + onClickTally = 'onClickTally', + onConnectTally = 'onConnectTally', + onClickTrust = 'onClickTrust', + onConnectTrust = 'onConnectTrust', + onClickWC = 'onClickWC', + onConnectWC = 'onConnectWC', + onClickXdefi = 'onClickXdefi', + onConnectXdefi = 'onConnectXdefi', + onClickZenGo = 'onClickZenGo', + onConnectZenGo = 'onConnectZenGo', + onClickZerion = 'onClickZerion', + onConnectZerion = 'onConnectZerion', + onClickOkx = 'onClickOkx', + onConnectOkx = 'onConnectOkx', +} + +export const MATOMO_WALLETS_EVENTS: Record< + MATOMO_WALLETS_EVENTS_TYPES, + MatomoEventType +> = { + [MATOMO_WALLETS_EVENTS_TYPES.onClickAmbire]: [ + 'Ethereum_Staking_Widget', + 'Click on Ambire wallet', + 'eth_widget_click_ambire', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectAmbire]: [ + 'Ethereum_Staking_Widget', + 'Connect Ambire wallet', + 'eth_widget_connect_ambire', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickBlockchaincom]: [ + 'Ethereum_Staking_Widget', + 'Click Blockchain.com wallet', + 'eth_widget_click_blockchaincom', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectBlockchaincom]: [ + 'Ethereum_Staking_Widget', + 'Connect Blockchain.com wallet', + 'eth_widget_connect_blockchaincom', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickBrave]: [ + 'Ethereum_Staking_Widget', + 'Click Brave wallet', + 'eth_widget_click_brave', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectBrave]: [ + 'Ethereum_Staking_Widget', + 'Connect Brave wallet', + 'eth_widget_connect_brave', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickCoin98]: [ + 'Ethereum_Staking_Widget', + 'Click Coin98 wallet', + 'eth_widget_click_coin98', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectCoin98]: [ + 'Ethereum_Staking_Widget', + 'Connect Coin98 wallet', + 'eth_widget_connect_coin98', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickCoinbase]: [ + 'Ethereum_Staking_Widget', + 'Click Coinbase Wallet wallet', + 'eth_widget_click_coinbase_wallet', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectCoinbase]: [ + 'Ethereum_Staking_Widget', + 'Connect Coinbase Wallet wallet', + 'eth_widget_connect_coinbase_wallet', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickExodus]: [ + 'Ethereum_Staking_Widget', + 'Click Exodus wallet', + 'eth_widget_click_exodus', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectExodus]: [ + 'Ethereum_Staking_Widget', + 'Connect Exodus wallet', + 'eth_widget_connect_exodus', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickGamestop]: [ + 'Ethereum_Staking_Widget', + 'Click Gamestop wallet', + 'eth_widget_click_gamestop', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectGamestop]: [ + 'Ethereum_Staking_Widget', + 'Connect Gamestop wallet', + 'eth_widget_connect_gamestop', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickImToken]: [ + 'Ethereum_Staking_Widget', + 'Click imToken wallet', + 'eth_widget_click_imtoken', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectImToken]: [ + 'Ethereum_Staking_Widget', + 'Connect imToken wallet', + 'eth_widget_connect_imtoken', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickLedger]: [ + 'Ethereum_Staking_Widget', + 'Click Ledger wallet', + 'eth_widget_click_ledger', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectLedger]: [ + 'Ethereum_Staking_Widget', + 'Connect Ledger wallet', + 'eth_widget_connect_ledger', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickMathWallet]: [ + 'Ethereum_Staking_Widget', + 'Click MathWallet wallet', + 'eth_widget_click_mathwallet', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectMathWallet]: [ + 'Ethereum_Staking_Widget', + 'Connect MathWallet wallet', + 'eth_widget_connect_mathwallet', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickMetamask]: [ + 'Ethereum_Staking_Widget', + 'Click Metamask wallet', + 'eth_widget_click_metamask', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectMetamask]: [ + 'Ethereum_Staking_Widget', + 'Connect Metamask wallet', + 'eth_widget_connect_metamask', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickOperaWallet]: [ + 'Ethereum_Staking_Widget', + 'Click Opera wallet', + 'eth_widget_click_opera', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectOperaWallet]: [ + 'Ethereum_Staking_Widget', + 'Connect Opera wallet', + 'eth_widget_connect_opera', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickTally]: [ + 'Ethereum_Staking_Widget', + 'Click Tally wallet', + 'eth_widget_click_tally', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectTally]: [ + 'Ethereum_Staking_Widget', + 'Connect Tally wallet', + 'eth_widget_connect_tally', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickTrust]: [ + 'Ethereum_Staking_Widget', + 'Click Trust wallet', + 'eth_widget_click_trust', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectTrust]: [ + 'Ethereum_Staking_Widget', + 'Connect Trust wallet', + 'eth_widget_connect_trust', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickWC]: [ + 'Ethereum_Staking_Widget', + 'Click WalletConnect wallet', + 'eth_widget_click_walletconnect', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectWC]: [ + 'Ethereum_Staking_Widget', + 'Connect WalletConnect wallet', + 'eth_widget_connect_walletconnect', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickXdefi]: [ + 'Ethereum_Staking_Widget', + 'Click XDEFI wallet', + 'eth_widget_click_xdefi', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectXdefi]: [ + 'Ethereum_Staking_Widget', + 'Connect XDEFI wallet', + 'eth_widget_connect_xdefi', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickZenGo]: [ + 'Ethereum_Staking_Widget', + 'Click ZenGo wallet', + 'eth_widget_click_zengo', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectZenGo]: [ + 'Ethereum_Staking_Widget', + 'Connect ZenGo wallet', + 'eth_widget_connect_zengo', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickZerion]: [ + 'Ethereum_Staking_Widget', + 'Click Zerion wallet', + 'eth_widget_click_zerion', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectZerion]: [ + 'Ethereum_Staking_Widget', + 'Connect Zerion wallet', + 'eth_widget_connect_zerion', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onClickOkx]: [ + 'Ethereum_Staking_Widget', + 'Click OKX wallet', + 'eth_widget_click_okx', + ], + [MATOMO_WALLETS_EVENTS_TYPES.onConnectOkx]: [ + 'Ethereum_Staking_Widget', + 'Connect OKX wallet', + 'eth_widget_connect_okx', + ], +}; + +export const walletsMetrics: WalletsMetrics = { + events: { + click: { + handlers: { + onClickAmbire: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickAmbire); + }, + onClickBlockchaincom: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickBlockchaincom); + }, + onClickBrave: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickBrave); + }, + onClickCoin98: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickCoin98); + }, + onClickCoinbase: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickCoinbase); + }, + onClickExodus: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickExodus); + }, + onClickGamestop: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickGamestop); + }, + onClickImToken: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickImToken); + }, + onClickLedger: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickLedger); + }, + onClickMathWallet: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickMathWallet); + }, + onClickMetamask: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickMetamask); + }, + onClickOperaWallet: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickOperaWallet); + }, + onClickTally: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickTally); + }, + onClickTrust: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickTrust); + }, + onClickWC: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickWC); + }, + onClickXdefi: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickXdefi); + }, + onClickZenGo: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickZenGo); + }, + onClickZerion: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickZerion); + }, + onClickOkx: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onClickOkx); + }, + }, + }, + connect: { + handlers: { + onConnectAmbire: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectAmbire); + }, + onConnectBlockchaincom: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectBlockchaincom); + }, + onConnectBrave: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectBrave); + }, + onConnectCoin98: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectCoin98); + }, + onConnectCoinbase: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectCoinbase); + }, + onConnectExodus: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectExodus); + }, + onConnectGamestop: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectGamestop); + }, + onConnectImToken: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectImToken); + }, + onConnectLedger: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectLedger); + }, + onConnectMathWallet: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectMathWallet); + }, + onConnectMetamask: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectMetamask); + }, + onConnectOperaWallet: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectOperaWallet); + }, + onConnectTally: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectTally); + }, + onConnectTrust: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectTrust); + }, + onConnectWC: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectWC); + }, + onConnectXdefi: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectXdefi); + }, + onConnectZenGo: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectZenGo); + }, + onConnectZerion: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectZerion); + }, + onConnectOkx: () => { + trackEvent(...MATOMO_WALLETS_EVENTS.onConnectOkx); + }, + }, + }, + }, +}; diff --git a/config/metrics.ts b/config/metrics.ts new file mode 100644 index 000000000..59e678d77 --- /dev/null +++ b/config/metrics.ts @@ -0,0 +1,7 @@ +export const METRICS_PREFIX = 'eth_stake_widget_ui_'; + +export const enum METRIC_NAMES { + REQUESTS_TOTAL = 'requests_total', + API_RESPONSE = 'api_response', + SUBGRAPHS_RESPONSE = 'subgraphs_response', +} diff --git a/config/oracle.ts b/config/oracle.ts new file mode 100644 index 000000000..fdfe6a8e8 --- /dev/null +++ b/config/oracle.ts @@ -0,0 +1,19 @@ +import { CHAINS } from 'utils/chains'; +import { OracleAbi__factory } from 'generated'; + +export const ORACLE_BY_NETWORK: { + [key in CHAINS]: string; +} = { + [CHAINS.Mainnet]: '0x442af784A788A5bd6F42A01Ebe9F287a871243fb', + [CHAINS.Goerli]: '0x0000000000000000000000000000000000000000', +}; + +export const getOracleAddress = (chainId: CHAINS): string => { + return ORACLE_BY_NETWORK[chainId]; +}; + +export type ContractOracle = typeof OracleAbi__factory; + +export const getOracleContractFactory = (): ContractOracle => { + return OracleAbi__factory; +}; diff --git a/config/rateLimit.ts b/config/rateLimit.ts new file mode 100644 index 000000000..e0eb25c41 --- /dev/null +++ b/config/rateLimit.ts @@ -0,0 +1,7 @@ +import getConfig from 'next/config'; +const { serverRuntimeConfig } = getConfig(); +const { rateLimit, rateLimitTimeFrame } = serverRuntimeConfig; + +// requests per RATE_LIMIT_TIME_FRAME +export const RATE_LIMIT = rateLimit; +export const RATE_LIMIT_TIME_FRAME = rateLimitTimeFrame; diff --git a/config/rpc.ts b/config/rpc.ts new file mode 100644 index 000000000..5e41da2c9 --- /dev/null +++ b/config/rpc.ts @@ -0,0 +1,11 @@ +import { CHAINS } from 'utils/chains'; + +export const getBackendRPCPath = (chainId: string | number): string => { + const BASE_URL = typeof window === 'undefined' ? '' : window.location.origin; + return `${BASE_URL}/api/rpc?chainId=${chainId}`; +}; + +export const backendRPC = { + [CHAINS.Mainnet]: getBackendRPCPath(CHAINS.Mainnet), + [CHAINS.Goerli]: getBackendRPCPath(CHAINS.Goerli), +}; diff --git a/config/steth.ts b/config/steth.ts new file mode 100644 index 000000000..f744de637 --- /dev/null +++ b/config/steth.ts @@ -0,0 +1 @@ +export const STETH_SUBMIT_GAS_LIMIT_DEFAULT = 90000; diff --git a/config/storage.ts b/config/storage.ts new file mode 100644 index 000000000..b05cc73b6 --- /dev/null +++ b/config/storage.ts @@ -0,0 +1,4 @@ +export const STORAGE_TERMS_KEY = 'lido-terms-agree'; +export const STORAGE_THEME_AUTO_KEY = 'lido-theme-auto'; +export const STORAGE_THEME_MANUAL_KEY = 'lido-theme-manual'; +export const STORAGE_CURRENCY_KEY = 'lido-currency'; diff --git a/config/text.ts b/config/text.ts new file mode 100644 index 000000000..c4d2ab288 --- /dev/null +++ b/config/text.ts @@ -0,0 +1,2 @@ +export const LIDO_APR_TOOLTIP_TEXT = 'Moving average of APR for 7 days period.'; +export const DATA_UNAVAILABLE = 'N/A'; diff --git a/config/trackMatomoEvent.ts b/config/trackMatomoEvent.ts new file mode 100644 index 000000000..142eff38e --- /dev/null +++ b/config/trackMatomoEvent.ts @@ -0,0 +1,11 @@ +import { + MATOMO_CLICK_EVENTS_TYPES, + MATOMO_CLICK_EVENTS, +} from './matomoClickEvents'; +import { trackEvent } from '@lidofinance/analytics-matomo'; + +export { MATOMO_CLICK_EVENTS_TYPES } from './matomoClickEvents'; + +export const trackMatomoEvent = (eventType: MATOMO_CLICK_EVENTS_TYPES) => { + trackEvent(...MATOMO_CLICK_EVENTS[eventType]); +}; diff --git a/config/tx.ts b/config/tx.ts new file mode 100644 index 000000000..653ec2e3d --- /dev/null +++ b/config/tx.ts @@ -0,0 +1,8 @@ +export const STANDARD_GAS_LIMIT = 21000; + +export const WSTETH_APPROVE_GAS_LIMIT = 78000; + +export const WRAP_FROM_ETH_GAS_LIMIT = 100000; +export const WRAP_GAS_LIMIT = 140000; +export const WRAP_GAS_LIMIT_GOERLI = 120000; +export const UNWRAP_GAS_LIMIT = 115000; diff --git a/config/units.ts b/config/units.ts new file mode 100644 index 000000000..125b4f57d --- /dev/null +++ b/config/units.ts @@ -0,0 +1,3 @@ +import { BigNumber } from 'ethers'; + +export const ONE_GWEI = BigNumber.from(10 ** 9); diff --git a/env-dynamics.mjs b/env-dynamics.mjs new file mode 100644 index 000000000..4ef13480d --- /dev/null +++ b/env-dynamics.mjs @@ -0,0 +1,34 @@ +/** + * Convert to bool: + * - true to true + * - 'true' to true + * - 1 to true + * - '1' to true + * - another values to false + * @returns {Boolean} + */ +const toBoolean = (dataStr) => { + return !!( + dataStr?.toLowerCase?.() === 'true' || + dataStr === true || + Number.parseInt(dataStr, 10) === 1 + ); +}; + +/** @type string */ +export const matomoHost = process.env.MATOMO_URL; +/** @type number */ +export const defaultChain = parseInt(process.env.DEFAULT_CHAIN, 10) || 1; +/** @type number[] */ + +export const supportedChains = process.env?.SUPPORTED_CHAINS?.split(',').map( + (chainId) => parseInt(chainId, 10), +) ?? [1, 4, 5]; +/** @type boolean */ +export const enableQaHelpers = toBoolean(process.env.ENABLE_QA_HELPERS); +/** @type string */ +export const ethAPIBasePath = process.env.ETH_API_BASE_PATH; +/** @type string */ +export const wqAPIBasePath = process.env.WQ_API_BASE_PATH; +/** @type string */ +export const walletconnectProjectId = process.env.WALLETCONNECT_PROJECT_ID; diff --git a/features/home/hooks.tsx b/features/home/hooks.tsx new file mode 100644 index 000000000..53cc0b032 --- /dev/null +++ b/features/home/hooks.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { BigNumber } from 'ethers'; +import { isDesktop } from 'react-device-detect'; +import { SWRResponse, useEthereumBalance } from '@lido-sdk/react'; +import { useConnectorInfo } from 'reef-knot/web3-react'; +import { useStakingLimitInfo } from 'shared/hooks'; +import { bnMin } from 'utils'; + +export const useStakeableEther = (): Pick< + SWRResponse, + 'data' | 'initialLoading' +> => { + const ethereumBalance = useEthereumBalance(); + const stakingLimitInfo = useStakingLimitInfo(); + + return { + initialLoading: + ethereumBalance.initialLoading || stakingLimitInfo.initialLoading, + data: + ethereumBalance.data && stakingLimitInfo.data?.isStakingLimitSet + ? bnMin(ethereumBalance.data, stakingLimitInfo.data.currentStakeLimit) + : ethereumBalance.data, + }; +}; + +const ONE_INCH_URL = 'https://app.1inch.io/#/1/swap/ETH/steth'; +const LEDGER_LIVE_ONE_INCH_DESKTOP_DEEPLINK = 'ledgerlive://discover/1inch-lld'; +const LEDGER_LIVE_ONE_INCH_MOBILE_DEEPLINK = 'ledgerlive://discover/1inch-llm'; + +export const use1inchLinkProps = () => { + const { isLedgerLive } = useConnectorInfo(); + + const linkProps = useMemo(() => { + if (isLedgerLive) { + const href = isDesktop + ? LEDGER_LIVE_ONE_INCH_DESKTOP_DEEPLINK + : LEDGER_LIVE_ONE_INCH_MOBILE_DEEPLINK; + + return { + href, + target: '_self', + }; + } else { + return { + href: ONE_INCH_URL, + target: '_blank', + rel: 'noopener noreferrer', + }; + } + }, [isLedgerLive]); + + return linkProps; +}; diff --git a/features/home/index.ts b/features/home/index.ts new file mode 100644 index 000000000..db4fd42c2 --- /dev/null +++ b/features/home/index.ts @@ -0,0 +1,5 @@ +export { StakeForm } from './stake-form/stake-form'; +export { OneinchInfo } from './oneinch-info/oneinch-info'; +export { LidoStats } from './lido-stats/lido-stats'; +export { Wallet } from './wallet/wallet'; +export { StakeFaq } from './stake-faq/stake-faq'; diff --git a/features/home/lido-stats/lido-stats.tsx b/features/home/lido-stats/lido-stats.tsx new file mode 100644 index 000000000..4c05deba1 --- /dev/null +++ b/features/home/lido-stats/lido-stats.tsx @@ -0,0 +1,79 @@ +import { FC, memo, useMemo } from 'react'; +import { getEtherscanTokenLink } from '@lido-sdk/helpers'; +import { useSDK } from '@lido-sdk/react'; +import { getTokenAddress, TOKENS } from '@lido-sdk/constants'; +import { + Block, + DataTable, + DataTableRow, + Question, + Tooltip, +} from '@lidofinance/lido-ui'; +import { Section, MatomoLink } from 'shared/components'; +import { + LIDO_APR_TOOLTIP_TEXT, + DATA_UNAVAILABLE, + MATOMO_CLICK_EVENTS_TYPES, +} from 'config'; +import { useLidoApr, useLidoStats } from 'shared/hooks'; +import { FlexCenterVertical } from './styles'; + +export const LidoStats: FC = memo(() => { + const { chainId } = useSDK(); + const etherscanLink = useMemo(() => { + return getEtherscanTokenLink( + chainId, + getTokenAddress(chainId, TOKENS.STETH), + ); + }, [chainId]); + const lidoApr = useLidoApr(); + const lidoStats = useLidoStats(); + + return ( +

+ View on Etherscan + + } + > + + + + Annual percentage rate + + + + + } + loading={lidoApr.initialLoading} + highlight + > + {lidoApr.apr ? `${lidoApr.apr}%` : DATA_UNAVAILABLE} + + + {lidoStats.data.totalStaked} + + + {lidoStats.data.stakers} + + + {lidoStats.data.marketCap} + + + +
+ ); +}); diff --git a/features/home/lido-stats/styles.tsx b/features/home/lido-stats/styles.tsx new file mode 100644 index 000000000..111ac8c26 --- /dev/null +++ b/features/home/lido-stats/styles.tsx @@ -0,0 +1,8 @@ +import styled from 'styled-components'; + +export const FlexCenterVertical = styled.div` + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; +`; diff --git a/features/home/oneinch-info/oneinch-info.tsx b/features/home/oneinch-info/oneinch-info.tsx new file mode 100644 index 000000000..18630b9e6 --- /dev/null +++ b/features/home/oneinch-info/oneinch-info.tsx @@ -0,0 +1,56 @@ +import { FC } from 'react'; +import { Button } from '@lidofinance/lido-ui'; +import { trackEvent } from '@lidofinance/analytics-matomo'; +import { useLidoSWR } from 'shared/hooks'; +import { L2Banner } from 'shared/l2-banner'; +import { MATOMO_CLICK_EVENTS } from 'config'; + +import { + Wrap, + OneInchIconWrap, + OneInchIcon, + TextWrap, + ButtonWrap, + ButtonLinkWrap, +} from './styles'; +import { use1inchLinkProps } from '../hooks'; + +const ONE_INCH_RATE_LIMIT = 1.004; + +export const OneinchInfo: FC = () => { + const { data, initialLoading } = useLidoSWR<{ rate: number }>( + '/api/oneinch-rate', + ); + const rate = (data && data.rate) || 1; + const discount = (100 - (1 / rate) * 100).toFixed(2); + + const linkProps = use1inchLinkProps(); + + // for fix flashing banner + if (initialLoading) return null; + + if (!rate || rate < ONE_INCH_RATE_LIMIT) + return ; + + const linkClickHandler = () => + trackEvent(...MATOMO_CLICK_EVENTS.oneInchDiscount); + + return ( + + + + + + Get a {discount}% discount by buying stETH on the 1inch + platform + + + + + + + + ); +}; diff --git a/features/home/oneinch-info/styles.ts b/features/home/oneinch-info/styles.ts new file mode 100644 index 000000000..dba5e8bc4 --- /dev/null +++ b/features/home/oneinch-info/styles.ts @@ -0,0 +1,84 @@ +import styled from 'styled-components'; +import BgSrc from 'assets/icons/oneinch-info-bg.svg'; +import OneInchIconSrc from 'assets/icons/oneinch.svg'; + +export const Wrap = styled.div` + margin-top: 16px; + position: relative; + display: flex; + align-items: center; + gap: 20px; + padding: 0 20px; + border-radius: 10px; + height: 80px; + overflow: hidden; + background-image: url('${BgSrc}'); + background-size: contain; + background-repeat: no-repeat; + background-color: #07080c; + + & > * { + position: relative; + } + + @media (max-width: 440px) { + padding-top: 20px; + padding-bottom: 20px; + flex-wrap: wrap; + height: auto; + background-size: cover; + background-position: -10px; + } +`; + +export const OneInchIconWrap = styled.div` + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + background-color: #000000; + border-radius: 50%; +`; + +export const OneInchIcon = styled.img.attrs({ + src: OneInchIconSrc, + alt: '', +})` + display: block; + width: 36px; + height: 36px; +`; + +export const TextWrap = styled.div` + flex: 1 1 auto; + color: #fff; + font-size: 12px; + font-weight: 400; + + & > b { + font-weight: 700; + } + + @media (max-width: 440px) { + /* Padding (20px * 2) + Gap (20px) + Logo (40px) = 100px */ + width: calc(100% - 100px); + } +`; + +export const ButtonWrap = styled.div` + flex: 0 0 auto; + + @media (max-width: 440px) { + width: 100%; + } +`; + +export const ButtonLinkWrap = styled.a` + display: block; + + @media (max-width: 440px) { + width: 100%; + } +`; diff --git a/features/home/stake-faq/list/how-can-i-get-steth.tsx b/features/home/stake-faq/list/how-can-i-get-steth.tsx new file mode 100644 index 000000000..7f958d6d6 --- /dev/null +++ b/features/home/stake-faq/list/how-can-i-get-steth.tsx @@ -0,0 +1,36 @@ +import { FC } from 'react'; +import { Accordion, Link as OuterLink } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; +import { trackMatomoEvent } from 'config/trackMatomoEvent'; +import { LocalLink } from 'shared/components/local-link'; + +export const HowCanIGetSteth: FC = () => { + return ( + +

+ You can get stETH many ways, including interacting with the smart + contract directly.Yet, it is much easier to use a{' '} + + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.faqHowCanIGetStEthWidget, + ) + } + aria-hidden="true" + > + Lido Ethereum staking widget + + {' '} + and in other{' '} + + DEX Lido integrations + + . +

+
+ ); +}; diff --git a/features/home/stake-faq/list/how-can-i-unstake-steth.tsx b/features/home/stake-faq/list/how-can-i-unstake-steth.tsx new file mode 100644 index 000000000..ccefdab3d --- /dev/null +++ b/features/home/stake-faq/list/how-can-i-unstake-steth.tsx @@ -0,0 +1,40 @@ +import { FC } from 'react'; +import { Accordion, Link as OuterLink } from '@lidofinance/lido-ui'; +import { LocalLink } from 'shared/components/local-link'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; +import { trackMatomoEvent } from 'config/trackMatomoEvent'; + +export const HowCanIUnstakeSteth: FC = () => { + return ( + +

+ You can use our{' '} + + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.faqHowCanIUnstakeStEthWithdrawals, + ) + } + aria-hidden="true" + > + Withdrawals Request and Claim tabs + + {' '} + to unstake stETH and receive ETH at a 1:1 ratio. Under normal + circumstances, withdrawal period can take anywhere between 1-5 days. + After that, you can claim your ETH using the Claim tab. Also, you can + exchange stETH on{' '} + + DEX Lido integrations + + . +

+
+ ); +}; diff --git a/features/home/stake-faq/list/how-can-i-use-steth.tsx b/features/home/stake-faq/list/how-can-i-use-steth.tsx new file mode 100644 index 000000000..64efd0805 --- /dev/null +++ b/features/home/stake-faq/list/how-can-i-use-steth.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react'; +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; + +export const HowCanIUseSteth: FC = () => { + return ( + +

+ You can use your stETH as collateral, for lending, and{' '} + + more + + . +

+
+ ); +}; diff --git a/features/home/stake-faq/list/how-does-lido-work.tsx b/features/home/stake-faq/list/how-does-lido-work.tsx new file mode 100644 index 000000000..f263cd3de --- /dev/null +++ b/features/home/stake-faq/list/how-does-lido-work.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; + +export const HowDoesLidoWork: FC = () => { + return ( + +

+ While each network works differently, generally, the Lido protocols + batch user tokens to stake with validators and route the staking + packages to network staking contracts. Users mint amounts of stTokens + which correspond to the amount of tokens sent as stake and they receive + staking rewards. When they unstake, they burn the stToken to initiate + the network-specific withdrawal process to withdraw the balance of stake + and rewards. +

+
+ ); +}; diff --git a/features/home/stake-faq/list/index.ts b/features/home/stake-faq/list/index.ts new file mode 100644 index 000000000..4f0670c8e --- /dev/null +++ b/features/home/stake-faq/list/index.ts @@ -0,0 +1,11 @@ +export * from './how-does-lido-work'; +export * from './lido-fee'; +export * from './risks-of-staking-with-lido'; +export * from './safe-work-with-lido'; +export * from './how-can-i-unstake-steth'; +export * from './how-can-i-use-steth'; +export * from './how-can-i-get-steth'; +export * from './what-is-lido'; +export * from './lido-eth-apr'; +export * from './what-is-steth'; +export * from './where-can-i-cover-my-steth'; diff --git a/features/home/stake-faq/list/lido-eth-apr.tsx b/features/home/stake-faq/list/lido-eth-apr.tsx new file mode 100644 index 000000000..d51ba8a1c --- /dev/null +++ b/features/home/stake-faq/list/lido-eth-apr.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; + +export const LidoEthApr: FC = () => { + return ( + +

Lido staking APR for Ethereum = Protocol APR * (1 - Protocol fee)

+

+ Protocol APR — the overall Consensus Layer (CL) and Execution Layer (EL) + rewards received by Lido validators to total pooled ETH estimated as the + moving average of the last seven days. +

+

+ Protocol fee — Lido applies a 10% fee on staking rewards that are split + between node operators and the DAO Treasury. +

+

+ More about Lido staking APR for Ethereum you could find on the{' '} + + Ethereum landing page + {' '} + and in our{' '} + + Docs + + . +

+
+ ); +}; diff --git a/features/home/stake-faq/list/lido-fee.tsx b/features/home/stake-faq/list/lido-fee.tsx new file mode 100644 index 000000000..4f35579fc --- /dev/null +++ b/features/home/stake-faq/list/lido-fee.tsx @@ -0,0 +1,26 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; +import { useContractSWR, useSTETHContractRPC } from '@lido-sdk/react'; +import { DATA_UNAVAILABLE } from 'config'; + +export const LidoFee: FC = () => { + const contractRpc = useSTETHContractRPC(); + const lidoFee = useContractSWR({ + contract: contractRpc, + method: 'getFee', + }); + const feeValue = + lidoFee.initialLoading || !lidoFee.data + ? DATA_UNAVAILABLE + : `${lidoFee.data / 100}%`; + + return ( + +

+ The protocol applies a {feeValue} fee on staking rewards. This fee is + split between node operators and the Lido DAO. That means the users + receive 90% of the staking rewards returned by the networks. +

+
+ ); +}; diff --git a/features/home/stake-faq/list/risks-of-staking-with-lido.tsx b/features/home/stake-faq/list/risks-of-staking-with-lido.tsx new file mode 100644 index 000000000..f17cd9ea3 --- /dev/null +++ b/features/home/stake-faq/list/risks-of-staking-with-lido.tsx @@ -0,0 +1,67 @@ +import { FC } from 'react'; +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; + +export const RisksOfStakingWithLido: FC = () => { + return ( + +

+ There exist a number of potential risks when staking using liquid + staking protocols. +

+
    +
  • + Smart contract security +

    + There is an inherent risk that Lido could contain a smart contract + vulnerability or bug. The Lido code is open-sourced, audited and + covered by an extensive bug bounty program to minimise this risk. To + mitigate smart contract risks, all of the core Lido contracts are + audited. Audit reports can be found{' '} + + here + + . Besides, Lido is covered with a massive{' '} + + Immunefi bug bounty program + + . +

    +
  • +
  • + Slashing risk +

    + Validators risk staking penalties, with up to 100% of staked funds + at risk if validators fail. To minimise this risk, Lido stakes + across multiple professional and reputable node operators with + heterogeneous setups, with additional mitigation in the form of + self-coverage. +

    +
  • +
  • + stToken price risk +

    + Users risk an exchange price of stTokens which is lower than + inherent value due to withdrawal restrictions on Lido, making + arbitrage and risk-free market-making impossible. The Lido DAO is + driven to mitigate the above risks and eliminate them entirely to + the extent possible. Despite this, they may still exist and, as + such, it is our duty to communicate them. +

    +
  • +
+

+ The Lido DAO is driven to mitigate the above risks and eliminate them + entirely to the extent possible. Despite this, they may still exist. +

+
+ ); +}; diff --git a/features/home/stake-faq/list/safe-work-with-lido.tsx b/features/home/stake-faq/list/safe-work-with-lido.tsx new file mode 100644 index 000000000..cbcb28f0b --- /dev/null +++ b/features/home/stake-faq/list/safe-work-with-lido.tsx @@ -0,0 +1,40 @@ +import { FC } from 'react'; +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; + +export const SafeWorkWithLido: FC = () => { + return ( + + In order to work safe, Lido fits the next points: +
    +
  • Open-sourcing & continuous review of all code.
  • +
  • + Committee of elected, best-in-class validators to minimise staking + risk. +
  • +
  • + Use of non-custodial staking service to eliminate counterparty risk. +
  • +
  • + Use of DAO for governance decisions & to manage risk factors. +
  • +
  • + Lido has been audited by Certora, StateMind, Hexens, ChainSecurity, + Oxorio, MixBytes, SigmaPrime, Quantstamp. Lido audits can be found in + more detail{' '} + + here + + . +
  • +
+

+ Usually when staking ETH you choose only one validator. In the case of + Lido you stake across many validators, minimising your staking risk. +

+
+ ); +}; diff --git a/features/home/stake-faq/list/what-is-lido.tsx b/features/home/stake-faq/list/what-is-lido.tsx new file mode 100644 index 000000000..ea86bf430 --- /dev/null +++ b/features/home/stake-faq/list/what-is-lido.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; + +export const WhatIsLido: FC = () => { + return ( + +

+ Lido is the name of a family of open-source peer-to-system software + tools deployed and functioning on the Ethereum, Solana, and Polygon + blockchain networks. The software enables users to mint transferable + utility tokens, which receive rewards linked to the related validation + activities of writing data to the blockchain, while the tokens can be + used in other on-chain activities. +

+
+ ); +}; diff --git a/features/home/stake-faq/list/what-is-steth.tsx b/features/home/stake-faq/list/what-is-steth.tsx new file mode 100644 index 000000000..5f0514184 --- /dev/null +++ b/features/home/stake-faq/list/what-is-steth.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; + +export const WhatIsSteth: FC = () => { + return ( + +

+ stETH is a transferable rebasing utility token representing a share of + the total ETH staked through the protocol, which consists of user + deposits and staking rewards. Because stETH rebases daily, it + communicates the position of the share daily. +

+
+ ); +}; diff --git a/features/home/stake-faq/list/where-can-i-cover-my-steth.tsx b/features/home/stake-faq/list/where-can-i-cover-my-steth.tsx new file mode 100644 index 000000000..594a28004 --- /dev/null +++ b/features/home/stake-faq/list/where-can-i-cover-my-steth.tsx @@ -0,0 +1,61 @@ +import { FC } from 'react'; +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; + +export const WhereCanICoverMySteth: FC = () => { + return ( + + + There are multiple coverage and insurer providers with different + products for stETH: + +
    +
  • + + Bridge Mutual + +
  • +
  • + + Idle Finance + +
  • +
  • + + Nexus Mutual + +
  • +
  • + + Ribbon Finance + +
  • +
  • + + Chainproof + +
  • +
+

Check with providers for coverage and insurer conditions.

+
+ ); +}; diff --git a/features/home/stake-faq/stake-faq.tsx b/features/home/stake-faq/stake-faq.tsx new file mode 100644 index 000000000..81d15da80 --- /dev/null +++ b/features/home/stake-faq/stake-faq.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; +import { Section } from 'shared/components'; +import { useMatomoEventHandle } from 'shared/hooks'; + +import { + WhatIsLido, + HowDoesLidoWork, + LidoEthApr, + WhatIsSteth, + HowCanIGetSteth, + SafeWorkWithLido, + HowCanIUseSteth, + WhereCanICoverMySteth, + RisksOfStakingWithLido, + LidoFee, + HowCanIUnstakeSteth, +} from './list'; + +export const StakeFaq: FC = () => { + const onClickHandler = useMatomoEventHandle(); + + return ( +
+ + + + + + + + + + + +
+ ); +}; diff --git a/features/home/stake-form/hooks.ts b/features/home/stake-form/hooks.ts new file mode 100644 index 000000000..2a40cfdef --- /dev/null +++ b/features/home/stake-form/hooks.ts @@ -0,0 +1,54 @@ +import { AddressZero } from '@ethersproject/constants'; +import { useLidoSWR, useSTETHContractRPC } from '@lido-sdk/react'; +import { + ESTIMATE_ACCOUNT, + getBackendRPCPath, + STETH_SUBMIT_GAS_LIMIT_DEFAULT, +} from 'config'; +import { parseEther } from '@ethersproject/units'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { getStaticRpcBatchProvider } from 'utils/rpcProviders'; +import { BigNumber } from 'ethers'; +import { CHAINS } from 'utils/chains'; + +type UseStethSubmitGasLimit = () => number | undefined; + +export const useStethSubmitGasLimit: UseStethSubmitGasLimit = () => { + const stethContractRPC = useSTETHContractRPC(); + + const { chainId } = useWeb3(); + const { data } = useLidoSWR( + ['swr:submit-gas-limit', chainId], + async (_key, chainId) => { + if (!chainId) { + return; + } + + const provider = getStaticRpcBatchProvider( + chainId as string, + // TODO: add a way to type useWeb3 hook + getBackendRPCPath(chainId as CHAINS), + ); + + const feeData = await provider.getFeeData(); + const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined; + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + + const gasLimit = await stethContractRPC.estimateGas + .submit(AddressZero, { + from: ESTIMATE_ACCOUNT, + value: parseEther('0.001'), + maxPriorityFeePerGas, + maxFeePerGas, + }) + .catch((error) => { + console.warn(error); + return BigNumber.from(STETH_SUBMIT_GAS_LIMIT_DEFAULT); + }); + + return +gasLimit; + }, + ); + + return data ?? STETH_SUBMIT_GAS_LIMIT_DEFAULT; +}; diff --git a/features/home/stake-form/stake-form.tsx b/features/home/stake-form/stake-form.tsx new file mode 100644 index 000000000..1fe4b8cf1 --- /dev/null +++ b/features/home/stake-form/stake-form.tsx @@ -0,0 +1,255 @@ +import { + FC, + memo, + useCallback, + useState, + useMemo, + useEffect, + useRef, +} from 'react'; +import { useRouter } from 'next/router'; +import { parseEther } from '@ethersproject/units'; +import { + useSDK, + useContractSWR, + useEthereumBalance, + useSTETHBalance, + useSTETHContractRPC, + useSTETHContractWeb3, +} from '@lido-sdk/react'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { + Block, + Button, + DataTable, + DataTableRow, + Eth, +} from '@lidofinance/lido-ui'; +import { OneinchInfo } from 'features/home/oneinch-info/oneinch-info'; +import { DATA_UNAVAILABLE } from 'config'; +import { Connect } from 'shared/wallet'; +import { TxStageModal, TX_OPERATION, TX_STAGE } from 'shared/components'; +import { useTxCostInUsd } from 'shared/hooks'; +import { InputDecoratorMaxButton } from 'shared/forms/components/input-decorator-max-button'; +import { useCurrencyInput } from 'shared/forms/hooks/useCurrencyInput'; +import { FormStyled, InputStyled } from './styles'; +import { stakeProcessing } from './utils'; +import { useStethSubmitGasLimit } from './hooks'; +import { useStakeableEther } from '../hooks'; +import { useStakingLimitWarn } from './useStakingLimitWarn'; +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { useIsMultisig } from 'shared/hooks/useIsMultisig'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; + +export const StakeForm: FC = memo(() => { + const router = useRouter(); + + const formRef = useRef(null); + + const [txModalOpen, setTxModalOpen] = useState(false); + const [txStage, setTxStage] = useState(TX_STAGE.SUCCESS); + const [txHash, setTxHash] = useState(); + const [txModalFailedText, setTxModalFailedText] = useState(''); + const [inputValue, setInputValue] = useState(''); + + // consumes amount query param + // SSG safe + useEffect(() => { + if ( + router.isReady && + router.query.amount && + typeof router.query.amount === 'string' + ) { + const { amount, ...rest } = router.query; + router.replace({ pathname: router.pathname, query: rest }); + setInputValue(amount); + } + }, [router]); + + const { active, chainId } = useWeb3(); + const { providerWeb3 } = useSDK(); + const etherBalance = useEthereumBalance(undefined, STRATEGY_LAZY); + const stakeableEther = useStakeableEther(); + const stethBalance = useSTETHBalance(); + const stethContractWeb3 = useSTETHContractWeb3(); + const contractRpc = useSTETHContractRPC(); + const [isMultisig] = useIsMultisig(); + + const lidoFee = useContractSWR({ + contract: contractRpc, + method: 'getFee', + }); + + const submitGasLimit = useStethSubmitGasLimit(); + const txCostInUsd = useTxCostInUsd(submitGasLimit); + + const openTxModal = useCallback(() => { + setTxModalOpen(true); + }, []); + + const closeTxModal = useCallback(() => { + setTxModalOpen(false); + }, []); + + const submit = useCallback( + async (inputValue, resetForm) => { + await stakeProcessing( + providerWeb3, + stethContractWeb3, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + stethBalance.update, + inputValue, + resetForm, + chainId, + router?.query?.ref as string | undefined, + isMultisig, + ); + }, + [ + providerWeb3, + stethContractWeb3, + openTxModal, + closeTxModal, + stethBalance.update, + chainId, + router?.query?.ref, + isMultisig, + ], + ); + + const token = 'ETH'; + const inputName = `${getTokenDisplayName(token)} amount`; + + const { + handleSubmit, + handleChange, + error, + isSubmitting, + setMaxInputValue, + reset, + isMaxDisabled, + } = useCurrencyInput({ + inputValue, + setInputValue, + inputName, + submit, + limit: + etherBalance.data && + stakeableEther.data && + (stakeableEther.data.lt(etherBalance.data) + ? stakeableEther.data + : etherBalance.data), + padMaxAmount: (padAmount) => + Boolean( + !isMultisig && + etherBalance.data && + stakeableEther.data && + etherBalance.data.sub(padAmount).lte(stakeableEther.data), + ), + gasLimit: submitGasLimit, + }); + + const { limitWarning, limitReached } = useStakingLimitWarn(); + + const willReceiveStEthValue = useMemo(() => { + if (!inputValue) { + return 0; + } + + if (!Number(inputValue)) { + return 0; + } + + try { + parseEther(inputValue); + } catch { + return 0; + } + + return inputValue.slice(0, 30); + }, [inputValue]); + + // Reset form amount after disconnect wallet + useEffect(() => { + return () => { + if (active) { + reset(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [active]); + + return ( + + + } + rightDecorator={ + + } + label={inputName} + value={inputValue} + onChange={handleChange} + error={error} + warning={limitWarning} + /> + {active ? ( + + ) : ( + + )} + + + + + + {willReceiveStEthValue} stETH + + 1 ETH = 1 stETH + + ${txCostInUsd?.toFixed(2)} + + + {!lidoFee.data ? DATA_UNAVAILABLE : `${lidoFee.data / 100}%`} + + + + formRef.current?.requestSubmit()} + /> + + ); +}); diff --git a/features/home/stake-form/styles.tsx b/features/home/stake-form/styles.tsx new file mode 100644 index 000000000..cc9dfb147 --- /dev/null +++ b/features/home/stake-form/styles.tsx @@ -0,0 +1,11 @@ +import styled from 'styled-components'; +import { InputNumber } from 'shared/forms/components/input-number'; + +export const InputStyled = styled(InputNumber)` + margin-bottom: ${({ theme }) => theme.spaceMap.md}px; + z-index: 2; +`; + +export const FormStyled = styled.form` + margin-bottom: 24px; +`; diff --git a/features/home/stake-form/useStakingLimitWarn.ts b/features/home/stake-form/useStakingLimitWarn.ts new file mode 100644 index 000000000..0fe86afc6 --- /dev/null +++ b/features/home/stake-form/useStakingLimitWarn.ts @@ -0,0 +1,22 @@ +import { useStakingLimitLevel } from 'shared/hooks/useStakingLimitLevel'; +import { LIMIT_LEVEL } from 'types'; + +export const useStakingLimitWarn = () => { + const limitLevel = useStakingLimitLevel(); + + const limitWarning = + limitLevel === LIMIT_LEVEL.WARN + ? 'Stake limit is almost exhausted. Your transaction may not go through.' + : ''; + + const limitError = + limitLevel === LIMIT_LEVEL.WARN + ? 'Stake limit is exhausted. Please wait until the limit is restored.' + : ''; + + return { + limitError, + limitWarning, + limitReached: limitLevel === LIMIT_LEVEL.REACHED, + }; +}; diff --git a/features/home/stake-form/utils.ts b/features/home/stake-form/utils.ts new file mode 100644 index 000000000..0a8be08e1 --- /dev/null +++ b/features/home/stake-form/utils.ts @@ -0,0 +1,186 @@ +import { AddressZero } from '@ethersproject/constants'; +import { parseEther } from '@ethersproject/units'; +import { isAddress } from 'ethers/lib/utils'; +import { StethAbi } from '@lido-sdk/contracts'; +import { CHAINS } from '@lido-sdk/constants'; +import { getStaticRpcBatchProvider } from '@lido-sdk/providers'; +import { + enableQaHelpers, + ErrorMessage, + getErrorMessage, + runWithTransactionLogger, +} from 'utils'; +import { getBackendRPCPath } from 'config'; +import { TX_STAGE } from 'shared/components'; +import { BigNumber } from 'ethers'; +import invariant from 'tiny-invariant'; +import type { Web3Provider } from '@ethersproject/providers'; + +const SUBMIT_EXTRA_GAS_TRANSACTION_RATIO = 1.05; + +type StakeProcessingProps = ( + providerWeb3: Web3Provider | undefined, + stethContractWeb3: StethAbi | null, + openTxModal: () => void, + closeTxModal: () => void, + setTxStage: (value: TX_STAGE) => void, + setTxHash: (value: string | undefined) => void, + setTxModalFailedText: (value: string) => void, + stethBalanceUpdate: () => void, + inputValue: string, + resetForm: () => void, + chainId: number | undefined, + refFromQuery: string | undefined, + isMultisig: boolean, +) => Promise; + +export const getAddress = async ( + input: string | undefined, + chainId: CHAINS | undefined, +): Promise => { + if (!input || !chainId) return ''; + if (isAddress(input)) return input; + + try { + const provider = getStaticRpcBatchProvider( + chainId, + getBackendRPCPath(chainId), + ); + const address = await provider.resolveName(input); + + if (address) return address; + } catch (error) { + throw new Error('Failed to resolve referral address'); + } + + throw new Error('Invalid referral address'); +}; + +class MockLimitReachedError extends Error { + reason: string; + constructor(message: string) { + super(message); + this.reason = 'execution reverted: STAKE_LIMIT'; + } +} + +export const stakeProcessing: StakeProcessingProps = async ( + providerWeb3, + stethContractWeb3, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + stethBalanceUpdate, + inputValue, + resetForm, + chainId, + refFromQuery, + isMultisig, +) => { + if (!stethContractWeb3 || !chainId) { + return; + } + + invariant(providerWeb3, 'must have providerWeb3'); + + try { + const referralAddress = await getAddress(refFromQuery, chainId); + + const callback = async () => { + if (isMultisig) { + const tx = await stethContractWeb3.populateTransaction.submit( + referralAddress || AddressZero, + { + value: parseEther(inputValue), + }, + ); + return providerWeb3.getSigner().sendUncheckedTransaction(tx); + } else { + const provider = getStaticRpcBatchProvider( + chainId, + getBackendRPCPath(chainId), + ); + + const feeData = await provider.getFeeData(); + + const overrides = { + value: parseEther(inputValue), + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + maxFeePerGas: feeData.maxFeePerGas ?? undefined, + }; + + const originalGasLimit = await stethContractWeb3.estimateGas.submit( + referralAddress || AddressZero, + overrides, + ); + + const gasLimit = originalGasLimit + ? BigNumber.from( + Math.ceil( + originalGasLimit.toNumber() * + SUBMIT_EXTRA_GAS_TRANSACTION_RATIO, + ), + ) + : undefined; + + return stethContractWeb3.submit(referralAddress || AddressZero, { + ...overrides, + gasLimit, + }); + } + }; + + setTxStage(TX_STAGE.SIGN); + openTxModal(); + + if ( + enableQaHelpers && + window.localStorage.getItem('mockLimitReached') === 'true' + ) { + throw new MockLimitReachedError('Stake limit reached'); + } + + const transaction = await runWithTransactionLogger( + 'Stake signing', + callback, + ); + + const handleEnding = () => { + resetForm(); + stethBalanceUpdate(); + }; + + if (isMultisig) { + closeTxModal(); + handleEnding(); + return; + } + + if (typeof transaction === 'object') { + setTxHash(transaction.hash); + setTxStage(TX_STAGE.BLOCK); + openTxModal(); + await runWithTransactionLogger('Stake block confirmation', async () => + transaction.wait(), + ); + } + + setTxStage(TX_STAGE.SUCCESS); + openTxModal(); + handleEnding(); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + const errorMessage = getErrorMessage(error); + setTxModalFailedText(errorMessage); + // Both LIMIT and FAIL are fail stages but limit reached has different UI + setTxStage( + errorMessage == ErrorMessage.LIMIT_REACHED + ? TX_STAGE.LIMIT + : TX_STAGE.FAIL, + ); + setTxHash(undefined); + openTxModal(); + } +}; diff --git a/features/home/wallet/limit-meter/components.tsx b/features/home/wallet/limit-meter/components.tsx new file mode 100644 index 000000000..fe74fd6fa --- /dev/null +++ b/features/home/wallet/limit-meter/components.tsx @@ -0,0 +1,109 @@ +import { LIMIT_LEVEL } from 'types'; +import { LimitReachedIcon, LimitSafeIcon, LimitWarnIcon } from './icons'; +import { TooltipHoverable } from 'shared/components'; +import { + Bars, + EmptyBar, + GreenBar, + GreenSpan, + IconWrapper, + LevelContainer, + LevelText, + RedBar, + RedSpan, + YellowBar, + YellowSpan, +} from './styles'; +import { LimitComponent } from './types'; + +const LevelSafe = () => ( + + + Staking limit level: + Safe to stake + + + + + + + +); + +const LevelWarn = () => ( + + + Staking limit level: + Almost reached + + + + + + + +); + +const LevelReached = () => ( + + + Staking limit level: + Reached + + + + + + + +); + +const Level: LimitComponent = ({ limitLevel }) => { + switch (limitLevel) { + case LIMIT_LEVEL.WARN: + return ; + case LIMIT_LEVEL.REACHED: + return ; + default: + return ; + } +}; + +const LimitIcon: LimitComponent = ({ limitLevel }) => { + switch (limitLevel) { + case LIMIT_LEVEL.WARN: + return ; + case LIMIT_LEVEL.REACHED: + return ; + default: + return ; + } +}; + +export const LimitHelp: LimitComponent = ({ limitLevel }) => { + return ( + +

+ Represents how much ether you can stake at this moment. You cannot + stake over the global staking limit. The global limit goes down with + each deposit but it's passively restored on each block.{' '} + + More info + +

+ + + } + > + + + +
+ ); +}; diff --git a/features/home/wallet/limit-meter/icons.tsx b/features/home/wallet/limit-meter/icons.tsx new file mode 100644 index 000000000..b37bbe702 --- /dev/null +++ b/features/home/wallet/limit-meter/icons.tsx @@ -0,0 +1,19 @@ +import styled from 'styled-components'; + +const Icon = styled.div` + width: 8px; + height: 8px; + border-radius: 50%; +`; + +export const LimitSafeIcon = styled(Icon)` + background-color: var(--lido-color-success); +`; + +export const LimitWarnIcon = styled(Icon)` + background-color: var(--lido-color-warning); +`; + +export const LimitReachedIcon = styled(Icon)` + background-color: var(--lido-color-error); +`; diff --git a/features/home/wallet/limit-meter/index.tsx b/features/home/wallet/limit-meter/index.tsx new file mode 100644 index 000000000..b43f643ef --- /dev/null +++ b/features/home/wallet/limit-meter/index.tsx @@ -0,0 +1 @@ +export { LimitMeter } from './limit-meter'; diff --git a/features/home/wallet/limit-meter/limit-meter.tsx b/features/home/wallet/limit-meter/limit-meter.tsx new file mode 100644 index 000000000..c3b8c10e0 --- /dev/null +++ b/features/home/wallet/limit-meter/limit-meter.tsx @@ -0,0 +1,10 @@ +import { useStakingLimitLevel } from 'shared/hooks/useStakingLimitLevel'; +import { LimitHelp } from './components'; + +export const LimitMeter = () => { + const limitLevel = useStakingLimitLevel(); + + if (limitLevel === null) return null; + + return ; +}; diff --git a/features/home/wallet/limit-meter/styles.ts b/features/home/wallet/limit-meter/styles.ts new file mode 100644 index 000000000..feb42aa37 --- /dev/null +++ b/features/home/wallet/limit-meter/styles.ts @@ -0,0 +1,64 @@ +import styled from 'styled-components'; + +export const LevelText = styled.div` + display: flex; + justify-content: space-between; +`; + +export const GreenSpan = styled.span` + color: var(--lido-color-success); +`; + +export const YellowSpan = styled.span` + color: var(--lido-color-warning); +`; + +export const RedSpan = styled.span` + color: var(--lido-color-error); +`; + +export const EmptyBar = styled.div` + display: inline-block; + background: #fff; + opacity: 0.1; + border-radius: 8px; + height: 4px; + width: 100%; +`; + +export const GreenBar = styled(EmptyBar)` + background: var(--lido-color-success); + opacity: 1; +`; + +export const YellowBar = styled(EmptyBar)` + background: var(--lido-color-warning); + opacity: 1; +`; + +export const RedBar = styled(EmptyBar)` + background: var(--lido-color-error); + opacity: 1; +`; + +export const Bars = styled.div` + display: grid; + grid-template-columns: 1fr 1fr 1fr; + column-gap: 3px; + margin-top: 10px; +`; + +export const LevelContainer = styled.div` + margin-top: 16px; + margin-bottom: 4px; +`; + +export const IconWrapper = styled.div` + display: flex; + align-items: center; + margin-left: 8px; + line-height: 0; + :hover { + cursor: pointer; + } +`; diff --git a/features/home/wallet/limit-meter/types.ts b/features/home/wallet/limit-meter/types.ts new file mode 100644 index 000000000..a16693472 --- /dev/null +++ b/features/home/wallet/limit-meter/types.ts @@ -0,0 +1,4 @@ +import { FC } from 'react'; +import { LIMIT_LEVEL } from 'types'; + +export type LimitComponent = FC<{ limitLevel: LIMIT_LEVEL }>; diff --git a/features/home/wallet/styles.tsx b/features/home/wallet/styles.tsx new file mode 100644 index 000000000..4290d96fc --- /dev/null +++ b/features/home/wallet/styles.tsx @@ -0,0 +1,15 @@ +import { Card } from 'shared/wallet'; +import styled from 'styled-components'; + +export const LidoAprStyled = styled.span` + color: rgb(97, 183, 95); +`; + +export const StyledCard = styled((props) => )` + background: linear-gradient(65.21deg, #37394a 19.1%, #3e4b4f 100%); +`; + +export const FlexCenter = styled.div` + display: flex; + align-items: center; +`; diff --git a/features/home/wallet/wallet.tsx b/features/home/wallet/wallet.tsx new file mode 100644 index 000000000..98bb84caa --- /dev/null +++ b/features/home/wallet/wallet.tsx @@ -0,0 +1,86 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { useSDK, useSTETHBalance, useTokenAddress } from '@lido-sdk/react'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { Divider, Question, Tooltip } from '@lidofinance/lido-ui'; +import { LIDO_APR_TOOLTIP_TEXT } from 'config'; +import { memo } from 'react'; +import { TokenToWallet } from 'shared/components'; +import { FormatToken } from 'shared/formatters'; +import { useLidoApr } from 'shared/hooks'; +import { DATA_UNAVAILABLE } from 'config'; +import { CardAccount, CardBalance, CardRow, Fallback } from 'shared/wallet'; +import type { WalletComponentType } from 'shared/wallet/types'; +import { useStakeableEther } from '../hooks'; +import { LimitMeter } from './limit-meter'; +import { FlexCenter, LidoAprStyled, StyledCard } from './styles'; + +const WalletComponent: WalletComponentType = (props) => { + const { account } = useSDK(); + const stakeableEther = useStakeableEther(); + const steth = useSTETHBalance(); + + const stethAddress = useTokenAddress(TOKENS.STETH); + const lidoApr = useLidoApr(); + + return ( + + + + Available to stake + + + } + loading={stakeableEther.initialLoading} + value={ + + } + /> + + + + + + + + + } + /> + + Lido APR{' '} + {lidoApr && lidoApr.data && ( + + + + )} + + } + loading={lidoApr.initialLoading} + value={ + + {lidoApr.apr ? `${lidoApr.apr}%` : DATA_UNAVAILABLE} + + } + /> + + + ); +}; + +export const Wallet: WalletComponentType = memo((props) => { + const { active } = useWeb3(); + return active ? : ; +}); diff --git a/features/referral/banner/banner.tsx b/features/referral/banner/banner.tsx new file mode 100644 index 000000000..d8f668f4f --- /dev/null +++ b/features/referral/banner/banner.tsx @@ -0,0 +1,41 @@ +import { FC } from 'react'; +import { Block, Link } from '@lidofinance/lido-ui'; + +import { BannerTextStyle, BannerHeader, BannerMainTextStyle } from './styles'; + +export const Banner: FC = () => { + return ( + + Whitelist mode is on +

+ The Lido referral program transitioned to 'whitelist mode' + starting from 13.09.2021 / 00:00 UTC. +

+
+

+ Only + +  whitelisted referral  + + partners approved by the Lido DAO are eligible for rewards. To apply to + Lido's whitelist you could here: + + {' '} + https://research.lido.fi/t/referral-program-whitelisting-ethereum/1039 + +

+
+ + All the other referral links don't get rewards anymore. + +
+ + All rewards got before 13.09.2021 can be claimed via + Rhino.fi + from 20.09.2021 + +
+

Thank you for participating!

+
+ ); +}; diff --git a/features/referral/banner/index.ts b/features/referral/banner/index.ts new file mode 100644 index 000000000..5f15d5d4e --- /dev/null +++ b/features/referral/banner/index.ts @@ -0,0 +1 @@ +export * from './banner'; diff --git a/features/referral/banner/styles.ts b/features/referral/banner/styles.ts new file mode 100644 index 000000000..f2aa49e66 --- /dev/null +++ b/features/referral/banner/styles.ts @@ -0,0 +1,24 @@ +import styled, { css } from 'styled-components'; + +const textStyle = css` + line-height: 20px; +`; + +export const BannerHeader = styled.p` + color: var(--lido-color-text); + margin-bottom: ${({ theme }) => theme.spaceMap.sm}px; + font-size: ${({ theme }) => theme.fontSizes[3]}px; + font-weight: bold; + line-height: 24px; +`; + +export const BannerTextStyle = styled.p` + ${textStyle} +`; + +export const BannerMainTextStyle = styled.p` + ${textStyle} + + color: var(--lido-color-text); + font-weight: bold; +`; diff --git a/features/referral/index.ts b/features/referral/index.ts new file mode 100644 index 000000000..5f15d5d4e --- /dev/null +++ b/features/referral/index.ts @@ -0,0 +1 @@ +export * from './banner'; diff --git a/features/rewards/components/CopyAddressUrl.tsx b/features/rewards/components/CopyAddressUrl.tsx new file mode 100644 index 000000000..3b2948415 --- /dev/null +++ b/features/rewards/components/CopyAddressUrl.tsx @@ -0,0 +1,23 @@ +import { ButtonIcon, Copy } from '@lidofinance/lido-ui'; +import { useCopyToClipboard } from 'shared/hooks'; + +// TODO: move to separate folders +const CopyAddressUrl = ({ address }: { address: string }) => { + const { href } = location; + const withoutQuery = href.split('?')[0]; + const url = `${withoutQuery}?address=${address}`; + + const handleCopy = useCopyToClipboard(url); + + return ( + } + size="xs" + variant="translucent" + onClick={handleCopy} + /> + ); +}; + +export default CopyAddressUrl; diff --git a/features/rewards/components/CurrencySelector.tsx b/features/rewards/components/CurrencySelector.tsx new file mode 100644 index 000000000..77467ae98 --- /dev/null +++ b/features/rewards/components/CurrencySelector.tsx @@ -0,0 +1,64 @@ +import Cookies from 'js-cookie'; +import styled from 'styled-components'; + +import { Box, Select, Option } from '@lidofinance/lido-ui'; + +import { CURRENCIES, type CurrencyType } from 'features/rewards/constants'; +import { STORAGE_CURRENCY_KEY } from 'config'; + +const StyledSelect = styled(Select)` + height: 32px; + width: 70px; + + border-radius: 6px; + + & span { + padding: unset; + } + + & input { + font-size: 12px; + font-weight: 400; + } + + & span:nth-of-type(2) { + padding-left: unset; + } +`; + +const COOKIES_THEME_EXPIRES_DAYS = 365; + +export const setCurrencyCookie = (value: string) => + Cookies.set(STORAGE_CURRENCY_KEY, value, { + expires: COOKIES_THEME_EXPIRES_DAYS, + }); + +export const getCurrencyCookie = () => Cookies.get(STORAGE_CURRENCY_KEY); + +type CurrencySelectorProps = { + currency: CurrencyType; + onChange: (val: string) => void; +}; + +const CurrencySelector = ({ currency, onChange }: CurrencySelectorProps) => ( + + { + const optionString = option.toString(); + onChange(optionString); + setCurrencyCookie(optionString); + }} + value={currency.code} + variant="small" + > + {CURRENCIES.map((cur) => ( + + ))} + + +); + +export default CurrencySelector; diff --git a/features/rewards/components/Date.tsx b/features/rewards/components/Date.tsx new file mode 100644 index 000000000..9a138a422 --- /dev/null +++ b/features/rewards/components/Date.tsx @@ -0,0 +1,23 @@ +import { Tooltip, Box } from '@lidofinance/lido-ui'; +import { lightFormat, fromUnixTime } from 'date-fns'; +import type { Event } from 'features/rewards/types'; + +// TODO: move to separate folders +const Date = ({ blockTime }: { blockTime: Event['blockTime'] }) => { + const parsed = fromUnixTime(parseInt(blockTime)); + + const light = lightFormat(parsed, 'dd.MM.yyyy'); + const full = lightFormat(parsed, 'dd.MM.yyyy HH:mm'); + + return ( + {full}} + > + {light} + + ); +}; + +export default Date; diff --git a/features/rewards/components/EthSymbol.tsx b/features/rewards/components/EthSymbol.tsx new file mode 100644 index 000000000..ea04918e9 --- /dev/null +++ b/features/rewards/components/EthSymbol.tsx @@ -0,0 +1,11 @@ +import { Box } from '@lidofinance/lido-ui'; +import { constants } from 'ethers'; + +// TODO: move to separate folders +const EthSymbol = () => ( + + {constants.EtherSymbol} + +); + +export default EthSymbol; diff --git a/features/rewards/components/IndexerLink.tsx b/features/rewards/components/IndexerLink.tsx new file mode 100644 index 000000000..d2232ec91 --- /dev/null +++ b/features/rewards/components/IndexerLink.tsx @@ -0,0 +1,23 @@ +import { Box, External as ExternalLinkIcon } from '@lidofinance/lido-ui'; +import { getEtherscanTxLink } from '@lido-sdk/helpers'; +import { dynamics } from 'config'; + +// TODO: move to separate folders +type Props = { + transactionHash: string; +}; + +const IndexerLink = ({ transactionHash }: Props) => { + if (!transactionHash) return null; + + const link = getEtherscanTxLink(dynamics.defaultChain, transactionHash); + return ( + + + + + + ); +}; + +export default IndexerLink; diff --git a/features/rewards/components/NumberFormat.tsx b/features/rewards/components/NumberFormat.tsx new file mode 100644 index 000000000..c3c8cd6d8 --- /dev/null +++ b/features/rewards/components/NumberFormat.tsx @@ -0,0 +1,73 @@ +import { + formatWEI, + formatETH, + formatStEthEth, + formatCurrency, + formatPercentage, +} from 'features/rewards/utils/numberFormatting'; +import { Tooltip, Box, InlineLoader } from '@lidofinance/lido-ui'; +import type { BigNumber } from 'features/rewards/helpers'; +import { Big, BigDecimal } from 'features/rewards/helpers'; + +// TODO: move to separate folders + +type FormatArgs = { + number?: string | number | BigNumber | undefined; + StEthEth?: boolean; + currency?: boolean; + percent?: boolean; + ETH?: boolean; +}; + +// Using ETH as a default formatter +const format = ( + { number, StEthEth, currency, percent, ETH }: FormatArgs, + manyDigits?: boolean, +): string => { + if (number === undefined) return ''; + + const args = [new BigDecimal(number), Boolean(manyDigits)] as const; + + if (StEthEth) { + return formatStEthEth(...args); + } else if (currency) { + return formatCurrency(...args); + } else if (percent) { + return formatPercentage(...args); + } else if (ETH) { + return formatETH(...args); + } else { + return formatWEI(new Big(number), args[1]); + } +}; + +type Props = Partial & { + id?: string; + pending?: boolean; +}; + +const NumberFormat = (props: Props) => { + if (props.pending) + return ; + + return props.number ? ( + + + {format(props, true)} + + + } + > + + {format(props)} + + + ) : ( + <>- + ); +}; + +export default NumberFormat; diff --git a/features/rewards/components/addressInput/AddressInput.tsx b/features/rewards/components/addressInput/AddressInput.tsx new file mode 100644 index 000000000..e7b5e1d45 --- /dev/null +++ b/features/rewards/components/addressInput/AddressInput.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { Input, Loader, Identicon } from '@lidofinance/lido-ui'; +import CopyAddressUrl from 'features/rewards/components/CopyAddressUrl'; +import { isValidAnyAddress } from 'features/rewards/utils'; + +import { AddressInputProps } from './types'; + +export const AddressInput: FC = (props) => { + const { inputValue, isAddressResolving, handleInputChange, address } = props; + + return ( + handleInputChange(e.target.value)} + placeholder="Ethereum address" + leftDecorator={ + isAddressResolving ? ( + + ) : address ? ( + + ) : null + } + rightDecorator={address ? : null} + spellCheck="false" + error={!!inputValue.length && !isValidAnyAddress(inputValue)} + /> + ); +}; diff --git a/features/rewards/components/addressInput/index.ts b/features/rewards/components/addressInput/index.ts new file mode 100644 index 000000000..1845571c0 --- /dev/null +++ b/features/rewards/components/addressInput/index.ts @@ -0,0 +1 @@ +export * from './AddressInput'; diff --git a/features/rewards/components/addressInput/types.ts b/features/rewards/components/addressInput/types.ts new file mode 100644 index 000000000..a35ba9f50 --- /dev/null +++ b/features/rewards/components/addressInput/types.ts @@ -0,0 +1,6 @@ +export type AddressInputProps = { + inputValue: string; + isAddressResolving: boolean; + handleInputChange: (value: string) => void; + address: string; +}; diff --git a/features/rewards/components/errorBlocks/ErrorBlockBase.tsx b/features/rewards/components/errorBlocks/ErrorBlockBase.tsx new file mode 100644 index 000000000..dfd849869 --- /dev/null +++ b/features/rewards/components/errorBlocks/ErrorBlockBase.tsx @@ -0,0 +1,16 @@ +import { Box, Block, Text } from '@lidofinance/lido-ui'; + +type Props = React.ComponentProps & { + text: React.ReactNode; + textProps?: React.ComponentProps; +}; + +export const ErrorBlockBase = ({ text, textProps = {}, ...rest }: Props) => ( + + + + {text} + + + +); diff --git a/features/rewards/components/errorBlocks/ErrorBlockNoSteth.tsx b/features/rewards/components/errorBlocks/ErrorBlockNoSteth.tsx new file mode 100644 index 000000000..0b3ad204b --- /dev/null +++ b/features/rewards/components/errorBlocks/ErrorBlockNoSteth.tsx @@ -0,0 +1,22 @@ +import { Box, Button } from '@lidofinance/lido-ui'; +import { LocalLink } from 'shared/components/local-link'; + +export const ErrorBlockNoSteth = () => ( + + + You don't have staked tokens. Stake now and receive daily rewards. + + + + + + + +); diff --git a/features/rewards/components/errorBlocks/ErrorBlockServer.tsx b/features/rewards/components/errorBlocks/ErrorBlockServer.tsx new file mode 100644 index 000000000..4fdcba322 --- /dev/null +++ b/features/rewards/components/errorBlocks/ErrorBlockServer.tsx @@ -0,0 +1,8 @@ +import { ErrorBlockBase } from './ErrorBlockBase'; + +export const ErrorBlockServer = () => ( + +); diff --git a/features/rewards/components/export/Export.tsx b/features/rewards/components/export/Export.tsx new file mode 100644 index 000000000..66fdf63c6 --- /dev/null +++ b/features/rewards/components/export/Export.tsx @@ -0,0 +1,49 @@ +import { genExportData, saveAsCSV } from 'features/rewards/utils'; +import { useRewardsHistory } from 'features/rewards/hooks'; +import { backendRequest } from 'features/rewards/fetchers/requesters'; + +import { ButtonStyle } from './Exportstyled'; + +import type { CurrencyType } from 'features/rewards/constants'; + +type ExportProps = { + currency: CurrencyType; + address: string; + archiveRate: boolean; + onlyRewards: boolean; +}; + +export const Export = ({ + currency: currencyObject, + address, + currency, + archiveRate, + onlyRewards, +}: ExportProps) => { + const { data } = useRewardsHistory(); + + const triggerExport = async () => { + // Ignoring any other options eg limit or skip + // const { address, currency, archiveRate, onlyRewards } = backendOptions; + const result = await backendRequest({ + address, + currency: currency.code, + archiveRate, + onlyRewards, + }); + const formatted = genExportData(currencyObject, result.events); + saveAsCSV(formatted); + }; + + return ( + + Export CSV + + ); +}; diff --git a/features/rewards/components/export/Exportstyled.ts b/features/rewards/components/export/Exportstyled.ts new file mode 100644 index 000000000..ffbc059b8 --- /dev/null +++ b/features/rewards/components/export/Exportstyled.ts @@ -0,0 +1,11 @@ +import styled from 'styled-components'; +import { Button } from '@lidofinance/lido-ui'; + +export const ButtonStyle = styled(Button)` + font-weight: 400; + height: 32px; + + width: 83px; + min-width: unset; + padding: unset; +`; diff --git a/features/rewards/components/export/index.ts b/features/rewards/components/export/index.ts new file mode 100644 index 000000000..188e5ded6 --- /dev/null +++ b/features/rewards/components/export/index.ts @@ -0,0 +1 @@ +export * from './Export'; diff --git a/features/rewards/components/inputDescription/InputDescription.tsx b/features/rewards/components/inputDescription/InputDescription.tsx new file mode 100644 index 000000000..208202105 --- /dev/null +++ b/features/rewards/components/inputDescription/InputDescription.tsx @@ -0,0 +1,7 @@ +import { FC } from 'react'; + +import { WrapperStyle } from './InputDescriptionStyles'; + +export const InputDescription: FC = ({ children }) => { + return {children}; +}; diff --git a/features/rewards/components/inputDescription/InputDescriptionStyles.ts b/features/rewards/components/inputDescription/InputDescriptionStyles.ts new file mode 100644 index 000000000..b874bbfdd --- /dev/null +++ b/features/rewards/components/inputDescription/InputDescriptionStyles.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +export const WrapperStyle = styled.div` + padding: ${({ theme }) => theme.spaceMap.sm}px; + border-radius: ${({ theme }) => theme.spaceMap.sm}px; + color: #ffac2f; + background-color: rgb(255, 172, 47, 0.1); + text-align: center; + margin-top: 16px; +`; diff --git a/features/rewards/components/inputDescription/index.ts b/features/rewards/components/inputDescription/index.ts new file mode 100644 index 000000000..264c3cb95 --- /dev/null +++ b/features/rewards/components/inputDescription/index.ts @@ -0,0 +1 @@ +export * from './InputDescription'; diff --git a/features/rewards/components/inputWrapper/InputWrapper.tsx b/features/rewards/components/inputWrapper/InputWrapper.tsx new file mode 100644 index 000000000..810d9a425 --- /dev/null +++ b/features/rewards/components/inputWrapper/InputWrapper.tsx @@ -0,0 +1,8 @@ +import { FC } from 'react'; +import { BlockProps } from '@lidofinance/lido-ui'; + +import { InputWrapperStyle } from './InputWrapperStyles'; + +export const InputWrapper: FC = ({ children, ...rest }) => { + return {children}; +}; diff --git a/features/rewards/components/inputWrapper/InputWrapperStyles.ts b/features/rewards/components/inputWrapper/InputWrapperStyles.ts new file mode 100644 index 000000000..65cf098cd --- /dev/null +++ b/features/rewards/components/inputWrapper/InputWrapperStyles.ts @@ -0,0 +1,6 @@ +import styled from 'styled-components'; +import { Block } from '@lidofinance/lido-ui'; + +export const InputWrapperStyle = styled(Block)` + padding-bottom: 24px; +`; diff --git a/features/rewards/components/inputWrapper/index.ts b/features/rewards/components/inputWrapper/index.ts new file mode 100644 index 000000000..ff6f77010 --- /dev/null +++ b/features/rewards/components/inputWrapper/index.ts @@ -0,0 +1 @@ +export * from './InputWrapper'; diff --git a/features/rewards/components/rewardsListContent/RewardsListContent.tsx b/features/rewards/components/rewardsListContent/RewardsListContent.tsx new file mode 100644 index 000000000..663a7b49d --- /dev/null +++ b/features/rewards/components/rewardsListContent/RewardsListContent.tsx @@ -0,0 +1,51 @@ +import { FC } from 'react'; +import { Loader, Divider } from '@lidofinance/lido-ui'; +import { useRewardsHistory } from 'features/rewards/hooks'; +import { ErrorBlockNoSteth } from 'features/rewards/components/errorBlocks/ErrorBlockNoSteth'; + +import { RewardsListsEmpty } from './RewardsListsEmpty'; +import { RewardsListErrorMessage } from './RewardsListErrorMessage'; +import { LoaderWrapper, TableWrapperStyle } from './RewardsListContentStyles'; +import { RewardsTable } from 'features/rewards/components/rewardsTable'; + +export const RewardsListContent: FC = () => { + const { + error, + initialLoading, + data, + currencyObject, + page, + setPage, + isLagging, + } = useRewardsHistory(); + + if (!data && !initialLoading && !error) return ; + // showing loading when canceling requests and empty response + if ((!data && !error) || (initialLoading && !data?.events.length)) { + return ( + <> + + + + + + ); + } + if (error) return ; + if (data && !data.events.length) return ; + + return ( + + {data?.events.length && !error && ( + + )} + + ); +}; diff --git a/features/rewards/components/rewardsListContent/RewardsListContentStyles.ts b/features/rewards/components/rewardsListContent/RewardsListContentStyles.ts new file mode 100644 index 000000000..4ca31d086 --- /dev/null +++ b/features/rewards/components/rewardsListContent/RewardsListContentStyles.ts @@ -0,0 +1,10 @@ +import styled from 'styled-components'; + +export const LoaderWrapper = styled.div` + display: flex; + justify-content: center; +`; + +export const TableWrapperStyle = styled.div` + margin-top: 20px; +`; diff --git a/features/rewards/components/rewardsListContent/RewardsListErrorMessage.tsx b/features/rewards/components/rewardsListContent/RewardsListErrorMessage.tsx new file mode 100644 index 000000000..998ce5741 --- /dev/null +++ b/features/rewards/components/rewardsListContent/RewardsListErrorMessage.tsx @@ -0,0 +1,19 @@ +import { ErrorBlockBase } from '../errorBlocks/ErrorBlockBase'; +import { ErrorBlockServer } from '../errorBlocks/ErrorBlockServer'; + +import { extractErrorMessage } from 'utils'; +import { FetcherError } from 'utils/fetcherError'; + +type Props = { + error: unknown; +}; + +export const RewardsListErrorMessage: React.FC = ({ error }) => { + const errorMessage = extractErrorMessage(error); + + if (error instanceof FetcherError && error.status === 503) { + return ; + } + + return ; +}; diff --git a/features/rewards/components/rewardsListContent/RewardsListsEmpty.tsx b/features/rewards/components/rewardsListContent/RewardsListsEmpty.tsx new file mode 100644 index 000000000..51f0a5742 --- /dev/null +++ b/features/rewards/components/rewardsListContent/RewardsListsEmpty.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { Divider } from '@lidofinance/lido-ui'; + +import { RewardsListEmptyWrapper } from './RewardsListsEmptyStyles'; + +export const RewardsListsEmpty: FC = () => { + return ( + <> + + + Connect your wallet or enter your Ethereum address to see the stats. + + + ); +}; diff --git a/features/rewards/components/rewardsListContent/RewardsListsEmptyStyles.ts b/features/rewards/components/rewardsListContent/RewardsListsEmptyStyles.ts new file mode 100644 index 000000000..3dd077d9c --- /dev/null +++ b/features/rewards/components/rewardsListContent/RewardsListsEmptyStyles.ts @@ -0,0 +1,5 @@ +import styled from 'styled-components'; + +export const RewardsListEmptyWrapper = styled.div` + text-align: center; +`; diff --git a/features/rewards/components/rewardsListContent/index.ts b/features/rewards/components/rewardsListContent/index.ts new file mode 100644 index 000000000..f151591d0 --- /dev/null +++ b/features/rewards/components/rewardsListContent/index.ts @@ -0,0 +1 @@ +export * from './RewardsListContent'; diff --git a/features/rewards/components/rewardsListHeader/LeftOptions.tsx b/features/rewards/components/rewardsListHeader/LeftOptions.tsx new file mode 100644 index 000000000..c5baa4166 --- /dev/null +++ b/features/rewards/components/rewardsListHeader/LeftOptions.tsx @@ -0,0 +1,43 @@ +import { FC } from 'react'; +import { Tooltip, Checkbox } from '@lidofinance/lido-ui'; + +import { LeftOptionsWrapper } from './styles'; +import { useRewardsHistory } from 'features/rewards/hooks/useRewardsHistory'; + +export const LeftOptions: FC = () => { + const { + isUseArchiveExchangeRate, + isOnlyRewards, + setIsUseArchiveExchangeRate, + setIsOnlyRewards, + } = useRewardsHistory(); + + return ( + + + + setIsUseArchiveExchangeRate(!isUseArchiveExchangeRate) + } + label="Historical stETH price" + /> + + + setIsOnlyRewards(!isOnlyRewards)} + label="Only Show Rewards" + /> + + + ); +}; diff --git a/features/rewards/components/rewardsListHeader/RewardsListHeader.tsx b/features/rewards/components/rewardsListHeader/RewardsListHeader.tsx new file mode 100644 index 000000000..0e9fcd45d --- /dev/null +++ b/features/rewards/components/rewardsListHeader/RewardsListHeader.tsx @@ -0,0 +1,18 @@ +import { FC } from 'react'; +import { useRewardsHistory } from 'features/rewards/hooks'; + +import { LeftOptions } from './LeftOptions'; +import { RightOptions } from './RightOptions'; +import { RewardsListHeaderStyle } from './styles'; +import { TitleStyle } from './styles'; + +export const RewardsListHeader: FC = () => { + const { error, data } = useRewardsHistory(); + return ( + + Reward history + + {!error && data && data?.events.length > 0 && } + + ); +}; diff --git a/features/rewards/components/rewardsListHeader/RightOptions.tsx b/features/rewards/components/rewardsListHeader/RightOptions.tsx new file mode 100644 index 000000000..0b5252024 --- /dev/null +++ b/features/rewards/components/rewardsListHeader/RightOptions.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import CurrencySelector from 'features/rewards/components/CurrencySelector'; +import { Export } from 'features/rewards/components/export'; + +import { RightOptionsWrapper } from './styles'; +import { useRewardsHistory } from 'features/rewards/hooks/useRewardsHistory'; + +export const RightOptions: FC = () => { + const { + address, + currencyObject, + setCurrency, + isUseArchiveExchangeRate, + isOnlyRewards, + } = useRewardsHistory(); + return ( + + + + + ); +}; diff --git a/features/rewards/components/rewardsListHeader/index.ts b/features/rewards/components/rewardsListHeader/index.ts new file mode 100644 index 000000000..8447a02d9 --- /dev/null +++ b/features/rewards/components/rewardsListHeader/index.ts @@ -0,0 +1 @@ +export * from './RewardsListHeader'; diff --git a/features/rewards/components/rewardsListHeader/styles.ts b/features/rewards/components/rewardsListHeader/styles.ts new file mode 100644 index 000000000..c58a4a178 --- /dev/null +++ b/features/rewards/components/rewardsListHeader/styles.ts @@ -0,0 +1,52 @@ +import styled from 'styled-components'; + +export const RewardsListHeaderStyle = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 20px 32px; + height: 32px; + align-items: center; + + color: ${({ theme }) => theme.colors.secondary}; + + ${({ theme }) => theme.mediaQueries.md} { + flex-direction: column; + height: auto; + align-items: initial; + } +`; + +export const TitleStyle = styled.span` + font-weight: bold; + line-height: 24px; + font-size: 14px; +`; + +export const LeftOptionsWrapper = styled.div` + display: flex; + flex-wrap: wrap; + margin-right: auto; + gap: 16px; + ${({ theme }) => theme.mediaQueries.lg} { + order: 3; + margin-right: 0; + width: 100%; + & > * { + flex: 1 0; + } + } +`; + +export const RightOptionsWrapper = styled.div` + display: flex; + gap: 10px; + ${({ theme }) => theme.mediaQueries.lg} { + order: 2; + width: 100%; + & > * { + flex: 1 0; + max-width: 50%; + } + } +`; diff --git a/features/rewards/components/rewardsListWrapper/RewardListWrapperStyles.ts b/features/rewards/components/rewardsListWrapper/RewardListWrapperStyles.ts new file mode 100644 index 000000000..c7fe1c2f3 --- /dev/null +++ b/features/rewards/components/rewardsListWrapper/RewardListWrapperStyles.ts @@ -0,0 +1,6 @@ +import styled from 'styled-components'; +import { Block } from '@lidofinance/lido-ui'; + +export const RewardsListWrapperStyle = styled(Block)` + margin: 32px 0; +`; diff --git a/features/rewards/components/rewardsListWrapper/RewardsListWrapper.tsx b/features/rewards/components/rewardsListWrapper/RewardsListWrapper.tsx new file mode 100644 index 000000000..791a3faaa --- /dev/null +++ b/features/rewards/components/rewardsListWrapper/RewardsListWrapper.tsx @@ -0,0 +1,6 @@ +import { FC } from 'react'; +import { RewardsListWrapperStyle } from './RewardListWrapperStyles'; + +export const RewardsListWrapper: FC = ({ children }) => { + return {children}; +}; diff --git a/features/rewards/components/rewardsListWrapper/index.ts b/features/rewards/components/rewardsListWrapper/index.ts new file mode 100644 index 000000000..c453eff47 --- /dev/null +++ b/features/rewards/components/rewardsListWrapper/index.ts @@ -0,0 +1 @@ +export * from './RewardsListWrapper'; diff --git a/features/rewards/components/rewardsTable/RewardsTable.tsx b/features/rewards/components/rewardsTable/RewardsTable.tsx new file mode 100644 index 000000000..cec7993d1 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTable.tsx @@ -0,0 +1,50 @@ +import { FC } from 'react'; +import { Tbody } from '@lidofinance/lido-ui'; + +import { RewardsTableHeader } from './RewardsTableHeader'; +import { RewardsTableRow } from './RewardsTableRow'; +import { RewardsTablePagination } from './RewardsTablePagination'; +import { RewardsTableLoader } from './RewardsTableLoader'; +import { REWARDS_TABLE_CONFIG } from './contsnats'; +import { + RewardsTableStyle, + RewardsTableWrapperStyle, +} from './RewardsTableStyles'; +import { RewardsTableProps } from './types'; + +export const RewardsTable: FC = (props) => { + const { data, currency, totalItems, page, setPage, pending } = props; + const pageCount = Math.ceil((totalItems ?? 0) / REWARDS_TABLE_CONFIG.take); + + return ( + <> + + {pending && } + + + + {data?.map((data, index) => ( + + ))} + + + + setPage(currentPage - 1)} + activePage={page + 1} + siblingCount={4} + /> + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCell.tsx new file mode 100644 index 000000000..116ce0ea2 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCell.tsx @@ -0,0 +1,39 @@ +import { FC } from 'react'; + +import { + DateCell, + TypeCell, + ChangeCell, + CurrencyChangeCell, + AprCell, + BalanceCell, + DefaultCell, +} from './RewardsTableCells'; +import { RewardsTableCellProps } from './types'; + +const getComponent = (type?: string): React.FC => { + switch (type) { + case 'blockTime': + return DateCell; + case 'type': + return TypeCell; + case 'change': + return ChangeCell; + case 'currencyChange': + return CurrencyChangeCell; + case 'apr': + return AprCell; + case 'balance': + return BalanceCell; + default: + return DefaultCell; + } +}; + +export const RewardsTableCell: FC = ( + props, +): JSX.Element => { + const Component = getComponent(props.column.field); + + return ; +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/AprCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCells/AprCell.tsx new file mode 100644 index 000000000..5cb8d8f30 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/AprCell.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { Td } from '@lidofinance/lido-ui'; +import NumberFormat from 'features/rewards/components/NumberFormat'; + +import { RewardsTableCellProps } from '../types'; + +export const AprCell: FC = (props) => { + const { value, cellConfig } = props; + + return ( + + {value ? : '-'} + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/BalanceCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCells/BalanceCell.tsx new file mode 100644 index 000000000..b4c86913b --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/BalanceCell.tsx @@ -0,0 +1,17 @@ +import { FC } from 'react'; +import { Td } from '@lidofinance/lido-ui'; +import NumberFormat from 'features/rewards/components/NumberFormat'; +import EthSymbol from 'features/rewards/components/EthSymbol'; + +import { RewardsTableCellProps } from '../types'; + +export const BalanceCell: FC = (props) => { + const { value, cellConfig } = props; + + return ( + + + + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/CellStyles.ts b/features/rewards/components/rewardsTable/RewardsTableCells/CellStyles.ts new file mode 100644 index 000000000..e68b814cf --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/CellStyles.ts @@ -0,0 +1,29 @@ +import styled from 'styled-components'; + +export const TypeCellValueWrapper = styled.div` + display: flex; + white-space: nowrap; +`; + +export const ChangeCellValueWrapper = styled.div<{ negative: boolean }>` + color: ${({ negative }) => + negative ? 'var(--lido-color-error)' : 'var(--lido-color-success)'}; +`; + +export const OnlyMobileCellValueWrapper = styled.div` + display: none; + ${({ theme }) => theme.mediaQueries.lg} { + display: block; + } +`; + +export const OnlyMobileChangeCellValueWrapper = styled.div<{ + negative: boolean; +}>` + display: none; + color: ${({ negative }) => + negative ? 'var(--lido-color-error)' : 'var(--lido-color-success)'}; + ${({ theme }) => theme.mediaQueries.lg} { + display: block; + } +`; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/ChangeCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCells/ChangeCell.tsx new file mode 100644 index 000000000..f66c5c038 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/ChangeCell.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; +import { Td } from '@lidofinance/lido-ui'; +import EthSymbol from 'features/rewards/components/EthSymbol'; +import NumberFormat from 'features/rewards/components/NumberFormat'; + +import { + ChangeCellValueWrapper, + OnlyMobileChangeCellValueWrapper, +} from './CellStyles'; +import { RewardsTableCellProps } from '../types'; + +export const ChangeCell: FC = (props) => { + const { value, data, currency, cellConfig } = props; + const isNegative = data?.direction === 'out' || data.type === 'withdrawal'; + + return ( + + + + + + + {currency.symbol} + + + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/CurrencyChangeCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCells/CurrencyChangeCell.tsx new file mode 100644 index 000000000..658ccf138 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/CurrencyChangeCell.tsx @@ -0,0 +1,20 @@ +import { FC } from 'react'; +import { Td } from '@lidofinance/lido-ui'; +import NumberFormat from 'features/rewards/components/NumberFormat'; + +import { ChangeCellValueWrapper } from './CellStyles'; +import { RewardsTableCellProps } from '../types'; + +export const CurrencyChangeCell: FC = (props) => { + const { value, currency, data, cellConfig } = props; + const isNegative = data?.direction === 'out' || data.type === 'withdrawal'; + + return ( + + + {currency.symbol} + + + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/DateCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCells/DateCell.tsx new file mode 100644 index 000000000..48cdb0b73 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/DateCell.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { Td } from '@lidofinance/lido-ui'; +import Date from 'features/rewards/components/Date'; + +import { RewardsTableCellProps } from '../types'; + +export const DateCell: FC = (props) => { + const { value, cellConfig } = props; + + return ( + + + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/DefaultCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCells/DefaultCell.tsx new file mode 100644 index 000000000..5e66808f3 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/DefaultCell.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; +import { Td } from '@lidofinance/lido-ui'; + +import { RewardsTableCellProps } from '../types'; + +export const DefaultCell: FC = (props): JSX.Element => { + const { value, cellConfig, ...rest } = props; + + return ( + + {String(value)} + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/TypeCell.tsx b/features/rewards/components/rewardsTable/RewardsTableCells/TypeCell.tsx new file mode 100644 index 000000000..99ae3c02a --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/TypeCell.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { Td } from '@lidofinance/lido-ui'; +import { capitalize } from 'features/rewards/utils'; +import IndexerLink from 'features/rewards/components/IndexerLink'; +import Date from 'features/rewards/components/Date'; + +import { OnlyMobileCellValueWrapper, TypeCellValueWrapper } from './CellStyles'; +import { RewardsTableCellProps } from '../types'; + +export const TypeCell: FC = (props) => { + const { value, data, cellConfig } = props; + + return ( + + + + + + {capitalize(String(value))}{' '} + {data.direction && capitalize(data.direction)}{' '} + + + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableCells/index.ts b/features/rewards/components/rewardsTable/RewardsTableCells/index.ts new file mode 100644 index 000000000..99a8ab4cd --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableCells/index.ts @@ -0,0 +1,7 @@ +export * from './DateCell'; +export * from './TypeCell'; +export * from './ChangeCell'; +export * from './CurrencyChangeCell'; +export * from './AprCell'; +export * from './BalanceCell'; +export * from './DefaultCell'; diff --git a/features/rewards/components/rewardsTable/RewardsTableHeader.tsx b/features/rewards/components/rewardsTable/RewardsTableHeader.tsx new file mode 100644 index 000000000..b692a081e --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableHeader.tsx @@ -0,0 +1,28 @@ +import { Tr, Thead } from '@lidofinance/lido-ui'; + +import { RewardsTableHeaderCell } from './RewardsTableHeaderCell'; +import { RewardsTableHeaderProps } from './types'; + +export const RewardsTableHeader = ( + props: RewardsTableHeaderProps, +): JSX.Element => { + const { columns, currency, config } = props; + + return ( + + + {columns.map(({ field, name }) => { + return ( + + ); + })} + + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableHeaderCell.tsx b/features/rewards/components/rewardsTable/RewardsTableHeaderCell.tsx new file mode 100644 index 000000000..78b960c02 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableHeaderCell.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react'; +import { Th } from '@lidofinance/lido-ui'; +import EthSymbol from 'features/rewards/components/EthSymbol'; + +import { RewardsTableHeaderCellProps } from './types'; + +export const RewardsTableHeaderCell: FC = ( + props, +) => { + const { value, field, currency, cellConfig } = props; + + const showEthIcon = field === 'change' || field === 'balance'; + const showFiatIcon = field === 'currencyChange'; + + return ( + + {showEthIcon && } + {showFiatIcon && {currency.symbol} } + {value} + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableLoader.tsx b/features/rewards/components/rewardsTable/RewardsTableLoader.tsx new file mode 100644 index 000000000..8c9627aa0 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableLoader.tsx @@ -0,0 +1,17 @@ +import React, { FC } from 'react'; + +import { + LoaderWrapperStyle, + ContentStyle, + LoaderStyle, +} from './RewardsTableLoaderStyles'; + +export const RewardsTableLoader: FC> = ( + props, +) => ( + + + + + +); diff --git a/features/rewards/components/rewardsTable/RewardsTableLoaderStyles.ts b/features/rewards/components/rewardsTable/RewardsTableLoaderStyles.ts new file mode 100644 index 000000000..9d63dacca --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableLoaderStyles.ts @@ -0,0 +1,51 @@ +import styled from 'styled-components'; +import { Loader } from '@lidofinance/lido-ui'; + +export const LoaderWrapperStyle = styled.div` + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 10; + animation: wrapper-loader 0.1s ease-out 0.25s 1 both; + + &:before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.8; + background: ${({ theme }) => theme.colors.foreground}; + } + + @keyframes wrapper-loader { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } + } +`; + +export const ContentStyle = styled.div` + display: flex; + height: 100%; + justify-content: center; +`; + +export const LoaderStyle = styled(Loader)` + position: relative; + margin: 10px; + background: ${({ theme }) => theme.colors.foreground}; +`; + +export const CenterLoaderStyle = styled(Loader)` + position: absolute; + margin: -12px; + top: 50%; + left: 50%; +`; diff --git a/features/rewards/components/rewardsTable/RewardsTablePagination.tsx b/features/rewards/components/rewardsTable/RewardsTablePagination.tsx new file mode 100644 index 000000000..2fa2aedb4 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTablePagination.tsx @@ -0,0 +1,12 @@ +import { FC } from 'react'; +import { Pagination, PaginationProps } from '@lidofinance/lido-ui'; + +import { RewardsTablePaginationWrapperStyle } from './RewardsTableStyles'; + +export const RewardsTablePagination: FC = (props) => { + return ( + + + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableRow.tsx b/features/rewards/components/rewardsTable/RewardsTableRow.tsx new file mode 100644 index 000000000..5811df0c6 --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableRow.tsx @@ -0,0 +1,26 @@ +import { Tr } from '@lidofinance/lido-ui'; +import { FC } from 'react'; + +import { RewardsTableCell } from './RewardsTableCell'; +import { RewardsTableRowProps } from './types'; + +export const RewardsTableRow: FC = ( + props, +): JSX.Element => { + const { columns, data, config, ...rest } = props; + + return ( + + {columns.map((column) => ( + + ))} + + ); +}; diff --git a/features/rewards/components/rewardsTable/RewardsTableStyles.ts b/features/rewards/components/rewardsTable/RewardsTableStyles.ts new file mode 100644 index 000000000..8cad3638e --- /dev/null +++ b/features/rewards/components/rewardsTable/RewardsTableStyles.ts @@ -0,0 +1,47 @@ +import styled from 'styled-components'; +import { Table } from '@lidofinance/lido-ui'; + +export const RewardsTableWrapperStyle = styled.div` + position: relative; + + width: calc(100% + ${({ theme }) => 2 * theme.spaceMap.xxl}px); + margin: 0 ${({ theme }) => -theme.spaceMap.xxl}px; + + ${({ theme }) => theme.mediaQueries.lg} { + width: calc(100% + ${({ theme }) => 2 * theme.spaceMap.lg}px); + margin: 0 ${({ theme }) => -theme.spaceMap.lg}px; + overflow-x: auto; + } +`; + +export const RewardsTableStyle = styled(Table)` + width: 100%; + border-collapse: collapse; + ${({ theme }) => theme.mediaQueries.lg} { + thead { + height: 0px; + border-top: none; + tr::before, + tr::after { + border-top: none; + } + border-top: none; + } + thead th { + display: none; + } + td[data-mobile='false'], + th[data-mobile='false'] { + display: none; + } + td[data-mobile-align='right'] { + text-align: right; + } + } +`; + +export const RewardsTablePaginationWrapperStyle = styled.div` + margin-top: ${({ theme }) => theme.spaceMap.md}px; + display: flex; + justify-content: center; +`; diff --git a/features/rewards/components/rewardsTable/contsnats.ts b/features/rewards/components/rewardsTable/contsnats.ts new file mode 100644 index 000000000..320efd162 --- /dev/null +++ b/features/rewards/components/rewardsTable/contsnats.ts @@ -0,0 +1,60 @@ +import { RewardsTableConfig } from './types'; + +export const REWARDS_TABLE_TEXT = { + headers: { + blockTime: 'Date', + type: 'Type', + change: 'Change', + currencyChange: 'Change', + apr: 'Apr', + balance: 'Balance', + }, +}; + +export const REWARDS_TABLE_CONFIG: RewardsTableConfig = { + columnsOrder: [ + { + field: 'blockTime', + name: REWARDS_TABLE_TEXT.headers.blockTime, + }, + { + field: 'type', + name: REWARDS_TABLE_TEXT.headers.type, + }, + { + field: 'change', + name: REWARDS_TABLE_TEXT.headers.change, + }, + { + field: 'currencyChange', + name: REWARDS_TABLE_TEXT.headers.currencyChange, + }, + { + field: 'apr', + name: REWARDS_TABLE_TEXT.headers.apr, + }, + { + field: 'balance', + name: REWARDS_TABLE_TEXT.headers.balance, + }, + ], + columnsConfig: { + change: { + ['data-mobile-align']: 'right', + }, + blockTime: { + ['data-mobile']: false, + }, + currencyChange: { + ['data-mobile']: false, + }, + apr: { + ['data-mobile']: false, + }, + balance: { + ['data-mobile']: false, + }, + }, + page: 1, + take: 10, +}; diff --git a/features/rewards/components/rewardsTable/index.ts b/features/rewards/components/rewardsTable/index.ts new file mode 100644 index 000000000..20b2c5f35 --- /dev/null +++ b/features/rewards/components/rewardsTable/index.ts @@ -0,0 +1 @@ +export * from './RewardsTable'; diff --git a/features/rewards/components/rewardsTable/types.ts b/features/rewards/components/rewardsTable/types.ts new file mode 100644 index 000000000..f434050c0 --- /dev/null +++ b/features/rewards/components/rewardsTable/types.ts @@ -0,0 +1,62 @@ +import type { Event } from 'features/rewards/types'; +import { ComponentProps } from 'react'; +import { type CurrencyType } from 'features/rewards/constants'; +import { type Td } from '@lidofinance/lido-ui'; + +export type RewardsTableConfig = { + columnsOrder: Column[]; + page: number; + take: number; + columnsConfig?: ColumnConfig; +}; + +type Column = { + field: keyof T; + name?: string; +}; + +type RewardsColumnsConfig = { + ['data-mobile']?: boolean; + ['data-mobile-align']?: 'left' | 'right'; +} & ComponentProps; + +type ColumnConfig = + | Partial> + | undefined; + +export interface RewardsTableProps { + data: Event[]; + currency: CurrencyType; + totalItems: number | undefined; + page: number; + setPage: (page: number) => void; + pending: boolean; +} + +export interface RewardsTableHeaderProps { + columns: Column[]; + config?: ColumnConfig; + currency: CurrencyType; +} + +export interface RewardsTableRowProps { + columns: Column[]; + data: Event; + config: ColumnConfig; + currency: CurrencyType; +} + +export interface RewardsTableCellProps { + value: Event[keyof Event]; + column: Column; + cellConfig?: RewardsColumnsConfig; + data: Event; + currency: CurrencyType; +} + +export interface RewardsTableHeaderCellProps { + value: string; + field: keyof Event; + currency: CurrencyType; + cellConfig?: RewardsColumnsConfig; +} diff --git a/features/rewards/components/stats/Item.tsx b/features/rewards/components/stats/Item.tsx new file mode 100644 index 000000000..f47158b84 --- /dev/null +++ b/features/rewards/components/stats/Item.tsx @@ -0,0 +1,16 @@ +import { Box } from '@lidofinance/lido-ui'; + +type BoxProps = React.ComponentProps; + +// TODO: refactoring to style files +export const Item = ({ children, ...rest }: BoxProps) => ( + + {children} + +); diff --git a/features/rewards/components/stats/Stat.tsx b/features/rewards/components/stats/Stat.tsx new file mode 100644 index 000000000..f7785f6d9 --- /dev/null +++ b/features/rewards/components/stats/Stat.tsx @@ -0,0 +1,20 @@ +import { Box } from '@lidofinance/lido-ui'; + +type BoxProps = React.ComponentProps; + +// TODO: refactoring to style files +export const Stat = ({ children, ...rest }: BoxProps) => ( + + {children} + +); diff --git a/features/rewards/components/stats/Stats.tsx b/features/rewards/components/stats/Stats.tsx new file mode 100644 index 000000000..f6b350798 --- /dev/null +++ b/features/rewards/components/stats/Stats.tsx @@ -0,0 +1,142 @@ +import { FC, useCallback, useEffect, useState } from 'react'; +import { Box, Link } from '@lidofinance/lido-ui'; +import EthSymbol from 'features/rewards/components/EthSymbol'; +import NumberFormat from 'features/rewards/components/NumberFormat'; +import type { BigNumber as EthersBigNumber } from 'ethers'; +import { constants } from 'ethers'; +import { dynamics } from 'config'; + +import { Big, BigDecimal } from 'features/rewards/helpers'; +import { ETHER } from 'features/rewards/constants'; + +import { useSDK, useTokenBalance } from '@lido-sdk/react'; +import { TOKENS, getTokenAddress } from '@lido-sdk/constants'; +import { stEthEthRequest } from 'features/rewards/fetchers/requesters'; + +import { Item } from './Item'; +import { Stat } from './Stat'; +import { Title } from './Title'; +import { StatsProps } from './types'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; + +// TODO: refactoring to style files +export const Stats: FC = (props) => { + const { address, data, currency, pending } = props; + + const [stEthEth, setStEthEth] = useState(); + const { chainId } = useSDK(); + + const steth = useTokenBalance( + getTokenAddress(chainId, TOKENS.STETH), + address, + STRATEGY_LAZY, + ); + + const getStEthEth = useCallback(async () => { + if (dynamics.defaultChain !== 1) { + setStEthEth(constants.WeiPerEther); + } else { + const stEthEth = await stEthEthRequest(); + + setStEthEth(stEthEth); + } + }, []); + + useEffect(() => { + getStEthEth(); + }, [getStEthEth]); + + const stEthBalanceParsed = steth.data && new Big(steth.data.toString()); + const stEthCurrencyBalance = + steth.data && + data && + new BigDecimal(steth.data.toString()) // Convert to right BN + .div(ETHER) + .times(data.stETHCurrencyPrice[currency.id]); + + return ( + <> + + stETH balance + + + + + + <Box display="inline-block" pr="3px"> + {currency.symbol} + </Box> + <NumberFormat + number={stEthCurrencyBalance} + currency + pending={pending} + /> + + + + stETH rewarded + + + + + + <Box display="inline-block" pr="3px"> + {currency.symbol} + </Box> + <NumberFormat + number={data?.totals.currencyRewards} + currency + pending={pending} + /> + + + + Average APR + + {parseFloat(data?.averageApr || '0') ? ( + + ) : ( + '-' + )} + + + <Link href="https://lido.fi/faq"> + <Box color="secondary" style={{ textDecoration: 'underline' }}> + More info + </Box> + </Link> + + + + stETH price + + + {currency.symbol} + + + + + <EthSymbol /> + <NumberFormat + number={stEthEth?.toString()} + StEthEth + pending={pending} + /> + + + + ); +}; diff --git a/features/rewards/components/stats/Title.tsx b/features/rewards/components/stats/Title.tsx new file mode 100644 index 000000000..14dd8176f --- /dev/null +++ b/features/rewards/components/stats/Title.tsx @@ -0,0 +1,22 @@ +import { Box } from '@lidofinance/lido-ui'; + +type BoxProps = React.ComponentProps; +type TitleProps = BoxProps & { + hideMobile?: boolean; +}; + +// TODO: refactoring to style files +export const Title = ({ children, hideMobile, ...rest }: TitleProps) => ( + + {children} + +); diff --git a/features/rewards/components/stats/index.ts b/features/rewards/components/stats/index.ts new file mode 100644 index 000000000..9f5965efe --- /dev/null +++ b/features/rewards/components/stats/index.ts @@ -0,0 +1,4 @@ +export * from './Item'; +export * from './Stat'; +export * from './Title'; +export * from './Stats'; diff --git a/features/rewards/components/stats/types.ts b/features/rewards/components/stats/types.ts new file mode 100644 index 000000000..c8cdc6868 --- /dev/null +++ b/features/rewards/components/stats/types.ts @@ -0,0 +1,9 @@ +import { Backend } from 'features/rewards/types'; +import { CurrencyType } from 'features/rewards/constants'; + +export type StatsProps = { + address: string; + data?: Backend; + currency: CurrencyType; + pending?: boolean; +}; diff --git a/features/rewards/components/statsWrapper/StatsWrapper.tsx b/features/rewards/components/statsWrapper/StatsWrapper.tsx new file mode 100644 index 000000000..c3e980af6 --- /dev/null +++ b/features/rewards/components/statsWrapper/StatsWrapper.tsx @@ -0,0 +1,11 @@ +import { FC } from 'react'; + +import { StatsWrapperStyle, StatsContentWrapper } from './StatsWrapperStyles'; + +export const StatsWrapper: FC = ({ children }) => { + return ( + + {children} + + ); +}; diff --git a/features/rewards/components/statsWrapper/StatsWrapperStyles.ts b/features/rewards/components/statsWrapper/StatsWrapperStyles.ts new file mode 100644 index 000000000..494243976 --- /dev/null +++ b/features/rewards/components/statsWrapper/StatsWrapperStyles.ts @@ -0,0 +1,11 @@ +import styled from 'styled-components'; +import { Block } from '@lidofinance/lido-ui'; + +export const StatsWrapperStyle = styled(Block)` + padding: 0; +`; + +export const StatsContentWrapper = styled(Block)` + display: flex; + flex-wrap: wrap; +`; diff --git a/features/rewards/components/statsWrapper/index.ts b/features/rewards/components/statsWrapper/index.ts new file mode 100644 index 000000000..25179eca5 --- /dev/null +++ b/features/rewards/components/statsWrapper/index.ts @@ -0,0 +1 @@ +export * from './StatsWrapper'; diff --git a/features/rewards/constants.ts b/features/rewards/constants.ts new file mode 100644 index 000000000..35a485b98 --- /dev/null +++ b/features/rewards/constants.ts @@ -0,0 +1,27 @@ +export const ETHER = '1e18'; + +export const HUMAN_DECIMALS = 8; +export const PRECISE_DECIMALS = 18; + +export const HUMAN_DECIMALS_PERCENT = 1; +export const PRECISE_DECIMALS_PERCENT = 5; + +export const HUMAN_DECIMALS_CURRENCY = 2; +export const PRECISE_DECIMALS_CURRENCY = 6; + +export const CURRENCIES = [ + { id: 'usd', code: 'USD', symbol: '$', name: 'United States Dollar' }, + { id: 'eur', code: 'EUR', symbol: '€', name: 'Euro' }, + { id: 'gbp', code: 'GBP', symbol: '£', name: 'Pound Sterling' }, +] as { id: string; code: string; symbol: string; name: string }[]; +export const DEFAULT_CURRENCY = CURRENCIES[0]; + +export const getCurrency = (id: string) => { + const found = CURRENCIES.find((item) => item.id === id); + if (!found) throw new Error('Currency not found'); + return found; +}; + +export type CurrencyType = typeof DEFAULT_CURRENCY; + +export const PAGE_ITEMS = 10; diff --git a/features/rewards/features/index.ts b/features/rewards/features/index.ts new file mode 100644 index 000000000..ed64ffeab --- /dev/null +++ b/features/rewards/features/index.ts @@ -0,0 +1,2 @@ +export * from './top-card'; +export * from './rewards-list'; diff --git a/features/rewards/features/rewards-list/index.ts b/features/rewards/features/rewards-list/index.ts new file mode 100644 index 000000000..114b493e8 --- /dev/null +++ b/features/rewards/features/rewards-list/index.ts @@ -0,0 +1 @@ +export * from './rewards-list'; diff --git a/features/rewards/features/rewards-list/rewards-list.tsx b/features/rewards/features/rewards-list/rewards-list.tsx new file mode 100644 index 000000000..76fde2d03 --- /dev/null +++ b/features/rewards/features/rewards-list/rewards-list.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; + +import { RewardsListWrapper } from 'features/rewards/components/rewardsListWrapper'; +import { RewardsListHeader } from 'features/rewards/components/rewardsListHeader'; +import { RewardsListContent } from 'features/rewards/components/rewardsListContent'; + +export const RewardsList: FC = () => { + return ( + + + + + ); +}; diff --git a/features/rewards/features/top-card/index.ts b/features/rewards/features/top-card/index.ts new file mode 100644 index 000000000..7f45914fc --- /dev/null +++ b/features/rewards/features/top-card/index.ts @@ -0,0 +1 @@ +export * from './top-card'; diff --git a/features/rewards/features/top-card/top-card.tsx b/features/rewards/features/top-card/top-card.tsx new file mode 100644 index 000000000..250a27391 --- /dev/null +++ b/features/rewards/features/top-card/top-card.tsx @@ -0,0 +1,47 @@ +import { FC } from 'react'; +import { Block, ThemeProvider, themeDark } from '@lidofinance/lido-ui'; +import { InputDescription } from 'features/rewards/components/inputDescription'; +import { AddressInput } from 'features/rewards/components/addressInput'; +import { StatsWrapper } from 'features/rewards/components/statsWrapper'; +import { Stats } from 'features/rewards/components/stats'; +import { InputWrapper } from 'features/rewards/components/inputWrapper'; +import { useRewardsHistory } from 'features/rewards/hooks'; + +const INPUT_DESC_TEXT = + 'Current balance may differ from last balance in the table due to rounding.'; + +export const TopCard: FC = () => { + const { + address, + isAddressResolving, + currencyObject, + data, + inputValue, + setInputValue, + initialLoading, + } = useRewardsHistory(); + + return ( + + + + + {INPUT_DESC_TEXT} + + + + + + + ); +}; diff --git a/features/rewards/fetchers/requesters/index.ts b/features/rewards/fetchers/requesters/index.ts new file mode 100644 index 000000000..e46c9311b --- /dev/null +++ b/features/rewards/fetchers/requesters/index.ts @@ -0,0 +1,2 @@ +export * from './json'; +export * from './rpc'; diff --git a/features/rewards/fetchers/requesters/json/backend.ts b/features/rewards/fetchers/requesters/json/backend.ts new file mode 100644 index 000000000..b27738277 --- /dev/null +++ b/features/rewards/fetchers/requesters/json/backend.ts @@ -0,0 +1,23 @@ +export type BackendQuery = { + address: string; + currency?: string; + skip?: number; + limit?: number; + archiveRate?: boolean; + onlyRewards?: boolean; +}; + +export const backendRequest = async (query: BackendQuery) => { + const params = new URLSearchParams(); + + Object.entries(query).forEach(([k, v]) => params.append(k, v.toString())); + + const requested = await fetch(`/api/rewards?${params.toString()}`); + + if (!requested.ok) { + const responded = await requested.json(); + throw new Error(responded?.message ?? requested.statusText); + } + + return await requested.json(); +}; diff --git a/features/rewards/fetchers/requesters/json/index.ts b/features/rewards/fetchers/requesters/json/index.ts new file mode 100644 index 000000000..581a3f8d8 --- /dev/null +++ b/features/rewards/fetchers/requesters/json/index.ts @@ -0,0 +1 @@ +export * from './backend'; diff --git a/features/rewards/fetchers/requesters/rpc/index.ts b/features/rewards/fetchers/requesters/rpc/index.ts new file mode 100644 index 000000000..a59537dbd --- /dev/null +++ b/features/rewards/fetchers/requesters/rpc/index.ts @@ -0,0 +1 @@ +export * from './stEthEth'; diff --git a/features/rewards/fetchers/requesters/rpc/stEthEth.ts b/features/rewards/fetchers/requesters/rpc/stEthEth.ts new file mode 100644 index 000000000..157a387e5 --- /dev/null +++ b/features/rewards/fetchers/requesters/rpc/stEthEth.ts @@ -0,0 +1,17 @@ +import { dynamics } from 'config'; +import rpcFetch from 'features/rewards/fetchers/rpcFetch'; + +import { constants } from 'ethers'; +import type { BigNumber as EthersBigNumber } from 'ethers'; + +const MAINNET_CURVE = '0xDC24316b9AE028F1497c275EB9192a3Ea0f67022'; + +/** +Return dynamic price only on mainnet +For testnet simply provide 1-1 ratio for UI to work +**/ + +export const stEthEthRequest = () => + dynamics.defaultChain === 1 + ? rpcFetch(MAINNET_CURVE, 'get_dy', 0, 1, String(10 ** 18)) + : constants.WeiPerEther; diff --git a/features/rewards/fetchers/rpcFetch.ts b/features/rewards/fetchers/rpcFetch.ts new file mode 100644 index 000000000..b0fa98b02 --- /dev/null +++ b/features/rewards/fetchers/rpcFetch.ts @@ -0,0 +1,145 @@ +import { Contract, providers } from 'ethers'; +import { isAddress } from 'ethers/lib/utils'; + +import { getBackendRPCPath } from 'config'; + +const chainId = 1; +const rpc = getBackendRPCPath(chainId); + +import { CHAINS } from '@lido-sdk/constants'; +import { BigNumber, ContractInterface } from 'ethers'; + +import STETH_ABI from 'abi/steth.abi.json'; + +import get from 'lodash/get'; + +const CURVE_ABI = [ + { + name: 'get_dy', + outputs: [{ type: 'uint256', name: '' }], + inputs: [ + { type: 'int128', name: 'i' }, + { type: 'int128', name: 'j' }, + { type: 'uint256', name: 'dx' }, + ], + stateMutability: 'view', + type: 'function', + }, +]; + +const TOKENS = { + ETH: 'ETH', + STETH: 'stETH', + WSTETH: 'wstETH', + CURVE: 'curve', + LDO: 'LDO', + LDO_REWARDS: 'LDO_Rewards', +} as const; +export type TOKENS = (typeof TOKENS)[keyof typeof TOKENS]; + +export const TOKENS_BY_CHAIN_ID = { + [CHAINS.Mainnet]: [TOKENS.STETH, TOKENS.WSTETH, TOKENS.CURVE], + [CHAINS.Rinkeby]: [TOKENS.STETH, TOKENS.WSTETH], + [CHAINS.Goerli]: [TOKENS.STETH, TOKENS.LDO_REWARDS, TOKENS.WSTETH], +} as const; + +export const TOKEN_ADDRESS_BY_CHAIN_ID = { + [CHAINS.Mainnet]: { + [TOKENS.STETH]: '0xae7ab96520de3a18e5e111b5eaab095312d7fe84', + [TOKENS.CURVE]: '0xDC24316b9AE028F1497c275EB9192a3Ea0f67022', + }, + [CHAINS.Ropsten]: {}, + [CHAINS.Rinkeby]: { + [TOKENS.STETH]: '0xbA453033d328bFdd7799a4643611b616D80ddd97', + }, + [CHAINS.Goerli]: { + [TOKENS.STETH]: '0x1643e812ae58766192cf7d2cf9567df2c37e9b7f', + }, + [CHAINS.Kovan]: {}, +} as const; + +export const TOKEN_ABI_BY_CHAIN_ID = { + [CHAINS.Mainnet]: { + [TOKENS.STETH]: STETH_ABI, + [TOKENS.CURVE]: CURVE_ABI, + }, + [CHAINS.Ropsten]: {}, + [CHAINS.Rinkeby]: { + [TOKENS.STETH]: STETH_ABI, + }, + [CHAINS.Goerli]: { + [TOKENS.STETH]: STETH_ABI, + }, + [CHAINS.Kovan]: {}, +} as const; + +export const getTokenAddress = ( + chainId: CHAINS, + token: TOKENS, +): string | undefined => get(TOKEN_ADDRESS_BY_CHAIN_ID, [chainId, token]); + +export const getTokenAbi = ( + chainId: CHAINS, + token: TOKENS, +): ContractInterface | undefined => + get(TOKEN_ABI_BY_CHAIN_ID, [chainId, token]); + +// Transforms into [[address, abi], ...] +export const getSwrTokenConfig = (chainId: CHAINS) => { + const tokens = get(TOKENS_BY_CHAIN_ID, chainId, []) as TOKENS[]; + const config = tokens.reduce<[string, ContractInterface][]>( + (arr, tokenName) => { + const address = getTokenAddress(chainId, tokenName); + const abi = getTokenAbi(chainId, tokenName); + if (address && abi) arr.push([address, abi]); + return arr; + }, + [], + ); + return config; +}; + +// Returns { address, abi } by tokenName +export const getTokenConfig = (chainId: CHAINS, tokenName: TOKENS) => ({ + address: getTokenAddress(chainId, tokenName), + abi: getTokenAbi(chainId, tokenName), +}); + +export const GAS_LIMITS_BY_TOKEN = { + [TOKENS.STETH]: { + submit: BigNumber.from(120000), + }, +}; + +// TODO: Migrate to typechain for properly methods and arguments typings +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const rpcFetcher = (...args: any[]): Promise => { + const library = new providers.StaticJsonRpcProvider(rpc, chainId); + library.pollingInterval = 30000; + const ABIs = new Map(getSwrTokenConfig(chainId)); + + const [arg1, arg2, ...params] = args; + + // it's a contract + if (isAddress(arg1)) { + if (!ABIs) throw new Error('ABI repo not found'); + if (!ABIs.get) throw new Error("ABI repo isn't a Map"); + + const address = arg1; + const method = arg2; + const abi = ABIs.get(address); + + if (!abi) throw new Error(`ABI not found for ${address}`); + const contract = new Contract(address, abi, library); + return contract[method](...params); + } + + // it's a eth call + const method = arg1; + + // TODO: Migrate to typechain for properly methods and arguments typings + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (library as any)[method](arg2, ...params); +}; + +export default rpcFetcher; diff --git a/features/rewards/helpers/big.ts b/features/rewards/helpers/big.ts new file mode 100644 index 000000000..45f4ca678 --- /dev/null +++ b/features/rewards/helpers/big.ts @@ -0,0 +1,47 @@ +import { BigNumber } from 'bignumber.js'; +import { PRECISE_DECIMALS } from 'features/rewards/constants'; + +// TODO: change to general solution + +// Default WEI Solidity-like instance +class Big extends BigNumber.clone() {} + +// Special non-int mode for ETH amounts, APR and other final calculations +class BigDecimal extends BigNumber.clone() {} + +// Behave as ints-only until we are in BigDecimal mode +Big.set({ DECIMAL_PLACES: 0 }); +BigDecimal.set({ DECIMAL_PLACES: PRECISE_DECIMALS }); + +// Match solidity calculations by default +Big.set({ ROUNDING_MODE: BigNumber.ROUND_DOWN }); +BigDecimal.set({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP }); + +// Never output scientific notations, we don't want our users to see them +Big.set({ EXPONENTIAL_AT: [-70000000, 210000000] }); +BigDecimal.set({ EXPONENTIAL_AT: [-70000000, 210000000] }); + +// Formatting for human readability +BigDecimal.set({ + FORMAT: { + // string to prepend + prefix: '', + // decimal separator + decimalSeparator: '.', + // grouping separator of the integer part + groupSeparator: ',', + // primary grouping size of the integer part + groupSize: 3, + // secondary grouping size of the integer part + secondaryGroupSize: 0, + // grouping separator of the fraction part + fractionGroupSeparator: ' ', + // grouping size of the fraction part + fractionGroupSize: 0, + // string to append + suffix: '', + }, +}); + +// BigNumber is here to highlight a universal type between the two instances +export { Big, BigDecimal, type BigNumber }; diff --git a/features/rewards/helpers/index.ts b/features/rewards/helpers/index.ts new file mode 100644 index 000000000..2c9e668fd --- /dev/null +++ b/features/rewards/helpers/index.ts @@ -0,0 +1 @@ +export * from './big'; diff --git a/features/rewards/hooks/index.ts b/features/rewards/hooks/index.ts new file mode 100644 index 000000000..30a9b2134 --- /dev/null +++ b/features/rewards/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './useRewardsHistory'; +export * from './useRewardsDataLoad'; +export * from './useGetCurrentAddress'; diff --git a/features/rewards/hooks/useGetCurrentAddress.ts b/features/rewards/hooks/useGetCurrentAddress.ts new file mode 100644 index 000000000..2e9844693 --- /dev/null +++ b/features/rewards/hooks/useGetCurrentAddress.ts @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useRouter } from 'next/router'; +import { useSDK } from '@lido-sdk/react'; +import debounce from 'lodash/debounce'; +import { resolveEns, isValidEns, isValidAddress } from 'features/rewards/utils'; + +type UseGetCurrentAddress = () => { + address: string; + inputValue: string; + isAddressResolving: boolean; + setInputValue: (value: string) => void; +}; + +export const useGetCurrentAddress: UseGetCurrentAddress = () => { + const [inputValue, setInputValueState] = useState(''); + const setInputValue = useCallback((value: string) => { + setInputValueState(value.trim()); + }, []); + const [isAddressResolving, setIsAddressResolving] = useState(false); + const [address, setAddress] = useState(''); + + const { account } = useSDK(); + const { isReady, query } = useRouter(); + + const getEnsAddress = useCallback(async (value: string) => { + setAddress(''); + + setIsAddressResolving(true); + const result = await resolveEns(value); + setIsAddressResolving(false); + + if (result) setAddress(result); + }, []); + + const resolveInputValue = useMemo( + () => + debounce(async (value: string) => { + if (value && isValidEns(value)) { + await getEnsAddress(value); + } else if (isValidAddress(value)) { + setAddress(value); + } else { + setAddress(''); + } + }, 200), + [getEnsAddress, setAddress], + ); + + useEffect(() => { + resolveInputValue(inputValue); + }, [resolveInputValue, inputValue]); + + // Pick up an address + + useEffect(() => { + if (isReady) { + const queryAddr = Array.isArray(query.address) + ? query.address[0] + : query.address; + // From query parameters, more important + if (queryAddr) { + setInputValue(queryAddr); + return; + } + // From a connected wallet + if (account) setInputValue(account); + } + }, [account, query.address, isReady, setInputValue]); + + return { + address, + inputValue, + isAddressResolving, + setInputValue, + }; +}; diff --git a/features/rewards/hooks/useRewardsDataLoad.ts b/features/rewards/hooks/useRewardsDataLoad.ts new file mode 100644 index 000000000..6f41da2c4 --- /dev/null +++ b/features/rewards/hooks/useRewardsDataLoad.ts @@ -0,0 +1,69 @@ +import { Backend } from 'features/rewards/types'; +import { useEffect, useRef } from 'react'; +import { useLidoSWR } from 'shared/hooks'; +import { swrAbortableMiddleware } from 'utils'; + +type UseRewardsDataLoad = (props: { + address: string; + currency: string; + isOnlyRewards: boolean; + isUseArchiveExchangeRate: boolean; + skip: number; + limit: number; +}) => { + data?: Backend; + error?: unknown; + loading: boolean; + initialLoading: boolean; + isLagging: boolean; +}; + +export const useRewardsDataLoad: UseRewardsDataLoad = (props) => { + const { + address, + currency, + isOnlyRewards, + isUseArchiveExchangeRate, + skip, + limit, + } = props; + + const laggyDataRef = useRef(); + + const requestOptions = { + address, + currency, + onlyRewards: isOnlyRewards, + archiveRate: isUseArchiveExchangeRate, + skip, + limit, + }; + + const params = new URLSearchParams(); + Object.entries(requestOptions).forEach(([k, v]) => + params.append(k, v.toString()), + ); + const { data, ...rest } = useLidoSWR( + address ? `/api/rewards?${params.toString()}` : null, + { + shouldRetryOnError: false, + revalidateOnFocus: false, + use: [swrAbortableMiddleware], + }, + ); + + useEffect(() => { + if (data !== undefined) { + laggyDataRef.current = data; + } + }, [data]); + + // Return to previous data if current data is not defined. + const dataOrLaggyData = data === undefined ? laggyDataRef.current : data; + + // Shows previous data. + const isLagging = + !!address && data === undefined && laggyDataRef.current !== undefined; + + return { ...rest, isLagging, data: dataOrLaggyData }; +}; diff --git a/features/rewards/hooks/useRewardsHistory.ts b/features/rewards/hooks/useRewardsHistory.ts new file mode 100644 index 000000000..4e02c6141 --- /dev/null +++ b/features/rewards/hooks/useRewardsHistory.ts @@ -0,0 +1,9 @@ +import { + RewardsHistoryContext, + RewardsHistoryValue, +} from 'providers/rewardsHistory'; +import { useContext } from 'react'; + +export const useRewardsHistory = (): RewardsHistoryValue => { + return useContext(RewardsHistoryContext); +}; diff --git a/features/rewards/types/Backend.ts b/features/rewards/types/Backend.ts new file mode 100644 index 000000000..1f248b1e5 --- /dev/null +++ b/features/rewards/types/Backend.ts @@ -0,0 +1,15 @@ +import { Event } from '.'; + +export type Backend = { + events: Event[]; + totals: { + ethRewards: number; + currencyRewards: number; + }; + averageApr: string; + ethToStEthRatio: number; + stETHCurrencyPrice: { + [key: string]: number; + }; + totalItems: number; +}; diff --git a/features/rewards/types/Event.ts b/features/rewards/types/Event.ts new file mode 100644 index 000000000..8c936ea5e --- /dev/null +++ b/features/rewards/types/Event.ts @@ -0,0 +1,18 @@ +import { LidoSubmission, LidoTransfer, TotalReward } from '.'; + +export type SubgraphData = LidoSubmission | LidoTransfer | TotalReward; + +export type AdditionalData = { + type: string; + change: string; + currencyChange?: string; + apr?: string; + balance: string; + direction?: string; + epochDays?: string; + epochFullDays?: string; + rewards?: string; + reportShares?: string; +}; + +export type Event = SubgraphData & AdditionalData; diff --git a/features/rewards/types/LidoSubmission.ts b/features/rewards/types/LidoSubmission.ts new file mode 100644 index 000000000..9607dea22 --- /dev/null +++ b/features/rewards/types/LidoSubmission.ts @@ -0,0 +1,24 @@ +export type LidoSubmission = { + id: string; + + sender: string; + amount: string; + + shares: string; + sharesBefore: string; + sharesAfter: string; + + totalPooledEtherBefore: string; + totalPooledEtherAfter: string; + totalSharesBefore: string; + totalSharesAfter: string; + + balanceAfter: string; + + block: string; + blockTime: string; + transactionHash: string; + transactionIndex: string; + logIndex: string; + transactionLogIndex: string; +}; diff --git a/features/rewards/types/LidoTransfer.ts b/features/rewards/types/LidoTransfer.ts new file mode 100644 index 000000000..fbaba9484 --- /dev/null +++ b/features/rewards/types/LidoTransfer.ts @@ -0,0 +1,28 @@ +export type LidoTransfer = { + id: string; + + from: string; + to: string; + value: string; + + shares: string; + sharesBeforeDecrease: string; + sharesAfterDecrease: string; + sharesBeforeIncrease: string; + sharesAfterIncrease: string; + + totalPooledEther: string; + totalShares: string; + + balanceAfterDecrease: string; + balanceAfterIncrease: string; + + mintWithoutSubmission: string; + + block: string; + blockTime: string; + transactionHash: string; + transactionIndex: string; + logIndex: string; + transactionLogIndex: string; +}; diff --git a/features/rewards/types/TotalRewards.ts b/features/rewards/types/TotalRewards.ts new file mode 100644 index 000000000..bb1ecd458 --- /dev/null +++ b/features/rewards/types/TotalRewards.ts @@ -0,0 +1,13 @@ +export type TotalReward = { + id: string; + + totalPooledEtherBefore: string; + totalPooledEtherAfter: string; + totalSharesBefore: string; + totalSharesAfter: string; + + block: string; + blockTime: string; + logIndex: string; + transactionHash: string; +}; diff --git a/features/rewards/types/TotalRewardsItem.ts b/features/rewards/types/TotalRewardsItem.ts new file mode 100644 index 000000000..7491dfefb --- /dev/null +++ b/features/rewards/types/TotalRewardsItem.ts @@ -0,0 +1,12 @@ +export type TotalRewardsItem = { + id: string; + + totalPooledEtherBefore: string; + totalPooledEtherAfter: string; + totalSharesBefore: string; + totalSharesAfter: string; + + block: string; + blockTime: string; + logIndex: string; +}; diff --git a/features/rewards/types/index.ts b/features/rewards/types/index.ts new file mode 100644 index 000000000..b0bede11f --- /dev/null +++ b/features/rewards/types/index.ts @@ -0,0 +1,5 @@ +export * from './LidoSubmission'; +export * from './LidoTransfer'; +export * from './Event'; +export * from './TotalRewards'; +export * from './Backend'; diff --git a/features/rewards/utils/addressValidation.ts b/features/rewards/utils/addressValidation.ts new file mode 100644 index 000000000..f40ad81be --- /dev/null +++ b/features/rewards/utils/addressValidation.ts @@ -0,0 +1,11 @@ +import { ethers } from 'ethers'; + +const regex = new RegExp('[-a-zA-Z0-9@._]{1,256}.eth'); + +export const isValidAddress = (address: string) => + ethers.utils.isAddress(address); + +export const isValidEns = (ens: string) => regex.test(ens); + +export const isValidAnyAddress = (input: string) => + isValidAddress(input) || isValidEns(input); diff --git a/features/rewards/utils/genExportData.ts b/features/rewards/utils/genExportData.ts new file mode 100644 index 000000000..dcf633a68 --- /dev/null +++ b/features/rewards/utils/genExportData.ts @@ -0,0 +1,20 @@ +import { weiToEther } from 'features/rewards/utils'; +import type { Event } from 'features/rewards/types'; +import type { CurrencyType } from 'features/rewards/constants'; + +import { fromUnixTime } from 'date-fns'; + +export const genExportData = (currency: CurrencyType, data: Event[] | null) => + data + ? data.map((item) => ({ + date: fromUnixTime(parseInt(item.blockTime)), + type: item.type, + direction: item.direction, + change: weiToEther(item.change).toString(), + change_wei: item.change.toString(), + [`change_${currency.code}`]: item.currencyChange?.toString(), + apr: item.apr, + balance: weiToEther(item.balance).toString(), + balance_wei: item.balance, + })) + : []; diff --git a/features/rewards/utils/index.ts b/features/rewards/utils/index.ts new file mode 100644 index 000000000..b5fe59d21 --- /dev/null +++ b/features/rewards/utils/index.ts @@ -0,0 +1,6 @@ +export * from './numberFormatting'; +export * from './genExportData'; +export * from './resolveEns'; +export * from './addressValidation'; +export * from './stringFormatting'; +export * from './saveAsCSV'; diff --git a/features/rewards/utils/numberFormatting.ts b/features/rewards/utils/numberFormatting.ts new file mode 100644 index 000000000..496d5e3bb --- /dev/null +++ b/features/rewards/utils/numberFormatting.ts @@ -0,0 +1,89 @@ +import { BigDecimal } from 'features/rewards/helpers'; +import { + ETHER, + HUMAN_DECIMALS, + PRECISE_DECIMALS, + HUMAN_DECIMALS_PERCENT, + PRECISE_DECIMALS_PERCENT, + HUMAN_DECIMALS_CURRENCY, + PRECISE_DECIMALS_CURRENCY, +} from 'features/rewards/constants'; + +import type { BigNumber } from 'features/rewards/helpers'; + +// TODO: change to general solution + +export const weiToEther = (wei: BigNumber | string) => + new BigDecimal(wei).div(ETHER); + +export const formatWEI = (input: BigNumber, manyDigits: boolean) => { + const decimals = manyDigits ? PRECISE_DECIMALS : HUMAN_DECIMALS; + const inWei = weiToEther(input).decimalPlaces(decimals); + + return inWei.toFormat(); +}; + +export const formatETH = (input: BigNumber, manyDigits: boolean) => { + const decimals = manyDigits ? PRECISE_DECIMALS : HUMAN_DECIMALS; + const inWei = input.decimalPlaces(decimals); + + return inWei.toFormat(); +}; + +// ETH-stETH ratio formatting +export const formatStEthEth = (stEthEth: BigNumber, manyDigits: boolean) => { + const ratio = new BigDecimal(1) + .div(new BigDecimal(stEthEth).div(ETHER)) + .times(ETHER); + + return formatWEI(ratio, manyDigits); +}; + +export const simpleFormatCurrency = ( + input: BigNumber, + manyDigits: boolean, + decimalOverride?: number, +) => { + const decimals = decimalOverride + ? HUMAN_DECIMALS_CURRENCY + decimalOverride + : manyDigits + ? PRECISE_DECIMALS_CURRENCY + : HUMAN_DECIMALS_CURRENCY; + + const options = { + currency: 'USD', // TODO: make dynamic if beneficial + maximumFractionDigits: decimals, + }; + + return new Intl.NumberFormat('en-GB', options).format(input.toNumber()); +}; + +export const formatCurrency = ( + input: BigNumber, + manyDigits: boolean, + decimalOverride?: number, +): string | T => { + // Early sanity exit + if (decimalOverride && decimalOverride >= 10) { + return '0'; + } + + const formatted = simpleFormatCurrency(input, manyDigits, decimalOverride); + if (formatted !== '0') { + return formatted; + } else { + return formatCurrency( + input, + manyDigits, + decimalOverride ? decimalOverride + 1 : 1, + ); + } +}; + +export const formatPercentage = (input: BigNumber, manyDigits: boolean) => { + const decimals = manyDigits + ? PRECISE_DECIMALS_PERCENT + : HUMAN_DECIMALS_PERCENT; + + return input.decimalPlaces(decimals).toString() + '%'; +}; diff --git a/features/rewards/utils/resolveEns.ts b/features/rewards/utils/resolveEns.ts new file mode 100644 index 000000000..a288842de --- /dev/null +++ b/features/rewards/utils/resolveEns.ts @@ -0,0 +1,10 @@ +import { getStaticRpcBatchProvider } from '@lido-sdk/providers'; + +import { dynamics, getBackendRPCPath } from 'config'; + +const rpc = getBackendRPCPath(dynamics.defaultChain); + +export const resolveEns = async (name: string | Promise) => { + const provider = getStaticRpcBatchProvider(dynamics.defaultChain, rpc); + return await provider.resolveName(name); +}; diff --git a/features/rewards/utils/saveAsCSV.ts b/features/rewards/utils/saveAsCSV.ts new file mode 100644 index 000000000..f108bd40e --- /dev/null +++ b/features/rewards/utils/saveAsCSV.ts @@ -0,0 +1,35 @@ +type SomeObj = { + [key: string]: unknown; +}; + +const objToCSV = (objArray: SomeObj[]) => { + const firstItem = objArray[0]; + const header = Object.keys(firstItem).toString(); + + const data = objArray.map((object) => Object.values(object).toString()); + + return [header, ...data].join('\n'); +}; + +export const saveAsCSV = (data: SomeObj[], fileName = 'Lido Rewards') => { + if (!data.length) { + return; + } + + const csv = objToCSV(data); + + const exportedFilenmae = fileName + '.csv'; + + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + + link.setAttribute('href', url); + link.setAttribute('download', exportedFilenmae); + link.style.visibility = 'hidden'; + + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); +}; diff --git a/features/rewards/utils/stringFormatting.ts b/features/rewards/utils/stringFormatting.ts new file mode 100644 index 000000000..8b493d830 --- /dev/null +++ b/features/rewards/utils/stringFormatting.ts @@ -0,0 +1,2 @@ +export const capitalize = (str: string) => + str.charAt(0).toUpperCase() + str.slice(1); diff --git a/features/withdrawals/claim/form/bunker-info.tsx b/features/withdrawals/claim/form/bunker-info.tsx new file mode 100644 index 000000000..2db81ae55 --- /dev/null +++ b/features/withdrawals/claim/form/bunker-info.tsx @@ -0,0 +1,11 @@ +import { InfoBoxStyled } from 'features/withdrawals/shared'; + +export const BunkerInfo = () => { + return ( + + Lido protocol is in "Bunker mode". The withdrawal requests are + slowed down until the consequences of the incident that caused + "Bunker mode" are not resolved. + + ); +}; diff --git a/features/withdrawals/claim/form/claim-form-footer-sticky.tsx b/features/withdrawals/claim/form/claim-form-footer-sticky.tsx new file mode 100644 index 000000000..3871bec29 --- /dev/null +++ b/features/withdrawals/claim/form/claim-form-footer-sticky.tsx @@ -0,0 +1,161 @@ +import { useCallback, useRef } from 'react'; +import { useForceUpdate } from 'shared/hooks/useForceUpdate'; + +import { LayoutEffectSsrDelayed } from 'shared/components/layout-effect-ssr-delayed'; +import { + NAV_MOBILE_HEIGHT, + NAV_MOBILE_MAX_WIDTH, +} from 'shared/components/header/components/navigation/styles'; +import { + ClaimFormFooter, + ClaimFormFooterWrapper, + ClaimFooterBodyEnder, +} from './styles'; +import { getScreenSize } from 'utils/getScreenSize'; +import { + REQUESTS_LIST_MIN_HEIGHT, + REQUESTS_LIST_ITEM_SIZE, +} from '../requests-list/styles'; + +// Adding 2/3 of item size to make next item slightly visible +// so user can understand that there is scrollable list +const STICK_CHECKPOINT_OFFSET = + REQUESTS_LIST_MIN_HEIGHT + Math.floor(REQUESTS_LIST_ITEM_SIZE * 0.66); + +type ScrollState = { + isSticked: boolean; + footerShift: number; +}; + +type ScrollStateSetter = < + F extends keyof ScrollState, + V extends ScrollState[F], +>( + field: F, + value: V, +) => void; + +type ClaimFormFooterStickyProps = { + isEnabled: boolean; + refRequests: React.RefObject; + positionDeps: unknown[]; +}; + +export const ClaimFormFooterSticky: React.FC = ({ + isEnabled, + refRequests, + positionDeps, + children, +}) => { + const forceUpdate = useForceUpdate(); + const refFooter = useRef(null); + + // Need to keep `scrollState` object mutable to make + // frequent operations performant during scroll + const { current: scrollState } = useRef({ + isSticked: false, + footerShift: -1, + }); + + const setStateAndUpdate = useCallback( + function (field, value) { + scrollState[field] = value; + forceUpdate(); + }, + [scrollState, forceUpdate], + ); + + const updatePosition = useCallback(() => { + const elRequests = refRequests.current; + const elFooter = refFooter.current; + + if (!elRequests || !elFooter) return; + + // Sizes + const { y: screenH, x: screenW } = getScreenSize(); + const rectRequests = elRequests.getBoundingClientRect(); + const rectFooter = elFooter.getBoundingClientRect(); + const footerHeight = elFooter.clientHeight; + const menuOffset = screenW < NAV_MOBILE_MAX_WIDTH ? NAV_MOBILE_HEIGHT : 0; + + // Calcs + const checkpointStart = screenH - STICK_CHECKPOINT_OFFSET - menuOffset; + const distanceFromElStart = -Math.min( + 0, + rectRequests.top - checkpointStart, + ); + + const checkpointEnd = rectFooter.bottom - screenH + menuOffset; + + // Apply + if (distanceFromElStart > 0 && Math.round(checkpointEnd) >= 0) { + if (!scrollState.isSticked) { + setStateAndUpdate('isSticked', true); + } + + const currFooterShift = + footerHeight - Math.min(distanceFromElStart, footerHeight) - menuOffset; + if (currFooterShift !== scrollState.footerShift) { + elFooter.style.setProperty('bottom', `${-currFooterShift}px`); + scrollState.footerShift = currFooterShift; + } + } else { + if (scrollState.isSticked) { + scrollState.footerShift = -1; + elFooter.style.removeProperty('bottom'); + setStateAndUpdate('isSticked', false); + } + } + // Eslint is disabled here to omit mutable deps for better performance + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const positionInitializatorEffect = useCallback(() => { + if (!isEnabled) return; + const elFooter = refFooter.current; + + // Event subscriptions + window.addEventListener('resize', updatePosition, { passive: true }); + window.addEventListener('scroll', updatePosition, { passive: true }); + + return () => { + window.removeEventListener('resize', updatePosition); + window.removeEventListener('scroll', updatePosition); + scrollState.footerShift = -1; + if (elFooter) elFooter.style.removeProperty('bottom'); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEnabled]); + + const positionRevalidionEffect = useCallback(() => { + if (!isEnabled) return; + updatePosition(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEnabled]); + + return ( + <> + + + +
+
+
+ + {children} + + + + + + ); +}; diff --git a/features/withdrawals/claim/form/claim-form.tsx b/features/withdrawals/claim/form/claim-form.tsx new file mode 100644 index 000000000..d3b8e11e0 --- /dev/null +++ b/features/withdrawals/claim/form/claim-form.tsx @@ -0,0 +1,100 @@ +import { useCallback, useRef, useState } from 'react'; +import { useWeb3 } from '@reef-knot/web3-react'; +import { BigNumber } from 'ethers'; + +import { FormatToken } from 'shared/formatters'; +import { Connect } from 'shared/wallet'; + +import { BunkerInfo } from './bunker-info'; +import { useClaim } from 'features/withdrawals/hooks'; +import { useClaimTxPrice } from 'features/withdrawals/hooks/useWithdrawTxPrice'; +import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; + +import { Button, DataTableRow } from '@lidofinance/lido-ui'; +import { RequestsList } from '../requests-list/requests-list'; +import { ClaimFormBody } from './styles'; +import { ClaimFormFooterSticky } from './claim-form-footer-sticky'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; + +export const ClaimForm = () => { + const refRequests = useRef(null); + + const { active } = useWeb3(); + const { dispatchModalState } = useTransactionModal(); + const { ethToClaim, claimSelection } = useClaimData(); + const { isBunker } = useWithdrawals(); + const { requests, loading: isLoading } = useClaimData(); + const isEmpty = !isLoading && requests.length === 0; + + const [isSubmitting, setIsSubmitting] = useState(false); + const { claimTxPriceInUsd, loading: claimTxPriceLoading } = useClaimTxPrice(); + const claimMutation = useClaim(); + + const claim = useCallback(() => { + // fix (re)start point + const startTx = async () => { + setIsSubmitting(true); + try { + claimMutation(claimSelection.sortedSelectedRequests); + } finally { + setIsSubmitting(false); + } + }; + // send it to state + dispatchModalState({ type: 'set_starTx_callback', callback: startTx }); + // start flow + startTx(); + return; + }, [ + dispatchModalState, + claimMutation, + claimSelection.sortedSelectedRequests, + ]); + + const claimButtonAmount = ethToClaim?.lte(BigNumber.from(0)) ? null : ( + + ); + + return ( + <> + + {isBunker && } +
+ +
+
+ + {active ? ( + + ) : ( + + )} + + ${claimTxPriceInUsd?.toFixed(2)} + + + + ); +}; diff --git a/features/withdrawals/claim/form/index.ts b/features/withdrawals/claim/form/index.ts new file mode 100644 index 000000000..b6c442c84 --- /dev/null +++ b/features/withdrawals/claim/form/index.ts @@ -0,0 +1 @@ +export * from './claim-form'; diff --git a/features/withdrawals/claim/form/styles.ts b/features/withdrawals/claim/form/styles.ts new file mode 100644 index 000000000..314431a95 --- /dev/null +++ b/features/withdrawals/claim/form/styles.ts @@ -0,0 +1,102 @@ +import styled, { css } from 'styled-components'; +import { Block, Button } from '@lidofinance/lido-ui'; + +export const EditClaimButtonStyled = styled(Button)` + margin-bottom: ${({ theme }) => theme.spaceMap.md}px; +`; + +export const ClaimFormBody = styled(Block)` + margin-bottom: 0; + padding-bottom: 0; + border-bottom-left-radius: 0; + border-bottom-right-radius: 0; +`; + +export const ClaimFooterBodyEnder = styled.div` + --r: ${({ theme }) => theme.borderRadiusesMap.md}px; + --r-1: ${({ theme }) => theme.borderRadiusesMap.md - 1}px; + --g: #0000 98%, #000; + /* It should be --lido-color-accentBorder, but it can't be used here because it has transparency */ + --border-color: var(--lido-color-foreground); + + position: absolute; + display: block; + top: calc((-1 * var(--r))); + left: ${({ theme }) => theme.spaceMap.xxl}px; + right: ${({ theme }) => theme.spaceMap.xxl}px; + height: var(--r); + mask-image: radial-gradient(var(--r-1) at var(--r) 0, var(--g)); + + ${({ theme }) => theme.mediaQueries.md} { + left: ${({ theme }) => theme.spaceMap.lg}px; + right: ${({ theme }) => theme.spaceMap.lg}px; + } + + & div:nth-child(1), + & div:nth-child(2) { + position: absolute; + top: 0; + width: var(--r); + height: var(--r); + background-color: var(--lido-color-foreground); + &:after { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: var(--r); + height: var(--r); + border-width: 1px; + border-color: var(--border-color); + border-style: solid; + } + } + + & div:nth-child(1) { + left: 0; + mask-image: radial-gradient(var(--r-1) at var(--r) 0, var(--g)); + &:after { + border-top-width: 0; + border-right-width: 0; + border-bottom-left-radius: var(--r); + } + } + + & div:nth-child(2) { + right: 0; + mask-image: radial-gradient(var(--r-1) at 0 0, var(--g)); + &:after { + border-top-width: 0; + border-left-width: 0; + border-bottom-right-radius: var(--r); + } + } + + & div:nth-child(3) { + position: absolute; + bottom: 0; + right: var(--r); + left: var(--r); + height: 1px; + background-color: var(--border-color); + } +`; + +export const ClaimFormFooterWrapper = styled.div<{ isSticked: boolean }>` + position: ${({ isSticked }) => (isSticked ? 'sticky' : 'relative')}; + bottom: 0; + ${({ isSticked }) => + isSticked && + css` + background-color: var(--lido-color-background); + `} +`; + +export const ClaimFormFooter = styled(Block)` + position: relative; + padding-top: ${({ theme }) => theme.spaceMap.lg}px; + border-top-left-radius: 0; + border-top-right-radius: 0; + background-color: var(--lido-color-foreground); +`; diff --git a/features/withdrawals/claim/index.ts b/features/withdrawals/claim/index.ts new file mode 100644 index 000000000..fff834278 --- /dev/null +++ b/features/withdrawals/claim/index.ts @@ -0,0 +1,2 @@ +export { ClaimWallet } from './wallet'; +export { ClaimForm } from './form'; diff --git a/features/withdrawals/claim/requests-list/index.ts b/features/withdrawals/claim/requests-list/index.ts new file mode 100644 index 000000000..42fa645d4 --- /dev/null +++ b/features/withdrawals/claim/requests-list/index.ts @@ -0,0 +1 @@ +export * from './requests-list'; diff --git a/features/withdrawals/claim/requests-list/request-item-status.tsx b/features/withdrawals/claim/requests-list/request-item-status.tsx new file mode 100644 index 000000000..0306c918d --- /dev/null +++ b/features/withdrawals/claim/requests-list/request-item-status.tsx @@ -0,0 +1,39 @@ +import { Tooltip } from '@lidofinance/lido-ui'; +import { useWaitingTime } from 'features/withdrawals/hooks/useWaitingTime'; +import { + RequestsStatusStyled, + DesktopStatus, + MobileStatusIcon, + RequestInfoIcon, +} from './styles'; +import { forwardRef } from 'react'; + +type RequestItemStatusProps = { status: 'ready' | 'pending' }; + +export const RequestStatus: React.FC = ({ status }) => { + if (status === 'pending') return ; + return ; +}; + +const RequestStatusPending: React.FC = () => { + const waitingTime = useWaitingTime(''); + return ( + + + + ); +}; + +const RequestStatusBody = forwardRef< + HTMLDivElement, + RequestItemStatusProps & React.ComponentProps<'div'> +>(({ status, ...props }, ref) => { + const statusText = status === 'ready' ? 'Ready to claim' : 'Pending'; + return ( + + {statusText} + + {status === 'pending' && } + + ); +}); diff --git a/features/withdrawals/claim/requests-list/request-item.tsx b/features/withdrawals/claim/requests-list/request-item.tsx new file mode 100644 index 000000000..95e526052 --- /dev/null +++ b/features/withdrawals/claim/requests-list/request-item.tsx @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; + +import { Checkbox, External } from '@lidofinance/lido-ui'; +import { FormatToken } from 'shared/formatters'; +import { RequestStyled, LinkStyled } from './styles'; + +import { getNFTUrl } from 'utils'; +import type { RequestStatusesUnion } from 'features/withdrawals/types/request-status'; + +import { RequestStatus } from './request-item-status'; + +type RequestItemProps = { + request: RequestStatusesUnion; +}; + +export const RequestItem: React.FC = ({ request }) => { + const { chainId } = useWeb3(); + const { claimSelection } = useClaimData(); + const { + isSelected: getIsSelected, + canSelectMore, + setSelected, + } = claimSelection; + const { isFinalized, stringId: tokenId } = request; + + const isSelected = getIsSelected(request.stringId); + const isDisabled = !isFinalized || (!isSelected && !canSelectMore); + + const amountValue = + 'claimableEth' in request ? request.claimableEth : request.amountOfStETH; + const symbol = 'claimableEth' in request ? 'ETH' : 'stETH'; + const label = ( + + ); + // const expectedEth = 'expectedEth' in request ? request.expectedEth : undefined + + const handleSelect = useCallback( + (e: React.ChangeEvent) => + setSelected(tokenId, e.currentTarget.checked), + [setSelected, tokenId], + ); + + return ( + + + {/* TODO: uncomment this when the design will be finalized*/} + {/* {!isFinalized && expectedEth && ( + <> +  ( + ) + + )} */} + + + + + + ); +}; diff --git a/features/withdrawals/claim/requests-list/requests-empty.tsx b/features/withdrawals/claim/requests-list/requests-empty.tsx new file mode 100644 index 000000000..729debb20 --- /dev/null +++ b/features/withdrawals/claim/requests-list/requests-empty.tsx @@ -0,0 +1,21 @@ +import { useWeb3 } from 'reef-knot/web3-react'; + +import { EmptyText, WrapperEmpty } from './styles'; + +export const RequestsEmpty = () => { + const { active } = useWeb3(); + + if (!active) { + return ( + + Connect wallet to see your withdrawal requests + + ); + } + + return ( + + No withdrawal requests detected. + + ); +}; diff --git a/features/withdrawals/claim/requests-list/requests-list.tsx b/features/withdrawals/claim/requests-list/requests-list.tsx new file mode 100644 index 000000000..8d92c17d1 --- /dev/null +++ b/features/withdrawals/claim/requests-list/requests-list.tsx @@ -0,0 +1,33 @@ +import { RequestItem } from './request-item'; +import { RequestsEmpty } from './requests-empty'; +import { Wrapper } from './styles'; +import { RequestsLoader } from './requests-loader'; +import { RequestStatusesUnion } from 'features/withdrawals/types/request-status'; + +type RequestsListProps = { + isLoading: boolean; + isEmpty: boolean; + requests: RequestStatusesUnion[]; +}; + +export const RequestsList: React.FC = ({ + isLoading, + isEmpty, + requests, +}) => { + if (isLoading) { + return ; + } + + if (isEmpty) { + return ; + } + + return ( + + {requests.map((request) => ( + + ))} + + ); +}; diff --git a/features/withdrawals/claim/requests-list/requests-loader.tsx b/features/withdrawals/claim/requests-list/requests-loader.tsx new file mode 100644 index 000000000..6a2d92799 --- /dev/null +++ b/features/withdrawals/claim/requests-list/requests-loader.tsx @@ -0,0 +1,22 @@ +import { Checkbox } from '@lidofinance/lido-ui'; +import { + WrapperLoader, + RequestStyled, + InlineLoaderStyled, + REQUESTS_LIST_LOADERS_COUNT, +} from './styles'; + +const LOADERS_SIZE_ARRAY = Array.from(Array(REQUESTS_LIST_LOADERS_COUNT)); + +export const RequestsLoader = () => { + return ( + + {LOADERS_SIZE_ARRAY.map((_, i) => ( + + + + + ))} + + ); +}; diff --git a/features/withdrawals/claim/requests-list/styles.ts b/features/withdrawals/claim/requests-list/styles.ts new file mode 100644 index 000000000..50487cd57 --- /dev/null +++ b/features/withdrawals/claim/requests-list/styles.ts @@ -0,0 +1,143 @@ +import styled from 'styled-components'; +import { InlineLoader, Link, ThemeName } from '@lidofinance/lido-ui'; + +import RequestReady from 'assets/icons/request-ready.svg'; +import RequestPending from 'assets/icons/request-pending.svg'; +import RequestInfo from 'assets/icons/request-info.svg'; + +export const REQUESTS_LIST_ITEM_SIZE = 57; +export const REQUESTS_LIST_LOADERS_COUNT = 3; +export const REQUESTS_LIST_MIN_HEIGHT = 3 * REQUESTS_LIST_ITEM_SIZE; + +export const Wrapper = styled.div` + border-radius: ${({ theme }) => theme.borderRadiusesMap.md}px + ${({ theme }) => theme.borderRadiusesMap.md}px 0 0; + border: 1px solid var(--lido-color-foreground); + border-bottom: none; + overflow: hidden; +`; + +export const EmptyText = styled.span` + margin: 0 auto; + justify-self: center; + align-self: center; +`; + +export const RequestStyled = styled.div<{ + $disabled?: boolean; + $loading?: boolean; +}>` + padding: ${({ theme }) => theme.spaceMap.md}px + ${({ theme }) => theme.spaceMap.lg}px; + padding-right: 12px; + border-bottom: 1px solid var(--lido-color-foreground); + background-color: ${({ theme }) => + theme.name === ThemeName.light ? '#F2F5F8' : '#2A2A31'}; + display: flex; + align-items: center; + height: ${REQUESTS_LIST_ITEM_SIZE}px; + justify-content: space-between; + width: 100%; + box-sizing: border-box; + &:last-child { + border-bottom-color: var(--lido-color-backgroundSecondary); + } + + ${({ $loading }) => $loading && `cursor: progress;`} + + a:visited { + color: var(--lido-color-primary); + } +`; + +type RequestProps = { + $variant: 'ready' | 'pending'; +}; + +export const RequestsStatusStyled = styled.div` + height: 24px; + margin-left: auto; + margin-right: 8px; + padding: 2px ${({ theme }) => theme.spaceMap.sm}px; + ${({ theme }) => theme.mediaQueries.sm} { + padding: 4px; + min-width: 24px; + justify-content: center; + } + gap: 8px; + border-radius: 48px; + display: flex; + align-items: center; + + background-color: ${({ $variant }) => + $variant === 'ready' + ? 'rgba(83, 186, 149, 0.16)' + : 'rgba(236, 134, 0, 0.16)'}; + + ${({ $variant }) => + $variant === 'pending' && + `&:hover { + background-color: rgba(236, 134, 0, 0.26); + }`} + + color: ${({ $variant }) => ($variant === 'ready' ? '#53BA95' : '#EC8600')}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +`; + +export const DesktopStatus = styled.span` + font-size: 12px; + font-weight: 600; + ${({ theme }) => theme.mediaQueries.sm} { + display: none; + } +`; + +export const MobileStatusIcon = styled.img.attrs( + ({ $variant }) => ({ + alt: $variant, + src: $variant === 'ready' ? RequestReady : RequestPending, + }), +)` + display: none; + width: 16px; + height: 16px; + ${({ theme }) => theme.mediaQueries.sm} { + display: block; + } +`; + +export const RequestInfoIcon = styled.img.attrs({ + alt: 'info', + src: RequestInfo, +})` + width: 16px; + height: 16px; +`; + +export const InlineLoaderStyled = styled(InlineLoader)` + margin-left: ${({ theme }) => theme.spaceMap.lg}px; +`; + +export const LinkStyled = styled(Link)` + display: flex; + width: 24px; + height: 24px; + + background: rgba(0, 163, 255, 0.1); + border-radius: 4px; + + &:hover { + background: rgba(0, 163, 255, 0.2); + } +`; + +export const WrapperEmpty = styled(Wrapper)` + display: flex; + height: ${REQUESTS_LIST_MIN_HEIGHT}px; +`; + +export const WrapperLoader = styled(Wrapper)` + height: ${REQUESTS_LIST_MIN_HEIGHT}px; +`; diff --git a/features/withdrawals/claim/tx-modal/index.ts b/features/withdrawals/claim/tx-modal/index.ts new file mode 100644 index 000000000..50978a0d2 --- /dev/null +++ b/features/withdrawals/claim/tx-modal/index.ts @@ -0,0 +1 @@ +export * from './tx-claim-modal'; diff --git a/features/withdrawals/claim/tx-modal/tx-claim-modal.tsx b/features/withdrawals/claim/tx-modal/tx-claim-modal.tsx new file mode 100644 index 000000000..b98bbd9ec --- /dev/null +++ b/features/withdrawals/claim/tx-modal/tx-claim-modal.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; +import { formatBalance } from 'utils'; + +import { + TxStageModal, + TxStagePending, + TxStageSuccess, + TxStageSign, + TxStageFail, + TX_STAGE, +} from 'features/withdrawals/shared/tx-stage-modal'; +import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; +import { + trackMatomoEvent, + MATOMO_CLICK_EVENTS_TYPES, +} from 'config/trackMatomoEvent'; + +export const TxClaimModal = () => { + const { + isModalOpen, + txStage, + requestAmount, + txHash, + errorText, + startTx, + dispatchModalState, + } = useTransactionModal(); + + const amountAsString = useMemo( + () => (requestAmount ? formatBalance(requestAmount, 4) : ''), + [requestAmount], + ); + + const successDescription = 'Claiming operation was successful'; + const successTitle = `${amountAsString} ETH has been claimed`; + + const pendingDescription = 'Awaiting block confirmation'; + const pendingTitle = `You are now claiming ${amountAsString} ETH`; + + const signDescription = 'Processing your request'; + const signTitle = `You are now claiming ${amountAsString} ETH`; + + const content = useMemo(() => { + switch (txStage) { + case TX_STAGE.SIGN: + return ; + case TX_STAGE.BLOCK: + return ( + + ); + case TX_STAGE.SUCCESS: + return ( + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.claimViewOnEtherscanSuccessTemplate, + ) + } + /> + ); + case TX_STAGE.FAIL: + return ( + { + startTx && startTx(); + }} + /> + ); + default: + return null; + } + }, [ + errorText, + pendingTitle, + signTitle, + startTx, + successTitle, + txHash, + txStage, + ]); + + return ( + dispatchModalState({ type: 'close_modal' })} + txStage={txStage} + > + {content} + + ); +}; diff --git a/features/withdrawals/claim/wallet/index.ts b/features/withdrawals/claim/wallet/index.ts new file mode 100644 index 000000000..3c5958cf6 --- /dev/null +++ b/features/withdrawals/claim/wallet/index.ts @@ -0,0 +1 @@ +export * from './wallet'; diff --git a/features/withdrawals/claim/wallet/wallet-availale-amount.tsx b/features/withdrawals/claim/wallet/wallet-availale-amount.tsx new file mode 100644 index 000000000..14b14e44c --- /dev/null +++ b/features/withdrawals/claim/wallet/wallet-availale-amount.tsx @@ -0,0 +1,24 @@ +import { CardBalance } from 'shared/wallet'; +import { FormatToken } from 'shared/formatters'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; + +export const WalletAvailableAmount = () => { + const { withdrawalRequestsData, loading } = useClaimData(); + + const availableAmount = ( + + ); + + return ( + + ); +}; diff --git a/features/withdrawals/claim/wallet/wallet-pending-amount.tsx b/features/withdrawals/claim/wallet/wallet-pending-amount.tsx new file mode 100644 index 000000000..57ae6535a --- /dev/null +++ b/features/withdrawals/claim/wallet/wallet-pending-amount.tsx @@ -0,0 +1,24 @@ +import { CardBalance } from 'shared/wallet'; +import { FormatToken } from 'shared/formatters'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; + +export const WalletPendingAmount = () => { + const { withdrawalRequestsData, loading } = useClaimData(); + + const pendingAmount = ( + + ); + + return ( + + ); +}; diff --git a/features/withdrawals/claim/wallet/wallet.tsx b/features/withdrawals/claim/wallet/wallet.tsx new file mode 100644 index 000000000..c7e0d3263 --- /dev/null +++ b/features/withdrawals/claim/wallet/wallet.tsx @@ -0,0 +1,39 @@ +import { memo } from 'react'; +import { Divider } from '@lidofinance/lido-ui'; +import { useWeb3 } from '@reef-knot/web3-react'; +import { useSDK } from '@lido-sdk/react'; + +import { CardAccount, CardRow, Fallback } from 'shared/wallet'; +import type { WalletComponentType } from 'shared/wallet/types'; +import { + WalletWrapperStyled, + WalletMyRequests, +} from 'features/withdrawals/shared'; + +import { WalletAvailableAmount } from './wallet-availale-amount'; +import { WalletPendingAmount } from './wallet-pending-amount'; + +export const WalletComponent = () => { + const { account } = useSDK(); + + return ( + <> + + + + + + + + + + + + + ); +}; + +export const ClaimWallet: WalletComponentType = memo((props) => { + const { active } = useWeb3(); + return active ? : ; +}); diff --git a/features/withdrawals/contexts/claim-data-context/index.tsx b/features/withdrawals/contexts/claim-data-context/index.tsx new file mode 100644 index 000000000..a8ea0be7c --- /dev/null +++ b/features/withdrawals/contexts/claim-data-context/index.tsx @@ -0,0 +1,70 @@ +import { FC, createContext, useMemo, useContext } from 'react'; +import { BigNumber } from 'ethers'; + +import { useWithdrawalRequests } from 'features/withdrawals/hooks'; +import { RequestStatusesUnion } from 'features/withdrawals/types/request-status'; + +import { useClaimSelection } from './useClaimSelection'; +import invariant from 'tiny-invariant'; + +const claimDataContext = createContext(null); +claimDataContext.displayName = 'ClaimDataContext'; + +export type ClaimDataValue = { + ethToClaim: BigNumber; + requests: RequestStatusesUnion[]; + claimSelection: ReturnType; + withdrawalRequestsData: ReturnType['data']; + loading: ReturnType['initialLoading']; + refetching: ReturnType['loading']; + update: ReturnType['update']; +}; + +export const ClaimDataProvider: FC = ({ children }) => { + const withdrawRequests = useWithdrawalRequests(); + const claimSelection = useClaimSelection( + withdrawRequests.data?.sortedClaimableRequests ?? null, + ); + + const ethToClaim = useMemo(() => { + return claimSelection.sortedSelectedRequests.reduce( + (eth, r) => eth.add(r.claimableEth), + BigNumber.from(0), + ); + }, [claimSelection.sortedSelectedRequests]); + + const requests = useMemo(() => { + return [ + ...(withdrawRequests.data?.sortedClaimableRequests ?? []), + ...(withdrawRequests.data?.pendingRequests ?? []), + ]; + }, [withdrawRequests.data]); + + const value: ClaimDataValue = useMemo(() => { + return { + withdrawalRequestsData: withdrawRequests.data, + get loading() { + return withdrawRequests.initialLoading; + }, + get refetching() { + return withdrawRequests.loading; + }, + update: withdrawRequests.update, + claimSelection, + requests, + ethToClaim, + }; + }, [claimSelection, withdrawRequests, ethToClaim, requests]); + + return ( + + {children} + + ); +}; + +export const useClaimData = () => { + const r = useContext(claimDataContext); + invariant(r, 'useClaimData was used outside of ClaimDataProvider'); + return r; +}; diff --git a/features/withdrawals/contexts/claim-data-context/useClaimSelection.ts b/features/withdrawals/contexts/claim-data-context/useClaimSelection.ts new file mode 100644 index 000000000..dd5cad370 --- /dev/null +++ b/features/withdrawals/contexts/claim-data-context/useClaimSelection.ts @@ -0,0 +1,106 @@ +import { type RequestStatusClaimable } from 'features/withdrawals/types/request-status'; +import { + MAX_REQUESTS_COUNT, + DEFAULT_CLAIM_REQUEST_SELECTED, + MAX_REQUESTS_COUNT_LEDGER_LIMIT, +} from 'features/withdrawals/withdrawals-constants'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useIsLedgerLive } from 'shared/hooks/useIsLedgerLive'; + +export const useClaimSelection = ( + claimableRequests: RequestStatusClaimable[] | null, +) => { + const isLedgerLive = useIsLedgerLive(); + const maxRequestCount = isLedgerLive + ? MAX_REQUESTS_COUNT_LEDGER_LIMIT + : MAX_REQUESTS_COUNT; + const [state, setSelectionState] = useState<{ + selection_set: Set; + }>({ selection_set: new Set() }); + + const claimableIdToIndex = useMemo(() => { + return ( + claimableRequests?.reduce((map, cur, i) => { + map[cur.stringId] = i; + return map; + }, {} as { [key: string]: number }) ?? {} + ); + }, [claimableRequests]); + + // it's ok to rebuild array because we cap selected at MAX_REQUEST_PER_TX + const sortedSelectedRequests = useMemo(() => { + if (!claimableRequests) return []; + return Array.from(state.selection_set.keys()) + .map((id) => claimableRequests[claimableIdToIndex[id]]) + .filter((r) => r) + .sort((aReq, bReq) => (aReq.id.gt(bReq.id) ? 1 : -1)); + }, [claimableRequests, claimableIdToIndex, state]); + + // because we get count from sortedSelectedRequests, we don't count stale ids from removed reqs + const selectedCount = sortedSelectedRequests.length; + + // stablish setters + const setSelected = useCallback( + (key: string, value: boolean) => { + setSelectionState((old) => { + if (value && selectedCount >= maxRequestCount) return old; + if (value) old.selection_set.add(key); + else old.selection_set.delete(key); + return { selection_set: old.selection_set }; + }); + }, + [selectedCount, maxRequestCount], + ); + + const setSelectedMany = useCallback( + (keys: string[]) => { + const freeSpace = maxRequestCount - selectedCount; + if (freeSpace <= 0) return; + setSelectionState((old) => { + keys.slice(0, freeSpace).forEach((k) => old.selection_set.add(k)); + return { selection_set: old.selection_set }; + }); + }, + [maxRequestCount, selectedCount], + ); + + const setUnselectedMany = useCallback((keys: string[]) => { + setSelectionState((old) => { + keys.forEach((k) => old.selection_set.delete(k)); + return { selection_set: old.selection_set }; + }); + }, []); + + // getters + const isSelected = useCallback( + (key: string) => + Boolean(state.selection_set.has(key) && key in claimableIdToIndex), + [state, claimableIdToIndex], + ); + + // populate state on claimableRequests + const isEmptyData = !claimableRequests; + useEffect(() => { + if (isEmptyData) { + setSelectionState({ selection_set: new Set() }); + } else { + setSelectedMany( + claimableRequests + .slice(0, DEFAULT_CLAIM_REQUEST_SELECTED) + .map((r) => r.stringId), + ); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isEmptyData]); + + return { + isSelected, + setSelected, + setSelectedMany, + setUnselectedMany, + sortedSelectedRequests, + selectedCount, + canSelectMore: selectedCount < maxRequestCount, + }; +}; diff --git a/features/withdrawals/contexts/transaction-modal-context.tsx b/features/withdrawals/contexts/transaction-modal-context.tsx new file mode 100644 index 000000000..0682dac55 --- /dev/null +++ b/features/withdrawals/contexts/transaction-modal-context.tsx @@ -0,0 +1,200 @@ +import { + FC, + createContext, + useMemo, + useContext, + useReducer, + Dispatch, +} from 'react'; +import { BigNumber } from 'ethers'; + +import { TX_STAGE } from 'features/withdrawals/shared/tx-stage-modal'; +import invariant from 'tiny-invariant'; +import type { TokensWithdrawable } from '../types/tokens-withdrawable'; + +type TransactionModalContextValue = TransactionModalState & { + dispatchModalState: Dispatch; +}; + +type TransactionModalState = { + isModalOpen: boolean; + txStage: TX_STAGE; + startTx: (() => void) | null; + onOkBunker: (() => void) | null; + onCloseBunker: (() => void) | null; + errorText: string | null; + txHash: string | null; + requestAmount: BigNumber | null; + token: TokensWithdrawable | null; +}; + +type TransactionModalAction = + | { + type: 'reset'; + } + | { + type: 'set_starTx_callback'; + callback: () => void; + } + | { + type: 'close_modal'; + } + | { + type: 'open_modal'; + } + | { + type: 'bunker'; + onCloseBunker: () => void; + onOkBunker: () => void; + } + | { + type: 'start'; + flow: TX_STAGE.APPROVE | TX_STAGE.PERMIT | TX_STAGE.SIGN; + token: TokensWithdrawable | null; + requestAmount: BigNumber; + } + | { + type: 'signing'; + } + | { + type: 'block'; + txHash?: string; + } + | { + type: 'error'; + errorText?: string; + } + | { + type: 'success'; + }; + +const TransactionModalContext = + createContext(null); +TransactionModalContext.displayName = 'TransactionModalContext'; + +const TransactionModalReducer = ( + state: TransactionModalState, + action: TransactionModalAction, +): TransactionModalState => { + switch (action.type) { + case 'reset': + return { + isModalOpen: false, + txStage: TX_STAGE.NONE, + errorText: null, + requestAmount: null, + token: null, + txHash: null, + // keep old (re)start callback if have one + startTx: state.startTx, + onCloseBunker: null, + onOkBunker: null, + }; + case 'set_starTx_callback': + return { + ...state, + startTx: action.callback, + }; + case 'close_modal': + return { + ...state, + isModalOpen: false, + }; + case 'open_modal': + // noop in NONE stage + if (state.txStage === TX_STAGE.NONE) return state; + return { + ...state, + isModalOpen: true, + }; + case 'bunker': + invariant(state.startTx, 'state must already have start tx callback'); + return { + ...state, + isModalOpen: true, + onCloseBunker: action.onCloseBunker, + onOkBunker: action.onOkBunker, + txStage: TX_STAGE.BUNKER, + }; + case 'start': + return { + errorText: null, + txHash: null, + txStage: action.flow, + isModalOpen: true, + requestAmount: action.requestAmount, + token: action.token, + // keep (re)start callback + startTx: state.startTx, + onCloseBunker: state.onCloseBunker, + onOkBunker: state.onOkBunker, + }; + case 'signing': + invariant(state.requestAmount, 'state must already have request amount'); + return { + ...state, + isModalOpen: true, + txStage: TX_STAGE.SIGN, + }; + case 'block': + return { + ...state, + isModalOpen: true, + txStage: TX_STAGE.BLOCK, + txHash: action.txHash ?? null, + }; + case 'success': + return { + ...state, + isModalOpen: true, + txStage: TX_STAGE.SUCCESS, + }; + case 'error': + return { + ...state, + isModalOpen: true, + errorText: action.errorText ?? null, + txStage: TX_STAGE.FAIL, + }; + default: + throw new Error('unexpected reducer action'); + } +}; + +const initTxModalState = (): TransactionModalState => ({ + isModalOpen: false, + txStage: TX_STAGE.NONE, + startTx: null, + errorText: null, + txHash: null, + requestAmount: null, + token: null, + onCloseBunker: null, + onOkBunker: null, +}); + +export const useTransactionModal = () => { + const r = useContext(TransactionModalContext); + invariant(r, 'useTransactionModal was used outside TransactionModalContext'); + return r; +}; + +export const TransactionModalProvider: FC = ({ children }) => { + const [state, dispatch] = useReducer( + TransactionModalReducer, + undefined, + initTxModalState, + ); + const value = useMemo( + () => ({ + ...state, + dispatchModalState: dispatch, + }), + [state], + ); + return ( + + {children} + + ); +}; diff --git a/features/withdrawals/contexts/withdrawals-context.tsx b/features/withdrawals/contexts/withdrawals-context.tsx new file mode 100644 index 000000000..50618071c --- /dev/null +++ b/features/withdrawals/contexts/withdrawals-context.tsx @@ -0,0 +1,78 @@ +import { FC, createContext, useContext, useMemo } from 'react'; +import invariant from 'tiny-invariant'; + +import { StatusProps } from 'features/withdrawals/shared/status'; + +import { useWithdrawalsBaseData } from 'features/withdrawals/hooks/contract/useWithdrawalsBaseData'; +import { BigNumber } from 'ethers'; + +export type WithdrawalsContextValue = { + isClaimTab: boolean; + withdrawalsStatus: StatusProps['variant']; + isWithdrawalsStatusLoading: boolean; + isPaused?: boolean; + isTurbo?: boolean; + isBunker?: boolean; + maxAmount?: BigNumber; + minAmount?: BigNumber; +}; +const WithdrawalsContext = createContext(null); +WithdrawalsContext.displayName = 'WithdrawalsContext'; + +export const useWithdrawals = () => { + const value = useContext(WithdrawalsContext); + invariant(value, 'useWithdrawals was used outside WithdrawalContext'); + return value; +}; + +type WithdrawalsProviderProps = { + mode: 'request' | 'claim'; +}; + +export const WithdrawalsProvider: FC = ({ + children, + mode, +}) => { + const isClaimTab = mode === 'claim'; + + const { data, initialLoading: isWithdrawalsStatusLoading } = + useWithdrawalsBaseData(); + const { isBunker, isPaused, isTurbo, maxAmount, minAmount } = data ?? {}; + + const withdrawalsStatus: StatusProps['variant'] = isPaused + ? 'error' + : isBunker + ? 'warning' + : isTurbo + ? 'success' + : 'error'; + + const value = useMemo( + () => ({ + isClaimTab, + withdrawalsStatus, + isWithdrawalsStatusLoading, + isPaused, + isTurbo, + isBunker, + maxAmount, + minAmount, + }), + [ + isClaimTab, + withdrawalsStatus, + isWithdrawalsStatusLoading, + isPaused, + isTurbo, + isBunker, + maxAmount, + minAmount, + ], + ); + + return ( + + {children} + + ); +}; diff --git a/features/withdrawals/hooks/contract/index.ts b/features/withdrawals/hooks/contract/index.ts new file mode 100644 index 000000000..a74741988 --- /dev/null +++ b/features/withdrawals/hooks/contract/index.ts @@ -0,0 +1,5 @@ +export * from './useClaim'; +export * from './useRequest'; +export * from './useWithdrawalsData'; +export * from './useUnfinalizedSteth'; +export * from './useWithdrawalsBaseData'; diff --git a/features/withdrawals/hooks/contract/useClaim.ts b/features/withdrawals/hooks/contract/useClaim.ts new file mode 100644 index 000000000..0cd737e65 --- /dev/null +++ b/features/withdrawals/hooks/contract/useClaim.ts @@ -0,0 +1,97 @@ +import { useCallback } from 'react'; +import { BigNumber } from 'ethers'; + +import { TX_STAGE } from 'features/withdrawals/shared/tx-stage-modal'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; +import { getErrorMessage, runWithTransactionLogger } from 'utils'; + +import { useWithdrawalsContract } from './useWithdrawalsContract'; +import { RequestStatusClaimable } from 'features/withdrawals/types/request-status'; +import invariant from 'tiny-invariant'; +import { isContract } from 'utils/isContract'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { useSDK } from '@lido-sdk/react'; +import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; + +export const useClaim = () => { + const { account } = useWeb3(); + const { providerWeb3 } = useSDK(); + const { contractWeb3 } = useWithdrawalsContract(); + const { update } = useClaimData(); + const { dispatchModalState } = useTransactionModal(); + + return useCallback( + async (sortedRequests: RequestStatusClaimable[]) => { + try { + invariant(contractWeb3, 'must have contract'); + invariant(sortedRequests, 'must have requests'); + invariant(account, 'must have address'); + invariant(providerWeb3, 'must have provider'); + const isMultisig = await isContract(account, contractWeb3.provider); + + const ethToClaim = sortedRequests.reduce( + (s, r) => s.add(r.claimableEth), + BigNumber.from(0), + ); + + dispatchModalState({ + type: 'start', + flow: TX_STAGE.SIGN, + requestAmount: ethToClaim, + token: null, + }); + + const ids = sortedRequests.map((r) => r.id); + const hints = sortedRequests.map((r) => r.hint); + const callback = async () => { + if (isMultisig) { + const tx = await contractWeb3.populateTransaction.claimWithdrawals( + ids, + hints, + ); + return providerWeb3.getSigner().sendUncheckedTransaction(tx); + } else { + const feeData = await contractWeb3.provider.getFeeData(); + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + const maxPriorityFeePerGas = + feeData.maxPriorityFeePerGas ?? undefined; + const gasLimit = await contractWeb3.estimateGas.claimWithdrawals( + ids, + hints, + { + maxFeePerGas, + maxPriorityFeePerGas, + }, + ); + return contractWeb3.claimWithdrawals(ids, hints, { + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + }); + } + }; + + const transaction = await runWithTransactionLogger( + 'Claim signing', + callback, + ); + + const isTransaction = typeof transaction !== 'string'; + + if (!isMultisig && isTransaction) { + dispatchModalState({ type: 'block', txHash: transaction.hash }); + await runWithTransactionLogger('Claim block confirmation', async () => + transaction.wait(), + ); + } + await update(); + dispatchModalState({ type: isMultisig ? 'reset' : 'success' }); + } catch (error) { + const errorMessage = getErrorMessage(error); + dispatchModalState({ type: 'error', errorText: errorMessage }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [contractWeb3, account, providerWeb3, dispatchModalState, update], + ); +}; diff --git a/features/withdrawals/hooks/contract/useLidoShareRate.ts b/features/withdrawals/hooks/contract/useLidoShareRate.ts new file mode 100644 index 000000000..9daaec334 --- /dev/null +++ b/features/withdrawals/hooks/contract/useLidoShareRate.ts @@ -0,0 +1,25 @@ +import { + useSDK, + useLidoSWR, + SWRResponse, + useSTETHContractRPC, +} from '@lido-sdk/react'; +import { BigNumber } from 'ethers'; +import { calcShareRate } from 'features/withdrawals/utils/calc-share-rate'; +import { STRATEGY_CONSTANT } from 'utils/swrStrategies'; + +export const useLidoShareRate = (): SWRResponse => { + const { chainId } = useSDK(); + const steth = useSTETHContractRPC(); + return useLidoSWR( + ['swr:currentShareRate', steth.address, chainId], + async () => { + const [totalPooledEther, totalShares] = await Promise.all([ + steth.getTotalPooledEther(), + steth.getTotalShares(), + ]); + return calcShareRate(totalPooledEther, totalShares); + }, + STRATEGY_CONSTANT, + ); +}; diff --git a/features/withdrawals/hooks/contract/useRequest.ts b/features/withdrawals/hooks/contract/useRequest.ts new file mode 100644 index 000000000..32ca0be5e --- /dev/null +++ b/features/withdrawals/hooks/contract/useRequest.ts @@ -0,0 +1,419 @@ +import { useCallback } from 'react'; +import { BigNumber } from 'ethers'; +import invariant from 'tiny-invariant'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { + useSDK, + useSTETHContractRPC, + useWSTETHContractRPC, +} from '@lido-sdk/react'; +import { TOKENS, getWithdrawalQueueAddress } from '@lido-sdk/constants'; +import { useAccount } from 'wagmi'; + +import { TX_STAGE } from 'features/withdrawals/shared/tx-stage-modal'; +import { + GatherPermitSignatureResult, + useERC20PermitSignature, +} from 'shared/hooks'; +import { useIsMultisig } from 'shared/hooks/useIsMultisig'; +import { getErrorMessage, runWithTransactionLogger } from 'utils'; +import { isContract } from 'utils/isContract'; +import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; + +import { useWithdrawalsContract } from './useWithdrawalsContract'; +import { useApprove } from 'shared/hooks/useApprove'; +import { Zero } from '@ethersproject/constants'; +import { TokensWithdrawable } from 'features/withdrawals/types/tokens-withdrawable'; + +// this encapsulates permit/approval & steth/wsteth flows +const useWithdrawalRequestMethods = () => { + const { providerWeb3 } = useSDK(); + const { account, chainId, contractWeb3 } = useWithdrawalsContract(); + const { dispatchModalState } = useTransactionModal(); + const permitSteth = useCallback( + async ({ + signature, + requests, + }: { + signature?: GatherPermitSignatureResult; + requests: BigNumber[]; + }) => { + invariant(chainId, 'must have chainId'); + invariant(account, 'must have account'); + invariant(signature, 'must have signature'); + invariant(contractWeb3, 'must have contractWeb3'); + + dispatchModalState({ type: 'signing' }); + + const params = [ + requests, + signature.owner, + { + value: signature.value, + deadline: signature.deadline, + v: signature.v, + r: signature.r, + s: signature.s, + }, + ] as const; + + const feeData = await contractWeb3.provider.getFeeData(); + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined; + const gasLimit = + await contractWeb3.estimateGas.requestWithdrawalsWithPermit(...params, { + maxFeePerGas, + maxPriorityFeePerGas, + }); + + const txOptions = { + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + }; + + const callback = () => + contractWeb3.requestWithdrawalsWithPermit(...params, txOptions); + + const transaction = await runWithTransactionLogger( + 'Request signing', + callback, + ); + + dispatchModalState({ type: 'block', txHash: transaction.hash }); + await runWithTransactionLogger('Request block confirmation', async () => + transaction.wait(), + ); + }, + [account, chainId, contractWeb3, dispatchModalState], + ); + + const permitWsteth = useCallback( + async ({ + signature, + requests, + }: { + signature?: GatherPermitSignatureResult; + requests: BigNumber[]; + }) => { + invariant(chainId, 'must have chainId'); + invariant(account, 'must have account'); + invariant(signature, 'must have signature'); + invariant(contractWeb3, 'must have contractWeb3'); + + const params = [ + requests, + signature.owner, + { + value: signature.value, + deadline: signature.deadline, + v: signature.v, + r: signature.r, + s: signature.s, + }, + ] as const; + + const feeData = await contractWeb3.provider.getFeeData(); + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined; + const gasLimit = + await contractWeb3.estimateGas.requestWithdrawalsWstETHWithPermit( + ...params, + { + maxFeePerGas, + maxPriorityFeePerGas, + }, + ); + + const txOptions = { + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + }; + + const callback = () => + contractWeb3.requestWithdrawalsWstETHWithPermit(...params, txOptions); + + dispatchModalState({ type: 'signing' }); + + const transaction = await runWithTransactionLogger( + 'Stake signing', + callback, + ); + + dispatchModalState({ type: 'block', txHash: transaction.hash }); + await runWithTransactionLogger('Stake block confirmation', async () => + transaction.wait(), + ); + }, + [account, chainId, contractWeb3, dispatchModalState], + ); + + const steth = useCallback( + async ({ requests }: { requests: BigNumber[] }) => { + invariant(chainId, 'must have chainId'); + invariant(account, 'must have account'); + invariant(contractWeb3, 'must have contractWeb3'); + invariant(providerWeb3, 'must have providerWeb3'); + + dispatchModalState({ type: 'signing' }); + const isMultisig = await isContract(account, contractWeb3.provider); + + const params = [requests, account] as const; + + const callback = async () => { + if (isMultisig) { + const tx = await contractWeb3.populateTransaction.requestWithdrawals( + ...params, + ); + return providerWeb3?.getSigner().sendUncheckedTransaction(tx); + } else { + const feeData = await contractWeb3.provider.getFeeData(); + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + const maxPriorityFeePerGas = + feeData.maxPriorityFeePerGas ?? undefined; + const gasLimit = await contractWeb3.estimateGas.requestWithdrawals( + ...params, + { + maxFeePerGas, + maxPriorityFeePerGas, + }, + ); + return contractWeb3.requestWithdrawals(...params, { + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + }); + } + }; + + const transaction = await runWithTransactionLogger( + 'Request signing', + callback, + ); + const isTransaction = typeof transaction !== 'string'; + + if (!isMultisig && isTransaction) { + dispatchModalState({ type: 'block', txHash: transaction.hash }); + await runWithTransactionLogger('Request block confirmation', async () => + transaction.wait(), + ); + } + }, + [account, chainId, contractWeb3, dispatchModalState, providerWeb3], + ); + + const wstETH = useCallback( + async ({ requests }: { requests: BigNumber[] }) => { + invariant(chainId, 'must have chainId'); + invariant(account, 'must have account'); + invariant(contractWeb3, 'must have contractWeb3'); + invariant(providerWeb3, 'must have providerWeb3'); + const isMultisig = await isContract(account, contractWeb3.provider); + + dispatchModalState({ type: 'signing' }); + + const params = [requests, account] as const; + const callback = async () => { + if (isMultisig) { + const tx = + await contractWeb3.populateTransaction.requestWithdrawalsWstETH( + requests, + account, + ); + return providerWeb3?.getSigner().sendUncheckedTransaction(tx); + } else { + const feeData = await contractWeb3.provider.getFeeData(); + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + const maxPriorityFeePerGas = + feeData.maxPriorityFeePerGas ?? undefined; + const gasLimit = + await contractWeb3.estimateGas.requestWithdrawalsWstETH(...params, { + maxFeePerGas, + maxPriorityFeePerGas, + }); + return contractWeb3.requestWithdrawalsWstETH(...params, { + maxFeePerGas, + maxPriorityFeePerGas, + gasLimit, + }); + } + }; + + const transaction = await runWithTransactionLogger( + 'Stake signing', + callback, + ); + + const isTransaction = typeof transaction !== 'string'; + + if (!isMultisig && isTransaction) { + dispatchModalState({ type: 'block', txHash: transaction.hash }); + await runWithTransactionLogger('Request block confirmation', async () => + transaction.wait(), + ); + } + }, + [account, chainId, contractWeb3, dispatchModalState, providerWeb3], + ); + + return useCallback( + (isAllowance: boolean, token: TOKENS.STETH | TOKENS.WSTETH) => { + return token == TOKENS.STETH + ? isAllowance + ? steth + : permitSteth + : isAllowance + ? wstETH + : permitWsteth; + }, + [permitSteth, permitWsteth, steth, wstETH], + ); +}; + +// provides form with a handler to call signing flow +// and all needed indicators for ux + +type useWithdrawalRequestParams = { + amount: BigNumber | null; + token: TOKENS.STETH | TOKENS.WSTETH; +}; + +export const useWithdrawalRequest = ({ + amount, + token, +}: useWithdrawalRequestParams) => { + const { chainId } = useSDK(); + const withdrawalQueueAddress = getWithdrawalQueueAddress(chainId); + + const { connector } = useAccount(); + const { account } = useWeb3(); + const { isBunker } = useWithdrawals(); + const { dispatchModalState } = useTransactionModal(); + const getRequestMethod = useWithdrawalRequestMethods(); + const [isMultisig, isMultisigLoading] = useIsMultisig(); + + const wstethContract = useWSTETHContractRPC(); + const stethContract = useSTETHContractRPC(); + const tokenContract = token === TOKENS.STETH ? stethContract : wstethContract; + + const valueBN = amount ?? Zero; + + // TODO split into async callback and pauseable SWR + const { + approve, + needsApprove, + allowance, + loading: loadingUseApprove, + } = useApprove( + valueBN, + tokenContract.address, + withdrawalQueueAddress, + account ?? undefined, + ); + + const { gatherPermitSignature } = useERC20PermitSignature({ + tokenProvider: tokenContract, + spender: withdrawalQueueAddress, + }); + + const isApprovalFlow = + connector?.id === 'walletConnect' || + isMultisig || + (allowance.gt(BigNumber.from(0)) && !needsApprove); + + const isApprovalFlowLoading = + isMultisigLoading || (isApprovalFlow && loadingUseApprove); + + const isTokenLocked = isApprovalFlow && needsApprove; + + const request = useCallback( + ( + requests: BigNumber[] | null, + amount: BigNumber | null, + token: TokensWithdrawable, + ) => { + // define and set retry point + const startCallback = async () => { + try { + invariant( + requests && request.length > 0, + 'cannot submit empty requests', + ); + invariant(amount, 'cannot submit empty amount'); + if (isBunker) { + const bunkerDialogResult = await new Promise((resolve) => { + dispatchModalState({ + type: 'bunker', + onCloseBunker: () => resolve(false), + onOkBunker: () => resolve(true), + }); + }); + if (!bunkerDialogResult) return { success: false }; + } + // we can't know if tx was successful or even wait for it with multisig + // so we exit flow gracefully and reset UI + const shouldSkipSuccess = isMultisig; + // get right method + const method = getRequestMethod(isApprovalFlow, token); + // start flow + dispatchModalState({ + type: 'start', + flow: isApprovalFlow + ? needsApprove + ? TX_STAGE.APPROVE + : TX_STAGE.SIGN + : TX_STAGE.PERMIT, + requestAmount: amount, + token, + }); + + // each flow switches needed signing stages + if (isApprovalFlow) { + if (needsApprove) { + await approve(); + // multisig does not move to next tx + if (!isMultisig) await method({ requests }); + } else { + await method({ requests }); + } + } else { + const signature = await gatherPermitSignature(amount); + await method({ signature, requests }); + } + // end flow + dispatchModalState({ type: shouldSkipSuccess ? 'reset' : 'success' }); + return { success: true }; + } catch (error) { + const errorMessage = getErrorMessage(error); + dispatchModalState({ type: 'error', errorText: errorMessage }); + return { success: false, error: error }; + } + }; + dispatchModalState({ + type: 'set_starTx_callback', + callback: startCallback, + }); + return startCallback(); + }, + [ + approve, + dispatchModalState, + gatherPermitSignature, + getRequestMethod, + isApprovalFlow, + isBunker, + isMultisig, + needsApprove, + ], + ); + + return { + isTokenLocked, + isApprovalFlow, + allowance, + isApprovalFlowLoading, + request, + }; +}; diff --git a/features/withdrawals/hooks/contract/useUnfinalizedSteth.ts b/features/withdrawals/hooks/contract/useUnfinalizedSteth.ts new file mode 100644 index 000000000..9928dfd60 --- /dev/null +++ b/features/withdrawals/hooks/contract/useUnfinalizedSteth.ts @@ -0,0 +1,12 @@ +import { useContractSWR } from '@lido-sdk/react'; + +import { useWithdrawalsContract } from './useWithdrawalsContract'; + +export const useUnfinalizedStETH = () => { + const { contractRpc } = useWithdrawalsContract(); + + return useContractSWR({ + contract: contractRpc, + method: 'unfinalizedStETH', + }); +}; diff --git a/features/withdrawals/hooks/contract/useWithdrawalsBaseData.ts b/features/withdrawals/hooks/contract/useWithdrawalsBaseData.ts new file mode 100644 index 000000000..9ba18575c --- /dev/null +++ b/features/withdrawals/hooks/contract/useWithdrawalsBaseData.ts @@ -0,0 +1,39 @@ +import { useSDK, useLidoSWR, SWRResponse } from '@lido-sdk/react'; +import { BigNumber } from 'ethers'; + +import { useWithdrawalsContract } from './useWithdrawalsContract'; +import { STRATEGY_CONSTANT } from 'utils/swrStrategies'; + +type useWithdrawalsBaseDataResult = { + maxAmount: BigNumber; + minAmount: BigNumber; + isPaused: boolean; + isBunker: boolean; + isTurbo: boolean; +}; + +export const useWithdrawalsBaseData = + (): SWRResponse => { + const { chainId } = useSDK(); + const { contractRpc } = useWithdrawalsContract(); + + return useLidoSWR( + ['swr:wqBaseData', contractRpc.address, chainId], + async () => { + const [minAmount, maxAmount, isPausedMode, isBunkerMode] = + await Promise.all([ + contractRpc.MIN_STETH_WITHDRAWAL_AMOUNT(), + contractRpc.MAX_STETH_WITHDRAWAL_AMOUNT(), + contractRpc.isPaused(), + contractRpc.isBunkerModeActive(), + ]); + + const isPaused = !!isPausedMode; + const isBunker = !!isBunkerMode; + const isTurbo = !isPaused && !isBunkerMode; + + return { minAmount, maxAmount, isPaused, isBunker, isTurbo }; + }, + STRATEGY_CONSTANT, + ); + }; diff --git a/features/withdrawals/hooks/contract/useWithdrawalsContract.ts b/features/withdrawals/hooks/contract/useWithdrawalsContract.ts new file mode 100644 index 000000000..e874c7b84 --- /dev/null +++ b/features/withdrawals/hooks/contract/useWithdrawalsContract.ts @@ -0,0 +1,14 @@ +import { + useWithdrawalQueueContractWeb3, + useWithdrawalQueueContractRPC, +} from '@lido-sdk/react'; +import { useWeb3 } from 'reef-knot/web3-react'; + +export const useWithdrawalsContract = () => { + const contractWeb3 = useWithdrawalQueueContractWeb3(); + const contractRpc = useWithdrawalQueueContractRPC(); + + const { account, chainId } = useWeb3(); + + return { contractWeb3, contractRpc, account, chainId }; +}; diff --git a/features/withdrawals/hooks/contract/useWithdrawalsData.ts b/features/withdrawals/hooks/contract/useWithdrawalsData.ts new file mode 100644 index 000000000..a8e9261df --- /dev/null +++ b/features/withdrawals/hooks/contract/useWithdrawalsData.ts @@ -0,0 +1,130 @@ +import { BigNumber } from 'ethers'; +import { useLidoSWR } from '@lido-sdk/react'; +// import { useLidoShareRate } from 'features/withdrawals/hooks/contract/useLidoShareRate'; + +import { useWithdrawalsContract } from './useWithdrawalsContract'; + +import { + RequestStatus, + RequestStatusClaimable, + RequestStatusPending, +} from 'features/withdrawals/types/request-status'; +import { MAX_SHOWN_REQUEST_PER_TYPE } from 'features/withdrawals/withdrawals-constants'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; +// import { calcExpectedRequestEth } from 'features/withdrawals/utils/calc-expected-request-eth'; + +export const useWithdrawalRequests = () => { + const { contractRpc, account, chainId } = useWithdrawalsContract(); + // const { data: currentShareRate } = useLidoShareRate(); + + return useLidoSWR( + // TODO: use this fragment for expected eth calculation + // currentShareRate + // ? ['swr:withdrawals-requests', account, chainId, currentShareRate] + // : false, + ['swr:withdrawals-requests', account, chainId], + async (...args: unknown[]) => { + const account = args[1] as string; + // const currentShareRate = args[3] as BigNumber; + + const [requestIds, lastCheckpointIndex] = await Promise.all([ + contractRpc.getWithdrawalRequests(account), + contractRpc.getLastCheckpointIndex(), + ]); + const requestStatuses = await contractRpc.getWithdrawalStatus(requestIds); + + const claimableRequests: RequestStatus[] = []; + const pendingRequests: RequestStatusPending[] = []; + + let pendingAmountOfStETH = BigNumber.from(0); + let claimableAmountOfStETH = BigNumber.from(0); + + requestStatuses.forEach((request, index) => { + const id = requestIds[index]; + const req: RequestStatus = { + ...request, + id, + stringId: id.toString(), + }; + + if (request.isFinalized && !request.isClaimed) { + claimableRequests.push(req); + claimableAmountOfStETH = claimableAmountOfStETH.add( + request.amountOfStETH, + ); + } else if (!request.isFinalized) { + pendingRequests.push({ + ...req, + expectedEth: req.amountOfStETH, // TODO: replace with calcExpectedRequestEth(req, currentShareRate), + }); + pendingAmountOfStETH = pendingAmountOfStETH.add( + request.amountOfStETH, + ); + } + + return req; + }); + + let isClamped = + claimableRequests.splice(MAX_SHOWN_REQUEST_PER_TYPE).length > 0; + isClamped ||= + pendingRequests.splice(MAX_SHOWN_REQUEST_PER_TYPE).length > 0; + + /* Stress test + let id = BigNumber.from(pendingRequests[pendingRequests.length - 1].id); + for (let index = pendingRequests.length; index < 100000; index++) { + id = id.add(1); + pendingRequests.push({ + amountOfShares: BigNumber.from(10), + amountOfStETH: BigNumber.from('10000000000000000'), + id, + isClaimed: false, + isFinalized: false, + stringId: id.toString(), + owner: account, + timestamp: BigNumber.from('10000000000000000'), + }); + } + */ + + const _sortedClaimableRequests = claimableRequests.sort((aReq, bReq) => + aReq.id.gt(bReq.id) ? 1 : -1, + ); + + const hints = await contractRpc.findCheckpointHints( + _sortedClaimableRequests.map(({ id }) => id), + 1, + lastCheckpointIndex, + ); + + const claimableEth = await contractRpc.getClaimableEther( + _sortedClaimableRequests.map(({ id }) => id), + hints, + ); + + let claimableAmountOfETH = BigNumber.from(0); + const sortedClaimableRequests: RequestStatusClaimable[] = + _sortedClaimableRequests.map((request, index) => { + claimableAmountOfETH = claimableAmountOfETH.add(claimableEth[index]); + return { + ...request, + hint: hints[index], + claimableEth: claimableEth[index], + }; + }); + + return { + pendingRequests, + sortedClaimableRequests, + pendingCount: pendingRequests.length, + readyCount: sortedClaimableRequests.length, + claimedCount: claimableRequests.length, + pendingAmountOfStETH, + claimableAmountOfStETH, + claimableAmountOfETH, + isClamped, + }; + }, + STRATEGY_LAZY, + ); +}; diff --git a/features/withdrawals/hooks/index.ts b/features/withdrawals/hooks/index.ts new file mode 100644 index 000000000..63bd8a934 --- /dev/null +++ b/features/withdrawals/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './contract'; +export * from './useEthAmountByStethWsteth'; +export * from './useTvlMessage'; +export * from './useWaitingTime'; diff --git a/features/withdrawals/hooks/useEthAmountByStethWsteth.ts b/features/withdrawals/hooks/useEthAmountByStethWsteth.ts new file mode 100644 index 000000000..ecad26bda --- /dev/null +++ b/features/withdrawals/hooks/useEthAmountByStethWsteth.ts @@ -0,0 +1,29 @@ +import { parseEther } from '@ethersproject/units'; +import { BigNumber } from 'ethers'; +import { useMemo } from 'react'; + +import { useStethByWsteth } from 'shared/hooks'; +import { isValidEtherValue } from 'utils/isValidEtherValue'; + +type useEthAmountByInputProps = { + isSteth: boolean; + input?: string; +}; + +export const useEthAmountByStethWsteth = ({ + isSteth, + input, +}: useEthAmountByInputProps) => { + const isValidValue = + input && !isNaN(Number(input)) && isValidEtherValue(input); + const inputBN = useMemo( + () => (isValidValue ? parseEther(input) : BigNumber.from(0)), + [input, isValidValue], + ); + + const stethByWstethBalance = useStethByWsteth(isSteth ? undefined : inputBN); + + if (!isValidValue) return undefined; + if (isSteth) return inputBN; + return stethByWstethBalance; +}; diff --git a/features/withdrawals/hooks/useNftDataByTxHash.ts b/features/withdrawals/hooks/useNftDataByTxHash.ts new file mode 100644 index 000000000..3f675ee89 --- /dev/null +++ b/features/withdrawals/hooks/useNftDataByTxHash.ts @@ -0,0 +1,55 @@ +import { useSDK } from '@lido-sdk/react'; +import { useLidoSWR } from '@lido-sdk/react'; +import { useWithdrawalsContract } from './contract/useWithdrawalsContract'; + +import { standardFetcher } from 'utils/standardFetcher'; +import type { TransactionReceipt } from '@ethersproject/abstract-provider'; + +const EVENT_NAME = 'WithdrawalRequested'; + +type NFTApiData = { + description: string; + image: string; + name: string; +}; + +export const useNftDataByTxHash = (txHash: string | null) => { + const { contractRpc, account } = useWithdrawalsContract(); + const { providerWeb3 } = useSDK(); + + const swrNftApiData = useLidoSWR( + account && txHash && providerWeb3 + ? ['swr:nft-data-by-tx-hash', txHash, account] + : null, + async () => { + if (!txHash || !account || !providerWeb3) return null; + + const txReciept: TransactionReceipt = + await providerWeb3.getTransactionReceipt(txHash); + + const eventTopic = contractRpc.interface.getEventTopic(EVENT_NAME); + const eventLogs = txReciept.logs.filter( + (log) => log.topics[0] === eventTopic, + ); + const events = eventLogs.map((log) => + contractRpc.interface.decodeEventLog(EVENT_NAME, log.data, log.topics), + ); + + const nftDataRequests = events.map((e) => { + const fetch = async () => { + const tokenURI = await contractRpc.tokenURI(Number(e.requestId)); + const nftData = await standardFetcher(tokenURI); + return nftData; + }; + + return fetch(); + }); + + const nftData = await Promise.all(nftDataRequests); + + return nftData; + }, + ); + + return swrNftApiData; +}; diff --git a/features/withdrawals/hooks/useTvlMessage.tsx b/features/withdrawals/hooks/useTvlMessage.tsx new file mode 100644 index 000000000..e5b39ad7e --- /dev/null +++ b/features/withdrawals/hooks/useTvlMessage.tsx @@ -0,0 +1,44 @@ +import { useMemo } from 'react'; +import { formatEther } from '@ethersproject/units'; + +import { shortenTokenValue } from 'utils'; +import { + TvlErrorPayload, + ValidationTvlJoke, +} from '../request/request-form-context/validators'; + +const texts: ((amount: string) => string)[] = [ + (amount) => + `That's about ${amount} more than we've got, would suggest you stake more first!`, + () => + `Didn't realize you're a 🐋, did you leave your stETH in your other wallet? No worries, just stake some more!`, + () => 'Hey Justin Sun, the "stake" button is this way ^', +]; + +const getText = () => texts[Math.floor(Math.random() * texts.length)]; + +export const useTvlMessage = (error?: unknown) => { + // To render one text per page before refresh + const textTemplate = useMemo(() => getText(), []); + + const balanceDiff = + error && + typeof error === 'object' && + 'type' in error && + error.type == ValidationTvlJoke.type && + 'payload' in error && + error.payload + ? (error.payload as TvlErrorPayload).balanceDiffSteth + : undefined; + + return { + balanceDiff, + tvlMessage: useMemo( + () => + balanceDiff + ? textTemplate(shortenTokenValue(Number(formatEther(balanceDiff)))) + : undefined, + [balanceDiff, textTemplate], + ), + }; +}; diff --git a/features/withdrawals/hooks/useWaitingTime.ts b/features/withdrawals/hooks/useWaitingTime.ts new file mode 100644 index 000000000..8d5e3c728 --- /dev/null +++ b/features/withdrawals/hooks/useWaitingTime.ts @@ -0,0 +1,72 @@ +import { useMemo } from 'react'; +import { SWRResponse, useLidoSWR } from '@lido-sdk/react'; +import { dynamics } from 'config'; + +import { useDebouncedValue } from 'shared/hooks'; +import { encodeURLQuery } from 'utils/encodeURLQuery'; +import { standardFetcher } from 'utils/standardFetcher'; +import { STRATEGY_EAGER } from 'utils/swrStrategies'; +import { FetcherError } from 'utils/fetcherError'; + +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; + +const DEFAULT_DAYS_VALUE = 5; + +type RequestTimeResponse = { + days: number; + stethLastUpdate: number; + validatorsLastUpdate: number; + steth: string; + requests: number; +}; + +type useWaitingTimeOptions = { + isApproximate?: boolean; +}; + +// TODO: accept big Number +export const useWaitingTime = ( + amount: string, + options: useWaitingTimeOptions = {}, +) => { + const { isApproximate } = options; + const debouncedAmount = useDebouncedValue(amount, 1000); + const url = useMemo(() => { + const basePath = dynamics.wqAPIBasePath; + const params = encodeURLQuery({ amount: debouncedAmount }); + + return `${basePath}/v1/request-time${params ? `?${params}` : ''}`; + }, [debouncedAmount]); + + const { data, initialLoading, error } = useLidoSWR(url, standardFetcher, { + ...STRATEGY_EAGER, + shouldRetryOnError: (e: unknown) => { + // if api is not happy about our request - no retry + if (e && typeof e == 'object' && 'status' in e && e.status == 400) + return false; + return true; + }, + }) as SWRResponse; + const { isBunker, isPaused } = useWithdrawals(); + const isRequestError = error instanceof FetcherError && error.status < 500; + + const stethLastUpdate = + data?.stethLastUpdate && new Date(data?.stethLastUpdate * 1000); + const days = data?.days ?? DEFAULT_DAYS_VALUE; + + const waitingTime = + days && days > 1 + ? `${isApproximate ? '~ ' : ''}1-${days} day(s)` + : `${isApproximate ? '~ ' : ''}${days} day`; + const value = + isPaused || isRequestError ? '—' : isBunker ? 'Not estimated' : waitingTime; + + return { + ...data, + initialLoading, + error, + stethLastUpdate, + days, + value, + }; +}; diff --git a/features/withdrawals/hooks/useWithdrawTxPrice.ts b/features/withdrawals/hooks/useWithdrawTxPrice.ts new file mode 100644 index 000000000..e2e5e3428 --- /dev/null +++ b/features/withdrawals/hooks/useWithdrawTxPrice.ts @@ -0,0 +1,176 @@ +import { useMemo } from 'react'; +import { useLidoSWR, useSDK } from '@lido-sdk/react'; +import { standardFetcher } from 'utils/standardFetcher'; +import { + ESTIMATE_ACCOUNT, + WITHDRAWAL_QUEUE_CLAIM_GAS_LIMIT_DEFAULT, + WITHDRAWAL_QUEUE_REQUEST_STETH_APPROVED_GAS_LIMIT_DEFAULT, + WITHDRAWAL_QUEUE_REQUEST_STETH_PERMIT_GAS_LIMIT_DEFAULT, + WITHDRAWAL_QUEUE_REQUEST_WSTETH_PERMIT_GAS_LIMIT_DEFAULT, + WITHDRAWAL_QUEUE_REQUEST_WSTETH_APPROVED_GAS_LIMIT_DEFAULT, + dynamics, +} from 'config'; +import { MAX_REQUESTS_COUNT } from 'features/withdrawals/withdrawals-constants'; + +import { useWeb3 } from '@reef-knot/web3-react'; +import { TOKENS } from '@lido-sdk/constants'; + +import { useWithdrawalsContract } from './contract/useWithdrawalsContract'; +import { useTxCostInUsd } from 'shared/hooks/txCost'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'; +import { encodeURLQuery } from 'utils/encodeURLQuery'; +import { BigNumber } from 'ethers'; +import invariant from 'tiny-invariant'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; + +type UseRequestTxPriceOptions = { + requestCount?: number; + token: TOKENS; + isApprovalFlow: boolean; +}; + +export const useRequestTxPrice = ({ + token, + isApprovalFlow, + requestCount, +}: UseRequestTxPriceOptions) => { + const { chainId } = useSDK(); + const { contractRpc } = useWithdrawalsContract(); + // TODO add fallback for approval flow + const fallback = + token === 'STETH' + ? isApprovalFlow + ? WITHDRAWAL_QUEUE_REQUEST_STETH_APPROVED_GAS_LIMIT_DEFAULT + : WITHDRAWAL_QUEUE_REQUEST_STETH_PERMIT_GAS_LIMIT_DEFAULT + : isApprovalFlow + ? WITHDRAWAL_QUEUE_REQUEST_WSTETH_APPROVED_GAS_LIMIT_DEFAULT + : WITHDRAWAL_QUEUE_REQUEST_WSTETH_PERMIT_GAS_LIMIT_DEFAULT; + + const cappedRequestCount = Math.min(requestCount || 1, MAX_REQUESTS_COUNT); + const debouncedRequestCount = useDebouncedValue(cappedRequestCount, 2000); + + const url = useMemo(() => { + const basePath = dynamics.wqAPIBasePath; + const params = encodeURLQuery({ + token, + requestCount: debouncedRequestCount, + }); + return `${basePath}/v1/estimate-gas?${params}`; + }, [debouncedRequestCount, token]); + + const { data: permitEstimateData, initialLoading: permitLoading } = + useLidoSWR<{ gasLimit: number }>(url, standardFetcher, { + ...STRATEGY_LAZY, + isPaused: () => !chainId || isApprovalFlow, + }); + + const { data: approvalFlowGasLimit, initialLoading: approvalLoading } = + useLidoSWR( + ['swr:request-gas-limit', debouncedRequestCount, chainId], + async () => { + try { + invariant(chainId, 'chainId is required'); + invariant(contractRpc, 'contractRpc is required'); + const gasLimit = ( + await contractRpc.estimateGas.requestWithdrawals( + Array(debouncedRequestCount).fill(BigNumber.from(100)), + ESTIMATE_ACCOUNT, + { from: ESTIMATE_ACCOUNT }, + ) + ).toNumber(); + return gasLimit; + } catch (error) { + console.warn('Could not estimate gas for request', { + error, + }); + return undefined; + } + }, + { + ...STRATEGY_LAZY, + isPaused: () => !chainId || !isApprovalFlow, + }, + ); + + const gasLimit = + (isApprovalFlow ? approvalFlowGasLimit : permitEstimateData?.gasLimit) ?? + fallback * debouncedRequestCount; + + const txPriceUsd = useTxCostInUsd(gasLimit); + + const loading = + cappedRequestCount !== debouncedRequestCount || + (isApprovalFlow ? approvalLoading : permitLoading); + + return { + loading, + txPriceUsd, + gasLimit, + }; +}; + +export const useClaimTxPrice = () => { + const { contractRpc } = useWithdrawalsContract(); + const { claimSelection } = useClaimData(); + const { account, chainId } = useWeb3(); + + const requestCount = claimSelection.selectedCount || 1; + const debouncedSortedSelectedRequests = useDebouncedValue( + claimSelection.sortedSelectedRequests, + 2000, + ); + const { data: gasLimitResult, initialLoading: isEstimateLoading } = + useLidoSWR( + [ + 'swr:claim-request-gas-limit', + debouncedSortedSelectedRequests, + account, + chainId, + ], + async () => { + if ( + !chainId || + !account || + !contractRpc || + debouncedSortedSelectedRequests.length === 0 + ) + return undefined; + const sortedRequests = debouncedSortedSelectedRequests; + + const gasLimit = await contractRpc?.estimateGas + .claimWithdrawals( + sortedRequests.map((r) => r.id), + sortedRequests.map((r) => r.hint), + { from: account }, + ) + .catch((error) => { + console.warn('Could not estimate gas for claim', { + ids: sortedRequests.map((r) => r.id), + account, + error, + }); + return undefined; + }); + + return gasLimit; + }, + STRATEGY_LAZY, + ); + + const gasLimit = isEstimateLoading + ? undefined + : gasLimitResult?.toNumber() ?? + WITHDRAWAL_QUEUE_CLAIM_GAS_LIMIT_DEFAULT * requestCount; + + const price = useTxCostInUsd(gasLimit); + + return { + loading: + isEstimateLoading || + !price || + debouncedSortedSelectedRequests !== claimSelection.sortedSelectedRequests, + claimGasLimit: gasLimit, + claimTxPriceInUsd: price, + }; +}; diff --git a/features/withdrawals/hooks/useWithdrawalRates.ts b/features/withdrawals/hooks/useWithdrawalRates.ts new file mode 100644 index 000000000..a9a24e768 --- /dev/null +++ b/features/withdrawals/hooks/useWithdrawalRates.ts @@ -0,0 +1,268 @@ +import { useLidoSWR } from '@lido-sdk/react'; + +import { useDebouncedValue } from 'shared/hooks/useDebouncedValue'; + +import { BigNumber } from 'ethers'; +import { CHAINS, TOKENS, getTokenAddress } from '@lido-sdk/constants'; +import { useMemo } from 'react'; +import { standardFetcher } from 'utils/standardFetcher'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; +import { useWatch } from 'react-hook-form'; +import { RequestFormInputType } from '../request/request-form-context'; +import { Zero } from '@ethersproject/constants'; + +type getWithdrawalRatesParams = { + amount: BigNumber; + token: TOKENS.STETH | TOKENS.WSTETH; +}; + +type RateResult = { + name: string; + rate: number | null; + toReceive: BigNumber | null; +}; + +type getRate = ( + amount: BigNumber, + token: TOKENS.STETH | TOKENS.WSTETH, +) => Promise; + +type rateCalculationResult = ReturnType; + +type getWithdrawalRatesResult = RateResult[]; + +const RATE_PRECISION = 100000; +const RATE_PRECISION_BN = BigNumber.from(RATE_PRECISION); + +const calculateRateReceive = ( + amount: BigNumber, + src: BigNumber, + dest: BigNumber, +) => { + const _rate = dest.mul(RATE_PRECISION_BN).div(src); + const toReceive = amount.mul(dest).div(src); + const rate = _rate.toNumber() / RATE_PRECISION; + return { rate, toReceive }; +}; + +type OneInchQuotePartial = { + toTokenAmount: string; +}; + +const getOneInchRate: getRate = async (amount, token) => { + let rateInfo: rateCalculationResult | null; + try { + if (amount.isZero() || amount.isNegative()) { + return { + name: '1inch', + rate: 0, + toReceive: BigNumber.from(0), + }; + } + const capped_amount = amount; + const api = `https://api.1inch.io/v3.0/1/quote`; + const query = new URLSearchParams({ + fromTokenAddress: getTokenAddress(CHAINS.Mainnet, token), + toTokenAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + amount: amount.toString(), + }); + const url = `${api}?${query.toString()}`; + const data: OneInchQuotePartial = + await standardFetcher(url); + + rateInfo = calculateRateReceive( + amount, + capped_amount, + BigNumber.from(data.toTokenAmount), + ); + } catch { + rateInfo = null; + } + return { + name: '1inch', + rate: rateInfo?.rate ?? null, + toReceive: rateInfo?.toReceive ?? null, + }; +}; + +type ParaSwapPriceResponsePartial = { + priceRoute: { + srcAmount: string; + destAmount: string; + }; +}; + +const getParaSwapRate: getRate = async (amount, token) => { + let rateInfo: rateCalculationResult | null; + try { + if (amount.isZero() || amount.isNegative()) { + return { + name: 'paraswap', + rate: 0, + toReceive: BigNumber.from(0), + }; + } + const capped_amount = amount; + const api = `https://apiv5.paraswap.io/prices`; + const query = new URLSearchParams({ + srcToken: getTokenAddress(CHAINS.Mainnet, token), + srcDecimals: '18', + destToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + destDecimals: '18', + side: 'SELL', + excludeDirectContractMethods: 'true', + userAddress: '0x0000000000000000000000000000000000000000', + amount: capped_amount.toString(), + network: '1', + partner: 'lido', + }); + + const url = `${api}?${query.toString()}`; + const data: ParaSwapPriceResponsePartial = + await standardFetcher(url); + + rateInfo = calculateRateReceive( + amount, + BigNumber.from(data.priceRoute.srcAmount), + BigNumber.from(data.priceRoute.destAmount), + ); + } catch { + rateInfo = null; + } + return { + name: 'paraswap', + rate: rateInfo?.rate ?? null, + toReceive: rateInfo?.toReceive ?? null, + }; +}; + +type CowSwapQuoteResponsePartial = { + quote: { + sellAmount: string; + buyAmount: string; + }; +}; + +const getCowSwapRate: getRate = async (amount, token) => { + let rateInfo: rateCalculationResult | null; + try { + if (amount.isZero() || amount.isNegative()) { + return { + name: 'cowswap', + rate: 0, + toReceive: BigNumber.from(0), + }; + } + const capped_amount = amount; + const payload = { + sellToken: getTokenAddress(CHAINS.Mainnet, token), + buyToken: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + from: '0x0000000000000000000000000000000000000000', + receiver: '0x0000000000000000000000000000000000000000', + partiallyFillable: false, + kind: 'sell', + sellAmountBeforeFee: capped_amount.toString(), + }; + + const data: CowSwapQuoteResponsePartial = await standardFetcher( + `https://api.cow.fi/mainnet/api/v1/quote`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }, + ); + + rateInfo = calculateRateReceive( + amount, + BigNumber.from(data.quote.sellAmount), + BigNumber.from(data.quote.buyAmount), + ); + } catch { + rateInfo = null; + } + return { + name: 'cowswap', + rate: rateInfo?.rate ?? null, + toReceive: rateInfo?.toReceive ?? null, + }; +}; + +const getWithdrawalRates = async ({ + amount, + token, +}: getWithdrawalRatesParams): Promise => { + const rates = await Promise.all([ + getOneInchRate(amount, token), + getParaSwapRate(amount, token), + getCowSwapRate(amount, token), + ]); + + // sort by rate, then alphabetic + rates.sort((r1, r2) => { + const rate1 = r1.rate ?? 0; + const rate2 = r2.rate ?? 0; + if (rate1 == rate2) { + if (r1.name < r2.name) { + return -1; + } + if (r1.name > r2.name) { + return 1; + } + return 0; + } + return rate2 - rate1; + }); + + return rates; +}; + +type useWithdrawalRatesOptions = { + fallbackValue?: BigNumber; +}; + +export const useWithdrawalRates = ({ + fallbackValue = Zero, +}: useWithdrawalRatesOptions = {}) => { + const [token, amount] = useWatch({ + name: ['token', 'amount'], + }); + const fallbackedAmount = amount ?? fallbackValue; + const debouncedAmount = useDebouncedValue(fallbackedAmount, 1000); + const swr = useLidoSWR( + ['swr:withdrawal-rates', debouncedAmount, token], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (_, amount, token) => + getWithdrawalRates({ + amount: amount as BigNumber, + token: token as TOKENS.STETH | TOKENS.WSTETH, + }), + { + ...STRATEGY_LAZY, + isPaused: () => !debouncedAmount || !debouncedAmount._isBigNumber, + }, + ); + + const bestRate = useMemo(() => { + return swr.data?.[0]?.rate ?? null; + }, [swr.data]); + + return { + amount: fallbackedAmount, + bestRate, + selectedToken: token, + data: swr.data, + get initialLoading() { + return swr.initialLoading || !debouncedAmount.eq(fallbackedAmount); + }, + get loading() { + return swr.loading || !debouncedAmount.eq(fallbackedAmount); + }, + get error() { + return swr.error; + }, + update: swr.update, + }; +}; diff --git a/features/withdrawals/index.ts b/features/withdrawals/index.ts new file mode 100644 index 000000000..01609b175 --- /dev/null +++ b/features/withdrawals/index.ts @@ -0,0 +1 @@ +export * from './withdrawals-tabs'; diff --git a/features/withdrawals/request/form/bunker-info.tsx b/features/withdrawals/request/form/bunker-info.tsx new file mode 100644 index 000000000..1c191ee70 --- /dev/null +++ b/features/withdrawals/request/form/bunker-info.tsx @@ -0,0 +1,14 @@ +import Link from 'next/link'; + +import { InfoBoxStyled } from 'features/withdrawals/shared'; + +export const BunkerInfo = () => { + return ( + + Lido protocol is in "Bunker mode". The withdrawal requests are + slowed down until the consequences of the incident that caused + "Bunker mode" are not resolved. For more details,{' '} + see here. + + ); +}; diff --git a/features/withdrawals/request/form/index.ts b/features/withdrawals/request/form/index.ts new file mode 100644 index 000000000..7ad07a216 --- /dev/null +++ b/features/withdrawals/request/form/index.ts @@ -0,0 +1 @@ +export * from './request-form'; diff --git a/features/withdrawals/request/form/inputs/amount-input.tsx b/features/withdrawals/request/form/inputs/amount-input.tsx new file mode 100644 index 000000000..3adcee996 --- /dev/null +++ b/features/withdrawals/request/form/inputs/amount-input.tsx @@ -0,0 +1,42 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { InputDecoratorTvlStake } from 'features/withdrawals/shared/input-decorator-tvl-stake'; +import { useController, useWatch } from 'react-hook-form'; +import { InputAmount } from 'shared/forms/components/input-amount'; +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; + +import { + RequestFormInputType, + useRequestFormData, +} from 'features/withdrawals/request/request-form-context'; +import { useTvlMessage } from 'features/withdrawals/hooks/useTvlMessage'; + +export const AmountInput = () => { + const { balanceSteth, balanceWSteth, isTokenLocked } = useRequestFormData(); + const token = useWatch({ name: 'token' }); + + const { + field, + fieldState: { error }, + } = useController({ + name: 'amount', + }); + + const { balanceDiff } = useTvlMessage(error); + + const balance = token === TOKENS.STETH ? balanceSteth : balanceWSteth; + + return ( + + } + label={`${getTokenDisplayName(token)} amount`} + {...field} + /> + ); +}; diff --git a/features/withdrawals/request/form/inputs/input-group.tsx b/features/withdrawals/request/form/inputs/input-group.tsx new file mode 100644 index 000000000..8cc0eb03a --- /dev/null +++ b/features/withdrawals/request/form/inputs/input-group.tsx @@ -0,0 +1,17 @@ +import { useTvlMessage } from 'features/withdrawals/hooks'; +import { useFormState } from 'react-hook-form'; +import { RequestFormInputType } from '../../request-form-context'; +import { InputGroupStyled } from '../styles'; + +export const ErrorMessageInputGroup: React.FC = ({ children }) => { + const { + errors: { amount: amountError }, + } = useFormState({ name: 'amount' }); + const { tvlMessage } = useTvlMessage(amountError); + const errorMessage = amountError?.type === 'validate' && amountError.message; + return ( + + {children} + + ); +}; diff --git a/features/withdrawals/request/form/inputs/mode-input.tsx b/features/withdrawals/request/form/inputs/mode-input.tsx new file mode 100644 index 000000000..595b3d60e --- /dev/null +++ b/features/withdrawals/request/form/inputs/mode-input.tsx @@ -0,0 +1,18 @@ +import { useController } from 'react-hook-form'; +import { RequestFormInputType } from '../../request-form-context'; +import { OptionsPicker } from '../options/options-picker'; + +export const ModeInput = () => { + const { field } = useController({ + name: 'mode', + }); + return ( + { + field.onChange(value); + field.onBlur(); + }} + /> + ); +}; diff --git a/features/withdrawals/request/form/inputs/token-input.tsx b/features/withdrawals/request/form/inputs/token-input.tsx new file mode 100644 index 000000000..cf661bb02 --- /dev/null +++ b/features/withdrawals/request/form/inputs/token-input.tsx @@ -0,0 +1,51 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { SelectIcon, Steth, Wsteth, Option } from '@lidofinance/lido-ui'; + +import { useController, useFormContext, useFormState } from 'react-hook-form'; +import { RequestFormInputType } from 'features/withdrawals/request/request-form-context'; + +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { TokensWithdrawable } from 'features/withdrawals/types/tokens-withdrawable'; + +const iconsMap = { + [TOKENS.WSTETH]: , + [TOKENS.STETH]: , +}; + +export const TokenInput = () => { + const { setValue, getFieldState } = useFormContext(); + const { field } = useController({ + name: 'token', + }); + + const { errors } = useFormState({ name: 'amount' }); + + return ( + { + // this softly changes token state, resets amount and only validates if it was touched + const { isDirty } = getFieldState('amount'); + setValue('token', value, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: false, + }); + setValue('amount', null, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: isDirty, + }); + }} + > + + + + ); +}; diff --git a/features/withdrawals/request/form/options/dex-options.tsx b/features/withdrawals/request/form/options/dex-options.tsx new file mode 100644 index 000000000..9202f7ab9 --- /dev/null +++ b/features/withdrawals/request/form/options/dex-options.tsx @@ -0,0 +1,146 @@ +import { BigNumber } from 'ethers'; +import { CHAINS, TOKENS, getTokenAddress } from '@lido-sdk/constants'; +import { formatEther } from '@ethersproject/units'; + +import { useWithdrawalRates } from 'features/withdrawals/hooks/useWithdrawalRates'; +import { FormatToken } from 'shared/formatters/format-token'; + +import { + trackMatomoEvent, + MATOMO_CLICK_EVENTS_TYPES, +} from 'config/trackMatomoEvent'; +import { + DexOptionBlockLink, + DexOptionBlockTitle, + DexOptionStyled, + DexOptionsContainer, + DexOptionAmount, + InlineLoaderSmall, + OneInchIcon, + ParaSwapIcon, + CowSwapIcon, + DexOptionLoader, +} from './styles'; + +const placeholder = Array(3).fill(null); + +const dexInfo: { + [key: string]: { + title: string; + icon: JSX.Element; + onClickGoTo: React.MouseEventHandler; + link: (amount: BigNumber, token: TOKENS.STETH | TOKENS.WSTETH) => string; + }; +} = { + '1inch': { + title: '1inch', + icon: , + onClickGoTo: () => { + trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.withdrawalGoTo1inch); + }, + link: (amount, token) => + `https://app.1inch.io/#/1/simple/swap/${ + token == TOKENS.STETH ? 'stETH' : 'wstETH' + }/ETH?sourceTokenAmount=${formatEther(amount)}`, + }, + paraswap: { + title: 'ParaSwap', + icon: , + onClickGoTo: () => { + trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToParaswap); + }, + link: (amount, token) => + `https://app.paraswap.io/#/${getTokenAddress( + CHAINS.Mainnet, + token, + )}-ETH/${formatEther(amount)}?network=ethereum`, + }, + cowswap: { + title: 'CoW Swap', + icon: , + onClickGoTo: () => { + trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.withdrawalGoToCowSwap); + }, + link: (amount, token) => + `https://swap.cow.fi/#/1/swap/${getTokenAddress( + CHAINS.Mainnet, + token, + )}/ETH?sellAmount=${formatEther(amount)}&utm_source=lido`, + }, +}; + +type DexOptionProps = { + title: string; + icon: JSX.Element; + url: string; + loading?: boolean; + toReceive: BigNumber | null; + onClickGoTo: React.MouseEventHandler; +}; + +const DexOption: React.FC = ({ + title, + icon, + url, + toReceive, + loading, + onClickGoTo, +}) => { + return ( + + {icon} + {title} + + Go to {title} + + + {loading ? ( + + ) : toReceive ? ( + + ) : ( + '-' + )} + + + ); +}; + +export const DexOptions: React.FC< + React.ComponentProps +> = (props) => { + const { data, initialLoading, loading, amount, selectedToken } = + useWithdrawalRates(); + + return ( + + {initialLoading + ? placeholder.map((_, i) => ) + : data?.map(({ name, toReceive, rate }) => { + const dex = dexInfo[name]; + if (!dex) return null; + return ( + + ); + })} + + ); +}; diff --git a/features/withdrawals/request/form/options/lido-option.tsx b/features/withdrawals/request/form/options/lido-option.tsx new file mode 100644 index 000000000..bab60bae1 --- /dev/null +++ b/features/withdrawals/request/form/options/lido-option.tsx @@ -0,0 +1,72 @@ +import Link from 'next/link'; +import { useWatch } from 'react-hook-form'; +import { formatEther } from '@ethersproject/units'; + +import { Tooltip, Question } from '@lidofinance/lido-ui'; +import { TOKENS } from '@lido-sdk/constants'; + +import { useEthAmountByStethWsteth } from 'features/withdrawals/hooks'; +import { RequestFormInputType } from 'features/withdrawals/request/request-form-context'; + +import { + trackMatomoEvent, + MATOMO_CLICK_EVENTS_TYPES, +} from 'config/trackMatomoEvent'; + +import { + FormatTokenStyled, + LidoIcon, + LidoOptionContainer, + LidoOptionValue, +} from './styles'; + +const TooltipWithdrawalAmount = () => { + return ( + + The final amount of claimable ETH can differ +
For more info, please read{' '} + + + + + } + > + +
+ ); +}; + +export const LidoOption = () => { + const [token, amount] = useWatch({ + name: ['token', 'amount'], + }); + + // TODO: refactor to use intermediate validation values + const ethAmount = useEthAmountByStethWsteth({ + isSteth: token === TOKENS.STETH, + input: amount ? formatEther(amount) : undefined, + }); + + return ( + + + Lido + + {' '} + + + + ); +}; diff --git a/features/withdrawals/request/form/options/options-picker.tsx b/features/withdrawals/request/form/options/options-picker.tsx new file mode 100644 index 000000000..4ee34fa98 --- /dev/null +++ b/features/withdrawals/request/form/options/options-picker.tsx @@ -0,0 +1,128 @@ +import { formatEther, parseEther } from '@ethersproject/units'; + +import { useWaitingTime } from 'features/withdrawals/hooks/useWaitingTime'; +import { useWithdrawalRates } from 'features/withdrawals/hooks/useWithdrawalRates'; +import { useWstethToStethRatio } from 'shared/components/data-table-row-steth-by-wsteth'; + +import { formatBalance } from 'utils/formatBalance'; + +import { + InlineLoaderSmall, + LidoIcon, + CowSwapIcon, + OneInchIcon, + ParaSwapIcon, + OptionsPickerButton, + OptionsPickerContainer, + OptionsPickerIcons, + OptionsPickerLabel, + OptionsPickerRow, + OptionsPickerSubLabel, +} from './styles'; +import { + trackMatomoEvent, + MATOMO_CLICK_EVENTS_TYPES, +} from 'config/trackMatomoEvent'; +import { useWatch } from 'react-hook-form'; +import { RequestFormInputType } from 'features/withdrawals/request/request-form-context'; +import { TOKENS } from '@lido-sdk/constants'; + +type OptionButtonProps = { + onClick: React.ComponentProps<'button'>['onClick']; + isActive?: boolean; +}; + +const DEFAULT_VALUE_FOR_RATE = parseEther('1'); + +const LidoButton: React.FC = ({ isActive, onClick }) => { + const [amount, token] = useWatch({ + name: ['amount', 'token'], + }); + const isSteth = token === TOKENS.STETH; + const { value: waitingTime, initialLoading } = useWaitingTime( + amount ? formatEther(amount) : '', + { + isApproximate: true, + }, + ); + const { wstethAsStethBN, loading } = useWstethToStethRatio(); + const ratioLoading = !isSteth && loading; + const ratio = isSteth ? '1 : 1' : `1 : ${formatBalance(wstethAsStethBN)}`; + + return ( + + + Use Lido + + + + + + Rate: + {ratioLoading ? : ratio} + + + Waiting time: + {initialLoading ? : waitingTime} + + + ); +}; + +const DexButton: React.FC = ({ isActive, onClick }) => { + const { loading, bestRate } = useWithdrawalRates({ + fallbackValue: DEFAULT_VALUE_FOR_RATE, + }); + const bestRateValue = bestRate ? `1 : ${bestRate.toFixed(4)}` : '-'; + return ( + + + Use aggregators + + + + + + + + Best Rate: + {loading ? : bestRateValue} + + + Waiting time:~ 1-5 + minutes + + + ); +}; + +type OptionsPickerProps = { + selectedOption: 'lido' | 'dex'; + onOptionSelect?: (value: 'lido' | 'dex') => void; +}; + +export const OptionsPicker: React.FC = ({ + onOptionSelect, + selectedOption, +}) => { + return ( + + { + e.preventDefault(); + trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.withdrawalUseLido); + onOptionSelect?.('lido'); + }} + /> + { + e.preventDefault(); + trackMatomoEvent(MATOMO_CLICK_EVENTS_TYPES.withdrawalUseAggregators); + onOptionSelect?.('dex'); + }} + /> + + ); +}; diff --git a/features/withdrawals/request/form/options/styles.ts b/features/withdrawals/request/form/options/styles.ts new file mode 100644 index 000000000..470f9d498 --- /dev/null +++ b/features/withdrawals/request/form/options/styles.ts @@ -0,0 +1,260 @@ +import styled from 'styled-components'; +import { InlineLoader, ThemeName } from '@lidofinance/lido-ui'; +import { FormatToken } from 'shared/formatters'; + +import Lido from 'assets/icons/lido.svg'; +import Oneinch from 'assets/icons/oneinch-circle.svg'; +import Paraswao from 'assets/icons/paraswap-circle.svg'; +import Cowswap from 'assets/icons/cowswap-circle.svg'; +import ExternalLink from 'assets/icons/external-link-icon.svg'; + +// ICONS + +export const LidoIcon = styled.img.attrs({ + src: Lido, + alt: '', +})` + display: block; +`; + +export const OneInchIcon = styled.img.attrs({ + src: Oneinch, + alt: '1inch', +})` + display: block; +`; + +export const ParaSwapIcon = styled.img.attrs({ + src: Paraswao, + alt: 'paraswap', +})` + display: block; +`; + +export const CowSwapIcon = styled.img.attrs({ + src: Cowswap, + alt: 'cowswap', +})` + display: block; +`; + +export const OptionAmountRow = styled.div` + display: flex; + align-items: center; +`; + +// LIDO OPTION + +export const LidoOptionContainer = styled.div` + width: 100%; + min-height: 82px; + // we need to update lido ui + background-color: ${({ theme }) => + theme.name === ThemeName.light ? '#F6F8FA' : '#2D2D35'}; + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg}px; + + padding: 16px 20px; + display: flex; + flex-direction: row; + align-items: center; + gap: 16px; + + margin-bottom: 16px; + + color: var(--lido-color-text); + font-weight: 400; + font-size: 14px; +`; + +export const LidoOptionValue = styled.div` + display: flex; + flex-direction: row; + align-items: center; + margin-left: auto; +`; + +export const FormatTokenStyled = styled(FormatToken)` + font-size: 14px; + line-height: 24px; + font-weight: 700; + color: var(--lido-color-text); +`; + +// OPTIONS PICKER + +export const OptionsPickerContainer = styled.div` + display: flex; + gap: 16px; + align-items: stretch; + justify-content: space-between; + margin-bottom: 16px; +`; + +export const OptionsPickerButton = styled.button<{ $active?: boolean }>` + flex: 1 0; + display: flex; + flex-direction: column; + gap: 4px; + // we need to update lido ui + background-color: ${({ theme }) => + theme.name === ThemeName.light ? '#F6F8FA' : '#2D2D35'}; + + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg}px; + border: 1px solid var(--lido-color-border); + position: relative; + cursor: pointer; + + border-color: ${({ $active }) => + $active ? '#00A3FF' : 'var(--lido-color-border)'}; + padding: 16px 20px; + font-size: 12px; + font-family: inherit; + color: var(--lido-color-text); + + /* safari workaround */ + &:focus { + outline: none; + ::before { + content: ''; + pointer-events: none; + position: absolute; + top: -2px; + right: -2px; + bottom: -2px; + left: -2px; + + border: 1px solid var(--lido-color-borderActive); + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg + 1}px; + } + } + + & > :first-child { + margin-bottom: 12px; + } +`; + +export const OptionsPickerRow = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + + width: 100%; + gap: 8px; + line-height: 20px; + text-align: right; + ${({ theme }) => theme.mediaQueries.md} { + flex-direction: column; + text-align: center; + } +`; + +export const OptionsPickerLabel = styled.label` + color: var(--color-text); + font-weight: 700; + text-align: left; + ${({ theme }) => theme.mediaQueries.md} { + text-align: center; + } +`; +export const OptionsPickerSubLabel = styled.label` + color: var(--lido-color-textSecondary); + line-height: 20px; + text-align: left; + ${({ theme }) => theme.mediaQueries.md} { + text-align: center; + } +`; + +export const OptionsPickerIcons = styled.div` + display: flex; + justify-content: end; + + & > * { + box-sizing: content-box; + width: 20px; + height: 20px; + border-radius: 100%; + border: 1px solid var(--lido-color-backgroundSecondary); + background-color: var(--lido-color-backgroundSecondary); + margin: -1px 0 -1px -8px; + &:first-child { + margin-left: 0px; + } + } +`; + +// DEX OPTIONS + +export const DexOptionsContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +export const DexOptionStyled = styled.div<{ $loading?: boolean }>` + width: 100%; + min-height: 82px; + // we need to update lido ui + background-color: ${({ theme }) => + theme.name === ThemeName.light ? '#F6F8FA' : '#2D2D35'}; + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg}px; + padding: 16px 20px; + + display: grid; + gap: 5px 16px; + grid-template: 1fr 1fr / 44px max-content; + + & > svg, + & > img { + grid-row: 1 / 3; + grid-column: 1 / 1; + align-self: center; + width: 44px; + } +`; + +export const DexOptionLoader = styled(InlineLoader)` + display: block; + width: 100%; + min-height: 82px; + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg}px; +`; + +export const DexOptionBlockTitle = styled.span` + grid-row: 1; + grid-column: 2; + color: var(--lido-color-text); + font-weight: 400; + font-size: 14px; +`; + +export const DexOptionBlockLink = styled.a` + grid-row: 2; + grid-column: 2; + &::after { + content: ' '; + display: inline-block; + background: url(${ExternalLink}) center / contain no-repeat; + width: 12px; + height: 12px; + margin-left: 8px; + margin-bottom: -1px; + } +`; + +export const DexOptionAmount = styled.span` + grid-row: 1 / 3; + grid-column: 3; + width: 100%; + justify-self: end; + align-self: center; + text-align: end; + + color: var(--lido-color-text); + font-weight: 700; + font-size: 14px; +`; + +export const InlineLoaderSmall = styled(InlineLoader)` + max-width: 74px; +`; diff --git a/features/withdrawals/request/form/paused-info.tsx b/features/withdrawals/request/form/paused-info.tsx new file mode 100644 index 000000000..fdc57e81a --- /dev/null +++ b/features/withdrawals/request/form/paused-info.tsx @@ -0,0 +1,13 @@ +import { InfoBoxStyled } from 'features/withdrawals/shared'; +import { Link } from '@lidofinance/lido-ui'; + +const LIDO_TWITTER_LINK = 'https://twitter.com/lidofinance'; + +export const PausedInfo = () => { + return ( + + Withdrawals are currently unavailable. For more information,{' '} + see here + + ); +}; diff --git a/features/withdrawals/request/form/request-form.tsx b/features/withdrawals/request/form/request-form.tsx new file mode 100644 index 000000000..0fc81b549 --- /dev/null +++ b/features/withdrawals/request/form/request-form.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { Block } from '@lidofinance/lido-ui'; + +import { BunkerInfo } from './bunker-info'; +import { PausedInfo } from './paused-info'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; +import { useFormState, useWatch } from 'react-hook-form'; +import { + RequestFormInputType, + useRequestFormData, +} from 'features/withdrawals/request/request-form-context'; + +import { TokenInput } from './inputs/token-input'; +import { AmountInput } from './inputs/amount-input'; +import { ErrorMessageInputGroup } from './inputs/input-group'; +import { RequestsInfo } from './requests-info'; +import { ModeInput } from './inputs/mode-input'; +import { DexOptions } from './options/dex-options'; +import { LidoOption } from './options/lido-option'; +import { SubmitButton } from './submit-button'; +import { TransactionInfo } from './transaction-info'; + +export const RequestForm = () => { + const { isBunker, isPaused } = useWithdrawals(); + const { onSubmit } = useRequestFormData(); + // conditional render breaks useFormState, so it can't be inside SubmitButton + const { isValidating, isSubmitting, errors } = + useFormState({ name: ['requests', 'amount'] }); + const mode = useWatch({ name: 'mode' }); + + return ( + + {isPaused && } + {isBunker && } +
+ + + + + {mode === 'lido' && } + + {mode === 'lido' && ( + <> + + + + + )} + {mode === 'dex' && } + +
+ ); +}; diff --git a/features/withdrawals/request/form/requests-info.tsx b/features/withdrawals/request/form/requests-info.tsx new file mode 100644 index 000000000..289703c19 --- /dev/null +++ b/features/withdrawals/request/form/requests-info.tsx @@ -0,0 +1,44 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { useFormState, useWatch } from 'react-hook-form'; +import { FormatToken } from 'shared/formatters'; +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { + RequestFormInputType, + useRequestFormData, + useValidationResults, +} from '../request-form-context'; +import { RequestsInfoStyled, RequestsInfoDescStyled } from './styles'; +import { ValidationSplitRequest } from '../request-form-context/validators'; + +export const RequestsInfo = () => { + const { errors } = useFormState(); + const token = useWatch({ name: 'token' }); + const { requests } = useValidationResults(); + const { maxAmountPerRequestSteth, maxAmountPerRequestWSteth } = + useRequestFormData(); + + if (errors.amount?.type === ValidationSplitRequest.type) + return ( + + {errors.amount.message} + + ); + + const requestCount = requests?.length ?? 0; + const maxPerTx = + token === TOKENS.STETH + ? maxAmountPerRequestSteth + : maxAmountPerRequestWSteth; + + if (requestCount <= 1 || !maxPerTx) return null; + return ( + + + Your amount will be split into {requestCount} requests because{' '} + is + the maximum amount per one request. Although it will be {requestCount}{' '} + requests, you will pay one transaction fee. + + + ); +}; diff --git a/features/withdrawals/request/form/styles.ts b/features/withdrawals/request/form/styles.ts new file mode 100644 index 000000000..1645dba17 --- /dev/null +++ b/features/withdrawals/request/form/styles.ts @@ -0,0 +1,27 @@ +import styled from 'styled-components'; +import { InputGroup } from '@lidofinance/lido-ui'; + +export const InputGroupStyled = styled(InputGroup)<{ success?: string }>` + margin-bottom: ${({ theme }) => theme.spaceMap.md}px; + z-index: 2; + + span:nth-of-type(2) { + white-space: ${({ success }) => success && 'unset'}; + } +`; + +export const ButtonLinkWrap = styled.a` + display: block; +`; + +export const RequestsInfoStyled = styled.div` + background-color: var(--lido-color-backgroundSecondary); + padding: ${({ theme }) => theme.spaceMap.lg}px; + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg}px; + margin-bottom: ${({ theme }) => theme.spaceMap.md}px; +`; + +export const RequestsInfoDescStyled = styled.div` + font-size: 12px; + line-height: 20px; +`; diff --git a/features/withdrawals/request/form/submit-button.tsx b/features/withdrawals/request/form/submit-button.tsx new file mode 100644 index 000000000..df74cd839 --- /dev/null +++ b/features/withdrawals/request/form/submit-button.tsx @@ -0,0 +1,35 @@ +import { useRequestFormData } from '../request-form-context'; +import { ButtonIcon, Lock } from '@lidofinance/lido-ui'; +import { useWeb3 } from '@reef-knot/web3-react'; + +import { Connect } from 'shared/wallet'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; + +type SubmitButtonProps = { + disabled?: boolean; + loading?: boolean; +}; + +export const SubmitButton = ({ disabled, loading }: SubmitButtonProps) => { + const { isTokenLocked } = useRequestFormData(); + const { active } = useWeb3(); + const { isPaused } = useWithdrawals(); + + if (!active) return ; + + const buttonTitle = isTokenLocked + ? 'Unlock tokens for withdrawal' + : 'Request withdrawal'; + + return ( + : <>} + disabled={disabled || isPaused} + loading={loading} + > + {buttonTitle} + + ); +}; diff --git a/features/withdrawals/request/form/transaction-info.tsx b/features/withdrawals/request/form/transaction-info.tsx new file mode 100644 index 000000000..6327e4269 --- /dev/null +++ b/features/withdrawals/request/form/transaction-info.tsx @@ -0,0 +1,71 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { DataTableRow } from '@lidofinance/lido-ui'; +import { useRequestTxPrice } from 'features/withdrawals/hooks/useWithdrawTxPrice'; +import { useApproveGasLimit } from 'features/wrap/features/wrap-form/hooks'; +import { useWatch } from 'react-hook-form'; +import { DataTableRowStethByWsteth } from 'shared/components/data-table-row-steth-by-wsteth'; +import { FormatToken } from 'shared/formatters'; +import { useTxCostInUsd } from 'shared/hooks'; +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { + RequestFormInputType, + useRequestFormData, + useValidationResults, +} from '../request-form-context'; +import { MaxUint256 } from '@ethersproject/constants'; +import { useMemo } from 'react'; + +export const TransactionInfo = () => { + const { isApprovalFlow, isApprovalFlowLoading, allowance } = + useRequestFormData(); + const token = useWatch({ name: 'token' }); + const { requests } = useValidationResults(); + const unlockCostTooltip = isApprovalFlow ? undefined : ( + <>Lido leverages gasless token approvals via ERC-2612 permits + ); + const { txPriceUsd: requestTxPriceInUsd, loading: requestTxPriceLoading } = + useRequestTxPrice({ + token, + isApprovalFlow, + requestCount: requests?.length, + }); + const approveTxCostInUsd = useTxCostInUsd(useApproveGasLimit()); + + const isInfiniteAllowance = useMemo(() => { + return allowance.eq(MaxUint256); + }, [allowance]); + + return ( + <> + + {isApprovalFlow ? `$${approveTxCostInUsd?.toFixed(2)}` : 'FREE'} + + + ${requestTxPriceInUsd?.toFixed(2)} + + + {isInfiniteAllowance ? ( + 'Infinite' + ) : ( + + )} + + {token === TOKENS.STETH ? ( + 1 stETH = 1 ETH + ) : ( + + )} + + ); +}; diff --git a/features/withdrawals/request/index.ts b/features/withdrawals/request/index.ts new file mode 100644 index 000000000..56e4b0555 --- /dev/null +++ b/features/withdrawals/request/index.ts @@ -0,0 +1 @@ +export * from './request'; diff --git a/features/withdrawals/request/request-form-context/index.tsx b/features/withdrawals/request/request-form-context/index.tsx new file mode 100644 index 000000000..8744cbbce --- /dev/null +++ b/features/withdrawals/request/request-form-context/index.tsx @@ -0,0 +1,2 @@ +export * from './request-form-context'; +export * from './types'; diff --git a/features/withdrawals/request/request-form-context/request-form-context.tsx b/features/withdrawals/request/request-form-context/request-form-context.tsx new file mode 100644 index 000000000..21004505e --- /dev/null +++ b/features/withdrawals/request/request-form-context/request-form-context.tsx @@ -0,0 +1,132 @@ +import { useMemo, useState, createContext, useContext } from 'react'; +import { FormProvider, useForm, useWatch } from 'react-hook-form'; +import invariant from 'tiny-invariant'; +import { TOKENS } from '@lido-sdk/constants'; + +import { useWithdrawalRequest } from 'features/withdrawals/hooks'; + +import { RequestFormValidationResolver } from './validators'; +import { useRequestFormDataContextValue } from './use-request-form-data-context-value'; +import { useValidationContext } from './use-validation-context'; +import { + RequestFormDataContextValueType, + RequestFormInputType, + RequestFormValidationContextType, + ValidationResults, +} from './types'; + +// +// data context +// +const RequestFormDataContext = + createContext(null); +RequestFormDataContext.displayName = 'RequestFormDataContext'; + +export const useRequestFormData = () => { + const value = useContext(RequestFormDataContext); + invariant(value, 'useRequestFormData was used outside the provider'); + return value; +}; + +// +// intermediate values context +// +const IntermediateValidationResultsContext = + createContext(null); +IntermediateValidationResultsContext.displayName = + 'IntermediateValidationResultsContext'; + +export const useValidationResults = () => { + const value = useContext(IntermediateValidationResultsContext); + invariant(value, 'useValidationResults was used outside the provider'); + return value; +}; + +// +// Joint provider for form state, data, intermediate validation results +// +export const RequestFormProvider: React.FC = ({ children }) => { + const [intermediateValidationResults, setIntermediateValidationResults] = + useState({ requests: null }); + + const requestFormData = useRequestFormDataContextValue(); + const { onSuccessRequest } = requestFormData; + const validationContext = useValidationContext( + requestFormData, + setIntermediateValidationResults, + ); + const formObject = useForm< + RequestFormInputType, + Promise + >({ + defaultValues: { + amount: null, + token: TOKENS.STETH, + mode: 'lido', + requests: null, + }, + context: validationContext.awaiter, + criteriaMode: 'firstError', + mode: 'onChange', + resolver: RequestFormValidationResolver, + }); + + // TODO refactor this part as part of TX flow + const { control, handleSubmit, reset } = formObject; + const [token, amount] = useWatch({ + control: control, + name: ['token', 'amount'], + }); + const { + allowance, + request, + isApprovalFlow, + isApprovalFlowLoading, + isTokenLocked, + } = useWithdrawalRequest({ + token, + amount, + }); + + const onSubmit = useMemo( + () => + handleSubmit(async ({ requests, amount, token }) => { + const { success } = await request(requests, amount, token); + if (success) { + await onSuccessRequest(); + reset(); + } + }), + [reset, handleSubmit, request, onSuccessRequest], + ); + + const value = useMemo(() => { + return { + ...requestFormData, + isApprovalFlow, + isApprovalFlowLoading, + isTokenLocked, + allowance, + onSubmit, + }; + }, [ + requestFormData, + isApprovalFlow, + isApprovalFlowLoading, + isTokenLocked, + allowance, + onSubmit, + ]); + + return ( + + + + {children} + + + + ); +}; diff --git a/features/withdrawals/request/request-form-context/types.ts b/features/withdrawals/request/request-form-context/types.ts new file mode 100644 index 000000000..802ce3f7e --- /dev/null +++ b/features/withdrawals/request/request-form-context/types.ts @@ -0,0 +1,40 @@ +import { TOKENS } from '@lido-sdk/constants'; +import { BigNumber } from 'ethers'; +import { Dispatch, SetStateAction } from 'react'; +import { useRequestFormDataContextValue } from './use-request-form-data-context-value'; + +export type ValidationResults = { + requests: null | BigNumber[]; +}; + +export type RequestFormInputType = { + amount: null | BigNumber; + token: TOKENS.STETH | TOKENS.WSTETH; + mode: 'lido' | 'dex'; +} & ValidationResults; + +export type RequestFormValidationContextType = { + setIntermediateValidationResults: Dispatch>; + minUnstakeSteth: BigNumber; + minUnstakeWSteth: BigNumber; + balanceSteth: BigNumber; + balanceWSteth: BigNumber; + maxAmountPerRequestSteth: BigNumber; + maxAmountPerRequestWSteth: BigNumber; + stethTotalSupply: BigNumber; + maxRequestCount: number; +}; +export type RequestFormDataType = ReturnType< + typeof useRequestFormDataContextValue +>; + +export type ExtraRequestFormDataType = { + isApprovalFlow: boolean; + isApprovalFlowLoading: boolean; + isTokenLocked: boolean; + allowance: BigNumber; + onSubmit: NonNullable['onSubmit']>; +}; + +export type RequestFormDataContextValueType = RequestFormDataType & + ExtraRequestFormDataType; diff --git a/features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts b/features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts new file mode 100644 index 000000000..658b7e5cb --- /dev/null +++ b/features/withdrawals/request/request-form-context/use-request-form-data-context-value.ts @@ -0,0 +1,84 @@ +import { + useSTETHContractRPC, + useWSTETHContractRPC, + useSTETHBalance, + useWSTETHBalance, + useContractSWR, +} from '@lido-sdk/react'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; +import { useUnfinalizedStETH } from 'features/withdrawals/hooks'; +import { useCallback, useMemo } from 'react'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; + +// Provides all data fetching for form to function +export const useRequestFormDataContextValue = () => { + const { update: withdrawalRequestsDataUpdate } = useClaimData(); + // useTotalSupply is bugged and switches to undefined for 1 render + const stethTotalSupply = useContractSWR({ + contract: useSTETHContractRPC(), + method: 'totalSupply', + config: STRATEGY_LAZY, + }).data; + const { maxAmount: maxAmountPerRequestSteth, minAmount: minUnstakeSteth } = + useWithdrawals(); + const wstethContract = useWSTETHContractRPC(); + const { data: balanceSteth, update: stethUpdate } = useSTETHBalance(); + const { data: balanceWSteth, update: wstethUpdate } = useWSTETHBalance(); + const { data: unfinalizedStETH, update: unfinalizedStETHUpdate } = + useUnfinalizedStETH(); + + const maxAmountPerRequestWSteth = useContractSWR({ + contract: wstethContract, + method: 'getWstETHByStETH', + params: [maxAmountPerRequestSteth], + shouldFetch: !!maxAmountPerRequestSteth, + config: STRATEGY_LAZY, + }).data; + const minUnstakeWSteth = useContractSWR({ + contract: wstethContract, + method: 'getWstETHByStETH', + params: [minUnstakeSteth], + shouldFetch: !!minUnstakeSteth, + config: STRATEGY_LAZY, + }).data; + + const onSuccessRequest = useCallback(() => { + return Promise.all([ + stethUpdate(), + wstethUpdate(), + withdrawalRequestsDataUpdate(), + unfinalizedStETHUpdate(), + ]); + }, [ + stethUpdate, + unfinalizedStETHUpdate, + withdrawalRequestsDataUpdate, + wstethUpdate, + ]); + + return useMemo( + () => ({ + maxAmountPerRequestSteth, + minUnstakeSteth, + balanceSteth, + balanceWSteth, + maxAmountPerRequestWSteth, + minUnstakeWSteth, + stethTotalSupply, + unfinalizedStETH, + onSuccessRequest, + }), + [ + balanceSteth, + balanceWSteth, + maxAmountPerRequestSteth, + maxAmountPerRequestWSteth, + minUnstakeSteth, + minUnstakeWSteth, + stethTotalSupply, + unfinalizedStETH, + onSuccessRequest, + ], + ); +}; diff --git a/features/withdrawals/request/request-form-context/use-validation-context.ts b/features/withdrawals/request/request-form-context/use-validation-context.ts new file mode 100644 index 000000000..2e314bbe4 --- /dev/null +++ b/features/withdrawals/request/request-form-context/use-validation-context.ts @@ -0,0 +1,64 @@ +import { + MAX_REQUESTS_COUNT_LEDGER_LIMIT, + MAX_REQUESTS_COUNT, +} from 'features/withdrawals/withdrawals-constants'; +import { useMemo } from 'react'; +import { useIsLedgerLive } from 'shared/hooks/useIsLedgerLive'; +import { useAwaiter } from 'shared/hooks/use-awaiter'; +import { RequestFormDataType, RequestFormValidationContextType } from './types'; + +// Prepares validation context object from request form data +export const useValidationContext = ( + requestData: RequestFormDataType, + setIntermediateValidationResults: RequestFormValidationContextType['setIntermediateValidationResults'], +) => { + const isLedgerLive = useIsLedgerLive(); + const maxRequestCount = isLedgerLive + ? MAX_REQUESTS_COUNT_LEDGER_LIMIT + : MAX_REQUESTS_COUNT; + const { + balanceSteth, + balanceWSteth, + maxAmountPerRequestSteth, + maxAmountPerRequestWSteth, + minUnstakeSteth, + minUnstakeWSteth, + stethTotalSupply, + } = requestData; + + const context = useMemo(() => { + const validationContextObject = + balanceSteth && + balanceWSteth && + maxAmountPerRequestSteth && + maxAmountPerRequestWSteth && + minUnstakeSteth && + minUnstakeWSteth && + stethTotalSupply + ? { + balanceSteth, + balanceWSteth, + maxAmountPerRequestSteth, + maxAmountPerRequestWSteth, + minUnstakeSteth, + minUnstakeWSteth, + maxRequestCount, + stethTotalSupply, + setIntermediateValidationResults, + } + : undefined; + return validationContextObject; + }, [ + balanceSteth, + balanceWSteth, + maxAmountPerRequestSteth, + maxAmountPerRequestWSteth, + maxRequestCount, + minUnstakeSteth, + minUnstakeWSteth, + setIntermediateValidationResults, + stethTotalSupply, + ]); + + return useAwaiter(context); +}; diff --git a/features/withdrawals/request/request-form-context/validators.ts b/features/withdrawals/request/request-form-context/validators.ts new file mode 100644 index 000000000..9fbfbab67 --- /dev/null +++ b/features/withdrawals/request/request-form-context/validators.ts @@ -0,0 +1,277 @@ +import { MaxUint256, Zero } from '@ethersproject/constants'; +import { formatEther } from '@ethersproject/units'; +import { TOKENS } from '@lido-sdk/constants'; +import { BigNumber } from 'ethers'; +import invariant from 'tiny-invariant'; +import { Resolver } from 'react-hook-form'; + +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { TokensWithdrawable } from 'features/withdrawals/types/tokens-withdrawable'; +import { + RequestFormValidationContextType, + RequestFormInputType, + ValidationResults, +} from '.'; +import { VALIDATION_CONTEXT_TIMEOUT } from 'features/withdrawals/withdrawals-constants'; + +// helpers that should be shared when adding next hook-form + +export const withTimeout = (toWait: Promise, timeout: number) => + Promise.race([ + toWait, + new Promise((_, reject) => + setTimeout(() => reject(new Error('promise timeout')), timeout), + ), + ]); + +export class ValidationError extends Error { + field: string; + type: string; + payload: Record; + constructor( + field: string, + msg: string, + type?: string, + payload?: Record, + ) { + super(msg); + this.field = field; + this.type = type ?? 'validate'; + this.payload = payload ?? {}; + } +} + +export type TvlErrorPayload = { + balanceDiffSteth: BigNumber; +}; +export class ValidationTvlJoke extends ValidationError { + static type = 'validate_tvl_joke'; + payload: TvlErrorPayload; + constructor(field: string, msg: string, payload: TvlErrorPayload) { + super(field, msg, ValidationTvlJoke.type); + this.payload = payload; + } +} + +export type SplitRequestErrorPayload = { + requestCount: number; +}; + +export class ValidationSplitRequest extends ValidationError { + static type = 'validation_request_split'; + payload: SplitRequestErrorPayload; + constructor(field: string, msg: string, payload: SplitRequestErrorPayload) { + super(field, msg, ValidationTvlJoke.type); + this.payload = payload; + } +} + +// asserts only work with function declaration +// eslint-disable-next-line func-style +function validateEtherAmount( + field: string, + amount: BigNumber | null, + token: TokensWithdrawable, +): asserts amount is BigNumber { + if (!amount) + throw new ValidationError( + field, + `${getTokenDisplayName(token)} ${field} is required`, + ); + + if (amount.lte(Zero)) + throw new ValidationError( + field, + `${getTokenDisplayName(token)} ${field} must be greater than 0`, + ); + + if (amount.gt(MaxUint256)) + throw new ValidationError( + field, + `${getTokenDisplayName(token)} ${field} is not valid`, + ); +} + +const validateMinUnstake = ( + field: string, + value: BigNumber, + min: BigNumber, + token: TokensWithdrawable, +) => { + if (value.lt(min)) + throw new ValidationError( + field, + `Minimum unstake amount is ${formatEther(min)} ${getTokenDisplayName( + token, + )}`, + ); + return value; +}; + +const validateMaxAmount = ( + field: string, + value: BigNumber, + max: BigNumber, + token: TokensWithdrawable, +) => { + if (value.gt(max)) + throw new ValidationError( + field, + `${getTokenDisplayName( + token, + )} ${field} must not be greater than ${formatEther(max)}`, + ); +}; + +// TODO!: write tests for this validation function +const validateSplitRequests = ( + field: string, + amount: BigNumber, + amountPerRequest: BigNumber, + maxRequestCount: number, +): BigNumber[] => { + const maxAmount = amountPerRequest.mul(maxRequestCount); + + const lastRequestAmountEther = amount.mod(amountPerRequest); + const restCount = lastRequestAmountEther.gt(0) ? 1 : 0; + const requestCount = amount.div(amountPerRequest).toNumber() + restCount; + + const isMoreThanMax = amount.gt(maxAmount); + if (isMoreThanMax) { + throw new ValidationError( + field, + `You can send a maximum of ${maxRequestCount} requests per transaction. Current requests count is ${requestCount}.`, + 'validation_request_split', + { requestCount }, + ); + } + + const requests = Array(requestCount).fill(amountPerRequest); + if (restCount) { + requests[requestCount - 1] = lastRequestAmountEther; + } + + return requests; +}; + +const tvlJokeValidate = ( + field: string, + valueSteth: BigNumber, + tvl: BigNumber, + balanceSteth: BigNumber, +) => { + const tvlDiff = valueSteth.sub(tvl); + if (tvlDiff.gt(0)) + throw new ValidationTvlJoke(field, 'amount bigger than tvl', { + balanceDiffSteth: valueSteth.sub(balanceSteth), + }); +}; + +// helper to get filter out context values +const transformContext = ( + context: RequestFormValidationContextType, + values: RequestFormInputType, +) => { + const isSteth = values.token === TOKENS.STETH; + return { + isSteth, + balance: isSteth ? context.balanceSteth : context.balanceWSteth, + minAmountPerRequest: isSteth + ? context.minUnstakeSteth + : context.minUnstakeWSteth, + maxAmountPerRequest: isSteth + ? context.maxAmountPerRequestSteth + : context.maxAmountPerRequestWSteth, + maxRequestCount: context.maxRequestCount, + stethTotalSupply: context.stethTotalSupply, + }; +}; + +// Validation pipeline resolver +// receives values from form and context with helper data +// returns values or errors +export const RequestFormValidationResolver: Resolver< + RequestFormInputType, + Promise +> = async (values, contextPromise) => { + const { amount, mode, token } = values; + const validationResults: ValidationResults = { + requests: null, + }; + let setResults; + try { + // this check does not require context and can be placed first + // also limits context missing edge cases on page start + validateEtherAmount('amount', amount, token); + + // wait for context promise with timeout and extract relevant data + // validation function only waits limited time for data and fails validation otherwise + // most of the time data will already be available + invariant(contextPromise, 'must have context promise'); + const context = await withTimeout( + contextPromise, + VALIDATION_CONTEXT_TIMEOUT, + ); + setResults = context.setIntermediateValidationResults; + const { + isSteth, + balance, + maxAmountPerRequest, + minAmountPerRequest, + maxRequestCount, + stethTotalSupply, + } = transformContext(context, values); + + if (isSteth) tvlJokeValidate('amount', amount, stethTotalSupply, balance); + + // early validation exit for dex option + if (mode === 'dex') { + return { values, errors: {} }; + } + + const requests = validateSplitRequests( + 'amount', + amount, + maxAmountPerRequest, + maxRequestCount, + ); + validationResults.requests = requests; + + validateMinUnstake('amount', amount, minAmountPerRequest, token); + + validateMaxAmount('amount', amount, balance, token); + + return { + values: { ...values, requests }, + errors: {}, + }; + } catch (error) { + if (error instanceof ValidationError) { + return { + values: {}, + errors: { + [error.field]: { + message: error.message, + type: error.type, + payload: error.payload, + }, + }, + }; + } + console.warn('[RequestForm] Unhandled validation error in resolver', error); + return { + values: {}, + errors: { + // for general errors we use 'requests' field + // cause non-fields get ignored and form is still considerate valid + requests: { + type: 'validate', + message: 'unknown validation error', + }, + }, + }; + } finally { + // no matter validation result save results for the UI to show + setResults?.(validationResults); + } +}; diff --git a/features/withdrawals/request/request.tsx b/features/withdrawals/request/request.tsx new file mode 100644 index 000000000..c0f45576c --- /dev/null +++ b/features/withdrawals/request/request.tsx @@ -0,0 +1,19 @@ +import { RequestFormProvider } from './request-form-context'; +import { RequestFaq } from '../withdrawals-faq/request-faq'; +import { RequestForm } from './form'; +import { TxRequestModal } from './tx-modal'; +import { RequestWallet } from './wallet'; +import { TransactionModalProvider } from '../contexts/transaction-modal-context'; + +export const Request = () => { + return ( + + + + + + + + + ); +}; diff --git a/features/withdrawals/request/tx-modal/index.ts b/features/withdrawals/request/tx-modal/index.ts new file mode 100644 index 000000000..56a81e7db --- /dev/null +++ b/features/withdrawals/request/tx-modal/index.ts @@ -0,0 +1 @@ +export * from './tx-request-modal'; diff --git a/features/withdrawals/request/tx-modal/styles.ts b/features/withdrawals/request/tx-modal/styles.ts new file mode 100644 index 000000000..18ccc2744 --- /dev/null +++ b/features/withdrawals/request/tx-modal/styles.ts @@ -0,0 +1,61 @@ +import styled from 'styled-components'; +import NFTExample from 'assets/nft-example.png'; + +export const NFTBanner = styled.div` + position: relative; + height: auto; + margin-top: 24px; + padding: ${({ theme }) => theme.spaceMap.xxl}px; + background-color: var(--lido-color-backgroundSecondary); + display: flex; + align-items: center; + flex-direction: column; + text-align: center; + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg}px; + overflow: hidden; + + ${({ theme }) => theme.mediaQueries.sm} { + padding: ${({ theme }) => theme.spaceMap.sm}px; + } +`; + +export const NFTImageWrap = styled.div` + display: flex; + align-items: center; + justify-content: center; + height: 180px; +`; + +export const NFTImage = styled.img.attrs({ + alt: '', +})` + flex: 0 0 auto; + display: block; + width: auto; + height: 100%; + border-radius: ${({ theme }) => theme.borderRadiusesMap.lg}px; + + ${({ theme }) => theme.mediaQueries.sm} { + margin-right: 8px; + } +`; + +export const NFTImageExample = styled(NFTImage).attrs({ + src: NFTExample.src, +})` + position: relative; + left: 20px; + height: 140%; +`; + +export const AddNftWrapper = styled.div` + position: relative; + margin-top: ${({ theme }) => theme.spaceMap.md}px; + width: 100%; +`; + +export const Title = styled.div` + margin-top: ${({ theme }) => theme.spaceMap.xl}px; + line-height: 24px; + font-size: 14px; +`; diff --git a/features/withdrawals/request/tx-modal/tx-request-modal.tsx b/features/withdrawals/request/tx-modal/tx-request-modal.tsx new file mode 100644 index 000000000..0b9b59b80 --- /dev/null +++ b/features/withdrawals/request/tx-modal/tx-request-modal.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react'; + +import { formatBalance } from 'utils'; +import { + TxStageModal, + TxStagePending, + TxStageSign, + TxStagePermit, + TxStageFail, + TxStageBunker, + TX_STAGE, +} from 'features/withdrawals/shared/tx-stage-modal'; +import { useTransactionModal } from 'features/withdrawals/contexts/transaction-modal-context'; + +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { TxRequestStageSuccess } from './tx-request-stage-success'; + +export const TxRequestModal = () => { + const { + dispatchModalState, + startTx, + requestAmount, + token, + txHash, + errorText, + isModalOpen, + onOkBunker, + onCloseBunker, + txStage, + } = useTransactionModal(); + + const content = useMemo(() => { + const tokenName = token ? getTokenDisplayName(token) : ''; + const amountAsString = requestAmount ? formatBalance(requestAmount, 4) : ''; + + const pendingDescription = 'Awaiting block confirmation'; + const pendingTitle = `You are requesting withdrawal for ${amountAsString} ${tokenName}`; + + const signDescription = + txStage === TX_STAGE.APPROVE + ? `Approving for ${amountAsString} ${tokenName}` + : `Requesting withdrawal for ${amountAsString} ${tokenName}`; + const signTitle = + txStage === TX_STAGE.APPROVE + ? `You are now approving ${amountAsString} ${tokenName}` + : `You are requesting withdrawal for ${amountAsString} ${tokenName}`; + + switch (txStage) { + case TX_STAGE.PERMIT: + return ; + case TX_STAGE.APPROVE: + case TX_STAGE.SIGN: + return ; + case TX_STAGE.BLOCK: + return ( + + ); + case TX_STAGE.SUCCESS: + return ( + + ); + case TX_STAGE.FAIL: + return ( + { + dispatchModalState({ type: 'reset' }); + startTx && startTx(); + }} + /> + ); + case TX_STAGE.BUNKER: + return ( + onOkBunker?.()} + onClose={() => { + onCloseBunker?.(); + dispatchModalState({ type: 'close_modal' }); + }} + /> + ); + default: + return null; + } + }, [ + dispatchModalState, + errorText, + onCloseBunker, + onOkBunker, + requestAmount, + startTx, + token, + txHash, + txStage, + ]); + + return ( + dispatchModalState({ type: 'close_modal' })} + txStage={txStage} + > + {content} + + ); +}; diff --git a/features/withdrawals/request/tx-modal/tx-request-stage-success.tsx b/features/withdrawals/request/tx-modal/tx-request-stage-success.tsx new file mode 100644 index 000000000..ff8484310 --- /dev/null +++ b/features/withdrawals/request/tx-modal/tx-request-stage-success.tsx @@ -0,0 +1,99 @@ +import { useSDK } from '@lido-sdk/react'; +import { useNftDataByTxHash } from 'features/withdrawals/hooks/useNftDataByTxHash'; + +import { Link, Loader } from '@lidofinance/lido-ui'; +import { TxStageSuccess } from 'features/withdrawals/shared/tx-stage-modal'; +import { TxLinkEtherscan } from 'shared/components/tx-link-etherscan'; + +import { + trackMatomoEvent, + MATOMO_CLICK_EVENTS_TYPES, +} from 'config/trackMatomoEvent'; +import { + Title, + NFTBanner, + NFTImageWrap, + NFTImage, + NFTImageExample, + AddNftWrapper, +} from './styles'; +import { WITHDRAWAL_CLAIM_PATH } from 'features/withdrawals/withdrawals-constants'; + +const LINK_ADD_NFT_GUIDE = + 'https://help.lido.fi/en/articles/7858367-how-do-i-add-the-lido-nft-to-metamask'; + +type TxRequestStageSuccessProps = { + txHash: string | null; + tokenName: string; + amountAsString: string; +}; + +export const TxRequestStageSuccess = ({ + txHash, + tokenName, + amountAsString, +}: TxRequestStageSuccessProps) => { + const { providerWeb3 } = useSDK(); + const { data: nftData, initialLoading: nftLoading } = + useNftDataByTxHash(txHash); + const showAddGuideLink = !!providerWeb3?.provider.isMetaMask; + + const successTitle = 'Withdrawal request successfully sent'; + + const successDescription = ( + + Withdrawal request for {amountAsString} {tokenName} has been sent. +
+ Check Claim tab to view your + withdrawal requests or view your transaction on{' '} + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.withdrawalEtherscanSuccessTemplate, + ) + } + /> +
+ ); + + const showNftLoader = nftLoading; + const showNftRealImage = !showNftLoader && nftData && nftData.length === 1; + const showNftExample = !showNftLoader && (!nftData || nftData.length !== 1); + + return ( + + + + {showNftLoader && } + {showNftRealImage && } + {showNftExample && } + + + Add NFT to your wallet to monitor the status + of your request. + + {showAddGuideLink && ( + + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.withdrawalGuideSuccessTemplate, + ) + } + > + This guide will help you to do this. + + + )} + + + ); +}; diff --git a/features/withdrawals/request/wallet/index.ts b/features/withdrawals/request/wallet/index.ts new file mode 100644 index 000000000..3c5958cf6 --- /dev/null +++ b/features/withdrawals/request/wallet/index.ts @@ -0,0 +1 @@ +export * from './wallet'; diff --git a/features/withdrawals/request/wallet/styles.ts b/features/withdrawals/request/wallet/styles.ts new file mode 100644 index 000000000..be2a868fb --- /dev/null +++ b/features/withdrawals/request/wallet/styles.ts @@ -0,0 +1,19 @@ +import styled from 'styled-components'; +import { DataTableRow } from '@lidofinance/lido-ui'; + +export const QueueInfoStyled = styled.div` + margin-top: ${({ theme }) => theme.spaceMap.md}px; + color: var(--lido-color-accentContrast); +`; + +export const DataTableRowStyled = styled(DataTableRow)` + margin: 0 0; + div { + color: var(--lido-color-accentContrast); + font-size: 10px; + } + + span { + --loader-color: var(--lido-color-accentContrast); + } +`; diff --git a/features/withdrawals/request/wallet/wallet-mode.tsx b/features/withdrawals/request/wallet/wallet-mode.tsx new file mode 100644 index 000000000..118b5dad1 --- /dev/null +++ b/features/withdrawals/request/wallet/wallet-mode.tsx @@ -0,0 +1,35 @@ +import { CardBalance } from 'shared/wallet'; +import { Status } from 'features/withdrawals/shared'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; + +import { WalletQueueTooltip } from './wallet-queue-tooltip'; + +export const WalletMode = () => { + const { + withdrawalsStatus, + isBunker, + isTurbo, + isWithdrawalsStatusLoading, + isPaused, + } = useWithdrawals(); + + const modeLabel = isBunker + ? 'Bunker' + : isPaused + ? 'Paused' + : isTurbo + ? 'Turbo' + : '-'; + + const content = {modeLabel}; + const timeTitle = <>Withdrawals mode {}; + + return ( + + ); +}; diff --git a/features/withdrawals/request/wallet/wallet-queue-tooltip.tsx b/features/withdrawals/request/wallet/wallet-queue-tooltip.tsx new file mode 100644 index 000000000..3496bfe6c --- /dev/null +++ b/features/withdrawals/request/wallet/wallet-queue-tooltip.tsx @@ -0,0 +1,57 @@ +import { Question, Tooltip } from '@lidofinance/lido-ui'; +import Link from 'next/link'; + +import { FormatToken } from 'shared/formatters'; +import { useWaitingTime } from 'features/withdrawals/hooks'; + +import { + trackMatomoEvent, + MATOMO_CLICK_EVENTS_TYPES, +} from 'config/trackMatomoEvent'; +import { QueueInfoStyled, DataTableRowStyled } from './styles'; +import { useRequestFormData } from '../request-form-context'; + +export const WalletQueueTooltip = () => { + const waitingTime = useWaitingTime(''); + const { unfinalizedStETH } = useRequestFormData(); + + const queueInfo = ( + + + + + + {waitingTime.value} + + + ); + + const tooltipTitle = ( + <> + The withdrawal request time depends on the mode, overall amount of stETH + in queue and{' '} + + + + .{queueInfo} + + ); + + return ( + + + + ); +}; diff --git a/features/withdrawals/request/wallet/wallet-steth-balance.tsx b/features/withdrawals/request/wallet/wallet-steth-balance.tsx new file mode 100644 index 000000000..e39457a34 --- /dev/null +++ b/features/withdrawals/request/wallet/wallet-steth-balance.tsx @@ -0,0 +1,20 @@ +import { CardBalance } from 'shared/wallet'; +import { FormatToken } from 'shared/formatters'; +import { useRequestFormData } from '../request-form-context'; + +export const WalletStethBalance = () => { + const { balanceSteth } = useRequestFormData(); + + const stethBalanceValue = ( + + ); + + return ( + + ); +}; diff --git a/features/withdrawals/request/wallet/wallet-wsteth-balance.tsx b/features/withdrawals/request/wallet/wallet-wsteth-balance.tsx new file mode 100644 index 000000000..aaef53259 --- /dev/null +++ b/features/withdrawals/request/wallet/wallet-wsteth-balance.tsx @@ -0,0 +1,29 @@ +import { Text } from '@lidofinance/lido-ui'; + +import { CardBalance } from 'shared/wallet'; +import { FormatToken } from 'shared/formatters'; +import { useStethByWsteth } from 'shared/hooks'; +import { useRequestFormData } from '../request-form-context'; + +export const WalletWstethBalance = () => { + const { balanceWSteth } = useRequestFormData(); + const stethByWstethBalance = useStethByWsteth(balanceWSteth); + + const stethBalanceValue = ( + <> + + + ≈ + + + ); + + return ( + + ); +}; diff --git a/features/withdrawals/request/wallet/wallet.tsx b/features/withdrawals/request/wallet/wallet.tsx new file mode 100644 index 000000000..f94442505 --- /dev/null +++ b/features/withdrawals/request/wallet/wallet.tsx @@ -0,0 +1,40 @@ +import { memo } from 'react'; +import { Divider } from '@lidofinance/lido-ui'; +import { useWeb3 } from '@reef-knot/web3-react'; +import { useSDK } from '@lido-sdk/react'; + +import { CardAccount, CardRow, Fallback } from 'shared/wallet'; +import { WalletMyRequests } from 'features/withdrawals/shared'; +import type { WalletComponentType } from 'shared/wallet/types'; +import { WalletWrapperStyled } from 'features/withdrawals/shared'; + +import { WalletStethBalance } from './wallet-steth-balance'; +import { WalletWstethBalance } from './wallet-wsteth-balance'; +import { WalletMode } from './wallet-mode'; +import { RequestFormInputType } from '../request-form-context'; +import { useWatch } from 'react-hook-form'; +import { TOKENS } from '@lido-sdk/constants'; + +export const WalletComponent = () => { + const { account } = useSDK(); + const token = useWatch({ name: 'token' }); + const isSteth = token === TOKENS.STETH; + return ( + + + {isSteth ? : } + + + + + + + + + ); +}; + +export const RequestWallet: WalletComponentType = memo((props) => { + const { active } = useWeb3(); + return active ? : ; +}); diff --git a/features/withdrawals/shared/index.ts b/features/withdrawals/shared/index.ts new file mode 100644 index 000000000..a15bd1976 --- /dev/null +++ b/features/withdrawals/shared/index.ts @@ -0,0 +1,4 @@ +export * from './wallet-wrapper'; +export * from './info-box'; +export * from './status'; +export * from './wallet-my-requests'; diff --git a/features/withdrawals/shared/info-box/index.ts b/features/withdrawals/shared/info-box/index.ts new file mode 100644 index 000000000..15c73a450 --- /dev/null +++ b/features/withdrawals/shared/info-box/index.ts @@ -0,0 +1 @@ +export * from './styles'; diff --git a/features/withdrawals/shared/info-box/styles.ts b/features/withdrawals/shared/info-box/styles.ts new file mode 100644 index 000000000..13924a961 --- /dev/null +++ b/features/withdrawals/shared/info-box/styles.ts @@ -0,0 +1,6 @@ +import styled from 'styled-components'; +import { InfoBox } from 'shared/components'; + +export const InfoBoxStyled = styled(InfoBox)` + margin-bottom: ${({ theme }) => theme.spaceMap.md}px; +`; diff --git a/features/withdrawals/shared/input-decorator-tvl-stake/index.ts b/features/withdrawals/shared/input-decorator-tvl-stake/index.ts new file mode 100644 index 000000000..423f5f3b6 --- /dev/null +++ b/features/withdrawals/shared/input-decorator-tvl-stake/index.ts @@ -0,0 +1 @@ +export * from './input-decorator-tvl-stake'; diff --git a/features/withdrawals/shared/input-decorator-tvl-stake/input-decorator-tvl-stake.tsx b/features/withdrawals/shared/input-decorator-tvl-stake/input-decorator-tvl-stake.tsx new file mode 100644 index 000000000..c39dd073d --- /dev/null +++ b/features/withdrawals/shared/input-decorator-tvl-stake/input-decorator-tvl-stake.tsx @@ -0,0 +1,25 @@ +import { BigNumber } from 'ethers'; +import { formatEther } from '@ethersproject/units'; +import { useRouter } from 'next/router'; +import { Button } from '@lidofinance/lido-ui'; +import { useSafeQueryString } from 'shared/hooks/useSafeQueryString'; + +type InputDecoratorTvlStakeProps = { + tvlDiff: BigNumber; +}; + +export const InputDecoratorTvlStake = ({ + tvlDiff, +}: InputDecoratorTvlStakeProps) => { + const { push } = useRouter(); + const queryString = useSafeQueryString({ amount: formatEther(tvlDiff) }); + return ( + + ); +}; diff --git a/features/withdrawals/shared/status/index.ts b/features/withdrawals/shared/status/index.ts new file mode 100644 index 000000000..420cc02aa --- /dev/null +++ b/features/withdrawals/shared/status/index.ts @@ -0,0 +1 @@ +export * from './status'; diff --git a/features/withdrawals/shared/status/status.tsx b/features/withdrawals/shared/status/status.tsx new file mode 100644 index 000000000..3cb24d003 --- /dev/null +++ b/features/withdrawals/shared/status/status.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; + +import { StatusStyled, StatusWrapperStyled, StatusVariant } from './styles'; + +export type StatusProps = { + variant: keyof typeof StatusVariant; +}; + +export const Status: FC = ({ children, variant }) => { + return ( + + + {children} + + ); +}; diff --git a/features/withdrawals/shared/status/styles.ts b/features/withdrawals/shared/status/styles.ts new file mode 100644 index 000000000..c92d370e1 --- /dev/null +++ b/features/withdrawals/shared/status/styles.ts @@ -0,0 +1,49 @@ +import styled, { keyframes } from 'styled-components'; + +export const enum StatusVariant { + success = 'success', + warning = 'warning', + error = 'error', +} +type StatusProps = { + $variant: keyof typeof StatusVariant; +}; + +const animationColorMap: Record = { + [StatusVariant.success]: '83, 186, 149', + [StatusVariant.warning]: '236, 134, 0', + [StatusVariant.error]: '225, 77, 77', +}; + +export const pulseAnimation = (props: StatusProps) => keyframes` + 0% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(${animationColorMap[props.$variant]}, 0.7); + } + 70% { + transform: scale(1); + box-shadow: 0 0 0 6px rgba(${animationColorMap[props.$variant]}, 0); + } + 100% { + transform: scale(0.95); + box-shadow: 0 0 0 0 rgba(${animationColorMap[props.$variant]}, 0); + } +`; + +export const StatusStyled = styled.div` + border-radius: 50%; + margin-right: ${({ theme }) => theme.spaceMap.sm}px; + height: 8px; + width: 8px; + transform: scale(1); + background: ${({ $variant }) => `var(--lido-color-${$variant})`}; + box-shadow: 0 0 0 0 ${({ $variant }) => `var(--lido-color-${$variant})`}; + animation-name: ${pulseAnimation}; + animation-duration: 2s; + animation-iteration-count: infinite; +`; + +export const StatusWrapperStyled = styled.div` + display: flex; + align-items: center; +`; diff --git a/features/withdrawals/shared/tx-stage-modal/index.ts b/features/withdrawals/shared/tx-stage-modal/index.ts new file mode 100644 index 000000000..edb22a150 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/index.ts @@ -0,0 +1,3 @@ +export * from './tx-stage-modal'; +export * from './stages'; +export * from './types'; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/icons.tsx b/features/withdrawals/shared/tx-stage-modal/stages/icons.tsx new file mode 100644 index 000000000..00e37447f --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/icons.tsx @@ -0,0 +1,76 @@ +import { + LedgerFail, + LedgerConfirm, + LedgerLoading, + LedgerSuccess, +} from '@lidofinance/lido-ui'; + +import { + LedgerIconWrapper, + IconWrapper, + SuccessIcon, + FailIcon, + TxLoader, + WarningIcon, +} from './iconsStyles'; +import { TX_STAGE } from '../types'; + +export const iconsDict = { + ledger: { + [TX_STAGE.SUCCESS]: ( + + + + ), + [TX_STAGE.SIGN]: ( + + + + ), + [TX_STAGE.FAIL]: ( + + + + ), + [TX_STAGE.BLOCK]: ( + + + + ), + [TX_STAGE.PERMIT]: ( + + + + ), + [TX_STAGE.BUNKER]: ( + + + + ), + }, + default: { + [TX_STAGE.SUCCESS]: ( + + + + ), + [TX_STAGE.FAIL]: ( + + + + ), + [TX_STAGE.SIGN]: , + [TX_STAGE.BLOCK]: , + [TX_STAGE.PERMIT]: , + [TX_STAGE.BUNKER]: ( + + + + ), + }, +}; + +export const getStageIcon = (isLedger: boolean, stage: TX_STAGE) => + stage === TX_STAGE.NONE || stage === TX_STAGE.APPROVE + ? null + : iconsDict[isLedger ? 'ledger' : 'default'][stage]; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/iconsStyles.ts b/features/withdrawals/shared/tx-stage-modal/stages/iconsStyles.ts new file mode 100644 index 000000000..6581607b0 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/iconsStyles.ts @@ -0,0 +1,40 @@ +import { CheckLarge, Close, Loader, Warning } from '@lidofinance/lido-ui'; +import styled from 'styled-components'; + +export const LedgerIconWrapper = styled.div` + width: 100%; + text-align: center; + + svg { + max-width: 100%; + } +`; + +export const IconWrapper = styled.div` + height: 64px; + width: 100%; + text-align: center; +`; + +export const SuccessIcon = styled(CheckLarge)` + padding: 20px; + border: 2px solid var(--lido-color-success); + border-radius: 50%; + color: var(--lido-color-success); +`; + +export const FailIcon = styled(Close)` + padding: 20px; + border: 2px solid var(--lido-color-error); + border-radius: 50%; + color: var(--lido-color-error); +`; + +export const TxLoader = styled(Loader)` + margin: 0 auto; +`; + +export const WarningIcon = styled(Warning)` + border-radius: 50%; + color: var(--lido-color-warning); +`; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/index.ts b/features/withdrawals/shared/tx-stage-modal/stages/index.ts new file mode 100644 index 000000000..cc4409aee --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/index.ts @@ -0,0 +1,6 @@ +export * from './tx-stage-pending'; +export * from './tx-stage-success'; +export * from './tx-stage-sign'; +export * from './tx-stage-permit'; +export * from './tx-stage-fail'; +export * from './tx-stage-bunker'; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/styles.ts b/features/withdrawals/shared/tx-stage-modal/stages/styles.ts new file mode 100644 index 000000000..3de9021b3 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/styles.ts @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +export const BottomButtons = styled.div` + margin-top: 44px; + line-height: 20px; + display: flex; + justify-content: space-between; + + button:first-of-type { + margin-right: ${({ theme }) => theme.spaceMap.lg}px; + } + + ${({ theme }) => theme.mediaQueries.md} { + display: flex; + flex-direction: column; + + button:first-of-type { + margin-right: 0; + margin-bottom: ${({ theme }) => theme.spaceMap.lg}px; + } + } +`; + +export const RetryButtonStyled = styled.span` + cursor: pointer; + color: var(--lido-color-primary); +`; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-bunker.tsx b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-bunker.tsx new file mode 100644 index 000000000..115826ab3 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-bunker.tsx @@ -0,0 +1,37 @@ +import { FC } from 'react'; +import { useConnectorInfo } from 'reef-knot/web3-react'; +import { Button } from '@lidofinance/lido-ui'; + +import { TxStageModalContent } from 'shared/components/tx-stage-modal-content'; +import { getStageIcon } from './icons'; +import { BottomButtons } from './styles'; +import { TX_STAGE } from '../types'; + +type TxStageFailProps = { + failedText?: string; + onClick?: () => void; + onClose?: () => void; +}; + +export const TxStageBunker: FC = (props) => { + const { onClick, onClose } = props; + const { isLedger } = useConnectorInfo(); + + return ( + + + + + } + /> + ); +}; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx new file mode 100644 index 000000000..774e9194b --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-fail.tsx @@ -0,0 +1,32 @@ +import { FC } from 'react'; +import { useConnectorInfo } from 'reef-knot/web3-react'; + +import { ErrorMessage } from 'utils'; + +import { TxStageModalContent } from 'shared/components/tx-stage-modal-content'; +import { getStageIcon } from './icons'; +import { RetryButtonStyled } from './styles'; +import { TX_STAGE } from '../types'; + +type TxStageFailProps = { + failedText: string | null; + onClick?: () => void; +}; + +export const TxStageFail: FC = (props) => { + const { failedText, onClick } = props; + const { isLedger } = useConnectorInfo(); + + return ( + Retry + ) + } + /> + ); +}; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-pending.tsx b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-pending.tsx new file mode 100644 index 000000000..9a37952ce --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-pending.tsx @@ -0,0 +1,27 @@ +import { FC } from 'react'; +import { useConnectorInfo } from 'reef-knot/web3-react'; + +import { TxLinkEtherscan } from 'shared/components/tx-link-etherscan'; +import { TxStageModalContent } from 'shared/components/tx-stage-modal-content'; +import { getStageIcon } from './icons'; +import { TX_STAGE } from '../types'; + +type TxStagePendingProps = { + description: string; + title: string; + txHash: string | null; +}; + +export const TxStagePending: FC = (props) => { + const { title, description, txHash } = props; + const { isLedger } = useConnectorInfo(); + + return ( + } + /> + ); +}; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-permit.tsx b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-permit.tsx new file mode 100644 index 000000000..f5aae82c9 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-permit.tsx @@ -0,0 +1,19 @@ +import { FC } from 'react'; +import { useConnectorInfo } from 'reef-knot/web3-react'; + +import { TxStageModalContent } from 'shared/components/tx-stage-modal-content'; +import { getStageIcon } from './icons'; +import { TX_STAGE } from '../types'; + +export const TxStagePermit: FC = () => { + const { isLedger } = useConnectorInfo(); + + return ( + + ); +}; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-sign.tsx b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-sign.tsx new file mode 100644 index 000000000..97530d678 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-sign.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; +import { useConnectorInfo } from 'reef-knot/web3-react'; + +import { TxStageModalContent } from 'shared/components/tx-stage-modal-content'; +import { getStageIcon } from './icons'; +import { TX_STAGE } from '../types'; + +type TxStageSignProps = { + description: string; + title: string; +}; + +export const TxStageSign: FC = (props) => { + const { title, description } = props; + const { isLedger } = useConnectorInfo(); + + return ( + + ); +}; diff --git a/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-success.tsx b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-success.tsx new file mode 100644 index 000000000..aaaca5d4b --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/stages/tx-stage-success.tsx @@ -0,0 +1,40 @@ +import { FC } from 'react'; +import { useConnectorInfo } from 'reef-knot/web3-react'; + +import { TxLinkEtherscan } from 'shared/components/tx-link-etherscan'; +import { TxStageModalContent } from 'shared/components/tx-stage-modal-content'; +import { getStageIcon } from './icons'; +import { TX_STAGE } from '../types'; + +type TxStageSuccessProps = { + txHash: string | null; + description: React.ReactNode; + title: string; + showEtherscan?: boolean; + onClickEtherscan?: React.MouseEventHandler; +}; + +export const TxStageSuccess: FC = (props) => { + const { + txHash, + description, + title, + children, + showEtherscan = true, + onClickEtherscan, + } = props; + const { isLedger } = useConnectorInfo(); + + return ( + + } + footer={children} + /> + ); +}; diff --git a/features/withdrawals/shared/tx-stage-modal/tx-stage-modal.tsx b/features/withdrawals/shared/tx-stage-modal/tx-stage-modal.tsx new file mode 100644 index 000000000..cb2ac7352 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/tx-stage-modal.tsx @@ -0,0 +1,29 @@ +import { FC, useMemo } from 'react'; +import { Modal, ModalProps } from '@lidofinance/lido-ui'; +import { useConnectorInfo } from 'reef-knot/web3-react'; + +import { TX_STAGE } from './types'; + +interface TxStageModalProps extends ModalProps { + txStage?: TX_STAGE; +} + +export const TxStageModal: FC = (props) => { + const { onClose, txStage, children } = props; + + const { isLedger } = useConnectorInfo(); + + const isCloseButtonHidden = useMemo( + () => + isLedger && + txStage && + ![TX_STAGE.SUCCESS, TX_STAGE.FAIL].includes(txStage), + [isLedger, txStage], + ); + + return ( + + {children} + + ); +}; diff --git a/features/withdrawals/shared/tx-stage-modal/types.ts b/features/withdrawals/shared/tx-stage-modal/types.ts new file mode 100644 index 000000000..3b32dc266 --- /dev/null +++ b/features/withdrawals/shared/tx-stage-modal/types.ts @@ -0,0 +1,10 @@ +export enum TX_STAGE { + NONE, + APPROVE, + PERMIT, + SIGN, + BLOCK, + SUCCESS, + FAIL, + BUNKER, +} diff --git a/features/withdrawals/shared/wallet-my-requests/index.ts b/features/withdrawals/shared/wallet-my-requests/index.ts new file mode 100644 index 000000000..c130a96fe --- /dev/null +++ b/features/withdrawals/shared/wallet-my-requests/index.ts @@ -0,0 +1 @@ +export * from './wallet-my-requests'; diff --git a/features/withdrawals/shared/wallet-my-requests/styles.ts b/features/withdrawals/shared/wallet-my-requests/styles.ts new file mode 100644 index 000000000..190fa0c7c --- /dev/null +++ b/features/withdrawals/shared/wallet-my-requests/styles.ts @@ -0,0 +1,23 @@ +import styled from 'styled-components'; + +export const RequestCounterStyled = styled.span` + margin-right: 16px; + + svg { + margin-right: 8px; + line-height: 0; + vertical-align: middle; + margin-top: -2px; + border: 0; + padding: 0; + } + + &:not(:last-of-type) { + padding-right: 16px; + border-right: 1px solid rgba(255, 255, 255, 0.3); + } + + &:last-of-type { + margin-right: 0; + } +`; diff --git a/features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx b/features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx new file mode 100644 index 000000000..daa0437f0 --- /dev/null +++ b/features/withdrawals/shared/wallet-my-requests/wallet-my-requests.tsx @@ -0,0 +1,47 @@ +import { Tooltip, TimeSquare, TickSquare } from '@lidofinance/lido-ui'; +import { FC } from 'react'; + +import { CardBalance } from 'shared/wallet'; +import { useClaimData } from 'features/withdrawals/contexts/claim-data-context'; +import { DATA_UNAVAILABLE } from 'config'; + +import { RequestCounterStyled } from './styles'; + +export const WalletMyRequests: FC = ({ children }) => { + const { withdrawalRequestsData, loading } = useClaimData(); + const { readyCount = DATA_UNAVAILABLE, pendingCount = DATA_UNAVAILABLE } = + withdrawalRequestsData || {}; + + const title = <>My requests {children}; + + const requestsContent = ( + <> + + + + + {readyCount} + + + + + + + + + {pendingCount} + + + + + ); + + return ( + + ); +}; diff --git a/features/withdrawals/shared/wallet-wrapper/index.ts b/features/withdrawals/shared/wallet-wrapper/index.ts new file mode 100644 index 000000000..15c73a450 --- /dev/null +++ b/features/withdrawals/shared/wallet-wrapper/index.ts @@ -0,0 +1 @@ +export * from './styles'; diff --git a/features/withdrawals/shared/wallet-wrapper/styles.ts b/features/withdrawals/shared/wallet-wrapper/styles.ts new file mode 100644 index 000000000..38d652796 --- /dev/null +++ b/features/withdrawals/shared/wallet-wrapper/styles.ts @@ -0,0 +1,12 @@ +import styled from 'styled-components'; +import { Card } from 'shared/wallet'; + +export const WalletWrapperStyled = styled(Card)` + min-height: 137px; + background: linear-gradient( + 52.01deg, + #37394a 0%, + #363749 0.01%, + #40504f 100% + ); +`; diff --git a/features/withdrawals/types/request-status.ts b/features/withdrawals/types/request-status.ts new file mode 100644 index 000000000..4fb7acca0 --- /dev/null +++ b/features/withdrawals/types/request-status.ts @@ -0,0 +1,26 @@ +import type { BigNumber } from 'ethers'; + +export type RequestStatus = { + amountOfStETH: BigNumber; + amountOfShares: BigNumber; + owner: string; + timestamp: BigNumber; + isFinalized: boolean; + isClaimed: boolean; + id: BigNumber; + stringId: string; +}; + +export type RequestStatusClaimable = RequestStatus & { + hint: BigNumber; + claimableEth: BigNumber; +}; + +export type RequestStatusPending = RequestStatus & { + expectedEth: BigNumber; +}; + +export type RequestStatusesUnion = + | RequestStatus + | RequestStatusClaimable + | RequestStatusPending; diff --git a/features/withdrawals/types/tokens-withdrawable.ts b/features/withdrawals/types/tokens-withdrawable.ts new file mode 100644 index 000000000..200d80366 --- /dev/null +++ b/features/withdrawals/types/tokens-withdrawable.ts @@ -0,0 +1,3 @@ +import { TOKENS } from '@lido-sdk/constants'; + +export type TokensWithdrawable = TOKENS.STETH | TOKENS.WSTETH; diff --git a/features/withdrawals/utils/calc-expected-request-eth.ts b/features/withdrawals/utils/calc-expected-request-eth.ts new file mode 100644 index 000000000..f037af33e --- /dev/null +++ b/features/withdrawals/utils/calc-expected-request-eth.ts @@ -0,0 +1,21 @@ +import { BigNumber } from 'ethers'; +import { calcShareRate, e27 } from './calc-share-rate'; +import { RequestStatus } from '../types/request-status'; + +export const calcExpectedRequestEth = ( + requestStatus: RequestStatus, + currentShareRate: BigNumber, +) => { + const requestShareRate = calcShareRate( + requestStatus.amountOfStETH, + requestStatus.amountOfShares, + ); + if (currentShareRate.gte(requestShareRate)) { + return requestStatus.amountOfStETH; + } else { + const expectedETH = requestStatus.amountOfShares + .mul(currentShareRate) + .div(e27(1)); + return expectedETH; + } +}; diff --git a/features/withdrawals/utils/calc-share-rate.ts b/features/withdrawals/utils/calc-share-rate.ts new file mode 100644 index 000000000..a6eacc761 --- /dev/null +++ b/features/withdrawals/utils/calc-share-rate.ts @@ -0,0 +1,17 @@ +import { formatUnits, parseUnits } from '@ethersproject/units'; +import { BigNumber, BigNumberish } from 'ethers'; + +export const SHARE_RATE_PRECISION = 27; + +export const e27 = (value: number) => + parseUnits(String(value), SHARE_RATE_PRECISION); + +export const formatShareRate = (value: BigNumberish) => + formatUnits(value, SHARE_RATE_PRECISION); + +export const calcShareRate = ( + amountOfStETH: BigNumberish, + amountOfShares: BigNumberish, +) => { + return BigNumber.from(e27(1)).mul(amountOfStETH).div(amountOfShares); +}; diff --git a/features/withdrawals/withdrawals-constants/index.ts b/features/withdrawals/withdrawals-constants/index.ts new file mode 100644 index 000000000..b684e942a --- /dev/null +++ b/features/withdrawals/withdrawals-constants/index.ts @@ -0,0 +1,13 @@ +// max requests count for one tx +export const MAX_REQUESTS_COUNT = 256; +export const MAX_REQUESTS_COUNT_LEDGER_LIMIT = 2; + +export const DEFAULT_CLAIM_REQUEST_SELECTED = 80; +export const MAX_SHOWN_REQUEST_PER_TYPE = 1024; + +export const WITHDRAWAL_REQUEST_PATH = '/withdrawals/request'; +export const WITHDRAWAL_CLAIM_PATH = '/withdrawals/claim'; + +// time that validation function waits for context data to resolve +// should be enough to load token balances/tvl/max&min amounts and other contract data +export const VALIDATION_CONTEXT_TIMEOUT = 4000; diff --git a/features/withdrawals/withdrawals-faq/claim-faq.tsx b/features/withdrawals/withdrawals-faq/claim-faq.tsx new file mode 100644 index 000000000..80ddd6dd0 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/claim-faq.tsx @@ -0,0 +1,37 @@ +import { useMatomoEventHandle } from 'shared/hooks'; + +import { Section } from 'shared/components'; + +import { WhatAreWithdrawals } from './list/what-are-withdrawals'; +import { HowDoesWithdrawalsWork } from './list/how-does-withdrawals-work'; +import { HowToWithdraw } from './list/how-to-withdraw'; +import { ConvertSTETHtoETH } from './list/convert-steth-to-eth'; +import { ConvertWSTETHtoETH } from './list/convert-wsteth-to-eth'; +import { WhySTETH } from './list/why-steth'; +import { SeparateClaim } from './list/separate-claim'; +import { ClaimableAmountDifference } from './list/claimable-amount-difference'; +import { WhatIsSlashing } from './list/what-is-slashing'; +import { LidoNFT } from './list/lido-nft'; +import { HowToAddNFT } from './list/add-nft'; +import { NFTNotChange } from './list/nft-not-change'; + +export const ClaimFaq: React.FC = () => { + const onClickHandler = useMatomoEventHandle(); + + return ( +
+ + + + + + + + + + + + +
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/add-nft.tsx b/features/withdrawals/withdrawals-faq/list/add-nft.tsx new file mode 100644 index 000000000..c98ca8ea8 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/add-nft.tsx @@ -0,0 +1,27 @@ +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; +import { LINK_ADD_NFT_GUIDE } from 'config/external-links'; + +export const HowToAddNFT = () => { + return ( + +

+ Different wallets have specific functionality for adding and working + with NFT. Most often, you need to find the specific NFT Address and + Token ID. These parameters you can find on Etherscan. Visit Etherscan, + add your wallet, and locate the NFT transaction. Once located, open the + NFT transaction, and you will see the Address and Token ID. +

+

+ If you are a MetaMask user, use{' '} + + this guide + + . +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/bunker-mode-reasons.tsx b/features/withdrawals/withdrawals-faq/list/bunker-mode-reasons.tsx new file mode 100644 index 000000000..0c8bb5947 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/bunker-mode-reasons.tsx @@ -0,0 +1,27 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const BunkerModeReasons: React.FC = () => { + return ( + +

+ Bunker mode is triggered under three conditions when the penalties might + be big enough to have a significant impact on the protocol’s rewards: +

+
    +
  1. Mass slashing.
  2. +
  3. + Penalties exceeding rewards in the current period between two Oracle + reports. +
  4. +
  5. + Lower than expected Lido validators' performance in the current + period between two Oracle reports and penalties exceeding rewards at + the end of it. +
  6. +
+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/bunker-mode.tsx b/features/withdrawals/withdrawals-faq/list/bunker-mode.tsx new file mode 100644 index 000000000..265035f66 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/bunker-mode.tsx @@ -0,0 +1,18 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const BunkerMode: React.FC = () => { + return ( + +

+ Bunker mode is an emergency mode that activates under three worst-case + conditions (when penalties are large enough to significantly impact the + protocol’s rewards). +

+

+ Importantly, Bunker mode allows for orderly withdrawals to be still + processed, albeit more slowly, during chaotic tail-risk scenarios (e.g. + mass slashings or a significant portion of validators going offline). +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/bunker-while-request-ongoing.tsx b/features/withdrawals/withdrawals-faq/list/bunker-while-request-ongoing.tsx new file mode 100644 index 000000000..243faf9c0 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/bunker-while-request-ongoing.tsx @@ -0,0 +1,14 @@ +import { NoBr } from '../styles'; +import { Accordion } from '@lidofinance/lido-ui'; + +export const BunkerWhileRequestOngoing: React.FC = () => { + return ( + +

+ Most often, the stETH/wstETH withdrawal period will be from{' '} + 1-5 days. However, if any scenarios cause Bunker mode to + happen, this could be extended. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/claimable-amount-difference.tsx b/features/withdrawals/withdrawals-faq/list/claimable-amount-difference.tsx new file mode 100644 index 000000000..cda9f8c36 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/claimable-amount-difference.tsx @@ -0,0 +1,19 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +type ClaimableAmountDifferenceProps = { + title: string; +}; + +export const ClaimableAmountDifference: React.FC< + ClaimableAmountDifferenceProps +> = ({ title }) => { + return ( + +

+ The amount you can claim may differ from your initial request due to a + slashing occurrence and penalties. For these reasons, the total + claimable reward amount could be reduced. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/convert-steth-to-eth.tsx b/features/withdrawals/withdrawals-faq/list/convert-steth-to-eth.tsx new file mode 100644 index 000000000..9d3ca9bd8 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/convert-steth-to-eth.tsx @@ -0,0 +1,19 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +import { + WITHDRAWAL_REQUEST_PATH, + WITHDRAWAL_CLAIM_PATH, +} from 'features/withdrawals/withdrawals-constants'; +import { LocalLink } from 'shared/components/local-link'; + +export const ConvertSTETHtoETH: React.FC = () => { + return ( + +

+ Yes. Stakers can transform their stETH to ETH 1:1 using the{' '} + Request and{' '} + Claim tabs. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/convert-wsteth-to-eth.tsx b/features/withdrawals/withdrawals-faq/list/convert-wsteth-to-eth.tsx new file mode 100644 index 000000000..71c7081dd --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/convert-wsteth-to-eth.tsx @@ -0,0 +1,21 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; +import { LocalLink } from 'shared/components/local-link'; +import { + WITHDRAWAL_CLAIM_PATH, + WITHDRAWAL_REQUEST_PATH, +} from 'features/withdrawals/withdrawals-constants'; + +export const ConvertWSTETHtoETH: FC = () => { + return ( + +

+ Yes. You can transform your wstETH to ETH using the{' '} + Request and{' '} + Claim tabs. Note + that, under the hood, wstETH will unwrap to stETH first, so your request + will be denominated in stETH. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/how-does-withdrawals-work.tsx b/features/withdrawals/withdrawals-faq/list/how-does-withdrawals-work.tsx new file mode 100644 index 000000000..52510c0e9 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/how-does-withdrawals-work.tsx @@ -0,0 +1,24 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; +import { NoBr } from '../styles'; + +export const HowDoesWithdrawalsWork: FC = () => { + return ( + +

The withdrawal process is simple and has two steps:

+
    +
  1. + Request withdrawal: Lock your stETH/wstETH by issuing a + withdrawal request. ETH is sourced to fulfill the request, and then + locked stETH is burned, which marks the withdrawal request as + claimable. Under normal circumstances, this can take anywhere between{' '} + 1-5 days. +
  2. +
  3. + Claim: Claim your ETH after the withdrawal request has been + processed. +
  4. +
+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/how-long-to-withdraw.tsx b/features/withdrawals/withdrawals-faq/list/how-long-to-withdraw.tsx new file mode 100644 index 000000000..fc02e8a19 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/how-long-to-withdraw.tsx @@ -0,0 +1,17 @@ +import { NoBr } from '../styles'; +import { Accordion } from '@lidofinance/lido-ui'; +import { LocalLink } from 'shared/components/local-link'; +import { WITHDRAWAL_CLAIM_PATH } from 'features/withdrawals/withdrawals-constants'; + +export const HowLongToWithdraw: React.FC = () => { + return ( + +

+ Under normal circumstances, the stETH/wstETH withdrawal period can take + anywhere between 1-5 days. After that, you can claim your + ETH using the  + Claim tab. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/how-to-withdraw.tsx b/features/withdrawals/withdrawals-faq/list/how-to-withdraw.tsx new file mode 100644 index 000000000..b39e6cadc --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/how-to-withdraw.tsx @@ -0,0 +1,22 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; +import { LocalLink } from 'shared/components/local-link'; +import { + WITHDRAWAL_CLAIM_PATH, + WITHDRAWAL_REQUEST_PATH, +} from 'features/withdrawals/withdrawals-constants'; + +export const HowToWithdraw: FC = () => { + return ( + +

+ Press the{' '} + Request tab, + choose an amount of stETH/wstETH to withdraw, then press ‘Request + withdrawal’. Confirm the transaction using your wallet and press ‘Claim’ + on the Claim tab{' '} + once it is ready. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/lido-nft.tsx b/features/withdrawals/withdrawals-faq/list/lido-nft.tsx new file mode 100644 index 000000000..d9e9c65d8 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/lido-nft.tsx @@ -0,0 +1,15 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const LidoNFT: React.FC = () => { + return ( + +

+ Each withdrawal request is represented by an NFT: the NFT is + automatically minted for you when you send a request. You will need to + add it to your wallet to be able to monitor the request status. When the + request is ready for the claim, the NFT will change it's + appearance. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/nft-not-change.tsx b/features/withdrawals/withdrawals-faq/list/nft-not-change.tsx new file mode 100644 index 000000000..015bf57f7 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/nft-not-change.tsx @@ -0,0 +1,14 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const NFTNotChange: React.FC = () => { + return ( + +

+ Maybe your wallet doesn’t support the automatic changing of the NFT + view. To renew the NFT, you can import the Address and Token ID of your + NFT, and it could change it's appearance to a new “Ready to claim” + one. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/rewards-after-withdraw.tsx b/features/withdrawals/withdrawals-faq/list/rewards-after-withdraw.tsx new file mode 100644 index 000000000..7ddb55b83 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/rewards-after-withdraw.tsx @@ -0,0 +1,13 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const RewardsAfterWithdraw: React.FC = () => { + return ( + +

+ No. After you request a withdrawal, the stETH/wstETH submitted for + unstaking will not receive staking rewards on top of your submitted + balance. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/separate-claim.tsx b/features/withdrawals/withdrawals-faq/list/separate-claim.tsx new file mode 100644 index 000000000..2144d3c94 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/separate-claim.tsx @@ -0,0 +1,14 @@ +import { Accordion } from '@lidofinance/lido-ui'; +import { LocalLink } from 'shared/components/local-link'; +import { WITHDRAWAL_CLAIM_PATH } from 'features/withdrawals/withdrawals-constants'; + +export const SeparateClaim: React.FC = () => { + return ( + +

+ Yes. You can choose the requests you want to claim in the ‘Request List’ + on the Claim tab. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/turbo-mode.tsx b/features/withdrawals/withdrawals-faq/list/turbo-mode.tsx new file mode 100644 index 000000000..8f249f290 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/turbo-mode.tsx @@ -0,0 +1,13 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const TurboMode: React.FC = () => { + return ( + +

+ Turbo mode is a default mode used unless an emergency event affects the + Ethereum network. In Turbo Mode, withdrawal requests are fulfilled + quickly, using all available ETH from user deposits and rewards. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/unstake-amount-boundaries.tsx b/features/withdrawals/withdrawals-faq/list/unstake-amount-boundaries.tsx new file mode 100644 index 000000000..58f8050d2 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/unstake-amount-boundaries.tsx @@ -0,0 +1,28 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +import { weiToEth } from 'utils'; +import { LOCALE } from 'config'; +import { useWithdrawals } from 'features/withdrawals/contexts/withdrawals-context'; + +const formatAmount = (value: number | undefined) => + value ? value.toLocaleString(LOCALE, { maximumFractionDigits: 18 }) : '...'; + +export const UnstakeAmountBoundaries: React.FC = () => { + const { maxAmount, minAmount } = useWithdrawals(); + const minAmountDisplay = formatAmount(Number(minAmount)); + const maxAmountDisplay = formatAmount(maxAmount && weiToEth(maxAmount)); + + return ( + +

+ Request size should be at least {minAmountDisplay} wei (in stETH), and + at most {maxAmountDisplay} stETH. +

+

+ If you want to withdraw more than {maxAmountDisplay} stETH, your + withdrawal request will be split into several requests, but you will + still only pay one transaction fee. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/what-are-withdrawals.tsx b/features/withdrawals/withdrawals-faq/list/what-are-withdrawals.tsx new file mode 100644 index 000000000..db3f6d037 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/what-are-withdrawals.tsx @@ -0,0 +1,14 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const WhatAreWithdrawals: React.FC = () => { + return ( + +

+ Users can unstake their stETH or wstETH through withdrawals. Upon + unstaking stETH, they will receive ETH at a 1:1 ratio. When unstaking + wstETH, the unwrapping process will take place seamlessly in the + background. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/what-is-slashing.tsx b/features/withdrawals/withdrawals-faq/list/what-is-slashing.tsx new file mode 100644 index 000000000..6317d819c --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/what-is-slashing.tsx @@ -0,0 +1,34 @@ +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; + +const PENALTIES_INFO_LINK = + 'https://help.lido.fi/en/articles/5232780-what-are-staking-validator-penalties'; + +export const WhatIsSlashing: React.FC = () => { + return ( + +

+ Slashing is a penalty that affects validators for intentional or + accidental misbehavior. +

+

+ Mass slashing event is when slashing penalties are big enough to have + the impact on Protocol's rewards in the current frame or in the + future, esp. midterm penalties. +

+

+ Slashing penalties are spread across stakers and may lower your total + reward amount. For more information, check out{' '} + + What Are Staking/Validator Penalties + + . +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/why-steth.tsx b/features/withdrawals/withdrawals-faq/list/why-steth.tsx new file mode 100644 index 000000000..f8459fde8 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/why-steth.tsx @@ -0,0 +1,14 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const WhySTETH: React.FC = () => { + return ( + +

+ When you request to withdraw wstETH, it is automatically unwrapped into + stETH, which then gets transformed into ETH. The main withdrawal period + is when stETH is transformed into ETH. That's why you see the + amount pending denominated in stETH. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/withdrawaal-fee.tsx b/features/withdrawals/withdrawals-faq/list/withdrawaal-fee.tsx new file mode 100644 index 000000000..e91b9c169 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/withdrawaal-fee.tsx @@ -0,0 +1,13 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const WithdrawalFee: React.FC = () => { + return ( + +

+ There’s no withdrawal fee, but as with any Ethereum interaction, there + will be a network gas fee. Lido does not collect a fee when you request + a withdrawal. +

+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/list/withdrawal-period-circumstances.tsx b/features/withdrawals/withdrawals-faq/list/withdrawal-period-circumstances.tsx new file mode 100644 index 000000000..da5813f00 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/list/withdrawal-period-circumstances.tsx @@ -0,0 +1,17 @@ +import { Accordion } from '@lidofinance/lido-ui'; + +export const WithdrawalPeriodCircumstances: React.FC = () => { + return ( + +
    +
  • The amount of stETH in the queue.
  • +
  • Perfomance of the validator poolside.
  • +
  • Exit queue on the Beacon chain.
  • +
  • Demand for staking and unstaking.
  • +
+
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/request-faq.tsx b/features/withdrawals/withdrawals-faq/request-faq.tsx new file mode 100644 index 000000000..c82659bc1 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/request-faq.tsx @@ -0,0 +1,67 @@ +import { useMatomoEventHandle } from 'shared/hooks'; + +// import { Button } from '@lidofinance/lido-ui'; +import { Section } from 'shared/components'; +// import { ButtonLinkWrap } from './styles'; + +import { WhatAreWithdrawals } from './list/what-are-withdrawals'; +import { HowDoesWithdrawalsWork } from './list/how-does-withdrawals-work'; +import { HowToWithdraw } from './list/how-to-withdraw'; +import { ConvertSTETHtoETH } from './list/convert-steth-to-eth'; +import { ConvertWSTETHtoETH } from './list/convert-wsteth-to-eth'; +import { WhySTETH } from './list/why-steth'; +import { HowLongToWithdraw } from './list/how-long-to-withdraw'; +import { WithdrawalPeriodCircumstances } from './list/withdrawal-period-circumstances'; +import { WithdrawalFee } from './list/withdrawaal-fee'; +import { ClaimableAmountDifference } from './list/claimable-amount-difference'; +import { TurboMode } from './list/turbo-mode'; +import { BunkerMode } from './list/bunker-mode'; +import { BunkerModeReasons } from './list/bunker-mode-reasons'; +import { WhatIsSlashing } from './list/what-is-slashing'; +import { RewardsAfterWithdraw } from './list/rewards-after-withdraw'; +import { BunkerWhileRequestOngoing } from './list/bunker-while-request-ongoing'; +import { UnstakeAmountBoundaries } from './list/unstake-amount-boundaries'; +import { LidoNFT } from './list/lido-nft'; +import { HowToAddNFT } from './list/add-nft'; +import { NFTNotChange } from './list/nft-not-change'; + +// TODO: Replace this link when it will be finalized +// const LEARN_MORE_LINK = +// 'https://hackmd.io/@lido/SyaJQsZoj#Lido-on-Ethereum-Withdrawals-Landscape'; + +export const RequestFaq: React.FC = () => { + const onClickHandler = useMatomoEventHandle(); + + return ( +
+ + + + + + + + + + + + + + + + + + + + + + {/* + + */} +
+ ); +}; diff --git a/features/withdrawals/withdrawals-faq/styles.ts b/features/withdrawals/withdrawals-faq/styles.ts new file mode 100644 index 000000000..01de7e381 --- /dev/null +++ b/features/withdrawals/withdrawals-faq/styles.ts @@ -0,0 +1,11 @@ +import styled from 'styled-components'; + +export const NoBr = styled.span` + white-space: nowrap; +`; + +export const ButtonLinkWrap = styled.a` + margin: ${({ theme }) => theme.spaceMap.xxl}px auto 0; + display: block; + width: fit-content; +`; diff --git a/features/withdrawals/withdrawals-tabs.tsx b/features/withdrawals/withdrawals-tabs.tsx new file mode 100644 index 000000000..aeb5e92dc --- /dev/null +++ b/features/withdrawals/withdrawals-tabs.tsx @@ -0,0 +1,46 @@ +import { Switch } from 'shared/components'; +import { ClaimFaq } from 'features/withdrawals/withdrawals-faq/claim-faq'; + +import { TransactionModalProvider } from './contexts/transaction-modal-context'; +import { ClaimDataProvider } from './contexts/claim-data-context'; +import { useWithdrawals } from './contexts/withdrawals-context'; + +import { ClaimForm, ClaimWallet } from './claim'; +import { TxClaimModal } from './claim/tx-modal/tx-claim-modal'; + +import { Request } from './request'; + +import { + WITHDRAWAL_CLAIM_PATH, + WITHDRAWAL_REQUEST_PATH, +} from 'features/withdrawals//withdrawals-constants'; + +const withdrawalRoutes = [ + { + path: WITHDRAWAL_REQUEST_PATH, + name: 'Request', + }, + { + path: WITHDRAWAL_CLAIM_PATH, + name: 'Claim', + }, +]; + +export const WithdrawalsTabs = () => { + const { isClaimTab } = useWithdrawals(); + return ( + + + {isClaimTab ? ( + + + + + + + ) : ( + + )} + + ); +}; diff --git a/features/wrap/features/unwrap-form/hooks.ts b/features/wrap/features/unwrap-form/hooks.ts new file mode 100644 index 000000000..3667d2cfa --- /dev/null +++ b/features/wrap/features/unwrap-form/hooks.ts @@ -0,0 +1,45 @@ +import { parseEther } from '@ethersproject/units'; +import { getStaticRpcBatchProvider } from 'utils/rpcProviders'; +import { useLidoSWR, useWSTETHContractRPC } from '@lido-sdk/react'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { ESTIMATE_ACCOUNT, getBackendRPCPath, UNWRAP_GAS_LIMIT } from 'config'; +import { BigNumber } from 'ethers'; + +export const useUnwrapGasLimit = () => { + const wsteth = useWSTETHContractRPC(); + const { chainId } = useWeb3(); + + const { data } = useLidoSWR( + ['swr:unwrap-gas-limit', chainId], + async (_key, chainId) => { + if (!chainId) { + return; + } + + const provider = getStaticRpcBatchProvider( + // TODO: add a way to type useWeb3 hook + chainId as number, + getBackendRPCPath(chainId as number), + ); + + const feeData = await provider.getFeeData(); + const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined; + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + + const gasLimit = await wsteth.estimateGas + .unwrap(parseEther('0.0001'), { + from: ESTIMATE_ACCOUNT, + maxPriorityFeePerGas, + maxFeePerGas, + }) + .catch((error) => { + console.warn(error); + return BigNumber.from(UNWRAP_GAS_LIMIT); + }); + + return +gasLimit; + }, + ); + + return data ?? UNWRAP_GAS_LIMIT; +}; diff --git a/features/wrap/features/unwrap-form/unwrap-form.tsx b/features/wrap/features/unwrap-form/unwrap-form.tsx new file mode 100644 index 000000000..db652bfdc --- /dev/null +++ b/features/wrap/features/unwrap-form/unwrap-form.tsx @@ -0,0 +1,211 @@ +import { + FC, + memo, + useCallback, + useState, + useMemo, + useEffect, + useRef, +} from 'react'; +import { parseEther } from '@ethersproject/units'; +import { + Block, + DataTable, + DataTableRow, + Wsteth, + Button, +} from '@lidofinance/lido-ui'; +import { TOKENS } from '@lido-sdk/constants'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { + useSDK, + useSTETHBalance, + useWSTETHBalance, + useWSTETHContractWeb3, +} from '@lido-sdk/react'; +import { TxStageModal, TX_OPERATION, TX_STAGE } from 'shared/components'; +import { L2Banner } from 'shared/l2-banner'; +import { MATOMO_CLICK_EVENTS } from 'config'; +import { useTxCostInUsd, useStethByWsteth } from 'shared/hooks'; +import { useCurrencyInput } from 'shared/forms/hooks/useCurrencyInput'; +import { formatBalance } from 'utils'; +import { Connect } from 'shared/wallet'; +import { InputDecoratorMaxButton } from 'shared/forms/components/input-decorator-max-button'; +import { FormStyled, InputStyled } from 'features/wrap/styles'; +import { DataTableRowStethByWsteth } from 'shared/components/data-table-row-steth-by-wsteth'; +import { unwrapProcessing } from 'features/wrap/utils'; +import { useUnwrapGasLimit } from './hooks'; +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { FormatToken } from 'shared/formatters/format-token'; +import { useIsMultisig } from 'shared/hooks/useIsMultisig'; + +export const UnwrapForm: FC = memo(() => { + const { active, chainId } = useWeb3(); + const stethBalance = useSTETHBalance(); + const wstethBalance = useWSTETHBalance(); + const wstethContractWeb3 = useWSTETHContractWeb3(); + const { providerWeb3 } = useSDK(); + const [isMultisig] = useIsMultisig(); + + const formRef = useRef(null); + + // Needs for fix flashing balance in tx success modal + const [wrappingAmountValue, setWrappingAmountValue] = useState(''); + const [txModalOpen, setTxModalOpen] = useState(false); + const [txStage, setTxStage] = useState(TX_STAGE.SUCCESS); + const [txHash, setTxHash] = useState(); + const [txModalFailedText, setTxModalFailedText] = useState(''); + const [inputValue, setInputValue] = useState(''); + + const unwrapGasLimit = useUnwrapGasLimit(); + + const unwrapTxCostInUsd = useTxCostInUsd(unwrapGasLimit); + + const openTxModal = useCallback(() => { + setTxModalOpen(true); + }, []); + + const closeTxModal = useCallback(() => { + setTxModalOpen(false); + }, []); + + const unWrapProcessing = useCallback( + async (inputValue, resetForm) => { + // Needs for fix flashing balance in tx success modal + setWrappingAmountValue(inputValue); + + await unwrapProcessing( + providerWeb3, + wstethContractWeb3, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + wstethBalance.update, + stethBalance.update, + chainId, + inputValue, + resetForm, + isMultisig, + ); + + // Needs for fix flashing balance in tx success modal + setWrappingAmountValue(''); + }, + [ + providerWeb3, + wstethContractWeb3, + openTxModal, + closeTxModal, + wstethBalance.update, + stethBalance.update, + chainId, + isMultisig, + ], + ); + + const token = TOKENS.WSTETH; + const inputName = `${getTokenDisplayName(token)} amount`; + + const { + handleSubmit, + handleChange, + error, + isSubmitting, + setMaxInputValue, + isMaxDisabled, + reset, + } = useCurrencyInput({ + inputValue, + setInputValue, + inputName, + submit: unWrapProcessing, + limit: wstethBalance.data, + token, + }); + + // Needs for tx modal + const inputValueAsBigNumber = useMemo(() => { + try { + return parseEther(inputValue ? inputValue : '0'); + } catch { + return parseEther('0'); + } + }, [inputValue]); + const willReceiveStethAsBigNumber = useStethByWsteth(inputValueAsBigNumber); + + // Reset form amount after disconnect wallet + useEffect(() => { + if (!active) { + reset(); + } + }, [active, reset]); + + return ( + + + } + rightDecorator={ + + } + label={inputName} + value={inputValue} + onChange={handleChange} + error={error} + /> + {active ? ( + + ) : ( + + )} + + + + + + ${unwrapTxCostInUsd?.toFixed(2)} + + + + + + + + formRef.current?.requestSubmit()} + /> + + ); +}); diff --git a/features/wrap/features/wallet/styles.tsx b/features/wrap/features/wallet/styles.tsx new file mode 100644 index 000000000..c4e9a5bd3 --- /dev/null +++ b/features/wrap/features/wallet/styles.tsx @@ -0,0 +1,6 @@ +import { Card } from 'shared/wallet'; +import styled from 'styled-components'; + +export const StyledCard = styled(Card)` + background: linear-gradient(52.01deg, #1b3349 0%, #25697e 100%); +`; diff --git a/features/wrap/features/wallet/wallet.tsx b/features/wrap/features/wallet/wallet.tsx new file mode 100644 index 000000000..ee47e0791 --- /dev/null +++ b/features/wrap/features/wallet/wallet.tsx @@ -0,0 +1,90 @@ +import { memo } from 'react'; +import { Divider, Text } from '@lidofinance/lido-ui'; +import { TOKENS } from '@lido-sdk/constants'; +import { + useSDK, + useEthereumBalance, + useSTETHBalance, + useWSTETHBalance, + useTokenAddress, +} from '@lido-sdk/react'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { FormatToken } from 'shared/formatters'; +import { TokenToWallet } from 'shared/components'; +import { useWstethBySteth, useStethByWsteth } from 'shared/hooks'; +import type { WalletComponentType } from 'shared/wallet/types'; +import { CardBalance, CardRow, CardAccount, Fallback } from 'shared/wallet'; +import { StyledCard } from './styles'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; + +const WalletComponent: WalletComponentType = (props) => { + const { account } = useSDK(); + const ethBalance = useEthereumBalance(undefined, STRATEGY_LAZY); + const stethBalance = useSTETHBalance(); + const wstethBalance = useWSTETHBalance(); + + const stethAddress = useTokenAddress(TOKENS.STETH); + const wstethAddress = useTokenAddress(TOKENS.WSTETH); + + const wstethByStethBalance = useWstethBySteth(stethBalance.data); + const stethByWstethBalance = useStethByWsteth(wstethBalance.data); + + return ( + + + + } + /> + + + + + + + + + ≈ + + + } + /> + + + + + ≈ + + + } + /> + + + ); +}; + +export const Wallet: WalletComponentType = memo((props) => { + const { active } = useWeb3(); + return active ? : ; +}); diff --git a/features/wrap/features/wrap-faq/list/do-i-get-my-staking-rewards.tsx b/features/wrap/features/wrap-faq/list/do-i-get-my-staking-rewards.tsx new file mode 100644 index 000000000..7d6b76f96 --- /dev/null +++ b/features/wrap/features/wrap-faq/list/do-i-get-my-staking-rewards.tsx @@ -0,0 +1,16 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; + +export const DoIGetMyStakingRewards: FC = () => { + return ( + +

+ Yes, wrapped stETH gets staking rewards at the same rate as regular + stETH. When you keep your stETH in a wrapper you cannot see your daily + staking rewards. However, when you unwrap your wstETH your new stETH + balance will have increased relative to pre-wrapped amount to reflect + your received rewards. +

+
+ ); +}; diff --git a/features/wrap/features/wrap-faq/list/do-i-need-to-claim-my-staking-rewards.tsx b/features/wrap/features/wrap-faq/list/do-i-need-to-claim-my-staking-rewards.tsx new file mode 100644 index 000000000..05c4c538c --- /dev/null +++ b/features/wrap/features/wrap-faq/list/do-i-need-to-claim-my-staking-rewards.tsx @@ -0,0 +1,10 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; + +export const DoINeedToClaimMyStakingRewards: FC = () => { + return ( + +

No, staking rewards accrue to wstETH automatically.

+
+ ); +}; diff --git a/features/wrap/features/wrap-faq/list/do_i_need_to_unwrap_my_wsteth.tsx b/features/wrap/features/wrap-faq/list/do_i_need_to_unwrap_my_wsteth.tsx new file mode 100644 index 000000000..97c9ce3c2 --- /dev/null +++ b/features/wrap/features/wrap-faq/list/do_i_need_to_unwrap_my_wsteth.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; +import { LocalLink } from 'shared/components/local-link'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; +import { trackMatomoEvent } from 'config/trackMatomoEvent'; + +export const DoINeedToUnwrapMyWsteth: FC = () => { + return ( + +

+ No, you can transform your wstETH to ETH using the{' '} + + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.faqDoINeedToUnwrapMyWstethWithdrawalsTabs, + ) + } + aria-hidden="true" + > + Withdrawals Request and Claim tabs + + + . Note that, under the hood, wstETH will unwrap to stETH first, so your + request will be denominated in stETH. +

+
+ ); +}; diff --git a/features/wrap/features/wrap-faq/list/how-can-i-get-wsteth.tsx b/features/wrap/features/wrap-faq/list/how-can-i-get-wsteth.tsx new file mode 100644 index 000000000..90dd23258 --- /dev/null +++ b/features/wrap/features/wrap-faq/list/how-can-i-get-wsteth.tsx @@ -0,0 +1,35 @@ +import { FC } from 'react'; +import { Accordion, Link as OuterLink } from '@lidofinance/lido-ui'; + +import { LocalLink } from 'shared/components/local-link'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; +import { trackMatomoEvent } from 'config/trackMatomoEvent'; + +export const HowCanIGetWsteth: FC = () => { + return ( + +

+ You can wrap your stETH or ETH tokens using{' '} + + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.faqHowCanIGetWstethWrapLink, + ) + } + aria-hidden="true" + > + Wrap & Unwrap staking widget + + {' '} + or{' '} + + DEX Lido integrations + +

+
+ ); +}; diff --git a/features/wrap/features/wrap-faq/list/how-can-i-use-wsteth.tsx b/features/wrap/features/wrap-faq/list/how-can-i-use-wsteth.tsx new file mode 100644 index 000000000..c0b63fffd --- /dev/null +++ b/features/wrap/features/wrap-faq/list/how-can-i-use-wsteth.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { Accordion, Link } from '@lidofinance/lido-ui'; +import { MATOMO_CLICK_EVENTS_TYPES } from '../../../../../config'; + +export const HowCanIUseWsteth: FC = () => { + return ( + +

+ wstETH is useful across{' '} + + L2 + {' '} + and other{' '} + + DeFi protocols + + , which are based on constant balance tokens. +

+
+ ); +}; diff --git a/features/wrap/features/wrap-faq/list/how-could-i-unwrap-wsteth-to-steth.tsx b/features/wrap/features/wrap-faq/list/how-could-i-unwrap-wsteth-to-steth.tsx new file mode 100644 index 000000000..5b8d004fe --- /dev/null +++ b/features/wrap/features/wrap-faq/list/how-could-i-unwrap-wsteth-to-steth.tsx @@ -0,0 +1,30 @@ +import { FC } from 'react'; + +import { Accordion } from '@lidofinance/lido-ui'; + +import { LocalLink } from 'shared/components/local-link'; +import { MATOMO_CLICK_EVENTS_TYPES } from 'config'; +import { trackMatomoEvent } from 'config/trackMatomoEvent'; + +export const HowCouldIUnwrapWstethToSteth: FC = () => { + return ( + +

+ You can unwrap your wstETH tokens using{' '} + + + trackMatomoEvent( + MATOMO_CLICK_EVENTS_TYPES.faqHowDoIUnwrapWstethUnwrapLink, + ) + } + aria-hidden="true" + > + Wrap & Unwrap staking widget + + + . +

+
+ ); +}; diff --git a/features/wrap/features/wrap-faq/list/index.ts b/features/wrap/features/wrap-faq/list/index.ts new file mode 100644 index 000000000..aaad7792b --- /dev/null +++ b/features/wrap/features/wrap-faq/list/index.ts @@ -0,0 +1,7 @@ +export * from './what-is-wsteth'; +export * from './how-can-i-get-wsteth'; +export * from './how-can-i-use-wsteth'; +export * from './do-i-get-my-staking-rewards'; +export * from './do-i-need-to-claim-my-staking-rewards'; +export * from './how-could-i-unwrap-wsteth-to-steth'; +export * from './do_i_need_to_unwrap_my_wsteth'; diff --git a/features/wrap/features/wrap-faq/list/what-is-wsteth.tsx b/features/wrap/features/wrap-faq/list/what-is-wsteth.tsx new file mode 100644 index 000000000..b0aef15ef --- /dev/null +++ b/features/wrap/features/wrap-faq/list/what-is-wsteth.tsx @@ -0,0 +1,15 @@ +import { FC } from 'react'; +import { Accordion } from '@lidofinance/lido-ui'; + +export const WhatIsWsteth: FC = () => { + return ( + +

+ wstETH (wrapped stETH) is a non-rebasing version of stETH. Unlike the + stETH balance, which updates every day and communicates your share of + rewards, the wstETH balance stays the same while the stETH balance + updates inside the wrapper daily. +

+
+ ); +}; diff --git a/features/wrap/features/wrap-faq/wrap-faq.tsx b/features/wrap/features/wrap-faq/wrap-faq.tsx new file mode 100644 index 000000000..8d74cfb7c --- /dev/null +++ b/features/wrap/features/wrap-faq/wrap-faq.tsx @@ -0,0 +1,29 @@ +import { FC } from 'react'; +import { Section } from 'shared/components'; +import { useMatomoEventHandle } from 'shared/hooks'; + +import { + WhatIsWsteth, + HowCanIGetWsteth, + HowCanIUseWsteth, + DoIGetMyStakingRewards, + DoINeedToClaimMyStakingRewards, + HowCouldIUnwrapWstethToSteth, + DoINeedToUnwrapMyWsteth, +} from './list'; + +export const WrapFaq: FC = () => { + const onClickHandler = useMatomoEventHandle(); + + return ( +
+ + + + + + + +
+ ); +}; diff --git a/features/wrap/features/wrap-form/form.tsx b/features/wrap/features/wrap-form/form.tsx new file mode 100644 index 000000000..5865fd9a6 --- /dev/null +++ b/features/wrap/features/wrap-form/form.tsx @@ -0,0 +1,257 @@ +import { FC, useCallback, useEffect, useMemo } from 'react'; +import { + Button, + ButtonIcon, + Eth, + Lock, + Option, + Steth, +} from '@lidofinance/lido-ui'; +import { TOKENS } from '@lido-sdk/constants'; +import { + useEthereumBalance, + useSTETHBalance, + useWSTETHContractWeb3, + useSDK, +} from '@lido-sdk/react'; +import { useWeb3 } from '@reef-knot/web3-react'; +import { useCurrencyInput } from 'shared/forms/hooks/useCurrencyInput'; +import { useIsMultisig } from 'shared/hooks/useIsMultisig'; +import { wrapProcessingWithApprove } from 'features/wrap/utils'; +import { TX_OPERATION, TX_STAGE } from 'shared/components'; +import { L2Banner } from 'shared/l2-banner'; +import { MATOMO_CLICK_EVENTS } from 'config'; +import { Connect } from 'shared/wallet'; +import { InputDecoratorLocked } from 'shared/forms/components/input-decorator-locked'; +import { InputDecoratorMaxButton } from 'shared/forms/components/input-decorator-max-button'; +import { + FormStyled, + InputGroupStyled, + SelectIconWrapper, + InputWrapper, +} from 'features/wrap/styles'; +import { trackEvent } from '@lidofinance/analytics-matomo'; +import { getTokenDisplayName } from 'utils/getTokenDisplayName'; +import { STRATEGY_LAZY } from 'utils/swrStrategies'; + +const ETH = 'ETH'; + +const iconsMap = { + [ETH]: , + [TOKENS.STETH]: , +}; + +type FromProps = { + formRef: React.RefObject; + selectedToken: keyof typeof iconsMap; + setSelectedToken: (token: keyof typeof iconsMap) => void; + setWrappingAmountValue: (value: string) => void; + setTxOperation: (value: TX_OPERATION) => void; + setInputValue: (value: string) => void; + openTxModal: () => void; + closeTxModal: () => void; + setTxStage: (value: TX_STAGE) => void; + setTxHash: (value?: string) => void; + setTxModalFailedText: (value: string) => void; + needsApprove: boolean; + approve: () => Promise; + inputValue: string; + wrapGasLimit?: number; +}; + +export const Form: FC = (props) => { + const { + formRef, + selectedToken, + setSelectedToken, + setWrappingAmountValue, + setTxOperation, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + needsApprove, + approve, + setInputValue, + inputValue, + wrapGasLimit, + } = props; + + const { active, account } = useWeb3(); + const { chainId, providerWeb3 } = useSDK(); + + const ethBalance = useEthereumBalance(undefined, STRATEGY_LAZY); + const stethBalance = useSTETHBalance(); + const wstethContractWeb3 = useWSTETHContractWeb3(); + const [isMultisig] = useIsMultisig(); + + const balanceBySelectedToken = useMemo(() => { + return selectedToken === ETH ? ethBalance.data : stethBalance.data; + }, [selectedToken, ethBalance.data, stethBalance.data]); + + const wrapProcessing = useCallback( + async (inputValue, resetForm) => { + // Needs for fix flashing balance in tx success modal + setWrappingAmountValue(inputValue); + + // Set operation type of transaction + setTxOperation( + needsApprove && selectedToken === TOKENS.STETH + ? TX_OPERATION.APPROVING + : TX_OPERATION.WRAPPING, + ); + + // Run approving or wrapping + await wrapProcessingWithApprove( + chainId, + providerWeb3, + wstethContractWeb3, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + ethBalance.update, + stethBalance.update, + inputValue, + selectedToken, + needsApprove, + isMultisig, + approve, + resetForm, + ); + + // Needs for fix flashing balance in tx success modal + setWrappingAmountValue(''); + }, + [ + providerWeb3, + setWrappingAmountValue, + setTxOperation, + needsApprove, + selectedToken, + chainId, + wstethContractWeb3, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + ethBalance.update, + stethBalance.update, + approve, + isMultisig, + ], + ); + + const inputName = `${getTokenDisplayName(selectedToken)} amount`; + + const { + handleSubmit, + handleChange, + error, + isSubmitting, + setMaxInputValue, + isMaxDisabled, + reset, + } = useCurrencyInput({ + inputValue, + inputName, + setInputValue, + submit: wrapProcessing, + limit: balanceBySelectedToken, + token: selectedToken, + gasLimit: wrapGasLimit, + padMaxAmount: !isMultisig, + }); + + const onChangeSelectToken = useCallback( + async (value) => { + if (value === selectedToken) return; + setSelectedToken(value as keyof typeof iconsMap); + setInputValue(''); + reset(); + trackEvent( + ...(value === 'ETH' + ? MATOMO_CLICK_EVENTS.wrapTokenSelectEth + : MATOMO_CLICK_EVENTS.wrapTokenSelectSteth), + ); + }, + [setSelectedToken, setInputValue, reset, selectedToken], + ); + + // Reset form amount after disconnect wallet + useEffect(() => { + if (!active) { + setInputValue(''); + reset(); + } + }, [active, reset, setInputValue]); + + const buttonProps: React.ComponentProps = { + fullwidth: true, + type: 'submit', + disabled: !!error, + loading: isSubmitting, + }; + + return ( + + + + + + + + + {account && needsApprove && selectedToken === TOKENS.STETH ? ( + + ) : ( + '' + )} + + } + label={inputName} + value={inputValue} + onChange={handleChange} + error={!!error} + /> + + {active ? ( + needsApprove && selectedToken === TOKENS.STETH ? ( + }> + Unlock token to wrap + + ) : ( + + ) + ) : ( + + )} + + + ); +}; diff --git a/features/wrap/features/wrap-form/hooks.tsx b/features/wrap/features/wrap-form/hooks.tsx new file mode 100644 index 000000000..bcb02468c --- /dev/null +++ b/features/wrap/features/wrap-form/hooks.tsx @@ -0,0 +1,119 @@ +import { parseEther } from '@ethersproject/units'; +import { CHAINS } from '@lido-sdk/constants'; +import { getStaticRpcBatchProvider } from '@lido-sdk/providers'; +import { + useLidoSWR, + useSTETHContractRPC, + useWSTETHContractRPC, +} from '@lido-sdk/react'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { + ESTIMATE_ACCOUNT, + getBackendRPCPath, + WRAP_FROM_ETH_GAS_LIMIT, + WRAP_GAS_LIMIT, + WRAP_GAS_LIMIT_GOERLI, + WSTETH_APPROVE_GAS_LIMIT, +} from 'config'; +import { BigNumber } from 'ethers'; +import { STRATEGY_IMMUTABLE } from 'utils/swrStrategies'; + +export const useApproveGasLimit = () => { + const steth = useSTETHContractRPC(); + const wsteth = useWSTETHContractRPC(); + const { chainId } = useWeb3(); + + const { data } = useLidoSWR( + ['swr:approve-wrap-gas-limit', chainId], + async (_key, chainId) => { + if (!chainId) { + return; + } + + const gasLimit = await steth.estimateGas + .approve(wsteth.address, parseEther('0.001'), { + from: ESTIMATE_ACCOUNT, + }) + .catch((error) => { + console.warn('[swr:approve-wrap-gas-limit]', error); + return BigNumber.from(WSTETH_APPROVE_GAS_LIMIT); + }); + + return +gasLimit; + }, + STRATEGY_IMMUTABLE, + ); + + return data ?? WSTETH_APPROVE_GAS_LIMIT; +}; + +export const useWrapGasLimit = (fromEther: boolean) => { + const wsteth = useWSTETHContractRPC(); + const { chainId } = useWeb3(); + + const { data } = useLidoSWR( + ['swr:wrap-gas-limit', chainId, fromEther], + async (_key, chainId, fromEther) => { + if (!chainId) { + return; + } + + const provider = getStaticRpcBatchProvider( + chainId as CHAINS, + getBackendRPCPath(chainId as CHAINS), + ); + + const feeData = await provider.getFeeData(); + const maxPriorityFeePerGas = feeData.maxPriorityFeePerGas ?? undefined; + const maxFeePerGas = feeData.maxFeePerGas ?? undefined; + + if (fromEther) { + const gasLimit = await provider + .estimateGas({ + from: ESTIMATE_ACCOUNT, + to: wsteth.address, + value: parseEther('0.001'), + maxPriorityFeePerGas, + maxFeePerGas, + }) + .catch((error) => { + console.warn(error); + return BigNumber.from(WRAP_FROM_ETH_GAS_LIMIT); + }); + + return +gasLimit; + } else { + const gasLimit = await wsteth.estimateGas + .wrap(parseEther('0.0001'), { + from: ESTIMATE_ACCOUNT, + maxPriorityFeePerGas, + maxFeePerGas, + }) + .catch((error) => { + console.warn(error); + return BigNumber.from( + chainId === CHAINS.Goerli + ? WRAP_GAS_LIMIT_GOERLI + : WRAP_GAS_LIMIT, + ); + }); + + return +gasLimit; + } + }, + ); + + if (!data) { + if (fromEther) { + return WRAP_FROM_ETH_GAS_LIMIT; + } else { + if (chainId === CHAINS.Goerli) { + return WRAP_GAS_LIMIT_GOERLI; + } else { + return WRAP_GAS_LIMIT; + } + } + } + + return data; +}; diff --git a/features/wrap/features/wrap-form/wrap-form.tsx b/features/wrap/features/wrap-form/wrap-form.tsx new file mode 100644 index 000000000..212c164db --- /dev/null +++ b/features/wrap/features/wrap-form/wrap-form.tsx @@ -0,0 +1,217 @@ +import React, { FC, memo, useCallback, useMemo, useState, useRef } from 'react'; +import { + Block, + DataTable, + DataTableRow, + Eth, + Steth, +} from '@lidofinance/lido-ui'; +import { getTokenAddress, TOKENS } from '@lido-sdk/constants'; +import { useWeb3 } from 'reef-knot/web3-react'; +import { useSDK, useWSTETHBalance } from '@lido-sdk/react'; +import { parseEther } from '@ethersproject/units'; +import { TxStageModal, TX_OPERATION, TX_STAGE } from 'shared/components'; +import { useTxCostInUsd, useWstethBySteth } from 'shared/hooks'; +import { useIsMultisig } from 'shared/hooks/useIsMultisig'; +import { + formatBalance, + getErrorMessage, + runWithTransactionLogger, +} from 'utils'; + +import { FormatToken } from 'shared/formatters'; +import { useApproveGasLimit, useWrapGasLimit } from './hooks'; +import { useApprove } from 'shared/hooks/useApprove'; +import { Form } from './form'; + +const ETH = 'ETH'; + +const iconsMap = { + [ETH]: , + [TOKENS.STETH]: , +}; + +export const WrapForm: FC = memo(() => { + const { account } = useWeb3(); + const { chainId } = useSDK(); + + const wstethBalance = useWSTETHBalance(); + + const formRef = useRef(null); + + const [selectedToken, setSelectedToken] = useState( + TOKENS.STETH, + ); + + const [inputValue, setInputValue] = useState(''); + // Needs for fix flashing balance in tx success modal + const [wrappingAmountValue, setWrappingAmountValue] = useState(''); + const [txModalOpen, setTxModalOpen] = useState(false); + const [txStage, setTxStage] = useState(TX_STAGE.SUCCESS); + const [txOperation, setTxOperation] = useState(TX_OPERATION.STAKING); + const [txHash, setTxHash] = useState(); + const [txModalFailedText, setTxModalFailedText] = useState(''); + + const inputValueAsBigNumber = useMemo(() => { + try { + return parseEther(inputValue ? inputValue : '0'); + } catch { + return parseEther('0'); + } + }, [inputValue]); + + const stethTokenAddress = useMemo( + () => getTokenAddress(chainId, TOKENS.STETH), + [chainId], + ); + + const wstethTokenAddress = useMemo( + () => getTokenAddress(chainId, TOKENS.WSTETH), + [chainId], + ); + + const oneSteth = useMemo(() => parseEther('1'), []); + + const approveGasLimit = useApproveGasLimit(); + const approveTxCostInUsd = useTxCostInUsd(approveGasLimit); + + const wrapGasLimit = useWrapGasLimit(selectedToken === ETH); + const wrapTxCostInUsd = useTxCostInUsd(wrapGasLimit); + + const oneWstethConverted = useWstethBySteth(oneSteth); + + const openTxModal = useCallback(() => { + setTxModalOpen(true); + }, []); + + const closeTxModal = useCallback(() => { + setTxModalOpen(false); + }, []); + + const [isMultisig] = useIsMultisig(); + + const approveWrapper = useCallback< + NonNullable[4]> + >( + async (callback) => { + try { + setTxStage(TX_STAGE.SIGN); + openTxModal(); + + const transaction = await runWithTransactionLogger( + 'Approve signing', + callback, + ); + + if (isMultisig) { + setTxStage(TX_STAGE.IDLE); + closeTxModal(); + return; + } + + if (typeof transaction !== 'string') { + setTxHash(transaction.hash); + setTxStage(TX_STAGE.BLOCK); + openTxModal(); + + await runWithTransactionLogger( + 'Approve block confirmation', + async () => transaction.wait(), + ); + } + + setTxStage(TX_STAGE.SUCCESS); + openTxModal(); + + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + setTxModalFailedText(getErrorMessage(error)); + setTxStage(TX_STAGE.FAIL); + openTxModal(); + } + }, + [openTxModal, closeTxModal, isMultisig], + ); + + const { + approve, + needsApprove, + allowance, + loading: loadingUseApprove, + } = useApprove( + inputValueAsBigNumber, + stethTokenAddress, + wstethTokenAddress, + account ? account : undefined, + approveWrapper, + ); + + const willWrapSteth = useMemo(() => { + if (selectedToken === TOKENS.STETH && needsApprove) { + return parseEther('0'); + } + + return inputValueAsBigNumber; + }, [needsApprove, selectedToken, inputValueAsBigNumber]); + const willReceiveWsteth = useWstethBySteth(willWrapSteth); + + const isSteth = selectedToken === TOKENS.STETH; + + return ( + +
+ + + + ${approveTxCostInUsd?.toFixed(2)} + + + ${wrapTxCostInUsd?.toFixed(2)} + + + 1 {isSteth ? 'stETH' : 'ETH'} ={' '} + + + + {isSteth ? : <>-} + + + + + + + formRef.current?.requestSubmit()} + /> + + ); +}); diff --git a/features/wrap/index.ts b/features/wrap/index.ts new file mode 100644 index 000000000..b7111fdbf --- /dev/null +++ b/features/wrap/index.ts @@ -0,0 +1,4 @@ +export { WrapForm } from './features/wrap-form/wrap-form'; +export { UnwrapForm } from './features/unwrap-form/unwrap-form'; +export { Wallet } from './features/wallet/wallet'; +export { WrapFaq } from './features/wrap-faq/wrap-faq'; diff --git a/features/wrap/styles.tsx b/features/wrap/styles.tsx new file mode 100644 index 000000000..d27657494 --- /dev/null +++ b/features/wrap/styles.tsx @@ -0,0 +1,35 @@ +import styled, { css } from 'styled-components'; +import { InputGroup, SelectIcon } from '@lidofinance/lido-ui'; +import { InputNumber } from 'shared/forms/components/input-number'; + +const errorCSS = css` + &, + &:hover, + &:focus-within { + border-color: var(--lido-color-error); + } +`; + +export const FormStyled = styled.form` + margin-bottom: 24px; +`; + +export const InputStyled = styled(InputNumber)` + margin-bottom: ${({ theme }) => theme.spaceMap.md}px; + z-index: 2; +`; + +export const InputGroupStyled = styled(InputGroup)` + margin-bottom: ${({ theme }) => theme.spaceMap.md}px; + z-index: 2; +`; + +export const SelectIconWrapper = styled(SelectIcon)` + position: static; +`; + +export const InputWrapper = styled(InputNumber)<{ + error: boolean; +}>` + ${({ error }) => (error ? errorCSS : '')} +`; diff --git a/features/wrap/utils.ts b/features/wrap/utils.ts new file mode 100644 index 000000000..c30fa32f4 --- /dev/null +++ b/features/wrap/utils.ts @@ -0,0 +1,272 @@ +import { parseEther } from '@ethersproject/units'; +import { WstethAbi } from '@lido-sdk/contracts'; +import { getTokenAddress, TOKENS } from '@lido-sdk/constants'; +import { TX_STAGE } from 'shared/components'; +import { getErrorMessage, runWithTransactionLogger } from 'utils'; +import { getStaticRpcBatchProvider } from 'utils/rpcProviders'; +import { getBackendRPCPath } from 'config'; +import invariant from 'tiny-invariant'; +import type { Web3Provider } from '@ethersproject/providers'; + +const ETH = 'ETH'; + +type UnwrapProcessingProps = ( + providerWeb3: Web3Provider | undefined, + stethContractWeb3: WstethAbi | null, + openTxModal: () => void, + closeTxModal: () => void, + setTxStage: (value: TX_STAGE) => void, + setTxHash: (value: string | undefined) => void, + setTxModalFailedText: (value: string) => void, + wstethBalanceUpdate: () => void, + stethBalanceUpdate: () => void, + chainId: string | number | undefined, + inputValue: string, + resetForm: () => void, + isMultisig: boolean, +) => Promise; + +export const unwrapProcessing: UnwrapProcessingProps = async ( + providerWeb3, + wstethContractWeb3, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + wstethBalanceUpdate, + stethBalanceUpdate, + chainId, + inputValue, + resetForm, + isMultisig, +) => { + if (!wstethContractWeb3 || !chainId) { + return; + } + + invariant(providerWeb3, 'must have providerWeb3'); + + try { + const callback = async () => { + if (isMultisig) { + const tx = await wstethContractWeb3.populateTransaction.unwrap( + parseEther(inputValue), + ); + return providerWeb3.getSigner().sendUncheckedTransaction(tx); + } else { + const provider = getStaticRpcBatchProvider( + chainId, + getBackendRPCPath(chainId), + ); + const feeData = await provider.getFeeData(); + return wstethContractWeb3.unwrap(parseEther(inputValue), { + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + maxFeePerGas: feeData.maxFeePerGas ?? undefined, + }); + } + }; + + setTxStage(TX_STAGE.SIGN); + openTxModal(); + + const transaction = await runWithTransactionLogger( + 'Unwrap signing', + callback, + ); + + const handleEnding = () => { + resetForm(); + stethBalanceUpdate(); + wstethBalanceUpdate(); + }; + + if (isMultisig) { + handleEnding(); + closeTxModal(); + return; + } + + if (typeof transaction === 'object') { + setTxHash(transaction.hash); + setTxStage(TX_STAGE.BLOCK); + openTxModal(); + await runWithTransactionLogger('Unwrap block confirmation', async () => + transaction.wait(), + ); + } + + handleEnding(); + setTxStage(TX_STAGE.SUCCESS); + openTxModal(); + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + // errors are sometimes nested :( + setTxModalFailedText(getErrorMessage(error)); + setTxStage(TX_STAGE.FAIL); + setTxHash(undefined); + openTxModal(); + } +}; + +type WrapProcessingWithApproveProps = ( + chainId: number | undefined, + providerWeb3: Web3Provider | undefined, + stethContractWeb3: WstethAbi | null, + openTxModal: () => void, + closeTxModal: () => void, + setTxStage: (value: TX_STAGE) => void, + setTxHash: (value: string | undefined) => void, + setTxModalFailedText: (value: string) => void, + ethBalanceUpdate: () => void, + stethBalanceUpdate: () => void, + inputValue: string, + selectedToken: string, + needsApprove: boolean, + isMultisig: boolean, + approve: () => void, + resetForm: () => void, +) => Promise; + +export const wrapProcessingWithApprove: WrapProcessingWithApproveProps = async ( + chainId, + providerWeb3, + wstethContractWeb3, + openTxModal, + closeTxModal, + setTxStage, + setTxHash, + setTxModalFailedText, + ethBalanceUpdate, + stethBalanceUpdate, + inputValue, + selectedToken, + needsApprove, + isMultisig, + approve, + resetForm, +) => { + if (!chainId || !wstethContractWeb3) { + return; + } + + invariant(providerWeb3, 'must have providerWeb3'); + + const wstethTokenAddress = getTokenAddress(chainId, TOKENS.WSTETH); + + const handleEnding = () => { + resetForm(); + ethBalanceUpdate(); + stethBalanceUpdate(); + }; + + const getGasParameters = async () => { + const provider = getStaticRpcBatchProvider( + chainId, + getBackendRPCPath(chainId), + ); + const feeData = await provider.getFeeData(); + return { + maxPriorityFeePerGas: feeData.maxPriorityFeePerGas ?? undefined, + maxFeePerGas: feeData.maxFeePerGas ?? undefined, + }; + }; + + try { + if (selectedToken === ETH) { + const callback = async () => { + if (isMultisig) { + return providerWeb3.getSigner().sendUncheckedTransaction({ + to: wstethTokenAddress, + value: parseEther(inputValue), + }); + } else { + return wstethContractWeb3.signer.sendTransaction({ + to: wstethTokenAddress, + value: parseEther(inputValue), + ...(await getGasParameters()), + }); + } + }; + + setTxStage(TX_STAGE.SIGN); + openTxModal(); + + const transaction = await runWithTransactionLogger( + 'Wrap signing', + callback, + ); + + if (isMultisig) { + closeTxModal(); + handleEnding(); + return; + } + + if (typeof transaction === 'object') { + setTxHash(transaction.hash); + setTxStage(TX_STAGE.BLOCK); + openTxModal(); + await runWithTransactionLogger('Wrap block confirmation', async () => + transaction.wait(), + ); + } + + handleEnding(); + setTxStage(TX_STAGE.SUCCESS); + openTxModal(); + } else if (selectedToken === TOKENS.STETH) { + if (needsApprove) { + approve(); + } else { + const callback = async () => { + if (isMultisig) { + const tx = await wstethContractWeb3.populateTransaction.wrap( + parseEther(inputValue), + ); + return providerWeb3.getSigner().sendUncheckedTransaction(tx); + } else { + return wstethContractWeb3.wrap( + parseEther(inputValue), + await getGasParameters(), + ); + } + }; + + setTxStage(TX_STAGE.SIGN); + openTxModal(); + + const transaction = await runWithTransactionLogger( + 'Wrap signing', + callback, + ); + + if (isMultisig) { + closeTxModal(); + handleEnding(); + return; + } + + if (typeof transaction === 'object') { + setTxHash(transaction.hash); + setTxStage(TX_STAGE.BLOCK); + openTxModal(); + await runWithTransactionLogger('Wrap block confirmation', async () => + transaction.wait(), + ); + } + + handleEnding(); + setTxStage(TX_STAGE.SUCCESS); + openTxModal(); + } + } + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + } catch (error: any) { + // errors are sometimes nested :( + setTxModalFailedText(getErrorMessage(error)); + setTxStage(TX_STAGE.FAIL); + setTxHash(undefined); + openTxModal(); + } +}; diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 000000000..4d0af64fd --- /dev/null +++ b/global.d.ts @@ -0,0 +1,4 @@ +interface Window { + // see _document.js for definition + _paq: undefined | [string, ...unknown[]][]; +} diff --git a/jest.config.cjs b/jest.config.cjs new file mode 100644 index 000000000..193d3c7fb --- /dev/null +++ b/jest.config.cjs @@ -0,0 +1,7 @@ +module.exports = { + transform: { + '^.+\\.(t|j)sx?$': 'ts-jest', + }, + moduleDirectories: ['node_modules', ''], + modulePathIgnorePatterns: ['./test'], +}; diff --git a/lib/faqList.ts b/lib/faqList.ts new file mode 100644 index 000000000..8dd9f16b7 --- /dev/null +++ b/lib/faqList.ts @@ -0,0 +1,33 @@ +import matter from 'gray-matter'; +import remark from 'remark'; +import html from 'remark-html'; +import externalLinks from 'remark-external-links'; + +export interface FAQItem { + id: string; + content: string; + title: string; +} + +export const getFaqList = async (list: string[]): Promise => { + return Promise.all( + list.map(async (id) => { + const fileContents = await import(`faq/${id}.md`); + const matterResult = matter(fileContents.default); + + const processedContent = await remark() + .use(externalLinks, { target: '_blank', rel: ['nofollow', 'noopener'] }) + .use(html) + .process(matterResult.content); + + const content = processedContent.toString(); + const title = String(matterResult.data.title || id); + + return { + id, + content, + title, + }; + }), + ); +}; diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 000000000..2e344b564 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,37 @@ +import { cacheControlMiddlewareFactory } from '@lidofinance/next-cache-files-middleware'; + +export const CACHE_HEADERS_HTML_PAGE = + 'public, max-age=30, stale-if-error=1200, stale-while-revalidate=30'; +export const CACHE_ALLOWED_LIST_FILES_PATHS = [ + { path: '/', headers: CACHE_HEADERS_HTML_PAGE }, + { path: '/wrap', headers: CACHE_HEADERS_HTML_PAGE }, + { path: '/wrap/unwrap', headers: CACHE_HEADERS_HTML_PAGE }, + { path: '/rewards', headers: CACHE_HEADERS_HTML_PAGE }, + { path: '/withdrawals', headers: CACHE_HEADERS_HTML_PAGE }, + { path: '/withdrawals/request', headers: CACHE_HEADERS_HTML_PAGE }, + { path: '/withdrawals/claim', headers: CACHE_HEADERS_HTML_PAGE }, + { path: '/runtime/window-env.js', headers: CACHE_HEADERS_HTML_PAGE }, +]; + +// use only for cache files +export const middleware = cacheControlMiddlewareFactory( + CACHE_ALLOWED_LIST_FILES_PATHS, +); + +export const config = { + // paths where use middleware + matcher: [ + '/manifest.json', + '/favicon:size*', + '/', + '/wrap', + '/wrap/unwrap', + '/rewards', + '/withdrawals', + '/withdrawals/request', + '/withdrawals/claim', + '/runtime/window-env.js', + ], +}; + +export default middleware; diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 000000000..4f11a03dc --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/next-logger.config.cjs b/next-logger.config.cjs new file mode 100644 index 000000000..e53b53f56 --- /dev/null +++ b/next-logger.config.cjs @@ -0,0 +1,42 @@ +// eslint-disable-next-line @typescript-eslint/no-var-requires +const pino = require('pino'); // It's ok that pino is transit dependency, it's required by next-logger +// eslint-disable-next-line @typescript-eslint/no-var-requires +const { satanizer, commonPatterns } = require('@lidofinance/satanizer'); +// eslint-disable-next-line @typescript-eslint/no-var-requires +const loadEnvConfig = require('@next/env').loadEnvConfig; + +// Must load env first +const projectDir = process.cwd(); +loadEnvConfig(projectDir); + +const patterns = [ + ...commonPatterns, + process.env.INFURA_API_KEY, + process.env.ALCHEMY_API_KEY, + process.env.ETHPLORER_API_KEY, + // TODO: Delete this ENV + process.env.CLOUDFLARE_API_TOKEN, + process.env.CLOUDFLARE_ACCOUNT_ID, + process.env.CLOUDFLARE_KV_NAMESPACE_ID, +]; +const mask = satanizer(patterns); + +const logger = (defaultConfig) => + pino({ + ...defaultConfig, + formatters: { + ...defaultConfig.formatters, + level(label, _number) { + return { level: label }; + }, + }, + hooks: { + logMethod(inputArgs, method) { + return method.apply(this, mask(inputArgs)); + }, + }, + }); + +module.exports = { + logger, +}; diff --git a/next.config.mjs b/next.config.mjs new file mode 100644 index 000000000..9d8481d99 --- /dev/null +++ b/next.config.mjs @@ -0,0 +1,131 @@ +import NextBundleAnalyzer from '@next/bundle-analyzer'; +import buildDynamics from './scripts/build-dynamics.mjs'; + +buildDynamics(); + +const basePath = process.env.BASE_PATH; +const infuraApiKey = process.env.INFURA_API_KEY; +const alchemyApiKey = process.env.ALCHEMY_API_KEY; +const ethAPIBasePath = process.env.ETH_API_BASE_PATH; + +const ethplorerApiKey = process.env.ETHPLORER_API_KEY; + +// TODO: Delete this ENV +const cloudflareApiToken = process.env.CLOUDFLARE_API_TOKEN; +const cloudflareAccountId = process.env.CLOUDFLARE_ACCOUNT_ID; +const cloudflareKvNamespaceId = process.env.CLOUDFLARE_KV_NAMESPACE_ID; + +const cspTrustedHosts = process.env.CSP_TRUSTED_HOSTS; +const cspReportOnly = process.env.CSP_REPORT_ONLY; +const cspReportUri = process.env.CSP_REPORT_URI; + +const subgraphMainnet = process.env.SUBGRAPH_MAINNET; +const subgraphRopsten = process.env.SUBGRAPH_ROPSTEN; +const subgraphRinkeby = process.env.SUBGRAPH_RINKEBY; +const subgraphGoerli = process.env.SUBGRAPH_GOERLI; +const subgraphKovan = process.env.SUBGRAPH_KOVAN; +const subgraphKintsugi = process.env.SUBGRAPH_KINTSUGI; + +const subgraphRequestTimeout = process.env.SUBGRAPH_REQUEST_TIMEOUT; + +const analyzeBundle = process.env.ANALYZE_BUNDLE ?? false; + +// rate limit +const rateLimit = process.env.RATE_LIMIT || 100; +const rateLimitTimeFrame = process.env.RATE_LIMIT_TIME_FRAME || 60; // 1 minute; + +const rewardsBackendAPI = process.env.REWARDS_BACKEND; +const defaultChain = process.env.DEFAULT_CHAIN; + +const withBundleAnalyzer = NextBundleAnalyzer({ + enabled: analyzeBundle, +}); + +export default withBundleAnalyzer({ + basePath, + eslint: { + ignoreDuringBuilds: true, + }, + compiler: { + styledComponents: true, + }, + experimental: { + // Fixes a build error with importing Pure ESM modules, e.g. reef-knot + // Some docs are here: + // https://github.com/vercel/next.js/pull/27069 + // You can see how it is actually used in v12.3.4 here: + // https://github.com/vercel/next.js/blob/v12.3.4/packages/next/build/webpack-config.ts#L417 + // Presumably, it is true by default in next v13 and won't be needed + esmExternals: true, + }, + webpack(config) { + // Teach webpack to import svg files + config.module.rules.push({ + test: /\.svg$/, + use: ['@svgr/webpack', 'url-loader'], + }); + + // Teach webpack to import md files + config.module.rules.push({ + test: /\.md$/, + use: 'raw-loader', + }); + + return config; + }, + async headers() { + return [ + { + // required for gnosis save apps + source: '/manifest.json', + headers: [{ key: 'Access-Control-Allow-Origin', value: '*' }], + }, + { + // Apply these headers to all routes in your application. + source: '/(.*)', + headers: [ + { + key: 'X-DNS-Prefetch-Control', + value: 'on', + }, + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + { + key: 'Referrer-Policy', + value: 'same-origin', + }, + ], + }, + ]; + }, + serverRuntimeConfig: { + basePath, + infuraApiKey, + alchemyApiKey, + ethplorerApiKey, + cloudflareApiToken, + cloudflareAccountId, + cloudflareKvNamespaceId, + cspTrustedHosts, + cspReportOnly, + cspReportUri, + subgraphMainnet, + subgraphRopsten, + subgraphRinkeby, + subgraphGoerli, + subgraphKovan, + subgraphKintsugi, + subgraphRequestTimeout, + rateLimit, + rateLimitTimeFrame, + ethAPIBasePath, + rewardsBackendAPI, + defaultChain, + }, +}); diff --git a/package.json b/package.json new file mode 100644 index 000000000..b48013e70 --- /dev/null +++ b/package.json @@ -0,0 +1,130 @@ +{ + "name": "lido-staking-widget", + "version": "1.60.1", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "build:analyze": "ANALYZE_BUNDLE=true next build", + "start": "NODE_OPTIONS='-r next-logger' next start", + "lint": "eslint --ext ts,tsx,js,mjs .", + "lint:fix": "yarn lint --fix", + "types": "tsc --noEmit", + "typechain": "typechain --target=ethers-v5 --out-dir ./generated ./abi/*.json", + "postinstall": "husky install && yarn typechain || true", + "test": "yarn test:e2e", + "test:unit": "jest", + "test:e2e": "playwright test" + }, + "dependencies": { + "@ethersproject/bignumber": "^5.7.0", + "@ethersproject/bytes": "^5.7.0", + "@ethersproject/constants": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/units": "^5.7.0", + "@lido-sdk/constants": "^3.1.0", + "@lido-sdk/contracts": "^3.0.1", + "@lido-sdk/fetch": "^2.1.9", + "@lido-sdk/helpers": "^1.4.11", + "@lido-sdk/providers": "^1.4.12", + "@lido-sdk/react": "^2.0.0", + "@lidofinance/analytics-matomo": "^0.28.0", + "@lidofinance/api-metrics": "^0.28.0", + "@lidofinance/api-rpc": "^0.28.0", + "@lidofinance/eth-api-providers": "^0.28.0", + "@lidofinance/eth-providers": "^0.28.0", + "@lidofinance/lido-ui": "^3.7.4", + "@lidofinance/lido-ui-blocks": "2.10.2", + "@lidofinance/next-api-wrapper": "^0.28.0", + "@lidofinance/next-cache-files-middleware": "^0.28.0", + "@lidofinance/next-ip-rate-limit": "^0.28.0", + "@lidofinance/next-pages": "^0.28.0", + "@lidofinance/rpc": "^0.28.0", + "@lidofinance/satanizer": "^0.32.0", + "@types/cors": "^2.8.12", + "@types/js-cookie": "^3.0.0", + "@types/ms": "^0.7.31", + "@types/nprogress": "^0.2.0", + "@types/react-transition-group": "^4.4.3", + "bignumber.js": "9.1.0", + "copy-to-clipboard": "^3.3.1", + "cors": "^2.8.5", + "date-fns": "2.29.2", + "ethers": "^5.7.2", + "fs-extra": "^10.1.0", + "gray-matter": "^4.0.3", + "js-cookie": "^3.0.1", + "lodash": "^4.17.21", + "memory-cache": "^0.2.0", + "ms": "^2.1.3", + "next": "^12.2.5", + "next-logger": "^3.0.2", + "next-secure-headers": "^2.2.0", + "nprogress": "^0.2.0", + "prom-client": "^14.0.1", + "raw-loader": "^4.0.2", + "react": "17.0.2", + "react-device-detect": "^1.17.0", + "react-dom": "17.0.2", + "react-hook-form": "^7.45.1", + "react-is": "^17.0.2", + "react-transition-group": "^4.4.2", + "reef-knot": "^1.6.1", + "remark": "^13.0.0", + "remark-external-links": "^8.0.0", + "remark-html": "^13.0.1", + "styled-components": "5.3.5", + "swr": "^1.3.0", + "tiny-async-pool": "^1.2.0", + "tiny-invariant": "^1.1.0", + "wagmi": "0.12.18" + }, + "devDependencies": { + "@commitlint/cli": "^17.4.4", + "@commitlint/config-conventional": "^17.4.4", + "@commitlint/prompt": "^17.4.4", + "@next/bundle-analyzer": "^13.2.4", + "@playwright/test": "^1.29.2", + "@svgr/webpack": "^8.0.1", + "@typechain/ethers-v5": "^7.0.1", + "@types/jest": "28.1.6", + "@types/lodash": "^4.14.186", + "@types/memory-cache": "0.2.2", + "@types/node": "^18.6.1", + "@types/react": "^17.0.53", + "@types/styled-components": "^5.1.11", + "@types/styled-system": "5.1.15", + "@types/winston": "^2.4.4", + "@typescript-eslint/eslint-plugin": "^5.12.0", + "@typescript-eslint/parser": "^5.12.0", + "eslint": "8.9.0", + "eslint-config-prettier": "^8.3.0", + "eslint-plugin-import": "^2.23.4", + "eslint-plugin-jsx-a11y": "^6.4.1", + "eslint-plugin-prettier": "^3.4.0", + "eslint-plugin-react": "^7.24.0", + "eslint-plugin-react-hooks": "^4.3.0", + "husky": "^7.0.1", + "jest": "^29.5.0", + "jsonschema": "^1.4.1", + "lint-staged": "^13.2.0", + "playwright": "^1.29.2", + "prettier": "^2.3.2", + "ts-jest": "^29.1.0", + "typechain": "^5.1.2", + "typescript": "^4.9.4", + "url-loader": "^4.1.1" + }, + "lint-staged": { + "./**/*.{ts,tsx}": [ + "eslint --ignore-path .gitignore --max-warnings=0" + ], + "./**/*.{ts,tsx,css,md,json}": [ + "prettier --write" + ] + }, + "resolutions": { + "@types/react": "17.0.43" + } +} diff --git a/pages/404.tsx b/pages/404.tsx new file mode 100644 index 000000000..776a34f64 --- /dev/null +++ b/pages/404.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; +import Head from 'next/head'; +import { ServicePage } from '@lidofinance/lido-ui'; + +const Page404: FC = () => ( + + + Lido | Page Not Found + + Page Not Found + +); + +export default Page404; diff --git a/pages/500.tsx b/pages/500.tsx new file mode 100644 index 000000000..454723c3b --- /dev/null +++ b/pages/500.tsx @@ -0,0 +1,14 @@ +import { FC } from 'react'; +import Head from 'next/head'; +import { ServicePage } from '@lidofinance/lido-ui'; + +const Page404: FC = () => ( + + + Lido | Internal Server Error + + Internal Server Error + +); + +export default Page404; diff --git a/pages/_app.tsx b/pages/_app.tsx new file mode 100644 index 000000000..7560f5cc8 --- /dev/null +++ b/pages/_app.tsx @@ -0,0 +1,52 @@ +import { memo } from 'react'; +import { AppProps } from 'next/app'; +import { + ToastContainer, + CookiesTooltip, + migrationAllowCookieToCrossDomainCookieClientSide, + migrationThemeCookiesToCrossDomainCookiesClientSide, +} from '@lidofinance/lido-ui'; +import 'nprogress/nprogress.css'; + +import Providers from 'providers'; +import { nprogress, COOKIES_ALLOWED_FULL_KEY } from 'utils'; +import { withCsp } from 'utilsApi/withCsp'; +import { BackgroundGradient } from 'shared/components/background-gradient/background-gradient'; + +// Migrations old theme cookies to new cross domain cookies +migrationThemeCookiesToCrossDomainCookiesClientSide(); + +// Migrations old allow cookies to new cross domain cookies +migrationAllowCookieToCrossDomainCookieClientSide(COOKIES_ALLOWED_FULL_KEY); + +// Visualize route changes +nprogress(); + +const App = (props: AppProps) => { + const { Component, pageProps } = props; + + return ; +}; + +const MemoApp = memo(App); + +const AppWrapper = (props: AppProps): JSX.Element => { + return ( + + + + + + + ); +}; + +export default process.env.NODE_ENV === 'development' + ? AppWrapper + : withCsp(AppWrapper); diff --git a/pages/_document.tsx b/pages/_document.tsx new file mode 100644 index 000000000..18d0e5820 --- /dev/null +++ b/pages/_document.tsx @@ -0,0 +1,119 @@ +import Document, { + Head, + Html, + Main, + NextScript, + DocumentContext, + DocumentInitialProps, +} from 'next/document'; +import { Fonts, LidoUIHead } from '@lidofinance/lido-ui'; +import { ServerStyleSheet } from 'styled-components'; + +import { dynamics } from 'config'; + +let host = 'https://stake.lido.fi'; + +export default class MyDocument extends Document { + static async getInitialProps( + ctx: DocumentContext, + ): Promise { + const sheet = new ServerStyleSheet(); + const originalRenderPage = ctx.renderPage; + + if (ctx?.req?.headers?.host) { + host = `https://${ctx?.req?.headers?.host}`; + } + + try { + ctx.renderPage = () => + originalRenderPage({ + enhanceApp: (App) => (props) => + sheet.collectStyles(), + }); + + const initialProps = await Document.getInitialProps(ctx); + + return { + ...initialProps, + styles: ( + <> + {initialProps.styles} + {sheet.getStyleElement()} + + ), + }; + } finally { + sheet.seal(); + } + } + + get metaTitle(): string { + return 'Stake with Lido | Lido'; + } + + get metaDescription(): string { + return ( + 'Liquid staking with Lido. ' + + 'Stake Ether with Lido to get daily rewards while keeping full control of your staked tokens. ' + + 'Start receiving rewards in just a few clicks.' + ); + } + + get metaPreviewImgUrl(): string { + return `${host}/lido-preview.png`; + } + + render(): JSX.Element { + return ( + + + + + + + + + + + + + + + + + + + + + + + +