From 7572669eb4c5afbab7c973f11f389ac69c2c822b Mon Sep 17 00:00:00 2001 From: Krzysztof Kaczor Date: Wed, 24 Apr 2024 16:01:18 +0200 Subject: [PATCH] Init --- .editorconfig | 9 + .eslintrc.cjs | 63 + .github/workflows/ci.yml | 141 + .github/workflows/flaky-e2e-detector.yml | 48 + .github/workflows/ipfs-release.yml | 40 + .gitignore | 6 + .npmrc | 1 + .nvmrc | 1 + .prettierignore | 7 + .prettierrc | 10 + LICENSE | 14 + README.md | 103 + docs/bundle-size-optimizing-guide.md | 6 + docs/hot-patching-modules.md | 8 + package.json | 39 + packages/app/.env.development | 11 + packages/app/.env.example | 19 + packages/app/.env.playwright | 1 + packages/app/.env.production | 11 + packages/app/.env.staging | 11 + packages/app/.env.storybook | 3 + packages/app/.gitignore | 8 + packages/app/.storybook/DevContainer.tsx | 46 + packages/app/.storybook/ErrorBoundary.tsx | 44 + packages/app/.storybook/decorators.tsx | 118 + packages/app/.storybook/main.ts | 41 + packages/app/.storybook/preview.ts | 60 + packages/app/.storybook/tokens.ts | 90 + packages/app/.storybook/utils.ts | 14 + packages/app/.storybook/viewports.ts | 30 + packages/app/index.html | 31 + packages/app/lingui.config.ts | 13 + packages/app/package.json | 146 + packages/app/playwright.config.ts | 40 + packages/app/postcss.config.js | 6 + packages/app/public/_redirects | 1 + packages/app/public/apple-touch-icon.png | Bin 0 -> 10983 bytes packages/app/public/favicon-16x16.png | Bin 0 -> 540 bytes packages/app/public/favicon-32x32.png | Bin 0 -> 1155 bytes packages/app/public/favicon.ico | Bin 0 -> 285478 bytes packages/app/public/spark-meta-logo.jpg | Bin 0 -> 48826 bytes packages/app/src/App.tsx | 52 + packages/app/src/RootRouter.tsx | 49 + packages/app/src/config/abis/debtTokenAbi.ts | 44 + packages/app/src/config/abis/poolAbi.ts | 1616 ++ packages/app/src/config/chain/constants.ts | 4 + packages/app/src/config/chain/index.ts | 119 + packages/app/src/config/chain/types.ts | 46 + .../src/config/chain/utils/airdrops.test.ts | 70 + .../app/src/config/chain/utils/airdrops.ts | 38 + .../config/chain/utils/getNativeAssetInfo.ts | 6 + packages/app/src/config/consts.ts | 15 + .../app/src/config/contracts-generated.ts | 2856 +++ .../config/feature-flags/appConfig.default.ts | 66 + .../config/feature-flags/appConfig.testing.ts | 7 + .../app/src/config/feature-flags/clientEnv.ts | 13 + .../app/src/config/feature-flags/index.ts | 27 + packages/app/src/config/paths.ts | 7 + packages/app/src/config/query-client.ts | 12 + packages/app/src/config/tailwind.ts | 3 + .../app/src/config/wagmi/config.default.ts | 62 + packages/app/src/config/wagmi/config.e2e.ts | 74 + packages/app/src/config/wagmi/index.ts | 7 + packages/app/src/css/fonts.css | 8 + packages/app/src/css/main.css | 119 + ...alculateMaxBorrowBasedOnCollateral.test.ts | 15 + .../calculateMaxBorrowBasedOnCollateral.ts | 24 + .../getBorrowMaxValue.test.ts | 176 + .../getBorrowMaxValue.ts | 38 + .../getDepositMaxValue.test.ts | 160 + .../getDepositMaxValue.ts | 51 + .../getRepayMaxValue.test.ts | 76 + .../getRepayMaxValue.ts | 22 + .../getWithdrawMaxValue.test.ts | 22 + .../getWithdrawMaxValue.ts | 18 + packages/app/src/domain/common/assets.ts | 5 + packages/app/src/domain/common/format.test.ts | 131 + packages/app/src/domain/common/format.ts | 57 + packages/app/src/domain/common/risk.ts | 39 + .../app/src/domain/common/sorters.test.ts | 23 + packages/app/src/domain/common/sorters.ts | 15 + packages/app/src/domain/common/types.ts | 24 + packages/app/src/domain/e-mode/constants.ts | 5 + packages/app/src/domain/e-mode/types.ts | 4 + .../app/src/domain/errors/not-connected.ts | 6 + packages/app/src/domain/errors/not-found.ts | 6 + .../src/domain/errors/useViteErrorOverlay.ts | 27 + .../src/domain/exchanges/lifi/lifi.test.ts | 338 + .../app/src/domain/exchanges/lifi/lifi.ts | 137 + .../lifi/meta/MockLifiQueryMetaEvaluator.ts | 12 + .../meta/RealLifiQueryMetaEvaluator.test.ts | 67 + .../lifi/meta/RealLifiQueryMetaEvaluator.ts | 40 + .../src/domain/exchanges/lifi/meta/index.ts | 23 + .../lifi/meta/useLifiQueryMetaEvaluator.ts | 22 + .../app/src/domain/exchanges/lifi/query.ts | 98 + .../app/src/domain/exchanges/lifi/types.ts | 22 + .../exchanges/lifi/useLiFiTxData.test.ts | 223 + .../domain/exchanges/lifi/useLiFiTxData.ts | 41 + .../src/domain/exchanges/lifi/validation.ts | 33 + packages/app/src/domain/exchanges/types.ts | 45 + .../app/src/domain/hooks/sanityChecks.test.ts | 22 + packages/app/src/domain/hooks/sanityChecks.ts | 25 + .../hooks/useBlockExplorerAddressLink.ts | 16 + .../src/domain/hooks/useConditionalFreeze.ts | 13 + .../src/domain/hooks/useContractAddress.ts | 20 + .../app/src/domain/hooks/useOriginChainId.ts | 33 + .../app/src/domain/hooks/useSendTx.test.ts | 113 + packages/app/src/domain/hooks/useSendTx.ts | 119 + .../useWaitForTransactionReceiptGnosisSafe.ts | 58 + .../useWaitForTransactionReceiptUniversal.ts | 42 + .../app/src/domain/hooks/useWalletType.ts | 34 + .../app/src/domain/hooks/useWrite.test.ts | 145 + packages/app/src/domain/hooks/useWrite.ts | 136 + .../app/src/domain/i18n/I18nAppProvider.tsx | 16 + .../app/src/domain/i18n/I18nTestProvider.tsx | 14 + packages/app/src/domain/i18n/locales.ts | 22 + .../domain/maker-info/getIsChainSupported.ts | 13 + .../src/domain/maker-info/makerInfoQuery.ts | 103 + packages/app/src/domain/maker-info/types.ts | 16 + .../app/src/domain/maker-info/useMakerInfo.ts | 28 + .../market-info/aave-data-layer/query.ts | 184 + .../aave-data-layer/useAaveDataLayer.ts | 33 + packages/app/src/domain/market-info/emode.ts | 42 + .../market-info/getLiquidationDetails.test.ts | 314 + .../market-info/getLiquidationDetails.ts | 145 + .../app/src/domain/market-info/incentives.ts | 39 + .../app/src/domain/market-info/marketInfo.ts | 339 + .../app/src/domain/market-info/math.test.ts | 15 + packages/app/src/domain/market-info/math.ts | 78 + .../domain/market-info/reserve-status.test.ts | 116 + .../src/domain/market-info/reserve-status.ts | 67 + .../market-info/updatePositionSummary.ts | 224 + .../src/domain/market-info/useMarketInfo.ts | 37 + .../app/src/domain/market-info/utils.test.ts | 42 + packages/app/src/domain/market-info/utils.ts | 111 + .../market-operations/allowance/query.ts | 43 + .../allowance/useAllowance.test.tsx | 69 + .../allowance/useAllowance.ts | 27 + .../allowance/useHasEnoughAllowance.test.tsx | 56 + .../allowance/useHasEnoughAllowance.ts | 36 + .../borrow-allowance/query.ts | 36 + .../useBorrowAllowance.test.ts | 60 + .../borrow-allowance/useBorrowAllowance.ts | 30 + .../useHasEnoughBorrowAllowance.test.ts | 77 + .../useHasEnoughBorrowAllowance.ts | 36 + .../market-operations/normalizeErc20Abi.ts | 200 + .../market-operations/useApprove.test.ts | 81 + .../domain/market-operations/useApprove.ts | 49 + .../useApproveDelegation.test.ts | 70 + .../market-operations/useApproveDelegation.ts | 49 + .../market-operations/useBorrow.test.ts | 137 + .../src/domain/market-operations/useBorrow.ts | 71 + .../market-operations/useDeposit.test.ts | 151 + .../domain/market-operations/useDeposit.ts | 131 + .../market-operations/useExchange.test.ts | 87 + .../domain/market-operations/useExchange.ts | 129 + .../domain/market-operations/useRepay.test.ts | 204 + .../src/domain/market-operations/useRepay.ts | 132 + .../useSetUseAsCollateral.test.ts | 102 + .../useSetUseAsCollateral.ts | 52 + .../market-operations/useSetUserEMode.test.ts | 70 + .../market-operations/useSetUserEMode.ts | 49 + .../market-operations/useWithdraw.test.ts | 135 + .../domain/market-operations/useWithdraw.ts | 77 + .../market-validators/validateBorrow.test.ts | 310 + .../market-validators/validateBorrow.ts | 179 + .../market-validators/validateDeposit.test.ts | 64 + .../market-validators/validateDeposit.ts | 50 + .../market-validators/validateRepay.test.ts | 74 + .../domain/market-validators/validateRepay.ts | 54 + .../validateSetUseAsCollateral.test.ts | 179 + .../validateSetUseAsCollateral.ts | 120 + .../validateSetUserEMode.test.ts | 141 + .../market-validators/validateSetUserEMode.ts | 93 + .../validateWithdraw.test.ts | 84 + .../market-validators/validateWithdraw.ts | 55 + .../domain/sandbox/createSandboxConnector.ts | 39 + .../src/domain/sandbox/createTenderlyFork.ts | 46 + .../domain/sandbox/publicTenderlyActions.ts | 38 + packages/app/src/domain/sandbox/request.ts | 33 + .../app/src/domain/sandbox/useSandboxState.ts | 47 + .../domain/savings/makeAssetsInWalletList.ts | 26 + .../state/__snapshots__/index.test.ts.snap | 43 + .../app/src/domain/state/actions-settings.ts | 61 + packages/app/src/domain/state/compliance.ts | 50 + packages/app/src/domain/state/dialogs.ts | 40 + packages/app/src/domain/state/index.test.ts | 77 + packages/app/src/domain/state/index.ts | 76 + packages/app/src/domain/state/sandbox.ts | 76 + .../src/domain/types/CheckedAddress.test.ts | 18 + .../app/src/domain/types/CheckedAddress.ts | 14 + packages/app/src/domain/types/EnsName.ts | 6 + .../src/domain/types/NumericValues.test.ts | 58 + .../app/src/domain/types/NumericValues.ts | 40 + packages/app/src/domain/types/Token.test.ts | 258 + packages/app/src/domain/types/Token.ts | 177 + .../app/src/domain/types/TokenSymbol.test.ts | 18 + packages/app/src/domain/types/TokenSymbol.ts | 13 + packages/app/src/domain/types/types.ts | 9 + packages/app/src/domain/wallet/balances.ts | 62 + .../src/domain/wallet/createMockConnector.ts | 20 + .../app/src/domain/wallet/useAutoConnect.ts | 17 + .../src/domain/wallet/useConnectedAddress.ts | 22 + .../app/src/domain/wallet/useWalletInfo.ts | 80 + .../actions/ActionsContainer.PageObject.ts | 118 + .../src/features/actions/ActionsContainer.tsx | 56 + .../components/action-row/ActionRow.tsx | 185 + .../components/action-row/UpDownMarker.tsx | 23 + .../actions/components/action-row/types.ts | 10 + .../actions/components/action-row/utils.ts | 13 + .../components/actions-grid/ActionsGrid.tsx | 58 + .../stories/AllActions.stories.ts | 143 + .../stories/AllHandlerStates.stories.ts | 419 + .../stories/EasyBorrowFlow.stories.ts | 109 + .../actions-grid/stories/allActionHandlers.ts | 170 + .../settings-dialog/ActionSettings.stories.ts | 19 + .../settings-dialog/ActionSettings.tsx | 26 + .../SettingsDialogContent.stories.ts | 18 + .../settings-dialog/SettingsDialogContent.tsx | 33 + .../skeleton/ActionsSkeleton.stories.tsx | 15 + .../components/skeleton/ActionsSkeleton.tsx | 22 + .../ApproveDelegationActionRow.tsx | 46 + .../flavours/approve-delegation/types.ts | 11 + .../useCreateApproveDelegationHandler.ts | 66 + .../ApproveExchangeActionRow.tsx | 47 + .../flavours/approve-exchange/types.ts | 9 + .../useCreateApproveExchangeHandler.ts | 64 + .../flavours/approve/ApproveActionRow.tsx | 39 + .../approve/logic/getSignPermitDataConfig.ts | 57 + .../actions/flavours/approve/logic/queries.ts | 46 + .../approve/logic/useCreateApproveHandler.ts | 71 + .../logic/useCreateApproveOrPermitHandler.ts | 34 + .../approve/logic/useCreatePermitHandler.ts | 138 + .../actions/flavours/approve/types.ts | 12 + .../flavours/borrow/BorrowActionRow.tsx | 39 + .../features/actions/flavours/borrow/types.ts | 16 + .../flavours/borrow/useCreateBorrowHandler.ts | 28 + .../flavours/deposit/DepositActionRow.tsx | 39 + .../actions/flavours/deposit/types.ts | 16 + .../deposit/useCreateDepositHandler.ts | 31 + .../flavours/exchange/ExchangeActionRow.tsx | 111 + .../actions/flavours/exchange/types.ts | 19 + .../exchange/useCreateExchangeHandler.ts | 57 + .../actions/flavours/repay/RepayActionRow.tsx | 40 + .../features/actions/flavours/repay/types.ts | 19 + .../flavours/repay/useCreateRepayHandler.ts | 32 + .../SetUseAsCollateralActionRow.tsx | 43 + .../flavours/set-use-as-collateral/types.ts | 13 + .../useCreateSetUseAsCollateralHandler.ts | 28 + .../set-user-e-mode/SetUserEModeActionRow.tsx | 40 + .../actions/flavours/set-user-e-mode/types.ts | 9 + .../useCreateSetUserEModeHandler.ts | 27 + .../flavours/withdraw/WithdrawActionRow.tsx | 39 + .../actions/flavours/withdraw/types.ts | 16 + .../withdraw/useCreateWithdrawHandler.ts | 28 + .../app/src/features/actions/logic/permits.ts | 42 + .../actions/logic/simplifyQueryResult.ts | 26 + .../actions/logic/stringifyObjectives.ts | 22 + .../app/src/features/actions/logic/types.ts | 52 + .../actions/logic/useActionHandlers.ts | 112 + .../actions/logic/useCreateActions.ts | 163 + .../src/features/actions/logic/useGasPrice.ts | 13 + .../app/src/features/actions/logic/utils.ts | 37 + .../actions/utils/formatGasPrice.test.ts | 20 + .../features/actions/utils/formatGasPrice.ts | 12 + .../actions/views/ActionsView.stories.ts | 88 + .../features/actions/views/ActionsView.tsx | 83 + .../compliance/ComplianceContainer.tsx | 28 + .../components/AddressBlocked.stories.ts | 24 + .../compliance/components/AddressBlocked.tsx | 25 + .../features/compliance/components/Banner.tsx | 28 + .../components/PageNotAvailable.tsx | 17 + .../components/RegionBlocked.stories.ts | 18 + .../compliance/components/RegionBlocked.tsx | 17 + .../components/TermsOfService.stories.ts | 18 + .../compliance/components/TermsOfService.tsx | 93 + .../components/VPNBlocked.stories.ts | 18 + .../compliance/components/VPNBlocked.tsx | 12 + .../src/features/compliance/logic/consts.ts | 34 + .../compliance/logic/useBlockedPages.ts | 21 + .../compliance/logic/useCompliance.ts | 85 + .../compliance/logic/useIPAndAddressCheck.ts | 51 + .../logic/useIsCurrentPageBlocked.ts | 18 + .../features/dashboard/DashboardContainer.tsx | 35 + .../borrow-table/BorrowTable.stories.tsx | 77 + .../components/borrow-table/BorrowTable.tsx | 109 + .../components/EModeSwitch.stories.ts | 37 + .../borrow-table/components/EModeSwitch.tsx | 24 + .../CreatePositionPanel.stories.tsx | 24 + .../CreatePositionPanel.tsx | 29 + .../deposit-table/DepositTable.stories.tsx | 81 + .../components/deposit-table/DepositTable.tsx | 118 + .../components/position/Position.stories.tsx | 285 + .../components/position/Position.tsx | 207 + .../skeleton/DasboardSkeleton.stories.ts | 23 + .../components/skeleton/DashboardSkeleton.tsx | 17 + .../wallet-composition/AssetTable.tsx | 75 + .../WalletComposition.stories.tsx | 118 + .../wallet-composition/WalletComposition.tsx | 66 + .../logic/calculate-distribution.ts | 16 + .../src/features/dashboard/logic/assets.ts | 143 + .../dashboard/logic/makeLiquidationDetails.ts | 25 + .../src/features/dashboard/logic/position.ts | 120 + .../app/src/features/dashboard/logic/types.ts | 20 + .../features/dashboard/logic/useDashboard.ts | 61 + .../dashboard/logic/wallet-composition.ts | 86 + .../dashboard/views/GuestView.stories.ts | 27 + .../features/dashboard/views/GuestView.tsx | 23 + .../dashboard/views/PositionView.stories.ts | 153 + .../features/dashboard/views/PositionView.tsx | 52 + .../dialogs/borrow/BorrowDialog.test-e2e.ts | 339 + .../features/dialogs/borrow/BorrowDialog.tsx | 19 + .../borrow/BorrowDialogContentContainer.tsx | 49 + .../borrow/components/BorrowOverviewPanel.tsx | 23 + .../features/dialogs/borrow/logic/assets.ts | 65 + .../borrow/logic/createBorrowObjectives.ts | 17 + .../src/features/dialogs/borrow/logic/form.ts | 69 + .../dialogs/borrow/logic/useBorrowDialog.ts | 87 + .../borrow/views/BorrowView.stories.tsx | 84 + .../dialogs/borrow/views/BorrowView.tsx | 53 + .../collateral/CollateralDialog.PageObject.ts | 36 + .../collateral/CollateralDialog.test-e2e.ts | 321 + .../dialogs/collateral/CollateralDialog.tsx | 22 + .../CollateralDialogContentContainer.tsx | 46 + .../collateral/components/CollateralAlert.tsx | 29 + .../components/CollateralOverviewPanel.tsx | 34 + .../logic/createCollateralObjectives.ts | 12 + .../collateral/logic/getUpdatedUserSummary.ts | 35 + .../collateral/logic/useCollateralDialog.ts | 75 + .../src/features/dialogs/collateral/types.ts | 1 + .../views/CollateralView.stories.ts | 77 + .../collateral/views/CollateralView.tsx | 53 + .../collateral/views/SuccessView.stories.ts | 25 + .../dialogs/collateral/views/SuccessView.tsx | 34 + .../dialogs/common/Dialog.PageObject.ts | 107 + .../common/components/DialogActionsPanel.tsx | 5 + .../dialogs/common/components/DialogPanel.tsx | 12 + .../common/components/DialogPanelTitle.tsx | 15 + .../components/FormAndOverviewWrapper.tsx | 9 + .../components/HealthFactorChange.stories.ts | 76 + .../common/components/HealthFactorChange.tsx | 40 + .../common/components/MultiPanelDialog.tsx | 9 + .../components/RiskIndicator.stories.ts | 47 + .../common/components/RiskIndicator.tsx | 40 + .../TransactionOverviewDetailsItem.tsx | 12 + .../common/components/alert/Alert.stories.ts | 36 + .../dialogs/common/components/alert/Alert.tsx | 44 + .../common/components/form/DialogForm.tsx | 40 + .../DialogContentSkeleton.stories.tsx | 23 + .../skeletons/DialogContentSkeleton.tsx | 11 + .../success-view/SuccessViewCheckmark.tsx | 10 + .../success-view/SuccessViewContent.tsx | 9 + .../success-view/SuccessViewProceedButton.tsx | 16 + .../success-view/SuccessViewSummaryPanel.tsx | 22 + .../src/features/dialogs/common/logic/form.ts | 61 + .../features/dialogs/common/logic/title.ts | 11 + .../common/logic/useUpdateFormMaxValue.ts | 30 + .../app/src/features/dialogs/common/types.ts | 33 + .../common/views/SuccessView.stories.ts | 57 + .../dialogs/common/views/SuccessView.tsx | 39 + .../dialogs/deposit/DepositDialog.test-e2e.ts | 466 + .../dialogs/deposit/DepositDialog.tsx | 14 + .../deposit/DepositDialogContentContainer.tsx | 49 + .../components/DepositOverviewPanel.tsx | 30 + .../features/dialogs/deposit/logic/assets.ts | 65 + .../deposit/logic/collateralization.ts | 72 + .../features/dialogs/deposit/logic/form.ts | 86 + .../features/dialogs/deposit/logic/types.ts | 11 + .../deposit/logic/useCreateObjectives.ts | 17 + .../dialogs/deposit/logic/useDepositDialog.ts | 107 + .../deposit/views/DepositView.stories.tsx | 89 + .../dialogs/deposit/views/DepositView.tsx | 57 + .../dispatcher/DialogDispatcherContainer.tsx | 18 + .../features/dialogs/e-mode/EModeDialog.tsx | 19 + .../e-mode/EModeDialogContentContainer.tsx | 43 + .../e-mode/components/AvailableAssets.tsx | 26 + .../e-mode/components/CategoriesGrid.tsx | 14 + .../components/EModeCategoryTile.stories.ts | 105 + .../e-mode/components/EModeCategoryTile.tsx | 58 + .../e-mode/components/EModeOverviewPanel.tsx | 30 + .../dialogs/e-mode/components/LTVChange.tsx | 30 + .../e-mode/logic/createEModeObjectives.ts | 17 + .../e-mode/logic/getEModeCategories.ts | 35 + .../logic/getUpdatedPositionOverview.ts | 39 + .../dialogs/e-mode/logic/useEModeDialog.ts | 83 + .../app/src/features/dialogs/e-mode/types.ts | 18 + .../dialogs/e-mode/views/EModeView.stories.ts | 111 + .../dialogs/e-mode/views/EModeView.tsx | 74 + .../e-mode/views/SuccessView.stories.ts | 28 + .../dialogs/e-mode/views/SuccessView.tsx | 23 + .../dialogs/repay/RepayDialog.test-e2e.ts | 557 + .../features/dialogs/repay/RepayDialog.tsx | 19 + .../repay/RepayDialogContentContainer.tsx | 47 + .../repay/components/RepayOverviewPanel.tsx | 50 + .../features/dialogs/repay/logic/assets.ts | 81 + .../src/features/dialogs/repay/logic/form.ts | 67 + .../repay/logic/getRepayInFullOptions.ts | 41 + .../dialogs/repay/logic/positionOverview.ts | 20 + .../src/features/dialogs/repay/logic/types.ts | 8 + .../repay/logic/useCreateRepayObjectives.ts | 38 + .../dialogs/repay/logic/useRepayDialog.ts | 108 + .../dialogs/repay/views/RepayView.stories.tsx | 78 + .../dialogs/repay/views/RepayView.tsx | 58 + .../dialogs/sandbox/SandboxDialog.tsx | 19 + .../sandbox/SandboxDialogContentContainer.tsx | 23 + .../dialogs/sandbox/logic/createSandbox.ts | 43 + .../sandbox/logic/createSandboxConnector.ts | 39 + .../dialogs/sandbox/logic/useSandboxDialog.ts | 142 + .../app/src/features/dialogs/sandbox/types.ts | 1 + .../views/SandboxDialogView.stories.ts | 59 + .../sandbox/views/SandboxDialogView.tsx | 61 + .../components/DepositOverviewPanel.tsx | 37 + .../savings/common/components/TokenValue.tsx | 26 + ...TransactionOverviewBalanceChangeDetail.tsx | 27 + .../TransactionOverviewExchangeRateDetail.tsx | 28 + .../SavingsDepositDialog.PageObject.ts | 18 + .../deposit/SavingsDepositDialog.test-e2e.ts | 79 + .../savings/deposit/SavingsDepositDialog.tsx | 25 + .../SavingsDepositDialogContentContainer.tsx | 50 + .../dialogs/savings/deposit/logic/form.ts | 34 + .../savings/deposit/logic/objectives.ts | 39 + .../deposit/logic/useSavingsDepositDialog.ts | 89 + .../dialogs/savings/deposit/logic/useSwap.ts | 39 + .../deposit/logic/useTransactionOverview.ts | 66 + .../savings/deposit/logic/validation.ts | 49 + .../views/SavingsDepositView.stories.tsx | 97 + .../deposit/views/SavingsDepositView.tsx | 54 + .../savings/utils/formatWithHighPrecision.ts | 10 + .../SavingsWithdrawDialog.PageObject.ts | 18 + .../SavingsWithdrawDialog.test-e2e.ts | 144 + .../withdraw/SavingsWithdrawDialog.tsx | 14 + .../SavingsWithdrawDialogContentContainer.tsx | 43 + .../dialogs/savings/withdraw/logic/form.ts | 43 + .../withdraw/logic/getSDaiWithBalance.ts | 14 + .../savings/withdraw/logic/objectives.ts | 17 + .../logic/useSavingsWithdrawDialog.ts | 90 + .../dialogs/savings/withdraw/logic/useSwap.ts | 45 + .../withdraw/logic/useTransactionOverview.ts | 70 + .../savings/withdraw/logic/validation.ts | 52 + .../views/SavingsWithdrawView.stories.tsx | 87 + .../withdraw/views/SavingsWithdrawView.tsx | 60 + .../withdraw/WithdrawDialog.test-e2e.ts | 411 + .../dialogs/withdraw/WithdrawDialog.tsx | 19 + .../WithdrawDialogContentContainer.tsx | 47 + .../components/WithdrawOverviewPanel.tsx | 59 + .../features/dialogs/withdraw/logic/assets.ts | 71 + .../features/dialogs/withdraw/logic/form.ts | 79 + .../logic/getWithdrawInFullOptions.ts | 34 + .../dialogs/withdraw/logic/objectives.ts | 21 + .../features/dialogs/withdraw/logic/types.ts | 9 + .../withdraw/logic/useWithdrawDialog.ts | 110 + .../withdraw/views/WithdrawView.stories.tsx | 93 + .../dialogs/withdraw/views/WithdrawView.tsx | 57 + .../easy-borrow/EasyBorrowContainer.tsx | 63 + .../components/BorrowRateBanner.tsx | 24 + .../components/EasyBorrowPanel.tsx | 82 + .../easy-borrow/components/form/Borrow.tsx | 54 + .../easy-borrow/components/form/Deposits.tsx | 52 + .../components/form/EasyBorrowForm.tsx | 108 + .../components/form/LoanToValue.tsx | 29 + .../form/LoanToValueSlider.stories.tsx | 33 + .../components/form/LoanToValueSlider.tsx | 149 + .../components/form/TokenSummary.stories.tsx | 35 + .../components/form/TokenSummary.tsx | 29 + .../components/note/BorrowRate.tsx | 25 + .../components/note/EasyBorrowNote.tsx | 15 + .../components/note/EasyBorrowSidePanel.tsx | 17 + .../skeleton/EasyBorrowSkeleton.stories.ts | 23 + .../skeleton/EasyBorrowSkeleton.tsx | 43 + .../src/features/easy-borrow/logic/assets.ts | 33 + .../features/easy-borrow/logic/form/form.ts | 161 + .../easy-borrow/logic/form/normalization.ts | 24 + .../easy-borrow/logic/form/validation.ts | 120 + .../src/features/easy-borrow/logic/types.ts | 21 + .../easy-borrow/logic/useCreateObjectives.ts | 36 + .../easy-borrow/logic/useEasyBorrow.ts | 212 + .../views/EasyBorrowView.stories.tsx | 413 + .../easy-borrow/views/EasyBorrowView.tsx | 52 + .../easy-borrow/views/SuccessView.stories.ts | 64 + .../easy-borrow/views/SuccessView.tsx | 85 + .../src/features/errors/ErrorContainer.tsx | 54 + .../src/features/errors/NotFound.stories.ts | 18 + packages/app/src/features/errors/NotFound.tsx | 15 + .../market-details/MarketDetailsContainer.tsx | 19 + .../components/charts/colors.ts | 9 + .../components/charts/defaults.ts | 2 + .../InterestYieldChart.stories.ts | 49 + .../interest-yield/InterestYieldChart.tsx | 70 + .../interest-yield/components/Chart.tsx | 257 + .../charts/interest-yield/logic/getYields.ts | 60 + .../components/charts/interest-yield/types.ts | 4 + .../MarketOverviewChart.stories.tsx | 79 + .../market-overview/MarketOverviewChart.tsx | 17 + .../charts/market-overview/colors.ts | 5 + .../market-overview/components/Legend.tsx | 22 + .../DaiMarketOverview.stories.ts | 33 + .../market-overview/DaiMarketOverview.tsx | 67 + .../DefaultMarketOverview.stories.ts | 56 + .../market-overview/DefaultMarketOverview.tsx | 48 + .../market-overview/MarketOverview.tsx | 32 + .../components/DetailsGrid.tsx | 8 + .../components/DetailsGridItem.tsx | 38 + .../components/MarketOvierviewContent.tsx | 9 + .../components/my-wallet/MyWallet.stories.tsx | 178 + .../components/my-wallet/MyWallet.tsx | 61 + .../my-wallet/MyWalletChainMismatch.tsx | 16 + .../MyWalletDisconnected.stories.tsx | 24 + .../my-wallet/MyWalletDisconnected.tsx | 22 + .../components/my-wallet/MyWalletPanel.tsx | 24 + .../my-wallet/components/ActionDetails.tsx | 20 + .../my-wallet/components/ActionRow.tsx | 24 + .../my-wallet/components/BorrowRow.tsx | 30 + .../my-wallet/components/TokenBalance.tsx | 22 + .../components/WalletPanelContent.tsx | 9 + .../skeleton/MarketDetailsSkeleton.stories.ts | 16 + .../skeleton/MarketDetailsSkeleton.tsx | 25 + .../status-panel/BorrowStatusPanel.stories.ts | 107 + .../status-panel/BorrowStatusPanel.tsx | 84 + .../CollateralStatusPanel.stories.ts | 105 + .../status-panel/CollateralStatusPanel.tsx | 97 + .../status-panel/EModeStatusPanel.stories.ts | 54 + .../status-panel/EModeStatusPanel.tsx | 108 + .../status-panel/LendStatusPanel.stories.ts | 37 + .../status-panel/LendStatusPanel.tsx | 43 + .../status-panel/SupplyStatusPanel.stories.ts | 54 + .../status-panel/SupplyStatusPanel.tsx | 62 + .../components/EmptyStatusPanel.tsx | 22 + .../status-panel/components/Header.tsx | 68 + .../components/StatusPanelGrid.tsx | 13 + .../status-panel/components/Subheader.tsx | 80 + .../emode-badge/EModeBadge.stories.ts | 24 + .../components/emode-badge/EModeBadge.tsx | 34 + .../components/info-tile/InfoTile.stories.tsx | 40 + .../components/info-tile/InfoTile.tsx | 41 + .../components/info-tile/InfoTilesGrid.tsx | 19 + .../status-icon/StatusIcon.stories.tsx | 31 + .../components/status-icon/StatusIcon.tsx | 17 + .../token-badge/TokenBadge.stories.ts | 59 + .../components/token-badge/TokenBadge.tsx | 35 + .../logic/getReserveEModeCategoryTokens.ts | 11 + .../logic/makeDaiMarketOverview.ts | 70 + .../logic/makeMarketOverview.ts | 65 + .../logic/makeWalletOverview.ts | 156 + .../market-details/logic/useMarketDetails.ts | 63 + .../logic/useMarketDetailsParams.ts | 21 + .../app/src/features/market-details/types.ts | 94 + .../views/MarketDetailsView.stories.ts | 85 + .../views/MarketDetailsView.tsx | 15 + .../views/components/BackNav.tsx | 27 + .../views/components/CompactView.tsx | 46 + .../views/components/FullView.tsx | 40 + .../views/components/Header.tsx | 15 + .../features/market-details/views/types.ts | 14 + .../src/features/markets/MarketsContainer.tsx | 22 + .../airdrop-badge/AirdropBadge.stories.tsx | 30 + .../components/airdrop-badge/AirdropBadge.tsx | 42 + .../AssetStatusBadge.stories.tsx | 80 + .../asset-status-badge/AssetStatusBadge.tsx | 77 + .../components/AssetStatusDescription.tsx | 15 + .../getVariantFromStatus.test.ts | 26 + .../getVariantFromStatus.ts | 16 + .../DebtCeilingProgress.stories.ts | 29 + .../DebtCeilingProgress.tsx | 38 + .../markets-table/MarketsTable.stories.tsx | 123 + .../components/markets-table/MarketsTable.tsx | 127 + .../components/ApyWithRewardsCell.stories.tsx | 63 + .../components/ApyWithRewardsCell.tsx | 85 + .../components/AssetNameCell.stories.tsx | 30 + .../components/AssetNameCell.tsx | 63 + .../reward-badge/RewardBadge.stories.tsx | 38 + .../components/reward-badge/RewardBadge.tsx | 41 + .../skeleton/MarketsSkeleton.stories.ts | 17 + .../components/skeleton/MarketsSkeleton.tsx | 22 + .../summary-tile/SummaryTile.stories.tsx | 50 + .../components/summary-tile/SummaryTile.tsx | 32 + .../summary-tile/components/Tile.stories.tsx | 30 + .../summary-tile/components/Tile.tsx | 42 + .../summary-tiles/SummaryTiles.stories.tsx | 29 + .../components/summary-tiles/SummaryTiles.tsx | 19 + .../token-pill/TokenPill.stories.tsx | 27 + .../components/token-pill/TokenPill.tsx | 13 + .../features/markets/logic/aggregate-stats.ts | 41 + .../features/markets/logic/transformers.ts | 107 + .../src/features/markets/logic/useMarkets.ts | 33 + packages/app/src/features/markets/types.ts | 32 + .../markets/views/MarketsView.stories.ts | 125 + .../features/markets/views/MarketsView.tsx | 54 + packages/app/src/features/navbar/Navbar.tsx | 74 + .../navbar/components/MobileMenuButton.tsx | 23 + .../navbar/components/NavbarActionWrapper.tsx | 13 + .../navbar/components/NavbarActions.tsx | 55 + .../features/navbar/components/PageLinks.tsx | 51 + .../components/nav-link/NavLink.stories.tsx | 55 + .../navbar/components/nav-link/NavLink.tsx | 50 + .../NetworkSelector.stories.ts | 39 + .../network-selector/NetworkSelector.tsx | 64 + .../settings-dropdown/BuildInfoItem.tsx | 14 + .../SettingsDropDown.stories.ts | 34 + .../settings-dropdown/SettingsDropdown.tsx | 89 + .../SettingsDropdownItem.tsx | 58 + .../wallet-dropdown/WalletDropdown.stories.ts | 110 + .../wallet-dropdown/WalletDropdown.tsx | 44 + .../components/ConnectButton.tsx | 13 + .../components/ConnectedButton.stories.tsx | 47 + .../components/ConnectedButton.tsx | 54 + .../components/WalletButton.tsx | 18 + .../WalletDropdownContent.stories.tsx | 46 + .../components/WalletDropdownContent.tsx | 57 + .../navbar/logic/generateWalletAvatar.ts | 33 + .../features/navbar/logic/getWalletIcon.ts | 11 + .../features/navbar/logic/useDisconnect.ts | 48 + .../src/features/navbar/logic/useNavbar.ts | 126 + .../features/navbar/logic/useNetworkChange.ts | 110 + .../features/navbar/logic/useTotalBalance.ts | 54 + packages/app/src/features/navbar/types.ts | 42 + .../src/features/savings/SavingsContainer.tsx | 57 + .../savings/components/PageHeader.tsx | 3 + .../savings/components/PageLayout.tsx | 13 + .../cash-in-wallet/CashInWallet.stories.ts | 45 + .../cash-in-wallet/CashInWallet.tsx | 76 + .../navbar-item/DSRBadge.stories.ts | 32 + .../components/navbar-item/DSRBadge.tsx | 21 + .../savings-dai/SavingsDAI.stories.ts | 40 + .../components/savings-dai/SavingsDAI.tsx | 106 + .../SavingsInfoTile.stories.tsx | 163 + .../savings-info-tile/SavingsInfoTile.tsx | 72 + .../SavingdOpportunityNoCash.stories.ts | 23 + .../SavingsOpportunity.stories.ts | 105 + .../SavingsOpportunity.tsx | 100 + .../SavingsOpportunityGuestMode.stories.ts | 24 + .../SavingsOpportunityGuestMode.tsx | 34 + .../SavingsOpportunityNoCash.tsx | 27 + .../components/DSRLabel.tsx | 29 + .../components/Explainer.tsx | 26 + .../skeleton/SavingsSkeleton.stories.ts | 16 + .../components/skeleton/SavingsSkeleton.tsx | 16 + .../savings/logic/makeSavingsOverview.ts | 114 + .../savings/logic/projections.test.ts | 90 + .../src/features/savings/logic/projections.ts | 50 + .../src/features/savings/logic/useSavings.ts | 87 + packages/app/src/features/savings/types.ts | 6 + .../savings/views/GuestView.stories.ts | 27 + .../src/features/savings/views/GuestView.tsx | 30 + .../savings/views/SavingsView.stories.ts | 174 + .../features/savings/views/SavingsView.tsx | 82 + .../views/UnsupportedChainView.stories.ts | 34 + .../savings/views/UnsupportedChainView.tsx | 28 + packages/app/src/fonts/InterVariable.woff2 | Bin 0 -> 345588 bytes packages/app/src/global.d.ts | 3 + packages/app/src/locales/en.po | 44 + packages/app/src/main.tsx | 13 + packages/app/src/pages/Borrow.PageObject.ts | 112 + packages/app/src/pages/Borrow.test-e2e.ts | 488 + packages/app/src/pages/Borrow.tsx | 5 + .../app/src/pages/Dashboard.PageObject.ts | 220 + packages/app/src/pages/Dashboard.test-e2e.ts | 96 + packages/app/src/pages/Dashboard.tsx | 5 + .../app/src/pages/MarketDetails.PageObject.ts | 60 + .../app/src/pages/MarketDetails.test-e2e.ts | 197 + packages/app/src/pages/MarketDetails.tsx | 5 + packages/app/src/pages/Markets.tsx | 5 + packages/app/src/pages/Root.tsx | 19 + packages/app/src/pages/Savings.PageObject.ts | 77 + packages/app/src/pages/Savings.test-e2e.ts | 76 + packages/app/src/pages/Savings.tsx | 5 + packages/app/src/reset.d.ts | 1 + packages/app/src/test/e2e/BasePageObject.ts | 69 + .../app/src/test/e2e/TestTenderlyClient.ts | 47 + packages/app/src/test/e2e/assertions.ts | 22 + packages/app/src/test/e2e/constants.ts | 54 + packages/app/src/test/e2e/injectSetup.ts | 64 + packages/app/src/test/e2e/lifi.ts | 1037 + packages/app/src/test/e2e/processEnv.ts | 13 + packages/app/src/test/e2e/setup.ts | 93 + packages/app/src/test/e2e/setupFork.ts | 70 + packages/app/src/test/e2e/utils.ts | 154 + .../src/test/integration/TestingWrapper.tsx | 39 + .../app/src/test/integration/constants.ts | 356 + packages/app/src/test/integration/expect.ts | 9 + .../integration/mockTransport/handlers.ts | 238 + .../test/integration/mockTransport/index.ts | 36 + .../test/integration/mockTransport/types.ts | 4 + .../test/integration/mockTransport/utils.ts | 66 + .../integration/mocks/ResizeObserverMock.ts | 12 + .../test/integration/mocks/install-mocks.ts | 1 + .../app/src/test/integration/object-utils.ts | 22 + .../app/src/test/integration/query-client.ts | 10 + .../app/src/test/integration/renderError.tsx | 12 + packages/app/src/test/integration/setup.ts | 37 + .../test/integration/setupHookRenderer.tsx | 41 + packages/app/src/test/integration/trigger.ts | 23 + .../app/src/test/integration/wagmi-config.ts | 64 + .../app/src/ui/assets/actions/approve.svg | 4 + packages/app/src/ui/assets/actions/borrow.svg | 3 + .../app/src/ui/assets/actions/deposit.svg | 3 + packages/app/src/ui/assets/actions/done.svg | 3 + .../app/src/ui/assets/actions/exchange.svg | 3 + packages/app/src/ui/assets/actions/repay.svg | 3 + .../app/src/ui/assets/actions/withdraw.svg | 3 + packages/app/src/ui/assets/arrow-right.svg | 3 + .../app/src/ui/assets/box-arrow-top-right.svg | 4 + .../app/src/ui/assets/chains/ethereum.svg | 9 + packages/app/src/ui/assets/chains/gnosis.svg | 5 + packages/app/src/ui/assets/check-circle.svg | 3 + packages/app/src/ui/assets/chevron-down.svg | 3 + packages/app/src/ui/assets/circle-info.svg | 3 + packages/app/src/ui/assets/close.svg | 3 + packages/app/src/ui/assets/down.svg | 3 + packages/app/src/ui/assets/eye.svg | 4 + packages/app/src/ui/assets/flash.svg | 3 + packages/app/src/ui/assets/green-arrow-up.svg | 3 + packages/app/src/ui/assets/index.ts | 162 + packages/app/src/ui/assets/lifi-logo.svg | 11 + packages/app/src/ui/assets/link.svg | 6 + packages/app/src/ui/assets/magic-wand.svg | 6 + packages/app/src/ui/assets/markets/chart.svg | 3 + .../src/ui/assets/markets/input-output.svg | 3 + packages/app/src/ui/assets/markets/lock.svg | 4 + packages/app/src/ui/assets/markets/output.svg | 3 + packages/app/src/ui/assets/menu.svg | 3 + packages/app/src/ui/assets/more-icon.svg | 5 + packages/app/src/ui/assets/pause.svg | 1 + packages/app/src/ui/assets/slider-thumb.svg | 24 + packages/app/src/ui/assets/snowflake.svg | 3 + packages/app/src/ui/assets/spark-icon.svg | 29 + packages/app/src/ui/assets/spark-logo.svg | 38 + packages/app/src/ui/assets/success.svg | 3 + packages/app/src/ui/assets/three-dots.svg | 33 + packages/app/src/ui/assets/tokens/dai.svg | 1 + packages/app/src/ui/assets/tokens/eth.svg | 9 + packages/app/src/ui/assets/tokens/eure.svg | 1 + packages/app/src/ui/assets/tokens/gno.svg | 1 + packages/app/src/ui/assets/tokens/mkr.svg | 1 + packages/app/src/ui/assets/tokens/reth.svg | 1 + packages/app/src/ui/assets/tokens/sdai.svg | 1 + packages/app/src/ui/assets/tokens/steth.svg | 1 + packages/app/src/ui/assets/tokens/unknown.svg | 5 + packages/app/src/ui/assets/tokens/usdc.svg | 1 + packages/app/src/ui/assets/tokens/usdt.svg | 1 + packages/app/src/ui/assets/tokens/wbtc.svg | 1 + packages/app/src/ui/assets/tokens/weth.svg | 1 + packages/app/src/ui/assets/tokens/wsteth.svg | 1 + packages/app/src/ui/assets/tokens/wxdai.svg | 1 + packages/app/src/ui/assets/tokens/xdai.svg | 1 + packages/app/src/ui/assets/up.svg | 3 + .../src/ui/assets/wallet-icons/coinbase.svg | 13 + .../src/ui/assets/wallet-icons/default.svg | 1 + .../app/src/ui/assets/wallet-icons/enjin.svg | 12 + .../src/ui/assets/wallet-icons/metamask.svg | 32 + .../app/src/ui/assets/wallet-icons/torus.svg | 13 + .../ui/assets/wallet-icons/wallet-connect.svg | 4 + packages/app/src/ui/assets/wallet.svg | 6 + packages/app/src/ui/assets/warning.svg | 4 + packages/app/src/ui/assets/x-circle.svg | 4 + .../ui/atoms/accordion/Accordion.stories.tsx | 60 + .../app/src/ui/atoms/accordion/Accordion.tsx | 51 + .../src/ui/atoms/button/Button.stories.tsx | 252 + packages/app/src/ui/atoms/button/Button.tsx | 86 + .../ui/atoms/button/LinkButton.stories.tsx | 208 + .../ui/atoms/checkbox/Checkbox.stories.tsx | 20 + .../app/src/ui/atoms/checkbox/Checkbox.tsx | 28 + .../color-filter/ColorFilter.stories.tsx | 50 + .../src/ui/atoms/color-filter/ColorFilter.tsx | 20 + .../src/ui/atoms/dialog/Dialog.stories.tsx | 61 + packages/app/src/ui/atoms/dialog/Dialog.tsx | 102 + .../doughnut-chart/DoughnutChart.stories.tsx | 27 + .../ui/atoms/doughnut-chart/DoughnutChart.tsx | 80 + .../atoms/dropdown/DropdownMenu.stories.tsx | 40 + .../src/ui/atoms/dropdown/DropdownMenu.tsx | 179 + packages/app/src/ui/atoms/form/Form.tsx | 144 + .../HealthFactorBadge.stories.tsx | 58 + .../health-factor-badge/HealthFactorBadge.tsx | 39 + .../HealthFactorGauge.stories.tsx | 56 + .../health-factor-gauge/HealthFactorGauge.tsx | 248 + .../ui/atoms/icon-pill/IconPill.stories.tsx | 20 + .../app/src/ui/atoms/icon-pill/IconPill.tsx | 14 + .../indicator-icon/IndicatorIcon.stories.tsx | 39 + .../ui/atoms/indicator-icon/IndicatorIcon.tsx | 24 + packages/app/src/ui/atoms/input/Input.tsx | 22 + packages/app/src/ui/atoms/label/Label.tsx | 15 + .../ui/atoms/link-decorator/LinkDecorator.tsx | 27 + packages/app/src/ui/atoms/link/Link.tsx | 31 + .../ui/atoms/panel/CollapsiblePanel.test.tsx | 59 + .../src/ui/atoms/panel/CollapsiblePanel.tsx | 101 + .../app/src/ui/atoms/panel/Panel.stories.tsx | 118 + .../app/src/ui/atoms/panel/Panel.test.tsx | 51 + packages/app/src/ui/atoms/panel/Panel.tsx | 111 + .../src/ui/atoms/progress/Progress.stories.ts | 37 + .../app/src/ui/atoms/progress/Progress.tsx | 23 + .../atoms/scroll-area/ScrollArea.stories.tsx | 37 + .../src/ui/atoms/scroll-area/ScrollArea.tsx | 36 + .../src/ui/atoms/select/Select.stories.tsx | 36 + packages/app/src/ui/atoms/select/Select.tsx | 100 + .../app/src/ui/atoms/skeleton/Skeleton.tsx | 5 + packages/app/src/ui/atoms/switch/Switch.tsx | 38 + .../src/ui/atoms/switch/Swtich.stories.tsx | 20 + .../app/src/ui/atoms/table/Table.stories.tsx | 85 + packages/app/src/ui/atoms/table/Table.tsx | 66 + .../app/src/ui/atoms/tabs/Tabs.stories.tsx | 31 + packages/app/src/ui/atoms/tabs/Tabs.tsx | 55 + .../ui/atoms/token-icon/TokenIcon.stories.ts | 38 + .../app/src/ui/atoms/token-icon/TokenIcon.tsx | 47 + .../src/ui/atoms/tooltip/Tooltip.stories.tsx | 71 + packages/app/src/ui/atoms/tooltip/Tooltip.tsx | 62 + .../ui/atoms/tooltip/TooltipContentLayout.tsx | 28 + .../ui/atoms/top-banner/TopBanner.stories.ts | 19 + .../app/src/ui/atoms/top-banner/TopBanner.tsx | 16 + .../atoms/typography/Typography.stories.tsx | 74 + .../src/ui/atoms/typography/Typography.tsx | 41 + packages/app/src/ui/constants/links.ts | 18 + packages/app/src/ui/layouts/AppLayout.tsx | 23 + packages/app/src/ui/layouts/ErrorLayout.tsx | 7 + .../app/src/ui/layouts/FallbackLayout.tsx | 13 + packages/app/src/ui/layouts/PageLayout.tsx | 12 + .../action-button/ActionButton.stories.tsx | 27 + .../molecules/action-button/ActionButton.tsx | 20 + .../apy-tooltip/ApyTooltip.stories.ts | 27 + .../ui/molecules/apy-tooltip/ApyTooltip.tsx | 60 + .../asset-input/AssetInput.stories.tsx | 154 + .../ui/molecules/asset-input/AssetInput.tsx | 136 + .../asset-selector/AssetSelector.stories.tsx | 68 + .../asset-selector/AssetSelector.tsx | 81 + .../src/ui/molecules/confetti/Confetti.tsx | 17 + .../data-table/DataTable.stories.tsx | 89 + .../src/ui/molecules/data-table/DataTable.tsx | 136 + .../data-table/components/ActionsCell.tsx | 13 + .../data-table/components/ColumnHeader.tsx | 44 + .../components/CompactValueCell.tsx | 64 + .../data-table/components/PercentageCell.tsx | 23 + .../data-table/components/SwitchCell.tsx | 29 + .../data-table/components/TokenWithLogo.tsx | 31 + .../app/src/ui/molecules/data-table/types.ts | 15 + .../frozen-pill/FrozenPill.stories.tsx | 21 + .../ui/molecules/frozen-pill/FrozenPill.tsx | 28 + .../icon-stack/IconStack.stories.tsx | 53 + .../src/ui/molecules/icon-stack/IconStack.tsx | 70 + .../molecules/info-pill/InfoPill.stories.tsx | 21 + .../src/ui/molecules/info-pill/InfoPill.tsx | 21 + .../src/ui/molecules/info/Info.stories.tsx | 17 + packages/app/src/ui/molecules/info/Info.tsx | 21 + .../labeled-switch/LabeledSwitch.stories.tsx | 33 + .../labeled-switch/LabeledSwitch.tsx | 20 + .../paused-pill/PausedPill.stories.tsx | 20 + .../ui/molecules/paused-pill/PausedPill.tsx | 27 + .../AssetSelectorWithInput.stories.tsx | 60 + .../AssetSelectorWithInput.tsx | 66 + .../HealthFactorPanel.stories.tsx | 55 + .../health-factor-panel/HealthFactorPanel.tsx | 70 + .../components/LiquidationOverview.tsx | 76 + .../multi-selector/MultiSelector.stories.tsx | 96 + .../multi-selector/MultiSelector.tsx | 146 + .../ResponsiveDataTable.stories.tsx | 96 + .../ResponsiveDataTable.tsx | 69 + .../components/CollapsibleCell.tsx | 41 + .../WalletActionPanel.stories.ts | 27 + .../wallet-action-panel/WalletActionPanel.tsx | 29 + packages/app/src/ui/utils/get-random-color.ts | 8 + .../app/src/ui/utils/shortenAddress.test.ts | 15 + packages/app/src/ui/utils/shortenAddress.ts | 13 + packages/app/src/ui/utils/style.ts | 6 + packages/app/src/ui/utils/testIds.ts | 66 + packages/app/src/ui/utils/useBreakpoint.ts | 58 + packages/app/src/ui/utils/useIsTruncated.ts | 24 + packages/app/src/ui/utils/useParentSize.ts | 25 + packages/app/src/ui/utils/useWindowSize.ts | 24 + packages/app/src/ui/utils/withSuspense.tsx | 30 + .../app/src/utils/applyTransformers.test.ts | 28 + packages/app/src/utils/applyTransformers.ts | 21 + packages/app/src/utils/bigNumber.test.ts | 99 + packages/app/src/utils/bigNumber.ts | 50 + packages/app/src/utils/math.ts | 31 + packages/app/src/utils/object.ts | 42 + packages/app/src/utils/promises.ts | 30 + packages/app/src/utils/raise.ts | 6 + packages/app/src/utils/random.ts | 10 + packages/app/src/utils/solidFetch.ts | 17 + packages/app/src/utils/strings.test.ts | 25 + packages/app/src/utils/strings.ts | 12 + packages/app/src/utils/time.ts | 3 + packages/app/src/utils/tryOrDefault.ts | 7 + packages/app/src/utils/types.ts | 25 + packages/app/src/utils/useDebounce.ts | 45 + packages/app/src/utils/usePrevious.ts | 14 + packages/app/src/utils/useTimestamp.test.ts | 54 + packages/app/src/utils/useTimestamp.ts | 39 + packages/app/src/utils/useValidatedParams.ts | 13 + packages/app/src/vite-env.d.ts | 1 + packages/app/tailwind.config.ts | 128 + packages/app/tsconfig.json | 29 + packages/app/tsconfig.node.json | 10 + packages/app/vercel.json | 12 + packages/app/vite.config.ts | 47 + packages/app/wagmi.config.ts | 119 + pnpm-lock.yaml | 20221 ++++++++++++++++ pnpm-workspace.yaml | 2 + 895 files changed, 72827 insertions(+) create mode 100644 .editorconfig create mode 100644 .eslintrc.cjs create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/flaky-e2e-detector.yml create mode 100644 .github/workflows/ipfs-release.yml create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 README.md create mode 100644 docs/bundle-size-optimizing-guide.md create mode 100644 docs/hot-patching-modules.md create mode 100644 package.json create mode 100644 packages/app/.env.development create mode 100644 packages/app/.env.example create mode 100644 packages/app/.env.playwright create mode 100644 packages/app/.env.production create mode 100644 packages/app/.env.staging create mode 100644 packages/app/.env.storybook create mode 100644 packages/app/.gitignore create mode 100644 packages/app/.storybook/DevContainer.tsx create mode 100644 packages/app/.storybook/ErrorBoundary.tsx create mode 100644 packages/app/.storybook/decorators.tsx create mode 100644 packages/app/.storybook/main.ts create mode 100644 packages/app/.storybook/preview.ts create mode 100644 packages/app/.storybook/tokens.ts create mode 100644 packages/app/.storybook/utils.ts create mode 100644 packages/app/.storybook/viewports.ts create mode 100644 packages/app/index.html create mode 100644 packages/app/lingui.config.ts create mode 100644 packages/app/package.json create mode 100644 packages/app/playwright.config.ts create mode 100644 packages/app/postcss.config.js create mode 100644 packages/app/public/_redirects create mode 100644 packages/app/public/apple-touch-icon.png create mode 100644 packages/app/public/favicon-16x16.png create mode 100644 packages/app/public/favicon-32x32.png create mode 100644 packages/app/public/favicon.ico create mode 100644 packages/app/public/spark-meta-logo.jpg create mode 100644 packages/app/src/App.tsx create mode 100644 packages/app/src/RootRouter.tsx create mode 100644 packages/app/src/config/abis/debtTokenAbi.ts create mode 100644 packages/app/src/config/abis/poolAbi.ts create mode 100644 packages/app/src/config/chain/constants.ts create mode 100644 packages/app/src/config/chain/index.ts create mode 100644 packages/app/src/config/chain/types.ts create mode 100644 packages/app/src/config/chain/utils/airdrops.test.ts create mode 100644 packages/app/src/config/chain/utils/airdrops.ts create mode 100644 packages/app/src/config/chain/utils/getNativeAssetInfo.ts create mode 100644 packages/app/src/config/consts.ts create mode 100644 packages/app/src/config/contracts-generated.ts create mode 100644 packages/app/src/config/feature-flags/appConfig.default.ts create mode 100644 packages/app/src/config/feature-flags/appConfig.testing.ts create mode 100644 packages/app/src/config/feature-flags/clientEnv.ts create mode 100644 packages/app/src/config/feature-flags/index.ts create mode 100644 packages/app/src/config/paths.ts create mode 100644 packages/app/src/config/query-client.ts create mode 100644 packages/app/src/config/tailwind.ts create mode 100644 packages/app/src/config/wagmi/config.default.ts create mode 100644 packages/app/src/config/wagmi/config.e2e.ts create mode 100644 packages/app/src/config/wagmi/index.ts create mode 100644 packages/app/src/css/fonts.css create mode 100644 packages/app/src/css/main.css create mode 100644 packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.test.ts create mode 100644 packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.test.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getDepositMaxValue.test.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getDepositMaxValue.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getRepayMaxValue.test.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getRepayMaxValue.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.test.ts create mode 100644 packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.ts create mode 100644 packages/app/src/domain/common/assets.ts create mode 100644 packages/app/src/domain/common/format.test.ts create mode 100644 packages/app/src/domain/common/format.ts create mode 100644 packages/app/src/domain/common/risk.ts create mode 100644 packages/app/src/domain/common/sorters.test.ts create mode 100644 packages/app/src/domain/common/sorters.ts create mode 100644 packages/app/src/domain/common/types.ts create mode 100644 packages/app/src/domain/e-mode/constants.ts create mode 100644 packages/app/src/domain/e-mode/types.ts create mode 100644 packages/app/src/domain/errors/not-connected.ts create mode 100644 packages/app/src/domain/errors/not-found.ts create mode 100644 packages/app/src/domain/errors/useViteErrorOverlay.ts create mode 100644 packages/app/src/domain/exchanges/lifi/lifi.test.ts create mode 100644 packages/app/src/domain/exchanges/lifi/lifi.ts create mode 100644 packages/app/src/domain/exchanges/lifi/meta/MockLifiQueryMetaEvaluator.ts create mode 100644 packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.test.ts create mode 100644 packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.ts create mode 100644 packages/app/src/domain/exchanges/lifi/meta/index.ts create mode 100644 packages/app/src/domain/exchanges/lifi/meta/useLifiQueryMetaEvaluator.ts create mode 100644 packages/app/src/domain/exchanges/lifi/query.ts create mode 100644 packages/app/src/domain/exchanges/lifi/types.ts create mode 100644 packages/app/src/domain/exchanges/lifi/useLiFiTxData.test.ts create mode 100644 packages/app/src/domain/exchanges/lifi/useLiFiTxData.ts create mode 100644 packages/app/src/domain/exchanges/lifi/validation.ts create mode 100644 packages/app/src/domain/exchanges/types.ts create mode 100644 packages/app/src/domain/hooks/sanityChecks.test.ts create mode 100644 packages/app/src/domain/hooks/sanityChecks.ts create mode 100644 packages/app/src/domain/hooks/useBlockExplorerAddressLink.ts create mode 100644 packages/app/src/domain/hooks/useConditionalFreeze.ts create mode 100644 packages/app/src/domain/hooks/useContractAddress.ts create mode 100644 packages/app/src/domain/hooks/useOriginChainId.ts create mode 100644 packages/app/src/domain/hooks/useSendTx.test.ts create mode 100644 packages/app/src/domain/hooks/useSendTx.ts create mode 100644 packages/app/src/domain/hooks/useWaitForTransactionReceiptGnosisSafe.ts create mode 100644 packages/app/src/domain/hooks/useWaitForTransactionReceiptUniversal.ts create mode 100644 packages/app/src/domain/hooks/useWalletType.ts create mode 100644 packages/app/src/domain/hooks/useWrite.test.ts create mode 100644 packages/app/src/domain/hooks/useWrite.ts create mode 100644 packages/app/src/domain/i18n/I18nAppProvider.tsx create mode 100644 packages/app/src/domain/i18n/I18nTestProvider.tsx create mode 100644 packages/app/src/domain/i18n/locales.ts create mode 100644 packages/app/src/domain/maker-info/getIsChainSupported.ts create mode 100644 packages/app/src/domain/maker-info/makerInfoQuery.ts create mode 100644 packages/app/src/domain/maker-info/types.ts create mode 100644 packages/app/src/domain/maker-info/useMakerInfo.ts create mode 100644 packages/app/src/domain/market-info/aave-data-layer/query.ts create mode 100644 packages/app/src/domain/market-info/aave-data-layer/useAaveDataLayer.ts create mode 100644 packages/app/src/domain/market-info/emode.ts create mode 100644 packages/app/src/domain/market-info/getLiquidationDetails.test.ts create mode 100644 packages/app/src/domain/market-info/getLiquidationDetails.ts create mode 100644 packages/app/src/domain/market-info/incentives.ts create mode 100644 packages/app/src/domain/market-info/marketInfo.ts create mode 100644 packages/app/src/domain/market-info/math.test.ts create mode 100644 packages/app/src/domain/market-info/math.ts create mode 100644 packages/app/src/domain/market-info/reserve-status.test.ts create mode 100644 packages/app/src/domain/market-info/reserve-status.ts create mode 100644 packages/app/src/domain/market-info/updatePositionSummary.ts create mode 100644 packages/app/src/domain/market-info/useMarketInfo.ts create mode 100644 packages/app/src/domain/market-info/utils.test.ts create mode 100644 packages/app/src/domain/market-info/utils.ts create mode 100644 packages/app/src/domain/market-operations/allowance/query.ts create mode 100644 packages/app/src/domain/market-operations/allowance/useAllowance.test.tsx create mode 100644 packages/app/src/domain/market-operations/allowance/useAllowance.ts create mode 100644 packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.test.tsx create mode 100644 packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.ts create mode 100644 packages/app/src/domain/market-operations/borrow-allowance/query.ts create mode 100644 packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.test.ts create mode 100644 packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.ts create mode 100644 packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.test.ts create mode 100644 packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.ts create mode 100644 packages/app/src/domain/market-operations/normalizeErc20Abi.ts create mode 100644 packages/app/src/domain/market-operations/useApprove.test.ts create mode 100644 packages/app/src/domain/market-operations/useApprove.ts create mode 100644 packages/app/src/domain/market-operations/useApproveDelegation.test.ts create mode 100644 packages/app/src/domain/market-operations/useApproveDelegation.ts create mode 100644 packages/app/src/domain/market-operations/useBorrow.test.ts create mode 100644 packages/app/src/domain/market-operations/useBorrow.ts create mode 100644 packages/app/src/domain/market-operations/useDeposit.test.ts create mode 100644 packages/app/src/domain/market-operations/useDeposit.ts create mode 100644 packages/app/src/domain/market-operations/useExchange.test.ts create mode 100644 packages/app/src/domain/market-operations/useExchange.ts create mode 100644 packages/app/src/domain/market-operations/useRepay.test.ts create mode 100644 packages/app/src/domain/market-operations/useRepay.ts create mode 100644 packages/app/src/domain/market-operations/useSetUseAsCollateral.test.ts create mode 100644 packages/app/src/domain/market-operations/useSetUseAsCollateral.ts create mode 100644 packages/app/src/domain/market-operations/useSetUserEMode.test.ts create mode 100644 packages/app/src/domain/market-operations/useSetUserEMode.ts create mode 100644 packages/app/src/domain/market-operations/useWithdraw.test.ts create mode 100644 packages/app/src/domain/market-operations/useWithdraw.ts create mode 100644 packages/app/src/domain/market-validators/validateBorrow.test.ts create mode 100644 packages/app/src/domain/market-validators/validateBorrow.ts create mode 100644 packages/app/src/domain/market-validators/validateDeposit.test.ts create mode 100644 packages/app/src/domain/market-validators/validateDeposit.ts create mode 100644 packages/app/src/domain/market-validators/validateRepay.test.ts create mode 100644 packages/app/src/domain/market-validators/validateRepay.ts create mode 100644 packages/app/src/domain/market-validators/validateSetUseAsCollateral.test.ts create mode 100644 packages/app/src/domain/market-validators/validateSetUseAsCollateral.ts create mode 100644 packages/app/src/domain/market-validators/validateSetUserEMode.test.ts create mode 100644 packages/app/src/domain/market-validators/validateSetUserEMode.ts create mode 100644 packages/app/src/domain/market-validators/validateWithdraw.test.ts create mode 100644 packages/app/src/domain/market-validators/validateWithdraw.ts create mode 100644 packages/app/src/domain/sandbox/createSandboxConnector.ts create mode 100644 packages/app/src/domain/sandbox/createTenderlyFork.ts create mode 100644 packages/app/src/domain/sandbox/publicTenderlyActions.ts create mode 100644 packages/app/src/domain/sandbox/request.ts create mode 100644 packages/app/src/domain/sandbox/useSandboxState.ts create mode 100644 packages/app/src/domain/savings/makeAssetsInWalletList.ts create mode 100644 packages/app/src/domain/state/__snapshots__/index.test.ts.snap create mode 100644 packages/app/src/domain/state/actions-settings.ts create mode 100644 packages/app/src/domain/state/compliance.ts create mode 100644 packages/app/src/domain/state/dialogs.ts create mode 100644 packages/app/src/domain/state/index.test.ts create mode 100644 packages/app/src/domain/state/index.ts create mode 100644 packages/app/src/domain/state/sandbox.ts create mode 100644 packages/app/src/domain/types/CheckedAddress.test.ts create mode 100644 packages/app/src/domain/types/CheckedAddress.ts create mode 100644 packages/app/src/domain/types/EnsName.ts create mode 100644 packages/app/src/domain/types/NumericValues.test.ts create mode 100644 packages/app/src/domain/types/NumericValues.ts create mode 100644 packages/app/src/domain/types/Token.test.ts create mode 100644 packages/app/src/domain/types/Token.ts create mode 100644 packages/app/src/domain/types/TokenSymbol.test.ts create mode 100644 packages/app/src/domain/types/TokenSymbol.ts create mode 100644 packages/app/src/domain/types/types.ts create mode 100644 packages/app/src/domain/wallet/balances.ts create mode 100644 packages/app/src/domain/wallet/createMockConnector.ts create mode 100644 packages/app/src/domain/wallet/useAutoConnect.ts create mode 100644 packages/app/src/domain/wallet/useConnectedAddress.ts create mode 100644 packages/app/src/domain/wallet/useWalletInfo.ts create mode 100644 packages/app/src/features/actions/ActionsContainer.PageObject.ts create mode 100644 packages/app/src/features/actions/ActionsContainer.tsx create mode 100644 packages/app/src/features/actions/components/action-row/ActionRow.tsx create mode 100644 packages/app/src/features/actions/components/action-row/UpDownMarker.tsx create mode 100644 packages/app/src/features/actions/components/action-row/types.ts create mode 100644 packages/app/src/features/actions/components/action-row/utils.ts create mode 100644 packages/app/src/features/actions/components/actions-grid/ActionsGrid.tsx create mode 100644 packages/app/src/features/actions/components/actions-grid/stories/AllActions.stories.ts create mode 100644 packages/app/src/features/actions/components/actions-grid/stories/AllHandlerStates.stories.ts create mode 100644 packages/app/src/features/actions/components/actions-grid/stories/EasyBorrowFlow.stories.ts create mode 100644 packages/app/src/features/actions/components/actions-grid/stories/allActionHandlers.ts create mode 100644 packages/app/src/features/actions/components/settings-dialog/ActionSettings.stories.ts create mode 100644 packages/app/src/features/actions/components/settings-dialog/ActionSettings.tsx create mode 100644 packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.stories.ts create mode 100644 packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.tsx create mode 100644 packages/app/src/features/actions/components/skeleton/ActionsSkeleton.stories.tsx create mode 100644 packages/app/src/features/actions/components/skeleton/ActionsSkeleton.tsx create mode 100644 packages/app/src/features/actions/flavours/approve-delegation/ApproveDelegationActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/approve-delegation/types.ts create mode 100644 packages/app/src/features/actions/flavours/approve-delegation/useCreateApproveDelegationHandler.ts create mode 100644 packages/app/src/features/actions/flavours/approve-exchange/ApproveExchangeActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/approve-exchange/types.ts create mode 100644 packages/app/src/features/actions/flavours/approve-exchange/useCreateApproveExchangeHandler.ts create mode 100644 packages/app/src/features/actions/flavours/approve/ApproveActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/approve/logic/getSignPermitDataConfig.ts create mode 100644 packages/app/src/features/actions/flavours/approve/logic/queries.ts create mode 100644 packages/app/src/features/actions/flavours/approve/logic/useCreateApproveHandler.ts create mode 100644 packages/app/src/features/actions/flavours/approve/logic/useCreateApproveOrPermitHandler.ts create mode 100644 packages/app/src/features/actions/flavours/approve/logic/useCreatePermitHandler.ts create mode 100644 packages/app/src/features/actions/flavours/approve/types.ts create mode 100644 packages/app/src/features/actions/flavours/borrow/BorrowActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/borrow/types.ts create mode 100644 packages/app/src/features/actions/flavours/borrow/useCreateBorrowHandler.ts create mode 100644 packages/app/src/features/actions/flavours/deposit/DepositActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/deposit/types.ts create mode 100644 packages/app/src/features/actions/flavours/deposit/useCreateDepositHandler.ts create mode 100644 packages/app/src/features/actions/flavours/exchange/ExchangeActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/exchange/types.ts create mode 100644 packages/app/src/features/actions/flavours/exchange/useCreateExchangeHandler.ts create mode 100644 packages/app/src/features/actions/flavours/repay/RepayActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/repay/types.ts create mode 100644 packages/app/src/features/actions/flavours/repay/useCreateRepayHandler.ts create mode 100644 packages/app/src/features/actions/flavours/set-use-as-collateral/SetUseAsCollateralActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/set-use-as-collateral/types.ts create mode 100644 packages/app/src/features/actions/flavours/set-use-as-collateral/useCreateSetUseAsCollateralHandler.ts create mode 100644 packages/app/src/features/actions/flavours/set-user-e-mode/SetUserEModeActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/set-user-e-mode/types.ts create mode 100644 packages/app/src/features/actions/flavours/set-user-e-mode/useCreateSetUserEModeHandler.ts create mode 100644 packages/app/src/features/actions/flavours/withdraw/WithdrawActionRow.tsx create mode 100644 packages/app/src/features/actions/flavours/withdraw/types.ts create mode 100644 packages/app/src/features/actions/flavours/withdraw/useCreateWithdrawHandler.ts create mode 100644 packages/app/src/features/actions/logic/permits.ts create mode 100644 packages/app/src/features/actions/logic/simplifyQueryResult.ts create mode 100644 packages/app/src/features/actions/logic/stringifyObjectives.ts create mode 100644 packages/app/src/features/actions/logic/types.ts create mode 100644 packages/app/src/features/actions/logic/useActionHandlers.ts create mode 100644 packages/app/src/features/actions/logic/useCreateActions.ts create mode 100644 packages/app/src/features/actions/logic/useGasPrice.ts create mode 100644 packages/app/src/features/actions/logic/utils.ts create mode 100644 packages/app/src/features/actions/utils/formatGasPrice.test.ts create mode 100644 packages/app/src/features/actions/utils/formatGasPrice.ts create mode 100644 packages/app/src/features/actions/views/ActionsView.stories.ts create mode 100644 packages/app/src/features/actions/views/ActionsView.tsx create mode 100644 packages/app/src/features/compliance/ComplianceContainer.tsx create mode 100644 packages/app/src/features/compliance/components/AddressBlocked.stories.ts create mode 100644 packages/app/src/features/compliance/components/AddressBlocked.tsx create mode 100644 packages/app/src/features/compliance/components/Banner.tsx create mode 100644 packages/app/src/features/compliance/components/PageNotAvailable.tsx create mode 100644 packages/app/src/features/compliance/components/RegionBlocked.stories.ts create mode 100644 packages/app/src/features/compliance/components/RegionBlocked.tsx create mode 100644 packages/app/src/features/compliance/components/TermsOfService.stories.ts create mode 100644 packages/app/src/features/compliance/components/TermsOfService.tsx create mode 100644 packages/app/src/features/compliance/components/VPNBlocked.stories.ts create mode 100644 packages/app/src/features/compliance/components/VPNBlocked.tsx create mode 100644 packages/app/src/features/compliance/logic/consts.ts create mode 100644 packages/app/src/features/compliance/logic/useBlockedPages.ts create mode 100644 packages/app/src/features/compliance/logic/useCompliance.ts create mode 100644 packages/app/src/features/compliance/logic/useIPAndAddressCheck.ts create mode 100644 packages/app/src/features/compliance/logic/useIsCurrentPageBlocked.ts create mode 100644 packages/app/src/features/dashboard/DashboardContainer.tsx create mode 100644 packages/app/src/features/dashboard/components/borrow-table/BorrowTable.stories.tsx create mode 100644 packages/app/src/features/dashboard/components/borrow-table/BorrowTable.tsx create mode 100644 packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.stories.ts create mode 100644 packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.tsx create mode 100644 packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.stories.tsx create mode 100644 packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.tsx create mode 100644 packages/app/src/features/dashboard/components/deposit-table/DepositTable.stories.tsx create mode 100644 packages/app/src/features/dashboard/components/deposit-table/DepositTable.tsx create mode 100644 packages/app/src/features/dashboard/components/position/Position.stories.tsx create mode 100644 packages/app/src/features/dashboard/components/position/Position.tsx create mode 100644 packages/app/src/features/dashboard/components/skeleton/DasboardSkeleton.stories.ts create mode 100644 packages/app/src/features/dashboard/components/skeleton/DashboardSkeleton.tsx create mode 100644 packages/app/src/features/dashboard/components/wallet-composition/AssetTable.tsx create mode 100644 packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.stories.tsx create mode 100644 packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.tsx create mode 100644 packages/app/src/features/dashboard/components/wallet-composition/logic/calculate-distribution.ts create mode 100644 packages/app/src/features/dashboard/logic/assets.ts create mode 100644 packages/app/src/features/dashboard/logic/makeLiquidationDetails.ts create mode 100644 packages/app/src/features/dashboard/logic/position.ts create mode 100644 packages/app/src/features/dashboard/logic/types.ts create mode 100644 packages/app/src/features/dashboard/logic/useDashboard.ts create mode 100644 packages/app/src/features/dashboard/logic/wallet-composition.ts create mode 100644 packages/app/src/features/dashboard/views/GuestView.stories.ts create mode 100644 packages/app/src/features/dashboard/views/GuestView.tsx create mode 100644 packages/app/src/features/dashboard/views/PositionView.stories.ts create mode 100644 packages/app/src/features/dashboard/views/PositionView.tsx create mode 100644 packages/app/src/features/dialogs/borrow/BorrowDialog.test-e2e.ts create mode 100644 packages/app/src/features/dialogs/borrow/BorrowDialog.tsx create mode 100644 packages/app/src/features/dialogs/borrow/BorrowDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/borrow/components/BorrowOverviewPanel.tsx create mode 100644 packages/app/src/features/dialogs/borrow/logic/assets.ts create mode 100644 packages/app/src/features/dialogs/borrow/logic/createBorrowObjectives.ts create mode 100644 packages/app/src/features/dialogs/borrow/logic/form.ts create mode 100644 packages/app/src/features/dialogs/borrow/logic/useBorrowDialog.ts create mode 100644 packages/app/src/features/dialogs/borrow/views/BorrowView.stories.tsx create mode 100644 packages/app/src/features/dialogs/borrow/views/BorrowView.tsx create mode 100644 packages/app/src/features/dialogs/collateral/CollateralDialog.PageObject.ts create mode 100644 packages/app/src/features/dialogs/collateral/CollateralDialog.test-e2e.ts create mode 100644 packages/app/src/features/dialogs/collateral/CollateralDialog.tsx create mode 100644 packages/app/src/features/dialogs/collateral/CollateralDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/collateral/components/CollateralAlert.tsx create mode 100644 packages/app/src/features/dialogs/collateral/components/CollateralOverviewPanel.tsx create mode 100644 packages/app/src/features/dialogs/collateral/logic/createCollateralObjectives.ts create mode 100644 packages/app/src/features/dialogs/collateral/logic/getUpdatedUserSummary.ts create mode 100644 packages/app/src/features/dialogs/collateral/logic/useCollateralDialog.ts create mode 100644 packages/app/src/features/dialogs/collateral/types.ts create mode 100644 packages/app/src/features/dialogs/collateral/views/CollateralView.stories.ts create mode 100644 packages/app/src/features/dialogs/collateral/views/CollateralView.tsx create mode 100644 packages/app/src/features/dialogs/collateral/views/SuccessView.stories.ts create mode 100644 packages/app/src/features/dialogs/collateral/views/SuccessView.tsx create mode 100644 packages/app/src/features/dialogs/common/Dialog.PageObject.ts create mode 100644 packages/app/src/features/dialogs/common/components/DialogActionsPanel.tsx create mode 100644 packages/app/src/features/dialogs/common/components/DialogPanel.tsx create mode 100644 packages/app/src/features/dialogs/common/components/DialogPanelTitle.tsx create mode 100644 packages/app/src/features/dialogs/common/components/FormAndOverviewWrapper.tsx create mode 100644 packages/app/src/features/dialogs/common/components/HealthFactorChange.stories.ts create mode 100644 packages/app/src/features/dialogs/common/components/HealthFactorChange.tsx create mode 100644 packages/app/src/features/dialogs/common/components/MultiPanelDialog.tsx create mode 100644 packages/app/src/features/dialogs/common/components/RiskIndicator.stories.ts create mode 100644 packages/app/src/features/dialogs/common/components/RiskIndicator.tsx create mode 100644 packages/app/src/features/dialogs/common/components/TransactionOverviewDetailsItem.tsx create mode 100644 packages/app/src/features/dialogs/common/components/alert/Alert.stories.ts create mode 100644 packages/app/src/features/dialogs/common/components/alert/Alert.tsx create mode 100644 packages/app/src/features/dialogs/common/components/form/DialogForm.tsx create mode 100644 packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.stories.tsx create mode 100644 packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.tsx create mode 100644 packages/app/src/features/dialogs/common/components/success-view/SuccessViewCheckmark.tsx create mode 100644 packages/app/src/features/dialogs/common/components/success-view/SuccessViewContent.tsx create mode 100644 packages/app/src/features/dialogs/common/components/success-view/SuccessViewProceedButton.tsx create mode 100644 packages/app/src/features/dialogs/common/components/success-view/SuccessViewSummaryPanel.tsx create mode 100644 packages/app/src/features/dialogs/common/logic/form.ts create mode 100644 packages/app/src/features/dialogs/common/logic/title.ts create mode 100644 packages/app/src/features/dialogs/common/logic/useUpdateFormMaxValue.ts create mode 100644 packages/app/src/features/dialogs/common/types.ts create mode 100644 packages/app/src/features/dialogs/common/views/SuccessView.stories.ts create mode 100644 packages/app/src/features/dialogs/common/views/SuccessView.tsx create mode 100644 packages/app/src/features/dialogs/deposit/DepositDialog.test-e2e.ts create mode 100644 packages/app/src/features/dialogs/deposit/DepositDialog.tsx create mode 100644 packages/app/src/features/dialogs/deposit/DepositDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/deposit/components/DepositOverviewPanel.tsx create mode 100644 packages/app/src/features/dialogs/deposit/logic/assets.ts create mode 100644 packages/app/src/features/dialogs/deposit/logic/collateralization.ts create mode 100644 packages/app/src/features/dialogs/deposit/logic/form.ts create mode 100644 packages/app/src/features/dialogs/deposit/logic/types.ts create mode 100644 packages/app/src/features/dialogs/deposit/logic/useCreateObjectives.ts create mode 100644 packages/app/src/features/dialogs/deposit/logic/useDepositDialog.ts create mode 100644 packages/app/src/features/dialogs/deposit/views/DepositView.stories.tsx create mode 100644 packages/app/src/features/dialogs/deposit/views/DepositView.tsx create mode 100644 packages/app/src/features/dialogs/dispatcher/DialogDispatcherContainer.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/EModeDialog.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/EModeDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/components/AvailableAssets.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/components/CategoriesGrid.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.stories.ts create mode 100644 packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/components/EModeOverviewPanel.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/components/LTVChange.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/logic/createEModeObjectives.ts create mode 100644 packages/app/src/features/dialogs/e-mode/logic/getEModeCategories.ts create mode 100644 packages/app/src/features/dialogs/e-mode/logic/getUpdatedPositionOverview.ts create mode 100644 packages/app/src/features/dialogs/e-mode/logic/useEModeDialog.ts create mode 100644 packages/app/src/features/dialogs/e-mode/types.ts create mode 100644 packages/app/src/features/dialogs/e-mode/views/EModeView.stories.ts create mode 100644 packages/app/src/features/dialogs/e-mode/views/EModeView.tsx create mode 100644 packages/app/src/features/dialogs/e-mode/views/SuccessView.stories.ts create mode 100644 packages/app/src/features/dialogs/e-mode/views/SuccessView.tsx create mode 100644 packages/app/src/features/dialogs/repay/RepayDialog.test-e2e.ts create mode 100644 packages/app/src/features/dialogs/repay/RepayDialog.tsx create mode 100644 packages/app/src/features/dialogs/repay/RepayDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/repay/components/RepayOverviewPanel.tsx create mode 100644 packages/app/src/features/dialogs/repay/logic/assets.ts create mode 100644 packages/app/src/features/dialogs/repay/logic/form.ts create mode 100644 packages/app/src/features/dialogs/repay/logic/getRepayInFullOptions.ts create mode 100644 packages/app/src/features/dialogs/repay/logic/positionOverview.ts create mode 100644 packages/app/src/features/dialogs/repay/logic/types.ts create mode 100644 packages/app/src/features/dialogs/repay/logic/useCreateRepayObjectives.ts create mode 100644 packages/app/src/features/dialogs/repay/logic/useRepayDialog.ts create mode 100644 packages/app/src/features/dialogs/repay/views/RepayView.stories.tsx create mode 100644 packages/app/src/features/dialogs/repay/views/RepayView.tsx create mode 100644 packages/app/src/features/dialogs/sandbox/SandboxDialog.tsx create mode 100644 packages/app/src/features/dialogs/sandbox/SandboxDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/sandbox/logic/createSandbox.ts create mode 100644 packages/app/src/features/dialogs/sandbox/logic/createSandboxConnector.ts create mode 100644 packages/app/src/features/dialogs/sandbox/logic/useSandboxDialog.ts create mode 100644 packages/app/src/features/dialogs/sandbox/types.ts create mode 100644 packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.stories.ts create mode 100644 packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.tsx create mode 100644 packages/app/src/features/dialogs/savings/common/components/DepositOverviewPanel.tsx create mode 100644 packages/app/src/features/dialogs/savings/common/components/TokenValue.tsx create mode 100644 packages/app/src/features/dialogs/savings/common/components/TransactionOverviewBalanceChangeDetail.tsx create mode 100644 packages/app/src/features/dialogs/savings/common/components/TransactionOverviewExchangeRateDetail.tsx create mode 100644 packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.PageObject.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.test-e2e.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.tsx create mode 100644 packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/savings/deposit/logic/form.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/logic/objectives.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/logic/useSavingsDepositDialog.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/logic/useSwap.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/logic/useTransactionOverview.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/logic/validation.ts create mode 100644 packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.stories.tsx create mode 100644 packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.tsx create mode 100644 packages/app/src/features/dialogs/savings/utils/formatWithHighPrecision.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.PageObject.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.test-e2e.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.tsx create mode 100644 packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/savings/withdraw/logic/form.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/logic/getSDaiWithBalance.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/logic/objectives.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/logic/useSavingsWithdrawDialog.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/logic/useSwap.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/logic/useTransactionOverview.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/logic/validation.ts create mode 100644 packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.stories.tsx create mode 100644 packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.tsx create mode 100644 packages/app/src/features/dialogs/withdraw/WithdrawDialog.test-e2e.ts create mode 100644 packages/app/src/features/dialogs/withdraw/WithdrawDialog.tsx create mode 100644 packages/app/src/features/dialogs/withdraw/WithdrawDialogContentContainer.tsx create mode 100644 packages/app/src/features/dialogs/withdraw/components/WithdrawOverviewPanel.tsx create mode 100644 packages/app/src/features/dialogs/withdraw/logic/assets.ts create mode 100644 packages/app/src/features/dialogs/withdraw/logic/form.ts create mode 100644 packages/app/src/features/dialogs/withdraw/logic/getWithdrawInFullOptions.ts create mode 100644 packages/app/src/features/dialogs/withdraw/logic/objectives.ts create mode 100644 packages/app/src/features/dialogs/withdraw/logic/types.ts create mode 100644 packages/app/src/features/dialogs/withdraw/logic/useWithdrawDialog.ts create mode 100644 packages/app/src/features/dialogs/withdraw/views/WithdrawView.stories.tsx create mode 100644 packages/app/src/features/dialogs/withdraw/views/WithdrawView.tsx create mode 100644 packages/app/src/features/easy-borrow/EasyBorrowContainer.tsx create mode 100644 packages/app/src/features/easy-borrow/components/BorrowRateBanner.tsx create mode 100644 packages/app/src/features/easy-borrow/components/EasyBorrowPanel.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/Borrow.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/Deposits.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/EasyBorrowForm.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/LoanToValue.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.stories.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/TokenSummary.stories.tsx create mode 100644 packages/app/src/features/easy-borrow/components/form/TokenSummary.tsx create mode 100644 packages/app/src/features/easy-borrow/components/note/BorrowRate.tsx create mode 100644 packages/app/src/features/easy-borrow/components/note/EasyBorrowNote.tsx create mode 100644 packages/app/src/features/easy-borrow/components/note/EasyBorrowSidePanel.tsx create mode 100644 packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.stories.ts create mode 100644 packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.tsx create mode 100644 packages/app/src/features/easy-borrow/logic/assets.ts create mode 100644 packages/app/src/features/easy-borrow/logic/form/form.ts create mode 100644 packages/app/src/features/easy-borrow/logic/form/normalization.ts create mode 100644 packages/app/src/features/easy-borrow/logic/form/validation.ts create mode 100644 packages/app/src/features/easy-borrow/logic/types.ts create mode 100644 packages/app/src/features/easy-borrow/logic/useCreateObjectives.ts create mode 100644 packages/app/src/features/easy-borrow/logic/useEasyBorrow.ts create mode 100644 packages/app/src/features/easy-borrow/views/EasyBorrowView.stories.tsx create mode 100644 packages/app/src/features/easy-borrow/views/EasyBorrowView.tsx create mode 100644 packages/app/src/features/easy-borrow/views/SuccessView.stories.ts create mode 100644 packages/app/src/features/easy-borrow/views/SuccessView.tsx create mode 100644 packages/app/src/features/errors/ErrorContainer.tsx create mode 100644 packages/app/src/features/errors/NotFound.stories.ts create mode 100644 packages/app/src/features/errors/NotFound.tsx create mode 100644 packages/app/src/features/market-details/MarketDetailsContainer.tsx create mode 100644 packages/app/src/features/market-details/components/charts/colors.ts create mode 100644 packages/app/src/features/market-details/components/charts/defaults.ts create mode 100644 packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.stories.ts create mode 100644 packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.tsx create mode 100644 packages/app/src/features/market-details/components/charts/interest-yield/components/Chart.tsx create mode 100644 packages/app/src/features/market-details/components/charts/interest-yield/logic/getYields.ts create mode 100644 packages/app/src/features/market-details/components/charts/interest-yield/types.ts create mode 100644 packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.stories.tsx create mode 100644 packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.tsx create mode 100644 packages/app/src/features/market-details/components/charts/market-overview/colors.ts create mode 100644 packages/app/src/features/market-details/components/charts/market-overview/components/Legend.tsx create mode 100644 packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.stories.ts create mode 100644 packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.tsx create mode 100644 packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.stories.ts create mode 100644 packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.tsx create mode 100644 packages/app/src/features/market-details/components/market-overview/MarketOverview.tsx create mode 100644 packages/app/src/features/market-details/components/market-overview/components/DetailsGrid.tsx create mode 100644 packages/app/src/features/market-details/components/market-overview/components/DetailsGridItem.tsx create mode 100644 packages/app/src/features/market-details/components/market-overview/components/MarketOvierviewContent.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/MyWallet.stories.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/MyWallet.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/MyWalletChainMismatch.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.stories.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/MyWalletPanel.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/components/ActionDetails.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/components/ActionRow.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/components/BorrowRow.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/components/TokenBalance.tsx create mode 100644 packages/app/src/features/market-details/components/my-wallet/components/WalletPanelContent.tsx create mode 100644 packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.stories.ts create mode 100644 packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.stories.ts create mode 100644 packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.stories.ts create mode 100644 packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.stories.ts create mode 100644 packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/LendStatusPanel.stories.ts create mode 100644 packages/app/src/features/market-details/components/status-panel/LendStatusPanel.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.stories.ts create mode 100644 packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/EmptyStatusPanel.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/Header.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/StatusPanelGrid.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/Subheader.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.stories.ts create mode 100644 packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.stories.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTilesGrid.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.stories.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.tsx create mode 100644 packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.stories.ts create mode 100644 packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.tsx create mode 100644 packages/app/src/features/market-details/logic/getReserveEModeCategoryTokens.ts create mode 100644 packages/app/src/features/market-details/logic/makeDaiMarketOverview.ts create mode 100644 packages/app/src/features/market-details/logic/makeMarketOverview.ts create mode 100644 packages/app/src/features/market-details/logic/makeWalletOverview.ts create mode 100644 packages/app/src/features/market-details/logic/useMarketDetails.ts create mode 100644 packages/app/src/features/market-details/logic/useMarketDetailsParams.ts create mode 100644 packages/app/src/features/market-details/types.ts create mode 100644 packages/app/src/features/market-details/views/MarketDetailsView.stories.ts create mode 100644 packages/app/src/features/market-details/views/MarketDetailsView.tsx create mode 100644 packages/app/src/features/market-details/views/components/BackNav.tsx create mode 100644 packages/app/src/features/market-details/views/components/CompactView.tsx create mode 100644 packages/app/src/features/market-details/views/components/FullView.tsx create mode 100644 packages/app/src/features/market-details/views/components/Header.tsx create mode 100644 packages/app/src/features/market-details/views/types.ts create mode 100644 packages/app/src/features/markets/MarketsContainer.tsx create mode 100644 packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.stories.tsx create mode 100644 packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.tsx create mode 100644 packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.stories.tsx create mode 100644 packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.tsx create mode 100644 packages/app/src/features/markets/components/asset-status-badge/components/AssetStatusDescription.tsx create mode 100644 packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.test.ts create mode 100644 packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.ts create mode 100644 packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.stories.ts create mode 100644 packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.tsx create mode 100644 packages/app/src/features/markets/components/markets-table/MarketsTable.stories.tsx create mode 100644 packages/app/src/features/markets/components/markets-table/MarketsTable.tsx create mode 100644 packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.stories.tsx create mode 100644 packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.tsx create mode 100644 packages/app/src/features/markets/components/markets-table/components/AssetNameCell.stories.tsx create mode 100644 packages/app/src/features/markets/components/markets-table/components/AssetNameCell.tsx create mode 100644 packages/app/src/features/markets/components/reward-badge/RewardBadge.stories.tsx create mode 100644 packages/app/src/features/markets/components/reward-badge/RewardBadge.tsx create mode 100644 packages/app/src/features/markets/components/skeleton/MarketsSkeleton.stories.ts create mode 100644 packages/app/src/features/markets/components/skeleton/MarketsSkeleton.tsx create mode 100644 packages/app/src/features/markets/components/summary-tile/SummaryTile.stories.tsx create mode 100644 packages/app/src/features/markets/components/summary-tile/SummaryTile.tsx create mode 100644 packages/app/src/features/markets/components/summary-tile/components/Tile.stories.tsx create mode 100644 packages/app/src/features/markets/components/summary-tile/components/Tile.tsx create mode 100644 packages/app/src/features/markets/components/summary-tiles/SummaryTiles.stories.tsx create mode 100644 packages/app/src/features/markets/components/summary-tiles/SummaryTiles.tsx create mode 100644 packages/app/src/features/markets/components/token-pill/TokenPill.stories.tsx create mode 100644 packages/app/src/features/markets/components/token-pill/TokenPill.tsx create mode 100644 packages/app/src/features/markets/logic/aggregate-stats.ts create mode 100644 packages/app/src/features/markets/logic/transformers.ts create mode 100644 packages/app/src/features/markets/logic/useMarkets.ts create mode 100644 packages/app/src/features/markets/types.ts create mode 100644 packages/app/src/features/markets/views/MarketsView.stories.ts create mode 100644 packages/app/src/features/markets/views/MarketsView.tsx create mode 100644 packages/app/src/features/navbar/Navbar.tsx create mode 100644 packages/app/src/features/navbar/components/MobileMenuButton.tsx create mode 100644 packages/app/src/features/navbar/components/NavbarActionWrapper.tsx create mode 100644 packages/app/src/features/navbar/components/NavbarActions.tsx create mode 100644 packages/app/src/features/navbar/components/PageLinks.tsx create mode 100644 packages/app/src/features/navbar/components/nav-link/NavLink.stories.tsx create mode 100644 packages/app/src/features/navbar/components/nav-link/NavLink.tsx create mode 100644 packages/app/src/features/navbar/components/network-selector/NetworkSelector.stories.ts create mode 100644 packages/app/src/features/navbar/components/network-selector/NetworkSelector.tsx create mode 100644 packages/app/src/features/navbar/components/settings-dropdown/BuildInfoItem.tsx create mode 100644 packages/app/src/features/navbar/components/settings-dropdown/SettingsDropDown.stories.ts create mode 100644 packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdown.tsx create mode 100644 packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdownItem.tsx create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.stories.ts create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.tsx create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectButton.tsx create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.stories.tsx create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.tsx create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/components/WalletButton.tsx create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/components/WalletDropdownContent.stories.tsx create mode 100644 packages/app/src/features/navbar/components/wallet-dropdown/components/WalletDropdownContent.tsx create mode 100644 packages/app/src/features/navbar/logic/generateWalletAvatar.ts create mode 100644 packages/app/src/features/navbar/logic/getWalletIcon.ts create mode 100644 packages/app/src/features/navbar/logic/useDisconnect.ts create mode 100644 packages/app/src/features/navbar/logic/useNavbar.ts create mode 100644 packages/app/src/features/navbar/logic/useNetworkChange.ts create mode 100644 packages/app/src/features/navbar/logic/useTotalBalance.ts create mode 100644 packages/app/src/features/navbar/types.ts create mode 100644 packages/app/src/features/savings/SavingsContainer.tsx create mode 100644 packages/app/src/features/savings/components/PageHeader.tsx create mode 100644 packages/app/src/features/savings/components/PageLayout.tsx create mode 100644 packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.stories.ts create mode 100644 packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.tsx create mode 100644 packages/app/src/features/savings/components/navbar-item/DSRBadge.stories.ts create mode 100644 packages/app/src/features/savings/components/navbar-item/DSRBadge.tsx create mode 100644 packages/app/src/features/savings/components/savings-dai/SavingsDAI.stories.ts create mode 100644 packages/app/src/features/savings/components/savings-dai/SavingsDAI.tsx create mode 100644 packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.stories.tsx create mode 100644 packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.tsx create mode 100644 packages/app/src/features/savings/components/savings-opportunity/SavingdOpportunityNoCash.stories.ts create mode 100644 packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.stories.ts create mode 100644 packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.tsx create mode 100644 packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.stories.ts create mode 100644 packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.tsx create mode 100644 packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityNoCash.tsx create mode 100644 packages/app/src/features/savings/components/savings-opportunity/components/DSRLabel.tsx create mode 100644 packages/app/src/features/savings/components/savings-opportunity/components/Explainer.tsx create mode 100644 packages/app/src/features/savings/components/skeleton/SavingsSkeleton.stories.ts create mode 100644 packages/app/src/features/savings/components/skeleton/SavingsSkeleton.tsx create mode 100644 packages/app/src/features/savings/logic/makeSavingsOverview.ts create mode 100644 packages/app/src/features/savings/logic/projections.test.ts create mode 100644 packages/app/src/features/savings/logic/projections.ts create mode 100644 packages/app/src/features/savings/logic/useSavings.ts create mode 100644 packages/app/src/features/savings/types.ts create mode 100644 packages/app/src/features/savings/views/GuestView.stories.ts create mode 100644 packages/app/src/features/savings/views/GuestView.tsx create mode 100644 packages/app/src/features/savings/views/SavingsView.stories.ts create mode 100644 packages/app/src/features/savings/views/SavingsView.tsx create mode 100644 packages/app/src/features/savings/views/UnsupportedChainView.stories.ts create mode 100644 packages/app/src/features/savings/views/UnsupportedChainView.tsx create mode 100644 packages/app/src/fonts/InterVariable.woff2 create mode 100644 packages/app/src/global.d.ts create mode 100644 packages/app/src/locales/en.po create mode 100644 packages/app/src/main.tsx create mode 100644 packages/app/src/pages/Borrow.PageObject.ts create mode 100644 packages/app/src/pages/Borrow.test-e2e.ts create mode 100644 packages/app/src/pages/Borrow.tsx create mode 100644 packages/app/src/pages/Dashboard.PageObject.ts create mode 100644 packages/app/src/pages/Dashboard.test-e2e.ts create mode 100644 packages/app/src/pages/Dashboard.tsx create mode 100644 packages/app/src/pages/MarketDetails.PageObject.ts create mode 100644 packages/app/src/pages/MarketDetails.test-e2e.ts create mode 100644 packages/app/src/pages/MarketDetails.tsx create mode 100644 packages/app/src/pages/Markets.tsx create mode 100644 packages/app/src/pages/Root.tsx create mode 100644 packages/app/src/pages/Savings.PageObject.ts create mode 100644 packages/app/src/pages/Savings.test-e2e.ts create mode 100644 packages/app/src/pages/Savings.tsx create mode 100644 packages/app/src/reset.d.ts create mode 100644 packages/app/src/test/e2e/BasePageObject.ts create mode 100644 packages/app/src/test/e2e/TestTenderlyClient.ts create mode 100644 packages/app/src/test/e2e/assertions.ts create mode 100644 packages/app/src/test/e2e/constants.ts create mode 100644 packages/app/src/test/e2e/injectSetup.ts create mode 100644 packages/app/src/test/e2e/lifi.ts create mode 100644 packages/app/src/test/e2e/processEnv.ts create mode 100644 packages/app/src/test/e2e/setup.ts create mode 100644 packages/app/src/test/e2e/setupFork.ts create mode 100644 packages/app/src/test/e2e/utils.ts create mode 100644 packages/app/src/test/integration/TestingWrapper.tsx create mode 100644 packages/app/src/test/integration/constants.ts create mode 100644 packages/app/src/test/integration/expect.ts create mode 100644 packages/app/src/test/integration/mockTransport/handlers.ts create mode 100644 packages/app/src/test/integration/mockTransport/index.ts create mode 100644 packages/app/src/test/integration/mockTransport/types.ts create mode 100644 packages/app/src/test/integration/mockTransport/utils.ts create mode 100644 packages/app/src/test/integration/mocks/ResizeObserverMock.ts create mode 100644 packages/app/src/test/integration/mocks/install-mocks.ts create mode 100644 packages/app/src/test/integration/object-utils.ts create mode 100644 packages/app/src/test/integration/query-client.ts create mode 100644 packages/app/src/test/integration/renderError.tsx create mode 100644 packages/app/src/test/integration/setup.ts create mode 100644 packages/app/src/test/integration/setupHookRenderer.tsx create mode 100644 packages/app/src/test/integration/trigger.ts create mode 100644 packages/app/src/test/integration/wagmi-config.ts create mode 100644 packages/app/src/ui/assets/actions/approve.svg create mode 100644 packages/app/src/ui/assets/actions/borrow.svg create mode 100644 packages/app/src/ui/assets/actions/deposit.svg create mode 100644 packages/app/src/ui/assets/actions/done.svg create mode 100644 packages/app/src/ui/assets/actions/exchange.svg create mode 100644 packages/app/src/ui/assets/actions/repay.svg create mode 100644 packages/app/src/ui/assets/actions/withdraw.svg create mode 100644 packages/app/src/ui/assets/arrow-right.svg create mode 100644 packages/app/src/ui/assets/box-arrow-top-right.svg create mode 100644 packages/app/src/ui/assets/chains/ethereum.svg create mode 100644 packages/app/src/ui/assets/chains/gnosis.svg create mode 100644 packages/app/src/ui/assets/check-circle.svg create mode 100644 packages/app/src/ui/assets/chevron-down.svg create mode 100644 packages/app/src/ui/assets/circle-info.svg create mode 100644 packages/app/src/ui/assets/close.svg create mode 100644 packages/app/src/ui/assets/down.svg create mode 100644 packages/app/src/ui/assets/eye.svg create mode 100644 packages/app/src/ui/assets/flash.svg create mode 100644 packages/app/src/ui/assets/green-arrow-up.svg create mode 100644 packages/app/src/ui/assets/index.ts create mode 100644 packages/app/src/ui/assets/lifi-logo.svg create mode 100644 packages/app/src/ui/assets/link.svg create mode 100644 packages/app/src/ui/assets/magic-wand.svg create mode 100644 packages/app/src/ui/assets/markets/chart.svg create mode 100644 packages/app/src/ui/assets/markets/input-output.svg create mode 100644 packages/app/src/ui/assets/markets/lock.svg create mode 100644 packages/app/src/ui/assets/markets/output.svg create mode 100644 packages/app/src/ui/assets/menu.svg create mode 100644 packages/app/src/ui/assets/more-icon.svg create mode 100644 packages/app/src/ui/assets/pause.svg create mode 100644 packages/app/src/ui/assets/slider-thumb.svg create mode 100644 packages/app/src/ui/assets/snowflake.svg create mode 100644 packages/app/src/ui/assets/spark-icon.svg create mode 100644 packages/app/src/ui/assets/spark-logo.svg create mode 100644 packages/app/src/ui/assets/success.svg create mode 100644 packages/app/src/ui/assets/three-dots.svg create mode 100644 packages/app/src/ui/assets/tokens/dai.svg create mode 100644 packages/app/src/ui/assets/tokens/eth.svg create mode 100644 packages/app/src/ui/assets/tokens/eure.svg create mode 100644 packages/app/src/ui/assets/tokens/gno.svg create mode 100644 packages/app/src/ui/assets/tokens/mkr.svg create mode 100644 packages/app/src/ui/assets/tokens/reth.svg create mode 100644 packages/app/src/ui/assets/tokens/sdai.svg create mode 100644 packages/app/src/ui/assets/tokens/steth.svg create mode 100644 packages/app/src/ui/assets/tokens/unknown.svg create mode 100644 packages/app/src/ui/assets/tokens/usdc.svg create mode 100644 packages/app/src/ui/assets/tokens/usdt.svg create mode 100644 packages/app/src/ui/assets/tokens/wbtc.svg create mode 100644 packages/app/src/ui/assets/tokens/weth.svg create mode 100644 packages/app/src/ui/assets/tokens/wsteth.svg create mode 100644 packages/app/src/ui/assets/tokens/wxdai.svg create mode 100644 packages/app/src/ui/assets/tokens/xdai.svg create mode 100644 packages/app/src/ui/assets/up.svg create mode 100644 packages/app/src/ui/assets/wallet-icons/coinbase.svg create mode 100644 packages/app/src/ui/assets/wallet-icons/default.svg create mode 100644 packages/app/src/ui/assets/wallet-icons/enjin.svg create mode 100644 packages/app/src/ui/assets/wallet-icons/metamask.svg create mode 100644 packages/app/src/ui/assets/wallet-icons/torus.svg create mode 100644 packages/app/src/ui/assets/wallet-icons/wallet-connect.svg create mode 100644 packages/app/src/ui/assets/wallet.svg create mode 100644 packages/app/src/ui/assets/warning.svg create mode 100644 packages/app/src/ui/assets/x-circle.svg create mode 100644 packages/app/src/ui/atoms/accordion/Accordion.stories.tsx create mode 100644 packages/app/src/ui/atoms/accordion/Accordion.tsx create mode 100644 packages/app/src/ui/atoms/button/Button.stories.tsx create mode 100644 packages/app/src/ui/atoms/button/Button.tsx create mode 100644 packages/app/src/ui/atoms/button/LinkButton.stories.tsx create mode 100644 packages/app/src/ui/atoms/checkbox/Checkbox.stories.tsx create mode 100644 packages/app/src/ui/atoms/checkbox/Checkbox.tsx create mode 100644 packages/app/src/ui/atoms/color-filter/ColorFilter.stories.tsx create mode 100644 packages/app/src/ui/atoms/color-filter/ColorFilter.tsx create mode 100644 packages/app/src/ui/atoms/dialog/Dialog.stories.tsx create mode 100644 packages/app/src/ui/atoms/dialog/Dialog.tsx create mode 100644 packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.stories.tsx create mode 100644 packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.tsx create mode 100644 packages/app/src/ui/atoms/dropdown/DropdownMenu.stories.tsx create mode 100644 packages/app/src/ui/atoms/dropdown/DropdownMenu.tsx create mode 100644 packages/app/src/ui/atoms/form/Form.tsx create mode 100644 packages/app/src/ui/atoms/health-factor-badge/HealthFactorBadge.stories.tsx create mode 100644 packages/app/src/ui/atoms/health-factor-badge/HealthFactorBadge.tsx create mode 100644 packages/app/src/ui/atoms/health-factor-gauge/HealthFactorGauge.stories.tsx create mode 100644 packages/app/src/ui/atoms/health-factor-gauge/HealthFactorGauge.tsx create mode 100644 packages/app/src/ui/atoms/icon-pill/IconPill.stories.tsx create mode 100644 packages/app/src/ui/atoms/icon-pill/IconPill.tsx create mode 100644 packages/app/src/ui/atoms/indicator-icon/IndicatorIcon.stories.tsx create mode 100644 packages/app/src/ui/atoms/indicator-icon/IndicatorIcon.tsx create mode 100644 packages/app/src/ui/atoms/input/Input.tsx create mode 100644 packages/app/src/ui/atoms/label/Label.tsx create mode 100644 packages/app/src/ui/atoms/link-decorator/LinkDecorator.tsx create mode 100644 packages/app/src/ui/atoms/link/Link.tsx create mode 100644 packages/app/src/ui/atoms/panel/CollapsiblePanel.test.tsx create mode 100644 packages/app/src/ui/atoms/panel/CollapsiblePanel.tsx create mode 100644 packages/app/src/ui/atoms/panel/Panel.stories.tsx create mode 100644 packages/app/src/ui/atoms/panel/Panel.test.tsx create mode 100644 packages/app/src/ui/atoms/panel/Panel.tsx create mode 100644 packages/app/src/ui/atoms/progress/Progress.stories.ts create mode 100644 packages/app/src/ui/atoms/progress/Progress.tsx create mode 100644 packages/app/src/ui/atoms/scroll-area/ScrollArea.stories.tsx create mode 100644 packages/app/src/ui/atoms/scroll-area/ScrollArea.tsx create mode 100644 packages/app/src/ui/atoms/select/Select.stories.tsx create mode 100644 packages/app/src/ui/atoms/select/Select.tsx create mode 100644 packages/app/src/ui/atoms/skeleton/Skeleton.tsx create mode 100644 packages/app/src/ui/atoms/switch/Switch.tsx create mode 100644 packages/app/src/ui/atoms/switch/Swtich.stories.tsx create mode 100644 packages/app/src/ui/atoms/table/Table.stories.tsx create mode 100644 packages/app/src/ui/atoms/table/Table.tsx create mode 100644 packages/app/src/ui/atoms/tabs/Tabs.stories.tsx create mode 100644 packages/app/src/ui/atoms/tabs/Tabs.tsx create mode 100644 packages/app/src/ui/atoms/token-icon/TokenIcon.stories.ts create mode 100644 packages/app/src/ui/atoms/token-icon/TokenIcon.tsx create mode 100644 packages/app/src/ui/atoms/tooltip/Tooltip.stories.tsx create mode 100644 packages/app/src/ui/atoms/tooltip/Tooltip.tsx create mode 100644 packages/app/src/ui/atoms/tooltip/TooltipContentLayout.tsx create mode 100644 packages/app/src/ui/atoms/top-banner/TopBanner.stories.ts create mode 100644 packages/app/src/ui/atoms/top-banner/TopBanner.tsx create mode 100644 packages/app/src/ui/atoms/typography/Typography.stories.tsx create mode 100644 packages/app/src/ui/atoms/typography/Typography.tsx create mode 100644 packages/app/src/ui/constants/links.ts create mode 100644 packages/app/src/ui/layouts/AppLayout.tsx create mode 100644 packages/app/src/ui/layouts/ErrorLayout.tsx create mode 100644 packages/app/src/ui/layouts/FallbackLayout.tsx create mode 100644 packages/app/src/ui/layouts/PageLayout.tsx create mode 100644 packages/app/src/ui/molecules/action-button/ActionButton.stories.tsx create mode 100644 packages/app/src/ui/molecules/action-button/ActionButton.tsx create mode 100644 packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.stories.ts create mode 100644 packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.tsx create mode 100644 packages/app/src/ui/molecules/asset-input/AssetInput.stories.tsx create mode 100644 packages/app/src/ui/molecules/asset-input/AssetInput.tsx create mode 100644 packages/app/src/ui/molecules/asset-selector/AssetSelector.stories.tsx create mode 100644 packages/app/src/ui/molecules/asset-selector/AssetSelector.tsx create mode 100644 packages/app/src/ui/molecules/confetti/Confetti.tsx create mode 100644 packages/app/src/ui/molecules/data-table/DataTable.stories.tsx create mode 100644 packages/app/src/ui/molecules/data-table/DataTable.tsx create mode 100644 packages/app/src/ui/molecules/data-table/components/ActionsCell.tsx create mode 100644 packages/app/src/ui/molecules/data-table/components/ColumnHeader.tsx create mode 100644 packages/app/src/ui/molecules/data-table/components/CompactValueCell.tsx create mode 100644 packages/app/src/ui/molecules/data-table/components/PercentageCell.tsx create mode 100644 packages/app/src/ui/molecules/data-table/components/SwitchCell.tsx create mode 100644 packages/app/src/ui/molecules/data-table/components/TokenWithLogo.tsx create mode 100644 packages/app/src/ui/molecules/data-table/types.ts create mode 100644 packages/app/src/ui/molecules/frozen-pill/FrozenPill.stories.tsx create mode 100644 packages/app/src/ui/molecules/frozen-pill/FrozenPill.tsx create mode 100644 packages/app/src/ui/molecules/icon-stack/IconStack.stories.tsx create mode 100644 packages/app/src/ui/molecules/icon-stack/IconStack.tsx create mode 100644 packages/app/src/ui/molecules/info-pill/InfoPill.stories.tsx create mode 100644 packages/app/src/ui/molecules/info-pill/InfoPill.tsx create mode 100644 packages/app/src/ui/molecules/info/Info.stories.tsx create mode 100644 packages/app/src/ui/molecules/info/Info.tsx create mode 100644 packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.stories.tsx create mode 100644 packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.tsx create mode 100644 packages/app/src/ui/molecules/paused-pill/PausedPill.stories.tsx create mode 100644 packages/app/src/ui/molecules/paused-pill/PausedPill.tsx create mode 100644 packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.stories.tsx create mode 100644 packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.tsx create mode 100644 packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.stories.tsx create mode 100644 packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.tsx create mode 100644 packages/app/src/ui/organisms/health-factor-panel/components/LiquidationOverview.tsx create mode 100644 packages/app/src/ui/organisms/multi-selector/MultiSelector.stories.tsx create mode 100644 packages/app/src/ui/organisms/multi-selector/MultiSelector.tsx create mode 100644 packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.stories.tsx create mode 100644 packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.tsx create mode 100644 packages/app/src/ui/organisms/responsive-data-table/components/CollapsibleCell.tsx create mode 100644 packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.stories.ts create mode 100644 packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.tsx create mode 100644 packages/app/src/ui/utils/get-random-color.ts create mode 100644 packages/app/src/ui/utils/shortenAddress.test.ts create mode 100644 packages/app/src/ui/utils/shortenAddress.ts create mode 100644 packages/app/src/ui/utils/style.ts create mode 100644 packages/app/src/ui/utils/testIds.ts create mode 100644 packages/app/src/ui/utils/useBreakpoint.ts create mode 100644 packages/app/src/ui/utils/useIsTruncated.ts create mode 100644 packages/app/src/ui/utils/useParentSize.ts create mode 100644 packages/app/src/ui/utils/useWindowSize.ts create mode 100644 packages/app/src/ui/utils/withSuspense.tsx create mode 100644 packages/app/src/utils/applyTransformers.test.ts create mode 100644 packages/app/src/utils/applyTransformers.ts create mode 100644 packages/app/src/utils/bigNumber.test.ts create mode 100644 packages/app/src/utils/bigNumber.ts create mode 100644 packages/app/src/utils/math.ts create mode 100644 packages/app/src/utils/object.ts create mode 100644 packages/app/src/utils/promises.ts create mode 100644 packages/app/src/utils/raise.ts create mode 100644 packages/app/src/utils/random.ts create mode 100644 packages/app/src/utils/solidFetch.ts create mode 100644 packages/app/src/utils/strings.test.ts create mode 100644 packages/app/src/utils/strings.ts create mode 100644 packages/app/src/utils/time.ts create mode 100644 packages/app/src/utils/tryOrDefault.ts create mode 100644 packages/app/src/utils/types.ts create mode 100644 packages/app/src/utils/useDebounce.ts create mode 100644 packages/app/src/utils/usePrevious.ts create mode 100644 packages/app/src/utils/useTimestamp.test.ts create mode 100644 packages/app/src/utils/useTimestamp.ts create mode 100644 packages/app/src/utils/useValidatedParams.ts create mode 100644 packages/app/src/vite-env.d.ts create mode 100644 packages/app/tailwind.config.ts create mode 100644 packages/app/tsconfig.json create mode 100644 packages/app/tsconfig.node.json create mode 100644 packages/app/vercel.json create mode 100644 packages/app/vite.config.ts create mode 100644 packages/app/wagmi.config.ts create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..0f1786729 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.eslintrc.cjs b/.eslintrc.cjs new file mode 100644 index 000000000..85b630ebc --- /dev/null +++ b/.eslintrc.cjs @@ -0,0 +1,63 @@ +/* eslint-env node */ + +module.exports = { + root: true, + env: { browser: true, es2020: true }, + extends: ['typestrict', 'plugin:react/recommended', 'plugin:react-hooks/recommended', 'plugin:react/jsx-runtime'], + parser: '@typescript-eslint/parser', + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + project: true, + tsconfigRootDir: __dirname, + }, + plugins: ['react-refresh', 'simple-import-sort', 'unused-imports', 'import'], + rules: { + 'object-shorthand': ['error', 'always', { avoidQuotes: true }], + 'no-throw-literal': 'error', + 'func-style': ['error', 'declaration'], + 'react-refresh/only-export-components': ['error', { allowConstantExport: true }], + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { allowExpressions: true, allowDirectConstAssertionInArrowFunctions: true }, + ], + '@typescript-eslint/no-useless-constructor': 'error', + // this rule can't find automatically mistakes and needs to be guided + // 'import/no-internal-modules': ['error', { forbid: ['**/utils/*'] }], + 'import/no-useless-path-segments': ['error', { noUselessIndex: true }], + 'no-console': 'error', + 'no-debugger': 'error', + 'no-duplicate-imports': 'error', + 'no-with': 'error', + 'one-var': ['error', { initialized: 'never' }], + 'prefer-const': ['error', { destructuring: 'all' }], + 'simple-import-sort/exports': 'error', + 'simple-import-sort/imports': 'error', + 'unused-imports/no-unused-imports-ts': 'error', + 'no-restricted-imports': ['error', { + 'patterns': [{ + 'group': ['../../../**/*'], + 'message': 'consider using @ instead of going too many folders up.' + }] + }], + 'react/prop-types': 'off', + 'react/no-unescaped-entities': 'off', + 'react/jsx-curly-brace-presence': ['error', { props: 'never', children: 'never' }], + }, + overrides: [ + { + // react components don't have to have explicit return type + files: ['*.tsx'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + }, + }, + { + // react components don't have to have explicit return type + files: ['*.stories.tsx'], + rules: { + 'react-refresh/only-export-components': ['off'], + }, + }, + ], +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..58c1d1bf0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,141 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +# when new commit is pushed to a branch, cancel previous runs +# https://stackoverflow.com/a/67939898/580181 +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + build: + strategy: + matrix: + node: ['20'] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + - uses: pnpm/action-setup@v2 + with: + version: 8.5.0 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + - run: pnpm install + + - run: pnpm build + + - uses: krzkaczor/size-limit-action@master + if: github.ref != 'refs/heads/main' + with: + skip_step: build # already built + directory: packages/app + github_token: ${{ secrets.GITHUB_TOKEN }} + package_manager: pnpm + + test: + strategy: + matrix: + node: ['20'] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v2 + - uses: pnpm/action-setup@v2 + with: + version: 8.5.0 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + - run: pnpm install + + - run: pnpm lint + - run: pnpm format + - run: pnpm run test --coverage + - run: pnpm typecheck + + storybook-visual-regression: + strategy: + matrix: + node: ['20'] + os: [ubicloud-standard-4] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v2 + with: + version: 8.5.0 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + - run: pnpm install + + - name: Publish to Chromatic + uses: chromaui/action@latest + with: + workingDir: packages/app + buildScriptName: storybook:build + projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} + autoAcceptChanges: main + + test-e2e: + strategy: + fail-fast: false + matrix: + node: ['20'] + os: [ubicloud-standard-16] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v2 + - uses: pnpm/action-setup@v2 + with: + version: 8.5.0 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + - run: pnpm install + + - name: TMP fix MS repos + run: | + sudo rm /etc/apt/sources.list.d/microsoft-prod.list + sudo apt-get update + + - run: pnpm exec playwright install --with-deps chromium + working-directory: ./packages/app + - run: pnpm run test-e2e + working-directory: ./packages/app + env: + TENDERLY_API_KEY: '${{ secrets.TENDERLY_API_KEY }}' + TENDERLY_ACCOUNT: phoenixlabs + TENDERLY_PROJECT: sparklend + PLAYWRIGHT_TRACE: 1 + - name: Upload report to GitHub Actions Artifacts + if: failure() + uses: actions/upload-artifact@v3 + with: + name: test-e2e-report + path: packages/app/playwright-report + retention-days: 3 + + - uses: krzkaczor/reg-actions@action-v2 + with: + github-token: '${{ secrets.GITHUB_TOKEN }}' + image-directory-path: './packages/app/__screenshots-e2e__' + collection-name: 'e2e' + threshold-pixel: 5 + matching-threshold: 0.09 diff --git a/.github/workflows/flaky-e2e-detector.yml b/.github/workflows/flaky-e2e-detector.yml new file mode 100644 index 000000000..329cbb466 --- /dev/null +++ b/.github/workflows/flaky-e2e-detector.yml @@ -0,0 +1,48 @@ +name: Flaky E2E tests detector + +on: + pull_request: + paths: + - '**/*.test-e2e.*' + +# when new commit is pushed to a branch, cancel previous runs +# https://stackoverflow.com/a/67939898/580181 +concurrency: + group: flaky-e2e-detector-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + test-e2e: + strategy: + matrix: + node: ['20'] + os: [ubicloud-standard-16] + runs-on: ${{ matrix.os }} + timeout-minutes: 40 + steps: + - uses: actions/checkout@v2 + - uses: pnpm/action-setup@v2 + with: + version: 8.5.0 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + - run: pnpm install + - run: pnpm exec playwright install --with-deps chromium + working-directory: ./packages/app + - run: pnpm run test-e2e --repeat-each 4 --retries 2 + working-directory: ./packages/app + env: + TENDERLY_API_KEY: '${{ secrets.TENDERLY_API_KEY }}' + TENDERLY_ACCOUNT: phoenixlabs + TENDERLY_PROJECT: sparklend + PLAYWRIGHT_TRACE: 1 + + - name: Upload report to GitHub Actions Artifacts + if: failure() + uses: actions/upload-artifact@v3 + with: + name: flaky-test-e2e-report + path: packages/app/playwright-report + retention-days: 3 diff --git a/.github/workflows/ipfs-release.yml b/.github/workflows/ipfs-release.yml new file mode 100644 index 000000000..17c070aa8 --- /dev/null +++ b/.github/workflows/ipfs-release.yml @@ -0,0 +1,40 @@ +# Manually triggered IPFS release workflow +name: IPFS Release + +on: workflow_dispatch + +jobs: + ipfs-release: + strategy: + matrix: + node: ['20'] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + # a safe guard to ensure that the workflow is only triggered on the main branch + if: github.ref == 'refs/heads/main' + outputs: + pinata_hash: '${{ steps.pinata.outputs.hash }}' + + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - uses: pnpm/action-setup@v2 + with: + version: 8.5.0 + - uses: actions/setup-node@v2 + with: + node-version: ${{ matrix.node }} + cache: 'pnpm' + - run: pnpm install + - run: pnpm build + + - uses: phoenixlabsresearch/pinata-action@a3409e26f4cb859a2d9984109317caac53db5f68 + name: pinata + id: pinata + with: + PINATA_API_KEY: ${{ secrets.PINATA_API_KEY }} + PINATA_SECRET_KEY: ${{ secrets.PINATA_SECRET_KEY }} + PIN_ALIAS: 'app-beta-spark-${{ github.head_ref || github.ref }}' + BUILD_LOCATION: 'packages/app/dist' + CID_VERSION: 1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..6e59bf864 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +.vite +.env +packages/app/.swc +build-storybook.log diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..d0789f2f0 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +//registry.npmjs.org/:_authToken=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 \ No newline at end of file diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..2edeafb09 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 000000000..bb51c18a6 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +node_modules +.vscode +package.json +**/dist/** +.vite +packages/app/storybook-static +packages/app/__screenshots__ diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..ced163af1 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,10 @@ +{ + "printWidth": 120, + "tabWidth": 2, + "semi": false, + "singleQuote": true, + "trailingComma": "all", + "proseWrap": "always", + "plugins": ["prettier-plugin-tailwindcss"], + "tailwindFunctions": ["cva"] +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..f3d9e1519 --- /dev/null +++ b/LICENSE @@ -0,0 +1,14 @@ +Copyright 2024 Mars Foundation, a Cayman Islands exempted foundation company + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the “Software”), to deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit +persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the +Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE +WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 000000000..82008bf15 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# App + +[![CI](https://github.com/marsfoundation/app/actions/workflows/ci.yml/badge.svg)](https://github.com/marsfoundation/app/actions/workflows/ci.yml) + +[Production](https://app.spark.fi/) + +## Development + +```sh +pnpm install +pnpm run dev # runs application in dev mode +pnpm run storybook # runs storybook in dev mode +``` + +## Directory structure + +- `ui` folder should only contain reusable chunks of code responsible only for rendering, any data should be passed via + props. `atoms` consist from `radix-ui` primitives and html tags, `molecules` consist of `atoms`, `organisms` consist + of `molecules`. `layouts` defines general page layouts and screens. + [Atomic Design](https://bradfrost.com/blog/post/atomic-web-design/). + +- `domain` folder contains domain logic. Functions, hooks, types etc. It should contain all the business logic and data + fetching logic. If some chunks of code are reusable, consider putting it in the `common` subfolder. + +- `features` folder is the place where the code specific for a given feature (like easy borrow flow) should be placed. + It contains logic as well as pure feature specific visual components. It should export a single container (connected + component) that should wire logic with components. If the chunk of code is reusable (or is generic enough to be + reusable in the future), put it either in `ui` or `domain` following all the rules. + +- `config` folder contains only static (defined during compile time) objects that define the behaviour of the app. + +- `utils` folder contains reusable functions, hooks that are **not** domain specific. For domain specific stuff consider + `domain/common`. For strictly UI specific utils consider `ui/utils`. + +## Feature flags + +Feature flags need to be verified by using `import.meta.env.VITE_FEATURE_X` (and not some helper functions). Only then +dead code elimination for production builds work and disabled features will be entirely deleted in production build. + +Control feature flags via `.env` files: + +``` +VITE_FEATURE_X=1 +``` + +All feature flags are listed in `.env.example` file. + +### Integration tests + +Integration tests focus on testing integration between different components within the app and don't hit real (or +forked) blockchain node. We mock networking on viem's transport layer. + +```sh +pnpm test +pnpm run test --coverage # to get coverage report +``` + +### E2E tests + +E2E tests utilize Tenderly forks (because local nodes like anvil are too slow) to test real interactions with +blockchain. + +```sh +pnpm test-e2e +CI=true pnpm test-e2e # to check locally if E2E tests pass, it's fully parallel, will record trace and retry failed tests (we do the same on CI) +pnpm test-e2e:ui # to inspect in Playwright UI and debug in ephemeral chrome window +``` + +To control, from E2E test, the way application loads web3 wallets we inject wallet info via global object. To enable +this mechanism you need to start the app with: `pnpm run dev --mode playwright`. + +Forks are by default removed after tests, to persist it set `TENDERLY_PERSIST_FORK` env variable to `1`. + +To enable tracing for better CI debugging, set `PLAYWRIGHT_TRACE` env variable to `1`. You will able to download +playwright report with traces. The easiest way to explore it is to unpack the contents of `playwright-report` artifact +into the local folder `packages/app/playwright-report`, go to the folder `packages/app` and run + +```sh +pnpx playwright show-report +``` + +#### Deterministic Time in E2E Tests + +To ensure deterministic outcomes in e2e tests, we utilize a hardcoded future `simulationDate`, in both the browser +environment and on the forked blockchain. This approach is vital for accurately testing time-sensitive features, such as +LTV checks or fluctuating aToken values. We've chosen an arbitrary future date due to the current lack of more dynamic +time control methods on tenderly forks with idea that in near future we'll refactor to more robust solution. + +### Visual regression + +Storybook and e2e tests are visually tested. Every story is automatically tested. In E2E tests screenshots are made +explicitly. + +### i18n + +Read the [Lingui guide](https://lingui.dev/tutorials/setup-react) to understand how we translate the app. + +Note: right now we extract the strings right before building the app to ensure that production build works fine. Read +[this](https://github.com/lingui/js-lingui/issues/1803) issue to learn more. + +--- + +_The IP in this repository was assigned to Mars SPC Limited in respect of the MarsOne SP_ diff --git a/docs/bundle-size-optimizing-guide.md b/docs/bundle-size-optimizing-guide.md new file mode 100644 index 000000000..779a31295 --- /dev/null +++ b/docs/bundle-size-optimizing-guide.md @@ -0,0 +1,6 @@ +# Bundle size optimizing mini guide + +0. We track bundle size changes on each PR so changes that make bundle explode should be caught very early. +1. Run `pnpm build` and use `npx vite-bundle-visualizer` to understand what makes bundle size grow. +2. In most cases something is preventing tree shaking from doing its work. This can be a lack of `sideEffects: false` in + `package.json` or usage of CJS in one of the dependencies. Try updating the dep or patching it with `pnpm patch`. diff --git a/docs/hot-patching-modules.md b/docs/hot-patching-modules.md new file mode 100644 index 000000000..87f043659 --- /dev/null +++ b/docs/hot-patching-modules.md @@ -0,0 +1,8 @@ +# Hot patching `node_modules` + +Sometimes it's useful to tweak source code of a dependency to debug an issue or fix a bug. With `vite` it won't work +unless its cache is reloaded. You can enforce it with `--force` flag. + +```sh +pnpm dev --force +``` diff --git a/package.json b/package.json new file mode 100644 index 000000000..34efe0189 --- /dev/null +++ b/package.json @@ -0,0 +1,39 @@ +{ + "name": "app-monorepo", + "private": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, + "scripts": { + "dev": "pnpm run --filter './packages/app' dev", + "storybook": "pnpm run --filter './packages/app' storybook", + "format": "prettier --check \"./**/*.{js,ts,tsx}\" README.md", + "format:fix": "prettier --write \"./**/*.{js,ts,tsx}\" README.md", + "lint": "pnpm run --parallel --aggregate-output --reporter append-only --filter './packages/**' lint", + "lint:fix": "pnpm --parallel --aggregate-output --reporter append-only --filter './packages/**' lint:fix", + "typecheck": "pnpm --parallel --aggregate-output --reporter append-only --filter './packages/**' typecheck", + "build": "pnpm run --parallel --aggregate-output --reporter append-only --filter './packages/**' build", + "test": "pnpm run --parallel --aggregate-output --reporter append-only --filter './packages/**' test", + "fix": "pnpm lint:fix && pnpm format:fix && pnpm test && pnpm typecheck" + }, + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^5.61.0", + "@typescript-eslint/parser": "^5.61.0", + "eslint": "^8.44.0", + "eslint-config-typestrict": "^1.0.5", + "eslint-plugin-import": "^2.27.5", + "eslint-plugin-no-only-tests": "^3.1.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.1", + "eslint-plugin-simple-import-sort": "^10.0.0", + "eslint-plugin-sonarjs": "^0.19.0", + "eslint-plugin-unused-imports": "^2.0.0", + "prettier": "^3.2.5", + "prettier-plugin-tailwindcss": "^0.5.13", + "typescript": "^5.0.2", + "vitest": "^0.33.0" + } +} diff --git a/packages/app/.env.development b/packages/app/.env.development new file mode 100644 index 000000000..dd5f9444d --- /dev/null +++ b/packages/app/.env.development @@ -0,0 +1,11 @@ +VITE_FEATURE_AUTH_IP_AND_ADDRESS_CHECKS=0 +VITE_FEATURE_SANDBOX=1 +VITE_FEATURE_DEV_SANDBOX=1 +VITE_FEATURE_DISABLE_DAI_LEND=0 +VITE_FEATURE_TOS_REQUIRED=0 +VITE_FEATURE_TOP_BANNER=0 +VITE_DEV_ACTIONS_SETTINGS=1 +VITE_WALLET_CONNECT_ID='bd1843f2419f5d4c758366c55f9a556c' +VITE_ANALYTICS_PLAUSIBLE_ID='' + +VITE_API_URL=/api diff --git a/packages/app/.env.example b/packages/app/.env.example new file mode 100644 index 000000000..caa2859a2 --- /dev/null +++ b/packages/app/.env.example @@ -0,0 +1,19 @@ +# e2e tests +TENDERLY_API_KEY= +TENDERLY_ACCOUNT=phoenixlabs +TENDERLY_PROJECT=sparklend + +# feature flags +VITE_FEATURE_AUTH_IP_AND_ADDRESS_CHECKS=1 # enable VPN protection and user address verification +VITE_FEATURE_SANDBOX=1 # enable sandbox using tenderly +VITE_FEATURE_DEV_SANDBOX=1 # enable dev sandbox using tenderly - doesn't create ephemeral account +VITE_FEATURE_DISABLE_DAI_LEND=1 # disable button allowing to deposit directly to DAI market +VITE_FEATURE_TOS_REQUIRED=1 # require user to accept terms of service when connecting wallet for the first time +VITE_FEATURE_TOP_BANNER=1 # enable top banner with information about beta version + +# development +VITE_DEV_ACTIONS_SETTINGS=0 # enable actions settings button in dev mode + +VITE_WALLET_CONNECT_ID='bd1843f2419f5d4c758366c55f9a556c' +VITE_ANALYTICS_PLAUSIBLE_ID='' # leave empty to disable analytics +VITE_API_URL=/api diff --git a/packages/app/.env.playwright b/packages/app/.env.playwright new file mode 100644 index 000000000..f331eb03a --- /dev/null +++ b/packages/app/.env.playwright @@ -0,0 +1 @@ +VITE_PLAYWRIGHT=true diff --git a/packages/app/.env.production b/packages/app/.env.production new file mode 100644 index 000000000..1629e41b6 --- /dev/null +++ b/packages/app/.env.production @@ -0,0 +1,11 @@ +VITE_FEATURE_AUTH_IP_AND_ADDRESS_CHECKS=1 +VITE_FEATURE_SANDBOX=1 +VITE_FEATURE_DEV_SANDBOX=0 +VITE_FEATURE_DISABLE_DAI_LEND=1 +VITE_FEATURE_TOS_REQUIRED=1 +VITE_FEATURE_TOP_BANNER=1 +VITE_DEV_ACTIONS_SETTINGS=0 +VITE_WALLET_CONNECT_ID='d6f7c4929a1acfeaa020bc2ee57586b3' +VITE_ANALYTICS_PLAUSIBLE_ID='new-app.spark.fi' + +VITE_API_URL=https://api.spark.fi/v2 diff --git a/packages/app/.env.staging b/packages/app/.env.staging new file mode 100644 index 000000000..2614858bd --- /dev/null +++ b/packages/app/.env.staging @@ -0,0 +1,11 @@ +VITE_FEATURE_AUTH_IP_AND_ADDRESS_CHECKS=1 +VITE_FEATURE_SANDBOX=1 +VITE_FEATURE_DEV_SANDBOX=1 +VITE_FEATURE_DISABLE_DAI_LEND=1 +VITE_FEATURE_TOS_REQUIRED=1 +VITE_FEATURE_TOP_BANNER=1 +VITE_DEV_ACTIONS_SETTINGS=1 +VITE_WALLET_CONNECT_ID='bd1843f2419f5d4c758366c55f9a556c' +VITE_ANALYTICS_PLAUSIBLE_ID='' + +VITE_API_URL=/api diff --git a/packages/app/.env.storybook b/packages/app/.env.storybook new file mode 100644 index 000000000..0493a4191 --- /dev/null +++ b/packages/app/.env.storybook @@ -0,0 +1,3 @@ +VITE_FEATURE_AUTH=0 +VITE_FEATURE_SANDBOX=0 +STORYBOOK_PREVIEW=1 diff --git a/packages/app/.gitignore b/packages/app/.gitignore new file mode 100644 index 000000000..4df551b07 --- /dev/null +++ b/packages/app/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +/test-results/ +/playwright-report/ +/playwright/.cache/ +storybook-static +__screenshots__ +__screenshots-e2e__ +coverage \ No newline at end of file diff --git a/packages/app/.storybook/DevContainer.tsx b/packages/app/.storybook/DevContainer.tsx new file mode 100644 index 000000000..a75dab75c --- /dev/null +++ b/packages/app/.storybook/DevContainer.tsx @@ -0,0 +1,46 @@ +import { lightTheme, RainbowKitProvider } from '@rainbow-me/rainbowkit' +import { QueryClientProvider } from '@tanstack/react-query' +import { Suspense } from 'react' +import { WagmiProvider } from 'wagmi' + +import { queryClient } from '@/config/query-client' +import { getConfig } from '@/config/wagmi' +import { I18nAppProvider } from '@/domain/i18n/I18nAppProvider' +import { TooltipProvider } from '@/ui/atoms/tooltip/Tooltip' + +import { StorybookErrorBoundary } from './ErrorBoundary' + +interface DevContainerProps { + children: React.ReactNode +} +/** + * Helpful for developing connected components using Storybook. + */ +export function DevContainer({ children }: DevContainerProps) { + const config = getConfig() + + return ( + + + + + + + }>{children} + + + + + + + ) +} + +function Loading() { + return
Loading...
+} diff --git a/packages/app/.storybook/ErrorBoundary.tsx b/packages/app/.storybook/ErrorBoundary.tsx new file mode 100644 index 000000000..4758b0d39 --- /dev/null +++ b/packages/app/.storybook/ErrorBoundary.tsx @@ -0,0 +1,44 @@ +/* eslint-disable react-refresh/only-export-components */ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import React from 'react' + +import { NotConnectedError } from '../src/domain/errors/not-connected' +import { Button } from '../src/ui/atoms/button/Button' + +interface ErrorBoundaryProps { + children: React.ReactNode +} + +interface ErrorBoundaryState { + error: Error | undefined +} + +export class StorybookErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = { error: undefined } + } + + static getDerivedStateFromError(error: any): ErrorBoundaryState { + return { + error, + } + } + + render() { + if (this.state.error) { + return + } + + return this.props.children + } +} + +function ErrorComponent({ error }: { error: Error }) { + const { openConnectModal } = useConnectModal() + + if (error instanceof NotConnectedError) { + return + } + return <>Unknown error: {error.message} +} diff --git a/packages/app/.storybook/decorators.tsx b/packages/app/.storybook/decorators.tsx new file mode 100644 index 000000000..a60929562 --- /dev/null +++ b/packages/app/.storybook/decorators.tsx @@ -0,0 +1,118 @@ +import { StoryFn } from '@storybook/react' +import { QueryClient, QueryClientConfig, QueryClientProvider } from '@tanstack/react-query' +import { useEffect } from 'react' +import { custom, encodeFunctionResult, zeroAddress } from 'viem' +import { WagmiProvider, createConfig, useAccount, useConnect } from 'wagmi' + +import { I18nAppProvider } from '@/domain/i18n/I18nAppProvider' +import { TooltipProvider } from '@/ui/atoms/tooltip/Tooltip' + +import { DevContainer } from './DevContainer' +import { erc20Abi } from 'viem' +import { mainnet } from 'viem/chains' +import { mock } from 'wagmi/connectors' + +export function WithTooltipProvider() { + return function WithTooltipProvider(Story: StoryFn) { + return ( + + + + ) + } +} + +export function WithClassname(classname: string) { + return function WithClassname(Story: StoryFn) { + return ( +
+ +
+ ) + } +} + +export function WithDevContainer() { + return function WithDevContainer(Story: StoryFn) { + return ( + + + + ) + } +} + +export function ZeroAllowanceWagmiDecorator() { + const config = createConfig({ + chains: [mainnet], + transports: { + [mainnet.id]: custom({ + request: async (): Promise => { + return encodeFunctionResult({ + abi: erc20Abi, + functionName: 'allowance', + result: 0n, + }) + }, + }), + }, + connectors: [ + mock({ + accounts: [zeroAddress], + }), + ], + batch: { + multicall: false, + }, + }) + + // explicitly retries connection if it fails when auto connecting. This is the case for incognito mode + function ForceConnectWrapper({ children }: { children: React.ReactNode }) { + const { connect, connectors } = useConnect() + const { address } = useAccount() + useEffect(() => { + if (!address) { + connect({ + connector: connectors[0]!, + }) + } + }, [address, connect, connectors]) + + if (!address) { + return
Loading account
+ } + return <>{children} + } + + return function ZeroAllowanceWagmiDecorator(Story: StoryFn) { + return ( + + + + + + ) + } +} + +export function WithI18n() { + return function WithI18n(Story: StoryFn) { + return ( + + + + ) + } +} + +export function WithQueryClient(config?: QueryClientConfig) { + const queryClient = new QueryClient(config) + + return function WithQueryClient(Story: StoryFn) { + return ( + + + + ) + } +} diff --git a/packages/app/.storybook/main.ts b/packages/app/.storybook/main.ts new file mode 100644 index 000000000..75a08402b --- /dev/null +++ b/packages/app/.storybook/main.ts @@ -0,0 +1,41 @@ +import type { StorybookConfig } from '@storybook/react-vite' +import { mergeConfig } from 'vite' +import svgr from 'vite-plugin-svgr' +import { join } from 'path' +import dotenv from 'dotenv' + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-styling', + 'storybook-addon-react-router-v6', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + env: () => { + const env = dotenv.config({ path: join(__dirname, '../.env.storybook') }) + if (env.error) { + throw env.error + } + return env.parsed! + }, + viteFinal: (config) => + mergeConfig(config, { + plugins: [svgr()], + }), +} +export default config + +// This addon can cause random Chrome crashes ("Snap!" errors) but works well in production. +// We need it to be able to force components into desired states for screenshots +if (process.env.NODE_ENV === 'production') { + config.addons!.push('storybook-addon-pseudo-states') +} diff --git a/packages/app/.storybook/preview.ts b/packages/app/.storybook/preview.ts new file mode 100644 index 000000000..f53851be2 --- /dev/null +++ b/packages/app/.storybook/preview.ts @@ -0,0 +1,60 @@ +import { withThemeByClassName } from '@storybook/addon-styling' +import { WithI18n, WithQueryClient } from './decorators' + +import '../src/css/fonts.css' +import '../src/css/main.css' +import { Preview } from '@storybook/react' + +const preview: Preview = { + parameters: { + actions: { argTypesRegex: '^on[A-Z].*' }, + controls: { + matchers: { + color: /(background|color)$/i, + date: /Date$/, + }, + }, + viewport: { + viewports: { + mobile: { + name: 'Mobile', + styles: { + width: '375px', + height: '667px', + }, + }, + tablet: { + name: 'Tablet', + styles: { + width: '767px', + height: '1024px', + }, + }, + desktop: { + name: 'Desktop', + styles: { + width: '1440px', + height: '1024px', + }, + }, + }, + }, + }, + + decorators: [ + WithI18n(), + WithQueryClient(), + // Adds theme switching support. + // NOTE: requires setting "darkMode" to "class" in your tailwind config + // NOTE: order matters, this decorator must be the last one + withThemeByClassName({ + themes: { + light: 'light', + dark: 'dark', + }, + defaultTheme: 'light', + }), + ], +} + +export default preview diff --git a/packages/app/.storybook/tokens.ts b/packages/app/.storybook/tokens.ts new file mode 100644 index 000000000..cd00f67e0 --- /dev/null +++ b/packages/app/.storybook/tokens.ts @@ -0,0 +1,90 @@ +import { CheckedAddress } from '../src/domain/types/CheckedAddress' +import { Token } from '../src/domain/types/Token' +import { TokenSymbol } from '../src/domain/types/TokenSymbol' + +export const tokens = { + ETH: new Token({ + unitPriceUsd: '2235.0672', + symbol: TokenSymbol('ETH'), + name: 'Ether', + decimals: 18, + address: CheckedAddress('0x7D5afF7ab67b431cDFA6A94d50d3124cC4AB2611'), + }), + DAI: new Token({ + unitPriceUsd: '1.00001023', + symbol: TokenSymbol('DAI'), + name: 'Dai Stablecoin', + decimals: 18, + address: CheckedAddress('0x11fE4B6AE13d2a6055C8D9cF65c55bac32B5d844'), + }), + sDAI: new Token({ + unitPriceUsd: '1.02847014', + symbol: TokenSymbol('sDAI'), + name: 'Savings Dai', + decimals: 18, + address: CheckedAddress('0xD8134205b0328F5676aaeFb3B2a0DC15f4029d8C'), + }), + USDC: new Token({ + unitPriceUsd: '1', + symbol: TokenSymbol('USDC'), + name: 'USDC', + decimals: 6, + address: CheckedAddress('0x6Fb5ef893d44F4f88026430d82d4ef269543cB23'), + }), + WETH: new Token({ + unitPriceUsd: '2235.0672', + symbol: TokenSymbol('wETH'), + name: 'Wrapped Ether', + decimals: 18, + address: CheckedAddress('0x7D5afF7ab67b431cDFA6A94d50d3124cC4AB2611'), + }), + wstETH: new Token({ + unitPriceUsd: '2235.0672', + symbol: TokenSymbol('wstETH'), + name: 'Wrapped liquid staked Ether 2.0', + decimals: 18, + address: CheckedAddress('0x6E4F1e8d4c5E5E6e2781FD814EE0744cc16Eb352'), + }), + stETH: new Token({ + unitPriceUsd: '2235.0672', + symbol: TokenSymbol('stETH'), + name: 'Liquid staked Ether 2.0', + decimals: 18, + address: CheckedAddress('0x6E4F1e8d4c5E5E6e2781FD814EE0744cc16Eb352'), + }), + WBTC: new Token({ + unitPriceUsd: '42189.925', + symbol: TokenSymbol('WBTC'), + name: 'Wrapped BTC', + decimals: 8, + address: CheckedAddress('0x91277b74a9d1Cc30fA0ff4927C287fe55E307D78'), + }), + GNO: new Token({ + unitPriceUsd: '99.98724155', + symbol: TokenSymbol('GNO'), + name: 'Gnosis', + decimals: 18, + address: CheckedAddress('0x86Bc432064d7F933184909975a384C7E4c9d0977'), + }), + rETH: new Token({ + unitPriceUsd: '2235.0672', + symbol: TokenSymbol('rETH'), + name: 'Rocket Pool ETH', + decimals: 18, + address: CheckedAddress('0x62BC478FFC429161115A6E4090f819CE5C50A5d9'), + }), + MKR: new Token({ + unitPriceUsd: '1403.75', + symbol: TokenSymbol('MKR'), + name: 'Maker', + decimals: 18, + address: CheckedAddress('0x62BC478FFC429161115A6E4090f819CE5C50A5d9'), + }), + USDT: new Token({ + unitPriceUsd: '1', + symbol: TokenSymbol('USDT'), + name: 'Tether USD', + decimals: 18, + address: CheckedAddress('0x62BC478FFC429161115A6E4090f819CE5C50A5d9'), + }), +} diff --git a/packages/app/.storybook/utils.ts b/packages/app/.storybook/utils.ts new file mode 100644 index 000000000..eb6485816 --- /dev/null +++ b/packages/app/.storybook/utils.ts @@ -0,0 +1,14 @@ +import { StoryObj } from '@storybook/react' +import { userEvent, within } from '@storybook/testing-library' +import { ByRoleMatcher } from '@testing-library/react' + +export function getHoveredStory(story: StoryObj, role: ByRoleMatcher): StoryObj { + return { + ...story, + play: async ({ canvasElement }: { canvasElement: HTMLElement }) => { + await userEvent.hover(await within(canvasElement).findByRole(role)) + }, + } +} + +export const fakeBigInt = 0 as any as bigint // @note: storybook does not support BigInt diff --git a/packages/app/.storybook/viewports.ts b/packages/app/.storybook/viewports.ts new file mode 100644 index 000000000..d667f134c --- /dev/null +++ b/packages/app/.storybook/viewports.ts @@ -0,0 +1,30 @@ +import { StoryObj } from '@storybook/react' + +export const chromatic = { + mobile: 375, + tablet: 767, +} as const + +export function getMobileStory(story: StoryObj): StoryObj { + return { + ...story, + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, + } +} + +export function getTabletStory(story: StoryObj): StoryObj { + return { + ...story, + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + chromatic: { viewports: [chromatic.tablet] }, + }, + } +} diff --git a/packages/app/index.html b/packages/app/index.html new file mode 100644 index 000000000..e3d7f39e8 --- /dev/null +++ b/packages/app/index.html @@ -0,0 +1,31 @@ + + + + + + + + + + Spark + + + + + + + + + + + + + + +
+ + + diff --git a/packages/app/lingui.config.ts b/packages/app/lingui.config.ts new file mode 100644 index 000000000..f4bfd6146 --- /dev/null +++ b/packages/app/lingui.config.ts @@ -0,0 +1,13 @@ +import type { LinguiConfig } from '@lingui/conf' + +const config: LinguiConfig = { + locales: ['en', 'pl'], + catalogs: [ + { + path: '/src/locales/{locale}', + include: ['src'], + }, + ], +} + +export default config diff --git a/packages/app/package.json b/packages/app/package.json new file mode 100644 index 000000000..5cad773cc --- /dev/null +++ b/packages/app/package.json @@ -0,0 +1,146 @@ +{ + "name": "app", + "private": true, + "version": "1.0.0", + "type": "module", + "engines": { + "node": ">=18.0.0", + "pnpm": ">=8.0.0" + }, + "scripts": { + "dev": "vite --port 4000", + "build": "tsc && pnpm run lingui:extract && vite build", + "build:staging": "pnpm run build --mode staging", + "format": "cd ../.. && pnpm run format", + "format:fix": "cd ../.. && pnpm run format:fix", + "lint": "eslint --max-warnings=0 --ext ts,tsx --report-unused-disable-directives src", + "lint:fix": "pnpm lint --fix", + "test": "DEBUG_PRINT_LIMIT=100000 vitest --run", + "test-e2e": "playwright test", + "test-e2e:ui": "pnpm test-e2e --ui --headed", + "typecheck": "tsc --noEmit", + "storybook": "storybook dev -p 6006", + "storybook:build": "pnpm run lingui:extract && storybook build", + "size": "pnpm run build && size-limit", + "lingui:extract": "lingui extract", + "chromatic": "npx chromatic --build-script-name storybook:build --exit-zero-on-changes" + }, + "dependencies": { + "@aave/math-utils": "^1.20.0", + "@hookform/resolvers": "^3.3.2", + "@jetstreamgg/hooks": "^1.0.6", + "@lingui/core": "^4.5.0", + "@lingui/macro": "^4.5.0", + "@lingui/react": "^4.5.0", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-portal": "^1.0.4", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", + "@rainbow-me/rainbowkit": "^2.0.5", + "@size-limit/file": "^10.0.1", + "@tanstack/react-query": "^5.28.6", + "@tanstack/react-table": "^8.10.7", + "@viem/anvil": "^0.0.6", + "@visx/annotation": "^3.3.0", + "@visx/axis": "^3.8.0", + "@visx/curve": "^3.3.0", + "@visx/event": "^3.3.0", + "@visx/grid": "^3.5.0", + "@visx/group": "^3.3.0", + "@visx/scale": "^3.5.0", + "@visx/shape": "^3.5.0", + "@visx/text": "^3.3.0", + "@visx/tooltip": "^3.3.0", + "bignumber.js": "^9.1.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "d3-array": "^3.2.4", + "deepmerge-ts": "^5.1.0", + "fetch-retry": "^5.0.6", + "jsbi": "^3.2.5", + "lucide-react": "^0.284.0", + "react": "^18.2.0", + "react-confetti": "^6.1.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.48.2", + "react-hot-toast": "^2.4.1", + "react-router-dom": "^6.14.2", + "size-limit": "^10.0.1", + "storybook-addon-react-router-v6": "^2.0.8", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.6", + "viem": "^2.9.21", + "wagmi": "^2.5.20", + "zod": "^3.22.4", + "zustand": "^4.4.1" + }, + "devDependencies": { + "@lingui/cli": "^4.5.0", + "@lingui/conf": "^4.5.0", + "@lingui/swc-plugin": "^4.0.4", + "@lingui/vite-plugin": "^4.5.0", + "@playwright/test": "^1.43.0", + "@storybook/addon-essentials": "^7.5.1", + "@storybook/addon-interactions": "^7.5.1", + "@storybook/addon-links": "^7.5.1", + "@storybook/addon-styling": "^1.3.7", + "@storybook/blocks": "^7.5.1", + "@storybook/jest": "^0.2.3", + "@storybook/react": "^7.5.1", + "@storybook/react-vite": "^7.5.1", + "@storybook/testing-library": "^0.2.2", + "@testing-library/jest-dom": "^5.16.5", + "@testing-library/react": "^14.0.0", + "@testing-library/user-event": "^14.4.3", + "@total-typescript/ts-reset": "^0.5.1", + "@types/d3-array": "^3.2.1", + "@types/jsdom": "^21.1.1", + "@types/lokijs": "^1.5.8", + "@types/node": "^20.4.10", + "@types/react": "^18.2.14", + "@types/react-dom": "^18.2.6", + "@types/testing-library__jest-dom": "^5.14.8", + "@vitejs/plugin-react-swc": "^3.3.2", + "@vitest/coverage-v8": "^0.34.6", + "@wagmi/cli": "^2.1.1", + "autoprefixer": "^10.4.14", + "axios": "^1.6.0", + "chromatic": "^10.1.0", + "dotenv": "^16.3.1", + "http-server": "^14.1.1", + "jsdom": "^22.1.0", + "lokijs": "^1.5.12", + "postcss": "^8.4.26", + "prop-types": "^15.8.1", + "serve": "^14.2.1", + "storybook": "^7.5.1", + "storybook-addon-pseudo-states": "^2.1.2", + "tailwindcss": "^3.4.3", + "tiny-invariant": "^1.3.1", + "ts-essentials": "^9.4.2", + "vite": "^4.5.0", + "vite-plugin-svgr": "^4.2.0", + "vite-tsconfig-paths": "^4.3.1" + }, + "size-limit": [ + { + "name": "JS", + "path": "dist/assets/*.js" + }, + { + "name": "CSS", + "path": "dist/assets/*.css" + } + ] +} diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts new file mode 100644 index 000000000..9d80c9361 --- /dev/null +++ b/packages/app/playwright.config.ts @@ -0,0 +1,40 @@ +import 'dotenv/config' +import { defineConfig, devices } from '@playwright/test' + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './src', + testMatch: '**/*.test-e2e.ts', + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 3 : 0, + // CI will use all available cores + workers: process.env.CI || process.env.PLAYWRIGHT_PARALLEL ? '100%' : 1, + reporter: 'html', + use: { + baseURL: 'http://127.0.0.1:4000', + trace: process.env.PLAYWRIGHT_TRACE === '1' ? 'on' : 'off', + video: process.env.PLAYWRIGHT_TRACE === '1' ? 'retain-on-failure' : 'off', + }, + expect: { + timeout: 20_000, + }, + timeout: 60_000 * 3, // sometimes tenderly can be slow + maxFailures: undefined, // don't use this as it doesn't respect retires + + webServer: { + command: 'pnpm build --mode playwright && pnpm exec serve dist -sL -p 4000', + port: 4000, + reuseExistingServer: true, + timeout: 90_000, + }, + fullyParallel: process.env.CI, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}) diff --git a/packages/app/postcss.config.js b/packages/app/postcss.config.js new file mode 100644 index 000000000..2e7af2b7f --- /dev/null +++ b/packages/app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/packages/app/public/_redirects b/packages/app/public/_redirects new file mode 100644 index 000000000..7797f7c6a --- /dev/null +++ b/packages/app/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/packages/app/public/apple-touch-icon.png b/packages/app/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..75e3754e4913cfbe2daca3923f8ad5f77b7c2154 GIT binary patch literal 10983 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 Ve{FF*X;c6J002ovPDHLkV1hw^7Zv~j literal 0 HcmV?d00001 diff --git a/packages/app/public/favicon-16x16.png b/packages/app/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..4410a5788345b720224afb660b70f907a2345237 GIT binary patch literal 540 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 ZwHg1(`o`C%WB%4A?Kj~!uH94a{|6=B07L)) literal 0 HcmV?d00001 diff --git a/packages/app/public/spark-meta-logo.jpg b/packages/app/public/spark-meta-logo.jpg new file mode 100644 index 0000000000000000000000000000000000000000..59cf9d4e7884ddd78d0fdd1b320f253536a984d5 GIT binary patch literal 48826 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

G{?IVE~J#!2=KIVgV4Wv_q zIiMggb}&-wuY8vft%8LOd(*5gbI=rfD|xC30jyujCO%fLHmtL{IcDBQWNw2y3QLtp z1Q^NKyD5&drE$UY;>FUdMYEm_c{;;a4p%#0m2+kEOiEq=TJIHFOLFoNwoG>2PZD&N zZ42&otf=tfcZ7?+^=>RRw{7Ye2Z}O!60o7zlM#{BjekAOrQz>QNZl{FhN?w}M2O=~ zwPhsFBJ1whHhffRV!d80Q$;<~y)Tz;Jlmrfz^5_%`$clZe(~_g&^sF6hjFhBfHI|o zn`X86 z6o=R)0m0sReF^Noho|ZUP#Aaf==T>k2XK;XkYKEk(B)*RL*4#9X!0G)WgZPwfwiKF=V`afw72ZeldefVR#4 ze(1X`IYhMB&p#GNd4PM1lN%Vp65ND-#z!md)LR)?LzGeQk$u`?4ytFMl;rMY53S{! z!{6`hveaS}YPk<+xwTjN@Hx8qtP$7;cvH9-si|ytSty57l1#VLKY?)9|0?kd2d{Vc z;)R}`<54GjV|Zc3M6PL+Yi&I@PH?`u8E3E1A??_)KKu_BRmkg%#F3dsKp<%PsKSGO zdHJL8SZaRH?sE4LsUiQewdVM%w{y75PeNXB!cVcU_H#MA4Vk(DqmAQ&mPSFqk>okd z-@gBSp1TB_r*b&mA7VRs_!&w;5Jp7wHRiEHtxbez^4M{)i#iIffq+K=G26_A$9j`= z`Sn%T^2ByC=*-TplbKLDqhrUslklU;*?IuYiX6yY)ls4*9qK!tRbuVliJq`8VM@)+ zEc(uL%+}B=45?(gzzrUo6GGWMM~|n~Xs$c^pQydNw*L;e4-{c5Wx^YJvi1#5CNLvQ z>i9)N1N_)SbRGi;HS|+;4`4D#J2nm3s%HGRUxy-5#gL)w{I%RPkj>qES=j57dmMqq zzUAel70zT^+gG=Wj57_S&xx#R1W=Dxf?v{ntuz!^5zvIScSW=;C+Fx!XA%^u z2$l6Ue%kwFg}Ya`u;;YPm= z*{K#P_W(iUOc+ES&yu!@11E1S8>ttB}?G9B>l= literal 0 HcmV?d00001 diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx new file mode 100644 index 000000000..40b68407f --- /dev/null +++ b/packages/app/src/App.tsx @@ -0,0 +1,52 @@ +import '@rainbow-me/rainbowkit/styles.css' + +import { lightTheme, RainbowKitProvider } from '@rainbow-me/rainbowkit' +import { QueryClientProvider } from '@tanstack/react-query' +import { Toaster } from 'react-hot-toast' +import { RouterProvider } from 'react-router-dom' +import { WagmiProvider } from 'wagmi' + +import { getConfig } from '@/config/wagmi' + +import { queryClient } from './config/query-client' +import { useViteErrorOverlay } from './domain/errors/useViteErrorOverlay' +import { I18nAppProvider } from './domain/i18n/I18nAppProvider' +import { useStore } from './domain/state' +import { useAutoConnect } from './domain/wallet/useAutoConnect' +import { rootRouter } from './RootRouter' +import { TooltipProvider } from './ui/atoms/tooltip/Tooltip' + +function App() { + const sandboxNetwork = useStore((state) => state.sandbox.network) + const config = getConfig(sandboxNetwork) + if (import.meta.env.VITE_PLAYWRIGHT || import.meta.env.MODE === 'development') { + // eslint-disable-next-line react-hooks/rules-of-hooks + useAutoConnect({ config }) + } + if (import.meta.env.MODE === 'development') { + // eslint-disable-next-line react-hooks/rules-of-hooks + useViteErrorOverlay() + } + + return ( + + + + + + + + + + + + + ) +} + +export default App diff --git a/packages/app/src/RootRouter.tsx b/packages/app/src/RootRouter.tsx new file mode 100644 index 000000000..5d5e93cfc --- /dev/null +++ b/packages/app/src/RootRouter.tsx @@ -0,0 +1,49 @@ +import { createBrowserRouter } from 'react-router-dom' + +import { paths } from './config/paths' +import { ErrorContainer } from './features/errors/ErrorContainer' +import { NotFound } from './features/errors/NotFound' +import { EasyBorrowPage } from './pages/Borrow' +import { DashboardPage } from './pages/Dashboard' +import { MarketDetails } from './pages/MarketDetails' +import { Markets } from './pages/Markets' +import { RootRoute } from './pages/Root' +import { Savings } from './pages/Savings' + +export const rootRouter = createBrowserRouter([ + { + path: '/', + element: , + children: [ + { + errorElement: , + children: [ + { + path: paths.easyBorrow, + element: , + }, + { + path: paths.dashboard, + element: , + }, + { + path: paths.savings, + element: , + }, + { + path: paths.markets, + element: , + }, + { + path: paths.marketDetails, + element: , + }, + { + path: '*', + element: , + }, + ], + }, + ], + }, +]) diff --git a/packages/app/src/config/abis/debtTokenAbi.ts b/packages/app/src/config/abis/debtTokenAbi.ts new file mode 100644 index 000000000..a229d742b --- /dev/null +++ b/packages/app/src/config/abis/debtTokenAbi.ts @@ -0,0 +1,44 @@ +export const debtTokenAbi = [ + { + inputs: [ + { + internalType: 'address', + name: 'delegatee', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'approveDelegation', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'fromUser', + type: 'address', + }, + { + internalType: 'address', + name: 'toUser', + type: 'address', + }, + ], + name: 'borrowAllowance', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, +] as const diff --git a/packages/app/src/config/abis/poolAbi.ts b/packages/app/src/config/abis/poolAbi.ts new file mode 100644 index 000000000..ffccc8bea --- /dev/null +++ b/packages/app/src/config/abis/poolAbi.ts @@ -0,0 +1,1616 @@ +export const poolAbi = [ + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'backer', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'fee', + type: 'uint256', + }, + ], + name: 'BackUnbacked', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'enum DataTypes.InterestRateMode', + name: 'interestRateMode', + type: 'uint8', + }, + { + indexed: false, + internalType: 'uint256', + name: 'borrowRate', + type: 'uint256', + }, + { + indexed: true, + internalType: 'uint16', + name: 'referral', + type: 'uint16', + }, + ], + name: 'Borrow', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'target', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'initiator', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'enum DataTypes.InterestRateMode', + name: 'interestRateMode', + type: 'uint8', + }, + { + indexed: false, + internalType: 'uint256', + name: 'premium', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + ], + name: 'FlashLoan', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'totalDebt', + type: 'uint256', + }, + ], + name: 'IsolationModeTotalDebtUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'collateralAsset', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'debtAsset', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'debtToCover', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'liquidatedCollateralAmount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'address', + name: 'liquidator', + type: 'address', + }, + { + indexed: false, + internalType: 'bool', + name: 'receiveAToken', + type: 'bool', + }, + ], + name: 'LiquidationCall', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + indexed: true, + internalType: 'uint16', + name: 'referral', + type: 'uint16', + }, + ], + name: 'MintUnbacked', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amountMinted', + type: 'uint256', + }, + ], + name: 'MintedToTreasury', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'RebalanceStableBorrowRate', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'repayer', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + indexed: false, + internalType: 'bool', + name: 'useATokens', + type: 'bool', + }, + ], + name: 'Repay', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'liquidityRate', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'stableBorrowRate', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'variableBorrowRate', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'liquidityIndex', + type: 'uint256', + }, + { + indexed: false, + internalType: 'uint256', + name: 'variableBorrowIndex', + type: 'uint256', + }, + ], + name: 'ReserveDataUpdated', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'ReserveUsedAsCollateralDisabled', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'ReserveUsedAsCollateralEnabled', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: false, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + indexed: true, + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + ], + name: 'Supply', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: false, + internalType: 'enum DataTypes.InterestRateMode', + name: 'interestRateMode', + type: 'uint8', + }, + ], + name: 'SwapBorrowRateMode', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: false, + internalType: 'uint8', + name: 'categoryId', + type: 'uint8', + }, + ], + name: 'UserEModeSet', + type: 'event', + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: 'address', + name: 'reserve', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'user', + type: 'address', + }, + { + indexed: true, + internalType: 'address', + name: 'to', + type: 'address', + }, + { + indexed: false, + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + ], + name: 'Withdraw', + type: 'event', + }, + { + inputs: [], + name: 'ADDRESSES_PROVIDER', + outputs: [ + { + internalType: 'contract IPoolAddressesProvider', + name: '', + type: 'address', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'BRIDGE_PROTOCOL_FEE', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'FLASHLOAN_PREMIUM_TOTAL', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'FLASHLOAN_PREMIUM_TO_PROTOCOL', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'MAX_NUMBER_RESERVES', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'MAX_STABLE_RATE_BORROW_SIZE_PERCENT', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'fee', + type: 'uint256', + }, + ], + name: 'backUnbacked', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'interestRateMode', + type: 'uint256', + }, + { + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + ], + name: 'borrow', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint8', + name: 'id', + type: 'uint8', + }, + { + components: [ + { + internalType: 'uint16', + name: 'ltv', + type: 'uint16', + }, + { + internalType: 'uint16', + name: 'liquidationThreshold', + type: 'uint16', + }, + { + internalType: 'uint16', + name: 'liquidationBonus', + type: 'uint16', + }, + { + internalType: 'address', + name: 'priceSource', + type: 'address', + }, + { + internalType: 'string', + name: 'label', + type: 'string', + }, + ], + internalType: 'struct DataTypes.EModeCategory', + name: 'config', + type: 'tuple', + }, + ], + name: 'configureEModeCategory', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + ], + name: 'deposit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + ], + name: 'dropReserve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'address', + name: 'from', + type: 'address', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'balanceFromBefore', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'balanceToBefore', + type: 'uint256', + }, + ], + name: 'finalizeTransfer', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'receiverAddress', + type: 'address', + }, + { + internalType: 'address[]', + name: 'assets', + type: 'address[]', + }, + { + internalType: 'uint256[]', + name: 'amounts', + type: 'uint256[]', + }, + { + internalType: 'uint256[]', + name: 'interestRateModes', + type: 'uint256[]', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + internalType: 'bytes', + name: 'params', + type: 'bytes', + }, + { + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + ], + name: 'flashLoan', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'receiverAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'bytes', + name: 'params', + type: 'bytes', + }, + { + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + ], + name: 'flashLoanSimple', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + ], + name: 'getConfiguration', + outputs: [ + { + components: [ + { + internalType: 'uint256', + name: 'data', + type: 'uint256', + }, + ], + internalType: 'struct DataTypes.ReserveConfigurationMap', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint8', + name: 'id', + type: 'uint8', + }, + ], + name: 'getEModeCategoryData', + outputs: [ + { + components: [ + { + internalType: 'uint16', + name: 'ltv', + type: 'uint16', + }, + { + internalType: 'uint16', + name: 'liquidationThreshold', + type: 'uint16', + }, + { + internalType: 'uint16', + name: 'liquidationBonus', + type: 'uint16', + }, + { + internalType: 'address', + name: 'priceSource', + type: 'address', + }, + { + internalType: 'string', + name: 'label', + type: 'string', + }, + ], + internalType: 'struct DataTypes.EModeCategory', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + ], + name: 'getReserveData', + outputs: [ + { + components: [ + { + components: [ + { + internalType: 'uint256', + name: 'data', + type: 'uint256', + }, + ], + internalType: 'struct DataTypes.ReserveConfigurationMap', + name: 'configuration', + type: 'tuple', + }, + { + internalType: 'uint128', + name: 'liquidityIndex', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'currentLiquidityRate', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'variableBorrowIndex', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'currentVariableBorrowRate', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'currentStableBorrowRate', + type: 'uint128', + }, + { + internalType: 'uint40', + name: 'lastUpdateTimestamp', + type: 'uint40', + }, + { + internalType: 'uint16', + name: 'id', + type: 'uint16', + }, + { + internalType: 'address', + name: 'aTokenAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'stableDebtTokenAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'variableDebtTokenAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'interestRateStrategyAddress', + type: 'address', + }, + { + internalType: 'uint128', + name: 'accruedToTreasury', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'unbacked', + type: 'uint128', + }, + { + internalType: 'uint128', + name: 'isolationModeTotalDebt', + type: 'uint128', + }, + ], + internalType: 'struct DataTypes.ReserveData', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + ], + name: 'getReserveNormalizedIncome', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + ], + name: 'getReserveNormalizedVariableDebt', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [], + name: 'getReservesList', + outputs: [ + { + internalType: 'address[]', + name: '', + type: 'address[]', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'getUserAccountData', + outputs: [ + { + internalType: 'uint256', + name: 'totalCollateralBase', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'totalDebtBase', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'availableBorrowsBase', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'currentLiquidationThreshold', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'ltv', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'healthFactor', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'getUserConfiguration', + outputs: [ + { + components: [ + { + internalType: 'uint256', + name: 'data', + type: 'uint256', + }, + ], + internalType: 'struct DataTypes.UserConfigurationMap', + name: '', + type: 'tuple', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'getUserEMode', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'view', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'address', + name: 'aTokenAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'stableDebtAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'variableDebtAddress', + type: 'address', + }, + { + internalType: 'address', + name: 'interestRateStrategyAddress', + type: 'address', + }, + ], + name: 'initReserve', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'collateralAsset', + type: 'address', + }, + { + internalType: 'address', + name: 'debtAsset', + type: 'address', + }, + { + internalType: 'address', + name: 'user', + type: 'address', + }, + { + internalType: 'uint256', + name: 'debtToCover', + type: 'uint256', + }, + { + internalType: 'bool', + name: 'receiveAToken', + type: 'bool', + }, + ], + name: 'liquidationCall', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address[]', + name: 'assets', + type: 'address[]', + }, + ], + name: 'mintToTreasury', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + ], + name: 'mintUnbacked', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'address', + name: 'user', + type: 'address', + }, + ], + name: 'rebalanceStableBorrowRate', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'interestRateMode', + type: 'uint256', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + ], + name: 'repay', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'interestRateMode', + type: 'uint256', + }, + ], + name: 'repayWithATokens', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'interestRateMode', + type: 'uint256', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { + internalType: 'uint8', + name: 'permitV', + type: 'uint8', + }, + { + internalType: 'bytes32', + name: 'permitR', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: 'permitS', + type: 'bytes32', + }, + ], + name: 'repayWithPermit', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'configuration', + type: 'uint256', + }, + ], + name: 'setConfiguration', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'address', + name: 'rateStrategyAddress', + type: 'address', + }, + ], + name: 'setReserveInterestRateStrategyAddress', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint8', + name: 'categoryId', + type: 'uint8', + }, + ], + name: 'setUserEMode', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'bool', + name: 'useAsCollateral', + type: 'bool', + }, + ], + name: 'setUserUseReserveAsCollateral', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + ], + name: 'supply', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'address', + name: 'onBehalfOf', + type: 'address', + }, + { + internalType: 'uint16', + name: 'referralCode', + type: 'uint16', + }, + { + internalType: 'uint256', + name: 'deadline', + type: 'uint256', + }, + { + internalType: 'uint8', + name: 'permitV', + type: 'uint8', + }, + { + internalType: 'bytes32', + name: 'permitR', + type: 'bytes32', + }, + { + internalType: 'bytes32', + name: 'permitS', + type: 'bytes32', + }, + ], + name: 'supplyWithPermit', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'interestRateMode', + type: 'uint256', + }, + ], + name: 'swapBorrowRateMode', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'bridgeProtocolFee', + type: 'uint256', + }, + ], + name: 'updateBridgeProtocolFee', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'uint256', + name: 'flashLoanPremiumTotal', + type: 'uint256', + }, + { + internalType: 'uint256', + name: 'flashLoanPremiumToProtocol', + type: 'uint256', + }, + ], + name: 'updateFlashloanPremiums', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { + internalType: 'address', + name: 'asset', + type: 'address', + }, + { + internalType: 'uint256', + name: 'amount', + type: 'uint256', + }, + { + internalType: 'address', + name: 'to', + type: 'address', + }, + ], + name: 'withdraw', + outputs: [ + { + internalType: 'uint256', + name: '', + type: 'uint256', + }, + ], + stateMutability: 'nonpayable', + type: 'function', + }, +] as const diff --git a/packages/app/src/config/chain/constants.ts b/packages/app/src/config/chain/constants.ts new file mode 100644 index 000000000..45035766e --- /dev/null +++ b/packages/app/src/config/chain/constants.ts @@ -0,0 +1,4 @@ +import { gnosis, mainnet } from 'viem/chains' + +export const SUPPORTED_CHAINS = [mainnet, gnosis] as const +export const SUPPORTED_CHAIN_IDS = SUPPORTED_CHAINS.map((chain) => chain.id) diff --git a/packages/app/src/config/chain/index.ts b/packages/app/src/config/chain/index.ts new file mode 100644 index 000000000..346831338 --- /dev/null +++ b/packages/app/src/config/chain/index.ts @@ -0,0 +1,119 @@ +import { gnosis, mainnet } from 'viem/chains' + +import { getOriginChainId } from '@/domain/hooks/useOriginChainId' +import { useStore } from '@/domain/state' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { assets } from '@/ui/assets' + +import { AppConfig } from '../feature-flags' +import { ChainConfig, ChainConfigEntry, ChainMeta } from './types' + +const commonTokenSymbolToReplacedName = { + [TokenSymbol('DAI')]: { name: 'DAI Stablecoin', symbol: TokenSymbol('DAI') }, + [TokenSymbol('sDAI')]: { name: 'DAI Stablecoin', symbol: TokenSymbol('DAI') }, + [TokenSymbol('USDC')]: { name: 'Circle USD', symbol: TokenSymbol('USDC') }, + [TokenSymbol('wstETH')]: { name: 'Lido Staked ETH', symbol: TokenSymbol('wstETH') }, + [TokenSymbol('rETH')]: { name: 'Rocket Pool Staked ETH', symbol: TokenSymbol('rETH') }, + [TokenSymbol('GNO')]: { name: 'Gnosis Token', symbol: TokenSymbol('GNO') }, + [TokenSymbol('WETH')]: { name: 'Ethereum', symbol: TokenSymbol('ETH') }, +} + +const chainConfig: ChainConfig = { + [mainnet.id]: { + id: mainnet.id, + meta: { + name: 'Ethereum Mainnet', + logo: assets.chain.ethereum, + defaultAssetToBorrow: TokenSymbol('DAI'), + }, + nativeAssetInfo: { + nativeAssetName: 'Ethereum', + wrappedNativeAssetSymbol: TokenSymbol('WETH'), + wrappedNativeAssetAddress: CheckedAddress('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), + nativeAssetSymbol: TokenSymbol('ETH'), + }, + erc20TokensWithApproveFnMalformed: [CheckedAddress('0xdac17f958d2ee523a2206206994597c13d831ec7')], // USDT + permitSupport: { + [CheckedAddress('0x6b175474e89094c44da98b954eedeac495271d0f')]: false, // DAI + [CheckedAddress('0x83f20f44975d03b1b09e64809b757c47f942beea')]: true, // sDAI + [CheckedAddress('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48')]: false, // USDC + [CheckedAddress('0xdAC17F958D2ee523a2206206994597C13D831ec7')]: false, // USDT + [CheckedAddress('0x2260fac5e5542a773aa44fbcfedf7c193bc2c599')]: false, // WBTC + [CheckedAddress('0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2')]: false, // WETH + [CheckedAddress('0x7f39c581f595b53c5cb19bd0b3f8da6c935e2ca0')]: true, // wstETH + [CheckedAddress('0xae78736Cd615f374D3085123A210448E74Fc6393')]: false, // rETH + [CheckedAddress('0x6810e776880C02933D47DB1b9fc05908e5386b96')]: false, // GNO + }, + tokenSymbolToReplacedName: { + ...commonTokenSymbolToReplacedName, + }, + airdrop: { + [TokenSymbol('SPK')]: { + [TokenSymbol('ETH')]: { + deposit: NormalizedUnitNumber(6_000_000), + }, + [TokenSymbol('DAI')]: { + borrow: NormalizedUnitNumber(24_000_000), + }, + }, + }, + }, + [gnosis.id]: { + id: gnosis.id, + meta: { + name: 'Gnosis Chain', + logo: assets.chain.gnosis, + defaultAssetToBorrow: TokenSymbol('WXDAI'), + }, + nativeAssetInfo: { + nativeAssetName: 'XDAI', + wrappedNativeAssetSymbol: TokenSymbol('WXDAI'), + wrappedNativeAssetAddress: CheckedAddress('0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d'), + nativeAssetSymbol: TokenSymbol('XDAI'), + }, + erc20TokensWithApproveFnMalformed: [], + permitSupport: { + [CheckedAddress('0x9C58BAcC331c9aa871AFD802DB6379a98e80CEdb')]: false, // GNO + [CheckedAddress('0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d')]: false, // WXDAI + [CheckedAddress('0xaf204776c7245bF4147c2612BF6e5972Ee483701')]: false, // sDAI + [CheckedAddress('0x6A023CCd1ff6F2045C3309768eAd9E68F978f6e1')]: false, // WETH + [CheckedAddress('0x6C76971f98945AE98dD7d4DFcA8711ebea946eA6')]: false, // wstETH + [CheckedAddress('0xDDAfbb505ad214D7b80b1f830fcCc89B60fb7A83')]: false, // USDC + [CheckedAddress('0x4ECaBa5870353805a9F068101A40E0f32ed605C6')]: false, // USDT + [CheckedAddress('0xcB444e90D8198415266c6a2724b7900fb12FC56E')]: false, // EURe + }, + tokenSymbolToReplacedName: { + ...commonTokenSymbolToReplacedName, + [TokenSymbol('WXDAI')]: { name: 'DAI Stablecoin', symbol: TokenSymbol('XDAI') }, + [TokenSymbol('USDC')]: { name: 'Circle USD (Bridged)', symbol: TokenSymbol('USDC') }, + [TokenSymbol('USDT')]: { name: 'Tether USD (Bridged)', symbol: TokenSymbol('USDT') }, + [TokenSymbol('EURe')]: { name: 'Monerium EURO', symbol: TokenSymbol('EURe') }, + }, + airdrop: {}, + }, +} + +export function getChainConfigEntry(chainId: number): ChainConfigEntry { + const sandboxConfig = useStore.getState().appConfig.sandbox + const sandbox = useStore.getState().sandbox.network + + const originChainId = getOriginChainId(chainId, sandbox) + if (originChainId !== chainId) { + return { + ...chainConfig[originChainId], + meta: getSandboxChainMeta(chainConfig[originChainId].meta, sandboxConfig), + } + } + + return chainConfig[chainId] +} + +function getSandboxChainMeta(originChainMeta: ChainMeta, sandboxConfig: AppConfig['sandbox']): ChainMeta { + return { + ...originChainMeta, + name: sandboxConfig?.chainName || originChainMeta.name, + logo: assets.sparkIcon, + } +} diff --git a/packages/app/src/config/chain/types.ts b/packages/app/src/config/chain/types.ts new file mode 100644 index 000000000..f9e060883 --- /dev/null +++ b/packages/app/src/config/chain/types.ts @@ -0,0 +1,46 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { SUPPORTED_CHAIN_IDS } from './constants' + +export type SupportedChainId = (typeof SUPPORTED_CHAIN_IDS)[number] + +export interface NativeAssetInfo { + nativeAssetName: string + nativeAssetSymbol: TokenSymbol + wrappedNativeAssetSymbol: TokenSymbol + wrappedNativeAssetAddress: CheckedAddress +} + +export interface ChainMeta { + name: string + logo: string + defaultAssetToBorrow: TokenSymbol +} + +export type PermitSupport = Record + +export type Erc20TokensWithApproveFnMalformed = CheckedAddress[] + +export type TokenSymbolToReplacedName = Record + +export interface TokenToAirdropAmounts { + [token: TokenSymbol]: { + deposit?: NormalizedUnitNumber + borrow?: NormalizedUnitNumber + } +} +export type Airdrop = Record + +export interface ChainConfigEntry { + id: SupportedChainId + meta: ChainMeta + nativeAssetInfo: NativeAssetInfo + permitSupport: PermitSupport + erc20TokensWithApproveFnMalformed: Erc20TokensWithApproveFnMalformed + tokenSymbolToReplacedName: TokenSymbolToReplacedName + airdrop: Airdrop +} + +export type ChainConfig = Record diff --git a/packages/app/src/config/chain/utils/airdrops.test.ts b/packages/app/src/config/chain/utils/airdrops.test.ts new file mode 100644 index 000000000..5711b99ae --- /dev/null +++ b/packages/app/src/config/chain/utils/airdrops.test.ts @@ -0,0 +1,70 @@ +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { iterateAirdropData } from './airdrops' + +describe(iterateAirdropData.name, () => { + it('works for real config', () => { + const airdrops = { + SPK: { + ETH: { + deposit: 6_000_000, + }, + DAI: { + borrow: 24_000_000, + }, + }, + } as const + + expect(iterateAirdropData(airdrops, TokenSymbol('ETH'), 'deposit')).toEqual([{ id: 'SPK', amount: 6_000_000 }]) + expect(iterateAirdropData(airdrops, TokenSymbol('DAI'), 'borrow')).toEqual([{ id: 'SPK', amount: 24_000_000 }]) + }) + it('works with both deposit and borrow specified', () => { + const airdrops = { + SPK: { + ETH: { + deposit: 6_000_000, + borrow: 7_000_000, + }, + DAI: { + deposit: 23_000_000, + borrow: 24_000_000, + }, + }, + } as const + + expect(iterateAirdropData(airdrops, TokenSymbol('ETH'), 'deposit')).toEqual([{ id: 'SPK', amount: 6_000_000 }]) + expect(iterateAirdropData(airdrops, TokenSymbol('ETH'), 'borrow')).toEqual([{ id: 'SPK', amount: 7_000_000 }]) + expect(iterateAirdropData(airdrops, TokenSymbol('DAI'), 'deposit')).toEqual([{ id: 'SPK', amount: 23_000_000 }]) + expect(iterateAirdropData(airdrops, TokenSymbol('DAI'), 'borrow')).toEqual([{ id: 'SPK', amount: 24_000_000 }]) + }) + + it('works for multiple airdrops', () => { + const airdrops = { + SPK: { + ETH: { + deposit: 6_000_000, + }, + DAI: { + borrow: 24_000_000, + }, + }, + SPK2: { + ETH: { + deposit: 7_000_000, + }, + DAI: { + borrow: 25_000_000, + }, + }, + } as const + + expect(iterateAirdropData(airdrops, TokenSymbol('ETH'), 'deposit')).toEqual([ + { id: 'SPK', amount: 6_000_000 }, + { id: 'SPK2', amount: 7_000_000 }, + ]) + expect(iterateAirdropData(airdrops, TokenSymbol('DAI'), 'borrow')).toEqual([ + { id: 'SPK', amount: 24_000_000 }, + { id: 'SPK2', amount: 25_000_000 }, + ]) + }) +}) diff --git a/packages/app/src/config/chain/utils/airdrops.ts b/packages/app/src/config/chain/utils/airdrops.ts new file mode 100644 index 000000000..3775308bf --- /dev/null +++ b/packages/app/src/config/chain/utils/airdrops.ts @@ -0,0 +1,38 @@ +import { SUPPORTED_CHAIN_IDS } from '@/config/chain/constants' +import { Airdrop } from '@/config/chain/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { getChainConfigEntry } from '..' + +export interface AirdropEntry { + id: string + amount: NormalizedUnitNumber +} +export interface AirdropsData { + deposit: AirdropEntry[] + borrow: AirdropEntry[] +} +export function getAirdropsData(chainId: number, tokenSymbol: TokenSymbol): AirdropsData { + if (!SUPPORTED_CHAIN_IDS.includes(chainId)) { + return { deposit: [], borrow: [] } + } + + const { airdrop } = getChainConfigEntry(chainId) + + return { + deposit: iterateAirdropData(airdrop, tokenSymbol, 'deposit'), + borrow: iterateAirdropData(airdrop, tokenSymbol, 'borrow'), + } +} + +export function iterateAirdropData(airdrop: Airdrop, symbol: TokenSymbol, type: 'deposit' | 'borrow'): AirdropEntry[] { + return Object.entries(airdrop) + .map(([id, airdrop]) => { + const airdropAmount = airdrop[symbol]?.[type] + if (airdropAmount) { + return { id, amount: airdropAmount } + } + }) + .filter(Boolean) as AirdropEntry[] +} diff --git a/packages/app/src/config/chain/utils/getNativeAssetInfo.ts b/packages/app/src/config/chain/utils/getNativeAssetInfo.ts new file mode 100644 index 000000000..7cc492b1a --- /dev/null +++ b/packages/app/src/config/chain/utils/getNativeAssetInfo.ts @@ -0,0 +1,6 @@ +import { getChainConfigEntry } from '..' +import { NativeAssetInfo } from '../types' + +export function getNativeAssetInfo(chainId: number): NativeAssetInfo { + return getChainConfigEntry(chainId).nativeAssetInfo +} diff --git a/packages/app/src/config/consts.ts b/packages/app/src/config/consts.ts new file mode 100644 index 000000000..35587be79 --- /dev/null +++ b/packages/app/src/config/consts.ts @@ -0,0 +1,15 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' + +export enum InterestRate { + Stable = 1, + Variable = 2, +} + +export const NATIVE_ASSET_MOCK_ADDRESS = CheckedAddress('0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE') + +export const MAX_INT = BigInt(2 ** 256 - 1) + +export const ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY = 'zustand-app-store' +export const ZUSTAND_APP_STORE_LOCAL_STORAGE_VERSION = 1 + +export const apiUrl: string = import.meta.env.VITE_API_URL ?? '/api' diff --git a/packages/app/src/config/contracts-generated.ts b/packages/app/src/config/contracts-generated.ts new file mode 100644 index 000000000..7b760b1ba --- /dev/null +++ b/packages/app/src/config/contracts-generated.ts @@ -0,0 +1,2856 @@ +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Chainlog +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F) + */ +export const chainlogAbi = [ + { type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'usr', internalType: 'address', type: 'address', indexed: false }], + name: 'Deny', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'usr', internalType: 'address', type: 'address', indexed: false }], + name: 'Rely', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'key', internalType: 'bytes32', type: 'bytes32', indexed: false }], + name: 'RemoveAddress', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'key', internalType: 'bytes32', type: 'bytes32', indexed: false }, + { name: 'addr', internalType: 'address', type: 'address', indexed: false }, + ], + name: 'UpdateAddress', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'ipfs', internalType: 'string', type: 'string', indexed: false }], + name: 'UpdateIPFS', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'sha256sum', internalType: 'string', type: 'string', indexed: false }], + name: 'UpdateSha256sum', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'version', internalType: 'string', type: 'string', indexed: false }], + name: 'UpdateVersion', + }, + { + type: 'function', + inputs: [], + name: 'count', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'deny', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_index', internalType: 'uint256', type: 'uint256' }], + name: 'get', + outputs: [ + { name: '', internalType: 'bytes32', type: 'bytes32' }, + { name: '', internalType: 'address', type: 'address' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_key', internalType: 'bytes32', type: 'bytes32' }], + name: 'getAddress', + outputs: [{ name: 'addr', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'ipfs', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + name: 'keys', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'list', + outputs: [{ name: '', internalType: 'bytes32[]', type: 'bytes32[]' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'rely', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_key', internalType: 'bytes32', type: 'bytes32' }], + name: 'removeAddress', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '_key', internalType: 'bytes32', type: 'bytes32' }, + { name: '_addr', internalType: 'address', type: 'address' }, + ], + name: 'setAddress', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_ipfs', internalType: 'string', type: 'string' }], + name: 'setIPFS', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_sha256sum', internalType: 'string', type: 'string' }], + name: 'setSha256sum', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_version', internalType: 'string', type: 'string' }], + name: 'setVersion', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'sha256sum', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'version', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'wards', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F) + */ +export const chainlogAddress = { + 1: '0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F', + 5: '0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F) + */ +export const chainlogConfig = { address: chainlogAddress, abi: chainlogAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Collector +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xb137E7d16564c81ae2b0C8ee6B55De81dd46ECe5) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x0D56700c90a690D8795D6C148aCD94b12932f4E3) + */ +export const collectorAbi = [ + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'previousAdmin', internalType: 'address', type: 'address', indexed: false }, + { name: 'newAdmin', internalType: 'address', type: 'address', indexed: false }, + ], + name: 'AdminChanged', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'implementation', internalType: 'address', type: 'address', indexed: true }], + name: 'Upgraded', + }, + { type: 'fallback', stateMutability: 'payable' }, + { + type: 'function', + inputs: [], + name: 'admin', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newAdmin', internalType: 'address', type: 'address' }], + name: 'changeAdmin', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'implementation', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'logic', internalType: 'address', type: 'address' }, + { name: 'admin', internalType: 'address', type: 'address' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'initialize', + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [ + { name: '_logic', internalType: 'address', type: 'address' }, + { name: '_data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'initialize', + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [{ name: 'newImplementation', internalType: 'address', type: 'address' }], + name: 'upgradeTo', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'newImplementation', internalType: 'address', type: 'address' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'upgradeToAndCall', + outputs: [], + stateMutability: 'payable', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xb137E7d16564c81ae2b0C8ee6B55De81dd46ECe5) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x0D56700c90a690D8795D6C148aCD94b12932f4E3) + */ +export const collectorAddress = { + 1: '0xb137E7d16564c81ae2b0C8ee6B55De81dd46ECe5', + 5: '0x0D56700c90a690D8795D6C148aCD94b12932f4E3', + 100: '0xb9E6DBFa4De19CCed908BcbFe1d015190678AB5f', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xb137E7d16564c81ae2b0C8ee6B55De81dd46ECe5) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x0D56700c90a690D8795D6C148aCD94b12932f4E3) + */ +export const collectorConfig = { address: collectorAddress, abi: collectorAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Faucet +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xe2bE5BfdDbA49A86e27f3Dd95710B528D43272C2) + */ +export const faucetAbi = [ + { + type: 'constructor', + inputs: [ + { name: '_makerFaucet', internalType: 'address', type: 'address' }, + { name: '_psm', internalType: 'address', type: 'address' }, + { name: '_sDai', internalType: 'address', type: 'address' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'dai', + outputs: [{ name: '', internalType: 'contract TokenLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'gem', + outputs: [{ name: '', internalType: 'contract TokenLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'makerFaucet', + outputs: [{ name: '', internalType: 'contract MakerFaucetLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'token', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + ], + name: 'mint', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'psm', + outputs: [{ name: '', internalType: 'contract PsmLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'sDai', + outputs: [{ name: '', internalType: 'contract TokenLike', type: 'address' }], + stateMutability: 'view', + }, +] as const + +/** + * [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xe2bE5BfdDbA49A86e27f3Dd95710B528D43272C2) + */ +export const faucetAddress = { + 5: '0xe2bE5BfdDbA49A86e27f3Dd95710B528D43272C2', +} as const + +/** + * [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xe2bE5BfdDbA49A86e27f3Dd95710B528D43272C2) + */ +export const faucetConfig = { address: faucetAddress, abi: faucetAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// IAMAutoLine +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xC7Bdd1F2B16447dcf3dE045C4a039A60EC2f0ba3) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x21DaD87779D9FfA8Ed3E1036cBEA8784cec4fB83) + */ +export const iamAutoLineAbi = [ + { + type: 'constructor', + inputs: [{ name: 'vat_', internalType: 'address', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'usr', internalType: 'address', type: 'address', indexed: true }], + name: 'Deny', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'ilk', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'line', internalType: 'uint256', type: 'uint256', indexed: false }, + { name: 'lineNew', internalType: 'uint256', type: 'uint256', indexed: false }, + ], + name: 'Exec', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'usr', internalType: 'address', type: 'address', indexed: true }], + name: 'Rely', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'ilk', internalType: 'bytes32', type: 'bytes32', indexed: true }], + name: 'Remove', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'ilk', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'line', internalType: 'uint256', type: 'uint256', indexed: false }, + { name: 'gap', internalType: 'uint256', type: 'uint256', indexed: false }, + { name: 'ttl', internalType: 'uint256', type: 'uint256', indexed: false }, + ], + name: 'Setup', + }, + { + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'deny', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '_ilk', internalType: 'bytes32', type: 'bytes32' }], + name: 'exec', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + name: 'ilks', + outputs: [ + { name: 'line', internalType: 'uint256', type: 'uint256' }, + { name: 'gap', internalType: 'uint256', type: 'uint256' }, + { name: 'ttl', internalType: 'uint48', type: 'uint48' }, + { name: 'last', internalType: 'uint48', type: 'uint48' }, + { name: 'lastInc', internalType: 'uint48', type: 'uint48' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'rely', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'ilk', internalType: 'bytes32', type: 'bytes32' }], + name: 'remIlk', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'ilk', internalType: 'bytes32', type: 'bytes32' }, + { name: 'line', internalType: 'uint256', type: 'uint256' }, + { name: 'gap', internalType: 'uint256', type: 'uint256' }, + { name: 'ttl', internalType: 'uint256', type: 'uint256' }, + ], + name: 'setIlk', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'vat', + outputs: [{ name: '', internalType: 'contract VatLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'wards', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xC7Bdd1F2B16447dcf3dE045C4a039A60EC2f0ba3) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x21DaD87779D9FfA8Ed3E1036cBEA8784cec4fB83) + */ +export const iamAutoLineAddress = { + 1: '0xC7Bdd1F2B16447dcf3dE045C4a039A60EC2f0ba3', + 5: '0x21DaD87779D9FfA8Ed3E1036cBEA8784cec4fB83', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xC7Bdd1F2B16447dcf3dE045C4a039A60EC2f0ba3) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x21DaD87779D9FfA8Ed3E1036cBEA8784cec4fB83) + */ +export const iamAutoLineConfig = { address: iamAutoLineAddress, abi: iamAutoLineAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// LendingPool +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xC13e21B648A5Ee794902342038FF3aDAB66BE987) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x26ca51Af4506DE7a6f0785D20CD776081a05fF6d) + */ +export const lendingPoolAbi = [ + { + type: 'constructor', + inputs: [{ name: 'admin', internalType: 'address', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'event', + anonymous: false, + inputs: [{ name: 'implementation', internalType: 'address', type: 'address', indexed: true }], + name: 'Upgraded', + }, + { type: 'fallback', stateMutability: 'payable' }, + { + type: 'function', + inputs: [], + name: 'admin', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'implementation', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '_logic', internalType: 'address', type: 'address' }, + { name: '_data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'initialize', + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [{ name: 'newImplementation', internalType: 'address', type: 'address' }], + name: 'upgradeTo', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'newImplementation', internalType: 'address', type: 'address' }, + { name: 'data', internalType: 'bytes', type: 'bytes' }, + ], + name: 'upgradeToAndCall', + outputs: [], + stateMutability: 'payable', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xC13e21B648A5Ee794902342038FF3aDAB66BE987) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x26ca51Af4506DE7a6f0785D20CD776081a05fF6d) + */ +export const lendingPoolAddress = { + 1: '0xC13e21B648A5Ee794902342038FF3aDAB66BE987', + 5: '0x26ca51Af4506DE7a6f0785D20CD776081a05fF6d', + 100: '0x2Dae5307c5E3FD1CF5A72Cb6F698f915860607e0', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xC13e21B648A5Ee794902342038FF3aDAB66BE987) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x26ca51Af4506DE7a6f0785D20CD776081a05fF6d) + */ +export const lendingPoolConfig = { address: lendingPoolAddress, abi: lendingPoolAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// LendingPoolAddressProvider +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x02C3eA4e34C0cBd694D2adFa2c690EECbC1793eE) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x026a5B6114431d8F3eF2fA0E1B2EDdDccA9c540E) + */ +export const lendingPoolAddressProviderAbi = [ + { + type: 'constructor', + inputs: [ + { name: 'marketId', internalType: 'string', type: 'string' }, + { name: 'owner', internalType: 'address', type: 'address' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'ACLAdminUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'ACLManagerUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'id', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'AddressSet', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'id', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'proxyAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'oldImplementationAddress', internalType: 'address', type: 'address', indexed: false }, + { name: 'newImplementationAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'AddressSetAsProxy', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldMarketId', internalType: 'string', type: 'string', indexed: true }, + { name: 'newMarketId', internalType: 'string', type: 'string', indexed: true }, + ], + name: 'MarketIdSet', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'previousOwner', internalType: 'address', type: 'address', indexed: true }, + { name: 'newOwner', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'OwnershipTransferred', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'PoolConfiguratorUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'PoolDataProviderUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'PoolUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'PriceOracleSentinelUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'oldAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'newAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'PriceOracleUpdated', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'id', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'proxyAddress', internalType: 'address', type: 'address', indexed: true }, + { name: 'implementationAddress', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'ProxyCreated', + }, + { + type: 'function', + inputs: [], + name: 'getACLAdmin', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getACLManager', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'id', internalType: 'bytes32', type: 'bytes32' }], + name: 'getAddress', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getMarketId', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getPool', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getPoolConfigurator', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getPoolDataProvider', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getPriceOracle', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'getPriceOracleSentinel', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'owner', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { type: 'function', inputs: [], name: 'renounceOwnership', outputs: [], stateMutability: 'nonpayable' }, + { + type: 'function', + inputs: [{ name: 'newAclAdmin', internalType: 'address', type: 'address' }], + name: 'setACLAdmin', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newAclManager', internalType: 'address', type: 'address' }], + name: 'setACLManager', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'id', internalType: 'bytes32', type: 'bytes32' }, + { name: 'newAddress', internalType: 'address', type: 'address' }, + ], + name: 'setAddress', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'id', internalType: 'bytes32', type: 'bytes32' }, + { name: 'newImplementationAddress', internalType: 'address', type: 'address' }, + ], + name: 'setAddressAsProxy', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newMarketId', internalType: 'string', type: 'string' }], + name: 'setMarketId', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newPoolConfiguratorImpl', internalType: 'address', type: 'address' }], + name: 'setPoolConfiguratorImpl', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newDataProvider', internalType: 'address', type: 'address' }], + name: 'setPoolDataProvider', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newPoolImpl', internalType: 'address', type: 'address' }], + name: 'setPoolImpl', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newPriceOracle', internalType: 'address', type: 'address' }], + name: 'setPriceOracle', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newPriceOracleSentinel', internalType: 'address', type: 'address' }], + name: 'setPriceOracleSentinel', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x02C3eA4e34C0cBd694D2adFa2c690EECbC1793eE) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x026a5B6114431d8F3eF2fA0E1B2EDdDccA9c540E) + */ +export const lendingPoolAddressProviderAddress = { + 1: '0x02C3eA4e34C0cBd694D2adFa2c690EECbC1793eE', + 5: '0x026a5B6114431d8F3eF2fA0E1B2EDdDccA9c540E', + 100: '0xA98DaCB3fC964A6A0d2ce3B77294241585EAbA6d', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x02C3eA4e34C0cBd694D2adFa2c690EECbC1793eE) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x026a5B6114431d8F3eF2fA0E1B2EDdDccA9c540E) + */ +export const lendingPoolAddressProviderConfig = { + address: lendingPoolAddressProviderAddress, + abi: lendingPoolAddressProviderAbi, +} as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Pot +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x50672F0a14B40051B65958818a7AcA3D54Bd81Af) + */ +export const potAbi = [ + { + payable: false, + type: 'constructor', + inputs: [{ name: 'vat_', internalType: 'address', type: 'address' }], + stateMutability: 'nonpayable', + }, + { + type: 'event', + anonymous: true, + inputs: [ + { name: 'sig', internalType: 'bytes4', type: 'bytes4', indexed: true }, + { name: 'usr', internalType: 'address', type: 'address', indexed: true }, + { name: 'arg1', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'arg2', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'data', internalType: 'bytes', type: 'bytes', indexed: false }, + ], + name: 'LogNote', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'Pie', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [], + name: 'cage', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'chi', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'guy', internalType: 'address', type: 'address' }], + name: 'deny', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [], + name: 'drip', + outputs: [{ name: 'tmp', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'dsr', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'wad', internalType: 'uint256', type: 'uint256' }], + name: 'exit', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'what', internalType: 'bytes32', type: 'bytes32' }, + { name: 'data', internalType: 'uint256', type: 'uint256' }, + ], + name: 'file', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'what', internalType: 'bytes32', type: 'bytes32' }, + { name: 'addr', internalType: 'address', type: 'address' }, + ], + name: 'file', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'wad', internalType: 'uint256', type: 'uint256' }], + name: 'join', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'live', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'pie', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'guy', internalType: 'address', type: 'address' }], + name: 'rely', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'rho', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'vat', + outputs: [{ name: '', internalType: 'contract VatLike', type: 'address' }], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'vow', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'wards', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x50672F0a14B40051B65958818a7AcA3D54Bd81Af) + */ +export const potAddress = { + 1: '0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7', + 5: '0x50672F0a14B40051B65958818a7AcA3D54Bd81Af', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x50672F0a14B40051B65958818a7AcA3D54Bd81Af) + */ +export const potConfig = { address: potAddress, abi: potAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// SavingsDai +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x83f20f44975d03b1b09e64809b757c47f942beea) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xd8134205b0328f5676aaefb3b2a0dc15f4029d8c) + */ +export const savingsDaiAbi = [ + { + type: 'constructor', + inputs: [ + { name: '_daiJoin', internalType: 'address', type: 'address' }, + { name: '_pot', internalType: 'address', type: 'address' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'owner', internalType: 'address', type: 'address', indexed: true }, + { name: 'spender', internalType: 'address', type: 'address', indexed: true }, + { name: 'value', internalType: 'uint256', type: 'uint256', indexed: false }, + ], + name: 'Approval', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'sender', internalType: 'address', type: 'address', indexed: true }, + { name: 'owner', internalType: 'address', type: 'address', indexed: true }, + { name: 'assets', internalType: 'uint256', type: 'uint256', indexed: false }, + { name: 'shares', internalType: 'uint256', type: 'uint256', indexed: false }, + ], + name: 'Deposit', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'from', internalType: 'address', type: 'address', indexed: true }, + { name: 'to', internalType: 'address', type: 'address', indexed: true }, + { name: 'value', internalType: 'uint256', type: 'uint256', indexed: false }, + ], + name: 'Transfer', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'sender', internalType: 'address', type: 'address', indexed: true }, + { name: 'receiver', internalType: 'address', type: 'address', indexed: true }, + { name: 'owner', internalType: 'address', type: 'address', indexed: true }, + { name: 'assets', internalType: 'uint256', type: 'uint256', indexed: false }, + { name: 'shares', internalType: 'uint256', type: 'uint256', indexed: false }, + ], + name: 'Withdraw', + }, + { + type: 'function', + inputs: [], + name: 'DOMAIN_SEPARATOR', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'PERMIT_TYPEHASH', + outputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: '', internalType: 'address', type: 'address' }, + ], + name: 'allowance', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'approve', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'asset', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'balanceOf', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'shares', internalType: 'uint256', type: 'uint256' }], + name: 'convertToAssets', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'assets', internalType: 'uint256', type: 'uint256' }], + name: 'convertToShares', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'dai', + outputs: [{ name: '', internalType: 'contract DaiLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'daiJoin', + outputs: [{ name: '', internalType: 'contract DaiJoinLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'decimals', + outputs: [{ name: '', internalType: 'uint8', type: 'uint8' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'subtractedValue', internalType: 'uint256', type: 'uint256' }, + ], + name: 'decreaseAllowance', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'deploymentChainId', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'assets', internalType: 'uint256', type: 'uint256' }, + { name: 'receiver', internalType: 'address', type: 'address' }, + ], + name: 'deposit', + outputs: [{ name: 'shares', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'addedValue', internalType: 'uint256', type: 'uint256' }, + ], + name: 'increaseAllowance', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'maxDeposit', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'pure', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'maxMint', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'pure', + }, + { + type: 'function', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'maxRedeem', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'owner', internalType: 'address', type: 'address' }], + name: 'maxWithdraw', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'shares', internalType: 'uint256', type: 'uint256' }, + { name: 'receiver', internalType: 'address', type: 'address' }, + ], + name: 'mint', + outputs: [{ name: 'assets', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'name', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'nonces', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + { name: 'deadline', internalType: 'uint256', type: 'uint256' }, + { name: 'signature', internalType: 'bytes', type: 'bytes' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'spender', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + { name: 'deadline', internalType: 'uint256', type: 'uint256' }, + { name: 'v', internalType: 'uint8', type: 'uint8' }, + { name: 'r', internalType: 'bytes32', type: 'bytes32' }, + { name: 's', internalType: 'bytes32', type: 'bytes32' }, + ], + name: 'permit', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'pot', + outputs: [{ name: '', internalType: 'contract PotLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'assets', internalType: 'uint256', type: 'uint256' }], + name: 'previewDeposit', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'shares', internalType: 'uint256', type: 'uint256' }], + name: 'previewMint', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'shares', internalType: 'uint256', type: 'uint256' }], + name: 'previewRedeem', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'assets', internalType: 'uint256', type: 'uint256' }], + name: 'previewWithdraw', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'shares', internalType: 'uint256', type: 'uint256' }, + { name: 'receiver', internalType: 'address', type: 'address' }, + { name: 'owner', internalType: 'address', type: 'address' }, + ], + name: 'redeem', + outputs: [{ name: 'assets', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'symbol', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'totalAssets', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'totalSupply', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transfer', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'from', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + ], + name: 'transferFrom', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'vat', + outputs: [{ name: '', internalType: 'contract VatLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'version', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'assets', internalType: 'uint256', type: 'uint256' }, + { name: 'receiver', internalType: 'address', type: 'address' }, + { name: 'owner', internalType: 'address', type: 'address' }, + ], + name: 'withdraw', + outputs: [{ name: 'shares', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'nonpayable', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x83f20f44975d03b1b09e64809b757c47f942beea) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xd8134205b0328f5676aaefb3b2a0dc15f4029d8c) + */ +export const savingsDaiAddress = { + 1: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + 5: '0xD8134205b0328F5676aaeFb3B2a0DC15f4029d8C', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x83f20f44975d03b1b09e64809b757c47f942beea) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xd8134205b0328f5676aaefb3b2a0dc15f4029d8c) + */ +export const savingsDaiConfig = { address: savingsDaiAddress, abi: savingsDaiAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// UiIncentiveDataProvider +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xA7F8A757C4f7696c015B595F51B2901AC0121B18) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x1472B7d120ab62D60f60e1D804B3858361c3C475) + */ +export const uiIncentiveDataProviderAbi = [ + { + type: 'function', + inputs: [ + { name: 'provider', internalType: 'contract IPoolAddressesProvider', type: 'address' }, + { name: 'user', internalType: 'address', type: 'address' }, + ], + name: 'getFullReservesIncentiveData', + outputs: [ + { + name: '', + internalType: 'struct IUiIncentiveDataProviderV3.AggregatedReserveIncentiveData[]', + type: 'tuple[]', + components: [ + { name: 'underlyingAsset', internalType: 'address', type: 'address' }, + { + name: 'aIncentiveData', + internalType: 'struct IUiIncentiveDataProviderV3.IncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'rewardsTokenInformation', + internalType: 'struct IUiIncentiveDataProviderV3.RewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'emissionPerSecond', internalType: 'uint256', type: 'uint256' }, + { name: 'incentivesLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'emissionEndTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'precision', internalType: 'uint8', type: 'uint8' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'vIncentiveData', + internalType: 'struct IUiIncentiveDataProviderV3.IncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'rewardsTokenInformation', + internalType: 'struct IUiIncentiveDataProviderV3.RewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'emissionPerSecond', internalType: 'uint256', type: 'uint256' }, + { name: 'incentivesLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'emissionEndTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'precision', internalType: 'uint8', type: 'uint8' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'sIncentiveData', + internalType: 'struct IUiIncentiveDataProviderV3.IncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'rewardsTokenInformation', + internalType: 'struct IUiIncentiveDataProviderV3.RewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'emissionPerSecond', internalType: 'uint256', type: 'uint256' }, + { name: 'incentivesLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'emissionEndTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'precision', internalType: 'uint8', type: 'uint8' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + ], + }, + { + name: '', + internalType: 'struct IUiIncentiveDataProviderV3.UserReserveIncentiveData[]', + type: 'tuple[]', + components: [ + { name: 'underlyingAsset', internalType: 'address', type: 'address' }, + { + name: 'aTokenIncentivesUserData', + internalType: 'struct IUiIncentiveDataProviderV3.UserIncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'userRewardsInformation', + internalType: 'struct IUiIncentiveDataProviderV3.UserRewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'userUnclaimedRewards', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesUserIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'vTokenIncentivesUserData', + internalType: 'struct IUiIncentiveDataProviderV3.UserIncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'userRewardsInformation', + internalType: 'struct IUiIncentiveDataProviderV3.UserRewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'userUnclaimedRewards', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesUserIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'sTokenIncentivesUserData', + internalType: 'struct IUiIncentiveDataProviderV3.UserIncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'userRewardsInformation', + internalType: 'struct IUiIncentiveDataProviderV3.UserRewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'userUnclaimedRewards', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesUserIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'provider', internalType: 'contract IPoolAddressesProvider', type: 'address' }], + name: 'getReservesIncentivesData', + outputs: [ + { + name: '', + internalType: 'struct IUiIncentiveDataProviderV3.AggregatedReserveIncentiveData[]', + type: 'tuple[]', + components: [ + { name: 'underlyingAsset', internalType: 'address', type: 'address' }, + { + name: 'aIncentiveData', + internalType: 'struct IUiIncentiveDataProviderV3.IncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'rewardsTokenInformation', + internalType: 'struct IUiIncentiveDataProviderV3.RewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'emissionPerSecond', internalType: 'uint256', type: 'uint256' }, + { name: 'incentivesLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'emissionEndTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'precision', internalType: 'uint8', type: 'uint8' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'vIncentiveData', + internalType: 'struct IUiIncentiveDataProviderV3.IncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'rewardsTokenInformation', + internalType: 'struct IUiIncentiveDataProviderV3.RewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'emissionPerSecond', internalType: 'uint256', type: 'uint256' }, + { name: 'incentivesLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'emissionEndTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'precision', internalType: 'uint8', type: 'uint8' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'sIncentiveData', + internalType: 'struct IUiIncentiveDataProviderV3.IncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'rewardsTokenInformation', + internalType: 'struct IUiIncentiveDataProviderV3.RewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'emissionPerSecond', internalType: 'uint256', type: 'uint256' }, + { name: 'incentivesLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'emissionEndTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'precision', internalType: 'uint8', type: 'uint8' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'provider', internalType: 'contract IPoolAddressesProvider', type: 'address' }, + { name: 'user', internalType: 'address', type: 'address' }, + ], + name: 'getUserReservesIncentivesData', + outputs: [ + { + name: '', + internalType: 'struct IUiIncentiveDataProviderV3.UserReserveIncentiveData[]', + type: 'tuple[]', + components: [ + { name: 'underlyingAsset', internalType: 'address', type: 'address' }, + { + name: 'aTokenIncentivesUserData', + internalType: 'struct IUiIncentiveDataProviderV3.UserIncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'userRewardsInformation', + internalType: 'struct IUiIncentiveDataProviderV3.UserRewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'userUnclaimedRewards', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesUserIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'vTokenIncentivesUserData', + internalType: 'struct IUiIncentiveDataProviderV3.UserIncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'userRewardsInformation', + internalType: 'struct IUiIncentiveDataProviderV3.UserRewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'userUnclaimedRewards', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesUserIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + { + name: 'sTokenIncentivesUserData', + internalType: 'struct IUiIncentiveDataProviderV3.UserIncentiveData', + type: 'tuple', + components: [ + { name: 'tokenAddress', internalType: 'address', type: 'address' }, + { name: 'incentiveControllerAddress', internalType: 'address', type: 'address' }, + { + name: 'userRewardsInformation', + internalType: 'struct IUiIncentiveDataProviderV3.UserRewardInfo[]', + type: 'tuple[]', + components: [ + { name: 'rewardTokenSymbol', internalType: 'string', type: 'string' }, + { name: 'rewardOracleAddress', internalType: 'address', type: 'address' }, + { name: 'rewardTokenAddress', internalType: 'address', type: 'address' }, + { name: 'userUnclaimedRewards', internalType: 'uint256', type: 'uint256' }, + { name: 'tokenIncentivesUserIndex', internalType: 'uint256', type: 'uint256' }, + { name: 'rewardPriceFeed', internalType: 'int256', type: 'int256' }, + { name: 'priceFeedDecimals', internalType: 'uint8', type: 'uint8' }, + { name: 'rewardTokenDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + }, + ], + }, + ], + stateMutability: 'view', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xA7F8A757C4f7696c015B595F51B2901AC0121B18) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x1472B7d120ab62D60f60e1D804B3858361c3C475) + */ +export const uiIncentiveDataProviderAddress = { + 1: '0xA7F8A757C4f7696c015B595F51B2901AC0121B18', + 5: '0x1472B7d120ab62D60f60e1D804B3858361c3C475', + 100: '0xA7F8A757C4f7696c015B595F51B2901AC0121B18', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xA7F8A757C4f7696c015B595F51B2901AC0121B18) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x1472B7d120ab62D60f60e1D804B3858361c3C475) + */ +export const uiIncentiveDataProviderConfig = { + address: uiIncentiveDataProviderAddress, + abi: uiIncentiveDataProviderAbi, +} as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// UiPoolDataProvider +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xF028c2F4b19898718fD0F77b9b881CbfdAa5e8Bb) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x36eddc380C7f370e5f05Da5Bd7F970a27f063e39) + */ +export const uiPoolDataProviderAbi = [ + { + type: 'constructor', + inputs: [ + { + name: '_networkBaseTokenPriceInUsdProxyAggregator', + internalType: 'contract IEACAggregatorProxy', + type: 'address', + }, + { + name: '_marketReferenceCurrencyPriceInUsdProxyAggregator', + internalType: 'contract IEACAggregatorProxy', + type: 'address', + }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'ETH_CURRENCY_UNIT', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'MKR_ADDRESS', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '_bytes32', internalType: 'bytes32', type: 'bytes32' }], + name: 'bytes32ToString', + outputs: [{ name: '', internalType: 'string', type: 'string' }], + stateMutability: 'pure', + }, + { + type: 'function', + inputs: [{ name: 'provider', internalType: 'contract IPoolAddressesProvider', type: 'address' }], + name: 'getReservesData', + outputs: [ + { + name: '', + internalType: 'struct IUiPoolDataProviderV3.AggregatedReserveData[]', + type: 'tuple[]', + components: [ + { name: 'underlyingAsset', internalType: 'address', type: 'address' }, + { name: 'name', internalType: 'string', type: 'string' }, + { name: 'symbol', internalType: 'string', type: 'string' }, + { name: 'decimals', internalType: 'uint256', type: 'uint256' }, + { name: 'baseLTVasCollateral', internalType: 'uint256', type: 'uint256' }, + { name: 'reserveLiquidationThreshold', internalType: 'uint256', type: 'uint256' }, + { name: 'reserveLiquidationBonus', internalType: 'uint256', type: 'uint256' }, + { name: 'reserveFactor', internalType: 'uint256', type: 'uint256' }, + { name: 'usageAsCollateralEnabled', internalType: 'bool', type: 'bool' }, + { name: 'borrowingEnabled', internalType: 'bool', type: 'bool' }, + { name: 'stableBorrowRateEnabled', internalType: 'bool', type: 'bool' }, + { name: 'isActive', internalType: 'bool', type: 'bool' }, + { name: 'isFrozen', internalType: 'bool', type: 'bool' }, + { name: 'liquidityIndex', internalType: 'uint128', type: 'uint128' }, + { name: 'variableBorrowIndex', internalType: 'uint128', type: 'uint128' }, + { name: 'liquidityRate', internalType: 'uint128', type: 'uint128' }, + { name: 'variableBorrowRate', internalType: 'uint128', type: 'uint128' }, + { name: 'stableBorrowRate', internalType: 'uint128', type: 'uint128' }, + { name: 'lastUpdateTimestamp', internalType: 'uint40', type: 'uint40' }, + { name: 'aTokenAddress', internalType: 'address', type: 'address' }, + { name: 'stableDebtTokenAddress', internalType: 'address', type: 'address' }, + { name: 'variableDebtTokenAddress', internalType: 'address', type: 'address' }, + { name: 'interestRateStrategyAddress', internalType: 'address', type: 'address' }, + { name: 'availableLiquidity', internalType: 'uint256', type: 'uint256' }, + { name: 'totalPrincipalStableDebt', internalType: 'uint256', type: 'uint256' }, + { name: 'averageStableRate', internalType: 'uint256', type: 'uint256' }, + { name: 'stableDebtLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + { name: 'totalScaledVariableDebt', internalType: 'uint256', type: 'uint256' }, + { name: 'priceInMarketReferenceCurrency', internalType: 'uint256', type: 'uint256' }, + { name: 'priceOracle', internalType: 'address', type: 'address' }, + { name: 'variableRateSlope1', internalType: 'uint256', type: 'uint256' }, + { name: 'variableRateSlope2', internalType: 'uint256', type: 'uint256' }, + { name: 'stableRateSlope1', internalType: 'uint256', type: 'uint256' }, + { name: 'stableRateSlope2', internalType: 'uint256', type: 'uint256' }, + { name: 'baseStableBorrowRate', internalType: 'uint256', type: 'uint256' }, + { name: 'baseVariableBorrowRate', internalType: 'uint256', type: 'uint256' }, + { name: 'optimalUsageRatio', internalType: 'uint256', type: 'uint256' }, + { name: 'isPaused', internalType: 'bool', type: 'bool' }, + { name: 'isSiloedBorrowing', internalType: 'bool', type: 'bool' }, + { name: 'accruedToTreasury', internalType: 'uint128', type: 'uint128' }, + { name: 'unbacked', internalType: 'uint128', type: 'uint128' }, + { name: 'isolationModeTotalDebt', internalType: 'uint128', type: 'uint128' }, + { name: 'flashLoanEnabled', internalType: 'bool', type: 'bool' }, + { name: 'debtCeiling', internalType: 'uint256', type: 'uint256' }, + { name: 'debtCeilingDecimals', internalType: 'uint256', type: 'uint256' }, + { name: 'eModeCategoryId', internalType: 'uint8', type: 'uint8' }, + { name: 'borrowCap', internalType: 'uint256', type: 'uint256' }, + { name: 'supplyCap', internalType: 'uint256', type: 'uint256' }, + { name: 'eModeLtv', internalType: 'uint16', type: 'uint16' }, + { name: 'eModeLiquidationThreshold', internalType: 'uint16', type: 'uint16' }, + { name: 'eModeLiquidationBonus', internalType: 'uint16', type: 'uint16' }, + { name: 'eModePriceSource', internalType: 'address', type: 'address' }, + { name: 'eModeLabel', internalType: 'string', type: 'string' }, + { name: 'borrowableInIsolation', internalType: 'bool', type: 'bool' }, + ], + }, + { + name: '', + internalType: 'struct IUiPoolDataProviderV3.BaseCurrencyInfo', + type: 'tuple', + components: [ + { name: 'marketReferenceCurrencyUnit', internalType: 'uint256', type: 'uint256' }, + { name: 'marketReferenceCurrencyPriceInUsd', internalType: 'int256', type: 'int256' }, + { name: 'networkBaseTokenPriceInUsd', internalType: 'int256', type: 'int256' }, + { name: 'networkBaseTokenPriceDecimals', internalType: 'uint8', type: 'uint8' }, + ], + }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'provider', internalType: 'contract IPoolAddressesProvider', type: 'address' }], + name: 'getReservesList', + outputs: [{ name: '', internalType: 'address[]', type: 'address[]' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'provider', internalType: 'contract IPoolAddressesProvider', type: 'address' }, + { name: 'user', internalType: 'address', type: 'address' }, + ], + name: 'getUserReservesData', + outputs: [ + { + name: '', + internalType: 'struct IUiPoolDataProviderV3.UserReserveData[]', + type: 'tuple[]', + components: [ + { name: 'underlyingAsset', internalType: 'address', type: 'address' }, + { name: 'scaledATokenBalance', internalType: 'uint256', type: 'uint256' }, + { name: 'usageAsCollateralEnabledOnUser', internalType: 'bool', type: 'bool' }, + { name: 'stableBorrowRate', internalType: 'uint256', type: 'uint256' }, + { name: 'scaledVariableDebt', internalType: 'uint256', type: 'uint256' }, + { name: 'principalStableDebt', internalType: 'uint256', type: 'uint256' }, + { name: 'stableBorrowLastUpdateTimestamp', internalType: 'uint256', type: 'uint256' }, + ], + }, + { name: '', internalType: 'uint8', type: 'uint8' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'marketReferenceCurrencyPriceInUsdProxyAggregator', + outputs: [{ name: '', internalType: 'contract IEACAggregatorProxy', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'networkBaseTokenPriceInUsdProxyAggregator', + outputs: [{ name: '', internalType: 'contract IEACAggregatorProxy', type: 'address' }], + stateMutability: 'view', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xF028c2F4b19898718fD0F77b9b881CbfdAa5e8Bb) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x36eddc380C7f370e5f05Da5Bd7F970a27f063e39) + */ +export const uiPoolDataProviderAddress = { + 1: '0xF028c2F4b19898718fD0F77b9b881CbfdAa5e8Bb', + 5: '0x36eddc380C7f370e5f05Da5Bd7F970a27f063e39', + 100: '0xF028c2F4b19898718fD0F77b9b881CbfdAa5e8Bb', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xF028c2F4b19898718fD0F77b9b881CbfdAa5e8Bb) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x36eddc380C7f370e5f05Da5Bd7F970a27f063e39) + */ +export const uiPoolDataProviderConfig = { address: uiPoolDataProviderAddress, abi: uiPoolDataProviderAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// V3Migrator +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xe2a3C1ff038E14d401cA6dE0673a598C33168460) + */ +export const v3MigratorAbi = [ + { type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'previousOwner', internalType: 'address', type: 'address', indexed: true }, + { name: 'newOwner', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'OwnershipTransferred', + }, + { + type: 'function', + inputs: [], + name: 'DAI', + outputs: [{ name: '', internalType: 'contract IERC20WithPermit', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'PSM', + outputs: [{ name: '', internalType: 'contract PsmLike', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'SPARK_POOL', + outputs: [{ name: '', internalType: 'contract IPool', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'STETH', + outputs: [{ name: '', internalType: 'contract IERC20WithPermit', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'USDC', + outputs: [{ name: '', internalType: 'contract IERC20WithPermit', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'V2_POOL', + outputs: [{ name: '', internalType: 'contract ILendingPool', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'V3_POOL', + outputs: [{ name: '', internalType: 'contract IPool', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'WSTETH', + outputs: [{ name: '', internalType: 'contract IWstETH', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'aTokens', + outputs: [{ name: '', internalType: 'contract IERC20WithPermit', type: 'address' }], + stateMutability: 'view', + }, + { type: 'function', inputs: [], name: 'cacheATokens', outputs: [], stateMutability: 'nonpayable' }, + { + type: 'function', + inputs: [ + { name: 'assetsToFlash', internalType: 'address[]', type: 'address[]' }, + { name: 'amountsToFlash', internalType: 'uint256[]', type: 'uint256[]' }, + { name: '', internalType: 'uint256[]', type: 'uint256[]' }, + { name: 'initiator', internalType: 'address', type: 'address' }, + { name: 'params', internalType: 'bytes', type: 'bytes' }, + ], + name: 'executeOperation', + outputs: [{ name: '', internalType: 'bool', type: 'bool' }], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'asset', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + ], + name: 'getMigrationSupply', + outputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: '', internalType: 'uint256', type: 'uint256' }, + ], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'assetsToMigrate', internalType: 'address[]', type: 'address[]' }, + { + name: 'positionsToRepay', + internalType: 'struct IMigrationHelper.RepaySimpleInput[]', + type: 'tuple[]', + components: [ + { name: 'asset', internalType: 'address', type: 'address' }, + { name: 'rateMode', internalType: 'uint256', type: 'uint256' }, + ], + }, + { + name: 'permits', + internalType: 'struct IMigrationHelper.PermitInput[]', + type: 'tuple[]', + components: [ + { name: 'aToken', internalType: 'contract IERC20WithPermit', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + { name: 'deadline', internalType: 'uint256', type: 'uint256' }, + { name: 'v', internalType: 'uint8', type: 'uint8' }, + { name: 'r', internalType: 'bytes32', type: 'bytes32' }, + { name: 's', internalType: 'bytes32', type: 'bytes32' }, + ], + }, + { + name: 'creditDelegationPermits', + internalType: 'struct IMigrationHelper.CreditDelegationInput[]', + type: 'tuple[]', + components: [ + { name: 'debtToken', internalType: 'contract ICreditDelegationToken', type: 'address' }, + { name: 'value', internalType: 'uint256', type: 'uint256' }, + { name: 'deadline', internalType: 'uint256', type: 'uint256' }, + { name: 'v', internalType: 'uint8', type: 'uint8' }, + { name: 'r', internalType: 'bytes32', type: 'bytes32' }, + { name: 's', internalType: 'bytes32', type: 'bytes32' }, + ], + }, + ], + name: 'migrate', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'owner', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { type: 'function', inputs: [], name: 'renounceOwnership', outputs: [], stateMutability: 'nonpayable' }, + { + type: 'function', + inputs: [ + { + name: 'emergencyInput', + internalType: 'struct IMigrationHelper.EmergencyTransferInput[]', + type: 'tuple[]', + components: [ + { name: 'asset', internalType: 'contract IERC20WithPermit', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + { name: 'to', internalType: 'address', type: 'address' }, + ], + }, + ], + name: 'rescueFunds', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'sTokens', + outputs: [{ name: '', internalType: 'contract IERC20WithPermit', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'vTokens', + outputs: [{ name: '', internalType: 'contract IERC20WithPermit', type: 'address' }], + stateMutability: 'view', + }, +] as const + +/** + * [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xe2a3C1ff038E14d401cA6dE0673a598C33168460) + */ +export const v3MigratorAddress = { + 1: '0xe2a3C1ff038E14d401cA6dE0673a598C33168460', +} as const + +/** + * [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xe2a3C1ff038E14d401cA6dE0673a598C33168460) + */ +export const v3MigratorConfig = { address: v3MigratorAddress, abi: v3MigratorAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// Vat +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xB966002DDAa2Baf48369f5015329750019736031) + */ +export const vatAbi = [ + { payable: false, type: 'constructor', inputs: [], stateMutability: 'nonpayable' }, + { + type: 'event', + anonymous: true, + inputs: [ + { name: 'sig', internalType: 'bytes4', type: 'bytes4', indexed: true }, + { name: 'arg1', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'arg2', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'arg3', internalType: 'bytes32', type: 'bytes32', indexed: true }, + { name: 'data', internalType: 'bytes', type: 'bytes', indexed: false }, + ], + name: 'LogNote', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'Line', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [], + name: 'cage', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: '', internalType: 'address', type: 'address' }, + ], + name: 'can', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'dai', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'debt', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'deny', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'ilk', internalType: 'bytes32', type: 'bytes32' }, + { name: 'what', internalType: 'bytes32', type: 'bytes32' }, + { name: 'data', internalType: 'uint256', type: 'uint256' }, + ], + name: 'file', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'what', internalType: 'bytes32', type: 'bytes32' }, + { name: 'data', internalType: 'uint256', type: 'uint256' }, + ], + name: 'file', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'ilk', internalType: 'bytes32', type: 'bytes32' }, + { name: 'src', internalType: 'address', type: 'address' }, + { name: 'dst', internalType: 'address', type: 'address' }, + { name: 'wad', internalType: 'uint256', type: 'uint256' }, + ], + name: 'flux', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'i', internalType: 'bytes32', type: 'bytes32' }, + { name: 'u', internalType: 'address', type: 'address' }, + { name: 'rate', internalType: 'int256', type: 'int256' }, + ], + name: 'fold', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'ilk', internalType: 'bytes32', type: 'bytes32' }, + { name: 'src', internalType: 'address', type: 'address' }, + { name: 'dst', internalType: 'address', type: 'address' }, + { name: 'dink', internalType: 'int256', type: 'int256' }, + { name: 'dart', internalType: 'int256', type: 'int256' }, + ], + name: 'fork', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'i', internalType: 'bytes32', type: 'bytes32' }, + { name: 'u', internalType: 'address', type: 'address' }, + { name: 'v', internalType: 'address', type: 'address' }, + { name: 'w', internalType: 'address', type: 'address' }, + { name: 'dink', internalType: 'int256', type: 'int256' }, + { name: 'dart', internalType: 'int256', type: 'int256' }, + ], + name: 'frob', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [ + { name: '', internalType: 'bytes32', type: 'bytes32' }, + { name: '', internalType: 'address', type: 'address' }, + ], + name: 'gem', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'i', internalType: 'bytes32', type: 'bytes32' }, + { name: 'u', internalType: 'address', type: 'address' }, + { name: 'v', internalType: 'address', type: 'address' }, + { name: 'w', internalType: 'address', type: 'address' }, + { name: 'dink', internalType: 'int256', type: 'int256' }, + { name: 'dart', internalType: 'int256', type: 'int256' }, + ], + name: 'grab', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'rad', internalType: 'uint256', type: 'uint256' }], + name: 'heal', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'hope', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [{ name: '', internalType: 'bytes32', type: 'bytes32' }], + name: 'ilks', + outputs: [ + { name: 'Art', internalType: 'uint256', type: 'uint256' }, + { name: 'rate', internalType: 'uint256', type: 'uint256' }, + { name: 'spot', internalType: 'uint256', type: 'uint256' }, + { name: 'line', internalType: 'uint256', type: 'uint256' }, + { name: 'dust', internalType: 'uint256', type: 'uint256' }, + ], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'ilk', internalType: 'bytes32', type: 'bytes32' }], + name: 'init', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'live', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'src', internalType: 'address', type: 'address' }, + { name: 'dst', internalType: 'address', type: 'address' }, + { name: 'rad', internalType: 'uint256', type: 'uint256' }, + ], + name: 'move', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'nope', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [{ name: 'usr', internalType: 'address', type: 'address' }], + name: 'rely', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'sin', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'ilk', internalType: 'bytes32', type: 'bytes32' }, + { name: 'usr', internalType: 'address', type: 'address' }, + { name: 'wad', internalType: 'int256', type: 'int256' }, + ], + name: 'slip', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: false, + payable: false, + type: 'function', + inputs: [ + { name: 'u', internalType: 'address', type: 'address' }, + { name: 'v', internalType: 'address', type: 'address' }, + { name: 'rad', internalType: 'uint256', type: 'uint256' }, + ], + name: 'suck', + outputs: [], + stateMutability: 'nonpayable', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [ + { name: '', internalType: 'bytes32', type: 'bytes32' }, + { name: '', internalType: 'address', type: 'address' }, + ], + name: 'urns', + outputs: [ + { name: 'ink', internalType: 'uint256', type: 'uint256' }, + { name: 'art', internalType: 'uint256', type: 'uint256' }, + ], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [], + name: 'vice', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + constant: true, + payable: false, + type: 'function', + inputs: [{ name: '', internalType: 'address', type: 'address' }], + name: 'wards', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xB966002DDAa2Baf48369f5015329750019736031) + */ +export const vatAddress = { + 1: '0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B', + 5: '0xB966002DDAa2Baf48369f5015329750019736031', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xB966002DDAa2Baf48369f5015329750019736031) + */ +export const vatConfig = { address: vatAddress, abi: vatAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// WETHGateway +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xBD7D6a9ad7865463DE44B05F04559f65e3B11704) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xe6fC577E87F7c977c4393300417dCC592D90acF8) + */ +export const wethGatewayAbi = [ + { + type: 'constructor', + inputs: [ + { name: 'weth', internalType: 'address', type: 'address' }, + { name: 'owner', internalType: 'address', type: 'address' }, + { name: 'pool', internalType: 'contract IPool', type: 'address' }, + ], + stateMutability: 'nonpayable', + }, + { + type: 'event', + anonymous: false, + inputs: [ + { name: 'previousOwner', internalType: 'address', type: 'address', indexed: true }, + { name: 'newOwner', internalType: 'address', type: 'address', indexed: true }, + ], + name: 'OwnershipTransferred', + }, + { type: 'fallback', stateMutability: 'payable' }, + { + type: 'function', + inputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + { name: 'interestRateMode', internalType: 'uint256', type: 'uint256' }, + { name: 'referralCode', internalType: 'uint16', type: 'uint16' }, + ], + name: 'borrowETH', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: 'onBehalfOf', internalType: 'address', type: 'address' }, + { name: 'referralCode', internalType: 'uint16', type: 'uint16' }, + ], + name: 'depositETH', + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [ + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + ], + name: 'emergencyEtherTransfer', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: 'token', internalType: 'address', type: 'address' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + ], + name: 'emergencyTokenTransfer', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [], + name: 'getWETHAddress', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [], + name: 'owner', + outputs: [{ name: '', internalType: 'address', type: 'address' }], + stateMutability: 'view', + }, + { type: 'function', inputs: [], name: 'renounceOwnership', outputs: [], stateMutability: 'nonpayable' }, + { + type: 'function', + inputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + { name: 'rateMode', internalType: 'uint256', type: 'uint256' }, + { name: 'onBehalfOf', internalType: 'address', type: 'address' }, + ], + name: 'repayETH', + outputs: [], + stateMutability: 'payable', + }, + { + type: 'function', + inputs: [{ name: 'newOwner', internalType: 'address', type: 'address' }], + name: 'transferOwnership', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + { name: 'to', internalType: 'address', type: 'address' }, + ], + name: 'withdrawETH', + outputs: [], + stateMutability: 'nonpayable', + }, + { + type: 'function', + inputs: [ + { name: '', internalType: 'address', type: 'address' }, + { name: 'amount', internalType: 'uint256', type: 'uint256' }, + { name: 'to', internalType: 'address', type: 'address' }, + { name: 'deadline', internalType: 'uint256', type: 'uint256' }, + { name: 'permitV', internalType: 'uint8', type: 'uint8' }, + { name: 'permitR', internalType: 'bytes32', type: 'bytes32' }, + { name: 'permitS', internalType: 'bytes32', type: 'bytes32' }, + ], + name: 'withdrawETHWithPermit', + outputs: [], + stateMutability: 'nonpayable', + }, + { type: 'receive', stateMutability: 'payable' }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xBD7D6a9ad7865463DE44B05F04559f65e3B11704) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xe6fC577E87F7c977c4393300417dCC592D90acF8) + */ +export const wethGatewayAddress = { + 1: '0xBD7D6a9ad7865463DE44B05F04559f65e3B11704', + 5: '0xe6fC577E87F7c977c4393300417dCC592D90acF8', + 100: '0xBD7D6a9ad7865463DE44B05F04559f65e3B11704', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xBD7D6a9ad7865463DE44B05F04559f65e3B11704) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0xe6fC577E87F7c977c4393300417dCC592D90acF8) + */ +export const wethGatewayConfig = { address: wethGatewayAddress, abi: wethGatewayAbi } as const + +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// +// WalletBalanceProvider +////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xd2AeF86F51F92E8e49F42454c287AE4879D1BeDc) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x261135877A92B42183c998bFB8580558a28377a6) + */ +export const walletBalanceProviderAbi = [ + { + type: 'function', + inputs: [ + { name: 'user', internalType: 'address', type: 'address' }, + { name: 'token', internalType: 'address', type: 'address' }, + ], + name: 'balanceOf', + outputs: [{ name: '', internalType: 'uint256', type: 'uint256' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'users', internalType: 'address[]', type: 'address[]' }, + { name: 'tokens', internalType: 'address[]', type: 'address[]' }, + ], + name: 'batchBalanceOf', + outputs: [{ name: '', internalType: 'uint256[]', type: 'uint256[]' }], + stateMutability: 'view', + }, + { + type: 'function', + inputs: [ + { name: 'provider', internalType: 'address', type: 'address' }, + { name: 'user', internalType: 'address', type: 'address' }, + ], + name: 'getUserWalletBalances', + outputs: [ + { name: '', internalType: 'address[]', type: 'address[]' }, + { name: '', internalType: 'uint256[]', type: 'uint256[]' }, + ], + stateMutability: 'view', + }, + { type: 'receive', stateMutability: 'payable' }, +] as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xd2AeF86F51F92E8e49F42454c287AE4879D1BeDc) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x261135877A92B42183c998bFB8580558a28377a6) + */ +export const walletBalanceProviderAddress = { + 1: '0xd2AeF86F51F92E8e49F42454c287AE4879D1BeDc', + 5: '0x261135877A92B42183c998bFB8580558a28377a6', + 100: '0xd2AeF86F51F92E8e49F42454c287AE4879D1BeDc', +} as const + +/** + * - [__View Contract on Ethereum Etherscan__](https://etherscan.io/address/0xd2AeF86F51F92E8e49F42454c287AE4879D1BeDc) + * - [__View Contract on Goerli Etherscan__](https://goerli.etherscan.io/address/0x261135877A92B42183c998bFB8580558a28377a6) + */ +export const walletBalanceProviderConfig = { + address: walletBalanceProviderAddress, + abi: walletBalanceProviderAbi, +} as const diff --git a/packages/app/src/config/feature-flags/appConfig.default.ts b/packages/app/src/config/feature-flags/appConfig.default.ts new file mode 100644 index 000000000..11cd8dcaf --- /dev/null +++ b/packages/app/src/config/feature-flags/appConfig.default.ts @@ -0,0 +1,66 @@ +import { mainnet } from 'viem/chains' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { AppConfig } from '.' +import { clientEnv } from './clientEnv' + +export function getDefaultAppConfig(): AppConfig { + return { + sandbox: featureFlag('VITE_FEATURE_SANDBOX') && { + originChainId: mainnet.id, + forkChainIdPrefix: 3030, + chainName: 'Spark Sandbox', + + mintBalances: { + etherAmt: NormalizedUnitNumber(10_000), + tokenAmt: NormalizedUnitNumber(10_000), + tokens: { + DAI: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + decimals: 18, + }, + sDAI: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + decimals: 18, + }, + USDC: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }, + WETH: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }, + wstETH: { + address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + decimals: 18, + }, + WBTC: { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + decimals: 8, + }, + GNO: { + address: '0x6810e776880C02933D47DB1b9fc05908e5386b96', + decimals: 18, + }, + rETH: { + address: '0xae78736Cd615f374D3085123A210448E74Fc6393', + decimals: 18, + }, + USDT: { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, + }, + }, + }, + } +} + +/** + * @note: do not use outside this file because it will break dead code elimination + */ +function featureFlag(key: string): true | undefined { + return clientEnv.boolean(key) || undefined +} diff --git a/packages/app/src/config/feature-flags/appConfig.testing.ts b/packages/app/src/config/feature-flags/appConfig.testing.ts new file mode 100644 index 000000000..2998ff271 --- /dev/null +++ b/packages/app/src/config/feature-flags/appConfig.testing.ts @@ -0,0 +1,7 @@ +import { AppConfig } from '.' + +export function getTestingAppConfig(): AppConfig { + return { + sandbox: undefined, + } +} diff --git a/packages/app/src/config/feature-flags/clientEnv.ts b/packages/app/src/config/feature-flags/clientEnv.ts new file mode 100644 index 000000000..2d4159b30 --- /dev/null +++ b/packages/app/src/config/feature-flags/clientEnv.ts @@ -0,0 +1,13 @@ +import invariant from 'tiny-invariant' + +export function clientEnv(key: string): string { + const value = import.meta.env[key] + invariant(value, `${key} env not defined`) + + return value +} + +clientEnv.boolean = (key: string): boolean => { + const value = import.meta.env[key] + return value === '1' +} diff --git a/packages/app/src/config/feature-flags/index.ts b/packages/app/src/config/feature-flags/index.ts new file mode 100644 index 000000000..0adc37874 --- /dev/null +++ b/packages/app/src/config/feature-flags/index.ts @@ -0,0 +1,27 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { getDefaultAppConfig } from './appConfig.default' +import { getTestingAppConfig } from './appConfig.testing' + +export const getAppConfig = import.meta.env.MODE === 'test' ? getTestingAppConfig : getDefaultAppConfig + +/** + * @note: Do not use config data to check for feature availability. Use import.meta.env instead to make dead code elimination work. + */ +export interface AppConfig { + sandbox?: { + originChainId: number + forkChainIdPrefix: number + chainName: string + mintBalances: { + etherAmt: NormalizedUnitNumber + tokenAmt: NormalizedUnitNumber + tokens: { + [name: string]: { + address: string + decimals: number + } + } + } + } +} diff --git a/packages/app/src/config/paths.ts b/packages/app/src/config/paths.ts new file mode 100644 index 000000000..e6eb958c6 --- /dev/null +++ b/packages/app/src/config/paths.ts @@ -0,0 +1,7 @@ +export const paths = { + easyBorrow: '/', + dashboard: '/dashboard', + markets: '/markets', + savings: '/savings', + marketDetails: '/market-details/:chainId/:asset', +} as const diff --git a/packages/app/src/config/query-client.ts b/packages/app/src/config/query-client.ts new file mode 100644 index 000000000..9301eb0c5 --- /dev/null +++ b/packages/app/src/config/query-client.ts @@ -0,0 +1,12 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + gcTime: 1_000 * 60 * 60 * 24, // 24 hours + refetchOnWindowFocus: true, + retry: 0, + staleTime: 1_000 * 60, // 1 minute + }, + }, +}) diff --git a/packages/app/src/config/tailwind.ts b/packages/app/src/config/tailwind.ts new file mode 100644 index 000000000..0f54b6f2b --- /dev/null +++ b/packages/app/src/config/tailwind.ts @@ -0,0 +1,3 @@ +export const screensOverrides = { + '2xl': '1400px', +} diff --git a/packages/app/src/config/wagmi/config.default.ts b/packages/app/src/config/wagmi/config.default.ts new file mode 100644 index 000000000..d69949e68 --- /dev/null +++ b/packages/app/src/config/wagmi/config.default.ts @@ -0,0 +1,62 @@ +import { getDefaultConfig, getDefaultWallets } from '@rainbow-me/rainbowkit' +import { Chain, http, Transport, webSocket } from 'viem' +import { gnosis, goerli, mainnet } from 'viem/chains' +import { Config } from 'wagmi' + +import { SandboxNetwork } from '@/domain/state/sandbox' +import { raise } from '@/utils/raise' + +import { SUPPORTED_CHAINS } from '../chain/constants' +import { SupportedChainId } from '../chain/types' +import { VIEM_TIMEOUT_ON_FORKS } from './config.e2e' + +const { wallets } = getDefaultWallets() + +const ALCHEMY_API_KEY = 'WVOCPHOxAVE1R9PySEqcO7WX2b9_V-9L' + +export function getConfig(sandboxNetwork?: SandboxNetwork): Config { + const forkChain = getForkChainFromSandboxConfig(sandboxNetwork) + + const transports: Record = { + [mainnet.id]: webSocket(`wss://eth-mainnet.g.alchemy.com/v2/${ALCHEMY_API_KEY}`), + [gnosis.id]: http('https://rpc.ankr.com/gnosis'), + } + + const config = getDefaultConfig({ + appName: 'Spark', + projectId: import.meta.env.VITE_WALLET_CONNECT_ID || raise('Missing VITE_WALLET_CONNECT_ID'), + chains: forkChain ? [...SUPPORTED_CHAINS, forkChain] : SUPPORTED_CHAINS, + transports: forkChain + ? { ...transports, [forkChain.id]: http(forkChain.rpcUrls.default.http[0], { timeout: VIEM_TIMEOUT_ON_FORKS }) } + : transports, + wallets, + }) + + return config +} + +function getForkChainFromSandboxConfig(sandboxNetwork?: SandboxNetwork): Chain | undefined { + if (!sandboxNetwork) { + return undefined + } + + const base = (() => { + if (sandboxNetwork.originChainId === mainnet.id) { + return mainnet + } + if (sandboxNetwork.originChainId === goerli.id) { + return goerli + } + throw new Error(`Unsupported origin chain = ${sandboxNetwork.originChainId}!`) + })() + + return { + ...base, + id: sandboxNetwork.forkChainId, + name: sandboxNetwork.name, + rpcUrls: { + public: { http: [sandboxNetwork.forkUrl] }, + default: { http: [sandboxNetwork.forkUrl] }, + }, + } +} diff --git a/packages/app/src/config/wagmi/config.e2e.ts b/packages/app/src/config/wagmi/config.e2e.ts new file mode 100644 index 000000000..751d0139b --- /dev/null +++ b/packages/app/src/config/wagmi/config.e2e.ts @@ -0,0 +1,74 @@ +import { Address, createWalletClient, http, isAddress, Transport } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { mainnet } from 'viem/chains' +import { Config, createConfig } from 'wagmi' +import { z } from 'zod' + +import { SandboxNetwork } from '@/domain/state/sandbox' +import { createMockConnector } from '@/domain/wallet/createMockConnector' + +import { getConfig } from './config.default' + +export const PLAYWRIGHT_WALLET_ADDRESS_KEY = '__PLAYWRIGHT_WALLET_ADDRESS' as const +export const PLAYWRIGHT_WALLET_PRIVATE_KEY_KEY = '__PLAYWRIGHT_WALLET_PRIVATE_KEY' as const +export const PLAYWRIGHT_WALLET_FORK_URL_KEY = '__PLAYWRIGHT_WALLET_FORK_URL_KEY' as const + +export const VIEM_TIMEOUT_ON_FORKS = 60_000 // forks tend to be slow. This improves reliability/performance. Default is 10_000 + +const addressSchema = z.custom

((address) => isAddress(address as string)) + +type PrivateKey = `0x${string}` +const privateKeySchema = z.custom((privateKey) => { + const privateKeyRegex = /^0x[a-fA-F0-9]{64}$/ + + return privateKeyRegex.test(privateKey as string) +}) + +export function getInjectedTransport(): Transport { + return http((window as any)[PLAYWRIGHT_WALLET_FORK_URL_KEY], { timeout: VIEM_TIMEOUT_ON_FORKS }) +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getMockConnectors() { + // Injects a mock connector if a wallet address or private key are injected into window object. + // Private key takes precedence over address. + const savedAddressSafeParse = addressSchema.safeParse((window as any)[PLAYWRIGHT_WALLET_ADDRESS_KEY]) + const savedAddress = savedAddressSafeParse.success ? savedAddressSafeParse.data : undefined + const savedPrivateKeySafeParse = privateKeySchema.safeParse((window as any)[PLAYWRIGHT_WALLET_PRIVATE_KEY_KEY]) + const savedPrivateKey = savedPrivateKeySafeParse.success ? savedPrivateKeySafeParse.data : undefined + const account = savedPrivateKey ? privateKeyToAccount(savedPrivateKey) : savedAddress + + if (!account) { + return [] + } + + const walletClient = createWalletClient({ + transport: getInjectedTransport(), + chain: mainnet, + pollingInterval: 100, + account, + }) + + const mockConnector = createMockConnector(walletClient) + + return [mockConnector] +} + +export function getMockConfig(sandboxNetwork?: SandboxNetwork): Config { + // if not configured properly assume just fallback to default config + if (!(window as any)[PLAYWRIGHT_WALLET_FORK_URL_KEY]) { + // eslint-disable-next-line no-console + console.warn('Mock config not found. Loading default config.') + return getConfig(sandboxNetwork) + } + + const connectors = getMockConnectors() + + const config = createConfig({ + chains: [mainnet], + transports: { [mainnet.id]: getInjectedTransport() }, + connectors, + }) + + return config +} diff --git a/packages/app/src/config/wagmi/index.ts b/packages/app/src/config/wagmi/index.ts new file mode 100644 index 000000000..87e31318b --- /dev/null +++ b/packages/app/src/config/wagmi/index.ts @@ -0,0 +1,7 @@ +import { getConfig as getDefaultConfig } from './config.default' +import { getMockConfig } from './config.e2e' + +// @note: even in devmode we try to use mock config (which enables running e2e tests) but if we detect that it's not configured properly (inside getMockConfig) +// it will fallback to default config. +export const getConfig = + import.meta.env.VITE_PLAYWRIGHT || import.meta.env.MODE === 'development' ? getMockConfig : getDefaultConfig diff --git a/packages/app/src/css/fonts.css b/packages/app/src/css/fonts.css new file mode 100644 index 000000000..177971e3d --- /dev/null +++ b/packages/app/src/css/fonts.css @@ -0,0 +1,8 @@ +@font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: block; + font-named-instance: 'Regular'; + src: url('../fonts/InterVariable.woff2') format('woff2'); +} diff --git a/packages/app/src/css/main.css b/packages/app/src/css/main.css new file mode 100644 index 000000000..ee93f61fb --- /dev/null +++ b/packages/app/src/css/main.css @@ -0,0 +1,119 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --body-background: 210 20% 98%; + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + + --primary: #0b2140; + --primary-bg: 227, 85%, 59%; + --primary-foreground: 0 0% 100%; + --primary-hover: 215, 71%, 15%; + + --secondary: 240, 20%, 98%; + --secondary-foreground: 215, 71%, 15%; + + --accent: 240 4.8% 95.9%; + --accent-foreground: 240 5.9% 10%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + + --nav-primary: 63 102 239; + --nav-shadow: rgba(11, 33, 64, 0.08); + + --tooltip-shadow: rgba(9, 24, 44, 0.15); + + --ring: 240 5% 64.9%; + + --radius: 0.5rem; + + --panel-border: rgba(35, 66, 84, 0.1); + --panel-bg: #f9f9fb; + --input-background: #f9f9fb; + --icon-foreground: 106 118 146; + + --product-green: 109 194 117; + --product-orange: 242 153 20; + --product-red: 252 80 56; + --product-blue: 62 100 239; + --product-dai: 245 172 55; + --product-sdai: 38 182 62; + --prompt-foreground: #6a7692; + --success-background: #e2f1ec; + --checkbox: #0b2140; + --product-dark-blue: 37, 87, 214; + + --basics-black: 11 33 64; + --basics-white: 255 255 255; + --basics-border: rgba(35, 66, 84, 0.1); + --basics-dark-grey: 106 118 146; + --basics-light-grey: 249 249 251; + --basics-green: 51 190 39; + --basics-red: 220 38 38; + --spark: 242 166 43; + + --main-blue: 64 102 239; + + --sec-green: 109 194 117; + } + + .dark { + --background: 240 10% 3.9%; + --foreground: 0 0% 98%; + + --muted: 240 3.7% 15.9%; + --muted-foreground: 240 5% 64.9%; + + --popover: 240 10% 3.9%; + --popover-foreground: 0 0% 98%; + + --card: 240 10% 3.9%; + --card-foreground: 0 0% 98%; + + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + + --primary: 0 0% 98%; + --primary-foreground: 240 5.9% 10%; + + --secondary: 240 3.7% 15.9%; + --secondary-foreground: 0 0% 98%; + + --accent: 240 3.7% 15.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + + --ring: 240 3.7% 15.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-body text-foreground slashed-zero; + } + input { + background: none; + @apply slashed-zero; + } +} diff --git a/packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.test.ts b/packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.test.ts new file mode 100644 index 000000000..0ef89f4fe --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.test.ts @@ -0,0 +1,15 @@ +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { calculateMaxBorrowBasedOnCollateral } from './calculateMaxBorrowBasedOnCollateral' + +describe(calculateMaxBorrowBasedOnCollateral.name, () => { + it('calculates max borrow for an asset', () => { + expect( + calculateMaxBorrowBasedOnCollateral({ + totalCollateralUSD: NormalizedUnitNumber(5000), + maxLoanToValue: Percentage(0.8), + totalBorrowsUSD: NormalizedUnitNumber(1000), + borrowingAssetPriceUsd: NormalizedUnitNumber(1500), + }), + ).toStrictEqual(NormalizedUnitNumber(2)) + }) +}) diff --git a/packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.ts b/packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.ts new file mode 100644 index 000000000..0f49c38ae --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/calculateMaxBorrowBasedOnCollateral.ts @@ -0,0 +1,24 @@ +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' + +interface CalculateMaxBorrowBasedOnCollateralParams { + totalCollateralUSD: NormalizedUnitNumber + totalBorrowsUSD: NormalizedUnitNumber + maxLoanToValue: Percentage + borrowingAssetPriceUsd: BigNumber +} + +export function calculateMaxBorrowBasedOnCollateral({ + totalCollateralUSD, + totalBorrowsUSD, + maxLoanToValue, + borrowingAssetPriceUsd, +}: CalculateMaxBorrowBasedOnCollateralParams): NormalizedUnitNumber { + const collateralBasedBorrowLimit = totalCollateralUSD + .multipliedBy(maxLoanToValue) + .minus(totalBorrowsUSD) + .dividedBy(borrowingAssetPriceUsd) + + return NormalizedUnitNumber(collateralBasedBorrowLimit) +} diff --git a/packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.test.ts b/packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.test.ts new file mode 100644 index 000000000..aacddcf4c --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.test.ts @@ -0,0 +1,176 @@ +import { NormalizedUnitNumber } from '../types/NumericValues' +import { getBorrowMaxValue } from './getBorrowMaxValue' + +describe(getBorrowMaxValue.name, () => { + describe('unlimited liquidity', () => { + it('returns 0 when no collateral based borrow limit', () => { + expect( + getBorrowMaxValue({ + asset: { + availableLiquidity: NormalizedUnitNumber(Infinity), + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(0), + }, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns collateral based borrow limit', () => { + expect( + getBorrowMaxValue({ + asset: { + availableLiquidity: NormalizedUnitNumber(Infinity), + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + }, + }), + ).toEqual(NormalizedUnitNumber(100)) + }) + }) + + describe('limited liquidity', () => { + it('returns 0 when collateral based borrow limit 0', () => { + expect( + getBorrowMaxValue({ + asset: { + availableLiquidity: NormalizedUnitNumber(10), + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(0), + }, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns available liquidity value when smaller than borrow limit', () => { + expect( + getBorrowMaxValue({ + asset: { + availableLiquidity: NormalizedUnitNumber(10), + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + }, + }), + ).toEqual(NormalizedUnitNumber(10)) + }) + }) + + describe('isolation mode', () => { + it('returns 0 when no collateral based borrow limit', () => { + expect( + getBorrowMaxValue({ + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(0), + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(0), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(100), + }, + asset: { + availableLiquidity: NormalizedUnitNumber(Infinity), + }, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns collateral based borrow limit', () => { + expect( + getBorrowMaxValue({ + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(0), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(100), + }, + asset: { + availableLiquidity: NormalizedUnitNumber(Infinity), + }, + }), + ).toEqual(NormalizedUnitNumber(100)) + }) + + it('returns correct value when isolation mode collateral debt and ceiling present', () => { + expect( + getBorrowMaxValue({ + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(50), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(100), + }, + + asset: { + availableLiquidity: NormalizedUnitNumber(Infinity), + }, + }), + ).toEqual(NormalizedUnitNumber(50)) + }) + }) + + describe('existing borrow validation issue', () => { + const userAndAsset = { + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + }, + asset: { + availableLiquidity: NormalizedUnitNumber(100), + }, + } + + it('returns 0 when reserve not active', () => { + expect( + getBorrowMaxValue({ + ...userAndAsset, + validationIssue: 'reserve-not-active', + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when reserve borrowing disabled', () => { + expect( + getBorrowMaxValue({ + ...userAndAsset, + validationIssue: 'reserve-borrowing-disabled', + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when asset not borrowable in isolation', () => { + expect( + getBorrowMaxValue({ + ...userAndAsset, + validationIssue: 'asset-not-borrowable-in-isolation', + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when siloed mode cannot enable', () => { + expect( + getBorrowMaxValue({ + ...userAndAsset, + validationIssue: 'siloed-mode-cannot-enable', + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when siloed mode enabled', () => { + expect( + getBorrowMaxValue({ + ...userAndAsset, + validationIssue: 'siloed-mode-enabled', + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when emode category mismatch', () => { + expect( + getBorrowMaxValue({ + ...userAndAsset, + validationIssue: 'emode-category-mismatch', + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + }) +}) diff --git a/packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.ts b/packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.ts new file mode 100644 index 000000000..7b423570e --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getBorrowMaxValue.ts @@ -0,0 +1,38 @@ +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber } from '../types/NumericValues' + +interface GetBorrowMaxValueParams { + asset: { + availableLiquidity: NormalizedUnitNumber + } + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber + inIsolationMode?: boolean + isolationModeCollateralTotalDebt?: NormalizedUnitNumber + isolationModeCollateralDebtCeiling?: NormalizedUnitNumber + } + validationIssue?: string +} + +export function getBorrowMaxValue({ asset, user, validationIssue }: GetBorrowMaxValueParams): NormalizedUnitNumber { + if ( + validationIssue === 'reserve-not-active' || + validationIssue === 'reserve-borrowing-disabled' || + validationIssue === 'asset-not-borrowable-in-isolation' || + validationIssue === 'siloed-mode-cannot-enable' || + validationIssue === 'siloed-mode-enabled' || + validationIssue === 'emode-category-mismatch' + ) { + return NormalizedUnitNumber(0) + } + + const ceilings = [asset.availableLiquidity, user.maxBorrowBasedOnCollateral] + const { inIsolationMode, isolationModeCollateralTotalDebt, isolationModeCollateralDebtCeiling } = user + + if (inIsolationMode && isolationModeCollateralTotalDebt && isolationModeCollateralDebtCeiling) { + ceilings.push(NormalizedUnitNumber(isolationModeCollateralDebtCeiling.minus(isolationModeCollateralTotalDebt))) + } + + return NormalizedUnitNumber(BigNumber.min(...ceilings)) +} diff --git a/packages/app/src/domain/action-max-value-getters/getDepositMaxValue.test.ts b/packages/app/src/domain/action-max-value-getters/getDepositMaxValue.test.ts new file mode 100644 index 000000000..7d47b7b51 --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getDepositMaxValue.test.ts @@ -0,0 +1,160 @@ +import { bigNumberify } from '@/utils/bigNumber' + +import { NormalizedUnitNumber } from '../types/NumericValues' +import { getDepositMaxValue } from './getDepositMaxValue' + +const assetParams = { + decimals: 8, + index: bigNumberify('1000184375813842487460564746'), + rate: bigNumberify('521468880203607399181048'), + lastUpdateTimestamp: 0, +} + +describe(getDepositMaxValue.name, () => { + describe('not active reserve', () => { + it('returns 0 for frozen reserve', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(100) }, + asset: { + status: 'frozen', + totalLiquidity: NormalizedUnitNumber(0), + totalDebt: NormalizedUnitNumber(0), + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 for paused reserve', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(100) }, + asset: { + status: 'paused', + totalLiquidity: NormalizedUnitNumber(0), + totalDebt: NormalizedUnitNumber(0), + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + }) + + describe('no supply cap', () => { + it('returns 0 when no balance', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(0) }, + asset: { + status: 'active', + totalLiquidity: NormalizedUnitNumber(10), + totalDebt: NormalizedUnitNumber(0), + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns balance', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(100) }, + asset: { + status: 'active', + totalLiquidity: NormalizedUnitNumber(0), + totalDebt: NormalizedUnitNumber(0), + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(100)) + }) + }) + + describe('supply cap', () => { + it('returns 0 when no balance', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(0) }, + asset: { + status: 'active', + totalLiquidity: NormalizedUnitNumber(0), + supplyCap: NormalizedUnitNumber(100), + totalDebt: NormalizedUnitNumber(0), + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when supply cap reached', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(100) }, + asset: { + status: 'active', + totalLiquidity: NormalizedUnitNumber(100), + supplyCap: NormalizedUnitNumber(100), + totalDebt: NormalizedUnitNumber(0), + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns supply cap when balance is greater than supply cap', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(100) }, + asset: { + status: 'active', + totalLiquidity: NormalizedUnitNumber(0), + supplyCap: NormalizedUnitNumber(50), + totalDebt: NormalizedUnitNumber(0), + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(50)) + }) + + it('returns available to supply value', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(100) }, + asset: { + status: 'active', + totalLiquidity: NormalizedUnitNumber(25), + supplyCap: NormalizedUnitNumber(50), + totalDebt: NormalizedUnitNumber(0), + + ...assetParams, + }, + timestamp: 0, + }), + ).toEqual(NormalizedUnitNumber(25)) + }) + + it('returns available to supply value for growing liquidity', () => { + expect( + getDepositMaxValue({ + user: { balance: NormalizedUnitNumber(100) }, + asset: { + status: 'active', + totalLiquidity: NormalizedUnitNumber(25), + supplyCap: NormalizedUnitNumber(50), + totalDebt: NormalizedUnitNumber(20), + ...assetParams, + }, + timestamp: 10, + }), + ).toEqual(NormalizedUnitNumber(24.9999998)) // 0.0000002 will grow over the next 10 minutes + }) + }) +}) diff --git a/packages/app/src/domain/action-max-value-getters/getDepositMaxValue.ts b/packages/app/src/domain/action-max-value-getters/getDepositMaxValue.ts new file mode 100644 index 000000000..d17487b7f --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getDepositMaxValue.ts @@ -0,0 +1,51 @@ +import BigNumber from 'bignumber.js' + +import { getCompoundedBalance, getCompoundedScaledBalance } from '../market-info/math' +import { ReserveStatus } from '../market-info/reserve-status' +import { BaseUnitNumber, NormalizedUnitNumber } from '../types/NumericValues' + +interface GetDepositMaxValueParams { + asset: { + status: ReserveStatus + totalLiquidity: NormalizedUnitNumber + decimals: number + totalDebt: NormalizedUnitNumber + rate: BigNumber + index: BigNumber + lastUpdateTimestamp: number + supplyCap?: NormalizedUnitNumber + } + user: { + balance: NormalizedUnitNumber + } + timestamp: number +} + +export function getDepositMaxValue({ user, asset, timestamp }: GetDepositMaxValueParams): NormalizedUnitNumber { + if (asset.status !== 'active') { + return NormalizedUnitNumber(0) + } + + const scaledTotalDebt = getCompoundedScaledBalance({ + balance: BaseUnitNumber(asset.totalDebt.shiftedBy(asset.decimals)), + index: asset.index, + rate: asset.rate, + lastUpdateTimestamp: asset.lastUpdateTimestamp, + timestamp, + }) + const totalDebtIn10Minutes = NormalizedUnitNumber( + getCompoundedBalance({ + balance: scaledTotalDebt, + index: asset.index, + rate: asset.rate, + lastUpdateTimestamp: asset.lastUpdateTimestamp, + timestamp: timestamp + 10 * 60, + }).shiftedBy(-asset.decimals), + ) + const totalLiquidityIn10Minutes = asset.totalLiquidity.minus(asset.totalDebt).plus(totalDebtIn10Minutes) + + const marketMaxDeposit = asset.supplyCap + ? BigNumber.max(asset.supplyCap.minus(totalLiquidityIn10Minutes), 0) + : Infinity + return NormalizedUnitNumber(BigNumber.min(user.balance, marketMaxDeposit)) +} diff --git a/packages/app/src/domain/action-max-value-getters/getRepayMaxValue.test.ts b/packages/app/src/domain/action-max-value-getters/getRepayMaxValue.test.ts new file mode 100644 index 000000000..28d882ffc --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getRepayMaxValue.test.ts @@ -0,0 +1,76 @@ +import { describe } from 'vitest' + +import { NormalizedUnitNumber } from '../types/NumericValues' +import { getRepayMaxValue } from './getRepayMaxValue' + +describe(getRepayMaxValue.name, () => { + it('returns 0 for paused reserve', () => { + expect( + getRepayMaxValue({ + user: { + debt: NormalizedUnitNumber(100), + balance: NormalizedUnitNumber(100), + }, + asset: { + status: 'paused', + }, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when no debt', () => { + expect( + getRepayMaxValue({ + user: { + debt: NormalizedUnitNumber(0), + balance: NormalizedUnitNumber(100), + }, + asset: { + status: 'active', + }, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns 0 when no balance', () => { + expect( + getRepayMaxValue({ + user: { + debt: NormalizedUnitNumber(100), + balance: NormalizedUnitNumber(0), + }, + asset: { + status: 'active', + }, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns debt when balance is greater', () => { + expect( + getRepayMaxValue({ + user: { + debt: NormalizedUnitNumber(100), + balance: NormalizedUnitNumber(200), + }, + asset: { + status: 'active', + }, + }), + ).toEqual(NormalizedUnitNumber(100)) + }) + + it('returns balance when debt is greater', () => { + expect( + getRepayMaxValue({ + user: { + debt: NormalizedUnitNumber(200), + balance: NormalizedUnitNumber(100), + }, + asset: { + status: 'active', + }, + }), + ).toEqual(NormalizedUnitNumber(100)) + }) +}) diff --git a/packages/app/src/domain/action-max-value-getters/getRepayMaxValue.ts b/packages/app/src/domain/action-max-value-getters/getRepayMaxValue.ts new file mode 100644 index 000000000..fd85b10c3 --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getRepayMaxValue.ts @@ -0,0 +1,22 @@ +import BigNumber from 'bignumber.js' + +import { ReserveStatus } from '../market-info/reserve-status' +import { NormalizedUnitNumber } from '../types/NumericValues' + +interface GetRepayMaxValueParams { + user: { + debt: NormalizedUnitNumber + balance: NormalizedUnitNumber + } + asset: { + status: ReserveStatus + } +} + +export function getRepayMaxValue({ user, asset }: GetRepayMaxValueParams): NormalizedUnitNumber { + if (asset.status === 'paused') { + return NormalizedUnitNumber(0) + } + const maxRepay = BigNumber.min(user.debt, user.balance) + return NormalizedUnitNumber(maxRepay) +} diff --git a/packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.test.ts b/packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.test.ts new file mode 100644 index 000000000..fddf07d0f --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.test.ts @@ -0,0 +1,22 @@ +import { NormalizedUnitNumber } from '../types/NumericValues' +import { getWithdrawMaxValue } from './getWithdrawMaxValue' + +describe(getWithdrawMaxValue.name, () => { + it('returns 0 for paused reserve', () => { + expect( + getWithdrawMaxValue({ + user: { deposited: NormalizedUnitNumber(100) }, + asset: { status: 'paused' }, + }), + ).toEqual(NormalizedUnitNumber(0)) + }) + + it('returns deposited amount', () => { + expect( + getWithdrawMaxValue({ + user: { deposited: NormalizedUnitNumber(100) }, + asset: { status: 'active' }, + }), + ).toEqual(NormalizedUnitNumber(100)) + }) +}) diff --git a/packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.ts b/packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.ts new file mode 100644 index 000000000..0aa12e7f1 --- /dev/null +++ b/packages/app/src/domain/action-max-value-getters/getWithdrawMaxValue.ts @@ -0,0 +1,18 @@ +import { ReserveStatus } from '../market-info/reserve-status' +import { NormalizedUnitNumber } from '../types/NumericValues' + +interface GetWithdrawMaxValueParams { + user: { + deposited: NormalizedUnitNumber + } + asset: { + status: ReserveStatus + } +} + +export function getWithdrawMaxValue({ user, asset }: GetWithdrawMaxValueParams): NormalizedUnitNumber { + if (asset.status === 'paused') { + return NormalizedUnitNumber(0) + } + return user.deposited +} diff --git a/packages/app/src/domain/common/assets.ts b/packages/app/src/domain/common/assets.ts new file mode 100644 index 000000000..94b00a009 --- /dev/null +++ b/packages/app/src/domain/common/assets.ts @@ -0,0 +1,5 @@ +import { Reserve } from '../market-info/marketInfo' + +export function assetCanBeBorrowed({ borrowEligibilityStatus }: Reserve): boolean { + return borrowEligibilityStatus === 'yes' || borrowEligibilityStatus === 'only-in-siloed-mode' +} diff --git a/packages/app/src/domain/common/format.test.ts b/packages/app/src/domain/common/format.test.ts new file mode 100644 index 000000000..2518df2a7 --- /dev/null +++ b/packages/app/src/domain/common/format.test.ts @@ -0,0 +1,131 @@ +import { describe } from 'vitest' + +import { bigNumberify } from '@/utils/bigNumber' + +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { findSignificantPrecision, formatHealthFactor, formatPercentage, formFormat } from './format' + +describe(formatPercentage.name, () => { + it('formats whole numbers', () => { + const tests: [number, string][] = [ + [0, '0.00%'], + [1, '100.00%'], + [2, '200.00%'], + [123_45, '1,234,500.00%'], + [999_999, '99,999,900.00%'], + ] + + tests.forEach(([value, expected]) => { + expect(formatPercentage(Percentage(value, true))).toEqual(expected) + }) + }) + + it('formats numbers with fractional part', () => { + const tests: [number, string][] = [ + [0.1, '10.00%'], + [0.2, '20.00%'], + [0.5, '50.00%'], + [0.01, '1.00%'], + [0.001, '0.10%'], + [0.0001, '0.01%'], + [0.123, '12.30%'], + [0.1234, '12.34%'], + [0.12345, '12.35%'], + [0.123456, '12.35%'], + [0.999, '99.90%'], + [0.9999, '99.99%'], + [0.99999, '100.00%'], + ] + + tests.forEach(([value, expected]) => { + expect(formatPercentage(Percentage(value, true))).toEqual(expected) + }) + }) + + it('formats small numbers', () => { + const tests: [number, string][] = [ + [0.0001, '0.01%'], + [0.00001, '<0.01%'], + [0.00002, '<0.01%'], + [0.000001, '<0.01%'], + [0.00009999999, '<0.01%'], + ] + + tests.forEach(([value, expected]) => { + expect(formatPercentage(Percentage(value, true))).toEqual(expected) + }) + }) +}) + +describe(formatHealthFactor.name, () => { + it('handles undefined', () => { + expect(formatHealthFactor(undefined)).toEqual('0.0') + }) + + it('formats whole numbers', () => { + const tests: [number, string][] = [ + [0, '0'], + [1, '1'], + [2, '2'], + [40, '40'], + [999, '999'], + ] + + tests.forEach(([value, expected]) => { + expect(formatHealthFactor(NormalizedUnitNumber(value))).toEqual(expected) + }) + }) + + it('formats numbers with fractional part', () => { + const tests: [number, string][] = [ + [0.1, '0.1'], + [0.2, '0.2'], + [1.123, '1.12'], + [2.125, '2.13'], + [3.12345, '3.12'], + [9.999, '10'], + [9.99, '9.99'], + ] + + tests.forEach(([value, expected]) => { + expect(formatHealthFactor(NormalizedUnitNumber(value))).toEqual(expected) + }) + }) +}) + +describe(formFormat.name, () => { + it('formats bigger numbers', () => { + expect(formFormat(bigNumberify(100.2), 18)).toBe('100.2') + }) + + it('avoids unnecessary zeroes', () => { + expect(formFormat(bigNumberify(0), 10)).toBe('0') + expect(formFormat(bigNumberify(1), 10)).toBe('1') + }) + + it('rounds down', () => { + expect(formFormat(bigNumberify(0.9999), 2)).toBe('0.99') + }) +}) + +describe(findSignificantPrecision.name, () => { + it('finds precision for stablecoins', () => { + expect(findSignificantPrecision(bigNumberify(1), 2)).toBe(2) + }) + + it('finds precision for BTC like', () => { + expect(findSignificantPrecision(bigNumberify(48_000), 2)).toBe(6) + }) + + it('finds precision for ETH like', () => { + expect(findSignificantPrecision(bigNumberify(2_500), 2)).toBe(5) + }) + + it('finds precision for coins with tiny prices', () => { + expect(findSignificantPrecision(bigNumberify(0.0001), 2)).toBe(0) + }) + + it('finds precision for BTC like and whole dollars', () => { + expect(findSignificantPrecision(bigNumberify(48_000), 0)).toBe(4) + }) +}) diff --git a/packages/app/src/domain/common/format.ts b/packages/app/src/domain/common/format.ts new file mode 100644 index 000000000..b02fa28e3 --- /dev/null +++ b/packages/app/src/domain/common/format.ts @@ -0,0 +1,57 @@ +import BigNumber from 'bignumber.js' + +import { trimCharEnd } from '@/utils/strings' + +import { bigNumberify } from '../../utils/bigNumber' +import { Percentage } from '../types/NumericValues' + +export interface FormatPercentageOptions { + skipSign?: boolean + minimumFractionDigits?: number +} +export function formatPercentage( + percentage: Percentage, + { skipSign, minimumFractionDigits = 2 }: FormatPercentageOptions = {}, +): string { + if (percentage.gt(0) && percentage.lt(0.0001)) { + return skipSign ? '<0.01' : '<0.01%' + } + + const percentageFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + minimumFractionDigits, + }) + + const value = percentage.multipliedBy(100).toNumber() + return `${percentageFormatter.format(value)}${skipSign ? '' : '%'}` +} + +const healthFactorFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, +}) +export function formatHealthFactor(healthFactor: BigNumber | undefined): string { + if (healthFactor === undefined) { + return '0.0' + } + if (!healthFactor.isFinite()) { + return String.fromCharCode(0x221e) + } + + return healthFactorFormatter.format(bigNumberify(healthFactor).toNumber()) +} + +export function formFormat(value: BigNumber, precision = 2): string { + const roundedValue = value.toFixed(precision, BigNumber.ROUND_DOWN) + + // avoid trailing zeroes + // @note: we can't use a Intl.formatter here because it doesn't support rounding modes :/ + return trimCharEnd(trimCharEnd(roundedValue, '0'), '.') +} + +export function findSignificantPrecision( + _unitPriceUsd: BigNumber, + desiredPrecisionOfUsd = 2, // 0.01 = cents +): number { + const unitPriceUsd = Number(_unitPriceUsd) + return Math.max(Math.floor(Math.log10(unitPriceUsd)) + desiredPrecisionOfUsd, 0) +} diff --git a/packages/app/src/domain/common/risk.ts b/packages/app/src/domain/common/risk.ts new file mode 100644 index 000000000..c97aa2546 --- /dev/null +++ b/packages/app/src/domain/common/risk.ts @@ -0,0 +1,39 @@ +import BigNumber from 'bignumber.js' + +export type RiskLevel = 'liquidation' | 'risky' | 'moderate' | 'healthy' | 'unknown' | 'no debt' + +export const LIQUIDATION_HEALTH_FACTOR_THRESHOLD = new BigNumber(1) +export const RISKY_HEALTH_FACTOR_THRESHOLD = new BigNumber(2) +export const MODERATE_HEALTH_FACTOR_THRESHOLD = new BigNumber(3) + +export function healthFactorToRiskLevel(hf: BigNumber | undefined): RiskLevel { + if (hf === undefined) { + return 'unknown' + } + + switch (true) { + case !hf.isFinite(): + return 'no debt' + + case hf.lt(LIQUIDATION_HEALTH_FACTOR_THRESHOLD): + return 'liquidation' + + case hf.lt(RISKY_HEALTH_FACTOR_THRESHOLD): + return 'risky' + + case hf.lt(MODERATE_HEALTH_FACTOR_THRESHOLD): + return 'moderate' + + default: + return 'healthy' + } +} + +export const riskLevelToTitle: Record = { + unknown: 'Unknown', + liquidation: 'Liquidation', + risky: 'Risky', + moderate: 'Moderate', + healthy: 'Healthy', + 'no debt': 'No debt', +} diff --git a/packages/app/src/domain/common/sorters.test.ts b/packages/app/src/domain/common/sorters.test.ts new file mode 100644 index 000000000..5452466fa --- /dev/null +++ b/packages/app/src/domain/common/sorters.test.ts @@ -0,0 +1,23 @@ +import { tokens } from '@storybook/tokens' + +import { NormalizedUnitNumber } from '../types/NumericValues' +import { sortByUsdValue } from './sorters' + +describe(sortByUsdValue, () => { + it('should sort by increasing usd value', () => { + const balances = [ + { token: tokens.DAI, balance: NormalizedUnitNumber('4000') }, + { token: tokens.USDC, balance: NormalizedUnitNumber('100') }, + { token: tokens.ETH, balance: NormalizedUnitNumber('1') }, // ~2000 USD + ] + + const sortedBalances = balances.sort((a, b) => sortByUsdValue(a, b, 'balance')) + + expect(sortedBalances.map((s) => s.token.symbol)).toStrictEqual(['USDC', 'ETH', 'DAI']) + }) + + it.skip("[TYPE LEVEL] should not be able to pick a key that value doesn't extend BigNumber", () => { + // @ts-expect-error + sortByUsdValue({ token: tokens.DAI, value: 5 }, { token: tokens.ETH, value: 10 }, 'value') + }) +}) diff --git a/packages/app/src/domain/common/sorters.ts b/packages/app/src/domain/common/sorters.ts new file mode 100644 index 000000000..d821468c4 --- /dev/null +++ b/packages/app/src/domain/common/sorters.ts @@ -0,0 +1,15 @@ +import BigNumber from 'bignumber.js' + +import { FilterObjectValues } from '@/utils/types' + +import { Token } from '../types/Token' + +export function sortByUsdValue>( + a: T, + b: T, + key: K, +): number { + const aUsdValue = a.token.toUSD((a as any)[key]) + const bUsdValue = b.token.toUSD((b as any)[key]) + return aUsdValue.comparedTo(bUsdValue) +} diff --git a/packages/app/src/domain/common/types.ts b/packages/app/src/domain/common/types.ts new file mode 100644 index 000000000..e0f78f95b --- /dev/null +++ b/packages/app/src/domain/common/types.ts @@ -0,0 +1,24 @@ +import { Reserve } from '../market-info/marketInfo' +import { NormalizedUnitNumber } from '../types/NumericValues' +import { Token } from '../types/Token' + +export interface TokenWithBalance { + token: Token + balance: NormalizedUnitNumber +} + +export interface TokenWithFormValue { + token: Token + balance: NormalizedUnitNumber + value: string // has to be a string because it's a form value +} + +export interface TokenWithValue { + token: Token + value: NormalizedUnitNumber +} + +export interface ReserveWithValue { + reserve: Reserve + value: NormalizedUnitNumber +} diff --git a/packages/app/src/domain/e-mode/constants.ts b/packages/app/src/domain/e-mode/constants.ts new file mode 100644 index 000000000..8351ad5e2 --- /dev/null +++ b/packages/app/src/domain/e-mode/constants.ts @@ -0,0 +1,5 @@ +export const eModeCategoryIdToName = { + 0: 'No E-Mode', + 1: 'ETH Correlated', + 2: 'Stablecoins', +} as const diff --git a/packages/app/src/domain/e-mode/types.ts b/packages/app/src/domain/e-mode/types.ts new file mode 100644 index 000000000..adf1020cd --- /dev/null +++ b/packages/app/src/domain/e-mode/types.ts @@ -0,0 +1,4 @@ +import { eModeCategoryIdToName } from './constants' + +export type EModeCategoryId = keyof typeof eModeCategoryIdToName +export type EModeCategoryName = (typeof eModeCategoryIdToName)[EModeCategoryId] diff --git a/packages/app/src/domain/errors/not-connected.ts b/packages/app/src/domain/errors/not-connected.ts new file mode 100644 index 000000000..bfd4f6b41 --- /dev/null +++ b/packages/app/src/domain/errors/not-connected.ts @@ -0,0 +1,6 @@ +export class NotConnectedError extends Error { + constructor() { + super('User wallet is not connected') + this.name = 'NotConnectedError' + } +} diff --git a/packages/app/src/domain/errors/not-found.ts b/packages/app/src/domain/errors/not-found.ts new file mode 100644 index 000000000..6a03c42ef --- /dev/null +++ b/packages/app/src/domain/errors/not-found.ts @@ -0,0 +1,6 @@ +export class NotFoundError extends Error { + constructor() { + super('The requested page could not be found.') + this.name = 'NotFoundError' + } +} diff --git a/packages/app/src/domain/errors/useViteErrorOverlay.ts b/packages/app/src/domain/errors/useViteErrorOverlay.ts new file mode 100644 index 000000000..ef1099d09 --- /dev/null +++ b/packages/app/src/domain/errors/useViteErrorOverlay.ts @@ -0,0 +1,27 @@ +import { useEffect } from 'react' + +export function useViteErrorOverlay(): void { + useEffect(function addViteErrorHandler() { + // eslint-disable-next-line func-style + const handler = ({ reason }: PromiseRejectionEvent): void => showErrorOverlay(reason) + + window.addEventListener('unhandledrejection', handler) + + return () => { + window.removeEventListener('unhandledrejection', handler) + } + }, []) +} + +function showErrorOverlay(err: Error): void { + // must be within function call because that's when the element is defined for sure. + const ErrorOverlay = customElements.get('vite-error-overlay') + // don't open outside vite environment + if (!ErrorOverlay) { + return + } + // eslint-disable-next-line no-console + console.log(err) + const overlay = new ErrorOverlay(err) + document.body.appendChild(overlay) +} diff --git a/packages/app/src/domain/exchanges/lifi/lifi.test.ts b/packages/app/src/domain/exchanges/lifi/lifi.test.ts new file mode 100644 index 000000000..a5dbc166f --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/lifi.test.ts @@ -0,0 +1,338 @@ +import { mainnet } from 'viem/chains' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { testAddresses } from '@/test/integration/constants' + +import { CheckedAddress } from '../../types/CheckedAddress' +import { BaseUnitNumber, Percentage } from '../../types/NumericValues' +import { LiFi } from './lifi' +import { QuoteResponseRaw } from './types' + +const userAddress = testAddresses.alice +const chainId = mainnet.id +const USDC = CheckedAddress('0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') // USDC +const sDAI = CheckedAddress('0x83f20f44975d03b1b09e64809b757c47f942beea') // sDAI +const amount = BaseUnitNumber('1000000000') +const maxSlippage = Percentage(0.005) +const maxSlippageAsString = '0.005' +const meta = { fee: Percentage(0), integratorKey: 'test' } + +const rawResponse: QuoteResponseRaw = { + transactionRequest: { + data: '0x4630a0d8', // just a sighash + from: userAddress, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0xe1a0aa5f1', + gasLimit: '0x75d63', + }, + estimate: { + feeCosts: [{ amountUSD: 1 }], + fromAmount: '1000000000000000000', + toAmount: '943000000000000000', + }, + action: { + fromToken: { address: USDC }, + toToken: { address: sDAI }, + }, +} + +describe('LiFi', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + describe(LiFi.prototype.getQuote.name, () => { + test('calls fetch with the correct URL', async () => { + let calledUrl: URL | string | undefined + vi.stubGlobal('fetch', async (...args: any[]) => { + calledUrl = args[0] + + return { + ok: false, + json: async () => ({}), + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + await lifi + .getQuote({ + fromToken: USDC, + toToken: sDAI, + amount, + meta, + maxSlippage, + }) + .catch(() => {}) // ignore error + + expect(calledUrl).toBeDefined() + const calledUrlObj = new URL(calledUrl!.toString()) + + expect(calledUrlObj.searchParams.get('fromChain')).toBe(chainId.toString()) + expect(calledUrlObj.searchParams.get('toChain')).toBe(chainId.toString()) + expect(calledUrlObj.searchParams.get('fromAddress')).toBe(userAddress) + expect(calledUrlObj.searchParams.get('fromToken')).toBe(USDC) + expect(calledUrlObj.searchParams.get('fromAmount')).toBe(amount.toString()) + expect(calledUrlObj.searchParams.get('toToken')).toBe(sDAI) + expect(calledUrlObj.searchParams.get('maxSlippage')).toBe(maxSlippageAsString) + expect(calledUrlObj.origin).toBe('https://li.quest') + expect(calledUrlObj.pathname).toBe('/v1/quote') + }) + + test('throws error if fetch fails', async () => { + vi.stubGlobal('fetch', async () => { + return { + ok: false, + json: async () => ({}), + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + await expect( + lifi.getQuote({ + fromToken: USDC, + toToken: sDAI, + amount, + meta, + maxSlippage, + }), + ).rejects.toThrow() + }) + + test('parses response', async () => { + vi.stubGlobal('fetch', async () => { + return { + ok: true, + json: async () => rawResponse, + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + const response = await lifi.getQuote({ + fromToken: USDC, + toToken: sDAI, + amount, + meta, + maxSlippage, + }) + + expect(response).toEqual({ + transactionRequest: { + data: rawResponse.transactionRequest.data, + from: userAddress, + to: rawResponse.transactionRequest.to, + value: BigInt(rawResponse.transactionRequest.value), + gasPrice: BigInt(rawResponse.transactionRequest.gasPrice), + gasLimit: BigInt(rawResponse.transactionRequest.gasLimit), + }, + estimate: { + feeCosts: [{ amountUSD: 1 }], + fromAmount: 1000000000000000000n, + toAmount: 943000000000000000n, + }, + action: { + fromToken: { address: USDC }, + toToken: { address: sDAI }, + }, + }) + }) + + test('throws error if response is not valid', async () => { + const responseData = { + data: '0x', // empty data + from: userAddress, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0xe1a0aa5f1', + gasLimit: '0x75d63', + chainId: 1, + } + + vi.stubGlobal('fetch', async () => { + return { + ok: true, + json: async () => ({ + transactionRequest: responseData, + }), + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + await expect( + lifi.getQuote({ + fromToken: USDC, + toToken: sDAI, + amount, + meta, + maxSlippage, + }), + ).rejects.toThrow() + }) + }) + + describe(LiFi.prototype.getReverseQuote.name, () => { + test('calls fetch witch correct url, method and options', async () => { + let calledUrl: URL | string | undefined + let calledOptions: RequestInit | undefined + + vi.stubGlobal('fetch', async (...args: any[]) => { + calledUrl = args[0] + calledOptions = args[1] + + return { + ok: false, + json: async () => ({}), + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + await lifi + .getReverseQuote({ + fromToken: sDAI, + toToken: USDC, + amount, + meta, + maxSlippage, + }) + .catch(() => {}) // ignore error + + expect(calledUrl).toBeDefined() + expect(calledUrl?.toString()).toBe('https://li.quest/v1/quote/contractCalls') + expect(calledOptions).toBeDefined() + expect(calledOptions?.method).toBe('POST') + expect(calledOptions?.body).toBeDefined() + expect(JSON.parse(calledOptions!.body as any)).toEqual({ + fromChain: chainId.toString(), + toChain: chainId.toString(), + fromAddress: userAddress, + fromToken: sDAI, + toToken: USDC, + toAmount: amount.toString(), + maxSlippage: maxSlippageAsString, + contractCalls: [], + }) + }) + + test('throws error if fetch fails', async () => { + vi.stubGlobal('fetch', async () => { + return { + ok: false, + json: async () => ({}), + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + await expect( + lifi.getReverseQuote({ + fromToken: sDAI, + toToken: USDC, + amount, + meta, + maxSlippage, + }), + ).rejects.toThrow() + }) + + test('parses response', async () => { + vi.stubGlobal('fetch', async () => { + return { + ok: true, + json: async () => rawResponse, + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + const response = await lifi.getReverseQuote({ + fromToken: sDAI, + toToken: USDC, + amount, + meta, + maxSlippage, + }) + + expect(response).toEqual({ + transactionRequest: { + data: rawResponse.transactionRequest.data, + from: userAddress, + to: rawResponse.transactionRequest.to, + value: BigInt(rawResponse.transactionRequest.value), + gasPrice: BigInt(rawResponse.transactionRequest.gasPrice), + gasLimit: BigInt(rawResponse.transactionRequest.gasLimit), + }, + estimate: { + feeCosts: [{ amountUSD: 1 }], + fromAmount: 1000000000000000000n, + toAmount: 943000000000000000n, + }, + action: { + fromToken: { address: USDC }, + toToken: { address: sDAI }, + }, + }) + }) + + test('throws error if response is not valid', async () => { + const transactionRequest = { + data: '0x', // empty data + from: userAddress, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0xe1a0aa5f1', + gasLimit: '0x75d63', + chainId: 1, + } + + vi.stubGlobal('fetch', async () => { + return { + ok: true, + json: async () => ({ + transactionRequest, + }), + } + }) + + const lifi = new LiFi({ + chainId, + userAddress, + }) + + await expect( + lifi.getReverseQuote({ + fromToken: sDAI, + toToken: USDC, + amount, + meta, + maxSlippage, + }), + ).rejects.toThrow() + }) + }) +}) diff --git a/packages/app/src/domain/exchanges/lifi/lifi.ts b/packages/app/src/domain/exchanges/lifi/lifi.ts new file mode 100644 index 000000000..1d2def5b0 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/lifi.ts @@ -0,0 +1,137 @@ +import { CheckedAddress } from '../../types/CheckedAddress' +import { BaseUnitNumber, Percentage } from '../../types/NumericValues' +import { LifiQuoteMeta } from './meta' +import { QuoteResponse, ReverseQuoteResponse } from './types' +import { quoteResponseSchema, reverseQuoteResponseSchema } from './validation' + +export interface LiFiConfig { + chainId: number + userAddress: CheckedAddress +} + +export interface GetQuoteOptions { + fromToken: CheckedAddress + toToken: CheckedAddress + amount: BaseUnitNumber + meta: LifiQuoteMeta + maxSlippage: Percentage +} + +interface QuoteRequestParams { + fromChain: string + toChain: string + fromAddress: CheckedAddress + fromToken: CheckedAddress + fromAmount: string + toToken: CheckedAddress + integrator: string + fee: string + maxSlippage: string +} + +interface ReverseQuoteRequestParams { + fromChain: string + toChain: string + fromAddress: CheckedAddress + fromToken: CheckedAddress + toToken: CheckedAddress + toAmount: string + maxSlippage?: string + contractCalls: [] +} + +export class LiFi { + private baseUrl = 'https://li.quest' + private chainId: number + private userAddress: CheckedAddress + + constructor(config: LiFiConfig) { + this.chainId = config.chainId + this.userAddress = config.userAddress + } + + getKey(): string { + return `lifi-${this.chainId}-${this.userAddress}` + } + + private parseQuoteResponse(response: unknown): QuoteResponse { + return quoteResponseSchema.parse(response) + } + + private buildQuoteUrl({ fromToken, toToken, amount, meta, maxSlippage }: GetQuoteOptions): URL { + const url = new URL(this.baseUrl) + url.pathname = '/v1/quote' + const params = { + fromChain: this.chainId.toString(), + toChain: this.chainId.toString(), + fromAddress: this.userAddress, + fromToken, + fromAmount: amount.toFixed(), + toToken, + integrator: meta.integratorKey, + fee: meta.fee.toFixed(), + maxSlippage: maxSlippage.toFixed(), + } satisfies QuoteRequestParams + url.search = new URLSearchParams(params).toString() + return url + } + + async getQuote({ fromToken, toToken, amount, maxSlippage, meta }: GetQuoteOptions): Promise { + const url = this.buildQuoteUrl({ fromToken, toToken, amount, meta, maxSlippage }) + + const response = await fetch(url) + if (!response.ok) { + throw new Error('Error fetching exchange quote') + } + const result = await response.json() + + return this.parseQuoteResponse(result) + } + + private parseReverseQuoteResponse(response: unknown): ReverseQuoteResponse { + return reverseQuoteResponseSchema.parse(response) + } + + private buildReverseQuoteUrl(): URL { + const url = new URL(this.baseUrl) + url.pathname = 'v1/quote/contractCalls' + return url + } + + private buildReverseQuoteRequestOptions({ fromToken, toToken, amount, maxSlippage }: GetQuoteOptions): RequestInit { + const params = { + fromChain: this.chainId.toString(), + toChain: this.chainId.toString(), + fromAddress: this.userAddress, + fromToken, + toToken, + toAmount: amount.toFixed(), + maxSlippage: maxSlippage.toFixed(), + contractCalls: [], + } satisfies ReverseQuoteRequestParams + return { + method: 'POST', + headers: { accept: 'application/json', 'content-type': 'application/json' }, + body: JSON.stringify(params), + } + } + + async getReverseQuote({ + fromToken, + toToken, + amount, + meta, + maxSlippage, + }: GetQuoteOptions): Promise { + const url = this.buildReverseQuoteUrl() + const options = this.buildReverseQuoteRequestOptions({ fromToken, toToken, amount, meta, maxSlippage }) + + const response = await fetch(url, options) + if (!response.ok) { + throw new Error(`Failed to get LiFi reverse quote: ${response.statusText}`) + } + const result = await response.json() + + return this.parseReverseQuoteResponse(result) + } +} diff --git a/packages/app/src/domain/exchanges/lifi/meta/MockLifiQueryMetaEvaluator.ts b/packages/app/src/domain/exchanges/lifi/meta/MockLifiQueryMetaEvaluator.ts new file mode 100644 index 000000000..657da0dd4 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/meta/MockLifiQueryMetaEvaluator.ts @@ -0,0 +1,12 @@ +import { Percentage } from '@/domain/types/NumericValues' + +import { LifiQueryMetaEvaluator, LifiQuoteMeta } from '.' + +export class MockLifiQueryMetaEvaluator implements LifiQueryMetaEvaluator { + evaluate(): LifiQuoteMeta { + return { + fee: Percentage(0), + integratorKey: 'spark_test', + } + } +} diff --git a/packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.test.ts b/packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.test.ts new file mode 100644 index 000000000..7cb21b387 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.test.ts @@ -0,0 +1,67 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { testAddresses } from '@/test/integration/constants' + +import { LifiQuoteMeta } from '.' +import { + LIFI_DEFAULT_FEE, + LIFI_DEFAULT_FEE_INTEGRATOR_KEY, + LIFI_WAIVED_FEE, + LIFI_WAIVED_FEE_INTEGRATOR_KEY, + RealLifiQueryMetaEvaluator, +} from './RealLifiQueryMetaEvaluator' + +const dai = testAddresses.token +const sdai = testAddresses.token2 +const usdc = testAddresses.token3 +const usdt = testAddresses.token4 + +function evaluate(params: { + fromToken: CheckedAddress + toToken: CheckedAddress + dai?: CheckedAddress + sdai?: CheckedAddress + usdc?: CheckedAddress +}): LifiQuoteMeta { + return new RealLifiQueryMetaEvaluator({ dai, sdai, usdc }).evaluate({ + fromToken: params.fromToken, + toToken: params.toToken, + }) +} + +describe(RealLifiQueryMetaEvaluator.name, () => { + it('should return the default fee and integrator key when the route is not waived', () => { + expect( + evaluate({ + dai, + sdai, + usdc, + fromToken: usdt, + toToken: dai, + }), + ).toEqual({ fee: LIFI_DEFAULT_FEE, integratorKey: LIFI_DEFAULT_FEE_INTEGRATOR_KEY }) + }) + + it('returns waived fee and integrator key for sdai to dai route', () => { + expect( + evaluate({ + dai, + sdai, + usdc, + fromToken: sdai, + toToken: dai, + }), + ).toEqual({ fee: LIFI_WAIVED_FEE, integratorKey: LIFI_WAIVED_FEE_INTEGRATOR_KEY }) + }) + + it('returns waived fee and integrator key for dai to sdai route', () => { + expect( + evaluate({ + dai, + sdai, + usdc, + fromToken: dai, + toToken: sdai, + }), + ).toEqual({ fee: LIFI_WAIVED_FEE, integratorKey: LIFI_WAIVED_FEE_INTEGRATOR_KEY }) + }) +}) diff --git a/packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.ts b/packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.ts new file mode 100644 index 000000000..191ca019e --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/meta/RealLifiQueryMetaEvaluator.ts @@ -0,0 +1,40 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { Percentage } from '@/domain/types/NumericValues' + +import { EvaluateParams, LifiQueryMetaEvaluator, LifiQuoteMeta } from '.' + +export const LIFI_DEFAULT_FEE_INTEGRATOR_KEY = 'spark_fee' +export const LIFI_DEFAULT_FEE = Percentage('0.002') + +export const LIFI_WAIVED_FEE_INTEGRATOR_KEY = 'spark_waivefee' +export const LIFI_WAIVED_FEE = Percentage('0') + +export class RealLifiQueryMetaEvaluator implements LifiQueryMetaEvaluator { + // all routes are bi-directional + private readonly whitelistedRoutes: [CheckedAddress, CheckedAddress][] + + constructor({ dai, sdai, usdc }: { dai?: CheckedAddress; sdai?: CheckedAddress; usdc?: CheckedAddress } = {}) { + this.whitelistedRoutes = [ + [sdai, dai], + [usdc, dai], + [usdc, sdai], + ] + .map((route) => (route.includes(undefined) ? undefined : route)) + .filter(Boolean) as any + } + + evaluate({ fromToken, toToken }: EvaluateParams): LifiQuoteMeta { + const isWaivedRoute = this.whitelistedRoutes.some((route) => route.includes(fromToken) && route.includes(toToken)) + if (isWaivedRoute) { + return { + fee: LIFI_WAIVED_FEE, + integratorKey: LIFI_WAIVED_FEE_INTEGRATOR_KEY, + } + } + + return { + fee: LIFI_DEFAULT_FEE, + integratorKey: LIFI_DEFAULT_FEE_INTEGRATOR_KEY, + } + } +} diff --git a/packages/app/src/domain/exchanges/lifi/meta/index.ts b/packages/app/src/domain/exchanges/lifi/meta/index.ts new file mode 100644 index 000000000..0a418bd44 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/meta/index.ts @@ -0,0 +1,23 @@ +export * from './MockLifiQueryMetaEvaluator' +export * from './RealLifiQueryMetaEvaluator' +export * from './useLifiQueryMetaEvaluator' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { Percentage } from '@/domain/types/NumericValues' + +export interface LifiQuoteMeta { + integratorKey: string + fee: Percentage +} + +export interface EvaluateParams { + fromToken: CheckedAddress + toToken: CheckedAddress +} + +/** + * Evaluates the meta parameters (fee, integrator string) for a given query + */ +export interface LifiQueryMetaEvaluator { + evaluate(params: EvaluateParams): LifiQuoteMeta +} diff --git a/packages/app/src/domain/exchanges/lifi/meta/useLifiQueryMetaEvaluator.ts b/packages/app/src/domain/exchanges/lifi/meta/useLifiQueryMetaEvaluator.ts new file mode 100644 index 000000000..283ca9b38 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/meta/useLifiQueryMetaEvaluator.ts @@ -0,0 +1,22 @@ +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { LifiQueryMetaEvaluator } from '.' +import { MockLifiQueryMetaEvaluator } from './MockLifiQueryMetaEvaluator' +import { RealLifiQueryMetaEvaluator } from './RealLifiQueryMetaEvaluator' + +export function useLifiQueryMetaEvaluator(): LifiQueryMetaEvaluator { + // avoid calling useMarketInfo in storybook + if (import.meta.env.STORYBOOK_PREVIEW) { + return new MockLifiQueryMetaEvaluator() + } + + // eslint-disable-next-line react-hooks/rules-of-hooks + const { marketInfo } = useMarketInfo() + + const dai = marketInfo.findReserveBySymbol(TokenSymbol('DAI'))?.token.address + const sdai = marketInfo.findReserveBySymbol(TokenSymbol('sDAI'))?.token.address + const usdc = marketInfo.findReserveBySymbol(TokenSymbol('USDC'))?.token.address + + return new RealLifiQueryMetaEvaluator({ dai, sdai, usdc }) +} diff --git a/packages/app/src/domain/exchanges/lifi/query.ts b/packages/app/src/domain/exchanges/lifi/query.ts new file mode 100644 index 000000000..49ef5e7ed --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/query.ts @@ -0,0 +1,98 @@ +import { queryOptions } from '@tanstack/react-query' +import invariant from 'tiny-invariant' +import { zeroAddress } from 'viem' + +import { CheckedAddress } from '../../types/CheckedAddress' +import { BaseUnitNumber, NormalizedUnitNumber, Percentage } from '../../types/NumericValues' +import { SwapRequest } from '../types' +import { LiFi } from './lifi' +import { LifiQueryMetaEvaluator } from './meta' +import { QuoteResponse } from './types' + +export interface FetchLiFiTxDataParams { + client: LiFi + type: 'direct' | 'reverse' + fromToken: CheckedAddress + toToken: CheckedAddress + amount: BaseUnitNumber + maxSlippage: Percentage + queryMetaEvaluator: LifiQueryMetaEvaluator +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function fetchLiFiTxData({ + client, + type, + fromToken, + toToken, + amount, + maxSlippage, + queryMetaEvaluator, +}: FetchLiFiTxDataParams) { + return queryOptions({ + queryKey: ['liFiTxData', client.getKey(), type, fromToken, toToken, amount, maxSlippage], + queryFn: async (): Promise => { + if (import.meta.env.STORYBOOK_PREVIEW) { + return { + txRequest: { + data: '0x4630a0d8', // just a sighash + from: zeroAddress, + to: zeroAddress, + value: 0n, + gasPrice: 0n, + gasLimit: 0n, + }, + fromToken: CheckedAddress(zeroAddress), + toToken: CheckedAddress(zeroAddress), + type: 'direct', + + estimate: { + fromAmount: BaseUnitNumber(1n), + toAmount: BaseUnitNumber(1n), + feeCostsUSD: NormalizedUnitNumber(1), + }, + } + } + + const meta = queryMetaEvaluator.evaluate({ fromToken, toToken }) + + if (type === 'direct') { + const response = await client.getQuote({ fromToken, toToken, amount, meta, maxSlippage }) + const fromAmount = BaseUnitNumber(response.estimate.fromAmount) + invariant(amount.eq(fromAmount), 'amount should eq fromAmount') + + return { + txRequest: response.transactionRequest, + fromToken: CheckedAddress(response.action.fromToken.address), + toToken: CheckedAddress(response.action.toToken.address), + type: 'direct', + + estimate: { + fromAmount, + toAmount: BaseUnitNumber(response.estimate.toAmount), + feeCostsUSD: calculateFees(response.estimate.feeCosts), + }, + } + } else { + const response = await client.getReverseQuote({ fromToken, toToken, amount, meta, maxSlippage }) + + return { + txRequest: response.transactionRequest, + fromToken: CheckedAddress(response.action.fromToken.address), + toToken: CheckedAddress(response.action.toToken.address), + type: 'reverse', + + estimate: { + fromAmount: BaseUnitNumber(response.estimate.fromAmount), + toAmount: BaseUnitNumber(response.estimate.toAmount), + feeCostsUSD: calculateFees(response.estimate.feeCosts), + }, + } + } + }, + }) +} + +function calculateFees(feeCosts: QuoteResponse['estimate']['feeCosts']): NormalizedUnitNumber { + return feeCosts.reduce((acc, { amountUSD }) => NormalizedUnitNumber(acc.plus(amountUSD)), NormalizedUnitNumber(0)) +} diff --git a/packages/app/src/domain/exchanges/lifi/types.ts b/packages/app/src/domain/exchanges/lifi/types.ts new file mode 100644 index 000000000..324d99bf3 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/types.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' + +import { CheckedAddress } from '../../types/CheckedAddress' +import { BaseUnitNumber } from '../../types/NumericValues' +import { quoteResponseSchema, reverseQuoteResponseSchema } from './validation' + +export type QuoteResponse = z.infer +export type QuoteResponseRaw = z.input +export type ReverseQuoteResponse = z.infer + +export interface LiFiTxData { + txRequest: QuoteResponse['transactionRequest'] + estimate: { + feeCosts: QuoteResponse['estimate']['feeCosts'] + fromAmount: BaseUnitNumber + toAmount: BaseUnitNumber + } + action: { + fromToken: CheckedAddress + toToken: CheckedAddress + } +} diff --git a/packages/app/src/domain/exchanges/lifi/useLiFiTxData.test.ts b/packages/app/src/domain/exchanges/lifi/useLiFiTxData.test.ts new file mode 100644 index 000000000..762c75d5e --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/useLiFiTxData.test.ts @@ -0,0 +1,223 @@ +import { tokens } from '@storybook/tokens' +import { QueryClient } from '@tanstack/react-query' +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' +import { afterEach, describe, expect, test, vi } from 'vitest' + +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { BaseUnitNumber, NormalizedUnitNumber, Percentage } from '../../types/NumericValues' +import { SwapRequest } from '../types' +import { MockLifiQueryMetaEvaluator } from './meta' +import { QuoteResponseRaw } from './types' +import { useLiFiTxData } from './useLiFiTxData' + +const account = testAddresses.alice +const fromToken = tokens.USDC +const toToken = tokens.sDAI +const amount = BaseUnitNumber(10_000_000_000) +const amountNormalized = fromToken.fromBaseUnit(amount) +const chainId = mainnet.id +const maxSlippage = Percentage(0.005) +const rawResponse: QuoteResponseRaw = { + transactionRequest: { + data: '0x4630a0d8', // just a sighash + from: account, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0xe1a0aa5f1', + gasLimit: '0x75d63', + }, + estimate: { + feeCosts: [{ amountUSD: 1 }], + fromAmount: amount.toString(), + toAmount: '943000000000000000', + }, + action: { + fromToken: { address: fromToken.address }, + toToken: { address: toToken.address }, + }, +} + +const hookRenderer = setupHookRenderer({ + hook: useLiFiTxData, + account, + handlers: [handlers.chainIdCall({ chainId })], + args: { + swapParams: { fromToken, toToken, value: amountNormalized, type: 'direct', maxSlippage }, + queryMetaEvaluator: new MockLifiQueryMetaEvaluator(), + }, +}) + +describe(useLiFiTxData.name, () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + test('fetches direct quote', async () => { + let calledUrl: URL | string | undefined + + vi.stubGlobal('fetch', async (...args: any[]) => { + calledUrl = args[0] + + return { + ok: true, + json: async () => rawResponse, + } + }) + + const { result } = hookRenderer() + + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + expect(result.current.error).toBeNull() + expect(result.current.data).toEqual({ + txRequest: { + data: rawResponse.transactionRequest.data, + from: account, + to: rawResponse.transactionRequest.to, + value: BigInt(rawResponse.transactionRequest.value), + gasPrice: BigInt(rawResponse.transactionRequest.gasPrice), + gasLimit: BigInt(rawResponse.transactionRequest.gasLimit), + }, + estimate: { + feeCostsUSD: NormalizedUnitNumber(1), + fromAmount: amount, + toAmount: BaseUnitNumber(943000000000000000n), + }, + fromToken: fromToken.address, + toToken: toToken.address, + type: 'direct', + } satisfies SwapRequest) + + expect(calledUrl).toBeDefined() + const calledUrlObj = new URL(calledUrl!.toString()) + + expect(calledUrlObj.pathname).toBe('/v1/quote') + }) + + test('fetches reverse quote', async () => { + let calledUrl: URL | string | undefined + + vi.stubGlobal('fetch', async (...args: any[]) => { + calledUrl = args[0] + + return { + ok: true, + json: async () => rawResponse, + } + }) + + const { result } = hookRenderer({ + args: { + swapParams: { fromToken, toToken, value: amountNormalized, type: 'reverse', maxSlippage }, + queryMetaEvaluator: new MockLifiQueryMetaEvaluator(), + }, + }) + + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + expect(result.current.error).toBeNull() + expect(result.current.data).toEqual({ + txRequest: { + data: rawResponse.transactionRequest.data as any, + from: account, + to: rawResponse.transactionRequest.to as any, + value: BigInt(rawResponse.transactionRequest.value), + gasPrice: BigInt(rawResponse.transactionRequest.gasPrice), + gasLimit: BigInt(rawResponse.transactionRequest.gasLimit), + }, + estimate: { + feeCostsUSD: NormalizedUnitNumber(1), + fromAmount: amount, + toAmount: BaseUnitNumber(943000000000000000n), + }, + fromToken: fromToken.address, + toToken: toToken.address, + type: 'reverse', + } satisfies SwapRequest) + + expect(calledUrl).toBeDefined() + const calledUrlObj = new URL(calledUrl!.toString()) + + expect(calledUrlObj.pathname).toBe('/v1/quote/contractCalls') + }) + + test('returns error when fetch fails', async () => { + vi.stubGlobal('fetch', async () => { + return { + ok: false, + json: async () => {}, + } + }) + + const { result } = hookRenderer() + + await waitFor(() => { + expect(result.current.status).toBe('error') + }) + + expect(result.current.data).toBeUndefined() + expect(result.current.isError).toBe(true) + expect(result.current.error).toBeInstanceOf(Error) + }) + + test('retries when fetch fails', async () => { + let fetchCount = 0 + + vi.stubGlobal('fetch', async () => { + fetchCount++ + + if (fetchCount === 1) { + return { + ok: false, + json: async () => {}, + } + } + return { + ok: true, + json: async () => rawResponse, + } + }) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retryDelay: 0, + }, + }, + }) + const { result } = hookRenderer({ queryClient }) + + await waitFor(() => { + expect(result.current.status).toBe('success') + }) + + expect(fetchCount).toBe(2) + expect(result.current.error).toBeNull() + expect(result.current.data).toEqual({ + txRequest: { + data: rawResponse.transactionRequest.data as any, + from: account, + to: rawResponse.transactionRequest.to as any, + value: BigInt(rawResponse.transactionRequest.value), + gasPrice: BigInt(rawResponse.transactionRequest.gasPrice), + gasLimit: BigInt(rawResponse.transactionRequest.gasLimit), + }, + estimate: { + feeCostsUSD: NormalizedUnitNumber(1), + fromAmount: amount, + toAmount: BaseUnitNumber(943000000000000000n), + }, + fromToken: fromToken.address, + toToken: toToken.address, + type: 'direct', + } satisfies SwapRequest) + }) +}) diff --git a/packages/app/src/domain/exchanges/lifi/useLiFiTxData.ts b/packages/app/src/domain/exchanges/lifi/useLiFiTxData.ts new file mode 100644 index 000000000..7e530c235 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/useLiFiTxData.ts @@ -0,0 +1,41 @@ +import { useQuery } from '@tanstack/react-query' +import { useMemo } from 'react' + +import { useConnectedAddress } from '@/domain/wallet/useConnectedAddress' + +import { useOriginChainId } from '../../hooks/useOriginChainId' +import { SwapInfo, SwapParams } from '../types' +import { LiFi } from './lifi' +import { LifiQueryMetaEvaluator } from './meta' +import { fetchLiFiTxData } from './query' + +export interface UseLiFiTxDataParams { + swapParams: SwapParams + queryMetaEvaluator: LifiQueryMetaEvaluator + enabled?: boolean +} + +export function useLiFiTxData({ swapParams, enabled = true, queryMetaEvaluator }: UseLiFiTxDataParams): SwapInfo { + const { account } = useConnectedAddress() + const chainId = useOriginChainId() + + const client = useMemo(() => new LiFi({ chainId, userAddress: account }), [chainId, account]) + + const amount = + swapParams.type === 'direct' + ? swapParams.fromToken.toBaseUnit(swapParams.value) + : swapParams.toToken.toBaseUnit(swapParams.value) + + return useQuery({ + ...fetchLiFiTxData({ + client, + type: swapParams.type, + fromToken: swapParams.fromToken.address, + toToken: swapParams.toToken.address, + amount, + queryMetaEvaluator, + maxSlippage: swapParams.maxSlippage, + }), + enabled: enabled && amount.gt(0), + }) +} diff --git a/packages/app/src/domain/exchanges/lifi/validation.ts b/packages/app/src/domain/exchanges/lifi/validation.ts new file mode 100644 index 000000000..deecf5009 --- /dev/null +++ b/packages/app/src/domain/exchanges/lifi/validation.ts @@ -0,0 +1,33 @@ +import { Address, isAddress } from 'viem' +import { z, ZodBigIntDef, ZodType } from 'zod' + +const callDataSchema = z.custom<`0x${string}`>((address) => /^0x[0-9a-fA-F]{8,}$/.test(address as string)) +const addressSchema = z.custom
((address) => isAddress(address as string)) +const bigIntSchema = z.coerce.bigint() as any as ZodType // we coerce strings to bigints. Explicit ZodType makes input type inference work (zod.input) + +const feesSchema = z.array(z.object({ amountUSD: z.coerce.number() })) +const lifiTokenSchema = z.object({ + address: addressSchema, +}) + +export const quoteResponseSchema = z.object({ + transactionRequest: z.object({ + data: callDataSchema, + from: addressSchema, + gasLimit: bigIntSchema, + gasPrice: bigIntSchema, + to: addressSchema, + value: bigIntSchema, + }), + estimate: z.object({ + feeCosts: feesSchema, + fromAmount: bigIntSchema, + toAmount: bigIntSchema, + }), + action: z.object({ + toToken: lifiTokenSchema, + fromToken: lifiTokenSchema, + }), +}) + +export const reverseQuoteResponseSchema = quoteResponseSchema diff --git a/packages/app/src/domain/exchanges/types.ts b/packages/app/src/domain/exchanges/types.ts new file mode 100644 index 000000000..2af917bd0 --- /dev/null +++ b/packages/app/src/domain/exchanges/types.ts @@ -0,0 +1,45 @@ +import { UseQueryResult } from '@tanstack/react-query' +import { Address, Hex } from 'viem' + +import { SimplifiedQueryResult } from '@/features/actions/logic/simplifyQueryResult' + +import { CheckedAddress } from '../types/CheckedAddress' +import { BaseUnitNumber, NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { Token } from '../types/Token' + +export interface SwapParams { + type: SwapType + fromToken: Token + toToken: Token + value: NormalizedUnitNumber + maxSlippage: Percentage +} + +export type SwapType = + | 'direct' // ex: sell 10 DAI (fromToken) for XXX USDC (toToken) + | 'reverse' // ex: sell XX DAI (fromToken) for ~10 USDC (toToken) + +export interface SwapRequest { + fromToken: CheckedAddress + toToken: CheckedAddress + type: SwapType + estimate: { + feeCostsUSD: NormalizedUnitNumber + fromAmount: BaseUnitNumber + toAmount: BaseUnitNumber + } + + txRequest: TxRequest +} + +export interface TxRequest { + from: Address + to: Address + data: Hex + value: bigint + gasLimit: bigint + gasPrice: bigint +} + +export type SwapInfo = UseQueryResult +export type SwapInfoSimplified = SimplifiedQueryResult diff --git a/packages/app/src/domain/hooks/sanityChecks.test.ts b/packages/app/src/domain/hooks/sanityChecks.test.ts new file mode 100644 index 000000000..deea5d201 --- /dev/null +++ b/packages/app/src/domain/hooks/sanityChecks.test.ts @@ -0,0 +1,22 @@ +import { zeroAddress } from 'viem' +import { mainnet } from 'viem/chains' + +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { testAddresses } from '@/test/integration/constants' + +import { sanityCheckTx } from './sanityChecks' + +describe(sanityCheckTx.name, () => { + it('throws when address is on a black list', () => { + expect(() => sanityCheckTx({ address: NATIVE_ASSET_MOCK_ADDRESS, value: 0n }, mainnet.id)).toThrow( + 'Cannot interact', + ) + expect(() => sanityCheckTx({ address: zeroAddress, value: 0n }, mainnet.id)).toThrow('Cannot interact') + }) + + it('throws when sending native asset to non-gateway contract', () => { + expect(() => sanityCheckTx({ address: testAddresses.alice, value: 1n }, mainnet.id)).toThrow( + 'Sending the native asset ', + ) + }) +}) diff --git a/packages/app/src/domain/hooks/sanityChecks.ts b/packages/app/src/domain/hooks/sanityChecks.ts new file mode 100644 index 000000000..26ab682c3 --- /dev/null +++ b/packages/app/src/domain/hooks/sanityChecks.ts @@ -0,0 +1,25 @@ +import invariant from 'tiny-invariant' +import { Address, zeroAddress } from 'viem' + +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { wethGatewayAddress } from '@/config/contracts-generated' + +/** + * Do basic sanity checks when sending txs. + * These checks should never fail in real life scenario but it's the final line of defense against bugs in the app. + */ +export function sanityCheckTx(tx: { address?: Address; value?: bigint }, chainId: number): void { + invariant(tx.address, 'Address is required') + invariant( + tx.address.toLowerCase() !== NATIVE_ASSET_MOCK_ADDRESS.toLowerCase(), + 'Cannot interact with ETH mock address', + ) + invariant(tx.address !== zeroAddress, 'Cannot interact with zero address') + + if (tx.value) { + invariant( + tx.address === (wethGatewayAddress as any)[chainId], + 'Sending the native asset is only allowed to gateway contracts', + ) + } +} diff --git a/packages/app/src/domain/hooks/useBlockExplorerAddressLink.ts b/packages/app/src/domain/hooks/useBlockExplorerAddressLink.ts new file mode 100644 index 000000000..bf5655def --- /dev/null +++ b/packages/app/src/domain/hooks/useBlockExplorerAddressLink.ts @@ -0,0 +1,16 @@ +import { Address } from 'viem' +import { useChainId, useChains } from 'wagmi' + +export function useBlockExplorerAddressLink(address: Address | undefined): string | undefined { + const chainId = useChainId() + const chains = useChains() + + const chain = chains.find((chain) => chain.id === chainId) + const blockExplorerLink = chain?.blockExplorers?.default.url + + if (!address || !blockExplorerLink) { + return undefined + } + + return `${blockExplorerLink}/address/${address}` +} diff --git a/packages/app/src/domain/hooks/useConditionalFreeze.ts b/packages/app/src/domain/hooks/useConditionalFreeze.ts new file mode 100644 index 000000000..1f83b6d14 --- /dev/null +++ b/packages/app/src/domain/hooks/useConditionalFreeze.ts @@ -0,0 +1,13 @@ +import { useEffect, useRef } from 'react' + +export function useConditionalFreeze(value: T, freeze: boolean): T { + const frozenValue = useRef(value) + + useEffect(() => { + if (!freeze) { + frozenValue.current = value + } + }, [value, freeze]) + + return freeze ? frozenValue.current : value +} diff --git a/packages/app/src/domain/hooks/useContractAddress.ts b/packages/app/src/domain/hooks/useContractAddress.ts new file mode 100644 index 000000000..1f6ef61e3 --- /dev/null +++ b/packages/app/src/domain/hooks/useContractAddress.ts @@ -0,0 +1,20 @@ +import { Address } from 'viem' + +import { raise } from '@/utils/raise' + +import { useStore } from '../state' +import { CheckedAddress } from '../types/CheckedAddress' +import { getOriginChainId, useOriginChainId } from './useOriginChainId' + +export function getContractAddress(addressMap: Record, chainId: number): CheckedAddress { + const sandbox = useStore.getState().sandbox.network + const originChainId = getOriginChainId(chainId, sandbox) + + const address = addressMap[originChainId] ?? raise(`Contract address for chain ${originChainId} not found`) + return CheckedAddress(address) +} + +export function useContractAddress(addressMap: Record): CheckedAddress { + const chainId = useOriginChainId() + return getContractAddress(addressMap, chainId) +} diff --git a/packages/app/src/domain/hooks/useOriginChainId.ts b/packages/app/src/domain/hooks/useOriginChainId.ts new file mode 100644 index 000000000..1230457f6 --- /dev/null +++ b/packages/app/src/domain/hooks/useOriginChainId.ts @@ -0,0 +1,33 @@ +import { useChainId } from 'wagmi' + +import { SupportedChainId } from '@/config/chain/types' + +import { useStore } from '../state' +import { SandboxNetwork } from '../state/sandbox' + +/** + * If user is in sandbox mode, it returns chain id of underlying (origin) chain. + */ +export function useOriginChainId(): SupportedChainId { + const chainId = useChainId() + const sandbox = useStore((state) => state.sandbox.network) + + return getOriginChainId(chainId, sandbox) +} + +/** + * Why do we *need* this cache? This is not for performance reasons but rather to avoid subtle timing issues (FRO-438). + * When user enters sandbox mode, while being already in the sandbox mode, the app can re-render with store.sandboxNetwork already updated but user wallet still connected to the old sandbox. + * This can result in unknown network error. This workaround be remove if we don't allow creating another sandboxs while being already in a sandbox mode. + */ +const chainIdToOriginChainIdCache = new Map() +export function getOriginChainId(chainId: number, sandboxNetwork: SandboxNetwork | undefined): SupportedChainId { + if (chainIdToOriginChainIdCache.has(chainId)) { + return chainIdToOriginChainIdCache.get(chainId)! as SupportedChainId + } + if (chainId === sandboxNetwork?.forkChainId) { + chainIdToOriginChainIdCache.set(chainId, sandboxNetwork.originChainId) + return sandboxNetwork.originChainId as SupportedChainId + } + return chainId as SupportedChainId +} diff --git a/packages/app/src/domain/hooks/useSendTx.test.ts b/packages/app/src/domain/hooks/useSendTx.test.ts new file mode 100644 index 000000000..de55f7113 --- /dev/null +++ b/packages/app/src/domain/hooks/useSendTx.test.ts @@ -0,0 +1,113 @@ +import { waitFor } from '@testing-library/react' +import { encodeFunctionData, erc20Abi } from 'viem' +import { mainnet } from 'viem/chains' + +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { getTestTrigger } from '@/test/integration/trigger' + +import { useSendTx } from './useSendTx' + +describe(useSendTx.name, () => { + it('simulates the transaction', async () => { + const { trigger, release } = getTestTrigger() + const { result } = hookRenderer({ handlers: [balanceCall, handlers.triggerHandler(simulateCallHandler, trigger)] }) + + await waitFor(() => expect(result.current.status.kind).toBe('simulating')) + + release() + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + }) + + it('sends the transaction and waits for it to be mined', async () => { + const { result } = hookRenderer({ extraHandlers: [handlers.mineTransaction()] }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + + result.current.send() + + await waitFor(() => expect(result.current.status.kind).toBe('success')) + }) + + it('propagates simulation errors', async () => { + const expectedError = 'forced error' + const { result } = hookRenderer({ + handlers: [balanceCall, handlers.forceCallErrorHandler(simulateCallHandler, expectedError)], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('error')) + expect((result.current.status as any).errorKind).toBe('simulation') + expect((result.current.status as any).error).toBeInstanceOf(Error) + }) + + it('propagates tx-submission errors', async () => { + const { result } = hookRenderer({ + handlers: [chainIdCall, balanceCall, simulateCallHandler, handlers.rejectSubmittedTransaction()], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + + result.current.send() + + await waitFor(() => expect(result.current.status.kind).toBe('error')) // it takes more time because of retries + expect((result.current.status as any).errorKind).toBe('tx-submission') + expect((result.current.status as any).error.shortMessage).toBe('An unknown RPC error occurred.') // @todo this is due to rejectSubmittedTransaction not being perfect + }) + + it('propagates tx-reverted errors', async () => { + const { result } = hookRenderer({ + handlers: [chainIdCall, balanceCall, simulateCallHandler, handlers.mineRevertedTransaction()], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + + result.current.send() + + await waitFor(() => expect(result.current.status.kind).toBe('error')) + expect((result.current.status as any).errorKind).toBe('tx-reverted') + expect((result.current.status as any).error.shortMessage).toBe('An unknown RPC error occurred.') // @todo this is due to mineRejectedTransaction not being perfect + }) + + it('does not propagate simulation errors if disabled', async () => { + const expectedError = 'forced error' + const { result, rerender } = hookRenderer({ + handlers: [balanceCall, handlers.forceCallErrorHandler(simulateCallHandler, expectedError)], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('error')) + rerender({ data: '0x', enabled: false }) + await waitFor(() => expect(result.current.status.kind).toBe('disabled')) + expect((result.current.status as any).error).toBe(undefined) + }) +}) + +const balanceCall = handlers.balanceCall({ balance: 0n, address: testAddresses.alice }) +const chainIdCall = handlers.chainIdCall({ chainId: mainnet.id }) + +const simulateCallHandler = handlers.contractCall({ + to: testAddresses.token, + abi: erc20Abi, + functionName: 'transfer', + args: [testAddresses.bob, 100n], + from: testAddresses.alice, + result: true, +}) + +const defaultArgs = { + to: testAddresses.token, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [testAddresses.bob, 100n], + }), + from: testAddresses.alice, +} as const + +const hookRenderer = setupHookRenderer({ + hook: useSendTx, + account: testAddresses.alice, + handlers: [chainIdCall, balanceCall, simulateCallHandler], + args: defaultArgs, +}) diff --git a/packages/app/src/domain/hooks/useSendTx.ts b/packages/app/src/domain/hooks/useSendTx.ts new file mode 100644 index 000000000..76dce955c --- /dev/null +++ b/packages/app/src/domain/hooks/useSendTx.ts @@ -0,0 +1,119 @@ +import { useEffect } from 'react' +import { useAccount, useEstimateGas, UseEstimateGasParameters, useSendTransaction } from 'wagmi' + +import { sanityCheckTx } from './sanityChecks' +import { useOriginChainId } from './useOriginChainId' +import { useWaitForTransactionReceiptUniversal } from './useWaitForTransactionReceiptUniversal' +import { WriteStatus } from './useWrite' + +export interface UseSendTxResult { + send: () => void + resimulate: () => void + reset: () => void + status: WriteStatus +} + +export interface UseeSendTxCallbacks { + onTransactionSettled?: () => void +} + +/** + * useWrite counterpart for sending generic transactions. + */ +export function useSendTx( + args: UseEstimateGasParameters & { enabled?: boolean }, + callbacks: UseeSendTxCallbacks = {}, +): UseSendTxResult { + const chainId = useOriginChainId() + const enabled = args.enabled ?? !!(args.to && (args.data || args.value)) + + const { address: account } = useAccount() + const { + data: gasEstimate, + error: _simulationError, + refetch: resimulate, + isLoading: isSimulationLoading, + } = useEstimateGas({ + account, + ...args, + query: { + gcTime: 0, + enabled, + ...args.query, + }, + }) + // @note: workaround for wagmi serving results from cache even if enabled = false. https://github.com/wevm/wagmi/issues/888 + const simulationError = enabled ? _simulationError : undefined + + const { + sendTransaction, + data: txHash, + isPending: isTxSending, + isSuccess: wasTxSent, + error: _txSubmissionError, + reset, + } = useSendTransaction() + + const { data: txReceipt, error: txReceiptError } = useWaitForTransactionReceiptUniversal({ + hash: txHash, + }) + const txSubmissionError = enabled ? _txSubmissionError : undefined + + useEffect(() => { + if (txReceipt) { + callbacks.onTransactionSettled?.() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [txReceipt]) + + const status = ((): WriteStatus => { + if (!enabled) { + return { kind: 'disabled' } + } + if (isSimulationLoading) { + return { kind: 'simulating' } + } + if (simulationError) { + return { kind: 'error', errorKind: 'simulation', error: simulationError } + } + if (isTxSending) { + return { kind: 'tx-sending' } + } + if (txSubmissionError) { + return { kind: 'error', errorKind: 'tx-submission', error: txSubmissionError } + } + if (wasTxSent && txReceipt) { + // txReceipt is only available when tx didn't revert + return { kind: 'success' } + } + if (wasTxSent && txReceiptError) { + return { kind: 'error', errorKind: 'tx-reverted', error: txReceiptError } + } + if (wasTxSent) { + return { kind: 'tx-mining' } + } + + return { kind: 'ready' } + })() + + const finalSend = + gasEstimate && enabled + ? () => { + sanityCheckTx({ address: args.to ?? undefined, value: args.value }, chainId) + + sendTransaction({ + to: args.to!, + data: args.data!, + value: args.value, + gas: args.gas ?? gasEstimate, + }) + } + : () => {} + + return { + reset, + send: finalSend, + resimulate, + status, + } +} diff --git a/packages/app/src/domain/hooks/useWaitForTransactionReceiptGnosisSafe.ts b/packages/app/src/domain/hooks/useWaitForTransactionReceiptGnosisSafe.ts new file mode 100644 index 000000000..22bee8470 --- /dev/null +++ b/packages/app/src/domain/hooks/useWaitForTransactionReceiptGnosisSafe.ts @@ -0,0 +1,58 @@ +import { useState } from 'react' +import invariant from 'tiny-invariant' +import { parseAbi } from 'viem' +import { + useAccount, + useWaitForTransactionReceipt, + UseWaitForTransactionReceiptParameters, + UseWaitForTransactionReceiptReturnType, + useWatchContractEvent, +} from 'wagmi' + +const gnosisAbi = parseAbi(['event ExecutionSuccess(bytes32 txHash, uint256 payment)']) + +/** + * Like `useWaitForTransactionReceipt`, but works with Gnosis Safe "subtransaction" hashes. + * It will watch events emitted by the sender (Gnosis Safe contract) and look for 'ExecutionSuccess' event with matching subtransaction hash. + */ +export function useWaitForTransactionReceiptGnosisSafe( + args: UseWaitForTransactionReceiptParameters = {}, +): UseWaitForTransactionReceiptReturnType & { + txHash: `0x${string}` | undefined +} { + const { address } = useAccount() + const enabled = args.query?.enabled ?? true + const subTxHash = args.hash + + const [gnosisTxHash, setGnosisTxHash] = useState<`0x${string}` | undefined>() + useWatchContractEvent({ + abi: gnosisAbi, + address, + eventName: 'ExecutionSuccess', + enabled: enabled && !!address && !!subTxHash && gnosisTxHash === undefined, + onLogs: (logs) => { + if (gnosisTxHash) { + return + } + + for (const log of logs) { + if (log.args.txHash === subTxHash) { + invariant(log.transactionHash, 'Transaction hash not found') + + setGnosisTxHash(log.transactionHash) + return + } + } + }, + }) + + const chainTx = useWaitForTransactionReceipt({ + ...args, + hash: gnosisTxHash, + }) + + return { + ...chainTx, + txHash: gnosisTxHash, + } +} diff --git a/packages/app/src/domain/hooks/useWaitForTransactionReceiptUniversal.ts b/packages/app/src/domain/hooks/useWaitForTransactionReceiptUniversal.ts new file mode 100644 index 000000000..2722be5ef --- /dev/null +++ b/packages/app/src/domain/hooks/useWaitForTransactionReceiptUniversal.ts @@ -0,0 +1,42 @@ +import { + useWaitForTransactionReceipt, + UseWaitForTransactionReceiptParameters, + UseWaitForTransactionReceiptReturnType, +} from 'wagmi' + +import { useWaitForTransactionReceiptGnosisSafe } from './useWaitForTransactionReceiptGnosisSafe' +import { useWalletType } from './useWalletType' + +/** + * This will watch for tx receipt for both "normal" tx hashes and Gnosis Safe "subtransaction" hashes. + * @note: Gnosis Safe tx can be sent as a subtx (just signed and later executed) or as a standalone tx + * (signed and executed in one go). We have no way of knowing so we watch for both. + */ +export function useWaitForTransactionReceiptUniversal( + args: UseWaitForTransactionReceiptParameters = {}, +): UseWaitForTransactionReceiptReturnType & { + txHash: `0x${string}` | undefined +} { + const walletType = useWalletType() + + const gnosisTxReceipt = useWaitForTransactionReceiptGnosisSafe({ + ...args, + hash: args.hash, + query: { + enabled: walletType === 'gnosis-safe', + }, + }) + const receipt = useWaitForTransactionReceipt({ + ...args, + hash: args.hash, + query: { + enabled: gnosisTxReceipt.data === undefined, + }, + }) + + if (gnosisTxReceipt.data) { + return gnosisTxReceipt + } + + return { ...receipt, txHash: args.hash } +} diff --git a/packages/app/src/domain/hooks/useWalletType.ts b/packages/app/src/domain/hooks/useWalletType.ts new file mode 100644 index 000000000..a8f8beb96 --- /dev/null +++ b/packages/app/src/domain/hooks/useWalletType.ts @@ -0,0 +1,34 @@ +import { Address } from 'viem' +import { useAccount, useBytecode } from 'wagmi' + +export type WalletType = 'gnosis-safe' | 'universal' + +export function useWalletType(): WalletType | undefined { + const { address, connector } = useAccount() + const canBeGnosisSafe = connector?.name === 'WalletConnect' // avoids querying bytecode if not needed + const isSmartContract = useIsSmartContract(canBeGnosisSafe ? address : undefined) + + if (!address) { + return undefined + } + + if (!canBeGnosisSafe) { + return 'universal' + } + + if (isSmartContract) { + return 'gnosis-safe' // this assumes that Gnosis Safe is the only smart contract wallet + } + + return undefined +} + +function useIsSmartContract(address: Address | undefined): boolean | undefined { + const response = useBytecode({ address }) + + if (!response.data) { + return undefined + } + + return response.data.length > 0 +} diff --git a/packages/app/src/domain/hooks/useWrite.test.ts b/packages/app/src/domain/hooks/useWrite.test.ts new file mode 100644 index 000000000..6bf723369 --- /dev/null +++ b/packages/app/src/domain/hooks/useWrite.test.ts @@ -0,0 +1,145 @@ +import { waitFor } from '@testing-library/react' +import { erc20Abi } from 'viem' +import { mainnet } from 'viem/chains' + +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { getTestTrigger } from '@/test/integration/trigger' + +import { useWrite } from './useWrite' + +describe(useWrite.name, () => { + it('simulates the transaction', async () => { + const { trigger, release } = getTestTrigger() + const { result } = hookRenderer({ handlers: [balanceCall, handlers.triggerHandler(simulateCallHandler, trigger)] }) + + await waitFor(() => expect(result.current.status.kind).toBe('simulating')) + + release() + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + }) + + it('sends the transaction and waits for it to be mined', async () => { + const { result } = hookRenderer({ extraHandlers: [handlers.mineTransaction()] }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + + result.current.write() + + await waitFor(() => expect(result.current.status.kind).toBe('success')) + }) + + it('propagates simulation errors', async () => { + const expectedError = 'forced error' + const { result } = hookRenderer({ + handlers: [balanceCall, handlers.forceCallErrorHandler(simulateCallHandler, expectedError)], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('error')) + expect((result.current.status as any).errorKind).toBe('simulation') + expect((result.current.status as any).error.shortMessage).toBe( + `The contract function "transfer" reverted with the following reason:\n${expectedError}`, + ) + }) + + it('propagates tx-submission errors', async () => { + const { result } = hookRenderer({ + handlers: [chainIdCall, balanceCall, simulateCallHandler, handlers.rejectSubmittedTransaction()], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + + result.current.write() + + await waitFor(() => expect(result.current.status.kind).toBe('error'), { timeout: 5000 }) // it takes more time because of retries + expect((result.current.status as any).errorKind).toBe('tx-submission') + expect((result.current.status as any).error.shortMessage).toBe('An unknown RPC error occurred.') // @todo this is due to rejectSubmittedTransaction not being perfect + }) + + it('propagates tx-reverted errors', async () => { + const { result } = hookRenderer({ + handlers: [chainIdCall, balanceCall, simulateCallHandler, handlers.mineRevertedTransaction()], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + + result.current.write() + + await waitFor(() => expect(result.current.status.kind).toBe('error')) + expect((result.current.status as any).errorKind).toBe('tx-reverted') + expect((result.current.status as any).error.shortMessage).toBe('An unknown RPC error occurred.') // @todo this is due to mineRejectedTransaction not being perfect + }) + + it('resets the write state when the args change', async () => { + // for some reason this test doesn't work and is stuck in the "disabled" state (write function is not recreated properly by wagmi) + const { result, rerender } = hookRenderer({ + extraHandlers: [ + handlers.mineTransaction(), + + handlers.contractCall({ + to: testAddresses.token, + abi: erc20Abi, + functionName: 'transfer', + args: [testAddresses.bob, 200n], + from: testAddresses.alice, + result: true, + }), + ], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + + result.current.write() + + await waitFor(() => expect(result.current.status.kind).toBe('success')) + + rerender({ + args: { + ...defaultArgs, + args: [testAddresses.bob, 200n], // bump value to trigger reset + } as any, + }) + + await waitFor(() => expect(result.current.status.kind).toBe('ready')) + }) + + it('does not propagate simulation errors if disabled', async () => { + const expectedError = 'forced error' + const { result, rerender } = hookRenderer({ + handlers: [balanceCall, handlers.forceCallErrorHandler(simulateCallHandler, expectedError)], + }) + + await waitFor(() => expect(result.current.status.kind).toBe('error')) + rerender({ args: { ...defaultArgs } as any, enabled: false }) + await waitFor(() => expect(result.current.status.kind).toBe('disabled')) + await waitFor(() => expect((result.current as any).error).toBe(undefined)) + }) +}) + +const balanceCall = handlers.balanceCall({ balance: 0n, address: testAddresses.alice }) +const chainIdCall = handlers.chainIdCall({ chainId: mainnet.id }) + +const simulateCallHandler = handlers.contractCall({ + to: testAddresses.token, + abi: erc20Abi, + functionName: 'transfer', + args: [testAddresses.bob, 100n], + from: testAddresses.alice, + result: true, +}) + +const defaultArgs = { + address: testAddresses.token, + abi: erc20Abi, + functionName: 'transfer', + args: [testAddresses.bob, 100n], +} as const + +const hookRenderer = setupHookRenderer({ + hook: useWrite, + account: testAddresses.alice, + handlers: [chainIdCall, balanceCall, simulateCallHandler], + args: defaultArgs, +}) diff --git a/packages/app/src/domain/hooks/useWrite.ts b/packages/app/src/domain/hooks/useWrite.ts new file mode 100644 index 000000000..c79fee91f --- /dev/null +++ b/packages/app/src/domain/hooks/useWrite.ts @@ -0,0 +1,136 @@ +import { useEffect } from 'react' +import { Abi, ContractFunctionName } from 'viem' +import { useAccount, useSimulateContract, UseSimulateContractParameters, useWriteContract } from 'wagmi' + +import { sanityCheckTx } from './sanityChecks' +import { useOriginChainId } from './useOriginChainId' +import { useWaitForTransactionReceiptUniversal } from './useWaitForTransactionReceiptUniversal' + +export type WriteStatus = + | { kind: 'disabled' } + | { kind: 'simulating' } + | { kind: 'ready' } + | { kind: 'tx-sending' } + | { kind: 'tx-mining' } + | { kind: 'success' } + | { kind: 'error'; errorKind: WriteErrorKind; error: Error } +export type WriteErrorKind = 'simulation' | 'tx-submission' | 'tx-reverted' + +export interface UseWriteResult { + write: () => void + resimulate: () => void + reset: () => void + status: WriteStatus +} + +export interface UseWriteCallbacks { + onTransactionSettled?: () => void +} + +/** + * Write to a contract with a sane API. + * - Prepares txs + * - Waits for tx to be mined + * - Does not propagate simulation errors if disabled + */ +export function useWrite>( + args: UseSimulateContractParameters & { enabled?: boolean }, + callbacks: UseWriteCallbacks = {}, +): UseWriteResult { + const chainId = useOriginChainId() + const enabled = args.enabled ?? true + // used to reset the write state when the args change + + const { address: account } = useAccount() + const { + data: parameters, + error: _simulationError, + refetch: resimulate, + isLoading: isSimulationLoading, + } = useSimulateContract({ + account, + ...args, + query: { + gcTime: 0, + enabled, + ...args.query, + }, + } as any) + // @note: workaround for wagmi serving results from cache even if enabled = false. https://github.com/wevm/wagmi/issues/888 + const simulationError = enabled ? _simulationError : undefined + + const { + writeContract, + data: txHash, + isPending: isTxSending, + isSuccess: wasTxSent, + error: _txSubmissionError, + reset, + } = useWriteContract() + + const { data: txReceipt, error: txReceiptError } = useWaitForTransactionReceiptUniversal({ + hash: txHash, + }) + const txSubmissionError = enabled ? _txSubmissionError : undefined + + useEffect(() => { + if (txReceipt) { + callbacks.onTransactionSettled?.() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [txReceipt]) + + const status = ((): WriteStatus => { + if (!enabled) { + return { kind: 'disabled' } + } + if (isSimulationLoading) { + return { kind: 'simulating' } + } + if (simulationError) { + return { kind: 'error', errorKind: 'simulation', error: simulationError } + } + if (isTxSending) { + return { kind: 'tx-sending' } + } + if (txSubmissionError) { + return { kind: 'error', errorKind: 'tx-submission', error: txSubmissionError } + } + if (wasTxSent && txReceipt) { + // txReceipt is only available when tx didn't revert + return { kind: 'success' } + } + if (wasTxSent && txReceiptError) { + return { kind: 'error', errorKind: 'tx-reverted', error: txReceiptError } + } + if (wasTxSent) { + return { kind: 'tx-mining' } + } + + return { kind: 'ready' } + })() + + const finalWrite = + parameters && enabled + ? () => { + sanityCheckTx(parameters.request, chainId) + + writeContract(parameters.request as any) + } + : () => {} + + return { + reset, + write: finalWrite, + resimulate, + status, + } +} + +// useful for creating conditional configs without losing type-safety +export function ensureConfigTypes< + TAbi extends Abi, + TFunctionName extends ContractFunctionName, +>(config: UseSimulateContractParameters): UseSimulateContractParameters { + return config as any +} diff --git a/packages/app/src/domain/i18n/I18nAppProvider.tsx b/packages/app/src/domain/i18n/I18nAppProvider.tsx new file mode 100644 index 000000000..fdfdcfe45 --- /dev/null +++ b/packages/app/src/domain/i18n/I18nAppProvider.tsx @@ -0,0 +1,16 @@ +import { i18n } from '@lingui/core' +import { I18nProvider } from '@lingui/react' +import { ReactNode, useEffect } from 'react' +import toast from 'react-hot-toast' + +import { defaultLocale, switchLocale } from './locales' + +export function I18nAppProvider({ children }: { children: ReactNode }) { + useEffect(() => { + switchLocale(defaultLocale).catch((error) => { + toast.error('Failed to load locale', error) + }) + }, []) + + return {children} +} diff --git a/packages/app/src/domain/i18n/I18nTestProvider.tsx b/packages/app/src/domain/i18n/I18nTestProvider.tsx new file mode 100644 index 000000000..c73dfc0b0 --- /dev/null +++ b/packages/app/src/domain/i18n/I18nTestProvider.tsx @@ -0,0 +1,14 @@ +import { i18n } from '@lingui/core' +import { I18nProvider } from '@lingui/react' +import { ReactNode } from 'react' + +// @todo: any better idea to import locales? TypeScript doesn't like this +// @ts-ignore +import { messages } from '../../locales/en.po' + +i18n.load('en', messages) +i18n.activate('en') + +export function I18nTestProvider({ children }: { children: ReactNode }) { + return {children} +} diff --git a/packages/app/src/domain/i18n/locales.ts b/packages/app/src/domain/i18n/locales.ts new file mode 100644 index 000000000..9161c0dcf --- /dev/null +++ b/packages/app/src/domain/i18n/locales.ts @@ -0,0 +1,22 @@ +import { i18n } from '@lingui/core' + +export const locales = { + en: 'English', + pl: 'Polski', +} +export type Locale = keyof typeof locales +export const defaultLocale = 'en' satisfies Locale + +/** + * Dynamically load and activate locale + */ +export async function switchLocale(locale: string): Promise { + const { messages } = await import(`../../locales/${locale}.po`) + + i18n.load(locale, messages) + i18n.activate(locale) +} + +export function getCurrentLocale(): Locale { + return i18n.locale as Locale +} diff --git a/packages/app/src/domain/maker-info/getIsChainSupported.ts b/packages/app/src/domain/maker-info/getIsChainSupported.ts new file mode 100644 index 000000000..fac4ac3e9 --- /dev/null +++ b/packages/app/src/domain/maker-info/getIsChainSupported.ts @@ -0,0 +1,13 @@ +import { goerli, mainnet } from 'viem/chains' + +import { getOriginChainId } from '../hooks/useOriginChainId' +import { useStore } from '../state' + +const MAKER_INFO_SUPPORTED_CHAIN_IDS = [mainnet, goerli].map((chain) => chain.id) + +export function getIsChainSupported(chainId: number): boolean { + const sandbox = useStore.getState().sandbox.network + const originChainId = getOriginChainId(chainId, sandbox) + + return MAKER_INFO_SUPPORTED_CHAIN_IDS.includes(originChainId) +} diff --git a/packages/app/src/domain/maker-info/makerInfoQuery.ts b/packages/app/src/domain/maker-info/makerInfoQuery.ts new file mode 100644 index 000000000..4f01b9f01 --- /dev/null +++ b/packages/app/src/domain/maker-info/makerInfoQuery.ts @@ -0,0 +1,103 @@ +import { rayMul } from '@aave/math-utils' +import BigNumber from 'bignumber.js' +import { stringToHex } from 'viem' +import { Config } from 'wagmi' +import { multicall } from 'wagmi/actions' + +import { + iamAutoLineAbi, + iamAutoLineAddress, + potAbi, + potAddress, + vatAbi, + vatAddress, +} from '@/config/contracts-generated' +import { getContractAddress } from '@/domain/hooks/useContractAddress' +import { bigNumberify } from '@/utils/bigNumber' +import { fromRad, fromRay, fromWad } from '@/utils/math' + +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { getIsChainSupported } from './getIsChainSupported' +import { MakerInfo } from './types' + +interface MakerInfoQueryParams { + wagmiConfig: Config + chainId: number +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function makerInfoQuery({ wagmiConfig, chainId }: MakerInfoQueryParams) { + const queryKey = ['maker-info', chainId] + + const isChainSupported = getIsChainSupported(chainId) + if (!isChainSupported) { + return { queryKey, queryFn: async () => null } + } + + const makerVatAddress = getContractAddress(vatAddress, chainId) + const makerIamAutoLineAddress = getContractAddress(iamAutoLineAddress, chainId) + const makerPotAddress = getContractAddress(potAddress, chainId) + const sparkIlkId = stringToHex('DIRECT-SPARK-DAI', { size: 32 }) + + async function queryFn(): Promise { + const [[vatArt, vatRate], [IAMLine], dsr, rho, chi] = await multicall(wagmiConfig, { + allowFailure: false, + chainId, + contracts: [ + { + address: makerVatAddress, + functionName: 'ilks', + args: [sparkIlkId], + abi: vatAbi, + }, + { + address: makerIamAutoLineAddress, + functionName: 'ilks', + args: [sparkIlkId], + abi: iamAutoLineAbi, + }, + { + address: makerPotAddress, + functionName: 'dsr', + args: [], + abi: potAbi, + }, + { + address: makerPotAddress, + functionName: 'rho', + args: [], + abi: potAbi, + }, + { + address: makerPotAddress, + functionName: 'chi', + args: [], + abi: potAbi, + }, + ], + }) + const D3MCurrentDebt = rayMul(bigNumberify(vatArt), bigNumberify(vatRate)) + const D3MCurrentDebtUSD = NormalizedUnitNumber(fromWad(D3MCurrentDebt)) + const maxDebtCeiling = NormalizedUnitNumber(fromRad(bigNumberify(IAMLine))) + // DSR is stored as a ray per second, so we need to convert it to a yearly rate + BigNumber.config({ POW_PRECISION: 100 }) // https://github.com/MikeMcl/bignumber.js/issues/38 + const DSR = Percentage( + fromRay(bigNumberify(dsr)) + .pow(60 * 60 * 24 * 365) + .minus(1), + ) + + return { + D3MCurrentDebtUSD, + maxDebtCeiling, + DSR, + potParameters: { + dsr: bigNumberify(dsr), + rho: bigNumberify(rho), + chi: bigNumberify(chi), + }, + } + } + + return { queryKey, queryFn } +} diff --git a/packages/app/src/domain/maker-info/types.ts b/packages/app/src/domain/maker-info/types.ts new file mode 100644 index 000000000..d05463a6a --- /dev/null +++ b/packages/app/src/domain/maker-info/types.ts @@ -0,0 +1,16 @@ +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' + +export interface MakerInfo { + D3MCurrentDebtUSD: NormalizedUnitNumber + maxDebtCeiling: NormalizedUnitNumber + DSR: Percentage + potParameters: PotParams +} + +export interface PotParams { + dsr: BigNumber + rho: BigNumber + chi: BigNumber +} diff --git a/packages/app/src/domain/maker-info/useMakerInfo.ts b/packages/app/src/domain/maker-info/useMakerInfo.ts new file mode 100644 index 000000000..6c39de117 --- /dev/null +++ b/packages/app/src/domain/maker-info/useMakerInfo.ts @@ -0,0 +1,28 @@ +import { useSuspenseQuery } from '@tanstack/react-query' +import { useChainId, useConfig } from 'wagmi' + +import { SuspenseQueryWith } from '@/utils/types' + +import { makerInfoQuery } from './makerInfoQuery' +import { MakerInfo } from './types' + +interface UseMakerInfoParams { + chainId?: number +} + +type UseMakerInfoResult = SuspenseQueryWith<{ + makerInfo: MakerInfo | undefined +}> + +export function useMakerInfo(params: UseMakerInfoParams = {}): UseMakerInfoResult { + const currentChainId = useChainId() + const chainId = params.chainId ?? currentChainId + const wagmiConfig = useConfig() + + const result = useSuspenseQuery(makerInfoQuery({ wagmiConfig, chainId })) + + return { + ...result, + makerInfo: result.data ?? undefined, + } +} diff --git a/packages/app/src/domain/market-info/aave-data-layer/query.ts b/packages/app/src/domain/market-info/aave-data-layer/query.ts new file mode 100644 index 000000000..6cddb7b41 --- /dev/null +++ b/packages/app/src/domain/market-info/aave-data-layer/query.ts @@ -0,0 +1,184 @@ +import { formatReservesAndIncentives, formatUserSummary } from '@aave/math-utils' +import { Address } from 'viem' +import { Config } from 'wagmi' +import { multicall } from 'wagmi/actions' + +import { + lendingPoolAddressProviderAddress, + uiIncentiveDataProviderAbi, + uiIncentiveDataProviderAddress, + uiPoolDataProviderAbi, + uiPoolDataProviderAddress, +} from '@/config/contracts-generated' + +import { getContractAddress } from '../../hooks/useContractAddress' + +export interface AaveDataLayerArgs { + wagmiConfig: Config + chainId: number + account?: Address +} + +export type AaveData = Awaited['queryFn']>> +export type AaveUserSummary = AaveData['userSummary'] +export type AaveUserReserve = AaveUserSummary['userReservesData'][number]['reserve'] +export type RawAaveUserReserve = AaveData['rawUserReserves'][number] +export type AaveFormattedReserve = AaveData['formattedReserves'][number] +export type AaveBaseCurrency = AaveData['baseCurrency'] +export type AaveUserSummaryReservesData = AaveUserSummary['userReservesData'] + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function aaveDataLayer({ wagmiConfig, chainId, account }: AaveDataLayerArgs) { + const uiPoolDataProvider = getContractAddress(uiPoolDataProviderAddress, chainId) + const lendingPoolAddressProvider = getContractAddress(lendingPoolAddressProviderAddress, chainId) + const uiIncentiveDataProvider = getContractAddress(uiIncentiveDataProviderAddress, chainId) + + const queryKey = ['reserves', account, chainId] + + // eslint-disable-next-line @typescript-eslint/explicit-function-return-type + async function queryFn() { + const data = await multicall(wagmiConfig, { + allowFailure: false, + chainId, + contracts: [ + { + address: uiPoolDataProvider, + functionName: 'getReservesData', + args: [lendingPoolAddressProvider], + abi: uiPoolDataProviderAbi, + }, + { + address: uiIncentiveDataProvider, + abi: uiIncentiveDataProviderAbi, + functionName: 'getReservesIncentivesData', + args: [lendingPoolAddressProvider], + }, + { + address: uiPoolDataProvider, + abi: uiPoolDataProviderAbi, + functionName: 'getUserReservesData', + args: [lendingPoolAddressProvider, account ?? uiPoolDataProvider], // little hack to support properly formatted data for guest + }, + ], + }) + + const [[reserves, baseCurrencyInfo], reservesIncentiveData, [userReserves, userEmodeCategoryId]] = data + + const currentTimestamp = Math.floor(Date.now() / 1000) + + const baseCurrency = { + marketReferenceCurrencyDecimals: baseCurrencyInfo.marketReferenceCurrencyUnit.toString().length - 1, + marketReferenceCurrencyPriceInUsd: baseCurrencyInfo.marketReferenceCurrencyPriceInUsd.toString(), + networkBaseTokenPriceInUsd: baseCurrencyInfo.networkBaseTokenPriceInUsd.toString(), + networkBaseTokenPriceDecimals: baseCurrencyInfo.networkBaseTokenPriceDecimals, + } + + const rawUserReserves = userReserves.map((r) => ({ + ...r, + scaledATokenBalance: r.scaledATokenBalance.toString(), + stableBorrowRate: r.stableBorrowRate.toString(), + scaledVariableDebt: r.scaledVariableDebt.toString(), + principalStableDebt: r.principalStableDebt.toString(), + stableBorrowLastUpdateTimestamp: Number(r.stableBorrowLastUpdateTimestamp), + })) + + const formattedReserves = formatReservesAndIncentives({ + reserveIncentives: reservesIncentiveData.map((r) => ({ + ...r, + aIncentiveData: { + ...r.aIncentiveData, + rewardsTokenInformation: r.aIncentiveData.rewardsTokenInformation.map((rawRewardInfo) => ({ + ...rawRewardInfo, + emissionPerSecond: rawRewardInfo.emissionPerSecond.toString(), + incentivesLastUpdateTimestamp: Number(rawRewardInfo.incentivesLastUpdateTimestamp), + tokenIncentivesIndex: rawRewardInfo.tokenIncentivesIndex.toString(), + emissionEndTimestamp: Number(rawRewardInfo.emissionEndTimestamp), + rewardPriceFeed: rawRewardInfo.rewardPriceFeed.toString(), + })), + }, + vIncentiveData: { + ...r.vIncentiveData, + rewardsTokenInformation: r.vIncentiveData.rewardsTokenInformation.map((rawRewardInfo) => ({ + ...rawRewardInfo, + emissionPerSecond: rawRewardInfo.emissionPerSecond.toString(), + incentivesLastUpdateTimestamp: Number(rawRewardInfo.incentivesLastUpdateTimestamp), + tokenIncentivesIndex: rawRewardInfo.tokenIncentivesIndex.toString(), + emissionEndTimestamp: Number(rawRewardInfo.emissionEndTimestamp), + rewardPriceFeed: rawRewardInfo.rewardPriceFeed.toString(), + })), + }, + sIncentiveData: { + ...r.sIncentiveData, + rewardsTokenInformation: r.sIncentiveData.rewardsTokenInformation.map((rawRewardInfo) => ({ + ...rawRewardInfo, + emissionPerSecond: rawRewardInfo.emissionPerSecond.toString(), + incentivesLastUpdateTimestamp: Number(rawRewardInfo.incentivesLastUpdateTimestamp), + tokenIncentivesIndex: rawRewardInfo.tokenIncentivesIndex.toString(), + emissionEndTimestamp: Number(rawRewardInfo.emissionEndTimestamp), + rewardPriceFeed: rawRewardInfo.rewardPriceFeed.toString(), + })), + }, + })), + currentTimestamp, + marketReferencePriceInUsd: baseCurrency.marketReferenceCurrencyPriceInUsd, + marketReferenceCurrencyDecimals: baseCurrency.marketReferenceCurrencyDecimals, + reserves: reserves.map((r) => ({ + ...r, + id: `${chainId}-${r.underlyingAsset}-${lendingPoolAddressProvider}`, + decimals: Number(r.decimals), + reserveFactor: r.reserveFactor.toString(), + baseLTVasCollateral: r.baseLTVasCollateral.toString(), + averageStableRate: r.averageStableRate.toString(), + stableDebtLastUpdateTimestamp: Number(r.stableDebtLastUpdateTimestamp), + liquidityIndex: r.liquidityIndex.toString(), + reserveLiquidationThreshold: r.reserveLiquidationThreshold.toString(), + reserveLiquidationBonus: r.reserveLiquidationBonus.toString(), + variableBorrowIndex: r.variableBorrowIndex.toString(), + variableBorrowRate: r.variableBorrowRate.toString(), + availableLiquidity: r.availableLiquidity.toString(), + stableBorrowRate: r.stableBorrowRate.toString(), + liquidityRate: r.liquidityRate.toString(), + totalPrincipalStableDebt: r.totalPrincipalStableDebt.toString(), + totalScaledVariableDebt: r.totalScaledVariableDebt.toString(), + borrowCap: r.borrowCap.toString(), + supplyCap: r.supplyCap.toString(), + debtCeiling: r.debtCeiling.toString(), + debtCeilingDecimals: Number(r.debtCeilingDecimals), + isolationModeTotalDebt: r.isolationModeTotalDebt.toString(), + unbacked: r.unbacked.toString(), + priceInMarketReferenceCurrency: r.priceInMarketReferenceCurrency.toString(), + variableRateSlope1: r.variableRateSlope1.toString(), + variableRateSlope2: r.variableRateSlope2.toString(), + stableRateSlope1: r.stableRateSlope1.toString(), + stableRateSlope2: r.stableRateSlope2.toString(), + baseStableBorrowRate: r.baseStableBorrowRate.toString(), + baseVariableBorrowRate: r.baseVariableBorrowRate.toString(), + optimalUsageRatio: r.optimalUsageRatio.toString(), + accruedToTreasury: r.accruedToTreasury.toString(), + })), + }) + + const userSummary = formatUserSummary({ + currentTimestamp, + marketReferencePriceInUsd: baseCurrency.marketReferenceCurrencyPriceInUsd, + marketReferenceCurrencyDecimals: baseCurrency.marketReferenceCurrencyDecimals, + userReserves: rawUserReserves, + formattedReserves, + userEmodeCategoryId, + }) + + return { + rawUserReserves, + baseCurrency, + formattedReserves, + userSummary, + userEmodeCategoryId, + timestamp: currentTimestamp, + } + } + + return { + queryKey, + queryFn, + } +} diff --git a/packages/app/src/domain/market-info/aave-data-layer/useAaveDataLayer.ts b/packages/app/src/domain/market-info/aave-data-layer/useAaveDataLayer.ts new file mode 100644 index 000000000..2a6417b52 --- /dev/null +++ b/packages/app/src/domain/market-info/aave-data-layer/useAaveDataLayer.ts @@ -0,0 +1,33 @@ +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { SuspenseQueryWith } from '@/utils/types' + +import { AaveData, aaveDataLayer } from './query' + +export interface UseAaveDataLayerParams { + chainId?: number +} +export type UseAaveDataLayerResultOnSuccess = SuspenseQueryWith<{ + aaveData: AaveData +}> + +export function useAaveDataLayer(params: UseAaveDataLayerParams = {}): UseAaveDataLayerResultOnSuccess { + const { address } = useAccount() + const currentChainId = useChainId() + const chainId = params.chainId ?? currentChainId + const wagmiConfig = useConfig() + + const result = useSuspenseQuery({ + ...aaveDataLayer({ + wagmiConfig, + chainId, + account: address, + }), + }) + + return { + ...result, + aaveData: result.data, + } +} diff --git a/packages/app/src/domain/market-info/emode.ts b/packages/app/src/domain/market-info/emode.ts new file mode 100644 index 000000000..10e072bcc --- /dev/null +++ b/packages/app/src/domain/market-info/emode.ts @@ -0,0 +1,42 @@ +import invariant from 'tiny-invariant' + +import { Percentage } from '../types/NumericValues' +import { AaveFormattedReserve } from './aave-data-layer/query' +import { EModeCategories, EModeState } from './marketInfo' +import { parseRawPercentage } from './math' + +// The easiest way to get information eMode categories is to extract and reconstruct it from the reserves +export function extractEmodeInfoFromReserves(reserves: AaveFormattedReserve[]): EModeCategories { + const emodeCategories: EModeCategories = {} + for (const reserve of reserves) { + if (reserve.eModeCategoryId === 0) { + continue + } + const eModeCategoryId = reserve.eModeCategoryId + if (emodeCategories[eModeCategoryId]) { + continue + } + + emodeCategories[eModeCategoryId] = { + id: eModeCategoryId, + name: reserve.eModeLabel, + ltv: parseRawPercentage(reserve.eModeLtv), + liquidationBonus: Percentage( + parseRawPercentage(reserve.eModeLiquidationBonus, { allowMoreThan1: true }).minus(1), + ), + liquidationThreshold: parseRawPercentage(reserve.eModeLiquidationThreshold), + } + } + + return emodeCategories +} + +export function determineEModeState(userEmodeCategoryId: number, emodeCategories: EModeCategories): EModeState { + if (userEmodeCategoryId === 0) { + return { enabled: false } + } + const userCategory = emodeCategories[userEmodeCategoryId] + invariant(userCategory, 'User emode category not found') + + return { enabled: true, category: userCategory } +} diff --git a/packages/app/src/domain/market-info/getLiquidationDetails.test.ts b/packages/app/src/domain/market-info/getLiquidationDetails.test.ts new file mode 100644 index 000000000..3e3eab972 --- /dev/null +++ b/packages/app/src/domain/market-info/getLiquidationDetails.test.ts @@ -0,0 +1,314 @@ +import { describe } from 'vitest' + +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { testAddresses } from '@/test/integration/constants' + +import { getLiquidationDetails } from './getLiquidationDetails' + +describe(getLiquidationDetails.name, () => { + describe('no existing deposits and debt', () => { + const alreadyDeposited = { + tokens: [], + totalValueUSD: NormalizedUnitNumber(0), + } + const alreadyBorrowed = { + tokens: [], + totalValueUSD: NormalizedUnitNumber(0), + } + const tokensToBorrow = [{ token: daiLike, value: NormalizedUnitNumber(1000) }] + const marketInfo = getMockedMarketInfo({}) + + it('returns undefined when no tokens to deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [], + }) + + expect(result).toBeUndefined() + }) + + it('returns undefined when multiple tokens to deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [ + { token: ethLike, value: NormalizedUnitNumber(1) }, + { token: btcLike, value: NormalizedUnitNumber(1) }, + ], + }) + + expect(result).toBeUndefined() + }) + + it('returns correct data for a single deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + tokensToDeposit: [{ token: ethLike, value: NormalizedUnitNumber(1) }], + marketInfo: getMockedMarketInfo({ depositToken: ethLike }), + }) + + expect(result).toStrictEqual({ + liquidationPrice: NormalizedUnitNumber(1250), + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(2000), + symbol: ethLike.symbol, + }, + }) + }) + }) + + describe('existing single deposit, no debt', () => { + const alreadyDeposited = { + tokens: [ethLike], + totalValueUSD: NormalizedUnitNumber(2000), + } + const alreadyBorrowed = { + tokens: [], + totalValueUSD: NormalizedUnitNumber(0), + } + const tokensToBorrow = [{ token: daiLike, value: NormalizedUnitNumber(1000) }] + const marketInfo = getMockedMarketInfo({ collateralToken: ethLike, collateralBalance: '1' }) + + it('returns undefined when multiple tokens to deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [ + { token: ethLike, value: NormalizedUnitNumber(1) }, + { token: btcLike, value: NormalizedUnitNumber(1) }, + ], + }) + + expect(result).toBeUndefined() + }) + + it('returns correct data for a single deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [{ token: ethLike, value: NormalizedUnitNumber(1) }], + }) + + expect(result).toStrictEqual({ + liquidationPrice: NormalizedUnitNumber(625), + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(2000), + symbol: ethLike.symbol, + }, + }) + }) + + it('returns correct data when no new deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [], + }) + + expect(result).toStrictEqual({ + liquidationPrice: NormalizedUnitNumber(1250), + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(2000), + symbol: ethLike.symbol, + }, + }) + }) + }) + + describe('existing multiple deposits, no debt', () => { + const alreadyDeposited = { + tokens: [ethLike, btcLike], + totalValueUSD: NormalizedUnitNumber(42000), + } + const alreadyBorrowed = { + tokens: [], + totalValueUSD: NormalizedUnitNumber(0), + } + const tokensToBorrow = [{ token: daiLike, value: NormalizedUnitNumber(1000) }] + const marketInfo = getMockedMarketInfo({}) + + it('returns undefined when multiple deposits', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [{ token: ethLike, value: NormalizedUnitNumber(1) }], + }) + + expect(result).toBeUndefined() + }) + }) + + describe('existing multiple asset debt', () => { + const alreadyDeposited = { + tokens: [], + totalValueUSD: NormalizedUnitNumber(0), + } + const alreadyBorrowed = { + tokens: [daiLike, ethLike], + totalValueUSD: NormalizedUnitNumber(1000), + } + const tokensToBorrow = [{ token: daiLike, value: NormalizedUnitNumber(1000) }] + const marketInfo = getMockedMarketInfo({}) + + it('returns undefined when debt in multiple assets', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [{ token: ethLike, value: NormalizedUnitNumber(1) }], + }) + + expect(result).toBeUndefined() + }) + }) + + describe('existing single deposit, has debt in dai', () => { + const alreadyDeposited = { + tokens: [ethLike], + totalValueUSD: NormalizedUnitNumber(2000), + } + const alreadyBorrowed = { + tokens: [daiLike], + totalValueUSD: NormalizedUnitNumber(1000), + } + const tokensToBorrow = [{ token: daiLike, value: NormalizedUnitNumber(500) }] + const marketInfo = getMockedMarketInfo({ collateralToken: ethLike, collateralBalance: '1' }) + + it('returns undefined when multiple tokens to deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [ + { token: ethLike, value: NormalizedUnitNumber(1) }, + { token: btcLike, value: NormalizedUnitNumber(1) }, + ], + }) + + expect(result).toBeUndefined() + }) + + it('returns undefined when new single deposit is different than already deposited', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [{ token: btcLike, value: NormalizedUnitNumber(1) }], + }) + + expect(result).toBeUndefined() + }) + + it('returns correct data if no new deposit', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [], + }) + + expect(result).toStrictEqual({ + liquidationPrice: NormalizedUnitNumber(1875), + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(2000), + symbol: ethLike.symbol, + }, + }) + }) + + it('returns correct data if new deposit same as already deposited', () => { + const result = getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToBorrow, + marketInfo, + tokensToDeposit: [{ token: ethLike, value: NormalizedUnitNumber(1) }], + }) + + expect(result).toStrictEqual({ + liquidationPrice: NormalizedUnitNumber(937.5), + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(2000), + symbol: ethLike.symbol, + }, + }) + }) + }) +}) + +interface GetMockedMarketInfoOptions { + collateralBalance?: string + collateralToken?: Token + liquidationThreshold?: string + depositToken?: Token +} + +function getMockedMarketInfo({ + collateralBalance = '0', + liquidationThreshold = '0.8', + depositToken = ethLike, + collateralToken = ethLike, +}: GetMockedMarketInfoOptions): MarketInfo { + return { + // used to get data about existing deposits + findOnePositionBySymbol: () => ({ + collateralBalance: NormalizedUnitNumber(collateralBalance), + reserve: { + liquidationThreshold: NormalizedUnitNumber(liquidationThreshold), + priceInUSD: NormalizedUnitNumber(depositToken.unitPriceUsd), + token: depositToken, + }, + }), + // used to get data about the token to deposit + findOneReserveBySymbol: () => ({ + liquidationThreshold: NormalizedUnitNumber(liquidationThreshold), + priceInUSD: NormalizedUnitNumber(collateralToken.unitPriceUsd), + token: collateralToken, + }), + } as unknown as MarketInfo +} + +const address = testAddresses.token +const ethLike = new Token({ + address, + symbol: TokenSymbol('ETH'), + decimals: 18, + name: 'ETH Token', + unitPriceUsd: '2000', +}) +const btcLike = new Token({ + address, + symbol: TokenSymbol('BTC'), + decimals: 18, + name: 'BTC Token', + unitPriceUsd: '40000', +}) +const daiLike = new Token({ + address, + symbol: TokenSymbol('DAI'), + decimals: 18, + name: 'DAI Token', + unitPriceUsd: '1', +}) diff --git a/packages/app/src/domain/market-info/getLiquidationDetails.ts b/packages/app/src/domain/market-info/getLiquidationDetails.ts new file mode 100644 index 000000000..deedd1b6e --- /dev/null +++ b/packages/app/src/domain/market-info/getLiquidationDetails.ts @@ -0,0 +1,145 @@ +import BigNumber from 'bignumber.js' + +import { TokenWithValue } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { Token } from '../types/Token' + +export interface LiquidationDetails { + liquidationPrice: NormalizedUnitNumber + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber + symbol: TokenSymbol + } +} + +interface ExistingPosition { + tokens: Token[] + totalValueUSD: NormalizedUnitNumber +} + +interface GetLiquidationDetailsParams { + alreadyDeposited: ExistingPosition + alreadyBorrowed: ExistingPosition + tokensToDeposit: TokenWithValue[] + tokensToBorrow: TokenWithValue[] + marketInfo: MarketInfo +} + +export function getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToDeposit, + tokensToBorrow, + marketInfo, +}: GetLiquidationDetailsParams): LiquidationDetails | undefined { + const depositsCount = tokensToDeposit.length + const alreadyBorrowedCount = alreadyBorrowed.tokens.length + const alreadyDepositedCount = alreadyDeposited.tokens.length + const depositTokenSymbol = tokensToDeposit[0]?.token.symbol + + // collateral checks + const hasDepositOrCollateral = depositsCount !== 0 || alreadyDepositedCount !== 0 + const hasNoDepositButSingleCollateral = depositsCount === 0 && alreadyDepositedCount === 1 + const hasSingleDepositButNoCollateral = depositsCount === 1 && alreadyDepositedCount === 0 + const hasSingleDepositSameAsSingleCollateral = + depositsCount === 1 && alreadyDepositedCount === 1 && depositTokenSymbol === alreadyDeposited.tokens[0]?.symbol + + const depositCheck = + hasDepositOrCollateral || + hasNoDepositButSingleCollateral || + hasSingleDepositButNoCollateral || + hasSingleDepositSameAsSingleCollateral + + // checking that debt is Dai or there is no debt + const hasNoDebt = alreadyBorrowedCount === 0 + const hasOnlyDaiDebt = alreadyBorrowedCount === 1 && alreadyBorrowed.tokens[0]?.symbol === TokenSymbol('DAI') + const borrowCheck = hasNoDebt || hasOnlyDaiDebt + + if (!depositCheck || !borrowCheck) { + return undefined + } + + // From here we have conditions that are viable to calculate liquidation price + + const tokenToBorrowUSDValue = tokensToBorrow[0]?.value.multipliedBy(tokensToBorrow[0].token.unitPriceUsd) ?? 0 + const borrowInUSD = NormalizedUnitNumber(alreadyBorrowed.totalValueUSD.plus(tokenToBorrowUSDValue)) + + if (hasNoDepositButSingleCollateral) { + const collateralToken = alreadyDeposited.tokens[0]! + const collateralPosition = marketInfo.findOnePositionBySymbol(collateralToken.symbol) + const liquidationPrice = calculateLiquidationPrice({ + borrowInUSD, + depositAmount: collateralPosition.collateralBalance, + liquidationThreshold: collateralPosition.reserve.liquidationThreshold, + }) + + return { + liquidationPrice, + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(collateralPosition.reserve.priceInUSD), + symbol: collateralPosition.reserve.token.symbol, + }, + } + } + + if (hasSingleDepositButNoCollateral) { + const deposit = tokensToDeposit[0]! + const depositReserve = marketInfo.findOneReserveBySymbol(depositTokenSymbol!) + const liquidationPrice = calculateLiquidationPrice({ + borrowInUSD, + depositAmount: deposit.value, + liquidationThreshold: depositReserve.liquidationThreshold, + }) + + return { + liquidationPrice, + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(depositReserve.priceInUSD), + symbol: depositReserve.token.symbol, + }, + } + } + + if (hasSingleDepositSameAsSingleCollateral) { + const deposit = tokensToDeposit[0]! + const collateralToken = alreadyDeposited.tokens[0]! + const collateral = marketInfo.findOnePositionBySymbol(collateralToken.symbol) + const totalDepositValue = deposit.value.plus(collateral.collateralBalance) + const liquidationPrice = calculateLiquidationPrice({ + borrowInUSD, + depositAmount: totalDepositValue, + liquidationThreshold: collateral.reserve.liquidationThreshold, + }) + + return { + liquidationPrice, + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(collateral.reserve.priceInUSD), + symbol: collateral.reserve.token.symbol, + }, + } + } +} + +interface CalculateLiquidationPriceArguments { + borrowInUSD: BigNumber + depositAmount: BigNumber + liquidationThreshold: Percentage +} + +function calculateLiquidationPrice({ + borrowInUSD, + depositAmount, + liquidationThreshold, +}: CalculateLiquidationPriceArguments): NormalizedUnitNumber { + const denominator = depositAmount.multipliedBy(liquidationThreshold) + if (denominator.isZero()) { + return NormalizedUnitNumber(0) + } + + const liquidationPrice = borrowInUSD.dividedBy(denominator) + return NormalizedUnitNumber(liquidationPrice) +} diff --git a/packages/app/src/domain/market-info/incentives.ts b/packages/app/src/domain/market-info/incentives.ts new file mode 100644 index 000000000..5290fb38c --- /dev/null +++ b/packages/app/src/domain/market-info/incentives.ts @@ -0,0 +1,39 @@ +import { Percentage } from '../types/NumericValues' +import { Token } from '../types/Token' +import { TokenSymbol } from '../types/TokenSymbol' +import type { AaveData, AaveUserReserve } from './aave-data-layer/query' + +export interface IncentivesData { + deposit: Incentive[] + borrow: Incentive[] +} + +export interface Incentive { + token: Token + APR: Percentage +} + +type ReserveIncentiveResponse = NonNullable[number] +export function getIncentivesData( + reserve: AaveUserReserve, + findOneTokenBySymbol: (symbol: TokenSymbol) => Token, +): IncentivesData { + function formatIncentive(incentive: ReserveIncentiveResponse): Incentive { + const token = findOneTokenBySymbol(TokenSymbol(incentive.rewardTokenSymbol)) + + return { + token, + APR: Percentage(incentive.incentiveAPR), + } + } + + return { + deposit: (reserve.aIncentivesData ?? []).map(formatIncentive).filter(notZeroIncentive), + // Spark doesn't support stable borrows, so it's safe to assume that the only incentive for borrow is the variable borrow + borrow: (reserve.vIncentivesData ?? []).map(formatIncentive).filter(notZeroIncentive), + } +} + +function notZeroIncentive(incentive: Incentive): boolean { + return !incentive.APR.isZero() +} diff --git a/packages/app/src/domain/market-info/marketInfo.ts b/packages/app/src/domain/market-info/marketInfo.ts new file mode 100644 index 000000000..ba589b8e9 --- /dev/null +++ b/packages/app/src/domain/market-info/marketInfo.ts @@ -0,0 +1,339 @@ +import BigNumber from 'bignumber.js' + +import { NativeAssetInfo } from '@/config/chain/types' +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { fromRay } from '@/utils/math' +import { raise } from '@/utils/raise' + +import { bigNumberify } from '../../utils/bigNumber' +import { CheckedAddress } from '../types/CheckedAddress' +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { Token } from '../types/Token' +import { TokenSymbol } from '../types/TokenSymbol' +import { AaveData } from './aave-data-layer/query' +import { determineEModeState, extractEmodeInfoFromReserves } from './emode' +import { getIncentivesData, IncentivesData } from './incentives' +import { parseRawPercentage } from './math' +import { + BorrowEligibilityStatus, + CollateralEligibilityStatus, + getBorrowEligibilityStatus, + getCollateralEligibilityStatus, + getReserveStatus, + getSupplyAvailabilityStatus, + ReserveStatus, + SupplyAvailabilityStatus, +} from './reserve-status' +import { + determineIsolationModeState, + determineSiloBorrowingState, + normalizeUserSummary as normalizeUserPositionSummary, +} from './utils' + +export interface Reserve { + token: Token + + aToken: Token + variableDebtTokenAddress: CheckedAddress + + status: ReserveStatus + + supplyAvailabilityStatus: SupplyAvailabilityStatus + collateralEligibilityStatus: CollateralEligibilityStatus + borrowEligibilityStatus: BorrowEligibilityStatus + + isIsolated: boolean + isBorrowableInIsolation: boolean // in practice this is true only for stablecoins + isSiloedBorrowing: boolean + eModeCategory?: EModeCategory + + // @note: available liquidity respects borrow cap, so it can be negative when the cap is reached and breached (interests) + availableLiquidity: NormalizedUnitNumber + availableLiquidityUSD: NormalizedUnitNumber + supplyCap?: NormalizedUnitNumber + borrowCap?: NormalizedUnitNumber + totalLiquidity: NormalizedUnitNumber // = supplied + totalLiquidityUSD: NormalizedUnitNumber + totalDebt: NormalizedUnitNumber + totalDebtUSD: NormalizedUnitNumber + totalVariableDebt: NormalizedUnitNumber + totalVariableDebtUSD: NormalizedUnitNumber + isolationModeTotalDebt: NormalizedUnitNumber // @note: this is already divided by debtCeilingDecimals + debtCeiling: NormalizedUnitNumber // @note: this is already divided by debtCeilingDecimals + supplyAPY: Percentage + maxLtv: Percentage + liquidationThreshold: Percentage + liquidationBonus: Percentage + reserveFactor: Percentage + aTokenBalance: NormalizedUnitNumber + + lastUpdateTimestamp: number + + variableBorrowIndex: BigNumber + variableBorrowRate: BigNumber + liquidityIndex: BigNumber + liquidityRate: BigNumber + variableRateSlope1: BigNumber + variableRateSlope2: BigNumber + optimalUtilizationRate: Percentage + utilizationRate: Percentage + baseVariableBorrowRate: BigNumber + + variableBorrowApy: Percentage + + priceInUSD: BigNumber + + usageAsCollateralEnabled: boolean + usageAsCollateralEnabledOnUser: boolean + + incentives: IncentivesData +} + +export interface UserPosition { + reserve: Reserve + scaledVariableDebt: BigNumber + scaledATokenBalance: BigNumber + collateralBalance: NormalizedUnitNumber + borrowBalance: NormalizedUnitNumber +} + +export interface UserPositionSummary { + loanToValue: Percentage + maxLoanToValue: Percentage + healthFactor: BigNumber | undefined + availableBorrowsUSD: NormalizedUnitNumber + totalBorrowsUSD: NormalizedUnitNumber + currentLiquidationThreshold: Percentage + totalCollateralUSD: NormalizedUnitNumber + totalLiquidityUSD: NormalizedUnitNumber +} + +export type EModeState = { enabled: false } | { enabled: true; category: EModeCategory } +export type SiloBorrowingState = { enabled: false } | { enabled: true; siloedBorrowingReserve: Reserve } +export type IsolatedBorrowingState = { enabled: false } | { enabled: true; isolatedBorrowingReserve: Reserve } + +export interface UserConfiguration { + eModeState: EModeState + isolationModeState: IsolatedBorrowingState + siloBorrowingState: SiloBorrowingState +} + +export interface EModeCategory { + id: number + name: string + ltv: Percentage + liquidationThreshold: Percentage + liquidationBonus: Percentage +} +export type EModeCategories = Record + +export class MarketInfo { + private readonly nativePosition: UserPosition + + constructor( + public readonly reserves: Reserve[], + public readonly userPositions: UserPosition[], // exists for every reserve, even if empty + public readonly userPositionSummary: UserPositionSummary, + public readonly userConfiguration: UserConfiguration, + public readonly emodeCategories: EModeCategories, + public readonly timestamp: number, + public readonly chainId: number, + private readonly nativeAssetInfo: NativeAssetInfo, + ) { + const wrappedNativeAssetPosition = + userPositions.find((p) => p.reserve.token.symbol === nativeAssetInfo.wrappedNativeAssetSymbol) ?? + raise('Wrapped native reserve not found.') + const wrappedNativeToken = wrappedNativeAssetPosition.reserve.token + const nativeReserve = { + ...wrappedNativeAssetPosition.reserve, + token: wrappedNativeToken.clone({ + symbol: nativeAssetInfo.nativeAssetSymbol, + name: nativeAssetInfo.nativeAssetName, + address: NATIVE_ASSET_MOCK_ADDRESS, + }), + } + this.nativePosition = { + ...wrappedNativeAssetPosition, + reserve: nativeReserve, + } + } + + findTokenBySymbol(symbol: TokenSymbol): Token | undefined { + return this.findReserveByATokenSymbol(symbol)?.aToken ?? this.findReserveBySymbol(symbol)?.token + } + findOneTokenBySymbol(symbol: TokenSymbol): Token { + return this.findTokenBySymbol(symbol) ?? raise(`Token ${symbol} not found`) + } + + findReserveBySymbol(symbol: TokenSymbol): Reserve | undefined { + if (symbol === this.nativeAssetInfo.nativeAssetSymbol) { + return this.nativePosition.reserve + } + + return this.findReserveByATokenSymbol(symbol) ?? this.reserves.find((r) => r.token.symbol === symbol) + } + findReserveByToken(token: Token): Reserve | undefined { + return this.findReserveBySymbol(token.symbol) + } + findReserveByUnderlyingAsset(underlyingAsset: CheckedAddress): Reserve | undefined { + if (underlyingAsset === NATIVE_ASSET_MOCK_ADDRESS) { + return this.nativePosition.reserve + } + + return this.reserves.find((r) => r.token.address === underlyingAsset) + } + findReserveByATokenSymbol(symbol: TokenSymbol): Reserve | undefined { + return this.reserves.find((r) => r.aToken.symbol === symbol) + } + findOneReserveBySymbol(symbol: TokenSymbol): Reserve { + return this.findReserveBySymbol(symbol) ?? raise(`Reserve ${symbol} not found`) + } + findOneReserveByToken(token: Token): Reserve { + return this.findOneReserveBySymbol(token.symbol) + } + findOneReserveByUnderlyingAsset(underlyingAsset: CheckedAddress): Reserve { + return this.findReserveByUnderlyingAsset(underlyingAsset) ?? raise(`Reserve ${underlyingAsset} not found`) + } + + findPositionBySymbol(symbol: TokenSymbol): UserPosition | undefined { + if (symbol === this.nativeAssetInfo.nativeAssetSymbol) { + return this.nativePosition + } + + return this.findPositionByATokenSymbol(symbol) ?? this.userPositions.find((p) => p.reserve.token.symbol === symbol) + } + findPositionByToken(token: Token): UserPosition | undefined { + return this.findPositionBySymbol(token.symbol) + } + findPositionByATokenSymbol(symbol: TokenSymbol): UserPosition | undefined { + return this.userPositions.find((p) => p.reserve.aToken.symbol === symbol) + } + findOnePositionBySymbol(symbol: TokenSymbol): UserPosition { + return this.findPositionBySymbol(symbol) ?? raise(`Position ${symbol} not found`) + } + findOnePositionByToken(token: Token): UserPosition { + return this.findOnePositionBySymbol(token.symbol) + } +} + +export function marketInfo(rawAaveData: AaveData, nativeAssetInfo: NativeAssetInfo, chainId: number): MarketInfo { + const tokens = rawAaveData.userSummary.userReservesData.map( + (r): Token => + new Token({ + address: CheckedAddress(r.reserve.underlyingAsset), + symbol: TokenSymbol(r.reserve.symbol), + name: r.reserve.name, + decimals: r.reserve.decimals, + unitPriceUsd: r.reserve.priceInUSD, + }), + ) + + /* eslint-disable func-style */ + const findOneTokenBySymbol = (symbol: TokenSymbol): Token => { + return tokens.find((t) => t.symbol === symbol) ?? raise(`Token ${symbol} not found`) + } + /* eslint-enable func-style */ + + const eModeCategories = extractEmodeInfoFromReserves(rawAaveData.formattedReserves) + + const reserves = rawAaveData.userSummary.userReservesData.map((r): Reserve => { + const token = findOneTokenBySymbol(TokenSymbol(r.reserve.symbol)) + + return { + token, + + aToken: token.createAToken(CheckedAddress(r.reserve.aTokenAddress)), + variableDebtTokenAddress: CheckedAddress(r.reserve.variableDebtTokenAddress), + + status: getReserveStatus(r.reserve), + + supplyAvailabilityStatus: getSupplyAvailabilityStatus(r.reserve), + collateralEligibilityStatus: getCollateralEligibilityStatus(r.reserve), + borrowEligibilityStatus: getBorrowEligibilityStatus(r.reserve), + + isIsolated: r.reserve.isIsolated, + eModeCategory: + r.reserve.eModeCategoryId !== 0 + ? eModeCategories[r.reserve.eModeCategoryId] ?? raise(`EMode category ${r.reserve.eModeCategoryId} not found`) + : undefined, + isSiloedBorrowing: r.reserve.isSiloedBorrowing, + isBorrowableInIsolation: r.reserve.borrowableInIsolation, + + availableLiquidity: NormalizedUnitNumber(r.reserve.formattedAvailableLiquidity), // @note: r.reserve.availableLiquidity doesn't respect borrow caps so we use formattedAvailableLiquidity which does + availableLiquidityUSD: NormalizedUnitNumber(r.reserve.availableLiquidityUSD), + supplyCap: r.reserve.supplyCap !== '0' ? NormalizedUnitNumber(r.reserve.supplyCap) : undefined, + borrowCap: r.reserve.borrowCap !== '0' ? NormalizedUnitNumber(r.reserve.borrowCap) : undefined, + totalLiquidity: NormalizedUnitNumber(r.reserve.totalLiquidity), + totalLiquidityUSD: NormalizedUnitNumber(r.reserve.totalLiquidityUSD), + totalDebt: NormalizedUnitNumber(r.reserve.totalDebt), + totalDebtUSD: NormalizedUnitNumber(r.reserve.totalDebtUSD), + totalVariableDebt: NormalizedUnitNumber(r.reserve.totalVariableDebt), + totalVariableDebtUSD: NormalizedUnitNumber(r.reserve.totalVariableDebtUSD), + isolationModeTotalDebt: NormalizedUnitNumber(r.reserve.isolationModeTotalDebtUSD), + debtCeiling: NormalizedUnitNumber(r.reserve.debtCeilingUSD), + supplyAPY: Percentage(r.reserve.supplyAPY), + maxLtv: parseRawPercentage(r.reserve.baseLTVasCollateral), + liquidationThreshold: parseRawPercentage(r.reserve.reserveLiquidationThreshold), + liquidationBonus: bigNumberify(r.reserve.formattedReserveLiquidationBonus).gt(0) + ? Percentage(r.reserve.formattedReserveLiquidationBonus) + : Percentage(0), + variableBorrowApy: Percentage(r.reserve.variableBorrowAPY), + reserveFactor: Percentage(r.reserve.reserveFactor), + aTokenBalance: NormalizedUnitNumber(r.underlyingBalance), + + lastUpdateTimestamp: r.reserve.lastUpdateTimestamp, + + variableBorrowIndex: bigNumberify(r.reserve.variableBorrowIndex), + variableBorrowRate: bigNumberify(r.reserve.variableBorrowRate), + liquidityIndex: bigNumberify(r.reserve.liquidityIndex), + liquidityRate: bigNumberify(r.reserve.liquidityRate), + variableRateSlope1: bigNumberify(r.reserve.variableRateSlope1), + variableRateSlope2: bigNumberify(r.reserve.variableRateSlope2), + optimalUtilizationRate: Percentage(fromRay(r.reserve.optimalUsageRatio)), + utilizationRate: Percentage(r.reserve.borrowUsageRatio), + baseVariableBorrowRate: NormalizedUnitNumber(r.reserve.baseVariableBorrowRate), + + priceInUSD: bigNumberify(r.reserve.priceInUSD), + + usageAsCollateralEnabled: r.reserve.usageAsCollateralEnabled, + usageAsCollateralEnabledOnUser: r.usageAsCollateralEnabledOnUser, + + // incentives are fetched from the blockchain + incentives: getIncentivesData(r.reserve, findOneTokenBySymbol), + } + }) + + const userPositions = rawAaveData.rawUserReserves.map((r): UserPosition => { + const reserve = reserves.find((res) => res.token.address === r.underlyingAsset)! + const formattedReserve = rawAaveData.userSummary.userReservesData.find( + (res) => res.underlyingAsset === r.underlyingAsset, + )! + + return { + reserve, + scaledATokenBalance: bigNumberify(r.scaledATokenBalance), + scaledVariableDebt: bigNumberify(r.scaledVariableDebt), + collateralBalance: NormalizedUnitNumber(formattedReserve.underlyingBalance), + borrowBalance: NormalizedUnitNumber(formattedReserve.variableBorrows), + } + }) + + const userPositionSummary = normalizeUserPositionSummary(rawAaveData.userSummary) + + const userConfiguration: UserConfiguration = { + eModeState: determineEModeState(rawAaveData.userEmodeCategoryId, eModeCategories), + isolationModeState: determineIsolationModeState(rawAaveData.userSummary, reserves), + siloBorrowingState: determineSiloBorrowingState(userPositions), + } + + return new MarketInfo( + reserves, + userPositions, + userPositionSummary, + userConfiguration, + eModeCategories, + rawAaveData.timestamp, + chainId, + nativeAssetInfo, + ) +} diff --git a/packages/app/src/domain/market-info/math.test.ts b/packages/app/src/domain/market-info/math.test.ts new file mode 100644 index 000000000..387872476 --- /dev/null +++ b/packages/app/src/domain/market-info/math.test.ts @@ -0,0 +1,15 @@ +import { bigNumberify } from '@/utils/bigNumber' + +import { Percentage } from '../types/NumericValues' +import { healthFactorToLtv } from './math' + +describe(healthFactorToLtv.name, () => { + it('calculates ltv', () => { + const healthFactor = bigNumberify(10) + const liquidationThreshold = Percentage(0.8) + + const result = healthFactorToLtv(healthFactor, liquidationThreshold) + + expect(result).toEqual(Percentage(0.08)) + }) +}) diff --git a/packages/app/src/domain/market-info/math.ts b/packages/app/src/domain/market-info/math.ts new file mode 100644 index 000000000..2f22e7d45 --- /dev/null +++ b/packages/app/src/domain/market-info/math.ts @@ -0,0 +1,78 @@ +import { calculateCompoundedInterest, calculateLinearInterest, rayDiv, rayMul } from '@aave/math-utils' +import BigNumber from 'bignumber.js' + +import { bigNumberify, NumberLike } from '@/utils/bigNumber' + +import { BaseUnitNumber, Percentage } from '../types/NumericValues' + +interface GetScaledBalanceArgs { + rate: BigNumber + index: BigNumber + timestamp: number + lastUpdateTimestamp: number + balance: BaseUnitNumber +} + +export function getScaledBalance({ + balance, + index, + rate, + lastUpdateTimestamp, + timestamp, +}: GetScaledBalanceArgs): BaseUnitNumber { + const updatedIndex = rayMul( + calculateLinearInterest({ + currentTimestamp: timestamp, + lastUpdateTimestamp, + rate, + }), + index, + ) + return BaseUnitNumber(rayDiv(balance, updatedIndex)) +} +export function getCompoundedScaledBalance({ + balance, + index, + rate, + lastUpdateTimestamp, + timestamp, +}: GetScaledBalanceArgs): BaseUnitNumber { + const updatedIndex = rayMul( + calculateCompoundedInterest({ + currentTimestamp: timestamp, + lastUpdateTimestamp, + rate, + }), + index, + ) + return BaseUnitNumber(rayDiv(balance, updatedIndex)) +} + +export function getCompoundedBalance({ + balance, + index, + rate, + lastUpdateTimestamp, + timestamp, +}: GetScaledBalanceArgs): BaseUnitNumber { + const updatedIndex = rayMul( + calculateCompoundedInterest({ + currentTimestamp: timestamp, + lastUpdateTimestamp, + rate, + }), + index, + ) + return BaseUnitNumber(rayMul(balance, updatedIndex)) +} + +export function healthFactorToLtv(healthFactor: BigNumber, liquidationThreshold: Percentage): Percentage { + return Percentage(bigNumberify(1).div(healthFactor).times(liquidationThreshold)) +} + +export function parseRawPercentage( + value: NumberLike, + { allowMoreThan1 }: { allowMoreThan1?: boolean } = { allowMoreThan1: true }, +): Percentage { + return Percentage(bigNumberify(value).div(10_000), allowMoreThan1) +} diff --git a/packages/app/src/domain/market-info/reserve-status.test.ts b/packages/app/src/domain/market-info/reserve-status.test.ts new file mode 100644 index 000000000..a05341259 --- /dev/null +++ b/packages/app/src/domain/market-info/reserve-status.test.ts @@ -0,0 +1,116 @@ +import { getMockAaveUserReserve } from '@/test/integration/constants' + +import { + getBorrowEligibilityStatus, + getCollateralEligibilityStatus, + getReserveStatus, + getSupplyAvailabilityStatus, +} from './reserve-status' + +describe(getReserveStatus.name, () => { + it('returns paused when reserve is paused', () => { + const reserve = getMockAaveUserReserve({ isPaused: true }) + expect(getReserveStatus(reserve)).toBe('paused') + }) + + it('returns frozen when reserve is frozen', () => { + const reserve = getMockAaveUserReserve({ isFrozen: true }) + expect(getReserveStatus(reserve)).toBe('frozen') + }) + + it('returns active when reserve is active', () => { + const reserve = getMockAaveUserReserve({ isActive: true }) + expect(getReserveStatus(reserve)).toBe('active') + }) + + it('returns not-active when reserve is neither paused, frozen, nor active', () => { + const reserve = getMockAaveUserReserve({ isPaused: false, isFrozen: false, isActive: false }) + expect(getReserveStatus(reserve)).toBe('not-active') + }) +}) + +describe(getSupplyAvailabilityStatus.name, () => { + it('returns no when reserve is not active', () => { + const reserve = getMockAaveUserReserve({ isActive: false, supplyCap: '100', totalLiquidity: '50' }) + expect(getSupplyAvailabilityStatus(reserve)).toBe('no') + }) + + it('returns no when reserve is frozen', () => { + const reserve = getMockAaveUserReserve({ isActive: true, isFrozen: true }) + expect(getSupplyAvailabilityStatus(reserve)).toBe('no') + }) + + it('returns supply-cap-reached when supply cap is reached', () => { + const reserve = getMockAaveUserReserve({ isActive: true, supplyCap: '100', totalLiquidity: '100' }) + expect(getSupplyAvailabilityStatus(reserve)).toBe('supply-cap-reached') + }) + + it('returns yes when supply is available', () => { + const reserve = getMockAaveUserReserve({ isActive: true, supplyCap: '200', totalLiquidity: '100' }) + expect(getSupplyAvailabilityStatus(reserve)).toBe('yes') + }) +}) + +describe(getCollateralEligibilityStatus.name, () => { + it('returns no when reserve is not active', () => { + const reserve = getMockAaveUserReserve({ isActive: false }) + expect(getCollateralEligibilityStatus(reserve)).toBe('no') + }) + + it('returns no when baseLTVasCollateral is 0', () => { + const reserve = getMockAaveUserReserve({ baseLTVasCollateral: '0' }) + expect(getCollateralEligibilityStatus(reserve)).toBe('no') + }) + + it('returns no when reserve is frozen', () => { + const reserve = getMockAaveUserReserve({ isActive: true, isFrozen: true }) + expect(getCollateralEligibilityStatus(reserve)).toBe('no') + }) + + it('returns only-in-isolation-mode when reserve is in isolation mode', () => { + const reserve = getMockAaveUserReserve({ isIsolated: true }) + expect(getCollateralEligibilityStatus(reserve)).toBe('only-in-isolation-mode') + }) + + it('returns yes when collateral is eligible', () => { + const reserve = getMockAaveUserReserve() + expect(getCollateralEligibilityStatus(reserve)).toBe('yes') + }) +}) + +describe(getBorrowEligibilityStatus.name, () => { + it('returns no when reserve is not active', () => { + const reserve = getMockAaveUserReserve({ isActive: false }) + expect(getBorrowEligibilityStatus(reserve)).toBe('no') + }) + + it('returns no when reserve is frozen', () => { + const reserve = getMockAaveUserReserve({ isActive: true, isFrozen: true }) + expect(getBorrowEligibilityStatus(reserve)).toBe('no') + }) + + it('returns no when borrowing is not enabled on reserve', () => { + const reserve = getMockAaveUserReserve({ borrowingEnabled: false }) + expect(getBorrowEligibilityStatus(reserve)).toBe('no') + }) + + it('returns borrow-cap-reached when borrow cap is reached', () => { + const reserve = getMockAaveUserReserve({ borrowCap: '100', totalDebt: '100' }) + expect(getBorrowEligibilityStatus(reserve)).toBe('borrow-cap-reached') + }) + + it('returns only-in-siloed-mode when reserve is in siloed borrowing mode', () => { + const reserve = getMockAaveUserReserve({ isSiloedBorrowing: true }) + expect(getBorrowEligibilityStatus(reserve)).toBe('only-in-siloed-mode') + }) + + it('returns yes when borrowing is eligible', () => { + const reserve = getMockAaveUserReserve() + expect(getBorrowEligibilityStatus(reserve)).toBe('yes') + }) + + it('returns yes when no borrow cap', () => { + const reserve = getMockAaveUserReserve({ borrowCap: '0', totalDebt: '100' }) + expect(getBorrowEligibilityStatus(reserve)).toBe('yes') + }) +}) diff --git a/packages/app/src/domain/market-info/reserve-status.ts b/packages/app/src/domain/market-info/reserve-status.ts new file mode 100644 index 000000000..7ba94c2dc --- /dev/null +++ b/packages/app/src/domain/market-info/reserve-status.ts @@ -0,0 +1,67 @@ +import { bigNumberify } from '@/utils/bigNumber' + +import { AaveFormattedReserve } from './aave-data-layer/query' + +export type ReserveStatus = 'active' | 'frozen' | 'paused' | 'not-active' +export function getReserveStatus(reserve: AaveFormattedReserve): ReserveStatus { + if (!reserve.isActive) { + return 'not-active' + } + + if (reserve.isPaused) { + return 'paused' + } + + if (reserve.isFrozen) { + return 'frozen' + } + + return 'active' +} + +export type SupplyAvailabilityStatus = 'yes' | 'supply-cap-reached' | 'no' +export function getSupplyAvailabilityStatus(reserve: AaveFormattedReserve): SupplyAvailabilityStatus { + if (!reserve.isActive || reserve.isFrozen) { + return 'no' + } + + const supplyCap = bigNumberify(reserve.supplyCap) + if (!supplyCap.eq(0) && supplyCap.lte(bigNumberify(reserve.totalLiquidity))) { + return 'supply-cap-reached' + } + + return 'yes' +} + +export type CollateralEligibilityStatus = 'yes' | 'only-in-isolation-mode' | 'no' +export function getCollateralEligibilityStatus(reserve: AaveFormattedReserve): CollateralEligibilityStatus { + if (!reserve.isActive || reserve.isFrozen || bigNumberify(reserve.baseLTVasCollateral).eq(0)) { + return 'no' + } + + if (reserve.isIsolated) { + return 'only-in-isolation-mode' + } + + return 'yes' +} + +export type BorrowEligibilityStatus = 'yes' | 'only-in-siloed-mode' | 'borrow-cap-reached' | 'no' +export function getBorrowEligibilityStatus(reserve: AaveFormattedReserve): BorrowEligibilityStatus { + if (!reserve.isActive || reserve.isFrozen || !reserve.borrowingEnabled) { + return 'no' + } + + const borrowCap = bigNumberify(reserve.borrowCap) + if (!borrowCap.eq(0) && borrowCap.lte(bigNumberify(reserve.totalDebt))) { + return 'borrow-cap-reached' + } + + if (reserve.isSiloedBorrowing) { + return 'only-in-siloed-mode' + } + + return 'yes' +} + +export type MarketAssetStatus = SupplyAvailabilityStatus | CollateralEligibilityStatus | BorrowEligibilityStatus diff --git a/packages/app/src/domain/market-info/updatePositionSummary.ts b/packages/app/src/domain/market-info/updatePositionSummary.ts new file mode 100644 index 000000000..1752cd2e2 --- /dev/null +++ b/packages/app/src/domain/market-info/updatePositionSummary.ts @@ -0,0 +1,224 @@ +import BigNumber from 'bignumber.js' + +import { NativeAssetInfo } from '@/config/chain/types' +import { AaveData, RawAaveUserReserve } from '@/domain/market-info/aave-data-layer/query' +import { MarketInfo, Reserve, UserPosition, UserPositionSummary } from '@/domain/market-info/marketInfo' +import { getCompoundedScaledBalance, getScaledBalance } from '@/domain/market-info/math' +import { mergeUserPositionIntoRawUserReserve, recalculateUserSummary } from '@/domain/market-info/utils' +import { bigNumberify } from '@/utils/bigNumber' + +import { ReserveWithValue } from '../common/types' +import { CheckedAddress } from '../types/CheckedAddress' +import { NormalizedUnitNumber } from '../types/NumericValues' + +export interface ReserveWithUseAsCollateralFlag { + reserve: Reserve + useAsCollateral: boolean +} + +export interface UpdatePositionSummaryParams { + borrows?: ReserveWithValue[] + deposits?: ReserveWithValue[] + withdrawals?: ReserveWithValue[] + repays?: ReserveWithValue[] + reservesWithUseAsCollateralFlag?: ReserveWithUseAsCollateralFlag[] + aaveData: AaveData + marketInfo: MarketInfo + eModeCategoryId?: number + nativeAssetInfo: NativeAssetInfo +} + +export function updatePositionSummary({ + borrows = [], + deposits = [], + withdrawals = [], + repays = [], + reservesWithUseAsCollateralFlag = [], + marketInfo, + aaveData, + eModeCategoryId, + nativeAssetInfo, +}: UpdatePositionSummaryParams): UserPositionSummary { + const timestamp = marketInfo.timestamp + const newUserPositions = marketInfo.userPositions + .map(getUserPositionMapper(timestamp, deposits, nativeAssetInfo, 'deposit')) + .map(getUserPositionMapper(timestamp, withdrawals, nativeAssetInfo, 'withdraw')) + .map(getUserPositionMapper(timestamp, borrows, nativeAssetInfo, 'borrow')) + .map(getUserPositionMapper(timestamp, repays, nativeAssetInfo, 'repay')) + + const newRawUserReserves = mergeUserPositionIntoRawUserReserve(newUserPositions, aaveData.rawUserReserves).map( + (r, index) => + tweakUseAsCollateral({ + rawUserReserve: aaveData.rawUserReserves[index]!, + updatedRawUserReserve: r, + reservesWithUseAsCollateralFlag, + deposits, + withdrawals, + nativeAssetInfo, + }), + ) + + const currentEModeCategoryId = marketInfo.userConfiguration.eModeState.enabled + ? marketInfo.userConfiguration.eModeState.category.id + : 0 + + const userSummary = recalculateUserSummary({ + currentTimestamp: timestamp, + formattedReserves: aaveData.formattedReserves, + rawUserReserves: newRawUserReserves, + baseCurrency: aaveData.baseCurrency, + eModeCategoryId: eModeCategoryId ?? currentEModeCategoryId, + }) + + return userSummary +} + +function getUserPositionMapper( + timestamp: number, + reserves: ReserveWithValue[], + nativeAssetInfo: NativeAssetInfo, + type: 'deposit' | 'withdraw' | 'borrow' | 'repay', +) { + return function (userPosition: UserPosition): UserPosition { + const value = getValueForUserPosition(userPosition, reserves, nativeAssetInfo) + if (!value) { + return userPosition + } + + if (type === 'deposit' || type === 'withdraw') { + return tweakDepositPosition(timestamp, type, value, userPosition) + } + + return tweakBorrowPosition(timestamp, type, value, userPosition) + } +} + +function getValueForUserPosition( + userPosition: UserPosition, + reserves: ReserveWithValue[], + nativeAssetInfo: NativeAssetInfo, +): NormalizedUnitNumber | undefined { + if (userPosition.reserve.token.symbol === nativeAssetInfo.wrappedNativeAssetSymbol) { + const nativeAssetValue = reserves.find((d) => d.reserve.token.symbol === nativeAssetInfo.nativeAssetSymbol)?.value + if (nativeAssetValue) { + return nativeAssetValue + } + } + + return reserves.find((r) => r.reserve.token.symbol === userPosition.reserve.token.symbol)?.value +} + +function findReserveWithValue( + rawUserReserve: RawAaveUserReserve, + reserves: T[], + nativeAssetInfo: NativeAssetInfo, +): T | undefined { + return reserves.find((r) => { + if (r.reserve.token.symbol === nativeAssetInfo.nativeAssetSymbol) { + return nativeAssetInfo.wrappedNativeAssetAddress === CheckedAddress(rawUserReserve.underlyingAsset) + } + + return r.reserve.token.address === CheckedAddress(rawUserReserve.underlyingAsset) + }) +} + +function tweakDepositPosition( + timestamp: number, + type: 'deposit' | 'withdraw', + value: NormalizedUnitNumber, + userPosition: UserPosition, +): UserPosition { + if (value.eq(0)) { + return userPosition + } + const reserve = userPosition.reserve + + const updatedBalance = getScaledBalance({ + balance: reserve.token.toBaseUnit(value), + index: reserve.liquidityIndex, + rate: reserve.liquidityRate, + lastUpdateTimestamp: reserve.lastUpdateTimestamp, + timestamp, + }).multipliedBy(type === 'withdraw' ? -1 : 1) + + return { + ...userPosition, + scaledATokenBalance: BigNumber.max(userPosition.scaledATokenBalance.plus(updatedBalance), 0), + } +} + +function tweakBorrowPosition( + timestamp: number, + type: 'borrow' | 'repay', + value: NormalizedUnitNumber, + userPosition: UserPosition, +): UserPosition { + if (value.eq(0)) { + return userPosition + } + const reserve = userPosition.reserve + + const updatedBalance = getCompoundedScaledBalance({ + balance: reserve.token.toBaseUnit(value), + index: reserve.variableBorrowIndex, + rate: reserve.variableBorrowRate, + lastUpdateTimestamp: reserve.lastUpdateTimestamp, + timestamp, + }).multipliedBy(type === 'repay' ? -1 : 1) + + return { + ...userPosition, + scaledVariableDebt: BigNumber.max(userPosition.scaledVariableDebt.plus(updatedBalance), 0), + } +} + +export interface TweakUseAsCollateralParams { + rawUserReserve: RawAaveUserReserve + updatedRawUserReserve: RawAaveUserReserve + reservesWithUseAsCollateralFlag: ReserveWithUseAsCollateralFlag[] + deposits: ReserveWithValue[] + withdrawals: ReserveWithValue[] + nativeAssetInfo: NativeAssetInfo +} +function tweakUseAsCollateral({ + rawUserReserve, + updatedRawUserReserve, + reservesWithUseAsCollateralFlag, + deposits, + withdrawals, + nativeAssetInfo, +}: TweakUseAsCollateralParams): RawAaveUserReserve { + const reserve = findReserveWithValue(rawUserReserve, reservesWithUseAsCollateralFlag, nativeAssetInfo) + if (reserve) { + return { + ...updatedRawUserReserve, + usageAsCollateralEnabledOnUser: reserve.useAsCollateral, + } + } + + const withdrawnReserve = findReserveWithValue(rawUserReserve, withdrawals, nativeAssetInfo) + if ( + withdrawnReserve && + bigNumberify(updatedRawUserReserve.scaledATokenBalance).eq(0) && + bigNumberify(rawUserReserve.scaledATokenBalance).gt(0) + ) { + return { + ...updatedRawUserReserve, + usageAsCollateralEnabledOnUser: false, + } + } + + const depositedReserve = findReserveWithValue(rawUserReserve, deposits, nativeAssetInfo) + if ( + depositedReserve && + bigNumberify(updatedRawUserReserve.scaledATokenBalance).gt(0) && + bigNumberify(rawUserReserve.scaledATokenBalance).eq(0) + ) { + return { + ...updatedRawUserReserve, + usageAsCollateralEnabledOnUser: true, + } + } + + return updatedRawUserReserve +} diff --git a/packages/app/src/domain/market-info/useMarketInfo.ts b/packages/app/src/domain/market-info/useMarketInfo.ts new file mode 100644 index 000000000..cf358577b --- /dev/null +++ b/packages/app/src/domain/market-info/useMarketInfo.ts @@ -0,0 +1,37 @@ +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { SuspenseQueryWith } from '@/utils/types' + +import { aaveDataLayer } from './aave-data-layer/query' +import { MarketInfo, marketInfo } from './marketInfo' + +export interface UseMarketInfoParams { + chainId?: number +} +export type UseMarketInfoResultOnSuccess = SuspenseQueryWith<{ + marketInfo: MarketInfo +}> + +export function useMarketInfo(params: UseMarketInfoParams = {}): UseMarketInfoResultOnSuccess { + const { address } = useAccount() + const currentChainId = useChainId() + const chainId = params.chainId ?? currentChainId + const wagmiConfig = useConfig() + const nativeAssetInfo = getNativeAssetInfo(chainId) + + const res = useSuspenseQuery({ + ...aaveDataLayer({ + wagmiConfig, + chainId, + account: address, + }), + select: (aaveData) => marketInfo(aaveData, nativeAssetInfo, chainId), + }) + + return { + ...res, + marketInfo: res.data, + } +} diff --git a/packages/app/src/domain/market-info/utils.test.ts b/packages/app/src/domain/market-info/utils.test.ts new file mode 100644 index 000000000..e87426c9a --- /dev/null +++ b/packages/app/src/domain/market-info/utils.test.ts @@ -0,0 +1,42 @@ +import { getMockReserve, getMockUserPosition, testAddresses } from '@/test/integration/constants' + +import { NormalizedUnitNumber } from '../types/NumericValues' +import { Token } from '../types/Token' +import { TokenSymbol } from '../types/TokenSymbol' +import { determineSiloBorrowingState } from './utils' + +describe(determineSiloBorrowingState.name, () => { + const siloedReserve = getMockReserve({ + token: new Token({ + symbol: TokenSymbol('USDC'), + address: testAddresses.token2, + decimals: 6, + name: 'USDC', + unitPriceUsd: '1', + }), + isSiloedBorrowing: true, + }) + + it('is not siloed when no borrows from siloed reserve', () => { + const userPositions = [ + getMockUserPosition({ reserve: siloedReserve }), + getMockUserPosition({ borrowBalance: NormalizedUnitNumber('10') }), + ] + + expect(determineSiloBorrowingState(userPositions)).toEqual({ + enabled: false, + }) + }) + + it('is siloed when borrows from siloed reserve', () => { + const userPositions = [ + getMockUserPosition({ reserve: siloedReserve, borrowBalance: NormalizedUnitNumber('100') }), + getMockUserPosition({ borrowBalance: NormalizedUnitNumber('0') }), + ] + + expect(determineSiloBorrowingState(userPositions)).toEqual({ + enabled: true, + siloedBorrowingReserve: siloedReserve, + }) + }) +}) diff --git a/packages/app/src/domain/market-info/utils.ts b/packages/app/src/domain/market-info/utils.ts new file mode 100644 index 000000000..31686ffcc --- /dev/null +++ b/packages/app/src/domain/market-info/utils.ts @@ -0,0 +1,111 @@ +import { formatUserSummary } from '@aave/math-utils' +import invariant from 'tiny-invariant' + +import { bigNumberify } from '../../utils/bigNumber' +import { CheckedAddress } from '../types/CheckedAddress' +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { AaveBaseCurrency, AaveFormattedReserve, AaveUserSummary, RawAaveUserReserve } from './aave-data-layer/query' +import type { + IsolatedBorrowingState, + Reserve, + SiloBorrowingState, + UserPosition, + UserPositionSummary, +} from './marketInfo' + +interface RecalculateUserSummaryArgs { + currentTimestamp: number + formattedReserves: AaveFormattedReserve[] + rawUserReserves: RawAaveUserReserve[] + baseCurrency: AaveBaseCurrency + eModeCategoryId: number +} + +export function recalculateUserSummary({ + currentTimestamp, + formattedReserves, + rawUserReserves, + eModeCategoryId, + baseCurrency, +}: RecalculateUserSummaryArgs): UserPositionSummary { + const aaveFormattedUserSummary = formatUserSummary({ + currentTimestamp, + marketReferencePriceInUsd: baseCurrency.marketReferenceCurrencyPriceInUsd, + marketReferenceCurrencyDecimals: baseCurrency.marketReferenceCurrencyDecimals, + userReserves: rawUserReserves, + formattedReserves, + userEmodeCategoryId: eModeCategoryId, + }) + + return normalizeUserSummary(aaveFormattedUserSummary) +} + +export function normalizeUserSummary(formattedUserSummary: AaveUserSummary): UserPositionSummary { + const loanToValue = + formattedUserSummary.totalCollateralMarketReferenceCurrency === '0' + ? bigNumberify(0) + : bigNumberify(formattedUserSummary.totalBorrowsMarketReferenceCurrency).dividedBy( + formattedUserSummary.totalCollateralMarketReferenceCurrency, + ) + const rawHealthFactor = bigNumberify(formattedUserSummary.healthFactor) + const healthFactor = rawHealthFactor.eq(-1) ? undefined : rawHealthFactor + + return { + loanToValue: Percentage(loanToValue, true), + healthFactor, + maxLoanToValue: Percentage(formattedUserSummary.currentLoanToValue), + availableBorrowsUSD: NormalizedUnitNumber(formattedUserSummary.availableBorrowsUSD), + totalBorrowsUSD: NormalizedUnitNumber(formattedUserSummary.totalBorrowsUSD), + currentLiquidationThreshold: Percentage(formattedUserSummary.currentLiquidationThreshold, true), + totalCollateralUSD: NormalizedUnitNumber(formattedUserSummary.totalCollateralUSD), + totalLiquidityUSD: NormalizedUnitNumber(formattedUserSummary.totalLiquidityUSD), + } +} + +export function mergeUserPositionIntoRawUserReserve( + userPositions: UserPosition[], + rawUserReserves: RawAaveUserReserve[], +): RawAaveUserReserve[] { + return rawUserReserves.map((r) => { + const userPosition = userPositions.find((up) => up.reserve.token.address === r.underlyingAsset) + if (!userPosition) { + return r + } + + return { + ...r, + scaledATokenBalance: userPosition.scaledATokenBalance.toString(), + scaledVariableDebt: userPosition.scaledVariableDebt.toString(), + } + }) +} + +export function determineSiloBorrowingState(userPositions: UserPosition[]): SiloBorrowingState { + const siloedUserReserves = userPositions.filter((pos) => pos.borrowBalance.gt(0) && pos.reserve.isSiloedBorrowing) + + invariant(siloedUserReserves.length <= 1, 'There should be at most one siloed reserve per user') + + if (siloedUserReserves.length === 0) { + return { enabled: false } + } + return { + enabled: true, + siloedBorrowingReserve: siloedUserReserves[0]!.reserve, + } +} + +export function determineIsolationModeState(userSummary: AaveUserSummary, reserves: Reserve[]): IsolatedBorrowingState { + if (!userSummary.isInIsolationMode) { + return { enabled: false } + } + invariant(userSummary.isolatedReserve, 'Isolated borrowing reserve should be defined') + const isolatedBorrowingReserve = reserves.find( + (r) => r.token.address === CheckedAddress(userSummary.isolatedReserve?.underlyingAsset!), + ) + invariant(isolatedBorrowingReserve, 'Isolated borrowing reserve should be found in reserves') + + return { + enabled: true, + isolatedBorrowingReserve, + } +} diff --git a/packages/app/src/domain/market-operations/allowance/query.ts b/packages/app/src/domain/market-operations/allowance/query.ts new file mode 100644 index 000000000..a9b554f0d --- /dev/null +++ b/packages/app/src/domain/market-operations/allowance/query.ts @@ -0,0 +1,43 @@ +import { queryOptions } from '@tanstack/react-query' +import { Address } from 'viem' +import { Config } from 'wagmi' +import { readContract } from 'wagmi/actions' + +import { MAX_INT, NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' + +import { normalizeErc20AbiForToken } from '../normalizeErc20Abi' + +export interface AllowanceOptions { + wagmiConfig: Config + token: Address + spender: Address + account: Address + chainId: number +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function allowance({ wagmiConfig, token, spender, account, chainId }: AllowanceOptions) { + return queryOptions({ + queryKey: [ + { + entity: 'readContract', + functionName: 'allowance', + address: token, + args: [account, spender], + chainId, + }, + ], + queryFn: () => { + if (token === NATIVE_ASSET_MOCK_ADDRESS) { + return MAX_INT + } + + return readContract(wagmiConfig, { + functionName: 'allowance', + address: token, + abi: normalizeErc20AbiForToken(chainId, token), + args: [account, spender], + }) + }, + }) +} diff --git a/packages/app/src/domain/market-operations/allowance/useAllowance.test.tsx b/packages/app/src/domain/market-operations/allowance/useAllowance.test.tsx new file mode 100644 index 000000000..f685625df --- /dev/null +++ b/packages/app/src/domain/market-operations/allowance/useAllowance.test.tsx @@ -0,0 +1,69 @@ +import { waitFor } from '@testing-library/react' +import { erc20Abi } from 'viem' + +import { MAX_INT, NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { useAllowance } from './useAllowance' + +const mockedAllowance = 2137n +const token = testAddresses.token +const account = testAddresses.alice +const spender = testAddresses.bob + +const hookRenderer = setupHookRenderer({ + hook: useAllowance, + account, + handlers: [handlers.balanceCall({ balance: 0n, address: account })], + args: { token, spender }, +}) + +describe(useAllowance.name, () => { + it('returns max allowance for native asset', async () => { + const { result } = hookRenderer({ + args: { token: NATIVE_ASSET_MOCK_ADDRESS, spender }, + }) + + await waitFor(() => expect(result.current.data).toBeDefined()) + expect(result.current.data).toBe(MAX_INT) + }) + + it('returns proper allowance from chain', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: token, + abi: erc20Abi, + functionName: 'allowance', + args: [account, spender], + result: mockedAllowance, + }), + ], + }) + + await waitFor(() => expect(result.current.data).toBeDefined()) + expect(result.current.data).toBe(mockedAllowance) + }) + + it('propagates errors', async () => { + const expectedError = 'Not allowed!' + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCallError({ + to: token, + abi: erc20Abi, + functionName: 'allowance', + args: [account, spender], + errorMessage: expectedError, + }), + ], + }) + + await waitFor(() => expect(result.current.error).not.toBe(null)) + expect((result.current.error as any).shortMessage).toBe( + `The contract function "allowance" reverted with the following reason:\n${expectedError}`, + ) + }) +}) diff --git a/packages/app/src/domain/market-operations/allowance/useAllowance.ts b/packages/app/src/domain/market-operations/allowance/useAllowance.ts new file mode 100644 index 000000000..f6e6e0054 --- /dev/null +++ b/packages/app/src/domain/market-operations/allowance/useAllowance.ts @@ -0,0 +1,27 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query' +import { Address } from 'viem' +import { useConfig } from 'wagmi' + +import { useConnectedAddress } from '@/domain/wallet/useConnectedAddress' + +import { allowance } from './query' + +export interface AllowanceProps { + token: Address + spender: Address +} + +export function useAllowance({ token, spender }: AllowanceProps): UseQueryResult { + const { account, chainId } = useConnectedAddress() + const wagmiConfig = useConfig() + + return useQuery({ + ...allowance({ + wagmiConfig, + account, + token, + spender, + chainId, + }), + }) +} diff --git a/packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.test.tsx b/packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.test.tsx new file mode 100644 index 000000000..7f9030b53 --- /dev/null +++ b/packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.test.tsx @@ -0,0 +1,56 @@ +import { waitFor } from '@testing-library/react' +import { erc20Abi } from 'viem' + +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { testAddresses } from '@/test/integration/constants' +import { expectToStayUndefined } from '@/test/integration/expect' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { toBigInt } from '@/utils/bigNumber' +import { sleep } from '@/utils/promises' + +import { useHasEnoughAllowance } from './useHasEnoughAllowance' + +const mockedAllowance = BaseUnitNumber(2) +const token = testAddresses.token +const account = testAddresses.alice +const spender = testAddresses.bob + +const hookRenderer = setupHookRenderer({ + hook: useHasEnoughAllowance, + account, + handlers: [], + args: { token, spender, value: BaseUnitNumber(1) }, +}) + +describe(useHasEnoughAllowance.name, () => { + it('returns undefined if no data', async () => { + const { result } = hookRenderer({ extraHandlers: [() => sleep(2000)] }) + + await expectToStayUndefined(() => result.current.data) + expect(result.current.error).toBeNull() + }) + + it('properly returns based on allowance', async () => { + const args = { token, spender, value: BaseUnitNumber(mockedAllowance.plus(1)) } + const { result, rerender } = hookRenderer({ + args, + handlers: [ + handlers.balanceCall({ balance: 0n, address: account }), + handlers.contractCall({ + to: token, + abi: erc20Abi, + functionName: 'allowance', + args: [account, spender], + result: toBigInt(mockedAllowance), + }), + ], + }) + + await waitFor(() => expect(result.current.data).toBe(false)) + + rerender({ ...args, value: mockedAllowance }) + + await waitFor(() => expect(result.current.data).toBe(true)) + }) +}) diff --git a/packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.ts b/packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.ts new file mode 100644 index 000000000..6969ce753 --- /dev/null +++ b/packages/app/src/domain/market-operations/allowance/useHasEnoughAllowance.ts @@ -0,0 +1,36 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query' +import { useConfig } from 'wagmi' + +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { useConnectedAddress } from '@/domain/wallet/useConnectedAddress' +import { toBigInt } from '@/utils/bigNumber' + +import { allowance } from './query' +import { AllowanceProps } from './useAllowance' + +export interface HasEnoughAllowanceProps extends AllowanceProps { + value: BaseUnitNumber + enabled?: boolean +} + +export function useHasEnoughAllowance({ + token, + spender, + value, + enabled, +}: HasEnoughAllowanceProps): UseQueryResult { + const wagmiConfig = useConfig() + const { account, chainId } = useConnectedAddress() + + return useQuery({ + ...allowance({ + wagmiConfig, + account, + token, + spender, + chainId, + }), + enabled, + select: (data) => data >= toBigInt(value), + }) +} diff --git a/packages/app/src/domain/market-operations/borrow-allowance/query.ts b/packages/app/src/domain/market-operations/borrow-allowance/query.ts new file mode 100644 index 000000000..04d2caed1 --- /dev/null +++ b/packages/app/src/domain/market-operations/borrow-allowance/query.ts @@ -0,0 +1,36 @@ +import { queryOptions } from '@tanstack/react-query' +import { Address } from 'viem' +import { Config } from 'wagmi' +import { readContract } from 'wagmi/actions' + +import { debtTokenAbi } from '@/config/abis/debtTokenAbi' + +export interface BorrowAllowanceOptions { + wagmiConfig: Config + debtTokenAddress: Address + fromUser: Address + toUser: Address + chainId: number +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function borrowAllowance({ wagmiConfig, debtTokenAddress, fromUser, toUser, chainId }: BorrowAllowanceOptions) { + return queryOptions({ + queryKey: [ + { + entity: 'readContract', + functionName: 'borrowAllowance', + args: [fromUser, toUser], + chainId, + }, + ], + queryFn: () => { + return readContract(wagmiConfig, { + functionName: 'borrowAllowance', + address: debtTokenAddress, + abi: debtTokenAbi, + args: [fromUser, toUser], + }) + }, + }) +} diff --git a/packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.test.ts b/packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.test.ts new file mode 100644 index 000000000..d85aee7a3 --- /dev/null +++ b/packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.test.ts @@ -0,0 +1,60 @@ +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' + +import { debtTokenAbi } from '@/config/abis/debtTokenAbi' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { useBorrowAllowance } from './useBorrowAllowance' + +const mockedBorrowAllowance = 2137n +const account = testAddresses.alice +const wethGateway = testAddresses.token +const debtToken = testAddresses.token2 + +const hookRenderer = setupHookRenderer({ + hook: useBorrowAllowance, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id })], + args: { fromUser: account, toUser: wethGateway, debtTokenAddress: debtToken }, +}) + +describe(useBorrowAllowance.name, () => { + it('returns proper allowance from chain', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: debtToken, + abi: debtTokenAbi, + functionName: 'borrowAllowance', + args: [account, wethGateway], + result: mockedBorrowAllowance, + }), + ], + }) + + await waitFor(() => expect(result.current.data).toBeDefined()) + expect(result.current.data).toBe(mockedBorrowAllowance) + }) + + it('propagates errors', async () => { + const expectedError = 'Not allowed!' + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCallError({ + to: debtToken, + abi: debtTokenAbi, + functionName: 'borrowAllowance', + args: [account, wethGateway], + errorMessage: expectedError, + }), + ], + }) + + await waitFor(() => expect(result.current.error).not.toBe(null)) + expect((result.current.error as any).shortMessage).toBe( + `The contract function "borrowAllowance" reverted with the following reason:\n${expectedError}`, + ) + }) +}) diff --git a/packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.ts b/packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.ts new file mode 100644 index 000000000..9aa573515 --- /dev/null +++ b/packages/app/src/domain/market-operations/borrow-allowance/useBorrowAllowance.ts @@ -0,0 +1,30 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query' +import { Address } from 'viem' +import { useChainId, useConfig } from 'wagmi' + +import { borrowAllowance } from './query' + +export interface BorrowAllowanceProps { + fromUser: Address + toUser: Address + debtTokenAddress: Address +} + +export function useBorrowAllowance({ + fromUser, + toUser, + debtTokenAddress, +}: BorrowAllowanceProps): UseQueryResult { + const wagmiConfig = useConfig() + const chainId = useChainId() + + return useQuery({ + ...borrowAllowance({ + wagmiConfig, + fromUser, + toUser, + debtTokenAddress, + chainId, + }), + }) +} diff --git a/packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.test.ts b/packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.test.ts new file mode 100644 index 000000000..47266cbc6 --- /dev/null +++ b/packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.test.ts @@ -0,0 +1,77 @@ +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' + +import { debtTokenAbi } from '@/config/abis/debtTokenAbi' +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { useHasEnoughBorrowAllowance } from './useHasEnoughBorrowAllowance' + +const mockedBorrowAllowance = 2n +const account = testAddresses.alice +const wethGateway = testAddresses.token +const debtToken = testAddresses.token2 + +const hookRenderer = setupHookRenderer({ + hook: useHasEnoughBorrowAllowance, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id })], + args: { toUser: wethGateway, debtTokenAddress: debtToken, value: BaseUnitNumber(mockedBorrowAllowance - 1n) }, +}) + +describe(useHasEnoughBorrowAllowance.name, () => { + it('returns true if borrow allowance is greater than value', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: debtToken, + abi: debtTokenAbi, + functionName: 'borrowAllowance', + args: [account, wethGateway], + result: mockedBorrowAllowance, + }), + ], + }) + + await waitFor(() => expect(result.current.data).toBeDefined()) + expect(result.current.data).toBe(true) + }) + + it('returns true if borrow allowance is equal to value', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: debtToken, + abi: debtTokenAbi, + functionName: 'borrowAllowance', + args: [account, wethGateway], + result: mockedBorrowAllowance, + }), + ], + args: { toUser: wethGateway, debtTokenAddress: debtToken, value: BaseUnitNumber(mockedBorrowAllowance) }, + }) + + await waitFor(() => expect(result.current.data).toBeDefined()) + expect(result.current.data).toBe(true) + }) + + it('returns false if borrow allowance is less than value', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: debtToken, + abi: debtTokenAbi, + functionName: 'borrowAllowance', + args: [account, wethGateway], + result: mockedBorrowAllowance, + }), + ], + args: { toUser: wethGateway, debtTokenAddress: debtToken, value: BaseUnitNumber(mockedBorrowAllowance + 1n) }, + }) + + await waitFor(() => expect(result.current.data).toBeDefined()) + expect(result.current.data).toBe(false) + }) +}) diff --git a/packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.ts b/packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.ts new file mode 100644 index 000000000..a22b44776 --- /dev/null +++ b/packages/app/src/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance.ts @@ -0,0 +1,36 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query' +import { useConfig } from 'wagmi' + +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { useConnectedAddress } from '@/domain/wallet/useConnectedAddress' +import { toBigInt } from '@/utils/bigNumber' + +import { borrowAllowance } from './query' +import { BorrowAllowanceProps } from './useBorrowAllowance' + +export interface HasEnoughBorrowAllowanceProps extends Omit { + value: BaseUnitNumber + enabled?: boolean +} + +export function useHasEnoughBorrowAllowance({ + toUser, + debtTokenAddress, + value, + enabled, +}: HasEnoughBorrowAllowanceProps): UseQueryResult { + const wagmiConfig = useConfig() + const { account, chainId } = useConnectedAddress() + + return useQuery({ + ...borrowAllowance({ + wagmiConfig, + fromUser: account, + toUser, + debtTokenAddress, + chainId, + }), + enabled, + select: (data) => data >= toBigInt(value), + }) +} diff --git a/packages/app/src/domain/market-operations/normalizeErc20Abi.ts b/packages/app/src/domain/market-operations/normalizeErc20Abi.ts new file mode 100644 index 000000000..50fab1919 --- /dev/null +++ b/packages/app/src/domain/market-operations/normalizeErc20Abi.ts @@ -0,0 +1,200 @@ +import { Address, erc20Abi } from 'viem' + +import { getChainConfigEntry } from '@/config/chain' + +/** + * Unfortunately, some tokens do not follow the ERC20 standard, so we need to use a slightly modified ABI for them. + */ +export function normalizeErc20AbiForToken(chainId: number, token: Address): typeof erc20Abi { + const { erc20TokensWithApproveFnMalformed } = getChainConfigEntry(chainId) + if (erc20TokensWithApproveFnMalformed.find((t) => t.toLowerCase() === token.toLowerCase())) { + return erc20AbiWithApproveFnMalformed as any + } + + return erc20Abi +} + +export const erc20AbiWithApproveFnMalformed = [ + { + type: 'event', + name: 'Approval', + inputs: [ + { + indexed: true, + name: 'owner', + type: 'address', + }, + { + indexed: true, + name: 'spender', + type: 'address', + }, + { + indexed: false, + name: 'value', + type: 'uint256', + }, + ], + }, + { + type: 'event', + name: 'Transfer', + inputs: [ + { + indexed: true, + name: 'from', + type: 'address', + }, + { + indexed: true, + name: 'to', + type: 'address', + }, + { + indexed: false, + name: 'value', + type: 'uint256', + }, + ], + }, + { + type: 'function', + name: 'allowance', + stateMutability: 'view', + inputs: [ + { + name: 'owner', + type: 'address', + }, + { + name: 'spender', + type: 'address', + }, + ], + outputs: [ + { + type: 'uint256', + }, + ], + }, + { + type: 'function', + name: 'approve', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'spender', + type: 'address', + }, + { + name: 'amount', + type: 'uint256', + }, + ], + outputs: [], + }, + { + type: 'function', + name: 'balanceOf', + stateMutability: 'view', + inputs: [ + { + name: 'account', + type: 'address', + }, + ], + outputs: [ + { + type: 'uint256', + }, + ], + }, + { + type: 'function', + name: 'decimals', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'uint8', + }, + ], + }, + { + type: 'function', + name: 'name', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'string', + }, + ], + }, + { + type: 'function', + name: 'symbol', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'string', + }, + ], + }, + { + type: 'function', + name: 'totalSupply', + stateMutability: 'view', + inputs: [], + outputs: [ + { + type: 'uint256', + }, + ], + }, + { + type: 'function', + name: 'transfer', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'recipient', + type: 'address', + }, + { + name: 'amount', + type: 'uint256', + }, + ], + outputs: [ + { + type: 'bool', + }, + ], + }, + { + type: 'function', + name: 'transferFrom', + stateMutability: 'nonpayable', + inputs: [ + { + name: 'sender', + type: 'address', + }, + { + name: 'recipient', + type: 'address', + }, + { + name: 'amount', + type: 'uint256', + }, + ], + outputs: [ + { + type: 'bool', + }, + ], + }, +] as const diff --git a/packages/app/src/domain/market-operations/useApprove.test.ts b/packages/app/src/domain/market-operations/useApprove.test.ts new file mode 100644 index 000000000..50577409f --- /dev/null +++ b/packages/app/src/domain/market-operations/useApprove.test.ts @@ -0,0 +1,81 @@ +import { waitFor } from '@testing-library/react' +import { erc20Abi } from 'viem' +import { mainnet } from 'viem/chains' + +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { toBigInt } from '../../utils/bigNumber' +import { BaseUnitNumber } from '../types/NumericValues' +import { useApprove } from './useApprove' + +const defaultValue = BaseUnitNumber(100) +const token = testAddresses.token +const account = testAddresses.alice +const spender = testAddresses.bob + +const hookRenderer = setupHookRenderer({ + hook: useApprove, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id }), handlers.balanceCall({ balance: 0n, address: account })], + args: { token, spender, value: defaultValue }, +}) + +describe(useApprove.name, () => { + it('is not enabled if wrong value', async () => { + const { result } = hookRenderer({ args: { value: BaseUnitNumber(0), token, spender } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled if address is a native asset', async () => { + const { result } = hookRenderer({ + args: { value: defaultValue, token: NATIVE_ASSET_MOCK_ADDRESS, spender }, + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('respects enabled flag', async () => { + const { result } = hookRenderer({ + args: { enabled: false, value: defaultValue, token, spender }, + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('approves', async () => { + const { result } = hookRenderer({ + args: { value: defaultValue, token, spender }, + extraHandlers: [ + handlers.contractCall({ + to: token, + abi: erc20Abi, + functionName: 'approve', + args: [spender, toBigInt(defaultValue)], + result: true, + from: account, + }), + handlers.mineTransaction(), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) +}) diff --git a/packages/app/src/domain/market-operations/useApprove.ts b/packages/app/src/domain/market-operations/useApprove.ts new file mode 100644 index 000000000..4576d0884 --- /dev/null +++ b/packages/app/src/domain/market-operations/useApprove.ts @@ -0,0 +1,49 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Address } from 'viem' +import { useConfig } from 'wagmi' + +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { useWrite, UseWriteCallbacks } from '@/domain/hooks/useWrite' + +import { toBigInt } from '../../utils/bigNumber' +import { BaseUnitNumber } from '../types/NumericValues' +import { useConnectedAddress } from '../wallet/useConnectedAddress' +import { allowance } from './allowance/query' +import { normalizeErc20AbiForToken } from './normalizeErc20Abi' + +export interface UseApproveArgs { + token: Address + spender: Address + value: BaseUnitNumber + enabled?: boolean +} + +export function useApprove( + { token, spender, value: _value, enabled = true }: UseApproveArgs, + callbacks: UseWriteCallbacks = {}, +): ReturnType { + const client = useQueryClient() + const value = toBigInt(_value) + const { account, chainId } = useConnectedAddress() + const wagmiConfig = useConfig() + + return useWrite( + { + address: token, + abi: normalizeErc20AbiForToken(chainId, token), + functionName: 'approve', + args: [spender, value], + enabled: value > 0n && token !== NATIVE_ASSET_MOCK_ADDRESS && enabled, + }, + { + ...callbacks, + onTransactionSettled: () => { + void client.invalidateQueries({ + queryKey: allowance({ wagmiConfig, token, spender, account, chainId }).queryKey, + }) + + callbacks.onTransactionSettled?.() + }, + }, + ) +} diff --git a/packages/app/src/domain/market-operations/useApproveDelegation.test.ts b/packages/app/src/domain/market-operations/useApproveDelegation.test.ts new file mode 100644 index 000000000..ba06359e8 --- /dev/null +++ b/packages/app/src/domain/market-operations/useApproveDelegation.test.ts @@ -0,0 +1,70 @@ +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' + +import { debtTokenAbi } from '@/config/abis/debtTokenAbi' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { toBigInt } from '../../utils/bigNumber' +import { BaseUnitNumber } from '../types/NumericValues' +import { useApproveDelegation } from './useApproveDelegation' + +const defaultValue = BaseUnitNumber(100) +const debtTokenAddress = testAddresses.token +const account = testAddresses.alice +const delegatee = testAddresses.bob + +const hookRenderer = setupHookRenderer({ + hook: useApproveDelegation, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id })], + args: { debtTokenAddress, delegatee, value: defaultValue }, +}) + +describe(useApproveDelegation.name, () => { + it('is not enabled if wrong value', async () => { + const { result } = hookRenderer({ args: { value: BaseUnitNumber(0), delegatee, debtTokenAddress } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('respects enabled flag', async () => { + const { result } = hookRenderer({ + args: { enabled: false, value: defaultValue, delegatee, debtTokenAddress }, + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('approves delegation', async () => { + const { result } = hookRenderer({ + args: { value: defaultValue, delegatee, debtTokenAddress }, + extraHandlers: [ + handlers.contractCall({ + to: debtTokenAddress, + abi: debtTokenAbi, + functionName: 'approveDelegation', + args: [delegatee, toBigInt(defaultValue)], + from: account, + result: undefined, + }), + handlers.mineTransaction(), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) +}) diff --git a/packages/app/src/domain/market-operations/useApproveDelegation.ts b/packages/app/src/domain/market-operations/useApproveDelegation.ts new file mode 100644 index 000000000..0bdcbbeb8 --- /dev/null +++ b/packages/app/src/domain/market-operations/useApproveDelegation.ts @@ -0,0 +1,49 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Address } from 'viem' +import { useConfig } from 'wagmi' + +import { debtTokenAbi } from '@/config/abis/debtTokenAbi' +import { useWrite, UseWriteCallbacks } from '@/domain/hooks/useWrite' + +import { toBigInt } from '../../utils/bigNumber' +import { BaseUnitNumber } from '../types/NumericValues' +import { useConnectedAddress } from '../wallet/useConnectedAddress' +import { borrowAllowance } from './borrow-allowance/query' + +export interface UseApproveDelegationParams { + debtTokenAddress: Address + delegatee: Address + value: BaseUnitNumber + enabled?: boolean +} + +export function useApproveDelegation( + { debtTokenAddress, delegatee, value: _value, enabled = true }: UseApproveDelegationParams, + callbacks: UseWriteCallbacks = {}, +): ReturnType { + const client = useQueryClient() + const value = toBigInt(_value) + const { account, chainId } = useConnectedAddress() + const wagmiConfig = useConfig() + + return useWrite( + { + address: debtTokenAddress, + abi: debtTokenAbi, + functionName: 'approveDelegation', + args: [delegatee, value], + enabled: value > 0n && enabled, + }, + { + ...callbacks, + onTransactionSettled: () => { + void client.invalidateQueries({ + queryKey: borrowAllowance({ wagmiConfig, fromUser: account, toUser: delegatee, debtTokenAddress, chainId }) + .queryKey, + }) + + callbacks.onTransactionSettled?.() + }, + }, + ) +} diff --git a/packages/app/src/domain/market-operations/useBorrow.test.ts b/packages/app/src/domain/market-operations/useBorrow.test.ts new file mode 100644 index 000000000..a6fce6c79 --- /dev/null +++ b/packages/app/src/domain/market-operations/useBorrow.test.ts @@ -0,0 +1,137 @@ +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' + +import { poolAbi } from '@/config/abis/poolAbi' +import { InterestRate, NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayAbi, wethGatewayAddress } from '@/config/contracts-generated' +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { toBigInt } from '@/utils/bigNumber' + +import { useBorrow } from './useBorrow' + +const asset = testAddresses.token +const account = testAddresses.alice +const value = BaseUnitNumber(1) +const interestRateMode = BigInt(InterestRate.Variable) +const referralCode = 0 + +const hookRenderer = setupHookRenderer({ + hook: useBorrow, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id }), handlers.balanceCall({ balance: 0n, address: account })], + args: { + value, + asset, + }, +}) + +describe(useBorrow.name, () => { + it('is not enabled for guest ', async () => { + const { result } = hookRenderer({ account: undefined }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled for 0 value', async () => { + const { result } = hookRenderer({ args: { asset, value: BaseUnitNumber(0) } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled when explicitly disabled', async () => { + const { result } = hookRenderer({ args: { asset, value, enabled: false } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('borrows native asset', async () => { + const { result } = hookRenderer({ + args: { + asset: NATIVE_ASSET_MOCK_ADDRESS, + value, + }, + extraHandlers: [ + handlers.contractCall({ + to: wethGatewayAddress[mainnet.id], + abi: wethGatewayAbi, + functionName: 'borrowETH', + args: [lendingPoolAddress[mainnet.id], toBigInt(value), interestRateMode, referralCode], + from: account, + result: undefined, + }), + handlers.mineTransaction(), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('borrows non native asset', async () => { + const { result } = hookRenderer({ + args: { + asset, + value, + }, + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'borrow', + args: [asset, toBigInt(value), interestRateMode, referralCode, account], + result: undefined, + from: account, + }), + handlers.mineTransaction(), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('uses proper config if other asset', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'borrow', + args: [asset, toBigInt(value), interestRateMode, referralCode, account], + result: undefined, + from: account, + }), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + }) +}) diff --git a/packages/app/src/domain/market-operations/useBorrow.ts b/packages/app/src/domain/market-operations/useBorrow.ts new file mode 100644 index 000000000..92fef4ed8 --- /dev/null +++ b/packages/app/src/domain/market-operations/useBorrow.ts @@ -0,0 +1,71 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Address } from 'viem' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { poolAbi } from '@/config/abis/poolAbi' +import { InterestRate, NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayConfig } from '@/config/contracts-generated' +import { useContractAddress } from '@/domain/hooks/useContractAddress' +import { ensureConfigTypes, useWrite } from '@/domain/hooks/useWrite' +import { aaveDataLayer } from '@/domain/market-info/aave-data-layer/query' +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { balances } from '@/domain/wallet/balances' +import { toBigInt } from '@/utils/bigNumber' + +export interface UseBorrowArgs { + asset: Address + value: BaseUnitNumber + enabled?: boolean + onTransactionSettled?: () => void +} + +export function useBorrow({ + value: _value, + enabled = true, + onTransactionSettled, + asset, +}: UseBorrowArgs): ReturnType { + const lendingPool = useContractAddress(lendingPoolAddress) + const wethGateway = useContractAddress(wethGatewayConfig.address) + const referralCode = 0 + const client = useQueryClient() + const { address: userAddress } = useAccount() + const chainId = useChainId() + const wagmiConfig = useConfig() + const value = toBigInt(_value) + const interestRateMode = BigInt(InterestRate.Variable) + + const config = + asset === NATIVE_ASSET_MOCK_ADDRESS + ? ensureConfigTypes({ + abi: wethGatewayConfig.abi, + address: wethGateway, + functionName: 'borrowETH', + args: [lendingPool, value, interestRateMode, referralCode], + }) + : ensureConfigTypes({ + abi: poolAbi, + address: lendingPool, + functionName: 'borrow', + args: [asset, value, interestRateMode, referralCode, userAddress!], + }) + + return useWrite( + { + ...config, + enabled: !!userAddress && value > 0n && enabled, + }, + { + onTransactionSettled: async () => { + void client.invalidateQueries({ + queryKey: aaveDataLayer({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: balances({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + + onTransactionSettled?.() + }, + }, + ) +} diff --git a/packages/app/src/domain/market-operations/useDeposit.test.ts b/packages/app/src/domain/market-operations/useDeposit.test.ts new file mode 100644 index 000000000..3b48c7003 --- /dev/null +++ b/packages/app/src/domain/market-operations/useDeposit.test.ts @@ -0,0 +1,151 @@ +import { waitFor } from '@testing-library/react' +import { generatePrivateKey } from 'viem/accounts' +import { mainnet } from 'viem/chains' + +import { poolAbi } from '@/config/abis/poolAbi' +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayAbi, wethGatewayAddress } from '@/config/contracts-generated' +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { toBigInt } from '@/utils/bigNumber' +import { getTimestampInSeconds } from '@/utils/time' + +import { Token } from '../types/Token' +import { TokenSymbol } from '../types/TokenSymbol' +import { useDeposit } from './useDeposit' + +const asset = testAddresses.token +const account = testAddresses.alice +const value = BaseUnitNumber(1) +const referralCode = 0 + +const hookRenderer = setupHookRenderer({ + hook: useDeposit, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id }), handlers.balanceCall({ balance: 0n, address: account })], + args: { + value, + asset, + }, +}) + +describe(useDeposit.name, () => { + it('is not enabled for guest ', async () => { + const { result } = hookRenderer({ account: undefined }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled for 0 value', async () => { + const { result } = hookRenderer({ args: { asset, value: BaseUnitNumber(0) } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled when explicitly disabled', async () => { + const { result } = hookRenderer({ args: { asset, value, enabled: false } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('deposits native asset', async () => { + const { result } = hookRenderer({ + args: { asset: NATIVE_ASSET_MOCK_ADDRESS, value }, + extraHandlers: [ + handlers.contractCall({ + to: wethGatewayAddress[mainnet.id], + abi: wethGatewayAbi, + functionName: 'depositETH', + args: [lendingPoolAddress[mainnet.id], account, referralCode], + from: account, + result: undefined, + value: toBigInt(value), + }), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + }) + + it('deposits any token', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'deposit', + args: [asset, toBigInt(value), account, referralCode], + from: account, + result: undefined, + }), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + }) + + it('deposits with permit', async () => { + const random32Bytes = generatePrivateKey() + const permitDeadline = new Date() + + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'supplyWithPermit', + args: [ + asset, + toBigInt(value), + account, + referralCode, + toBigInt(getTimestampInSeconds(permitDeadline)), + 0, + random32Bytes, + random32Bytes, + ], + from: account, + result: undefined, + }), + ], + args: { + value, + asset, + permit: { + token: new Token({ + address: asset, + decimals: 18, + symbol: TokenSymbol('TEST'), + name: 'Test', + unitPriceUsd: '1', + }), + deadline: permitDeadline, + signature: { + r: random32Bytes, + s: random32Bytes, + v: 0n, + }, + }, + }, + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + }) +}) diff --git a/packages/app/src/domain/market-operations/useDeposit.ts b/packages/app/src/domain/market-operations/useDeposit.ts new file mode 100644 index 000000000..d0c27c745 --- /dev/null +++ b/packages/app/src/domain/market-operations/useDeposit.ts @@ -0,0 +1,131 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Abi, Address } from 'viem' +import { useAccount, useChainId, useConfig, UseSimulateContractParameters } from 'wagmi' + +import { poolAbi } from '@/config/abis/poolAbi' +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayConfig } from '@/config/contracts-generated' +import { ensureConfigTypes, useWrite } from '@/domain/hooks/useWrite' +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { Permit } from '@/features/actions/logic/permits' +import { toBigInt } from '@/utils/bigNumber' +import { getTimestampInSeconds } from '@/utils/time' + +import { useContractAddress } from '../hooks/useContractAddress' +import { aaveDataLayer } from '../market-info/aave-data-layer/query' +import { balances } from '../wallet/balances' +import { allowance } from './allowance/query' + +export type UseDepositArgs = { + asset: Address + value: BaseUnitNumber + permit?: Permit + onTransactionSettled?: () => void + enabled?: boolean +} + +export function useDeposit({ + asset, + value: _value, + permit, + onTransactionSettled, + enabled = true, +}: UseDepositArgs): ReturnType { + const client = useQueryClient() + + const { address: userAddress } = useAccount() + const lendingPool = useContractAddress(lendingPoolAddress) + const wethGateway = useContractAddress(wethGatewayConfig.address) + const wagmiConfig = useConfig() + + const chainId = useChainId() + const referralCode = 0 + const value = toBigInt(_value) + + const config = getDepositWriteConfig({ + asset, + value, + userAddress, + lendingPool, + wethGateway, + referralCode, + permit, + }) + + return useWrite( + { + ...config, + enabled: !!userAddress && value > 0n && enabled, + }, + { + onTransactionSettled: async () => { + void client.invalidateQueries({ + queryKey: aaveDataLayer({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: balances({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: allowance({ wagmiConfig, chainId, token: asset, account: userAddress!, spender: lendingPool }) + .queryKey, + }) + + onTransactionSettled?.() + }, + }, + ) +} + +interface GetDepositWriteConfigArgs { + asset: Address + value: bigint + userAddress?: Address + lendingPool: Address + wethGateway: Address + referralCode: number + permit?: Permit +} +function getDepositWriteConfig({ + asset, + value, + userAddress, + lendingPool, + wethGateway, + referralCode, + permit, +}: GetDepositWriteConfigArgs): UseSimulateContractParameters { + if (asset === NATIVE_ASSET_MOCK_ADDRESS) { + return ensureConfigTypes({ + abi: wethGatewayConfig.abi, + address: wethGateway, + functionName: 'depositETH', + value, + args: [lendingPool, userAddress!, referralCode], + }) + } + + if (permit) { + return ensureConfigTypes({ + address: lendingPool, + abi: poolAbi, + functionName: 'supplyWithPermit', + args: [ + asset, + value, + userAddress!, + referralCode, + toBigInt(getTimestampInSeconds(permit.deadline)), + Number(permit.signature.v), + permit.signature.r, + permit.signature.s, + ], + }) + } + + return ensureConfigTypes({ + address: lendingPool, + abi: poolAbi, + functionName: 'deposit', + args: [asset, value, userAddress!, referralCode], + }) +} diff --git a/packages/app/src/domain/market-operations/useExchange.test.ts b/packages/app/src/domain/market-operations/useExchange.test.ts new file mode 100644 index 000000000..468ab31ca --- /dev/null +++ b/packages/app/src/domain/market-operations/useExchange.test.ts @@ -0,0 +1,87 @@ +import { waitFor } from '@testing-library/react' +import { encodeFunctionData, erc20Abi } from 'viem' +import { mainnet } from 'viem/chains' +import { describe, expect, test, vi } from 'vitest' + +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { toBigInt } from '@/utils/bigNumber' + +import { TxRequest } from '../exchanges/types' +import { BaseUnitNumber, NormalizedUnitNumber } from '../types/NumericValues' +import { useExchange } from './useExchange' + +const account = testAddresses.alice +const fromToken = testAddresses.token +const toToken = testAddresses.token2 +const amount = BaseUnitNumber(10_000_000_000) +const chainId = mainnet.id + +const dataArgs = { + abi: erc20Abi, + functionName: 'transfer', + args: [testAddresses.bob, toBigInt(amount)], +} as const + +const transactionRequest: TxRequest = { + data: encodeFunctionData(dataArgs), // mock calldata + from: account, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: 0n, + gasPrice: 0xe1a0aa5f1n, + gasLimit: 0x75d63n, +} + +const hookRenderer = setupHookRenderer({ + hook: useExchange, + account, + handlers: [handlers.chainIdCall({ chainId }), handlers.balanceCall({ balance: 0n, address: account })], + args: { + swapInfo: { + status: 'success', + data: { + fromToken, + toToken, + txRequest: transactionRequest, + estimate: { + feeCostsUSD: NormalizedUnitNumber(0), + fromAmount: amount, + toAmount: BaseUnitNumber('943000000000000000'), + }, + type: 'direct', + }, + }, + }, +}) + +describe(useExchange.name, () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + test('sends correct transaction', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + ...dataArgs, + to: transactionRequest.to, + from: account, + value: 0n, + result: true, + }), + handlers.mineTransaction(), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + + result.current.send() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) +}) diff --git a/packages/app/src/domain/market-operations/useExchange.ts b/packages/app/src/domain/market-operations/useExchange.ts new file mode 100644 index 000000000..e7f3308ce --- /dev/null +++ b/packages/app/src/domain/market-operations/useExchange.ts @@ -0,0 +1,129 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useAccount, useChainId, useConfig, UseEstimateGasParameters } from 'wagmi' + +import { SimplifiedQueryResult } from '@/features/actions/logic/simplifyQueryResult' + +import { SwapInfoSimplified, SwapRequest } from '../exchanges/types' +import { useSendTx, UseSendTxResult } from '../hooks/useSendTx' +import { WriteStatus } from '../hooks/useWrite' +import { aaveDataLayer } from '../market-info/aave-data-layer/query' +import { BaseUnitNumber, NormalizedUnitNumber } from '../types/NumericValues' +import { balances } from '../wallet/balances' +import { allowance } from './allowance/query' + +export interface UseExchangeParams { + swapInfo: SimplifiedQueryResult + enabled?: boolean + onTransactionSettled?: () => void +} + +export type ExchangeStatus = + | WriteStatus + | ( + | { kind: 'error'; errorKind: 'fetching-quote-error'; error: Error } // adds new error kind + | { kind: 'fetching-quote' } + ) + +export interface ExchangeEstimate { + USDFee: NormalizedUnitNumber + fromAmount: BaseUnitNumber + toAmount: BaseUnitNumber +} + +export interface UseExchangeResult { + send: () => void + resimulate: () => void + reset: () => void + estimate: ExchangeEstimate | undefined + status: ExchangeStatus +} + +export function useExchange({ swapInfo, enabled, onTransactionSettled }: UseExchangeParams): UseExchangeResult { + const client = useQueryClient() + const wagmiConfig = useConfig() + const { address: userAddress } = useAccount() + const chainId = useChainId() + + const request = useSendTx( + { + ...normalizeSwapRequest(swapInfo.data), + enabled: enabled && swapInfo.data !== undefined, + }, + { + onTransactionSettled: () => { + if (swapInfo.data?.txRequest.to && userAddress) { + void client.invalidateQueries({ + queryKey: allowance({ + wagmiConfig, + token: swapInfo.data!.fromToken, + spender: swapInfo.data.txRequest.to, + account: userAddress, + chainId, + }).queryKey, + }) + } + void client.invalidateQueries({ + queryKey: aaveDataLayer({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: balances({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + + onTransactionSettled?.() + }, + }, + ) + + return extendExchangeRequest(request, swapInfo) +} + +function extendExchangeRequest(request: UseSendTxResult, swapInfo: SwapInfoSimplified): UseExchangeResult { + const status = ((): ExchangeStatus => { + if (request.status.kind === 'disabled') { + return { kind: 'disabled' } + } + + if (swapInfo.status === 'error') { + return { kind: 'error', errorKind: 'fetching-quote-error', error: swapInfo.error } + } + + if (swapInfo.status === 'pending') { + return { kind: 'fetching-quote' } + } + + return request.status + })() + + return { + ...request, + reset: async () => { + request.reset() + }, + status, + estimate: getEstimate(swapInfo.data), + } +} + +function normalizeSwapRequest(data: SwapRequest | undefined): UseEstimateGasParameters | undefined { + if (data) { + return { + to: data.txRequest.to, + value: data.txRequest.value, + data: data.txRequest.data, + gas: data.txRequest.gasLimit, + } + } + + return undefined +} + +function getEstimate(data: SwapRequest | undefined): ExchangeEstimate | undefined { + if (data) { + return { + USDFee: data.estimate.feeCostsUSD, + fromAmount: BaseUnitNumber(data.estimate.fromAmount), + toAmount: BaseUnitNumber(data.estimate.toAmount), + } + } + return undefined +} diff --git a/packages/app/src/domain/market-operations/useRepay.test.ts b/packages/app/src/domain/market-operations/useRepay.test.ts new file mode 100644 index 000000000..314b81c2b --- /dev/null +++ b/packages/app/src/domain/market-operations/useRepay.test.ts @@ -0,0 +1,204 @@ +import { waitFor } from '@testing-library/react' +import { generatePrivateKey } from 'viem/accounts' +import { mainnet } from 'viem/chains' + +import { poolAbi } from '@/config/abis/poolAbi' +import { InterestRate, NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayAbi, wethGatewayAddress } from '@/config/contracts-generated' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { toBigInt } from '@/utils/bigNumber' +import { getTimestampInSeconds } from '@/utils/time' + +import { BaseUnitNumber } from '../types/NumericValues' +import { Token } from '../types/Token' +import { TokenSymbol } from '../types/TokenSymbol' +import { useRepay } from './useRepay' + +const asset = testAddresses.token +const account = testAddresses.alice +const value = BaseUnitNumber(1) +const interestRateMode = BigInt(InterestRate.Variable) + +const initialArgs = { + value, + asset, + useAToken: false, +} + +const hookRenderer = setupHookRenderer({ + hook: useRepay, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id }), handlers.balanceCall({ balance: 0n, address: account })], + args: initialArgs, +}) + +describe(useRepay.name, () => { + it('is not enabled for guest', async () => { + const { result } = hookRenderer({ account: undefined }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled for 0 value', async () => { + const { result } = hookRenderer({ args: { ...initialArgs, value: BaseUnitNumber(0) } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled when explicitly disabled', async () => { + const { result } = hookRenderer({ args: { ...initialArgs, enabled: false } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('repays with aToken', async () => { + const { result } = hookRenderer({ + args: { + ...initialArgs, + useAToken: true, + }, + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'repayWithATokens', + args: [asset, toBigInt(value), interestRateMode], + from: account, + result: 1n, + }), + handlers.mineTransaction(), + ], + }) + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('repays with a native token', async () => { + const { result } = hookRenderer({ + args: { + ...initialArgs, + asset: NATIVE_ASSET_MOCK_ADDRESS, + }, + extraHandlers: [ + handlers.contractCall({ + to: wethGatewayAddress[mainnet.id], + abi: wethGatewayAbi, + functionName: 'repayETH', + args: [lendingPoolAddress[mainnet.id], toBigInt(value), interestRateMode, account], + from: account, + result: undefined, + value: toBigInt(value), + }), + handlers.mineTransaction(), + ], + }) + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('repays with a not aToken', async () => { + const { result } = hookRenderer({ + args: { + ...initialArgs, + useAToken: false, + }, + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'repay', + args: [asset, toBigInt(value), interestRateMode, account], + from: account, + result: 1n, + }), + handlers.mineTransaction(), + ], + }) + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('repays with permit', async () => { + const random32Bytes = generatePrivateKey() + const permitDeadline = new Date() + + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'repayWithPermit', + args: [ + asset, + toBigInt(value), + interestRateMode, + account, + toBigInt(getTimestampInSeconds(permitDeadline)), + 0, + random32Bytes, + random32Bytes, + ], + from: account, + result: 1n, + }), + ], + args: { + value, + asset, + useAToken: false, + permit: { + token: new Token({ + address: asset, + decimals: 18, + symbol: TokenSymbol('TEST'), + name: 'Test', + unitPriceUsd: '1', + }), + deadline: permitDeadline, + signature: { + r: random32Bytes, + s: random32Bytes, + v: 0n, + }, + }, + }, + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + }) +}) diff --git a/packages/app/src/domain/market-operations/useRepay.ts b/packages/app/src/domain/market-operations/useRepay.ts new file mode 100644 index 000000000..ad04147ea --- /dev/null +++ b/packages/app/src/domain/market-operations/useRepay.ts @@ -0,0 +1,132 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Abi, Address } from 'viem' +import { useAccount, useChainId, useConfig, UseSimulateContractParameters } from 'wagmi' + +import { poolAbi } from '@/config/abis/poolAbi' +import { InterestRate, NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayConfig } from '@/config/contracts-generated' +import { ensureConfigTypes, useWrite } from '@/domain/hooks/useWrite' +import { Permit } from '@/features/actions/logic/permits' +import { toBigInt } from '@/utils/bigNumber' +import { getTimestampInSeconds } from '@/utils/time' + +import { useContractAddress } from '../hooks/useContractAddress' +import { aaveDataLayer } from '../market-info/aave-data-layer/query' +import { BaseUnitNumber } from '../types/NumericValues' +import { balances } from '../wallet/balances' +import { allowance } from './allowance/query' + +interface UseRepayArgs { + asset: Address + value: BaseUnitNumber + useAToken: boolean + permit?: Permit + enabled?: boolean + onTransactionSettled?: () => void +} + +export function useRepay({ + asset, + value: _value, + useAToken, + permit, + enabled = true, + onTransactionSettled, +}: UseRepayArgs): ReturnType { + const { address: userAddress } = useAccount() + const client = useQueryClient() + const chainId = useChainId() + const lendingPool = useContractAddress(lendingPoolAddress) + const wethGateway = useContractAddress(wethGatewayConfig.address) + const wagmiConfig = useConfig() + const value = toBigInt(_value) + + return useWrite( + { + ...getConfig({ lendingPool, wethGateway, asset, value, useAToken, userAddress, permit }), + enabled: enabled && value > 0n && !!userAddress && !!lendingPool, + }, + { + onTransactionSettled: async () => { + void client.invalidateQueries({ + queryKey: aaveDataLayer({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: balances({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: allowance({ wagmiConfig, chainId, token: asset, account: userAddress!, spender: lendingPool }) + .queryKey, + }) + + onTransactionSettled?.() + }, + }, + ) +} + +interface GetConfigOptions { + lendingPool: Address + wethGateway: Address + asset: Address + useAToken: boolean + value: bigint + permit: Permit | undefined + userAddress: Address | undefined +} + +function getConfig({ + lendingPool, + wethGateway, + asset, + useAToken, + value, + permit, + userAddress, +}: GetConfigOptions): UseSimulateContractParameters { + const interestRateMode = BigInt(InterestRate.Variable) + + if (useAToken) { + return ensureConfigTypes({ + address: lendingPool, + abi: poolAbi, + functionName: 'repayWithATokens', + args: [asset, value, interestRateMode], + }) + } + + if (permit) { + return ensureConfigTypes({ + address: lendingPool, + abi: poolAbi, + functionName: 'repayWithPermit', + args: [ + asset, + value, + interestRateMode, + userAddress!, + toBigInt(getTimestampInSeconds(permit.deadline)), + Number(permit.signature.v), + permit.signature.r, + permit.signature.s, + ], + }) + } + + if (asset === NATIVE_ASSET_MOCK_ADDRESS) { + return ensureConfigTypes({ + address: wethGateway, + abi: wethGatewayConfig.abi, + functionName: 'repayETH', + args: [lendingPool, value, interestRateMode, userAddress!], + value, + }) + } + + return ensureConfigTypes({ + address: lendingPool, + abi: poolAbi, + functionName: 'repay', + args: [asset, value, interestRateMode, userAddress!], + }) +} diff --git a/packages/app/src/domain/market-operations/useSetUseAsCollateral.test.ts b/packages/app/src/domain/market-operations/useSetUseAsCollateral.test.ts new file mode 100644 index 000000000..838cbed6f --- /dev/null +++ b/packages/app/src/domain/market-operations/useSetUseAsCollateral.test.ts @@ -0,0 +1,102 @@ +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' + +import { poolAbi } from '@/config/abis/poolAbi' +import { lendingPoolAddress } from '@/config/contracts-generated' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { useSetUseAsCollateral } from './useSetUseAsCollateral' + +const asset = testAddresses.token +const account = testAddresses.alice + +const initialArgs = { + asset, + useAsCollateral: true, +} + +const hookRenderer = setupHookRenderer({ + hook: useSetUseAsCollateral, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id })], + args: initialArgs, +}) + +describe(useSetUseAsCollateral.name, () => { + it('is not enabled for guest', async () => { + const { result } = hookRenderer({ account: undefined }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled when explicitly disabled', async () => { + const { result } = hookRenderer({ args: { ...initialArgs, enabled: false } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('enables asset as collateral', async () => { + const { result } = hookRenderer({ + args: { + ...initialArgs, + }, + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'setUserUseReserveAsCollateral', + args: [asset, true], + from: account, + result: undefined, + }), + handlers.mineTransaction(), + ], + }) + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('disables asset as collateral', async () => { + const { result } = hookRenderer({ + args: { + ...initialArgs, + useAsCollateral: false, + }, + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'setUserUseReserveAsCollateral', + args: [asset, false], + from: account, + result: undefined, + }), + handlers.mineTransaction(), + ], + }) + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) +}) diff --git a/packages/app/src/domain/market-operations/useSetUseAsCollateral.ts b/packages/app/src/domain/market-operations/useSetUseAsCollateral.ts new file mode 100644 index 000000000..187da20d5 --- /dev/null +++ b/packages/app/src/domain/market-operations/useSetUseAsCollateral.ts @@ -0,0 +1,52 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Address } from 'viem' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { poolAbi } from '@/config/abis/poolAbi' +import { lendingPoolAddress } from '@/config/contracts-generated' +import { useContractAddress } from '@/domain/hooks/useContractAddress' +import { ensureConfigTypes, useWrite } from '@/domain/hooks/useWrite' +import { aaveDataLayer } from '@/domain/market-info/aave-data-layer/query' + +export interface UseSetUseAsCollateralParams { + asset: Address + useAsCollateral: boolean + enabled?: boolean + onTransactionSettled?: () => void +} + +export function useSetUseAsCollateral({ + asset, + useAsCollateral, + enabled = true, + onTransactionSettled, +}: UseSetUseAsCollateralParams): ReturnType { + const lendingPool = useContractAddress(lendingPoolAddress) + const client = useQueryClient() + const { address: userAddress } = useAccount() + const chainId = useChainId() + const wagmiConfig = useConfig() + + const config = ensureConfigTypes({ + abi: poolAbi, + address: lendingPool, + functionName: 'setUserUseReserveAsCollateral', + args: [asset, useAsCollateral], + }) + + return useWrite( + { + ...config, + enabled: !!userAddress && enabled, + }, + { + onTransactionSettled: async () => { + void client.invalidateQueries({ + queryKey: aaveDataLayer({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + + onTransactionSettled?.() + }, + }, + ) +} diff --git a/packages/app/src/domain/market-operations/useSetUserEMode.test.ts b/packages/app/src/domain/market-operations/useSetUserEMode.test.ts new file mode 100644 index 000000000..9ca161806 --- /dev/null +++ b/packages/app/src/domain/market-operations/useSetUserEMode.test.ts @@ -0,0 +1,70 @@ +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' + +import { poolAbi } from '@/config/abis/poolAbi' +import { lendingPoolAddress } from '@/config/contracts-generated' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { useSetUserEMode } from './useSetUserEMode' + +const account = testAddresses.alice + +const initialArgs = { + categoryId: 0, +} + +const hookRenderer = setupHookRenderer({ + hook: useSetUserEMode, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id })], + args: initialArgs, +}) + +describe(useSetUserEMode.name, () => { + it('is not enabled for guest', async () => { + const { result } = hookRenderer({ account: undefined }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled when explicitly disabled', async () => { + const { result } = hookRenderer({ args: { ...initialArgs, enabled: false } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('changes eMode category', async () => { + const { result } = hookRenderer({ + args: { + categoryId: 1, + }, + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'setUserEMode', + args: [1], + from: account, + result: undefined, + }), + handlers.mineTransaction(), + ], + }) + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) +}) diff --git a/packages/app/src/domain/market-operations/useSetUserEMode.ts b/packages/app/src/domain/market-operations/useSetUserEMode.ts new file mode 100644 index 000000000..86cba52f2 --- /dev/null +++ b/packages/app/src/domain/market-operations/useSetUserEMode.ts @@ -0,0 +1,49 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { poolAbi } from '@/config/abis/poolAbi' +import { lendingPoolAddress } from '@/config/contracts-generated' +import { useContractAddress } from '@/domain/hooks/useContractAddress' +import { ensureConfigTypes, useWrite } from '@/domain/hooks/useWrite' +import { aaveDataLayer } from '@/domain/market-info/aave-data-layer/query' + +export interface UseSetUserEModeParams { + categoryId: number + enabled?: boolean + onTransactionSettled?: () => void +} + +export function useSetUserEMode({ + categoryId, + enabled = true, + onTransactionSettled, +}: UseSetUserEModeParams): ReturnType { + const lendingPool = useContractAddress(lendingPoolAddress) + const client = useQueryClient() + const wagmiConfig = useConfig() + const chainId = useChainId() + const { address } = useAccount() + + const config = ensureConfigTypes({ + abi: poolAbi, + address: lendingPool, + functionName: 'setUserEMode', + args: [categoryId], + }) + + return useWrite( + { + ...config, + enabled: !!address && enabled, + }, + { + onTransactionSettled: async () => { + void client.invalidateQueries({ + queryKey: aaveDataLayer({ wagmiConfig, chainId, account: address }).queryKey, + }) + + onTransactionSettled?.() + }, + }, + ) +} diff --git a/packages/app/src/domain/market-operations/useWithdraw.test.ts b/packages/app/src/domain/market-operations/useWithdraw.test.ts new file mode 100644 index 000000000..482762920 --- /dev/null +++ b/packages/app/src/domain/market-operations/useWithdraw.test.ts @@ -0,0 +1,135 @@ +import { waitFor } from '@testing-library/react' +import { mainnet } from 'viem/chains' + +import { poolAbi } from '@/config/abis/poolAbi' +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayAbi, wethGatewayAddress } from '@/config/contracts-generated' +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { testAddresses } from '@/test/integration/constants' +import { handlers } from '@/test/integration/mockTransport' +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' +import { toBigInt } from '@/utils/bigNumber' + +import { useWithdraw } from './useWithdraw' + +const asset = testAddresses.token +const account = testAddresses.alice +const value = BaseUnitNumber(1) + +const hookRenderer = setupHookRenderer({ + hook: useWithdraw, + account, + handlers: [handlers.chainIdCall({ chainId: mainnet.id }), handlers.balanceCall({ balance: 0n, address: account })], + args: { + value, + asset, + }, +}) + +describe(useWithdraw.name, () => { + it('is not enabled for guest ', async () => { + const { result } = hookRenderer({ account: undefined }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled for 0 value', async () => { + const { result } = hookRenderer({ args: { asset, value: BaseUnitNumber(0) } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('is not enabled when explicitly disabled', async () => { + const { result } = hookRenderer({ args: { asset, value, enabled: false } }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('disabled') + }) + }) + + it('withdraws native asset', async () => { + const { result } = hookRenderer({ + args: { + asset: NATIVE_ASSET_MOCK_ADDRESS, + value, + }, + extraHandlers: [ + handlers.contractCall({ + to: wethGatewayAddress[mainnet.id], + abi: wethGatewayAbi, + functionName: 'withdrawETH', + args: [lendingPoolAddress[mainnet.id], toBigInt(value), account], + from: account, + result: undefined, + }), + handlers.mineTransaction(), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('withdraws non native asset', async () => { + const { result } = hookRenderer({ + args: { + asset, + value, + }, + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'withdraw', + args: [asset, toBigInt(value), account], + result: toBigInt(value), + from: account, + }), + handlers.mineTransaction(), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + + result.current.write() + + await waitFor(() => { + expect(result.current.status.kind).toBe('success') + }) + }) + + it('uses proper config if other asset', async () => { + const { result } = hookRenderer({ + extraHandlers: [ + handlers.contractCall({ + to: lendingPoolAddress[mainnet.id], + abi: poolAbi, + functionName: 'withdraw', + args: [asset, toBigInt(value), account], + result: toBigInt(value), + from: account, + }), + ], + }) + + await waitFor(() => { + expect(result.current.status.kind).toBe('ready') + }) + expect((result.current as any).error).toBeUndefined() + }) +}) diff --git a/packages/app/src/domain/market-operations/useWithdraw.ts b/packages/app/src/domain/market-operations/useWithdraw.ts new file mode 100644 index 000000000..105a7e1df --- /dev/null +++ b/packages/app/src/domain/market-operations/useWithdraw.ts @@ -0,0 +1,77 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Address } from 'viem' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { poolAbi } from '@/config/abis/poolAbi' +import { NATIVE_ASSET_MOCK_ADDRESS } from '@/config/consts' +import { lendingPoolAddress, wethGatewayConfig } from '@/config/contracts-generated' +import { toBigInt } from '@/utils/bigNumber' + +import { useContractAddress } from '../hooks/useContractAddress' +import { ensureConfigTypes, useWrite } from '../hooks/useWrite' +import { aaveDataLayer } from '../market-info/aave-data-layer/query' +import { BaseUnitNumber } from '../types/NumericValues' +import { balances } from '../wallet/balances' +import { allowance } from './allowance/query' + +export type UseWithdrawArgs = { + asset: Address + value: BaseUnitNumber + onTransactionSettled?: () => void + enabled?: boolean +} + +export function useWithdraw({ + asset, + value: _value, + onTransactionSettled, + enabled = true, +}: UseWithdrawArgs): ReturnType { + const client = useQueryClient() + + const { address: userAddress } = useAccount() + const chainId = useChainId() + + const value = toBigInt(_value) + const lendingPool = useContractAddress(lendingPoolAddress) + const wethGateway = useContractAddress(wethGatewayConfig.address) + const wagmiConfig = useConfig() + + const config = + asset === NATIVE_ASSET_MOCK_ADDRESS + ? ensureConfigTypes({ + address: wethGateway, + abi: wethGatewayConfig.abi, + functionName: 'withdrawETH', + args: [lendingPool, value, userAddress!], + }) + : ensureConfigTypes({ + abi: poolAbi, + address: lendingPool, + functionName: 'withdraw', + args: [asset, value, userAddress!], + }) + + return useWrite( + { + ...config, + enabled: !!userAddress && value > 0n && enabled, + }, + { + onTransactionSettled: async () => { + void client.invalidateQueries({ + queryKey: aaveDataLayer({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: balances({ wagmiConfig, chainId, account: userAddress }).queryKey, + }) + void client.invalidateQueries({ + queryKey: allowance({ wagmiConfig, chainId, token: asset, account: userAddress!, spender: lendingPool }) + .queryKey, + }) + + onTransactionSettled?.() + }, + }, + ) +} diff --git a/packages/app/src/domain/market-validators/validateBorrow.test.ts b/packages/app/src/domain/market-validators/validateBorrow.test.ts new file mode 100644 index 000000000..e940c8e31 --- /dev/null +++ b/packages/app/src/domain/market-validators/validateBorrow.test.ts @@ -0,0 +1,310 @@ +import { testAddresses } from '@/test/integration/constants' + +import { NormalizedUnitNumber } from '../types/NumericValues' +import { validateBorrow } from './validateBorrow' + +describe(validateBorrow.name, () => { + it('validates if the value is positive', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(0), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(0), + isInSiloMode: false, + inIsolationMode: false, + eModeCategory: 0, + }, + }), + ).toStrictEqual('value-not-positive') + }) + + it('validates if the asset is active', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(100), + asset: { + address: testAddresses.token, + status: 'frozen', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(0), + isInSiloMode: false, + inIsolationMode: false, + eModeCategory: 0, + }, + }), + ).toStrictEqual('reserve-not-active') + }) + + it('validates if the asset is borrowable', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(100), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: false, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(0), + isInSiloMode: false, + inIsolationMode: false, + eModeCategory: 0, + }, + }), + ).toStrictEqual('reserve-borrowing-disabled') + }) + + it('validates liquidity', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(101), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(200), + totalBorrowedUSD: NormalizedUnitNumber(0), + isInSiloMode: false, + inIsolationMode: false, + eModeCategory: 0, + }, + }), + ).toStrictEqual('exceeds-liquidity') + }) + + it('validates collateralization', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(101), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(101), + isSiloed: false, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(0), + isInSiloMode: false, + inIsolationMode: false, + eModeCategory: 0, + }, + }), + ).toStrictEqual('insufficient-collateral') + }) + + it('if borrowing a siloed asset, validates that it is the first asset to borrow', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(100), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: true, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(10), + isInSiloMode: false, + inIsolationMode: false, + eModeCategory: 0, + }, + }), + ).toStrictEqual('siloed-mode-cannot-enable') + }) + + it('if in silo mode, validates against borrowing anything else', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(100), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: true, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(10), + isInSiloMode: true, + siloModeAsset: testAddresses.token2, + inIsolationMode: false, + eModeCategory: 0, + }, + }), + ).toStrictEqual('siloed-mode-enabled') + }) + + describe('isolation mode', () => { + it('if user in isolation mode, validates against borrowing something not available in isolation mode', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(100), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: false, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(10), + isInSiloMode: false, + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(800), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(1_000), + eModeCategory: 0, + }, + }), + ).toStrictEqual('asset-not-borrowable-in-isolation') + }) + + it('if user in isolation mode, validates against borrowing above debt ceiling', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(21), + + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: true, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(10), + isInSiloMode: false, + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(80), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(100), + eModeCategory: 0, + }, + }), + ).toStrictEqual('isolation-mode-debt-ceiling-exceeded') + }) + + it('if user in isolation mode, can match debt ceiling', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(20), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: true, + eModeCategory: 0, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(10), + isInSiloMode: false, + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(80), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(100), + eModeCategory: 0, + }, + }), + ).toStrictEqual(undefined) + }) + }) + + describe('eMode', () => { + it('if user eMode category is 0, asset category doesnt matter', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(20), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: true, + eModeCategory: 1, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(10), + isInSiloMode: false, + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(80), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(100), + eModeCategory: 0, + }, + }), + ).toStrictEqual(undefined) + }) + + it('validates if eMode category is consistent', () => { + expect( + validateBorrow({ + value: NormalizedUnitNumber(20), + asset: { + address: testAddresses.token, + status: 'active', + borrowingEnabled: true, + availableLiquidity: NormalizedUnitNumber(100), + isSiloed: false, + borrowableInIsolation: true, + eModeCategory: 2, + }, + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber(100), + totalBorrowedUSD: NormalizedUnitNumber(10), + isInSiloMode: false, + inIsolationMode: true, + isolationModeCollateralTotalDebt: NormalizedUnitNumber(80), + isolationModeCollateralDebtCeiling: NormalizedUnitNumber(100), + eModeCategory: 1, + }, + }), + ).toStrictEqual('emode-category-mismatch') + }) + }) +}) diff --git a/packages/app/src/domain/market-validators/validateBorrow.ts b/packages/app/src/domain/market-validators/validateBorrow.ts new file mode 100644 index 000000000..ab844c0dc --- /dev/null +++ b/packages/app/src/domain/market-validators/validateBorrow.ts @@ -0,0 +1,179 @@ +import invariant from 'tiny-invariant' + +import { calculateMaxBorrowBasedOnCollateral } from '../action-max-value-getters/calculateMaxBorrowBasedOnCollateral' +import { MarketInfo, Reserve, UserPositionSummary } from '../market-info/marketInfo' +import { ReserveStatus } from '../market-info/reserve-status' +import { CheckedAddress } from '../types/CheckedAddress' +import { NormalizedUnitNumber } from '../types/NumericValues' + +export interface ValidateBorrowParams { + value: NormalizedUnitNumber + + asset: { + address: CheckedAddress + status: ReserveStatus + borrowingEnabled: boolean + availableLiquidity: NormalizedUnitNumber + isSiloed: boolean + borrowableInIsolation: boolean + eModeCategory: number + } + + user: { + maxBorrowBasedOnCollateral: NormalizedUnitNumber + totalBorrowedUSD: NormalizedUnitNumber + isInSiloMode: boolean + siloModeAsset?: CheckedAddress + inIsolationMode: boolean + isolationModeCollateralTotalDebt?: NormalizedUnitNumber + isolationModeCollateralDebtCeiling?: NormalizedUnitNumber + eModeCategory: number + } +} + +export type BorrowValidationIssue = + | 'value-not-positive' + | 'reserve-not-active' + | 'reserve-borrowing-disabled' + | 'exceeds-liquidity' + | 'insufficient-collateral' + | 'siloed-mode-cannot-enable' + | 'siloed-mode-enabled' + | 'asset-not-borrowable-in-isolation' + | 'isolation-mode-debt-ceiling-exceeded' + | 'emode-category-mismatch' + +export function validateBorrow({ + value, + asset: { + address, + status, + borrowingEnabled, + availableLiquidity, + isSiloed, + borrowableInIsolation, + eModeCategory: assetEModeCategory, + }, + user: { + maxBorrowBasedOnCollateral, + totalBorrowedUSD, + isInSiloMode, + siloModeAsset, + inIsolationMode, + isolationModeCollateralTotalDebt, + isolationModeCollateralDebtCeiling, + eModeCategory: userEModeCategory, + }, +}: ValidateBorrowParams): BorrowValidationIssue | undefined { + const borrowedAnythingBefore = !totalBorrowedUSD.isEqualTo(0) + + if (value.isLessThanOrEqualTo(0)) { + return 'value-not-positive' + } + + if (status !== 'active') { + return 'reserve-not-active' + } + + if (!borrowingEnabled) { + return 'reserve-borrowing-disabled' + } + + if (availableLiquidity.lt(value)) { + return 'exceeds-liquidity' + } + + if (value.gt(maxBorrowBasedOnCollateral)) { + return 'insufficient-collateral' + } + + if (inIsolationMode) { + if (!borrowableInIsolation) { + return 'asset-not-borrowable-in-isolation' + } + + invariant( + isolationModeCollateralTotalDebt && isolationModeCollateralDebtCeiling, + 'Collateral total debt, ceiling and decimals should be defined', + ) + + if (isolationModeCollateralTotalDebt.plus(value).gt(isolationModeCollateralDebtCeiling)) { + return 'isolation-mode-debt-ceiling-exceeded' + } + } + + if (userEModeCategory !== 0 && userEModeCategory !== assetEModeCategory) { + return 'emode-category-mismatch' + } + + if (borrowedAnythingBefore) { + if (isInSiloMode) { + if (address !== siloModeAsset) { + return 'siloed-mode-enabled' + } + } else { + if (isSiloed) { + return 'siloed-mode-cannot-enable' + } + } + } +} + +export const borrowValidationIssueToMessage: Record = { + 'value-not-positive': 'Borrow value should be positive', + 'exceeds-liquidity': 'Borrow value exceeds liquidity', + 'insufficient-collateral': 'Not enough collateral to borrow this amount', + 'siloed-mode-enabled': 'Siloed borrowing enabled. Borrowing other assets is not allowed.', + 'siloed-mode-cannot-enable': + "Borrowing asset is siloed. Can't add it to position with other assets already borrowed.", + 'reserve-not-active': 'Borrowing is not available for this asset', + 'reserve-borrowing-disabled': 'Borrowing is not available for this asset', + 'asset-not-borrowable-in-isolation': 'Borrowing is not available for this asset in isolation mode', + 'isolation-mode-debt-ceiling-exceeded': 'Borrowing exceeds isolation mode debt ceiling', + 'emode-category-mismatch': 'Asset and user eMode categories do not match', +} + +export function getValidateBorrowArgs( + value: NormalizedUnitNumber, + reserve: Reserve, + marketInfo: MarketInfo, + _userSummary?: UserPositionSummary, +): ValidateBorrowParams { + const userSummary = _userSummary ?? marketInfo.userPositionSummary + + return { + value, + asset: { + address: reserve.token.address, + status: reserve.status, + borrowingEnabled: reserve.borrowEligibilityStatus !== 'no', + availableLiquidity: reserve.availableLiquidity, + isSiloed: reserve.isSiloedBorrowing, + borrowableInIsolation: reserve.isBorrowableInIsolation, + eModeCategory: reserve.eModeCategory?.id ?? 0, + }, + user: { + maxBorrowBasedOnCollateral: calculateMaxBorrowBasedOnCollateral({ + borrowingAssetPriceUsd: reserve.token.unitPriceUsd, + totalCollateralUSD: userSummary.totalCollateralUSD, + maxLoanToValue: userSummary.maxLoanToValue, + totalBorrowsUSD: marketInfo.userPositionSummary.totalBorrowsUSD, + }), + totalBorrowedUSD: userSummary.totalBorrowsUSD, + isInSiloMode: marketInfo.userConfiguration.siloBorrowingState.enabled, + siloModeAsset: marketInfo.userConfiguration.siloBorrowingState.enabled + ? marketInfo.userConfiguration.siloBorrowingState.siloedBorrowingReserve.token.address + : undefined, + inIsolationMode: marketInfo.userConfiguration.isolationModeState.enabled, + isolationModeCollateralTotalDebt: marketInfo.userConfiguration.isolationModeState.enabled + ? marketInfo.userConfiguration.isolationModeState.isolatedBorrowingReserve.isolationModeTotalDebt + : undefined, + isolationModeCollateralDebtCeiling: marketInfo.userConfiguration.isolationModeState.enabled + ? marketInfo.userConfiguration.isolationModeState.isolatedBorrowingReserve.debtCeiling + : undefined, + eModeCategory: marketInfo.userConfiguration.eModeState.enabled + ? marketInfo.userConfiguration.eModeState.category.id + : 0, + }, + } +} diff --git a/packages/app/src/domain/market-validators/validateDeposit.test.ts b/packages/app/src/domain/market-validators/validateDeposit.test.ts new file mode 100644 index 000000000..4b2df0a83 --- /dev/null +++ b/packages/app/src/domain/market-validators/validateDeposit.test.ts @@ -0,0 +1,64 @@ +import { NormalizedUnitNumber } from '../types/NumericValues' +import { validateDeposit } from './validateDeposit' + +describe(validateDeposit.name, () => { + it('validates that value is positive', () => { + expect( + validateDeposit({ + value: NormalizedUnitNumber(0), + asset: { status: 'active', totalLiquidity: NormalizedUnitNumber(100) }, + user: { balance: NormalizedUnitNumber(10), alreadyDepositedValueUSD: NormalizedUnitNumber(0) }, + }), + ).toBe('value-not-positive') + }) + + it('works with 0 value if deposited already', () => { + expect( + validateDeposit({ + value: NormalizedUnitNumber(0), + asset: { status: 'active', totalLiquidity: NormalizedUnitNumber(100) }, + user: { balance: NormalizedUnitNumber(10), alreadyDepositedValueUSD: NormalizedUnitNumber(10) }, + }), + ).toBe(undefined) + }) + + it('validates that reserve is active', () => { + expect( + validateDeposit({ + value: NormalizedUnitNumber(10), + asset: { status: 'frozen', totalLiquidity: NormalizedUnitNumber(100) }, + user: { balance: NormalizedUnitNumber(10), alreadyDepositedValueUSD: NormalizedUnitNumber(0) }, + }), + ).toBe('reserve-not-active') + }) + + it('validates balance', () => { + expect( + validateDeposit({ + value: NormalizedUnitNumber(10), + asset: { status: 'active', totalLiquidity: NormalizedUnitNumber(100) }, + user: { balance: NormalizedUnitNumber(1), alreadyDepositedValueUSD: NormalizedUnitNumber(0) }, + }), + ).toBe('exceeds-balance') + }) + + it('validates deposit cap', () => { + expect( + validateDeposit({ + value: NormalizedUnitNumber(1), + asset: { status: 'active', totalLiquidity: NormalizedUnitNumber(1), supplyCap: NormalizedUnitNumber(1) }, + user: { balance: NormalizedUnitNumber(10), alreadyDepositedValueUSD: NormalizedUnitNumber(0) }, + }), + ).toBe('deposit-cap-reached') + }) + + it('works when no errors', () => { + expect( + validateDeposit({ + value: NormalizedUnitNumber(1), + asset: { status: 'active', totalLiquidity: NormalizedUnitNumber(1) }, + user: { balance: NormalizedUnitNumber(10), alreadyDepositedValueUSD: NormalizedUnitNumber(0) }, + }), + ).toBe(undefined) + }) +}) diff --git a/packages/app/src/domain/market-validators/validateDeposit.ts b/packages/app/src/domain/market-validators/validateDeposit.ts new file mode 100644 index 000000000..8a47c6cf5 --- /dev/null +++ b/packages/app/src/domain/market-validators/validateDeposit.ts @@ -0,0 +1,50 @@ +import { ReserveStatus } from '../market-info/reserve-status' +import { NormalizedUnitNumber } from '../types/NumericValues' + +export type DepositValidationIssue = + | 'value-not-positive' + | 'exceeds-balance' + | 'deposit-cap-reached' + | 'reserve-not-active' + +export interface ValidateDepositArgs { + value: NormalizedUnitNumber + asset: { + status: ReserveStatus + totalLiquidity: NormalizedUnitNumber + supplyCap?: NormalizedUnitNumber + } + user: { + balance: NormalizedUnitNumber + alreadyDepositedValueUSD: NormalizedUnitNumber + } +} + +export function validateDeposit({ + value, + asset: { status, totalLiquidity, supplyCap }, + user: { balance, alreadyDepositedValueUSD }, +}: ValidateDepositArgs): DepositValidationIssue | undefined { + if (value.isLessThanOrEqualTo(0) && alreadyDepositedValueUSD.eq(0)) { + return 'value-not-positive' + } + + if (status !== 'active') { + return 'reserve-not-active' + } + + if (balance.lt(value)) { + return 'exceeds-balance' + } + + if (supplyCap && value.plus(totalLiquidity).gt(supplyCap)) { + return 'deposit-cap-reached' + } +} + +export const depositValidationIssueToMessage: Record = { + 'value-not-positive': 'Deposit value should be positive', + 'reserve-not-active': 'Depositing is not available for this asset', + 'deposit-cap-reached': 'Deposit cap reached', + 'exceeds-balance': 'Exceeds your balance', +} diff --git a/packages/app/src/domain/market-validators/validateRepay.test.ts b/packages/app/src/domain/market-validators/validateRepay.test.ts new file mode 100644 index 000000000..d37df1fce --- /dev/null +++ b/packages/app/src/domain/market-validators/validateRepay.test.ts @@ -0,0 +1,74 @@ +import { NormalizedUnitNumber } from '../types/NumericValues' +import { validateRepay } from './validateRepay' + +describe(validateRepay.name, () => { + it('validates that value is positive', () => { + expect( + validateRepay({ + value: NormalizedUnitNumber(0), + asset: { status: 'active' }, + user: { debt: NormalizedUnitNumber(10), balance: NormalizedUnitNumber(10) }, + }), + ).toBe('value-not-positive') + }) + + it('works with active reserves', () => { + expect( + validateRepay({ + value: NormalizedUnitNumber(10), + asset: { status: 'frozen' }, + user: { debt: NormalizedUnitNumber(10), balance: NormalizedUnitNumber(10) }, + }), + ).toBe(undefined) + }) + + it('works with frozen reserves', () => { + expect( + validateRepay({ + value: NormalizedUnitNumber(10), + asset: { status: 'frozen' }, + user: { debt: NormalizedUnitNumber(10), balance: NormalizedUnitNumber(10) }, + }), + ).toBe(undefined) + }) + + it('validates that reserve is not paused', () => { + expect( + validateRepay({ + value: NormalizedUnitNumber(10), + asset: { status: 'paused' }, + user: { debt: NormalizedUnitNumber(10), balance: NormalizedUnitNumber(10) }, + }), + ).toBe('reserve-paused') + }) + + it('validates that reserve is active', () => { + expect( + validateRepay({ + value: NormalizedUnitNumber(10), + asset: { status: 'not-active' }, + user: { debt: NormalizedUnitNumber(10), balance: NormalizedUnitNumber(10) }, + }), + ).toBe('reserve-not-active') + }) + + it('validates debt', () => { + expect( + validateRepay({ + value: NormalizedUnitNumber(10), + asset: { status: 'active' }, + user: { debt: NormalizedUnitNumber(1), balance: NormalizedUnitNumber(10) }, + }), + ).toBe('exceeds-debt') + }) + + it('validates balance', () => { + expect( + validateRepay({ + value: NormalizedUnitNumber(10), + asset: { status: 'active' }, + user: { debt: NormalizedUnitNumber(10), balance: NormalizedUnitNumber(1) }, + }), + ).toBe('exceeds-balance') + }) +}) diff --git a/packages/app/src/domain/market-validators/validateRepay.ts b/packages/app/src/domain/market-validators/validateRepay.ts new file mode 100644 index 000000000..be34a716f --- /dev/null +++ b/packages/app/src/domain/market-validators/validateRepay.ts @@ -0,0 +1,54 @@ +import { ReserveStatus } from '../market-info/reserve-status' +import { NormalizedUnitNumber } from '../types/NumericValues' + +export type RepayValidationIssue = + | 'value-not-positive' + | 'exceeds-debt' + | 'exceeds-balance' + | 'reserve-paused' + | 'reserve-not-active' + +export interface ValidateRepayArgs { + value: NormalizedUnitNumber + asset: { + status: ReserveStatus + } + user: { + debt: NormalizedUnitNumber + balance: NormalizedUnitNumber + } +} + +export function validateRepay({ + value, + asset: { status }, + user: { debt, balance }, +}: ValidateRepayArgs): RepayValidationIssue | undefined { + if (value.isLessThanOrEqualTo(0)) { + return 'value-not-positive' + } + + if (status === 'not-active') { + return 'reserve-not-active' + } + + if (status === 'paused') { + return 'reserve-paused' + } + + if (debt.lt(value)) { + return 'exceeds-debt' + } + + if (balance.lt(value)) { + return 'exceeds-balance' + } +} + +export const repayValidationIssueToMessage: Record = { + 'value-not-positive': 'Repay value should be positive', + 'reserve-paused': 'Reserve is paused', + 'reserve-not-active': 'Reserve is not active', + 'exceeds-debt': 'Exceeds your debt', + 'exceeds-balance': 'Exceeds your balance', +} diff --git a/packages/app/src/domain/market-validators/validateSetUseAsCollateral.test.ts b/packages/app/src/domain/market-validators/validateSetUseAsCollateral.test.ts new file mode 100644 index 000000000..7fa6ef58d --- /dev/null +++ b/packages/app/src/domain/market-validators/validateSetUseAsCollateral.test.ts @@ -0,0 +1,179 @@ +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { validateSetUseAsCollateral, ValidateSetUseAsCollateralParams } from './validateSetUseAsCollateral' + +describe(validateSetUseAsCollateral.name, () => { + it('validates that value is not the same as existing setting', () => { + expect( + validateSetUseAsCollateral({ + useAsCollateral: true, + asset: { + balance: NormalizedUnitNumber(0), + status: 'active', + isUsedAsCollateral: true, + maxLtv: Percentage(0.8), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(2), + inIsolationMode: false, + hasZeroLtvAssetsInCollateral: false, + }, + }), + ).toBe('collateral-already-enabled') + + expect( + validateSetUseAsCollateral({ + useAsCollateral: false, + asset: { + balance: NormalizedUnitNumber(0), + status: 'active', + isUsedAsCollateral: false, + maxLtv: Percentage(0.8), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(2), + inIsolationMode: false, + hasZeroLtvAssetsInCollateral: false, + }, + }), + ).toBe('collateral-already-disabled') + }) + + it('validates that asset balance is positive', () => { + expect( + validateSetUseAsCollateral({ + useAsCollateral: true, + asset: { + balance: NormalizedUnitNumber(0), + status: 'active', + isUsedAsCollateral: false, + maxLtv: Percentage(0.8), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(2), + inIsolationMode: false, + hasZeroLtvAssetsInCollateral: false, + }, + }), + ).toBe('zero-balance-asset') + }) + + it('validates that reserve is active', () => { + const args: ValidateSetUseAsCollateralParams = { + useAsCollateral: true, + asset: { + balance: NormalizedUnitNumber(10), + status: 'not-active', + isUsedAsCollateral: false, + maxLtv: Percentage(0.8), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(2), + inIsolationMode: false, + hasZeroLtvAssetsInCollateral: false, + }, + } + + expect( + validateSetUseAsCollateral({ + ...args, + asset: { ...args.asset, status: 'not-active' }, + }), + ).toBe('reserve-not-active') + + expect( + validateSetUseAsCollateral({ + ...args, + asset: { ...args.asset, status: 'paused' }, + }), + ).toBe('reserve-not-active') + + expect( + validateSetUseAsCollateral({ + ...args, + asset: { ...args.asset, status: 'frozen' }, + }), + ).toBe('reserve-not-active') + }) + + describe('enabling collateral', () => { + it('validates that maxLtv is not zero', () => { + expect( + validateSetUseAsCollateral({ + useAsCollateral: true, + asset: { + balance: NormalizedUnitNumber(10), + status: 'active', + isUsedAsCollateral: false, + maxLtv: Percentage(0), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(2), + inIsolationMode: false, + hasZeroLtvAssetsInCollateral: false, + }, + }), + ).toBe('zero-ltv-asset') + }) + + it('validates that isolation mode is not active', () => { + expect( + validateSetUseAsCollateral({ + useAsCollateral: true, + asset: { + balance: NormalizedUnitNumber(10), + status: 'active', + isUsedAsCollateral: false, + maxLtv: Percentage(0.8), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(2), + inIsolationMode: true, + hasZeroLtvAssetsInCollateral: false, + }, + }), + ).toBe('isolation-mode-active') + }) + }) + + describe('disabling collateral', () => { + it('validates that ltv is not exceeded', () => { + expect( + validateSetUseAsCollateral({ + useAsCollateral: false, + asset: { + balance: NormalizedUnitNumber(10), + status: 'active', + isUsedAsCollateral: true, + maxLtv: Percentage(0.8), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(0.9), + inIsolationMode: false, + hasZeroLtvAssetsInCollateral: false, + }, + }), + ).toBe('exceeds-ltv') + }) + + it('validates that there are no zero ltv assets used as collateral', () => { + expect( + validateSetUseAsCollateral({ + useAsCollateral: false, + asset: { + balance: NormalizedUnitNumber(10), + status: 'active', + isUsedAsCollateral: true, + maxLtv: Percentage(0.8), + }, + user: { + healthFactorAfterWithdrawal: new BigNumber(2), + inIsolationMode: false, + hasZeroLtvAssetsInCollateral: true, + }, + }), + ).toBe('zero-ltv-assets-in-collateral') + }) + }) +}) diff --git a/packages/app/src/domain/market-validators/validateSetUseAsCollateral.ts b/packages/app/src/domain/market-validators/validateSetUseAsCollateral.ts new file mode 100644 index 000000000..de3985cd2 --- /dev/null +++ b/packages/app/src/domain/market-validators/validateSetUseAsCollateral.ts @@ -0,0 +1,120 @@ +import BigNumber from 'bignumber.js' + +import { MarketInfo } from '../market-info/marketInfo' +import { ReserveStatus } from '../market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { Token } from '../types/Token' + +export interface ValidateSetUseAsCollateralParams { + useAsCollateral: boolean + asset: { + balance: NormalizedUnitNumber + status: ReserveStatus + isUsedAsCollateral: boolean + maxLtv: Percentage + } + user: { + healthFactorAfterWithdrawal?: BigNumber + inIsolationMode: boolean + hasZeroLtvAssetsInCollateral: boolean + } +} + +export type SetUseAsCollateralValidationIssue = + | 'collateral-already-enabled' + | 'collateral-already-disabled' + | 'zero-balance-asset' + | 'reserve-not-active' + | 'zero-ltv-asset' + | 'isolation-mode-active' + | 'exceeds-ltv' + | 'zero-ltv-assets-in-collateral' + +export function validateSetUseAsCollateral({ + useAsCollateral, + asset, + user, +}: ValidateSetUseAsCollateralParams): SetUseAsCollateralValidationIssue | undefined { + if (useAsCollateral === asset.isUsedAsCollateral) { + if (useAsCollateral) { + return 'collateral-already-enabled' + } + return 'collateral-already-disabled' + } + + if (asset.balance.isZero()) { + return 'zero-balance-asset' + } + + if (asset.status !== 'active') { + return 'reserve-not-active' + } + + // Enabling collateral + if (useAsCollateral) { + // @note: zero ltv means collateralizing is disabled + if (asset.maxLtv.isZero()) { + return 'zero-ltv-asset' + } + if (user.inIsolationMode) { + return 'isolation-mode-active' + } + } + // Disabling collateral + else { + // @todo: use actual LTV instead of health factor + if (user.healthFactorAfterWithdrawal && user.healthFactorAfterWithdrawal.lt(1)) { + return 'exceeds-ltv' + } + if (user.hasZeroLtvAssetsInCollateral && asset.maxLtv.isGreaterThan(0)) { + return 'zero-ltv-assets-in-collateral' + } + } +} + +export const setUseAsCollateralValidationIssueToMessage: Record = { + 'collateral-already-enabled': 'Collateral setting for this asset is already enabled', + 'collateral-already-disabled': 'Collateral setting for this asset is already disabled', + 'zero-balance-asset': 'Cannot use zero balance asset as collateral', + 'reserve-not-active': 'Cannot change collateral setting for inactive reserve', + 'zero-ltv-asset': 'This asset cannot be used as collateral', + 'isolation-mode-active': 'Cannot use other asset as collateral when in isolation mode', + 'exceeds-ltv': 'Disabling this collateral would cause liquidation call', + 'zero-ltv-assets-in-collateral': 'Cannot disable this collateral because other assets already have zero LTV', +} + +export interface GetValidateSetUseAsCollateralArgsParams { + useAsCollateral: boolean + collateral: Token + marketInfo: MarketInfo + healthFactorAfterWithdrawal?: BigNumber +} +export function getValidateSetUseAsCollateralArgs({ + useAsCollateral, + collateral, + marketInfo, + healthFactorAfterWithdrawal, +}: GetValidateSetUseAsCollateralArgsParams): ValidateSetUseAsCollateralParams { + const collateralPosition = marketInfo.findOnePositionByToken(collateral) + const hasZeroLtvAssetsInCollateral = marketInfo.userPositions.some( + (position) => + position.reserve.usageAsCollateralEnabledOnUser && + position.collateralBalance.gt(0) && + position.reserve.maxLtv.isZero(), + ) + + return { + useAsCollateral, + asset: { + balance: collateralPosition.collateralBalance, + status: collateralPosition.reserve.status, + isUsedAsCollateral: !useAsCollateral, + maxLtv: collateralPosition.reserve.maxLtv, + }, + user: { + healthFactorAfterWithdrawal, + hasZeroLtvAssetsInCollateral, + inIsolationMode: marketInfo.userConfiguration.isolationModeState.enabled, + }, + } +} diff --git a/packages/app/src/domain/market-validators/validateSetUserEMode.test.ts b/packages/app/src/domain/market-validators/validateSetUserEMode.test.ts new file mode 100644 index 000000000..065255cd3 --- /dev/null +++ b/packages/app/src/domain/market-validators/validateSetUserEMode.test.ts @@ -0,0 +1,141 @@ +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { validateSetUserEMode } from './validateSetUserEMode' + +describe(validateSetUserEMode.name, () => { + describe('returns validation issue', () => { + it('validates that requested eMode category has correct liquidation threshold', () => { + expect( + validateSetUserEMode({ + requestedEModeCategory: { + id: 1, + liquidationThreshold: Percentage(0), + }, + user: { + eModeCategoryId: 0, + reserves: [], + }, + }), + ).toBe('inconsistent-liquidation-threshold') + }) + + it('validates that user has no borrows with different eMode category', () => { + const requestedEModeCategory = { + id: 1, + liquidationThreshold: Percentage(0.1), + } + + expect( + validateSetUserEMode({ + requestedEModeCategory, + user: { + eModeCategoryId: 0, + reserves: [{ eModeCategoryId: 2, borrowBalance: NormalizedUnitNumber(1) }], + }, + }), + ).toBe('borrowed-assets-emode-category-mismatch') + + expect( + validateSetUserEMode({ + requestedEModeCategory, + user: { + eModeCategoryId: 0, + reserves: [ + { eModeCategoryId: 1, borrowBalance: NormalizedUnitNumber(2) }, + { eModeCategoryId: 2, borrowBalance: NormalizedUnitNumber(1) }, + ], + }, + }), + ).toBe('borrowed-assets-emode-category-mismatch') + }) + + it('validates that user health factor after changing eMode category is greater than 1', () => { + expect( + validateSetUserEMode({ + requestedEModeCategory: { + id: 2, + liquidationThreshold: Percentage(0.1), + }, + user: { + eModeCategoryId: 1, + reserves: [], + healthFactorAfterChangingEMode: NormalizedUnitNumber(0.9), + }, + }), + ).toBe('exceeds-ltv') + }) + }) + + describe('returns undefined', () => { + it('validates that requested eMode category has correct liquidation threshold', () => { + expect( + validateSetUserEMode({ + requestedEModeCategory: { + id: 1, + liquidationThreshold: Percentage(0.1), + }, + user: { + eModeCategoryId: 0, + reserves: [], + }, + }), + ).toBe(undefined) + }) + + it('validates that user has no borrows with different eMode category', () => { + const requestedEModeCategory = { + id: 1, + liquidationThreshold: Percentage(0.1), + } + + expect( + validateSetUserEMode({ + requestedEModeCategory, + user: { + eModeCategoryId: 0, + reserves: [ + { eModeCategoryId: 1, borrowBalance: NormalizedUnitNumber(1) }, + { eModeCategoryId: 1, borrowBalance: NormalizedUnitNumber(2) }, + { eModeCategoryId: 2, borrowBalance: NormalizedUnitNumber(0) }, + ], + }, + }), + ).toBe(undefined) + }) + + it('validates when user changes eMode to 0 category', () => { + const requestedEModeCategory = { + id: 0, + liquidationThreshold: undefined, + } + + expect( + validateSetUserEMode({ + requestedEModeCategory, + user: { + eModeCategoryId: 1, + reserves: [ + { eModeCategoryId: 1, borrowBalance: NormalizedUnitNumber(1) }, + { eModeCategoryId: 1, borrowBalance: NormalizedUnitNumber(2) }, + ], + }, + }), + ).toBe(undefined) + }) + + it('validates that user health factor after changing eMode category is greater than 1', () => { + expect( + validateSetUserEMode({ + requestedEModeCategory: { + id: 2, + liquidationThreshold: Percentage(0.1), + }, + user: { + eModeCategoryId: 1, + reserves: [], + healthFactorAfterChangingEMode: NormalizedUnitNumber(1.1), + }, + }), + ).toBe(undefined) + }) + }) +}) diff --git a/packages/app/src/domain/market-validators/validateSetUserEMode.ts b/packages/app/src/domain/market-validators/validateSetUserEMode.ts new file mode 100644 index 000000000..75ceb654e --- /dev/null +++ b/packages/app/src/domain/market-validators/validateSetUserEMode.ts @@ -0,0 +1,93 @@ +import BigNumber from 'bignumber.js' + +import { raise } from '@/utils/raise' + +import { MarketInfo } from '../market-info/marketInfo' +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' + +export interface ValidateSetUserEModeParams { + requestedEModeCategory: { + id: number + liquidationThreshold: Percentage | undefined + } + user: { + eModeCategoryId: number + reserves: { eModeCategoryId?: number; borrowBalance: NormalizedUnitNumber }[] + healthFactorAfterChangingEMode?: BigNumber + } +} + +export type SetUserEModeValidationIssue = + | 'inconsistent-liquidation-threshold' + | 'borrowed-assets-emode-category-mismatch' + | 'exceeds-ltv' + +export function validateSetUserEMode({ + requestedEModeCategory, + user, +}: ValidateSetUserEModeParams): SetUserEModeValidationIssue | undefined { + if (requestedEModeCategory.id !== 0) { + if (requestedEModeCategory.liquidationThreshold?.isZero()) { + return 'inconsistent-liquidation-threshold' + } + + const isBorrowedAssetsEModeCategoryMismatch = user.reserves.some( + (reserve) => reserve.borrowBalance.gt(0) && reserve.eModeCategoryId !== requestedEModeCategory.id, + ) + + if (isBorrowedAssetsEModeCategoryMismatch) { + return 'borrowed-assets-emode-category-mismatch' + } + } + + if (user.eModeCategoryId !== 0) { + if (user.healthFactorAfterChangingEMode && user.healthFactorAfterChangingEMode.isLessThan(1)) { + return 'exceeds-ltv' + } + } +} + +export const setUserEModeValidationIssueToMessage: Record = { + 'inconsistent-liquidation-threshold': 'EMode liquidation threshold must be greater than 0', + 'borrowed-assets-emode-category-mismatch': + 'Cannot change eMode category while having borrows with different eMode category', + 'exceeds-ltv': 'Changing eMode category would result in liquidation call', +} + +export interface GetValidateSetUserEModeArgsParams { + requestedEModeCategoryId: number + marketInfo: MarketInfo + healthFactorAfterChangingEMode?: BigNumber +} +export function getValidateSetUserEModeArgs({ + requestedEModeCategoryId, + marketInfo, + healthFactorAfterChangingEMode, +}: GetValidateSetUserEModeArgsParams): ValidateSetUserEModeParams { + const liquidationThreshold = + requestedEModeCategoryId === 0 + ? undefined + : marketInfo.emodeCategories[requestedEModeCategoryId]?.liquidationThreshold ?? + raise('Requested eMode category not found') + + const userEModeCategoryId = marketInfo.userConfiguration.eModeState.enabled + ? marketInfo.userConfiguration.eModeState.category.id + : 0 + + const reserves = marketInfo.userPositions.map((position) => ({ + eModeCategoryId: position.reserve.eModeCategory?.id, + borrowBalance: position.borrowBalance, + })) + + return { + requestedEModeCategory: { + id: requestedEModeCategoryId, + liquidationThreshold, + }, + user: { + eModeCategoryId: userEModeCategoryId, + reserves, + healthFactorAfterChangingEMode, + }, + } +} diff --git a/packages/app/src/domain/market-validators/validateWithdraw.test.ts b/packages/app/src/domain/market-validators/validateWithdraw.test.ts new file mode 100644 index 000000000..972e44953 --- /dev/null +++ b/packages/app/src/domain/market-validators/validateWithdraw.test.ts @@ -0,0 +1,84 @@ +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' +import { validateWithdraw } from './validateWithdraw' + +describe(validateWithdraw.name, () => { + it('validates that value is positive', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(0), + asset: { status: 'active', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(10), ltvAfterWithdrawal: Percentage(0.5) }, + }), + ).toBe('value-not-positive') + }) + + it('works with active reserves', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(10), + asset: { status: 'frozen', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(10), ltvAfterWithdrawal: Percentage(0.5) }, + }), + ).toBe(undefined) + }) + + it('works with frozen reserves', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(10), + asset: { status: 'frozen', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(10), ltvAfterWithdrawal: Percentage(0.5) }, + }), + ).toBe(undefined) + }) + + it('validates that reserve is not paused', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(10), + asset: { status: 'paused', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(10), ltvAfterWithdrawal: Percentage(0.5) }, + }), + ).toBe('reserve-paused') + }) + + it('validates that reserve is active', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(10), + asset: { status: 'not-active', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(10), ltvAfterWithdrawal: Percentage(0.5) }, + }), + ).toBe('reserve-not-active') + }) + + it('validates balance', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(10), + asset: { status: 'active', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(1), ltvAfterWithdrawal: Percentage(0.5) }, + }), + ).toBe('exceeds-balance') + }) + + it('work with matching balance', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(10), + asset: { status: 'active', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(10), ltvAfterWithdrawal: Percentage(0.5) }, + }), + ).toBe(undefined) + }) + + it('validates health factor', () => { + expect( + validateWithdraw({ + value: NormalizedUnitNumber(10), + asset: { status: 'active', maxLtv: Percentage(0.8) }, + user: { deposited: NormalizedUnitNumber(10), ltvAfterWithdrawal: Percentage(1) }, + }), + ).toBe('exceeds-ltv') + }) +}) diff --git a/packages/app/src/domain/market-validators/validateWithdraw.ts b/packages/app/src/domain/market-validators/validateWithdraw.ts new file mode 100644 index 000000000..98811cbaf --- /dev/null +++ b/packages/app/src/domain/market-validators/validateWithdraw.ts @@ -0,0 +1,55 @@ +import { ReserveStatus } from '../market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '../types/NumericValues' + +export type WithdrawValidationIssue = + | 'value-not-positive' + | 'exceeds-balance' + | 'reserve-paused' + | 'exceeds-ltv' + | 'reserve-not-active' + +export interface ValidateWithdrawArgs { + value: NormalizedUnitNumber + asset: { + status: ReserveStatus + maxLtv: Percentage + } + user: { + deposited: NormalizedUnitNumber + ltvAfterWithdrawal: Percentage + } +} + +export function validateWithdraw({ + value, + asset: { status, maxLtv }, + user: { deposited, ltvAfterWithdrawal }, +}: ValidateWithdrawArgs): WithdrawValidationIssue | undefined { + if (value.isLessThanOrEqualTo(0)) { + return 'value-not-positive' + } + + if (status === 'not-active') { + return 'reserve-not-active' + } + + if (status === 'paused') { + return 'reserve-paused' + } + + if (deposited.lt(value)) { + return 'exceeds-balance' + } + + if (ltvAfterWithdrawal.gt(maxLtv)) { + return 'exceeds-ltv' + } +} + +export const withdrawalValidationIssueToMessage: Record = { + 'value-not-positive': 'Withdraw value should be positive', + 'reserve-paused': 'Reserve is paused', + 'reserve-not-active': 'Reserve is not active', + 'exceeds-balance': 'Exceeds your balance', + 'exceeds-ltv': 'Remaining collateral cannot support the loan', +} diff --git a/packages/app/src/domain/sandbox/createSandboxConnector.ts b/packages/app/src/domain/sandbox/createSandboxConnector.ts new file mode 100644 index 000000000..deb1087e3 --- /dev/null +++ b/packages/app/src/domain/sandbox/createSandboxConnector.ts @@ -0,0 +1,39 @@ +import { createWalletClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { mainnet } from 'viem/chains' +import { CreateConnectorFn } from 'wagmi' + +import { createMockConnector } from '@/domain/wallet/createMockConnector' + +export interface CreateSandboxWalletArgs { + privateKey: `0x${string}` + forkUrl: string + chainName: string + chainId: number +} + +export function createSandboxConnector({ + privateKey, + forkUrl, + chainId, + chainName, +}: CreateSandboxWalletArgs): CreateConnectorFn { + const account = privateKeyToAccount(privateKey) + + const walletClient = createWalletClient({ + transport: http(forkUrl), + chain: { + ...mainnet, + id: chainId, + name: chainName, + rpcUrls: { + default: { + http: [forkUrl], + }, + }, + }, + account, + }) + + return createMockConnector(walletClient) +} diff --git a/packages/app/src/domain/sandbox/createTenderlyFork.ts b/packages/app/src/domain/sandbox/createTenderlyFork.ts new file mode 100644 index 000000000..21f880938 --- /dev/null +++ b/packages/app/src/domain/sandbox/createTenderlyFork.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' + +import { randomHexId } from '@/utils/random' +import { solidFetch } from '@/utils/solidFetch' + +const createForkResponseSchema = z.object({ + simulation_fork: z.object({ + rpc_url: z.string(), + }), +}) + +export interface CreateTenderlyForkArgs { + apiUrl: string + originChainId: number + forkChainId: number + namePrefix: string + blockNumber?: bigint + headers?: Record +} + +export interface CreateTenderlyForkResult { + rpcUrl: string +} + +export async function createTenderlyFork({ + apiUrl, + originChainId, + forkChainId, + namePrefix, + blockNumber, + headers, +}: CreateTenderlyForkArgs): Promise { + const response = await solidFetch(apiUrl, { + method: 'post', + headers, + body: JSON.stringify({ + network_id: originChainId, + block_number: blockNumber ? Number(blockNumber) : undefined, + chain_config: { chain_id: forkChainId }, + alias: `${namePrefix}_${randomHexId()}`, + }), + }) + + const data = createForkResponseSchema.parse(await response.json()) + return { rpcUrl: data.simulation_fork.rpc_url } +} diff --git a/packages/app/src/domain/sandbox/publicTenderlyActions.ts b/packages/app/src/domain/sandbox/publicTenderlyActions.ts new file mode 100644 index 000000000..c06137d12 --- /dev/null +++ b/packages/app/src/domain/sandbox/publicTenderlyActions.ts @@ -0,0 +1,38 @@ +import { BaseUnitNumber } from '@/domain/types/NumericValues' +import { toHex } from '@/utils/bigNumber' + +import { request } from './request' + +async function setBalance(forkUrl: string, address: string, balance: BaseUnitNumber): Promise { + await request(forkUrl, 'tenderly_setBalance', [address, toHex(balance)]) +} + +//@note: due to Tenderly race conditions this can't be parallelized +async function setTokenBalance( + forkUrl: string, + tokenAddress: string, + walletAddress: string, + balance: BaseUnitNumber, +): Promise { + await request(forkUrl, 'tenderly_setErc20Balance', [tokenAddress, walletAddress, toHex(balance)]) +} + +async function snapshot(forkUrl: string): Promise { + return request(forkUrl, 'evm_snapshot', []) +} + +async function revertToSnapshot(forkUrl: string, checkpoint: string): Promise { + await request(forkUrl, 'evm_revert', [checkpoint]) +} + +async function evmIncreaseTime(forkUrl: string, seconds: number): Promise { + await request(forkUrl, 'evm_increaseTime', [seconds]) +} + +export const publicTenderlyActions = { + setBalance, + setTokenBalance, + snapshot, + revertToSnapshot, + evmIncreaseTime, +} diff --git a/packages/app/src/domain/sandbox/request.ts b/packages/app/src/domain/sandbox/request.ts new file mode 100644 index 000000000..4b85af2c0 --- /dev/null +++ b/packages/app/src/domain/sandbox/request.ts @@ -0,0 +1,33 @@ +import { randomInt } from '@/utils/random' +import { solidFetch } from '@/utils/solidFetch' + +export async function request(forkUrl: string, method: 'tenderly_setBalance', params: [string, string]): Promise +export async function request( + forkUrl: string, + method: 'tenderly_setErc20Balance', + params: [string, string, string], +): Promise +export async function request(forkUrl: string, method: 'evm_snapshot', params: []): Promise +export async function request(forkUrl: string, method: 'evm_revert', params: [string]): Promise +export async function request(forkUrl: string, method: 'evm_increaseTime', params: [number]): Promise +export async function request(forkUrl: string, method: string, params: any[]): Promise { + const id = randomInt().toString() + + const result = await solidFetch(forkUrl, { + method: 'post', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method, + params, + id, + }), + }) + + if (!result.ok) { + throw new Error(`${method} failed: ${await result.text()}`) + } + + const data = await result.text() + return data +} diff --git a/packages/app/src/domain/sandbox/useSandboxState.ts b/packages/app/src/domain/sandbox/useSandboxState.ts new file mode 100644 index 000000000..3b593bb4f --- /dev/null +++ b/packages/app/src/domain/sandbox/useSandboxState.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import { Address, privateKeyToAccount } from 'viem/accounts' +import { useChainId } from 'wagmi' + +import { useStore } from '@/domain/state' + +export interface UseSandboxStateResult { + isSandboxEnabled: boolean + isDevSandboxEnabled: boolean + isInSandbox: boolean + isEphemeralAccount: (address: Address) => boolean + deleteSandbox: () => void +} + +export function useSandboxState(): UseSandboxStateResult { + const isSandboxEnabled = import.meta.env.VITE_FEATURE_SANDBOX === '1' + const isDevSandboxEnabled = import.meta.env.VITE_FEATURE_DEV_SANDBOX === '1' + + if (!isSandboxEnabled) { + return { + isInSandbox: false, + isDevSandboxEnabled: false, + isSandboxEnabled: false, + isEphemeralAccount: () => false, + deleteSandbox: () => {}, + } + } + + /* eslint-disable react-hooks/rules-of-hooks */ + const chainId = useChainId() + const { network, setNetwork } = useStore((state) => state.sandbox) + const ephemeralAccountAddress = useMemo( + () => network?.ephemeralAccountPrivateKey && privateKeyToAccount(network.ephemeralAccountPrivateKey).address, + [network?.ephemeralAccountPrivateKey], + ) + /* eslint-enable react-hooks/rules-of-hooks */ + + return { + isSandboxEnabled, + isDevSandboxEnabled, + isInSandbox: network?.forkChainId === chainId, + isEphemeralAccount: (address: Address) => ephemeralAccountAddress === address, + deleteSandbox: () => { + setNetwork(undefined) + }, + } +} diff --git a/packages/app/src/domain/savings/makeAssetsInWalletList.ts b/packages/app/src/domain/savings/makeAssetsInWalletList.ts new file mode 100644 index 000000000..b02304d68 --- /dev/null +++ b/packages/app/src/domain/savings/makeAssetsInWalletList.ts @@ -0,0 +1,26 @@ +import { TokenWithBalance } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { bigNumberify } from '@/utils/bigNumber' + +export const whitelistedAssets = ['DAI', 'USDC', 'USDT'] + +export interface MakeAssetsInWalletListParams { + walletInfo: WalletInfo +} + +export interface MakeAssetsInWalletListResults { + assets: TokenWithBalance[] + maxBalanceToken: TokenWithBalance + totalUSD: NormalizedUnitNumber +} + +export function makeAssetsInWalletList({ walletInfo }: MakeAssetsInWalletListParams): MakeAssetsInWalletListResults { + const assets = walletInfo.walletBalances.filter(({ token }) => whitelistedAssets.includes(token.symbol)) + const totalUSD = NormalizedUnitNumber( + assets.reduce((acc, { token, balance }) => acc.plus(token.toUSD(balance)), bigNumberify('0')), + ) + const maxBalanceToken = assets.reduce((acc, token) => (token.balance.gt(acc.balance) ? token : acc), assets[0]!) + + return { assets, totalUSD, maxBalanceToken } +} diff --git a/packages/app/src/domain/state/__snapshots__/index.test.ts.snap b/packages/app/src/domain/state/__snapshots__/index.test.ts.snap new file mode 100644 index 000000000..efcc0e7dd --- /dev/null +++ b/packages/app/src/domain/state/__snapshots__/index.test.ts.snap @@ -0,0 +1,43 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`initializes store 1`] = ` +{ + "actionsSettings": { + "exchangeMaxSlippage": "0.005", + "preferPermits": true, + "setPreferPermits": [Function], + }, + "appConfig": { + "sandbox": undefined, + }, + "compliance": { + "addAgreedToToSAddress": [Function], + "agreedToToSAdresses": [], + }, + "dialogs": { + "closeDialog": [Function], + "openDialog": [Function], + "openedDialog": undefined, + }, + "sandbox": { + "network": undefined, + "setNetwork": [Function], + }, +} +`; + +exports[`persists state 1`] = ` +{ + "state": { + "actionsSettings": { + "exchangeMaxSlippage": "0.005", + "preferPermits": true, + }, + "compliance": { + "agreedToToSAdresses": [], + }, + "sandbox": {}, + }, + "version": 1, +} +`; diff --git a/packages/app/src/domain/state/actions-settings.ts b/packages/app/src/domain/state/actions-settings.ts new file mode 100644 index 000000000..565e24af0 --- /dev/null +++ b/packages/app/src/domain/state/actions-settings.ts @@ -0,0 +1,61 @@ +import { DeepPartial } from 'ts-essentials' +import { StateCreator } from 'zustand' + +import { tryOrDefault } from '@/utils/tryOrDefault' + +import { Percentage } from '../types/NumericValues' +import { type StoreState } from '.' + +export interface ActionsSettings { + preferPermits: boolean + setPreferPermits: (preferPermits: boolean) => void + exchangeMaxSlippage: Percentage +} + +export interface ActionsSettingsSlice { + actionsSettings: ActionsSettings +} + +// eslint-disable-next-line func-style +export const initActionsSettingsSlice: StateCreator = (set) => ({ + actionsSettings: { + preferPermits: true, + setPreferPermits: (preferPermits: boolean) => + set((state) => ({ actionsSettings: { ...state.actionsSettings, preferPermits } })), + exchangeMaxSlippage: Percentage(0.005), + }, +}) + +export interface PersistedActionsSettingsSlice { + actionsSettings: { + preferPermits: boolean + exchangeMaxSlippage: string + } +} + +export function persistActionsSettingsSlice(state: StoreState): PersistedActionsSettingsSlice { + return { + actionsSettings: { + preferPermits: state.actionsSettings.preferPermits, + exchangeMaxSlippage: state.actionsSettings.exchangeMaxSlippage.toFixed(), + }, + } +} + +export function unPersistActionsSettingsSlice( + persistedState: DeepPartial, +): DeepPartial { + if (!persistedState.actionsSettings) { + return {} + } + + return { + actionsSettings: { + preferPermits: persistedState.actionsSettings.preferPermits, + exchangeMaxSlippage: tryOrDefault(() => { + const rawSlippage = persistedState.actionsSettings?.exchangeMaxSlippage + return rawSlippage && Percentage(rawSlippage) + }, undefined), + }, + } +} diff --git a/packages/app/src/domain/state/compliance.ts b/packages/app/src/domain/state/compliance.ts new file mode 100644 index 000000000..ea5c54b22 --- /dev/null +++ b/packages/app/src/domain/state/compliance.ts @@ -0,0 +1,50 @@ +import { StateCreator } from 'zustand' + +import { CheckedAddress } from '../types/CheckedAddress' +import { StoreState, useStore } from '.' + +export interface Compliance { + agreedToToSAdresses: CheckedAddress[] + addAgreedToToSAddress: (address: CheckedAddress) => void +} + +export interface ComplianceSlice { + compliance: Compliance +} + +// eslint-disable-next-line func-style +export const initComplianceSlice: StateCreator = (set) => ({ + compliance: { + agreedToToSAdresses: [], + addAgreedToToSAddress: (address: CheckedAddress) => + set((state) => ({ + compliance: { ...state.compliance, agreedToToSAdresses: [...state.compliance.agreedToToSAdresses, address] }, + })), + }, +}) + +export interface UseTermsOfServiceResults { + agreedToTermsOfService: (address: CheckedAddress) => boolean + saveAgreedToTermsOfService: (address: CheckedAddress) => void +} +export function useTermsOfService(): UseTermsOfServiceResults { + const { agreedToToSAdresses, addAgreedToToSAddress } = useStore((state) => state.compliance) + + return { + agreedToTermsOfService: (address: CheckedAddress) => agreedToToSAdresses.some((a) => a === address), + saveAgreedToTermsOfService: addAgreedToToSAddress, + } +} + +export interface PersistedComplianceSlice { + compliance: { + agreedToToSAdresses: CheckedAddress[] + } +} +export function persistComplianceSlice(state: StoreState): PersistedComplianceSlice { + return { + compliance: { + agreedToToSAdresses: state.compliance.agreedToToSAdresses, + }, + } +} diff --git a/packages/app/src/domain/state/dialogs.ts b/packages/app/src/domain/state/dialogs.ts new file mode 100644 index 000000000..e19916545 --- /dev/null +++ b/packages/app/src/domain/state/dialogs.ts @@ -0,0 +1,40 @@ +import { StateCreator } from 'zustand' + +import { CommonDialogProps } from '@/features/dialogs/common/types' + +import { StoreState, useStore } from '.' + +export interface DialogSlice

{ + dialogs: { + openedDialog?: { + element: React.ElementType

+ props: P + } + openDialog: (dialog: React.ElementType

, props: P) => void + closeDialog: () => void + } +} + +// eslint-disable-next-line func-style +export const initDialogSlice: StateCreator = (set) => ({ + dialogs: { + openedDialog: undefined, + openDialog: (dialog, props) => { + set((state) => ({ dialogs: { ...state.dialogs, openedDialog: { element: dialog, props } } })) + }, + closeDialog: () => { + set((state) => ({ dialogs: { ...state.dialogs, openedDialog: undefined } })) + }, + }, +}) + +export type OpenDialogFunction =

(dialog: React.ElementType

, props: P) => void +export function useOpenDialog(): OpenDialogFunction { + const openDialog = useStore((state) => state.dialogs.openDialog) + return openDialog as OpenDialogFunction +} + +export function useCloseDialog(): () => void { + const closeDialog = useStore((state) => state.dialogs.closeDialog) + return closeDialog +} diff --git a/packages/app/src/domain/state/index.test.ts b/packages/app/src/domain/state/index.test.ts new file mode 100644 index 000000000..f4dcdafb5 --- /dev/null +++ b/packages/app/src/domain/state/index.test.ts @@ -0,0 +1,77 @@ +import { create } from 'zustand' + +import { ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY } from '@/config/consts' +import { makeFunctionsComparisonStable } from '@/test/integration/object-utils' + +import { storeImplementation, StoreState } from '.' + +describe(storeImplementation.name, () => { + afterEach(() => localStorage.clear()) + + it('initializes store', () => { + const store = create(storeImplementation) + expect(store.getState()).toMatchSnapshot() + }) + + it('persists state', () => { + expect(localStorage.getItem(ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY)).toBeNull() + + const store = create(storeImplementation) + store.setState((s) => s) // triggers state persistence into local storage + + expect(JSON.parse(localStorage.getItem(ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY) as any)).toMatchSnapshot() + }) + + it('deserializes initial state back from the local storage', () => { + expect(localStorage.getItem(ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY)).toBeNull() + + let expectedState: StoreState + { + const store = create(storeImplementation) + expectedState = store.getState() // extract initial state which should be always fully correct + store.setState(() => ({})) // triggers state persistence into local storage + } + + // seconds store creation should deserialize persisted state from local storage + expect(localStorage.getItem(ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY)).not.toBeNull() + const store = create(storeImplementation) + const stateDeserializedFromLocalStorage = store.getState() + + expect(makeFunctionsComparisonStable(stateDeserializedFromLocalStorage)).toEqual( + makeFunctionsComparisonStable(expectedState), + ) + }) + + it('deserializes sandbox slice correctly', () => { + expect(localStorage.getItem(ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY)).toBeNull() + + let expectedState: StoreState + { + const store = create(storeImplementation) + store.setState((s) => ({ + sandbox: { + ...s.sandbox, + + network: { + name: 'name', + forkUrl: 'forkUrl', + originChainId: 1, + forkChainId: 2, + createdAt: new Date(), + ephemeralAccountPrivateKey: '0x123', + }, + }, + })) // triggers state persistence into local storage + expectedState = store.getState() // extract whole state which should be always fully correct + } + + // seconds store creation should deserialize persisted state from local storage + expect(localStorage.getItem(ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY)).not.toBeNull() + const store = create(storeImplementation) + const stateDeserializedFromLocalStorage = store.getState() + + expect(makeFunctionsComparisonStable(stateDeserializedFromLocalStorage)).toEqual( + makeFunctionsComparisonStable(expectedState), + ) + }) +}) diff --git a/packages/app/src/domain/state/index.ts b/packages/app/src/domain/state/index.ts new file mode 100644 index 000000000..923a18d64 --- /dev/null +++ b/packages/app/src/domain/state/index.ts @@ -0,0 +1,76 @@ +import { deepmerge } from 'deepmerge-ts' +import { DeepPartial } from 'react-hook-form' +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +import { ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY, ZUSTAND_APP_STORE_LOCAL_STORAGE_VERSION } from '@/config/consts' +import { filterOutUndefinedKeys } from '@/utils/object' +import { Serializable } from '@/utils/types' + +import { AppConfig, getAppConfig } from '../../config/feature-flags' +import { + ActionsSettings, + ActionsSettingsSlice, + initActionsSettingsSlice, + persistActionsSettingsSlice, + PersistedActionsSettingsSlice, + unPersistActionsSettingsSlice, +} from './actions-settings' +import { ComplianceSlice, initComplianceSlice, persistComplianceSlice, PersistedComplianceSlice } from './compliance' +import { DialogSlice, initDialogSlice } from './dialogs' +import { + initSandboxSlice, + PersistedSandboxSlice, + persistSandboxSlice, + SandboxSlice, + unPersistSandboxSlice, +} from './sandbox' + +export type StoreState = { + appConfig: AppConfig +} & DialogSlice & + SandboxSlice & + ActionsSettingsSlice & + ComplianceSlice + +export type PersistedState = Serializable< + PersistedSandboxSlice & PersistedActionsSettingsSlice & PersistedComplianceSlice +> + +export const storeImplementation = persist( + function initializer(...a): StoreState { + return { + ...initDialogSlice(...a), + ...initActionsSettingsSlice(...a), + ...initComplianceSlice(...a), + ...initSandboxSlice(...a), + appConfig: getAppConfig(), + } + }, + { + name: ZUSTAND_APP_STORE_LOCAL_STORAGE_KEY, + version: ZUSTAND_APP_STORE_LOCAL_STORAGE_VERSION, + partialize: (state): PersistedState => ({ + ...persistSandboxSlice(state), + ...persistActionsSettingsSlice(state), + ...persistComplianceSlice(state), + }), + merge: (_persistedState, currentState) => { + const persistedState = (_persistedState ?? {}) as DeepPartial + + const processedPersistedState = filterOutUndefinedKeys({ + ...persistedState, + ...unPersistActionsSettingsSlice(persistedState), + ...unPersistSandboxSlice(persistedState), + }) + + return deepmerge(currentState, processedPersistedState) as StoreState + }, + }, +) + +export const useStore = create()(storeImplementation) + +export function useActionsSettings(): ActionsSettings { + return useStore((state) => state.actionsSettings) +} diff --git a/packages/app/src/domain/state/sandbox.ts b/packages/app/src/domain/state/sandbox.ts new file mode 100644 index 000000000..f6689a87a --- /dev/null +++ b/packages/app/src/domain/state/sandbox.ts @@ -0,0 +1,76 @@ +import { DeepPartial } from 'ts-essentials' +import { StateCreator } from 'zustand' + +import { tryOrDefault } from '@/utils/tryOrDefault' + +import { StoreState } from '.' + +export interface SandboxNetwork { + name: string // will be displayed to the user in wallet UI + forkUrl: string + originChainId: number + forkChainId: number + createdAt: Date + ephemeralAccountPrivateKey?: `0x${string}` +} + +export interface Sandbox { + network: SandboxNetwork | undefined + setNetwork: (network: SandboxNetwork | undefined) => void +} + +export interface SandboxSlice { + sandbox: Sandbox +} + +// eslint-disable-next-line func-style +export const initSandboxSlice: StateCreator = (set) => ({ + sandbox: { + network: undefined, + setNetwork: (network: SandboxNetwork | undefined) => + set((state) => ({ sandbox: { network, setNetwork: state.sandbox.setNetwork } })), + }, +}) + +export interface PersistedSandboxSlice { + sandbox: { + network?: { + name: string + forkUrl: string + originChainId: number + forkChainId: number + createdAt: string + ephemeralAccountPrivateKey?: `0x${string}` + } + } +} + +export function persistSandboxSlice(state: StoreState): PersistedSandboxSlice { + return { + sandbox: { + network: state.sandbox.network && { + ...state.sandbox.network, + createdAt: state.sandbox.network.createdAt.toISOString(), + }, + }, + } +} + +export function unPersistSandboxSlice(persistedState: DeepPartial): DeepPartial { + if (!persistedState.sandbox?.network) { + return {} + } + + return { + sandbox: { + network: { + ...persistedState.sandbox.network, + createdAt: tryOrDefault(() => { + const date = persistedState.sandbox?.network?.createdAt + + return date ? new Date(date) : undefined + }, undefined), + }, + }, + } +} diff --git a/packages/app/src/domain/types/CheckedAddress.test.ts b/packages/app/src/domain/types/CheckedAddress.test.ts new file mode 100644 index 000000000..5c2c84055 --- /dev/null +++ b/packages/app/src/domain/types/CheckedAddress.test.ts @@ -0,0 +1,18 @@ +import { CheckedAddress } from './CheckedAddress' + +const validChecksummedAddress = '0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5' + +describe(CheckedAddress.name, () => { + it('works with a valid address', () => { + expect(CheckedAddress(validChecksummedAddress)).toEqual(validChecksummedAddress) + }) + + it('transforms valid, not checksummed address into a checksummed one', () => { + expect(CheckedAddress(validChecksummedAddress.toLowerCase())).toEqual(validChecksummedAddress) + }) + + it('throws with not valid address', () => { + expect(() => CheckedAddress('not-an-address')).toThrow('Invalid address: not-an-address') + expect(() => CheckedAddress('0x0')).toThrow('Invalid address: 0x0') + }) +}) diff --git a/packages/app/src/domain/types/CheckedAddress.ts b/packages/app/src/domain/types/CheckedAddress.ts new file mode 100644 index 000000000..11c68f6e7 --- /dev/null +++ b/packages/app/src/domain/types/CheckedAddress.ts @@ -0,0 +1,14 @@ +import { getAddress, isAddress } from 'viem' + +import { Opaque } from './types' + +/** + * Represents an Ethereum address with a checksum. ie. 0x95222290DD7278Aa3Ddd389Cc1E1d165CC4BAfe5 + */ +export type CheckedAddress = Opaque<`0x${string}`, 'CheckedAddress'> +export function CheckedAddress(value: string): CheckedAddress { + if (!isAddress(value)) { + throw new Error(`Invalid address: ${value}`) + } + return getAddress(value) as CheckedAddress +} diff --git a/packages/app/src/domain/types/EnsName.ts b/packages/app/src/domain/types/EnsName.ts new file mode 100644 index 000000000..6e0dc0000 --- /dev/null +++ b/packages/app/src/domain/types/EnsName.ts @@ -0,0 +1,6 @@ +import { Opaque } from './types' + +export type EnsName = Opaque +export function EnsName(value: string): EnsName { + return value as EnsName +} diff --git a/packages/app/src/domain/types/NumericValues.test.ts b/packages/app/src/domain/types/NumericValues.test.ts new file mode 100644 index 000000000..b19f79fef --- /dev/null +++ b/packages/app/src/domain/types/NumericValues.test.ts @@ -0,0 +1,58 @@ +import BigNumber from 'bignumber.js' + +import { BaseUnitNumber, NormalizedUnitNumber, Percentage } from './NumericValues' + +describe(BaseUnitNumber.name, () => { + it('works with an argument correctly representing base value', () => { + expect(BaseUnitNumber(10n ** 3n)).toEqual(new BigNumber(1000)) + expect(BaseUnitNumber(1000)).toEqual(new BigNumber(1000)) + expect(BaseUnitNumber('1000')).toEqual(new BigNumber(1000)) + expect(BaseUnitNumber(new BigNumber(1000))).toEqual(new BigNumber(1000)) + }) + + it('throws if value argument has decimal points', () => { + expect(() => BaseUnitNumber(123.45)).toThrow('Value should not have decimal points in its representation.') + }) + + it('throws if value argument is negative number', () => { + expect(NormalizedUnitNumber(-1)).toEqual(new BigNumber(-1)) + }) + + it('throws if value argument is non-numeric value', () => { + expect(() => BaseUnitNumber('non-numeric')).toThrow('Value argument: non-numeric cannot be converted to BigNumber.') + }) +}) + +describe(NormalizedUnitNumber.name, () => { + it('works for a numeric value', () => { + expect(NormalizedUnitNumber(10n ** 3n)).toEqual(new BigNumber(1000)) + expect(NormalizedUnitNumber(1000)).toEqual(new BigNumber(1000)) + expect(NormalizedUnitNumber('1000')).toEqual(new BigNumber(1000)) + expect(NormalizedUnitNumber(new BigNumber(1000))).toEqual(new BigNumber(1000)) + expect(NormalizedUnitNumber('123.456')).toEqual(new BigNumber(123.456)) + }) + + it('works with negative numbers', () => { + expect(NormalizedUnitNumber(-1)).toEqual(new BigNumber(-1)) + }) + + it('throws if value argument is non-numeric value', () => { + expect(() => NormalizedUnitNumber('123,456')).toThrow('Value argument: 123,456 cannot be converted to BigNumber.') + expect(() => NormalizedUnitNumber('non-numeric')).toThrow( + 'Value argument: non-numeric cannot be converted to BigNumber.', + ) + }) +}) + +describe(Percentage.name, () => { + it('works with a value from 0 to 1', () => { + expect(Percentage(0)).toEqual(new BigNumber(0)) + expect(Percentage(1)).toEqual(new BigNumber(1)) + expect(Percentage(0.25)).toEqual(new BigNumber(0.25)) + }) + + it('throws for a value outside of a 0 to 1 range', () => { + expect(() => Percentage(-1)).toThrow('Percentage value should be greater than or equal to 0.') + expect(() => Percentage(2)).toThrow('Percentage value should be less than or equal to 1.') + }) +}) diff --git a/packages/app/src/domain/types/NumericValues.ts b/packages/app/src/domain/types/NumericValues.ts new file mode 100644 index 000000000..d77cb21bc --- /dev/null +++ b/packages/app/src/domain/types/NumericValues.ts @@ -0,0 +1,40 @@ +import BigNumber from 'bignumber.js' +import invariant from 'tiny-invariant' + +import { bigNumberify, NumberLike } from '../../utils/bigNumber' +import { Opaque } from './types' + +/** + * Represents a base number. Always positive. ie. 1.5 * 10^18 (DAI) + */ +export type BaseUnitNumber = Opaque +export function BaseUnitNumber(value: NumberLike): BaseUnitNumber { + const result = bigNumberify(value) + invariant(!result.dp(), 'Value should not have decimal points in its representation.') + + return result as BaseUnitNumber +} + +/** + * Represents a base number divided by decimals. Always positive. ie. 1.5 (DAI) + */ +export type NormalizedUnitNumber = Opaque +export function NormalizedUnitNumber(value: NumberLike): NormalizedUnitNumber { + const result = bigNumberify(value) + return result as NormalizedUnitNumber +} + +/** + * Represents a percentage as a fraction. ie. 0.5 (50%) + * Percentages can be often greater that 1 (100%) so we need to allow that. + */ +export type Percentage = Opaque +export function Percentage(_value: NumberLike, allowMoreThan1: boolean = false): Percentage { + const value = bigNumberify(_value) + invariant(value.gte(0), 'Percentage value should be greater than or equal to 0.') + if (!allowMoreThan1) { + invariant(value.lte(1), 'Percentage value should be less than or equal to 1.') + } + + return value as Percentage +} diff --git a/packages/app/src/domain/types/Token.test.ts b/packages/app/src/domain/types/Token.test.ts new file mode 100644 index 000000000..ba267ed32 --- /dev/null +++ b/packages/app/src/domain/types/Token.test.ts @@ -0,0 +1,258 @@ +import BigNumber from 'bignumber.js' +import { zeroAddress } from 'viem' + +import { testAddresses } from '@/test/integration/constants' + +import { CheckedAddress } from './CheckedAddress' +import { BaseUnitNumber, NormalizedUnitNumber } from './NumericValues' +import { Token } from './Token' +import { TokenSymbol } from './TokenSymbol' + +describe(Token.name, () => { + const token = new Token({ + symbol: TokenSymbol('TEST'), + name: 'Test Token', + unitPriceUsd: '1', + decimals: 18, + address: CheckedAddress(testAddresses.token), + }) + + describe(Token.prototype.formatUSD.name, () => { + const tokenB = new Token({ + symbol: TokenSymbol('TEST'), + name: 'Test Token', + unitPriceUsd: '2', + decimals: 18, + address: CheckedAddress(testAddresses.token), + }) + + it('formats whole values', () => { + expect(token.formatUSD(NormalizedUnitNumber(0))).toEqual('$0.00') + expect(token.formatUSD(NormalizedUnitNumber(1))).toEqual('$1.00') + expect(token.formatUSD(NormalizedUnitNumber(2))).toEqual('$2.00') + expect(token.formatUSD(NormalizedUnitNumber(45))).toEqual('$45.00') + + expect(tokenB.formatUSD(NormalizedUnitNumber(0))).toEqual('$0.00') + expect(tokenB.formatUSD(NormalizedUnitNumber(1))).toEqual('$2.00') + expect(tokenB.formatUSD(NormalizedUnitNumber(2))).toEqual('$4.00') + expect(tokenB.formatUSD(NormalizedUnitNumber(45))).toEqual('$90.00') + }) + + it('formats small values', () => { + expect(token.formatUSD(NormalizedUnitNumber(0.01))).toEqual('$0.01') + expect(token.formatUSD(NormalizedUnitNumber(0.009))).toEqual('<$0.01') + expect(token.formatUSD(NormalizedUnitNumber(0.001))).toEqual('<$0.01') + expect(token.formatUSD(NormalizedUnitNumber(0.000_001))).toEqual('<$0.01') + + expect(tokenB.formatUSD(NormalizedUnitNumber(0.01))).toEqual('$0.02') + expect(tokenB.formatUSD(NormalizedUnitNumber(0.009))).toEqual('$0.02') + expect(tokenB.formatUSD(NormalizedUnitNumber(0.004))).toEqual('<$0.01') + expect(tokenB.formatUSD(NormalizedUnitNumber(0.000_001))).toEqual('<$0.01') + }) + + it('formats numbers with fractional part', () => { + expect(token.formatUSD(NormalizedUnitNumber(0.12))).toEqual('$0.12') + expect(token.formatUSD(NormalizedUnitNumber(1.121))).toEqual('$1.12') + expect(token.formatUSD(NormalizedUnitNumber(1.129))).toEqual('$1.13') + expect(token.formatUSD(NormalizedUnitNumber(1.99))).toEqual('$1.99') + expect(token.formatUSD(NormalizedUnitNumber(1.999))).toEqual('$2.00') + + expect(tokenB.formatUSD(NormalizedUnitNumber(0.12))).toEqual('$0.24') + expect(tokenB.formatUSD(NormalizedUnitNumber(1.121))).toEqual('$2.24') + expect(tokenB.formatUSD(NormalizedUnitNumber(1.124))).toEqual('$2.25') + expect(tokenB.formatUSD(NormalizedUnitNumber(1.99))).toEqual('$3.98') + expect(tokenB.formatUSD(NormalizedUnitNumber(1.999))).toEqual('$4.00') + }) + + it('formats in compact mode', () => { + expect(token.formatUSD(NormalizedUnitNumber(0), { compact: true })).toEqual(`$0.00`) + expect(token.formatUSD(NormalizedUnitNumber(0.0001), { compact: true })).toEqual('<$0.01') + expect(token.formatUSD(NormalizedUnitNumber(823.2345), { compact: true })).toEqual('$823.23') + expect(token.formatUSD(NormalizedUnitNumber(1234), { compact: true })).toEqual('$1.234K') + expect(token.formatUSD(NormalizedUnitNumber(100000), { compact: true })).toEqual('$100K') + expect(token.formatUSD(NormalizedUnitNumber(1000000), { compact: true })).toEqual('$1M') + expect(token.formatUSD(NormalizedUnitNumber(1000000000), { compact: true })).toEqual('$1B') + expect(token.formatUSD(NormalizedUnitNumber(1000000000000), { compact: true })).toEqual('$1T') + expect(token.formatUSD(NormalizedUnitNumber(1000000000000000), { compact: true })).toEqual('$1000T') + }) + + it('formats with display cents set to never', () => { + expect(token.formatUSD(NormalizedUnitNumber(0), { showCents: 'never' })).toEqual(`$0`) + expect(token.formatUSD(NormalizedUnitNumber(0.0001), { showCents: 'never' })).toEqual('<$0.01') + expect(token.formatUSD(NormalizedUnitNumber(1234), { showCents: 'never' })).toEqual('$1,234') + expect(token.formatUSD(NormalizedUnitNumber(1234.56), { showCents: 'never' })).toEqual('$1,235') + }) + + it('formats with display cents set to always', () => { + expect(token.formatUSD(NormalizedUnitNumber(0), { showCents: 'always' })).toEqual(`$0.00`) + expect(token.formatUSD(NormalizedUnitNumber(0.0001), { showCents: 'always' })).toEqual('<$0.01') + expect(token.formatUSD(NormalizedUnitNumber(1234), { showCents: 'always' })).toEqual('$1,234.00') + expect(token.formatUSD(NormalizedUnitNumber(1234.56), { showCents: 'always' })).toEqual('$1,234.56') + }) + + it('formats with display cents set to when-not-round', () => { + expect(token.formatUSD(NormalizedUnitNumber(0), { showCents: 'when-not-round' })).toEqual(`$0`) + expect(token.formatUSD(NormalizedUnitNumber(0.0001), { showCents: 'when-not-round' })).toEqual('<$0.01') + expect(token.formatUSD(NormalizedUnitNumber(1234), { showCents: 'when-not-round' })).toEqual('$1,234') + expect(token.formatUSD(NormalizedUnitNumber(1234.56), { showCents: 'when-not-round' })).toEqual('$1,234.56') + }) + + it('formats with thousand places separator', () => { + expect(token.formatUSD(NormalizedUnitNumber(123456789.12))).toEqual('$123,456,789.12') + }) + }) + + describe(Token.prototype.format.name, () => { + describe('compact style', () => { + it('should return 0 for 0', () => { + expect(token.format(NormalizedUnitNumber(0), { style: 'compact' })).toEqual('0') + }) + + it('should return <0.001 for values less than 0.001', () => { + expect(token.format(NormalizedUnitNumber(0.0001), { style: 'compact' })).toEqual('<0.001') + expect(token.format(NormalizedUnitNumber(0.0009), { style: 'compact' })).toEqual('<0.001') + }) + + it('should return short format with maximum 4 digits for values greater than 1', () => { + expect(token.format(NormalizedUnitNumber(1.2), { style: 'compact' })).toEqual('1.2') + expect(token.format(NormalizedUnitNumber(1.23), { style: 'compact' })).toEqual('1.23') + expect(token.format(NormalizedUnitNumber(1.234), { style: 'compact' })).toEqual('1.234') + expect(token.format(NormalizedUnitNumber(12.34), { style: 'compact' })).toEqual('12.34') + expect(token.format(NormalizedUnitNumber(12.345), { style: 'compact' })).toEqual('12.35') + expect(token.format(NormalizedUnitNumber(12.3456), { style: 'compact' })).toEqual('12.35') + expect(token.format(NormalizedUnitNumber(123.4), { style: 'compact' })).toEqual('123.4') + expect(token.format(NormalizedUnitNumber(123.45), { style: 'compact' })).toEqual('123.5') + expect(token.format(NormalizedUnitNumber(123.456), { style: 'compact' })).toEqual('123.5') + + expect(token.format(NormalizedUnitNumber(1234), { style: 'compact' })).toEqual('1.234K') + expect(token.format(NormalizedUnitNumber(12345), { style: 'compact' })).toEqual('12.35K') + expect(token.format(NormalizedUnitNumber(123456), { style: 'compact' })).toEqual('123.5K') + expect(token.format(NormalizedUnitNumber(1234567), { style: 'compact' })).toEqual('1.235M') + expect(token.format(NormalizedUnitNumber(12345678), { style: 'compact' })).toEqual('12.35M') + expect(token.format(NormalizedUnitNumber(123456789), { style: 'compact' })).toEqual('123.5M') + expect(token.format(NormalizedUnitNumber(1234567890), { style: 'compact' })).toEqual('1.235B') + expect(token.format(NormalizedUnitNumber(12345678900), { style: 'compact' })).toEqual('12.35B') + expect(token.format(NormalizedUnitNumber(123456789000), { style: 'compact' })).toEqual('123.5B') + + expect(token.format(NormalizedUnitNumber(1000), { style: 'compact' })).toEqual('1K') + expect(token.format(NormalizedUnitNumber(10000), { style: 'compact' })).toEqual('10K') + expect(token.format(NormalizedUnitNumber(100000), { style: 'compact' })).toEqual('100K') + expect(token.format(NormalizedUnitNumber(1000000), { style: 'compact' })).toEqual('1M') + expect(token.format(NormalizedUnitNumber(1000000000), { style: 'compact' })).toEqual('1B') + expect(token.format(NormalizedUnitNumber(1000000000000), { style: 'compact' })).toEqual('1T') + expect(token.format(NormalizedUnitNumber(1000000000000000), { style: 'compact' })).toEqual('1000T') + + expect(token.format(NormalizedUnitNumber(12340), { style: 'compact' })).toEqual('12.34K') + expect(token.format(NormalizedUnitNumber(123400), { style: 'compact' })).toEqual('123.4K') + expect(token.format(NormalizedUnitNumber(1234000), { style: 'compact' })).toEqual('1.234M') + expect(token.format(NormalizedUnitNumber(12340000), { style: 'compact' })).toEqual('12.34M') + expect(token.format(NormalizedUnitNumber(2790000000), { style: 'compact' })).toEqual('2.79B') + }) + + it('should return max 3 digits precision for values >=0.001 and <=1', () => { + expect(token.format(NormalizedUnitNumber(0.01), { style: 'compact' })).toEqual('0.01') + expect(token.format(NormalizedUnitNumber(0.15), { style: 'compact' })).toEqual('0.15') + expect(token.format(NormalizedUnitNumber(0.001), { style: 'compact' })).toEqual('0.001') + expect(token.format(NormalizedUnitNumber(0.12345), { style: 'compact' })).toEqual('0.123') + }) + }) + + describe('auto style', () => { + describe('with stablecoin like', () => { + it('formats whole values', () => { + expect(token.format(NormalizedUnitNumber(0), { style: 'auto' })).toEqual('0.00') + expect(token.format(NormalizedUnitNumber(1), { style: 'auto' })).toEqual('1.00') + expect(token.format(NormalizedUnitNumber(45), { style: 'auto' })).toEqual('45.00') + }) + + it('formats small values', () => { + expect(token.format(NormalizedUnitNumber(0.1), { style: 'auto' })).toEqual('0.10') + expect(token.format(NormalizedUnitNumber(0.01), { style: 'auto' })).toEqual('0.01') + expect(token.format(NormalizedUnitNumber(0.00009), { style: 'auto' })).toEqual('<0.01') + expect(token.format(NormalizedUnitNumber(0.000_001), { style: 'auto' })).toEqual('<0.01') + }) + + it('formats numbers with fractional part', () => { + expect(token.format(NormalizedUnitNumber(2.12), { style: 'auto' })).toEqual('2.12') + expect(token.format(NormalizedUnitNumber(1.999), { style: 'auto' })).toEqual('2.00') + }) + + it('formats numbers with thousands separators', () => { + expect(token.format(NormalizedUnitNumber(123456789), { style: 'auto' })).toEqual('123,456,789.00') + }) + }) + + describe('with BTC like', () => { + const token = new Token({ + symbol: TokenSymbol('BTC'), + name: 'BTC Token', + unitPriceUsd: '50000', + decimals: 18, + address: CheckedAddress(testAddresses.token), + }) + + it('formats whole values', () => { + expect(token.format(NormalizedUnitNumber(0), { style: 'auto' })).toEqual('0.00') + expect(token.format(NormalizedUnitNumber(1), { style: 'auto' })).toEqual('1.00') + expect(token.format(NormalizedUnitNumber(45), { style: 'auto' })).toEqual('45.00') + }) + + it('formats small values', () => { + expect(token.format(NormalizedUnitNumber(0.1), { style: 'auto' })).toEqual('0.10') + expect(token.format(NormalizedUnitNumber(0.01), { style: 'auto' })).toEqual('0.01') + expect(token.format(NormalizedUnitNumber(0.00009), { style: 'auto' })).toEqual('0.00009') + expect(token.format(NormalizedUnitNumber(0.000098), { style: 'auto' })).toEqual('0.000098') + expect(token.format(NormalizedUnitNumber(0.0000987), { style: 'auto' })).toEqual('0.000099') + expect(token.format(NormalizedUnitNumber(0.000_000_1), { style: 'auto' })).toEqual('<0.000001') + }) + + it('formats numbers with fractional part', () => { + expect(token.format(NormalizedUnitNumber(2.12), { style: 'auto' })).toEqual('2.12') + expect(token.format(NormalizedUnitNumber(1.999), { style: 'auto' })).toEqual('1.999') + }) + + it('formats numbers with thousands separators', () => { + expect(token.format(NormalizedUnitNumber(123456789), { style: 'auto' })).toEqual('123,456,789.00') + expect(token.format(NormalizedUnitNumber(123456789.123456789), { style: 'auto' })).toEqual( + '123,456,789.123457', + ) + }) + }) + }) + }) + + it(Token.prototype.toBaseUnit.name, () => { + const value = NormalizedUnitNumber(10) + expect(token.toBaseUnit(value)).toStrictEqual(BaseUnitNumber(10n ** 19n)) + }) + + it(Token.prototype.fromBaseUnit.name, () => { + const value = BaseUnitNumber(new BigNumber(10).pow(19)) + expect(token.fromBaseUnit(value)).toStrictEqual(BaseUnitNumber('10')) + }) + + it(Token.prototype.toUSD.name, () => { + const value = NormalizedUnitNumber(10) + expect(token.toUSD(value).toString()).toBe('10') + }) + + it(Token.prototype.clone.name, () => { + const token = new Token({ + symbol: TokenSymbol('TEST'), + name: 'Test Token', + unitPriceUsd: '1.12345678901', + decimals: 18, + address: CheckedAddress(zeroAddress), + }) + + const newAddress = CheckedAddress(testAddresses.alice) + const newSymbol = TokenSymbol('ETH') + + const clonedToken = token.clone({ address: newAddress, symbol: newSymbol }) + + expect(clonedToken.unitPriceUsd).toStrictEqual(token.unitPriceUsd) + expect(clonedToken.decimals).toBe(token.decimals) + expect(clonedToken.address).toBe(newAddress) + expect(clonedToken.symbol).toBe(newSymbol) + }) +}) diff --git a/packages/app/src/domain/types/Token.ts b/packages/app/src/domain/types/Token.ts new file mode 100644 index 000000000..2a6f19b97 --- /dev/null +++ b/packages/app/src/domain/types/Token.ts @@ -0,0 +1,177 @@ +import BigNumber from 'bignumber.js' +import invariant from 'tiny-invariant' +import { zeroAddress } from 'viem' + +import { findSignificantPrecision } from '../common/format' +import { CheckedAddress } from './CheckedAddress' +import { BaseUnitNumber, NormalizedUnitNumber } from './NumericValues' +import { TokenSymbol } from './TokenSymbol' + +export class Token { + readonly symbol: TokenSymbol + readonly name: string + readonly decimals: number + readonly address: CheckedAddress + readonly unitPriceUsd: NormalizedUnitNumber + readonly isAToken: boolean + + constructor({ + symbol, + name, + decimals, + address, + unitPriceUsd, + isAToken = false, + }: { + symbol: TokenSymbol + name: string + decimals: number + address: CheckedAddress + unitPriceUsd: string + isAToken?: boolean + }) { + // sanity checks + invariant(decimals >= 2, 'decimals value should be greater than 2') + invariant(decimals <= 30, 'decimals value should be less than 30') + + this.decimals = decimals + this.symbol = symbol + this.name = name + this.address = address + this.unitPriceUsd = NormalizedUnitNumber(unitPriceUsd) + this.isAToken = isAToken + } + + public formatUSD( + value: NormalizedUnitNumber, + { compact = false, showCents = 'always' }: FormatUSDOptions = {}, + ): string { + const USDValue = this.toUSD(value) + if (value.gt(0) && USDValue.lt(0.01)) { + return '<$0.01' + } + + if (compact && USDValue.gte(1000)) { + return '$' + formatCompact(USDValue) + } + + const fractionDigitsConfig = { + always: { minimumFractionDigits: 2, maximumFractionDigits: 2 }, + never: { minimumFractionDigits: 0, maximumFractionDigits: 0 }, + 'when-not-round': { minimumFractionDigits: value.isInteger() ? 0 : 2, maximumFractionDigits: 2 }, + } satisfies Record + + const usdFormatter = getNumberFormatter({ + style: 'currency', + currency: 'USD', + ...fractionDigitsConfig[showCents], + }) + return usdFormatter.format(USDValue.toNumber()) + } + + public format(value: NormalizedUnitNumber, { style }: FormatOptions): string { + if (style === 'auto') { + return formatAuto(value, this.unitPriceUsd) + } + return formatCompact(value) + } + + public toBaseUnit(value: NormalizedUnitNumber): BaseUnitNumber { + return BaseUnitNumber(value.shiftedBy(this.decimals)) + } + + public fromBaseUnit(value: BaseUnitNumber): NormalizedUnitNumber { + return NormalizedUnitNumber(value.shiftedBy(-this.decimals)) + } + + public toUSD(value: NormalizedUnitNumber): NormalizedUnitNumber { + return NormalizedUnitNumber(value.multipliedBy(this.unitPriceUsd)) + } + + public clone({ + address, + symbol, + name, + isAToken, + }: { + address?: CheckedAddress + symbol?: TokenSymbol + name?: string + isAToken?: boolean + }): Token { + return new Token({ + address: address ?? this.address, + symbol: symbol ?? this.symbol, + name: name ?? this.name, + isAToken: isAToken ?? this.isAToken, + decimals: this.decimals, + unitPriceUsd: this.unitPriceUsd.toFixed(), + }) + } + + public createAToken(address: CheckedAddress): Token { + return this.clone({ + address, + symbol: TokenSymbol(`a${this.symbol}`), + isAToken: true, + }) + } +} + +export interface FormatUSDOptions { + compact?: boolean + showCents?: 'always' | 'when-not-round' | 'never' +} + +export interface FormatOptions { + style?: 'auto' | 'compact' +} + +export const USD_MOCK_TOKEN = new Token({ + address: CheckedAddress(zeroAddress), + symbol: TokenSymbol('USD'), + name: 'US Dollar', + decimals: 2, + unitPriceUsd: '1', +}) + +function formatAuto(value: NormalizedUnitNumber, unitPriceUsd: NormalizedUnitNumber): string { + const precision = findSignificantPrecision(unitPriceUsd) + const leastSignificantValue = BigNumber(1).shiftedBy(-precision) + const rounded = BigNumber(value.toFixed(precision)) + if (value.gt(0) && rounded.lt(leastSignificantValue)) { + return `<${leastSignificantValue.toFixed()}` + } + + const formatter = getNumberFormatter({ + minimumFractionDigits: 2, + maximumFractionDigits: Math.max(precision, 2), + }) + + return formatter.format(value.toNumber()) +} + +function formatCompact(value: NormalizedUnitNumber): string { + const n = value.toNumber() + if (n === 0) return '0' + if (n < 0.001) return '<0.001' + if (n < 1) return getNumberFormatter({ maximumFractionDigits: 3 }).format(n) + + const significantDigits = countSignificantDigits(n) + const fractionDigits = Math.max(0, 4 - significantDigits) + + return getNumberFormatter({ + maximumFractionDigits: fractionDigits, + notation: 'compact', + }).format(n) +} + +function countSignificantDigits(n: number): number { + const totalDigits = Math.floor(Math.log10(Math.abs(n))) + 1 + const significantDigits = totalDigits % 3 || 3 + return significantDigits +} + +function getNumberFormatter(options: Intl.NumberFormatOptions): Intl.NumberFormat { + return new Intl.NumberFormat('en-US', options) +} diff --git a/packages/app/src/domain/types/TokenSymbol.test.ts b/packages/app/src/domain/types/TokenSymbol.test.ts new file mode 100644 index 000000000..42649d8e6 --- /dev/null +++ b/packages/app/src/domain/types/TokenSymbol.test.ts @@ -0,0 +1,18 @@ +import { TokenSymbol } from './TokenSymbol' + +describe(TokenSymbol.name, () => { + it('works with a correct token symbol', () => { + expect(TokenSymbol('sDAI')).toEqual('sDAI') + expect(TokenSymbol('ETH')).toEqual('ETH') + expect(TokenSymbol('wstETH')).toEqual('wstETH') + expect(TokenSymbol('aSYMBOL')).toEqual('aSYMBOL') + }) + + it('throws for an empty token symbol', () => { + expect(() => TokenSymbol('')).toThrow('Token symbol should be between 1 and 7 characters.') + }) + + it('throws for a token symbol longer than 7 characters', () => { + expect(() => TokenSymbol('DAI-WETH')).toThrow('Token symbol should be between 1 and 7 characters.') + }) +}) diff --git a/packages/app/src/domain/types/TokenSymbol.ts b/packages/app/src/domain/types/TokenSymbol.ts new file mode 100644 index 000000000..5558da177 --- /dev/null +++ b/packages/app/src/domain/types/TokenSymbol.ts @@ -0,0 +1,13 @@ +import invariant from 'tiny-invariant' + +import { Opaque } from './types' + +/** + * Represents a token symbol. ie. DAI + */ +export type TokenSymbol = Opaque +export function TokenSymbol(symbol: string): TokenSymbol { + invariant(symbol.length > 0 && symbol.length <= 7, 'Token symbol should be between 1 and 7 characters.') + + return symbol as TokenSymbol +} diff --git a/packages/app/src/domain/types/types.ts b/packages/app/src/domain/types/types.ts new file mode 100644 index 000000000..cd757ca78 --- /dev/null +++ b/packages/app/src/domain/types/types.ts @@ -0,0 +1,9 @@ +type StringLiteral = Type extends string ? (string extends Type ? never : Type) : never + +declare const __OPAQUE_TYPE__: unique symbol + +export type WithOpaque = { + readonly [__OPAQUE_TYPE__]: Token +} + +export type Opaque = Token extends StringLiteral ? Type & WithOpaque : never diff --git a/packages/app/src/domain/wallet/balances.ts b/packages/app/src/domain/wallet/balances.ts new file mode 100644 index 000000000..02221df62 --- /dev/null +++ b/packages/app/src/domain/wallet/balances.ts @@ -0,0 +1,62 @@ +import { queryOptions } from '@tanstack/react-query' +import { Address } from 'viem' +import { Config } from 'wagmi' +import { readContract } from 'wagmi/actions' + +import { + lendingPoolAddressProviderAddress, + walletBalanceProviderAbi, + walletBalanceProviderConfig, +} from '@/config/contracts-generated' + +import { getContractAddress } from '../hooks/useContractAddress' +import { CheckedAddress } from '../types/CheckedAddress' +import { BaseUnitNumber } from '../types/NumericValues' + +export interface BalanceOptions { + wagmiConfig: Config + account?: Address + chainId: number +} + +export interface BalanceItem { + address: CheckedAddress + balanceBaseUnit: BaseUnitNumber +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function balances({ wagmiConfig, account, chainId }: BalanceOptions) { + const lendingPoolAddressProvider = getContractAddress(lendingPoolAddressProviderAddress, chainId) + + return queryOptions({ + queryKey: [ + { + functionName: 'getUserWalletBalances', + }, + lendingPoolAddressProvider, + account, + chainId, + ], + queryFn: async () => { + if (!account) { + return [] + } + + const [addresses, balances] = await readContract(wagmiConfig, { + address: getContractAddress(walletBalanceProviderConfig.address, chainId), + abi: walletBalanceProviderAbi, + functionName: 'getUserWalletBalances', + args: [lendingPoolAddressProvider, account], + }) + + return addresses + .map( + (address, index): BalanceItem => ({ + address: CheckedAddress(address), + balanceBaseUnit: BaseUnitNumber(balances[index]!), + }), + ) + .sort((a, b) => b.balanceBaseUnit.minus(a.balanceBaseUnit).toNumber()) + }, + }) +} diff --git a/packages/app/src/domain/wallet/createMockConnector.ts b/packages/app/src/domain/wallet/createMockConnector.ts new file mode 100644 index 000000000..d4adfa71c --- /dev/null +++ b/packages/app/src/domain/wallet/createMockConnector.ts @@ -0,0 +1,20 @@ +import { Account, Chain, Transport, WalletClient } from 'viem' +import { createConnector, CreateConnectorFn } from 'wagmi' +import { mock } from 'wagmi/connectors' + +export interface CreateMockConnectorOverrides { + name?: string +} + +export function createMockConnector( + walletClient: WalletClient, + overrides: CreateMockConnectorOverrides = {}, +): CreateConnectorFn { + return createConnector((config) => ({ + ...mock({ + accounts: [walletClient.account.address], + })(config), + getClient: async () => walletClient, + ...overrides, + })) +} diff --git a/packages/app/src/domain/wallet/useAutoConnect.ts b/packages/app/src/domain/wallet/useAutoConnect.ts new file mode 100644 index 000000000..7458dfb55 --- /dev/null +++ b/packages/app/src/domain/wallet/useAutoConnect.ts @@ -0,0 +1,17 @@ +import { useRef } from 'react' +import { Config } from 'wagmi' +import { connect } from 'wagmi/actions' + +export interface UseAutoConnectParams { + config: Config +} + +export function useAutoConnect({ config }: UseAutoConnectParams): void { + const firstRender = useRef(true) + if (firstRender.current && config.connectors.length > 0) { + firstRender.current = false + void connect(config, { + connector: config.connectors[0]!, + }) + } +} diff --git a/packages/app/src/domain/wallet/useConnectedAddress.ts b/packages/app/src/domain/wallet/useConnectedAddress.ts new file mode 100644 index 000000000..015dc8190 --- /dev/null +++ b/packages/app/src/domain/wallet/useConnectedAddress.ts @@ -0,0 +1,22 @@ +import { useAccount, useChainId } from 'wagmi' + +import { NotConnectedError } from '@/domain/errors/not-connected' + +import { CheckedAddress } from '../types/CheckedAddress' + +export interface ConnectedInfo { + chainId: number + account: CheckedAddress +} + +export function useConnectedAddress(): ConnectedInfo { + const { address } = useAccount() + const chainId = useChainId() + + if (!address) throw new NotConnectedError() + + return { + chainId, + account: CheckedAddress(address), + } +} diff --git a/packages/app/src/domain/wallet/useWalletInfo.ts b/packages/app/src/domain/wallet/useWalletInfo.ts new file mode 100644 index 000000000..d1210be35 --- /dev/null +++ b/packages/app/src/domain/wallet/useWalletInfo.ts @@ -0,0 +1,80 @@ +import { useSuspenseQuery } from '@tanstack/react-query' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { useMarketInfo } from '../market-info/useMarketInfo' +import { CheckedAddress } from '../types/CheckedAddress' +import { NormalizedUnitNumber } from '../types/NumericValues' +import { Token } from '../types/Token' +import { TokenSymbol } from '../types/TokenSymbol' +import { balances } from './balances' + +export interface WalletBalance { + balance: NormalizedUnitNumber + token: Token +} + +export interface WalletInfo { + isConnected: boolean + walletBalances: WalletBalance[] + + findWalletBalanceForToken: (token: Token) => NormalizedUnitNumber + findWalletBalanceForSymbol: (symbol: TokenSymbol) => NormalizedUnitNumber +} + +export function useWalletInfo(): WalletInfo { + const { address, isConnected } = useAccount() + const chainId = useChainId() + const wagmiConfig = useConfig() + const { marketInfo } = useMarketInfo() + + const { data: balanceData } = useSuspenseQuery({ + ...balances({ + wagmiConfig, + account: address && CheckedAddress(address), + chainId, + }), + }) + + const walletBalances: WalletBalance[] = balanceData + .map((balanceItem) => { + const token = marketInfo.findReserveByUnderlyingAsset(balanceItem.address)?.token + if (!token) { + return { + balance: undefined, + token: undefined, + } + } + + return { + balance: token.fromBaseUnit(balanceItem.balanceBaseUnit), + token, + } + }) + .filter((r) => r.token) + // @note: this map is here only to make TS happy :( + .map((r) => ({ + balance: r.balance!, + token: r.token!, + })) + + /* eslint-disable func-style */ + const findWalletBalanceForToken = (token: Token): NormalizedUnitNumber => { + return findWalletBalanceForSymbol(token.symbol) + } + const findWalletBalanceForSymbol = (symbol: TokenSymbol): NormalizedUnitNumber => { + const aTokenReserve = marketInfo.findReserveByATokenSymbol(symbol) + if (aTokenReserve) { + return aTokenReserve.aTokenBalance + } + + return walletBalances.find((wb) => wb.token.symbol === symbol)?.balance ?? NormalizedUnitNumber(0) + } + /* eslint-enable func-style */ + + return { + walletBalances, + isConnected, + findWalletBalanceForToken, + findWalletBalanceForSymbol, + } +} diff --git a/packages/app/src/features/actions/ActionsContainer.PageObject.ts b/packages/app/src/features/actions/ActionsContainer.PageObject.ts new file mode 100644 index 000000000..fb55b72ce --- /dev/null +++ b/packages/app/src/features/actions/ActionsContainer.PageObject.ts @@ -0,0 +1,118 @@ +import { expect, Locator, Page } from '@playwright/test' +import invariant from 'tiny-invariant' + +import { BasePageObject } from '@/test/e2e/BasePageObject' +import { isPage } from '@/test/e2e/utils' +import { testIds } from '@/ui/utils/testIds' + +import { ActionType } from './logic/types' + +export class ActionsPageObject extends BasePageObject { + constructor(pageOrLocator: Page | Locator) { + if (isPage(pageOrLocator)) { + super(pageOrLocator) + this.region = this.locatePanelByHeader('Actions') + } else { + super(pageOrLocator) + } + } + + // #region actions + async acceptAllActionsAction(expectedNumberOfActions: number): Promise { + await this.region.getByRole('button').first().waitFor({ state: 'visible' }) // waits for any button to appear + for (let i = 0; i < expectedNumberOfActions; i++) { + await this.region.getByRole('button', { disabled: false }).click() + } + } + + async acceptNextActionAction(): Promise { + await this.region.getByRole('button', { disabled: false }).click() + } + + async switchPreferPermitsAction(): Promise { + await this.region.getByRole('switch', { disabled: false }).click() + } + // #endregion actions + + // #region assertions + async expectActions(expectedActions: SimplifiedAction[], shortForm = false): Promise { + await this.expectNextActionEnabled() + + const actionLocators = await this.region.getByTestId(testIds.component.Action.title).all() + expect(actionLocators.length, 'Number of expected actions does not equal to the number of actual actions').toEqual( + expectedActions.length, + ) + for (const [index, actualAction] of actionLocators.entries()) { + const actualTitle = await actualAction.textContent() + const expectedAction = expectedActions[index] + invariant(expectedAction, `Expected action ${actualTitle} not found`) + + expect(actualTitle).toEqual(actionToTitle(expectedAction, shortForm)) + } + } + + async expectNextActionEnabled(): Promise { + await expect(this.region.getByRole('button', { disabled: false })).not.toBeDisabled() + } + + async expectActionsDisabled(): Promise { + await expect(this.region.getByRole('button', { disabled: true })).toBeDisabled() + } + + async expectNextAction(expectedAction: SimplifiedAction, shortForm = false): Promise { + await expect(async () => { + const buttons = await this.region.getByRole('button').all() + const titles = await this.region.getByTestId(testIds.component.Action.title).all() + // when action is complete, the action button is removed from the DOM + const index = titles.length - buttons.length + const title = await titles[index]?.textContent() + expect(title).toEqual(actionToTitle(expectedAction, shortForm)) + }).toPass() + } + // #endregion assertions +} + +interface SimplifiedAction { + type: ActionType + asset: string + amount: number +} + +function actionToTitle(action: SimplifiedAction, shortForm: boolean): string { + const prefix = getActionTitlePrefix(action) + + if (shortForm) { + return `${prefix} ${action.asset}` + } + + const formatter = new Intl.NumberFormat('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + // this is quite naive and might require improving in the future + return `${prefix} ${formatter.format(action.amount)} ${action.asset}` +} + +function getActionTitlePrefix(action: SimplifiedAction): string { + switch (action.type) { + case 'approve': + return 'Approve' + case 'deposit': + return 'Deposit' + case 'withdraw': + return 'Withdraw' + case 'approveDelegation': + return 'Approve delegation' + case 'borrow': + return 'Borrow' + case 'permit': + return 'Permit' + case 'repay': + return 'Repay with' + case 'setUseAsCollateral': + return '' // not used in collateral dialog tests + case 'setUserEMode': + return '' // not used in e-mode dialog tests + case 'approveExchange': + return 'Approve exchange' + case 'exchange': + return 'Exchange' + } +} diff --git a/packages/app/src/features/actions/ActionsContainer.tsx b/packages/app/src/features/actions/ActionsContainer.tsx new file mode 100644 index 000000000..225572329 --- /dev/null +++ b/packages/app/src/features/actions/ActionsContainer.tsx @@ -0,0 +1,56 @@ +import { withSuspense } from '@/ui/utils/withSuspense' +import { RequireKeys } from '@/utils/types' +import { useDebounce } from '@/utils/useDebounce' + +import { ActionsSkeleton } from './components/skeleton/ActionsSkeleton' +import { stringifyObjectivesDeep, stringifyObjectivesToStableActions } from './logic/stringifyObjectives' +import { Objective } from './logic/types' +import { useActionHandlers } from './logic/useActionHandlers' +import { ActionsView } from './views/ActionsView' + +export interface ActionsContainerProps { + objectives: Objective[] + onFinish?: () => void // called only once, after render when all actions are marked successful + variant?: 'default' | 'dialog' + enabled?: boolean +} + +function ActionsContainer({ + objectives, + onFinish, + variant = 'default', + enabled, +}: RequireKeys) { + const { handlers, actionsSettings, gasPrice, settingsDisabled } = useActionHandlers(objectives, { + enabled, + onFinish, + }) + + return ( + + ) +} + +// @note: rerenders ActionsContainer when actions change. This is needed to not break the rule of hooks +function ActionsContainerWithKey(props: ActionsContainerProps) { + const instantKey = stringifyObjectivesDeep(props.objectives) + const { debouncedValue: debouncedActions, isDebouncing } = useDebounce(props.objectives, instantKey) + const enabled = (props.enabled ?? true) && !isDebouncing + + return ( + + ) +} +const ActionsContainerWithSuspense = withSuspense(ActionsContainerWithKey, ActionsSkeleton) +export { ActionsContainerWithSuspense as ActionsContainer } diff --git a/packages/app/src/features/actions/components/action-row/ActionRow.tsx b/packages/app/src/features/actions/components/action-row/ActionRow.tsx new file mode 100644 index 000000000..c678eeb66 --- /dev/null +++ b/packages/app/src/features/actions/components/action-row/ActionRow.tsx @@ -0,0 +1,185 @@ +import { ReactNode } from 'react' + +import { assets } from '@/ui/assets' +import { Tooltip, TooltipContentShort, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { ActionButton } from '@/ui/molecules/action-button/ActionButton' +import { cn } from '@/ui/utils/style' +import { testIds } from '@/ui/utils/testIds' +import { useIsTruncated } from '@/ui/utils/useIsTruncated' + +import { ActionHandlerState } from '../../logic/types' +import { ActionRowVariant } from './types' + +function ActionRow({ children, className }: { children: ReactNode; className?: string }) { + return ( +

+ {children} +
+ ) +} + +function Index({ index }: { index: number }) { + return
{index}.
+} + +function Icon({ path, actionStatus }: { path: string; actionStatus?: ActionHandlerState['status'] }) { + if (actionStatus === 'success') { + return action-success-icon + } + return action-icon +} + +function Title({ + children, + icon, + actionStatus, +}: { + children: ReactNode + icon?: ReactNode + actionStatus: ActionHandlerState['status'] +}) { + return ( +
+ {icon &&
{icon}
} +

+ {children} +

+
+ ) +} + +function Description({ + children, + successMessage, + actionStatus, + variant, +}: { + children?: ReactNode + successMessage: string + actionStatus: ActionHandlerState['status'] + variant: ActionRowVariant +}) { + if (variant === 'compact') { + return null + } + + // success row message + if (actionStatus === 'success') { + return ( +
+ {successMessage} +
+ ) + } + + // description + if (['disabled', 'ready', 'loading'].includes(actionStatus)) { + return ( +
{children}
+ ) + } + + return null +} + +function ErrorWarning({ + variant, + actionHandlerState, +}: { + variant: ActionRowVariant + actionHandlerState: ActionHandlerState +}) { + if (variant === 'compact') { + return + } + return +} + +function ErrorWarningExtended({ actionHandlerState }: { actionHandlerState: ActionHandlerState }) { + const [errorTextRef, isTruncated] = useIsTruncated() + if (actionHandlerState.status !== 'error') { + return null + } + + return ( + + +
+
+ warning + Error: +

+ {actionHandlerState.message} +

+
+
+
+ {actionHandlerState.message} +
+ ) +} + +function ErrorWarningCompact({ actionHandlerState }: { actionHandlerState: ActionHandlerState }) { + if (actionHandlerState.status !== 'error') { + return null + } + + return ( + + +
+ warning + Error +
+
+ {actionHandlerState.message} +
+ ) +} + +function Action({ + children, + status, + onAction, +}: { + children: ReactNode + status: ActionHandlerState['status'] + onAction: () => void +}) { + return ( + + {children} + + ) +} + +ActionRow.Index = Index +ActionRow.Icon = Icon +ActionRow.Title = Title +ActionRow.Description = Description +ActionRow.ErrorWarning = ErrorWarning +ActionRow.Action = Action + +export { ActionRow } diff --git a/packages/app/src/features/actions/components/action-row/UpDownMarker.tsx b/packages/app/src/features/actions/components/action-row/UpDownMarker.tsx new file mode 100644 index 000000000..187649c26 --- /dev/null +++ b/packages/app/src/features/actions/components/action-row/UpDownMarker.tsx @@ -0,0 +1,23 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { assets } from '@/ui/assets' + +interface UpDownMarkerProps { + token: Token + value: NormalizedUnitNumber + direction: 'up' | 'down' +} + +export function UpDownMarker({ token, value, direction }: UpDownMarkerProps) { + const up = direction === 'up' + + return ( +
+ {`${direction}-sign`} +
+ {up ? '+' : '-'} + {`${token.format(value, { style: 'auto' })} ${token.symbol}`} +
+
+ ) +} diff --git a/packages/app/src/features/actions/components/action-row/types.ts b/packages/app/src/features/actions/components/action-row/types.ts new file mode 100644 index 000000000..9012e897d --- /dev/null +++ b/packages/app/src/features/actions/components/action-row/types.ts @@ -0,0 +1,10 @@ +import { ActionHandler } from '../../logic/types' + +export type ActionRowVariant = 'extended' | 'compact' + +export interface ActionRowBaseProps { + index: number + actionHandlerState: ActionHandler['state'] + onAction: ActionHandler['onAction'] + variant: ActionRowVariant +} diff --git a/packages/app/src/features/actions/components/action-row/utils.ts b/packages/app/src/features/actions/components/action-row/utils.ts new file mode 100644 index 000000000..e64bf47a2 --- /dev/null +++ b/packages/app/src/features/actions/components/action-row/utils.ts @@ -0,0 +1,13 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +import { ActionRowVariant } from './types' + +export function getFormattedValue(value: NormalizedUnitNumber, token: Token, variant: ActionRowVariant): string { + const formattedValue = token.format(value, { style: 'auto' }) + const includeAmount = variant === 'extended' + if (includeAmount) { + return `${formattedValue} ${token.symbol}` + } + return token.symbol +} diff --git a/packages/app/src/features/actions/components/actions-grid/ActionsGrid.tsx b/packages/app/src/features/actions/components/actions-grid/ActionsGrid.tsx new file mode 100644 index 000000000..95b167dcf --- /dev/null +++ b/packages/app/src/features/actions/components/actions-grid/ActionsGrid.tsx @@ -0,0 +1,58 @@ +import { ApproveActionRow } from '../../flavours/approve/ApproveActionRow' +import { ApproveDelegationActionRow } from '../../flavours/approve-delegation/ApproveDelegationActionRow' +import { ApproveExchangeActionRow } from '../../flavours/approve-exchange/ApproveExchangeActionRow' +import { BorrowActionRow } from '../../flavours/borrow/BorrowActionRow' +import { DepositActionRow } from '../../flavours/deposit/DepositActionRow' +import { ExchangeActionRow } from '../../flavours/exchange/ExchangeActionRow' +import { RepayActionRow } from '../../flavours/repay/RepayActionRow' +import { SetUseAsCollateralActionRow } from '../../flavours/set-use-as-collateral/SetUseAsCollateralActionRow' +import { SetUserEModeActionRow } from '../../flavours/set-user-e-mode/SetUserEModeActionRow' +import { WithdrawActionRow } from '../../flavours/withdraw/WithdrawActionRow' +import { ActionHandler } from '../../logic/types' +import { ActionRowVariant } from '../action-row/types' + +interface ActionsGridProps { + actionHandlers: ActionHandler[] + variant: ActionRowVariant +} + +export function ActionsGrid({ actionHandlers, variant }: ActionsGridProps) { + return ( +
+ {actionHandlers.map((handler, index) => { + const props = { + key: index, + index: index + 1, + actionHandlerState: handler.state, + onAction: handler.onAction, + variant, + } + + switch (handler.action.type) { + case 'approve': + return + case 'approveDelegation': + return + case 'approveExchange': + return + case 'borrow': + return + case 'deposit': + return + case 'exchange': + return + case 'permit': + return + case 'repay': + return + case 'setUseAsCollateral': + return + case 'setUserEMode': + return + case 'withdraw': + return + } + })} +
+ ) +} diff --git a/packages/app/src/features/actions/components/actions-grid/stories/AllActions.stories.ts b/packages/app/src/features/actions/components/actions-grid/stories/AllActions.stories.ts new file mode 100644 index 000000000..4c90955ac --- /dev/null +++ b/packages/app/src/features/actions/components/actions-grid/stories/AllActions.stories.ts @@ -0,0 +1,143 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { ActionsGrid } from '../ActionsGrid' +import { allActionHandlers } from './allActionHandlers' + +const meta: Meta = { + title: 'Features/Actions/ActionsGrid/AllActions', + component: ActionsGrid, + decorators: [WithTooltipProvider(), WithClassname('max-w-3xl')], +} + +export default meta +type Story = StoryObj + +// Extended variant +export const AllActionsExtended: Story = { + name: 'All Actions (Extended)', + args: { + actionHandlers: Object.values(allActionHandlers), + variant: 'extended', + }, +} +export const AllActionExtendedMobile = { name: 'All Actions (Extended, Mobile)', ...getMobileStory(AllActionsExtended) } +export const AllActionsExtendedTablet = { + name: 'All Actions (Extended, Tablet)', + ...getTabletStory(AllActionsExtended), +} + +export const AllActionsExtendedError: Story = { + name: 'All Actions (Extended, Error)', + args: { + actionHandlers: Object.values(allActionHandlers).map((handler) => ({ + ...handler, + state: { status: 'error', message: 'Transaction rejected by user. This is lengthy error message. Layout test.' }, + })), + variant: 'extended', + }, +} + +export const AllActionsErrorMobile = { + name: 'All Actions (Extended, Error, Mobile)', + ...getMobileStory(AllActionsExtendedError), +} +export const AllActionsErrorTablet = { + name: 'All Actions (Extended, Error, Tablet)', + ...getTabletStory(AllActionsExtendedError), +} + +export const AllActionsExtendedLoading: Story = { + name: 'All Actions (Extended, Loading)', + args: { + actionHandlers: Object.values(allActionHandlers).map((handler) => ({ ...handler, state: { status: 'loading' } })), + variant: 'extended', + }, +} +export const AllActionsLoadingMobile = { + name: 'All Actions (Extended, Loading, Mobile)', + ...getMobileStory(AllActionsExtendedLoading), +} +export const AllActionsLoadingTablet = { + name: 'All Actions (Extended, Loading, Tablet)', + ...getTabletStory(AllActionsExtendedLoading), +} + +export const AllActionsExtendedSuccess: Story = { + name: 'All Actions (Extended, Success)', + args: { + actionHandlers: Object.values(allActionHandlers).map((handler) => ({ ...handler, state: { status: 'success' } })), + variant: 'extended', + }, +} +export const AllActionsSuccessMobile = { + name: 'All Actions (Extended, Success, Mobile)', + ...getMobileStory(AllActionsExtendedSuccess), +} +export const AllActionsSuccessTablet = { + name: 'All Actions (Extended, Success, Tablet)', + ...getTabletStory(AllActionsExtendedSuccess), +} + +// Compact variant +export const AllActionsCompact: Story = { + name: 'All Actions (Compact)', + args: { + actionHandlers: Object.values(allActionHandlers), + variant: 'compact', + }, +} +export const AllActionsCompactMobile = { name: 'All Actions (Compact, Mobile)', ...getMobileStory(AllActionsCompact) } +export const AllActionsCompactTablet = { name: 'All Actions (Compact, Tablet)', ...getTabletStory(AllActionsCompact) } + +export const AllActionsCompactError: Story = { + name: 'All Actions (Compact, Error)', + args: { + actionHandlers: Object.values(allActionHandlers).map((handler) => ({ + ...handler, + state: { status: 'error', message: 'Transaction rejected by user. This is lengthy error message. Layout test.' }, + })), + variant: 'compact', + }, +} +export const AllActionsCompactErrorMobile = { + name: 'All Actions (Compact, Error, Mobile)', + ...getMobileStory(AllActionsCompactError), +} +export const AllActionsCompactErrorTablet = { + name: 'All Actions (Compact, Error, Tablet)', + ...getTabletStory(AllActionsCompactError), +} + +export const AllActionsCompactLoading: Story = { + name: 'All Actions (Compact, Loading)', + args: { + actionHandlers: Object.values(allActionHandlers).map((handler) => ({ ...handler, state: { status: 'loading' } })), + variant: 'compact', + }, +} +export const AllActionsCompactLoadingMobile = { + name: 'All Actions (Compact, Loading, Mobile)', + ...getMobileStory(AllActionsCompactLoading), +} +export const AllActionsCompactLoadingTablet = { + name: 'All Actions (Compact, Loading, Tablet)', + ...getTabletStory(AllActionsCompactLoading), +} + +export const AllActionsCompactSuccess: Story = { + name: 'All Actions (Compact, Success)', + args: { + actionHandlers: Object.values(allActionHandlers).map((handler) => ({ ...handler, state: { status: 'success' } })), + variant: 'compact', + }, +} +export const AllActionsCompactSuccessMobile = { + name: 'All Actions (Compact, Success, Mobile)', + ...getMobileStory(AllActionsCompactSuccess), +} +export const AllActionsCompactSuccessTablet = { + name: 'All Actions (Compact, Success, Tablet)', + ...getTabletStory(AllActionsCompactSuccess), +} diff --git a/packages/app/src/features/actions/components/actions-grid/stories/AllHandlerStates.stories.ts b/packages/app/src/features/actions/components/actions-grid/stories/AllHandlerStates.stories.ts new file mode 100644 index 000000000..7f82eadaf --- /dev/null +++ b/packages/app/src/features/actions/components/actions-grid/stories/AllHandlerStates.stories.ts @@ -0,0 +1,419 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { ActionsGrid } from '../ActionsGrid' +import { allActionHandlers } from './allActionHandlers' + +const meta: Meta = { + title: 'Features/Actions/ActionsGrid/AllHandlersStates', + component: ActionsGrid, + decorators: [WithTooltipProvider(), WithClassname('max-w-3xl')], +} + +export default meta +type Story = StoryObj + +const message = 'Transaction rejected by user. This is lengthy error message. Layout test.' + +// Extended variant +export const ApproveExtended: Story = { + name: 'Approve (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.approve, state: { status: 'ready' } }, + { ...allActionHandlers.approve, state: { status: 'loading' } }, + { ...allActionHandlers.approve, state: { status: 'success' } }, + { ...allActionHandlers.approve, state: { status: 'disabled' } }, + { ...allActionHandlers.approve, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const ApproveExtendedMobile = { name: 'Approve (Extended, Mobile)', ...getMobileStory(ApproveExtended) } +export const ApproveExtendedTablet = { name: 'Approve (Extended, Tablet)', ...getTabletStory(ApproveExtended) } + +export const PermitExtended: Story = { + name: 'Permit (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.permit, state: { status: 'ready' } }, + { ...allActionHandlers.permit, state: { status: 'loading' } }, + { ...allActionHandlers.permit, state: { status: 'success' } }, + { ...allActionHandlers.permit, state: { status: 'disabled' } }, + { ...allActionHandlers.permit, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const PermitExtendedMobile = { name: 'Permit (Extended, Mobile)', ...getMobileStory(PermitExtended) } +export const PermitExtendedTablet = { name: 'Permit (Extended, Tablet)', ...getTabletStory(PermitExtended) } + +export const ApproveDelegationExtended: Story = { + name: 'Approve Delegation (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.approveDelegation, state: { status: 'ready' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'loading' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'success' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'disabled' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const ApproveDelegationExtendedMobile = { + name: 'Approve Delegation (Extended, Mobile)', + ...getMobileStory(ApproveDelegationExtended), +} +export const ApproveDelegationExtendedTablet = { + name: 'Approve Delegation (Extended, Tablet)', + ...getTabletStory(ApproveDelegationExtended), +} + +export const ApproveExchangeExtended: Story = { + name: 'Approve Exchange (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.approveExchange, state: { status: 'ready' } }, + { ...allActionHandlers.approveExchange, state: { status: 'loading' } }, + { ...allActionHandlers.approveExchange, state: { status: 'success' } }, + { ...allActionHandlers.approveExchange, state: { status: 'disabled' } }, + { ...allActionHandlers.approveExchange, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const ApproveExchangeExtendedMobile = { + name: 'Approve Exchange (Extended, Mobile)', + ...getMobileStory(ApproveExchangeExtended), +} +export const ApproveExchangeExtendedTablet = { + name: 'Approve Exchange (Extended, Tablet)', + ...getTabletStory(ApproveExchangeExtended), +} + +export const BorrowExtended: Story = { + name: 'Borrow (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.borrow, state: { status: 'ready' } }, + { ...allActionHandlers.borrow, state: { status: 'loading' } }, + { ...allActionHandlers.borrow, state: { status: 'success' } }, + { ...allActionHandlers.borrow, state: { status: 'disabled' } }, + { ...allActionHandlers.borrow, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const BorrowExtendedMobile = { name: 'Borrow (Extended, Mobile)', ...getMobileStory(BorrowExtended) } +export const BorrowExtendedTablet = { name: 'Borrow (Extended, Tablet)', ...getTabletStory(BorrowExtended) } + +export const DepositExtended: Story = { + name: 'Deposit (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.deposit, state: { status: 'ready' } }, + { ...allActionHandlers.deposit, state: { status: 'loading' } }, + { ...allActionHandlers.deposit, state: { status: 'success' } }, + { ...allActionHandlers.deposit, state: { status: 'disabled' } }, + { ...allActionHandlers.deposit, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const DepositExtendedMobile = { name: 'Deposit (Extended, Mobile)', ...getMobileStory(DepositExtended) } +export const DepositExtendedTablet = { name: 'Deposit (Extended, Tablet)', ...getTabletStory(DepositExtended) } + +export const RepayExtended: Story = { + name: 'Repay (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.repay, state: { status: 'ready' } }, + { ...allActionHandlers.repay, state: { status: 'loading' } }, + { ...allActionHandlers.repay, state: { status: 'success' } }, + { ...allActionHandlers.repay, state: { status: 'disabled' } }, + { ...allActionHandlers.repay, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const RepayExtendedMobile = { name: 'Repay (Extended, Mobile)', ...getMobileStory(RepayExtended) } +export const RepayExtendedTablet = { name: 'Repay (Extended, Tablet)', ...getTabletStory(RepayExtended) } + +export const SetUseAsCollateralExtended: Story = { + name: 'Set Use As Collateral (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.setUseAsCollateral, state: { status: 'ready' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'loading' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'success' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'disabled' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const SetUseAsCollateralExtendedMobile = { + name: 'Set Use As Collateral (Extended, Mobile)', + ...getMobileStory(SetUseAsCollateralExtended), +} +export const SetUseAsCollateralExtendedTablet = { + name: 'Set Use As Collateral (Extended, Tablet)', + ...getTabletStory(SetUseAsCollateralExtended), +} + +export const SetUserEModeExtended: Story = { + name: 'Set User EMode (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.setUserEMode, state: { status: 'ready' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'loading' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'success' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'disabled' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const SetUserEModeExtendedMobile = { + name: 'Set User EMode (Extended, Mobile)', + ...getMobileStory(SetUserEModeExtended), +} +export const SetUserEModeExtendedTablet = { + name: 'Set User EMode (Extended, Tablet)', + ...getTabletStory(SetUserEModeExtended), +} + +export const WithdrawExtended: Story = { + name: 'Withdraw (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.withdraw, state: { status: 'ready' } }, + { ...allActionHandlers.withdraw, state: { status: 'loading' } }, + { ...allActionHandlers.withdraw, state: { status: 'success' } }, + { ...allActionHandlers.withdraw, state: { status: 'disabled' } }, + { ...allActionHandlers.withdraw, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const WithdrawExtendedMobile = { name: 'Withdraw (Extended, Mobile)', ...getMobileStory(WithdrawExtended) } +export const WithdrawExtendedTablet = { name: 'Withdraw (Extended, Tablet)', ...getTabletStory(WithdrawExtended) } + +export const ExchangeExtended: Story = { + name: 'Exchange (Extended)', + args: { + actionHandlers: [ + { ...allActionHandlers.exchange, state: { status: 'ready' } }, + { ...allActionHandlers.exchange, state: { status: 'loading' } }, + { ...allActionHandlers.exchange, state: { status: 'success' } }, + { ...allActionHandlers.exchange, state: { status: 'disabled' } }, + { ...allActionHandlers.exchange, state: { status: 'error', message } }, + ], + variant: 'extended', + }, +} +export const ExchangeExtendedMobile = { name: 'Exchange (Extended, Mobile)', ...getMobileStory(ExchangeExtended) } +export const ExchangeExtendedTablet = { name: 'Exchange (Extended, Tablet)', ...getTabletStory(ExchangeExtended) } + +// Compact variant +export const ApproveCompact: Story = { + name: 'Approve (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.approve, state: { status: 'ready' } }, + { ...allActionHandlers.approve, state: { status: 'loading' } }, + { ...allActionHandlers.approve, state: { status: 'success' } }, + { ...allActionHandlers.approve, state: { status: 'disabled' } }, + { ...allActionHandlers.approve, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const ApproveCompactMobile = { name: 'Approve (Compact, Mobile)', ...getMobileStory(ApproveCompact) } +export const ApproveCompactTablet = { name: 'Approve (Compact, Tablet)', ...getTabletStory(ApproveCompact) } + +export const PermitCompact: Story = { + name: 'Permit (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.permit, state: { status: 'ready' } }, + { ...allActionHandlers.permit, state: { status: 'loading' } }, + { ...allActionHandlers.permit, state: { status: 'success' } }, + { ...allActionHandlers.permit, state: { status: 'disabled' } }, + { ...allActionHandlers.permit, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const PermitCompactMobile = { name: 'Permit (Compact, Mobile)', ...getMobileStory(PermitCompact) } +export const PermitCompactTablet = { name: 'Permit (Compact, Tablet)', ...getTabletStory(PermitCompact) } + +export const ApproveDelegationCompact: Story = { + name: 'Approve Delegation (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.approveDelegation, state: { status: 'ready' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'loading' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'success' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'disabled' } }, + { ...allActionHandlers.approveDelegation, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const ApproveDelegationCompactMobile = { + name: 'Approve Delegation (Compact, Mobile)', + ...getMobileStory(ApproveDelegationCompact), +} +export const ApproveDelegationCompactTablet = { + name: 'Approve Delegation (Compact, Tablet)', + ...getTabletStory(ApproveDelegationCompact), +} + +export const ApproveExchangeCompact: Story = { + name: 'Approve Exchange (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.approveExchange, state: { status: 'ready' } }, + { ...allActionHandlers.approveExchange, state: { status: 'loading' } }, + { ...allActionHandlers.approveExchange, state: { status: 'success' } }, + { ...allActionHandlers.approveExchange, state: { status: 'disabled' } }, + { ...allActionHandlers.approveExchange, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const ApproveExchangeCompactMobile = { + name: 'Approve Exchange (Compact, Mobile)', + ...getMobileStory(ApproveExchangeCompact), +} +export const ApproveExchangeCompactTablet = { + name: 'Approve Exchange (Compact, Tablet)', + ...getTabletStory(ApproveExchangeCompact), +} + +export const BorrowCompact: Story = { + name: 'Borrow (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.borrow, state: { status: 'ready' } }, + { ...allActionHandlers.borrow, state: { status: 'loading' } }, + { ...allActionHandlers.borrow, state: { status: 'success' } }, + { ...allActionHandlers.borrow, state: { status: 'disabled' } }, + { ...allActionHandlers.borrow, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const BorrowCompactMobile = { name: 'Borrow (Compact, Mobile)', ...getMobileStory(BorrowCompact) } +export const BorrowCompactTablet = { name: 'Borrow (Compact, Tablet)', ...getTabletStory(BorrowCompact) } + +export const DepositCompact: Story = { + name: 'Deposit (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.deposit, state: { status: 'ready' } }, + { ...allActionHandlers.deposit, state: { status: 'loading' } }, + { ...allActionHandlers.deposit, state: { status: 'success' } }, + { ...allActionHandlers.deposit, state: { status: 'disabled' } }, + { ...allActionHandlers.deposit, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const DepositCompactMobile = { name: 'Deposit (Compact, Mobile)', ...getMobileStory(DepositCompact) } +export const DepositCompactTablet = { name: 'Deposit (Compact, Tablet)', ...getTabletStory(DepositCompact) } + +export const RepayCompact: Story = { + name: 'Repay (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.repay, state: { status: 'ready' } }, + { ...allActionHandlers.repay, state: { status: 'loading' } }, + { ...allActionHandlers.repay, state: { status: 'success' } }, + { ...allActionHandlers.repay, state: { status: 'disabled' } }, + { ...allActionHandlers.repay, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const RepayCompactMobile = { name: 'Repay (Compact, Mobile)', ...getMobileStory(RepayCompact) } +export const RepayCompactTablet = { name: 'Repay (Compact, Tablet)', ...getTabletStory(RepayCompact) } + +export const SetUseAsCollateralCompact: Story = { + name: 'Set Use As Collateral (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.setUseAsCollateral, state: { status: 'ready' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'loading' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'success' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'disabled' } }, + { ...allActionHandlers.setUseAsCollateral, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const SetUseAsCollateralCompactMobile = { + name: 'Set Use As Collateral (Compact, Mobile)', + ...getMobileStory(SetUseAsCollateralCompact), +} +export const SetUseAsCollateralCompactTablet = { + name: 'Set Use As Collateral (Compact, Tablet)', + ...getTabletStory(SetUseAsCollateralCompact), +} + +export const SetUserEModeCompact: Story = { + name: 'Set User EMode (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.setUserEMode, state: { status: 'ready' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'loading' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'success' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'disabled' } }, + { ...allActionHandlers.setUserEMode, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const SetUserEModeCompactMobile = { + name: 'Set User EMode (Compact, Mobile)', + ...getMobileStory(SetUserEModeCompact), +} +export const SetUserEModeCompactTablet = { + name: 'Set User EMode (Compact, Tablet)', + ...getTabletStory(SetUserEModeCompact), +} + +export const WithdrawCompact: Story = { + name: 'Withdraw (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.withdraw, state: { status: 'ready' } }, + { ...allActionHandlers.withdraw, state: { status: 'loading' } }, + { ...allActionHandlers.withdraw, state: { status: 'success' } }, + { ...allActionHandlers.withdraw, state: { status: 'disabled' } }, + { ...allActionHandlers.withdraw, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const WithdrawCompactMobile = { name: 'Withdraw (Compact, Mobile)', ...getMobileStory(WithdrawCompact) } +export const WithdrawCompactTablet = { name: 'Withdraw (Compact, Tablet)', ...getTabletStory(WithdrawCompact) } + +export const ExchangeCompact: Story = { + name: 'Exchange (Compact)', + args: { + actionHandlers: [ + { ...allActionHandlers.exchange, state: { status: 'ready' } }, + { ...allActionHandlers.exchange, state: { status: 'loading' } }, + { ...allActionHandlers.exchange, state: { status: 'success' } }, + { ...allActionHandlers.exchange, state: { status: 'disabled' } }, + { ...allActionHandlers.exchange, state: { status: 'error', message } }, + ], + variant: 'compact', + }, +} +export const ExchangeCompactMobile = { name: 'Exchange (Compact, Mobile)', ...getMobileStory(ExchangeCompact) } +export const ExchangeCompactTablet = { name: 'Exchange (Compact, Tablet)', ...getTabletStory(ExchangeCompact) } diff --git a/packages/app/src/features/actions/components/actions-grid/stories/EasyBorrowFlow.stories.ts b/packages/app/src/features/actions/components/actions-grid/stories/EasyBorrowFlow.stories.ts new file mode 100644 index 000000000..707e0c0cd --- /dev/null +++ b/packages/app/src/features/actions/components/actions-grid/stories/EasyBorrowFlow.stories.ts @@ -0,0 +1,109 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { zeroAddress } from 'viem' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { ActionHandler } from '@/features/actions/logic/types' + +import { ActionsGrid } from '../ActionsGrid' +import { allActionHandlers } from './allActionHandlers' + +const meta: Meta = { + title: 'Features/Actions/ActionsGrid/EasyBorrowFlow', + component: ActionsGrid, + decorators: [WithTooltipProvider(), WithClassname('max-w-3xl')], +} + +export default meta +type Story = StoryObj + +const actionHandlers: ActionHandler[] = [ + { + action: { + type: 'approve', + token: tokens.wstETH, + spender: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + }, + state: { status: 'success' }, + onAction: () => {}, + }, + { + action: { + type: 'deposit', + token: tokens.wstETH, + value: NormalizedUnitNumber(1), + }, + state: { status: 'success' }, + onAction: () => {}, + }, + { + action: { + type: 'approve', + token: tokens.rETH, + spender: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + }, + state: { status: 'loading' }, + onAction: () => {}, + }, + { + action: { + type: 'deposit', + token: tokens.rETH, + value: NormalizedUnitNumber(1), + }, + state: { status: 'disabled' }, + onAction: () => {}, + }, + { ...allActionHandlers.borrow, state: { status: 'disabled' } }, +] + +export const EasyBorrowFlow: Story = { + name: 'Easy Borrow Flow', + args: { + actionHandlers, + variant: 'extended', + }, +} + +export const EasyBorrowFlowMobile: Story = { + name: 'Easy Borrow Flow (Mobile)', + ...getMobileStory(EasyBorrowFlow), +} + +export const EasyBorrowFlowTablet: Story = { + name: 'Easy Borrow Flow (Tablet)', + ...getTabletStory(EasyBorrowFlow), +} + +export const EasyBorrowFlowWithError: Story = { + name: 'Easy Borrow Flow (With Error)', + args: { + ...EasyBorrowFlow.args, + actionHandlers: actionHandlers.map((handler, index) => { + if (index === 2) { + return { + ...handler, + state: { + status: 'error', + message: 'Transaction rejected by user. This is lengthy error message. Layout test.', + }, + } + } + return handler + }), + }, +} + +export const EasyBorrowFlowWithErrorMobile: Story = { + name: 'Easy Borrow Flow (With Error, Mobile)', + ...getMobileStory(EasyBorrowFlowWithError), +} +export const EasyBorrowFlowWithErrorTablet: Story = { + name: 'Easy Borrow Flow (With Error, Tablet)', + ...getTabletStory(EasyBorrowFlowWithError), +} diff --git a/packages/app/src/features/actions/components/actions-grid/stories/allActionHandlers.ts b/packages/app/src/features/actions/components/actions-grid/stories/allActionHandlers.ts new file mode 100644 index 000000000..3a0d481ab --- /dev/null +++ b/packages/app/src/features/actions/components/actions-grid/stories/allActionHandlers.ts @@ -0,0 +1,170 @@ +import { tokens } from '@storybook/tokens' +import { fakeBigInt } from '@storybook/utils' +import { zeroAddress } from 'viem' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { BaseUnitNumber, NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { ActionHandler, ActionType } from '@/features/actions/logic/types' +import { getMockReserve } from '@/test/integration/constants' + +export const allActionHandlers: Record = { + approve: { + action: { + type: 'approve', + token: tokens.wstETH, + spender: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + permit: { + action: { + type: 'permit', + token: tokens.wstETH, + spender: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + approveDelegation: { + action: { + type: 'approveDelegation', + token: tokens.WETH, + debtTokenAddress: tokens.WETH.address, + delegatee: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + approveExchange: { + action: { + type: 'approveExchange', + swapParams: { + fromToken: tokens.USDC, + toToken: tokens.sDAI, + type: 'direct', + value: NormalizedUnitNumber(1), + maxSlippage: Percentage(0.005), + }, + swapInfo: { + status: 'success', + data: { + fromToken: tokens.USDC.address, + toToken: tokens.sDAI.address, + type: 'direct', + txRequest: { + data: '0x', + from: zeroAddress, + gasLimit: fakeBigInt, + gasPrice: fakeBigInt, + to: zeroAddress, + value: fakeBigInt, + }, + estimate: { + feeCostsUSD: NormalizedUnitNumber(0), + fromAmount: BaseUnitNumber(1e6), + toAmount: BaseUnitNumber(1e18), + }, + }, + error: null, + }, + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + borrow: { + action: { + type: 'borrow', + token: tokens.DAI, + value: NormalizedUnitNumber(1233.34), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + deposit: { + action: { + type: 'deposit', + token: tokens.wstETH, + value: NormalizedUnitNumber(1), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + repay: { + action: { + type: 'repay', + reserve: getMockReserve({ + token: tokens.DAI, + }), + value: NormalizedUnitNumber(1233.34), + useAToken: false, + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + setUseAsCollateral: { + action: { + type: 'setUseAsCollateral', + token: tokens.rETH, + useAsCollateral: true, + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + setUserEMode: { + action: { + type: 'setUserEMode', + eModeCategoryId: 1, + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + withdraw: { + action: { + type: 'withdraw', + token: tokens.wstETH, + value: NormalizedUnitNumber(12), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + exchange: { + action: { + type: 'exchange', + swapParams: { + fromToken: tokens.USDC, + toToken: tokens.sDAI, + type: 'direct', + value: NormalizedUnitNumber(1023), + maxSlippage: Percentage(0.005), + }, + swapInfo: { + data: { + fromToken: tokens.USDC.address, + toToken: tokens.sDAI.address, + type: 'direct', + estimate: { + feeCostsUSD: NormalizedUnitNumber(3.33), + fromAmount: BaseUnitNumber(1e6), + toAmount: BaseUnitNumber(1e18), + }, + txRequest: { + data: '0x', + from: zeroAddress, + gasLimit: fakeBigInt, + gasPrice: fakeBigInt, + to: zeroAddress, + value: fakeBigInt, + }, + }, + status: 'success', + }, + value: NormalizedUnitNumber(1023), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, +} diff --git a/packages/app/src/features/actions/components/settings-dialog/ActionSettings.stories.ts b/packages/app/src/features/actions/components/settings-dialog/ActionSettings.stories.ts new file mode 100644 index 000000000..989551e70 --- /dev/null +++ b/packages/app/src/features/actions/components/settings-dialog/ActionSettings.stories.ts @@ -0,0 +1,19 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { ActionSettings } from './ActionSettings' + +const meta: Meta = { + title: 'Features/Actions/ActionSettings', + component: ActionSettings, + args: { + openSettings: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = {} +export const Mobile: Story = getMobileStory(Default) +export const Tablet: Story = getTabletStory(Default) diff --git a/packages/app/src/features/actions/components/settings-dialog/ActionSettings.tsx b/packages/app/src/features/actions/components/settings-dialog/ActionSettings.tsx new file mode 100644 index 000000000..4d589fe40 --- /dev/null +++ b/packages/app/src/features/actions/components/settings-dialog/ActionSettings.tsx @@ -0,0 +1,26 @@ +import { Settings } from 'lucide-react' + +import { Button } from '@/ui/atoms/button/Button' +import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/ui/atoms/dialog/Dialog' + +interface ActionSettingsProps { + openSettings: () => void +} + +export function ActionSettings({ openSettings }: ActionSettingsProps) { + return ( + + + + ) +} diff --git a/packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.stories.ts b/packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.stories.ts new file mode 100644 index 000000000..c0e2880e9 --- /dev/null +++ b/packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.stories.ts @@ -0,0 +1,18 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { SettingsDialogContent } from './SettingsDialogContent' + +const meta: Meta = { + title: 'Features/Actions/SettingsDialogContent', + component: SettingsDialogContent, + decorators: [WithClassname('max-w-xl')], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.tsx b/packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.tsx new file mode 100644 index 000000000..e0b497860 --- /dev/null +++ b/packages/app/src/features/actions/components/settings-dialog/SettingsDialogContent.tsx @@ -0,0 +1,33 @@ +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' +import { Switch } from '@/ui/atoms/switch/Switch' + +export interface SettingsDialogContentProps {} + +export function SettingsDialogContent({}: SettingsDialogContentProps) { + return ( +
+ Settings + +
+

Use permits when available

+

+ Permits are a way to save gas by allowing a contract to execute multiple actions in a single transaction. +

+
+ {}} /> +
+ +
+

Slippage

+

+ Your swap transaction will revert if the price changes unfavourably by more than this percentage. +

+
+
+ Slippage input placeholder +
+
+
+ ) +} diff --git a/packages/app/src/features/actions/components/skeleton/ActionsSkeleton.stories.tsx b/packages/app/src/features/actions/components/skeleton/ActionsSkeleton.stories.tsx new file mode 100644 index 000000000..1c7d2a7ba --- /dev/null +++ b/packages/app/src/features/actions/components/skeleton/ActionsSkeleton.stories.tsx @@ -0,0 +1,15 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { ActionsSkeleton } from './ActionsSkeleton' + +const meta: Meta = { + title: 'Features/Actions/Skeleton', + component: ActionsSkeleton, +} + +export default meta +type Story = StoryObj + +export const ActionsSkeletonStory: Story = { + name: 'ActionsSkeleton', +} diff --git a/packages/app/src/features/actions/components/skeleton/ActionsSkeleton.tsx b/packages/app/src/features/actions/components/skeleton/ActionsSkeleton.tsx new file mode 100644 index 000000000..09eaa54f6 --- /dev/null +++ b/packages/app/src/features/actions/components/skeleton/ActionsSkeleton.tsx @@ -0,0 +1,22 @@ +import { Fragment } from 'react' + +import { Panel } from '@/ui/atoms/panel/Panel' +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' + +export function ActionsSkeleton() { + const rows = 3 + return ( + +
+ + {Array.from({ length: rows }).map((_, index) => ( + + + {index !== rows - 1 &&
} + + ))} + +
+ + ) +} diff --git a/packages/app/src/features/actions/flavours/approve-delegation/ApproveDelegationActionRow.tsx b/packages/app/src/features/actions/flavours/approve-delegation/ApproveDelegationActionRow.tsx new file mode 100644 index 000000000..b3d7340cd --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve-delegation/ApproveDelegationActionRow.tsx @@ -0,0 +1,46 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { getFormattedValue } from '../../components/action-row/utils' +import { ApproveDelegationAction } from './types' + +export interface ApproveDelegationActionRowProps extends ActionRowBaseProps { + action: ApproveDelegationAction +} + +export function ApproveDelegationActionRow({ + index, + action, + actionHandlerState, + onAction, + variant, +}: ApproveDelegationActionRowProps) { + const status = actionHandlerState.status + const formattedValue = getFormattedValue(action.value, action.token, variant) + + return ( + + + + + + } actionStatus={status}> + Approve delegation {formattedValue} + + + + + + + + Approve + + + ) +} diff --git a/packages/app/src/features/actions/flavours/approve-delegation/types.ts b/packages/app/src/features/actions/flavours/approve-delegation/types.ts new file mode 100644 index 000000000..333fdd4b5 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve-delegation/types.ts @@ -0,0 +1,11 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export interface ApproveDelegationAction { + type: 'approveDelegation' + token: Token + debtTokenAddress: CheckedAddress + delegatee: CheckedAddress + value: NormalizedUnitNumber +} diff --git a/packages/app/src/features/actions/flavours/approve-delegation/useCreateApproveDelegationHandler.ts b/packages/app/src/features/actions/flavours/approve-delegation/useCreateApproveDelegationHandler.ts new file mode 100644 index 000000000..bb31da433 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve-delegation/useCreateApproveDelegationHandler.ts @@ -0,0 +1,66 @@ +import { FetchStatus } from '@tanstack/react-query' + +import { UseWriteResult } from '@/domain/hooks/useWrite' +import { useHasEnoughBorrowAllowance } from '@/domain/market-operations/borrow-allowance/useHasEnoughBorrowAllowance' +import { useApproveDelegation } from '@/domain/market-operations/useApproveDelegation' + +import { ActionHandler, ActionHandlerState } from '../../logic/types' +import { mapWriteResultToActionState } from '../../logic/utils' +import { ApproveDelegationAction } from './types' + +export interface UseCreateApproveDelegationHandlerOptions { + enabled: boolean +} + +export function useCreateApproveDelegationHandler( + action: ApproveDelegationAction, + { enabled }: UseCreateApproveDelegationHandlerOptions, +): ActionHandler { + const { data: hasEnoughAllowance, fetchStatus: hasEnoughAllowanceFetchStatus } = useHasEnoughBorrowAllowance({ + debtTokenAddress: action.debtTokenAddress, + toUser: action.delegatee, + value: action.token.toBaseUnit(action.value), + enabled, + }) + + const approve = useApproveDelegation({ + debtTokenAddress: action.debtTokenAddress, + delegatee: action.delegatee, + value: action.token.toBaseUnit(action.value), + enabled: enabled && hasEnoughAllowance === false, + }) + + const state = mapStatusesToActionState(hasEnoughAllowance, hasEnoughAllowanceFetchStatus, approve, enabled) + + return { + action, + state, + onAction: approve.write, + } +} + +function mapStatusesToActionState( + hasEnoughBorrowAllowance: boolean | undefined, + hasEnoughBorrowAllowanceFetchStatus: FetchStatus, + approve: UseWriteResult, + enabled: boolean, +): ActionHandlerState { + if (!enabled) { + return { status: 'disabled' } + } + + // user already has allowance + if (hasEnoughBorrowAllowance && hasEnoughBorrowAllowanceFetchStatus === 'idle') { + return { status: 'success' } + } + // user went through the approval flow but manually tweaked approval level and it's still too low + if (approve.status.kind === 'success' && hasEnoughBorrowAllowance === false) { + if (hasEnoughBorrowAllowanceFetchStatus === 'fetching') { + return { status: 'loading' } + } + + return { status: 'ready' } + } + + return mapWriteResultToActionState(approve) +} diff --git a/packages/app/src/features/actions/flavours/approve-exchange/ApproveExchangeActionRow.tsx b/packages/app/src/features/actions/flavours/approve-exchange/ApproveExchangeActionRow.tsx new file mode 100644 index 000000000..154d8b377 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve-exchange/ApproveExchangeActionRow.tsx @@ -0,0 +1,47 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { getFormattedValue } from '../../components/action-row/utils' +import { ApproveExchangeAction } from './types' + +export interface ApproveExchangeActionRowProps extends ActionRowBaseProps { + action: ApproveExchangeAction +} + +export function ApproveExchangeActionRow({ + index, + action, + actionHandlerState, + onAction, + variant, +}: ApproveExchangeActionRowProps) { + const status = actionHandlerState.status + const fromToken = action.swapParams.fromToken + const formattedValue = getFormattedValue(action.swapParams.value, fromToken, variant) + + return ( + + + + + + } actionStatus={status}> + Approve {formattedValue} + + + + + + + + Approve + + + ) +} diff --git a/packages/app/src/features/actions/flavours/approve-exchange/types.ts b/packages/app/src/features/actions/flavours/approve-exchange/types.ts new file mode 100644 index 000000000..3cf8fe2ac --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve-exchange/types.ts @@ -0,0 +1,9 @@ +import { SwapParams, SwapRequest } from '@/domain/exchanges/types' + +import { SimplifiedQueryResult } from '../../logic/simplifyQueryResult' + +export interface ApproveExchangeAction { + type: 'approveExchange' + swapParams: SwapParams + swapInfo: SimplifiedQueryResult +} diff --git a/packages/app/src/features/actions/flavours/approve-exchange/useCreateApproveExchangeHandler.ts b/packages/app/src/features/actions/flavours/approve-exchange/useCreateApproveExchangeHandler.ts new file mode 100644 index 000000000..f4b68664a --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve-exchange/useCreateApproveExchangeHandler.ts @@ -0,0 +1,64 @@ +import { SwapInfoSimplified } from '@/domain/exchanges/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { ActionHandler, ActionHandlerState } from '../../logic/types' +import { useCreateApproveHandler } from '../approve/logic/useCreateApproveHandler' +import { ApproveExchangeAction } from './types' + +export interface UseCreateApproveExchangeActionHandlerOptions { + enabled: boolean +} + +export function useCreateApproveExchangeActionHandler( + action: ApproveExchangeAction, + { enabled }: UseCreateApproveExchangeActionHandlerOptions, +): ActionHandler { + const approveValue = action.swapInfo.data?.estimate.fromAmount // we can't use swapParams.value because of reversed swaps + const approveValueNormalized = approveValue + ? action.swapParams.fromToken.fromBaseUnit(approveValue) + : NormalizedUnitNumber(0) + const spender = action.swapInfo.data?.txRequest.to + + const approveActionHandler = useCreateApproveHandler( + { + type: 'approve', + token: action.swapParams.fromToken, + spender: spender!, + value: approveValueNormalized, + }, + { + enabled: enabled && action.swapInfo.data !== undefined, + }, + ) + + return extendApproveHandler(action, action.swapInfo, approveActionHandler, enabled) +} + +function extendApproveHandler( + action: ApproveExchangeAction, + swapInfo: SwapInfoSimplified, + approveActionHandler: ActionHandler, + enabled: boolean, +): ActionHandler { + const state: ActionHandlerState = (() => { + if (!enabled) { + return { status: 'disabled' } + } + + if (swapInfo.status === 'error') { + return { status: 'error', message: swapInfo.error.message } + } + + if (swapInfo.status === 'pending') { + return { status: 'loading' } + } + + return approveActionHandler.state + })() + + return { + state, + action, + onAction: approveActionHandler.onAction, + } +} diff --git a/packages/app/src/features/actions/flavours/approve/ApproveActionRow.tsx b/packages/app/src/features/actions/flavours/approve/ApproveActionRow.tsx new file mode 100644 index 000000000..620c46410 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve/ApproveActionRow.tsx @@ -0,0 +1,39 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { getFormattedValue } from '../../components/action-row/utils' +import { ApproveAction } from './types' + +export interface ApproveActionRowProps extends ActionRowBaseProps { + action: ApproveAction +} + +export function ApproveActionRow({ index, action, variant, actionHandlerState, onAction }: ApproveActionRowProps) { + const status = actionHandlerState.status + const isApprove = action.type === 'approve' + const actionTitle = isApprove ? 'Approve' : 'Permit' + const formattedValue = getFormattedValue(action.value, action.token, variant) + const successMessage = `${isApprove ? 'Approved' : 'Permitted'} for ${formattedValue}!` + + return ( + + + + + + } actionStatus={status}> + {actionTitle} {formattedValue} + + + + + + + + {actionTitle} + + + ) +} diff --git a/packages/app/src/features/actions/flavours/approve/logic/getSignPermitDataConfig.ts b/packages/app/src/features/actions/flavours/approve/logic/getSignPermitDataConfig.ts new file mode 100644 index 000000000..1b72761b9 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve/logic/getSignPermitDataConfig.ts @@ -0,0 +1,57 @@ +import { Address } from 'viem' +import { UseSignTypedDataReturnType } from 'wagmi' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { toBigInt } from '@/utils/bigNumber' + +const EIP2612_TYPES = { + Permit: [ + { name: 'owner', type: 'address' }, + { name: 'spender', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'nonce', type: 'uint256' }, + { name: 'deadline', type: 'uint256' }, + ], +} as const +type EIP2612_TYPES = typeof EIP2612_TYPES + +export interface GetSignDataConfigArgs { + token: Token + value: NormalizedUnitNumber + spender: Address + account: Address + deadline: number + chainId: number + contractName: string + nonce: bigint +} + +export function getSignPermitDataConfig({ + token, + value, + spender, + account, + chainId, + deadline, + contractName, + nonce, +}: GetSignDataConfigArgs): Parameters['signTypedData']>[0] { + return { + types: EIP2612_TYPES, + primaryType: 'Permit', + domain: { + name: contractName, + version: '1', + chainId, + verifyingContract: token.address, + }, + message: { + owner: account, + spender, + value: toBigInt(token.toBaseUnit(value)), + nonce, + deadline: toBigInt(deadline), + }, + } +} diff --git a/packages/app/src/features/actions/flavours/approve/logic/queries.ts b/packages/app/src/features/actions/flavours/approve/logic/queries.ts new file mode 100644 index 000000000..e601babe1 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve/logic/queries.ts @@ -0,0 +1,46 @@ +import { queryOptions } from '@tanstack/react-query' +import { Address, erc20Abi, parseAbi } from 'viem' +import { Config } from 'wagmi' +import { readContract } from 'wagmi/actions' + +export interface NonceQueryArgs { + wagmiConfig: Config + token: Address + account: Address + chainId: number +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function nonceQuery({ wagmiConfig, token, account, chainId }: NonceQueryArgs) { + return queryOptions({ + queryKey: ['permit', token, account, chainId], + queryFn: () => { + return readContract(wagmiConfig, { + abi: parseAbi(['function nonces(address) view returns (uint256)']), + address: token, + functionName: 'nonces', + args: [account], + }) + }, + }) +} + +export interface NameQueryArgs { + wagmiConfig: Config + token: Address + chainId: number +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function nameQuery({ wagmiConfig, token, chainId }: NameQueryArgs) { + return queryOptions({ + queryKey: ['name', token, chainId], + queryFn: () => { + return readContract(wagmiConfig, { + abi: erc20Abi, + address: token, + functionName: 'name', + }) + }, + }) +} diff --git a/packages/app/src/features/actions/flavours/approve/logic/useCreateApproveHandler.ts b/packages/app/src/features/actions/flavours/approve/logic/useCreateApproveHandler.ts new file mode 100644 index 000000000..19a618728 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve/logic/useCreateApproveHandler.ts @@ -0,0 +1,71 @@ +import { FetchStatus } from '@tanstack/react-query' + +import { UseWriteResult } from '@/domain/hooks/useWrite' +import { useHasEnoughAllowance } from '@/domain/market-operations/allowance/useHasEnoughAllowance' +import { useApprove } from '@/domain/market-operations/useApprove' +import { ApproveAction } from '@/features/actions/flavours/approve/types' +import { ActionHandler, ActionHandlerState } from '@/features/actions/logic/types' +import { mapWriteResultToActionState } from '@/features/actions/logic/utils' + +export interface UseCreateApproveHandlerOptions { + enabled: boolean +} + +export function useCreateApproveHandler( + action: ApproveAction, + { enabled }: UseCreateApproveHandlerOptions, +): ActionHandler { + const token = action.token + const requiredValue = token.toBaseUnit(action.requiredValue ?? action.value) + const { data: hasEnoughAllowance, fetchStatus: hasEnoughAllowanceFetchStatus } = useHasEnoughAllowance({ + token: token.address, + spender: action.spender, + value: requiredValue, + enabled, + }) + + const approve = useApprove({ + token: action.token.address, + spender: action.spender, + value: action.token.toBaseUnit(action.value), + enabled: enabled && hasEnoughAllowance === false, + }) + + const state = mapStatusesToActionState(hasEnoughAllowance, hasEnoughAllowanceFetchStatus, approve, enabled) + + return { + action, + state, + onAction: approve.write, + } +} + +function mapStatusesToActionState( + hasEnoughAllowance: boolean | undefined, + hasEnoughAllowanceFetchStatus: FetchStatus, + approve: UseWriteResult, + enabled: boolean, +): ActionHandlerState { + if (!enabled) { + return { status: 'disabled' } + } + + // user already has allowance + if (hasEnoughAllowance && hasEnoughAllowanceFetchStatus === 'idle') { + return { status: 'success' } + } + // user went through the approval flow but manually tweaked approval level and it's still too low + if (approve.status.kind === 'success' && hasEnoughAllowance === false) { + if (hasEnoughAllowanceFetchStatus === 'fetching') { + return { status: 'loading' } + } + + return { status: 'ready' } + } + + if (approve.status.kind === 'disabled' && hasEnoughAllowanceFetchStatus === 'fetching') { + return { status: 'loading' } + } + + return mapWriteResultToActionState(approve) +} diff --git a/packages/app/src/features/actions/flavours/approve/logic/useCreateApproveOrPermitHandler.ts b/packages/app/src/features/actions/flavours/approve/logic/useCreateApproveOrPermitHandler.ts new file mode 100644 index 000000000..1c43695ed --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve/logic/useCreateApproveOrPermitHandler.ts @@ -0,0 +1,34 @@ +import { useOriginChainId } from '@/domain/hooks/useOriginChainId' +import { ApproveAction } from '@/features/actions/flavours/approve/types' +import { isPermitSupported, PermitStore } from '@/features/actions/logic/permits' +import { ActionHandler } from '@/features/actions/logic/types' + +import { useCreateApproveHandler } from './useCreateApproveHandler' +import { useCreatePermitHandler } from './useCreatePermitHandler' + +export interface UseCreateApproveOrPermitHandlerOptions { + enabled: boolean + permitStore?: PermitStore +} + +export function useCreateApproveOrPermitHandler( + action: ApproveAction, + { enabled, permitStore }: UseCreateApproveOrPermitHandlerOptions, +): ActionHandler { + const chainId = useOriginChainId() + const supportsPermit = isPermitSupported(chainId, action.token) + + const shouldUsePermit = permitStore !== undefined && !!supportsPermit + const approveEnabled = enabled && !shouldUsePermit + const permitEnabled = enabled && shouldUsePermit + + const approveAction = useCreateApproveHandler(action, { + enabled: approveEnabled, + }) + const permitAction = useCreatePermitHandler(action, { + enabled: permitEnabled, + permitStore, + }) + + return shouldUsePermit ? permitAction : approveAction +} diff --git a/packages/app/src/features/actions/flavours/approve/logic/useCreatePermitHandler.ts b/packages/app/src/features/actions/flavours/approve/logic/useCreatePermitHandler.ts new file mode 100644 index 000000000..02c8dee07 --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve/logic/useCreatePermitHandler.ts @@ -0,0 +1,138 @@ +import { useQuery, UseQueryResult } from '@tanstack/react-query' +import { useRef } from 'react' +import { hexToSignature } from 'viem' +import { useChainId, useConfig, useSignTypedData } from 'wagmi' + +import { useConnectedAddress } from '@/domain/wallet/useConnectedAddress' +import { ApproveAction } from '@/features/actions/flavours/approve/types' +import { PermitStore } from '@/features/actions/logic/permits' +import { ActionHandler, ActionHandlerState } from '@/features/actions/logic/types' +import { parseWriteErrorMessage } from '@/features/actions/logic/utils' +import { JSONStringifyRich } from '@/utils/object' +import { useTimestamp } from '@/utils/useTimestamp' + +import { getSignPermitDataConfig } from './getSignPermitDataConfig' +import { nameQuery, nonceQuery } from './queries' + +export interface UseCreatePermitHandlerOptions { + enabled: boolean + permitStore?: PermitStore +} + +export function useCreatePermitHandler( + action: ApproveAction, + { enabled, permitStore }: UseCreatePermitHandlerOptions, +): ActionHandler { + const { account } = useConnectedAddress() + + const chainId = useChainId() + const wagmiConfig = useConfig() + + const name = useQuery({ + ...nameQuery({ + wagmiConfig, + token: action.token.address, + chainId, + }), + enabled, + }) + const nonce = useQuery({ + ...nonceQuery({ + wagmiConfig, + token: action.token.address, + account, + chainId, + }), + enabled, + gcTime: 0, + }) + const deadline = useTimestamp().timestamp + 60 * 60 * 24 // 24 hours + + const signDataConfig = + name.data !== undefined && nonce.data !== undefined + ? getSignPermitDataConfig({ + token: action.token, + value: action.value, + spender: action.spender, + account, + deadline, + chainId, + contractName: name.data, + nonce: nonce.data, + }) + : undefined + const snapshottedSignDataConfigRef = useRef() + + const sign = useSignTypedData({ + mutation: { + onSuccess: (data) => { + const signature = hexToSignature(data) + + if (!permitStore) { + // this can happen if the user switches to approvals after the sign request was sent, but then signs it anyway. + return + } + + permitStore.add({ + token: action.token, + deadline: new Date(deadline * 1000), + signature, + }) + }, + }, + }) + + if ( + (sign.isSuccess || sign.isError) && + JSONStringifyRich(snapshottedSignDataConfigRef.current) !== JSONStringifyRich(signDataConfig) + ) { + snapshottedSignDataConfigRef.current = undefined + sign.reset() + } + + return { + action: { + ...action, + type: 'permit', + }, + state: mapStatusesToActionState({ sign, nonce, name, enabled }), + onAction: () => { + snapshottedSignDataConfigRef.current = signDataConfig + signDataConfig && sign.signTypedData(signDataConfig) + }, + } +} + +interface MapStatusesToActionStateArgs { + nonce: UseQueryResult + name: UseQueryResult + sign: ReturnType + enabled: boolean +} +function mapStatusesToActionState({ nonce, name, sign, enabled }: MapStatusesToActionStateArgs): ActionHandlerState { + if (!enabled) { + return { status: 'disabled' } + } + + if (sign.isPending || nonce.isLoading || name.isLoading) { + return { status: 'loading' } + } + + if (sign.status === 'error') { + return { status: 'error', errorKind: 'tx-submission', message: parseWriteErrorMessage(sign.error!) } + } + + if (nonce.status === 'error') { + return { status: 'error', message: parseWriteErrorMessage(nonce.error) } + } + + if (name.status === 'error') { + return { status: 'error', message: parseWriteErrorMessage(name.error) } + } + + if (sign.isSuccess) { + return { status: 'success' } + } + + return { status: 'ready' } +} diff --git a/packages/app/src/features/actions/flavours/approve/types.ts b/packages/app/src/features/actions/flavours/approve/types.ts new file mode 100644 index 000000000..868297b7d --- /dev/null +++ b/packages/app/src/features/actions/flavours/approve/types.ts @@ -0,0 +1,12 @@ +import { Address } from 'viem' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export interface ApproveAction { + type: 'approve' | 'permit' // default action is approve - it is replaced with permit if permit is both preferred and supported + token: Token + spender: Address + value: NormalizedUnitNumber + requiredValue?: NormalizedUnitNumber // if reached, no action is needed. Useful when value is approximation (and constantly accrues debt) +} diff --git a/packages/app/src/features/actions/flavours/borrow/BorrowActionRow.tsx b/packages/app/src/features/actions/flavours/borrow/BorrowActionRow.tsx new file mode 100644 index 000000000..05935fb71 --- /dev/null +++ b/packages/app/src/features/actions/flavours/borrow/BorrowActionRow.tsx @@ -0,0 +1,39 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { UpDownMarker } from '../../components/action-row/UpDownMarker' +import { getFormattedValue } from '../../components/action-row/utils' +import { BorrowAction } from './types' + +export interface BorrowActionRowProps extends ActionRowBaseProps { + action: BorrowAction +} + +export function BorrowActionRow({ index, action, actionHandlerState, onAction, variant }: BorrowActionRowProps) { + const status = actionHandlerState.status + const formattedValue = getFormattedValue(action.value, action.token, variant) + + return ( + + + + + + } actionStatus={status}> + Borrow {formattedValue} + + + + + + + + + + Borrow + + + ) +} diff --git a/packages/app/src/features/actions/flavours/borrow/types.ts b/packages/app/src/features/actions/flavours/borrow/types.ts new file mode 100644 index 000000000..914d8aff5 --- /dev/null +++ b/packages/app/src/features/actions/flavours/borrow/types.ts @@ -0,0 +1,16 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export interface BorrowObjective { + type: 'borrow' + token: Token + debtTokenAddress: CheckedAddress + value: NormalizedUnitNumber +} + +export interface BorrowAction { + type: 'borrow' + token: Token + value: NormalizedUnitNumber +} diff --git a/packages/app/src/features/actions/flavours/borrow/useCreateBorrowHandler.ts b/packages/app/src/features/actions/flavours/borrow/useCreateBorrowHandler.ts new file mode 100644 index 000000000..89867e1d0 --- /dev/null +++ b/packages/app/src/features/actions/flavours/borrow/useCreateBorrowHandler.ts @@ -0,0 +1,28 @@ +import { useBorrow } from '@/domain/market-operations/useBorrow' + +import { ActionHandler } from '../../logic/types' +import { mapWriteResultToActionState } from '../../logic/utils' +import { BorrowAction } from './types' + +export interface UseCreateBorrowActionHandlerOptions { + enabled: boolean + onFinish?: () => void +} + +export function useCreateBorrowActionHandler( + action: BorrowAction, + { enabled, onFinish }: UseCreateBorrowActionHandlerOptions, +): ActionHandler { + const borrow = useBorrow({ + asset: action.token.address, + value: action.token.toBaseUnit(action.value), + enabled, + onTransactionSettled: onFinish, + }) + + return { + action, + state: mapWriteResultToActionState(borrow), + onAction: borrow.write, + } +} diff --git a/packages/app/src/features/actions/flavours/deposit/DepositActionRow.tsx b/packages/app/src/features/actions/flavours/deposit/DepositActionRow.tsx new file mode 100644 index 000000000..da69b7142 --- /dev/null +++ b/packages/app/src/features/actions/flavours/deposit/DepositActionRow.tsx @@ -0,0 +1,39 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { UpDownMarker } from '../../components/action-row/UpDownMarker' +import { getFormattedValue } from '../../components/action-row/utils' +import { DepositAction } from './types' + +export interface DepositActionRowProps extends ActionRowBaseProps { + action: DepositAction +} + +export function DepositActionRow({ index, action, actionHandlerState, onAction, variant }: DepositActionRowProps) { + const status = actionHandlerState.status + const formattedValue = getFormattedValue(action.value, action.token, variant) + + return ( + + + + + + } actionStatus={status}> + Deposit {formattedValue} + + + + + + + + + + Deposit + + + ) +} diff --git a/packages/app/src/features/actions/flavours/deposit/types.ts b/packages/app/src/features/actions/flavours/deposit/types.ts new file mode 100644 index 000000000..236380c15 --- /dev/null +++ b/packages/app/src/features/actions/flavours/deposit/types.ts @@ -0,0 +1,16 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export interface DepositObjective { + type: 'deposit' + token: Token + lendingPool: CheckedAddress + value: NormalizedUnitNumber +} + +export interface DepositAction { + type: 'deposit' + token: Token + value: NormalizedUnitNumber +} diff --git a/packages/app/src/features/actions/flavours/deposit/useCreateDepositHandler.ts b/packages/app/src/features/actions/flavours/deposit/useCreateDepositHandler.ts new file mode 100644 index 000000000..6ed9414e2 --- /dev/null +++ b/packages/app/src/features/actions/flavours/deposit/useCreateDepositHandler.ts @@ -0,0 +1,31 @@ +import { useDeposit } from '@/domain/market-operations/useDeposit' + +import { PermitStore } from '../../logic/permits' +import { ActionHandler } from '../../logic/types' +import { mapWriteResultToActionState } from '../../logic/utils' +import { DepositAction } from './types' + +export interface UseCreateDepositHandlerOptions { + enabled: boolean + permitStore?: PermitStore + onFinish?: () => void +} + +export function useCreateDepositHandler(action: DepositAction, options: UseCreateDepositHandlerOptions): ActionHandler { + const { enabled, permitStore, onFinish } = options + const permit = permitStore?.find(action.token) + + const deposit = useDeposit({ + asset: action.token.address, + value: action.token.toBaseUnit(action.value), + permit, + enabled, + onTransactionSettled: onFinish, + }) + + return { + action, + state: mapWriteResultToActionState(deposit), + onAction: deposit.write, + } +} diff --git a/packages/app/src/features/actions/flavours/exchange/ExchangeActionRow.tsx b/packages/app/src/features/actions/flavours/exchange/ExchangeActionRow.tsx new file mode 100644 index 000000000..68d5de676 --- /dev/null +++ b/packages/app/src/features/actions/flavours/exchange/ExchangeActionRow.tsx @@ -0,0 +1,111 @@ +import { formatPercentage } from '@/domain/common/format' +import { SwapRequest } from '@/domain/exchanges/types' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token, USD_MOCK_TOKEN } from '@/domain/types/Token' +import { ActionRow } from '@/features/actions/components/action-row/ActionRow' +import { assets, getTokenImage } from '@/ui/assets' +import { IconStack } from '@/ui/molecules/icon-stack/IconStack' + +import { ActionRowBaseProps } from '../../components/action-row/types' +import { UpDownMarker } from '../../components/action-row/UpDownMarker' +import { ActionHandlerState } from '../../logic/types' +import { ExchangeAction } from './types' + +export interface ExchangeActionRowProps extends ActionRowBaseProps { + action: ExchangeAction +} + +export function ExchangeActionRow({ index, action, actionHandlerState, onAction, variant }: ExchangeActionRowProps) { + const fromToken = action.swapParams.fromToken + const toToken = action.swapParams.toToken + const tokenIconPaths = [getTokenImage(fromToken.symbol), getTokenImage(toToken.symbol)] + const status = actionHandlerState.status + const token = action.swapParams.type === 'reverse' ? toToken : fromToken + const successMessage = `Converted ${token.format(action.value, { style: 'auto' })} ${token.symbol}!` + + return ( + + + + + + } actionStatus={status}> + Convert {fromToken.symbol} to {toToken.symbol} + + + + + + + + + + Convert + + + + + ) +} + +interface RowSummaryProps { + toToken: Token + estimate: SwapRequest['estimate'] | undefined + maxSlippage: Percentage + actionStatus: ActionHandlerState['status'] + formatAsDAIValue?: (amount: NormalizedUnitNumber) => string +} + +function RowSummary({ maxSlippage, toToken, estimate, actionStatus, formatAsDAIValue }: RowSummaryProps) { + if (actionStatus === 'success') { + return null + } + if (estimate === undefined) { + return ( +
+ +
+ ) + } + + const amount = toToken.fromBaseUnit(estimate.toAmount) + + return ( +
+
+ +

+ Extra fee: + {USD_MOCK_TOKEN.formatUSD(estimate.feeCostsUSD)} +
+ Slippage: + {formatPercentage(maxSlippage, { minimumFractionDigits: 1 })} +

+
+

+ You'll get ~{toToken.format(amount, { style: 'auto' })}{' '} + {toToken.symbol} + {formatAsDAIValue && ` (${formatAsDAIValue(amount)} DAI)`} +

+
+ ) +} + +function LiFiBadge() { + return ( +

+ LI.FI logo + POWERED BY LI.FI +

+ ) +} diff --git a/packages/app/src/features/actions/flavours/exchange/types.ts b/packages/app/src/features/actions/flavours/exchange/types.ts new file mode 100644 index 000000000..78d01b82c --- /dev/null +++ b/packages/app/src/features/actions/flavours/exchange/types.ts @@ -0,0 +1,19 @@ +import { SwapParams, SwapRequest } from '@/domain/exchanges/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { SimplifiedQueryResult } from '../../logic/simplifyQueryResult' + +export interface ExchangeObjective { + type: 'exchange' + swapParams: SwapParams + swapInfo: SimplifiedQueryResult + formatAsDAIValue?: (amount: NormalizedUnitNumber) => string +} + +export interface ExchangeAction { + type: 'exchange' + value: NormalizedUnitNumber // tmp for compatibility + swapParams: SwapParams + swapInfo: SimplifiedQueryResult + formatAsDAIValue?: (amount: NormalizedUnitNumber) => string +} diff --git a/packages/app/src/features/actions/flavours/exchange/useCreateExchangeHandler.ts b/packages/app/src/features/actions/flavours/exchange/useCreateExchangeHandler.ts new file mode 100644 index 000000000..2c690ff4f --- /dev/null +++ b/packages/app/src/features/actions/flavours/exchange/useCreateExchangeHandler.ts @@ -0,0 +1,57 @@ +import { useExchange, UseExchangeResult } from '@/domain/market-operations/useExchange' + +import { ActionHandler, ActionHandlerState } from '../../logic/types' +import { parseWriteErrorMessage } from '../../logic/utils' +import { ExchangeAction } from './types' + +export interface UseCreateExchangeHandlerOptions { + enabled: boolean + onFinish?: () => void +} + +export function useCreateExchangeHandler( + action: ExchangeAction, + { enabled, onFinish }: UseCreateExchangeHandlerOptions, +): ActionHandler { + const exchange = useExchange({ + swapInfo: action.swapInfo, + enabled, + onTransactionSettled: onFinish, + }) + + return { + action, + state: mapSendResultToActionState(exchange), + onAction: exchange.send, + } +} + +function mapSendResultToActionState(result: UseExchangeResult): ActionHandlerState { + switch (result.status.kind) { + case 'ready': + return { status: 'ready' } + + case 'disabled': + return { status: 'disabled' } + + case 'fetching-quote': + case 'simulating': + case 'tx-mining': + case 'tx-sending': + return { status: 'loading' } + + case 'error': + if (result.status.errorKind === 'fetching-quote-error') { + return { status: 'error', message: 'Error fetching exchange quote' } + } + + return { + status: 'error', + errorKind: result.status.errorKind, + message: parseWriteErrorMessage(result.status.error), + } + + case 'success': + return { status: 'success' } + } +} diff --git a/packages/app/src/features/actions/flavours/repay/RepayActionRow.tsx b/packages/app/src/features/actions/flavours/repay/RepayActionRow.tsx new file mode 100644 index 000000000..ec682a2d0 --- /dev/null +++ b/packages/app/src/features/actions/flavours/repay/RepayActionRow.tsx @@ -0,0 +1,40 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { UpDownMarker } from '../../components/action-row/UpDownMarker' +import { getFormattedValue } from '../../components/action-row/utils' +import { RepayAction } from './types' + +export interface RepayActionRowProps extends ActionRowBaseProps { + action: RepayAction +} + +export function RepayActionRow({ index, action, actionHandlerState, onAction, variant }: RepayActionRowProps) { + const token = action.useAToken ? action.reserve.aToken : action.reserve.token + const status = actionHandlerState.status + const formattedValue = getFormattedValue(action.value, token, variant) + + return ( + + + + + + } actionStatus={status}> + Repay with {formattedValue} + + + + + + + + + + Repay + + + ) +} diff --git a/packages/app/src/features/actions/flavours/repay/types.ts b/packages/app/src/features/actions/flavours/repay/types.ts new file mode 100644 index 000000000..f592784a0 --- /dev/null +++ b/packages/app/src/features/actions/flavours/repay/types.ts @@ -0,0 +1,19 @@ +import { Reserve } from '@/domain/market-info/marketInfo' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +export interface RepayObjective { + type: 'repay' + reserve: Reserve + lendingPool: CheckedAddress + useAToken: boolean + value: NormalizedUnitNumber + requiredApproval: NormalizedUnitNumber +} + +export interface RepayAction { + type: 'repay' + reserve: Reserve + value: NormalizedUnitNumber + useAToken: boolean +} diff --git a/packages/app/src/features/actions/flavours/repay/useCreateRepayHandler.ts b/packages/app/src/features/actions/flavours/repay/useCreateRepayHandler.ts new file mode 100644 index 000000000..a563627f1 --- /dev/null +++ b/packages/app/src/features/actions/flavours/repay/useCreateRepayHandler.ts @@ -0,0 +1,32 @@ +import { useRepay } from '@/domain/market-operations/useRepay' + +import { PermitStore } from '../../logic/permits' +import { ActionHandler } from '../../logic/types' +import { mapWriteResultToActionState } from '../../logic/utils' +import { RepayAction } from './types' + +export interface UseCreateRepayHandlerOptions { + enabled: boolean + permitStore?: PermitStore + onFinish?: () => void +} + +export function useCreateRepayHandler(action: RepayAction, options: UseCreateRepayHandlerOptions): ActionHandler { + const { enabled, permitStore, onFinish } = options + const permit = permitStore?.find(action.reserve.token) + + const repay = useRepay({ + asset: action.reserve.token.address, + value: action.reserve.token.toBaseUnit(action.value), + useAToken: action.useAToken, + permit, + enabled, + onTransactionSettled: onFinish, + }) + + return { + action, + state: mapWriteResultToActionState(repay), + onAction: repay.write, + } +} diff --git a/packages/app/src/features/actions/flavours/set-use-as-collateral/SetUseAsCollateralActionRow.tsx b/packages/app/src/features/actions/flavours/set-use-as-collateral/SetUseAsCollateralActionRow.tsx new file mode 100644 index 000000000..20ab3f3ec --- /dev/null +++ b/packages/app/src/features/actions/flavours/set-use-as-collateral/SetUseAsCollateralActionRow.tsx @@ -0,0 +1,43 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { SetUseAsCollateralAction } from './types' + +export interface SetUseAsCollateralActionRowProps extends ActionRowBaseProps { + action: SetUseAsCollateralAction +} + +export function SetUseAsCollateralActionRow({ + index, + action, + actionHandlerState, + onAction, + variant, +}: SetUseAsCollateralActionRowProps) { + const useAsCollateral = action.useAsCollateral + const status = actionHandlerState.status + const actionTitle = useAsCollateral ? 'Enable' : 'Disable' + const successMessage = `${useAsCollateral ? 'Enabled' : 'Disabled'} ${action.token.symbol} as collateral!` + + return ( + + + + + + } actionStatus={status}> + {actionTitle} {action.token.symbol} as collateral + + + + + + + + {actionTitle} + + + ) +} diff --git a/packages/app/src/features/actions/flavours/set-use-as-collateral/types.ts b/packages/app/src/features/actions/flavours/set-use-as-collateral/types.ts new file mode 100644 index 000000000..d58c217ad --- /dev/null +++ b/packages/app/src/features/actions/flavours/set-use-as-collateral/types.ts @@ -0,0 +1,13 @@ +import { Token } from '@/domain/types/Token' + +export interface SetUseAsCollateralObjective { + type: 'setUseAsCollateral' + token: Token + useAsCollateral: boolean +} + +export interface SetUseAsCollateralAction { + type: 'setUseAsCollateral' + token: Token + useAsCollateral: boolean +} diff --git a/packages/app/src/features/actions/flavours/set-use-as-collateral/useCreateSetUseAsCollateralHandler.ts b/packages/app/src/features/actions/flavours/set-use-as-collateral/useCreateSetUseAsCollateralHandler.ts new file mode 100644 index 000000000..ab7c225c1 --- /dev/null +++ b/packages/app/src/features/actions/flavours/set-use-as-collateral/useCreateSetUseAsCollateralHandler.ts @@ -0,0 +1,28 @@ +import { useSetUseAsCollateral } from '@/domain/market-operations/useSetUseAsCollateral' + +import { ActionHandler } from '../../logic/types' +import { mapWriteResultToActionState } from '../../logic/utils' +import { SetUseAsCollateralAction } from './types' + +export interface UseCreateSetUseAsCollateralHandlerOptions { + enabled: boolean + onFinish?: () => void +} + +export function useCreateSetUseAsCollateralHandler( + action: SetUseAsCollateralAction, + { enabled, onFinish }: UseCreateSetUseAsCollateralHandlerOptions, +): ActionHandler { + const setUseAsCollateral = useSetUseAsCollateral({ + asset: action.token.address, + useAsCollateral: action.useAsCollateral, + enabled, + onTransactionSettled: onFinish, + }) + + return { + action, + state: mapWriteResultToActionState(setUseAsCollateral), + onAction: setUseAsCollateral.write, + } +} diff --git a/packages/app/src/features/actions/flavours/set-user-e-mode/SetUserEModeActionRow.tsx b/packages/app/src/features/actions/flavours/set-user-e-mode/SetUserEModeActionRow.tsx new file mode 100644 index 000000000..3dba7ad25 --- /dev/null +++ b/packages/app/src/features/actions/flavours/set-user-e-mode/SetUserEModeActionRow.tsx @@ -0,0 +1,40 @@ +import { assets } from '@/ui/assets' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { SetUserEModeAction } from './types' + +export interface SetUserEModeActionRowProps extends ActionRowBaseProps { + action: SetUserEModeAction +} + +export function SetUserEModeActionRow({ + index, + action, + actionHandlerState, + onAction, + variant, +}: SetUserEModeActionRowProps) { + const status = actionHandlerState.status + const eModeEnabled = action.eModeCategoryId !== 0 + const actionTitle = eModeEnabled ? 'Enable' : 'Disable' + const successMessage = `E-Mode ${eModeEnabled ? 'enabled' : 'disabled'}!` + + return ( + + + + + + {actionTitle} E-Mode + + + + + + + {actionTitle} + + + ) +} diff --git a/packages/app/src/features/actions/flavours/set-user-e-mode/types.ts b/packages/app/src/features/actions/flavours/set-user-e-mode/types.ts new file mode 100644 index 000000000..74d9f9a3d --- /dev/null +++ b/packages/app/src/features/actions/flavours/set-user-e-mode/types.ts @@ -0,0 +1,9 @@ +export interface SetUserEModeObjective { + type: 'setUserEMode' + eModeCategoryId: number +} + +export interface SetUserEModeAction { + type: 'setUserEMode' + eModeCategoryId: number +} diff --git a/packages/app/src/features/actions/flavours/set-user-e-mode/useCreateSetUserEModeHandler.ts b/packages/app/src/features/actions/flavours/set-user-e-mode/useCreateSetUserEModeHandler.ts new file mode 100644 index 000000000..7930b1600 --- /dev/null +++ b/packages/app/src/features/actions/flavours/set-user-e-mode/useCreateSetUserEModeHandler.ts @@ -0,0 +1,27 @@ +import { useSetUserEMode } from '@/domain/market-operations/useSetUserEMode' + +import { ActionHandler } from '../../logic/types' +import { mapWriteResultToActionState } from '../../logic/utils' +import { SetUserEModeAction } from './types' + +export interface UseCreateSetUserEModeHandlerOptions { + enabled: boolean + onFinish?: () => void +} + +export function useCreateSetUserEModeHandler( + action: SetUserEModeAction, + { enabled, onFinish }: UseCreateSetUserEModeHandlerOptions, +): ActionHandler { + const setUserEMode = useSetUserEMode({ + categoryId: action.eModeCategoryId, + enabled, + onTransactionSettled: onFinish, + }) + + return { + action, + state: mapWriteResultToActionState(setUserEMode), + onAction: setUserEMode.write, + } +} diff --git a/packages/app/src/features/actions/flavours/withdraw/WithdrawActionRow.tsx b/packages/app/src/features/actions/flavours/withdraw/WithdrawActionRow.tsx new file mode 100644 index 000000000..2f570a53e --- /dev/null +++ b/packages/app/src/features/actions/flavours/withdraw/WithdrawActionRow.tsx @@ -0,0 +1,39 @@ +import { assets } from '@/ui/assets' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { ActionRow } from '../../components/action-row/ActionRow' +import { ActionRowBaseProps } from '../../components/action-row/types' +import { UpDownMarker } from '../../components/action-row/UpDownMarker' +import { getFormattedValue } from '../../components/action-row/utils' +import { WithdrawAction } from './types' + +export interface WithdrawActionRowProps extends ActionRowBaseProps { + action: WithdrawAction +} + +export function WithdrawActionRow({ index, action, actionHandlerState, onAction, variant }: WithdrawActionRowProps) { + const status = actionHandlerState.status + const formattedValue = getFormattedValue(action.value, action.token, variant) + + return ( + + + + + + } actionStatus={status}> + Withdraw {formattedValue} + + + + + + + + + + Withdraw + + + ) +} diff --git a/packages/app/src/features/actions/flavours/withdraw/types.ts b/packages/app/src/features/actions/flavours/withdraw/types.ts new file mode 100644 index 000000000..40579441a --- /dev/null +++ b/packages/app/src/features/actions/flavours/withdraw/types.ts @@ -0,0 +1,16 @@ +import { Reserve } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export interface WithdrawObjective { + type: 'withdraw' + reserve: Reserve + value: NormalizedUnitNumber + all: boolean +} + +export interface WithdrawAction { + type: 'withdraw' + token: Token + value: NormalizedUnitNumber +} diff --git a/packages/app/src/features/actions/flavours/withdraw/useCreateWithdrawHandler.ts b/packages/app/src/features/actions/flavours/withdraw/useCreateWithdrawHandler.ts new file mode 100644 index 000000000..80f40c47e --- /dev/null +++ b/packages/app/src/features/actions/flavours/withdraw/useCreateWithdrawHandler.ts @@ -0,0 +1,28 @@ +import { useWithdraw } from '@/domain/market-operations/useWithdraw' + +import { ActionHandler } from '../../logic/types' +import { mapWriteResultToActionState } from '../../logic/utils' +import { WithdrawAction } from './types' + +export interface UseCreateWithdrawHandlerOptions { + enabled: boolean + onFinish?: () => void +} + +export function useCreateWithdrawHandler( + action: WithdrawAction, + { enabled, onFinish }: UseCreateWithdrawHandlerOptions, +): ActionHandler { + const withdraw = useWithdraw({ + asset: action.token.address, + value: action.token.toBaseUnit(action.value), + enabled, + onTransactionSettled: onFinish, + }) + + return { + action, + state: mapWriteResultToActionState(withdraw), + onAction: withdraw.write, + } +} diff --git a/packages/app/src/features/actions/logic/permits.ts b/packages/app/src/features/actions/logic/permits.ts new file mode 100644 index 000000000..4998031bc --- /dev/null +++ b/packages/app/src/features/actions/logic/permits.ts @@ -0,0 +1,42 @@ +import invariant from 'tiny-invariant' +import { Signature } from 'viem' + +import { getChainConfigEntry } from '@/config/chain' +import { Token } from '@/domain/types/Token' + +export interface Permit { + token: Token + deadline: Date + signature: Signature +} + +export interface PermitStore { + add: (permit: Permit) => void + find: (token: Token) => Permit | undefined +} + +export function createPermitStore(): PermitStore { + const permits: Permit[] = [] + + /* eslint-disable func-style */ + const add = (permit: Permit): void => { + permits.push(permit) + } + + const find = (token: Token): Permit | undefined => { + const permitsForToken = permits.filter((permit) => permit.token.address === token.address) + invariant(permitsForToken.length <= 1, 'PermitStore: multiple permits for the same token') + return permitsForToken[0] + } + /* eslint-enable func-style */ + + return { + add, + find, + } +} + +export function isPermitSupported(chainId: number, token: Token): boolean { + const { permitSupport } = getChainConfigEntry(chainId) + return permitSupport[token.address] ?? false +} diff --git a/packages/app/src/features/actions/logic/simplifyQueryResult.ts b/packages/app/src/features/actions/logic/simplifyQueryResult.ts new file mode 100644 index 000000000..192f85d49 --- /dev/null +++ b/packages/app/src/features/actions/logic/simplifyQueryResult.ts @@ -0,0 +1,26 @@ +import { UseQueryResult } from '@tanstack/react-query' + +export type SimplifiedQueryResult = + | { + status: 'pending' + data: undefined + error?: null + } + | { + status: 'success' + data: T + error?: null + } + | { + status: 'error' + data: undefined + error: Error + } + +export function simplifyQueryResult(result: UseQueryResult): SimplifiedQueryResult { + return { + data: result.data, + status: result.status, + error: result.error, + } as any +} diff --git a/packages/app/src/features/actions/logic/stringifyObjectives.ts b/packages/app/src/features/actions/logic/stringifyObjectives.ts new file mode 100644 index 000000000..907403c64 --- /dev/null +++ b/packages/app/src/features/actions/logic/stringifyObjectives.ts @@ -0,0 +1,22 @@ +import { JSONStringifyRich } from '@/utils/object' + +import { Objective } from './types' + +export function stringifyObjectivesDeep(objectives: Objective[]): string { + return JSONStringifyRich(objectives) +} + +export function stringifyObjectivesToStableActions(objectives: Objective[]): string { + return JSON.stringify( + objectives.map((o: any) => [ + // required because of conditional creation of actions + o.type, + o?.token?.address, + o?.reserve?.token.address, + o?.useAToken, + // values are stringified to reload actions when inputs change (and for example new approval value is needed) + o?.value, + o?.swapParams?.value, + ]), + ) +} diff --git a/packages/app/src/features/actions/logic/types.ts b/packages/app/src/features/actions/logic/types.ts new file mode 100644 index 000000000..12eda8db8 --- /dev/null +++ b/packages/app/src/features/actions/logic/types.ts @@ -0,0 +1,52 @@ +import { WriteErrorKind } from '@/domain/hooks/useWrite' + +import { ApproveAction } from '../flavours/approve/types' +import { ApproveDelegationAction } from '../flavours/approve-delegation/types' +import { ApproveExchangeAction } from '../flavours/approve-exchange/types' +import { BorrowAction, BorrowObjective } from '../flavours/borrow/types' +import { DepositAction, DepositObjective } from '../flavours/deposit/types' +import { ExchangeAction, ExchangeObjective } from '../flavours/exchange/types' +import { RepayAction, RepayObjective } from '../flavours/repay/types' +import { SetUseAsCollateralAction, SetUseAsCollateralObjective } from '../flavours/set-use-as-collateral/types' +import { SetUserEModeAction, SetUserEModeObjective } from '../flavours/set-user-e-mode/types' +import { WithdrawAction, WithdrawObjective } from '../flavours/withdraw/types' + +/** + * Objective is an input to action component. It is a high level description of what user wants to do. + * Single objective usually maps to multiple actions. For example: DepositObjective maps to Approve/Permit and Deposit actions. + */ +export type Objective = + | DepositObjective + | BorrowObjective + | WithdrawObjective + | RepayObjective + | SetUseAsCollateralObjective + | SetUserEModeObjective + | ExchangeObjective +export type ObjectiveType = Objective['type'] + +export type Action = + | ApproveAction + | DepositAction + | ApproveDelegationAction + | BorrowAction + | WithdrawAction + | RepayAction + | SetUseAsCollateralAction + | SetUserEModeAction + | ApproveExchangeAction + | ExchangeAction +export type ActionType = Action['type'] + +export type ActionHandlerState = + | { status: 'disabled' } + | { status: 'ready' } + | { status: 'loading' } + | { status: 'success' } + | { status: 'error'; errorKind?: WriteErrorKind; message: string } + +export interface ActionHandler { + action: Action + state: ActionHandlerState + onAction: () => void +} diff --git a/packages/app/src/features/actions/logic/useActionHandlers.ts b/packages/app/src/features/actions/logic/useActionHandlers.ts new file mode 100644 index 000000000..c261b9bbd --- /dev/null +++ b/packages/app/src/features/actions/logic/useActionHandlers.ts @@ -0,0 +1,112 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +import { useMemo, useRef } from 'react' + +import { useActionsSettings } from '@/domain/state' +import { ActionsSettings } from '@/domain/state/actions-settings' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { useCreateApproveOrPermitHandler } from '../flavours/approve/logic/useCreateApproveOrPermitHandler' +import { useCreateApproveDelegationHandler } from '../flavours/approve-delegation/useCreateApproveDelegationHandler' +import { useCreateApproveExchangeActionHandler } from '../flavours/approve-exchange/useCreateApproveExchangeHandler' +import { useCreateBorrowActionHandler } from '../flavours/borrow/useCreateBorrowHandler' +import { useCreateDepositHandler } from '../flavours/deposit/useCreateDepositHandler' +import { useCreateExchangeHandler } from '../flavours/exchange/useCreateExchangeHandler' +import { useCreateRepayHandler } from '../flavours/repay/useCreateRepayHandler' +import { useCreateSetUseAsCollateralHandler } from '../flavours/set-use-as-collateral/useCreateSetUseAsCollateralHandler' +import { useCreateSetUserEModeHandler } from '../flavours/set-user-e-mode/useCreateSetUserEModeHandler' +import { useCreateWithdrawHandler } from '../flavours/withdraw/useCreateWithdrawHandler' +import { createPermitStore, PermitStore } from './permits' +import { Action, ActionHandler, Objective } from './types' +import { useCreateActions } from './useCreateActions' +import { useGasPrice } from './useGasPrice' + +export interface UseActionHandlersOptions { + onFinish?: () => void + enabled: boolean +} + +export interface UseActionHandlersResult { + handlers: ActionHandler[] + actionsSettings: ActionsSettings + settingsDisabled: boolean // @note: after first interaction, we don't enable for settings to change + gasPrice?: NormalizedUnitNumber +} + +export function useActionHandlers( + objectives: Objective[], + { onFinish: _onFinish, enabled }: UseActionHandlersOptions, +): UseActionHandlersResult { + const actions = useCreateActions(objectives) + const permitStore = useMemo(() => createPermitStore(), []) + const gasPrice = useGasPrice() + const actionsSettings = useActionsSettings() + + // @note: we call react hooks in a loop but this is fine as actions should never change + const handlers = actions.reduce((acc, action, index) => { + const nextOneToExecute = index > 0 ? acc[acc.length - 1]!.state.status === 'success' : true + // If succeeded once, don't try again. Further actions can invalidate previous actions (for example deposit will invalidate previous approvals) + const alreadySucceeded = useRef(false) + + const isLast = index === actions.length - 1 + const onFinish = isLast ? _onFinish : undefined + + const handler = useCreateActionHandler(action, { + enabled: enabled && alreadySucceeded.current === false && nextOneToExecute, + permitStore: actionsSettings.preferPermits ? permitStore : undefined, + onFinish, + }) + + if (alreadySucceeded.current) { + handler.state.status = 'success' + } + + if (handler.state.status === 'success') { + alreadySucceeded.current = true + } + + return [...acc, handler] + }, [] as ActionHandler[]) + + const settingsDisabled = handlers.some((handler) => handler.state.status === 'success') + + return { + handlers, + actionsSettings, + gasPrice, + settingsDisabled, + } +} + +interface UseCreateActionHandlerOptions { + enabled: boolean + permitStore?: PermitStore + onFinish?: () => void +} +function useCreateActionHandler( + action: Action, + { enabled, permitStore, onFinish }: UseCreateActionHandlerOptions, +): ActionHandler { + switch (action.type) { + case 'approve': + case 'permit': + return useCreateApproveOrPermitHandler(action, { permitStore, enabled }) + case 'deposit': + return useCreateDepositHandler(action, { permitStore, enabled, onFinish }) + case 'approveDelegation': + return useCreateApproveDelegationHandler(action, { enabled }) + case 'borrow': + return useCreateBorrowActionHandler(action, { enabled, onFinish }) + case 'withdraw': + return useCreateWithdrawHandler(action, { enabled, onFinish }) + case 'repay': + return useCreateRepayHandler(action, { permitStore, enabled, onFinish }) + case 'setUseAsCollateral': + return useCreateSetUseAsCollateralHandler(action, { enabled, onFinish }) + case 'setUserEMode': + return useCreateSetUserEModeHandler(action, { enabled, onFinish }) + case 'approveExchange': + return useCreateApproveExchangeActionHandler(action, { enabled }) + case 'exchange': + return useCreateExchangeHandler(action, { enabled, onFinish }) + } +} diff --git a/packages/app/src/features/actions/logic/useCreateActions.ts b/packages/app/src/features/actions/logic/useCreateActions.ts new file mode 100644 index 000000000..dd9d9faeb --- /dev/null +++ b/packages/app/src/features/actions/logic/useCreateActions.ts @@ -0,0 +1,163 @@ +import { maxUint256 } from 'viem' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { wethGatewayAddress } from '@/config/contracts-generated' +import { useContractAddress } from '@/domain/hooks/useContractAddress' +import { useOriginChainId } from '@/domain/hooks/useOriginChainId' +import { BaseUnitNumber, NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { ApproveAction } from '../flavours/approve/types' +import { ApproveDelegationAction } from '../flavours/approve-delegation/types' +import { ApproveExchangeAction } from '../flavours/approve-exchange/types' +import { BorrowAction } from '../flavours/borrow/types' +import { DepositAction } from '../flavours/deposit/types' +import { ExchangeAction } from '../flavours/exchange/types' +import { RepayAction } from '../flavours/repay/types' +import { SetUseAsCollateralAction } from '../flavours/set-use-as-collateral/types' +import { SetUserEModeAction } from '../flavours/set-user-e-mode/types' +import { WithdrawAction } from '../flavours/withdraw/types' +import { Action, Objective } from './types' + +export function useCreateActions(objectives: Objective[]): Action[] { + const chainId = useOriginChainId() + const nativeAssetInfo = getNativeAssetInfo(chainId) + const wethGateway = useContractAddress(wethGatewayAddress) + + return objectives.flatMap((objective): Action[] => { + // @note: you can create hooks (actions) conditionally, but ensure that component will be re-mounted when condition changes + // to accomplish this, tweak stringifyObjectivesToStableActions function + + switch (objective.type) { + case 'deposit': { + const depositAction: DepositAction = { + type: 'deposit', + token: objective.token, + value: objective.value, + } + if (objective.token.symbol === nativeAssetInfo.nativeAssetSymbol) { + return [depositAction] + } + const approveAction: ApproveAction = { + type: 'approve', + token: objective.token, + spender: objective.lendingPool, + value: objective.value, + } + return [approveAction, depositAction] + } + + case 'withdraw': { + const withdrawValue = objective.all + ? objective.reserve.token.fromBaseUnit(BaseUnitNumber(maxUint256)) + : objective.value + + if (objective.reserve.token.symbol === nativeAssetInfo.nativeAssetSymbol) { + const approveValue = objective.all + ? NormalizedUnitNumber(objective.value.multipliedBy(1.01).toFixed(objective.reserve.token.decimals)) + : withdrawValue + + const approveAction: ApproveAction = { + type: 'approve', + token: objective.reserve.aToken, + spender: wethGateway, + value: approveValue, + } + + const withdrawAction: WithdrawAction = { + type: 'withdraw', + token: objective.reserve.token, + value: withdrawValue, + } + return [approveAction, withdrawAction] + } + + const withdrawAction: WithdrawAction = { + type: 'withdraw', + token: objective.reserve.token, + value: withdrawValue, + } + return [withdrawAction] + } + + case 'borrow': { + if (objective.token.symbol === nativeAssetInfo.nativeAssetSymbol) { + const approveDelegationAction: ApproveDelegationAction = { + type: 'approveDelegation', + token: objective.token, + debtTokenAddress: objective.debtTokenAddress, + delegatee: wethGateway, + value: objective.value, + } + + const borrowAction: BorrowAction = { + type: 'borrow', + token: objective.token, + value: objective.value, + } + return [approveDelegationAction, borrowAction] + } + + const borrowAction: BorrowAction = { + type: 'borrow', + token: objective.token, + value: objective.value, + } + return [borrowAction] + } + + case 'repay': { + const repayAction: RepayAction = { + type: 'repay', + reserve: objective.reserve, + value: objective.value, + useAToken: objective.useAToken, + } + if (objective.reserve.token.symbol === nativeAssetInfo.nativeAssetSymbol || objective.useAToken) { + return [repayAction] + } + const approveAction: ApproveAction = { + type: 'approve', + token: objective.reserve.token, + spender: objective.lendingPool, + requiredValue: objective.requiredApproval, + value: objective.value, + } + return [approveAction, repayAction] + } + + case 'setUseAsCollateral': { + const setUseAsCollateralAction: SetUseAsCollateralAction = { + type: 'setUseAsCollateral', + token: objective.token, + useAsCollateral: objective.useAsCollateral, + } + return [setUseAsCollateralAction] + } + + case 'setUserEMode': { + const setUserEModeAction: SetUserEModeAction = { + type: 'setUserEMode', + eModeCategoryId: objective.eModeCategoryId, + } + return [setUserEModeAction] + } + + case 'exchange': { + const approveExchangeAction: ApproveExchangeAction = { + type: 'approveExchange', + swapParams: objective.swapParams, + swapInfo: objective.swapInfo, + } + const exchangeAction: ExchangeAction = { + type: 'exchange', + value: objective.swapParams.value, + swapParams: objective.swapParams, + swapInfo: objective.swapInfo, + formatAsDAIValue: objective.formatAsDAIValue, + } + + return [approveExchangeAction, exchangeAction] + } + } + }) +} diff --git a/packages/app/src/features/actions/logic/useGasPrice.ts b/packages/app/src/features/actions/logic/useGasPrice.ts new file mode 100644 index 000000000..da6afebae --- /dev/null +++ b/packages/app/src/features/actions/logic/useGasPrice.ts @@ -0,0 +1,13 @@ +import { formatEther } from 'viem' +import { useGasPrice as useWagmiGasPrice } from 'wagmi' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +export function useGasPrice(): NormalizedUnitNumber | undefined { + const { data } = useWagmiGasPrice() + if (!data) { + return undefined + } + + return NormalizedUnitNumber(formatEther(data)) +} diff --git a/packages/app/src/features/actions/logic/utils.ts b/packages/app/src/features/actions/logic/utils.ts new file mode 100644 index 000000000..240f20806 --- /dev/null +++ b/packages/app/src/features/actions/logic/utils.ts @@ -0,0 +1,37 @@ +import { BaseError } from 'viem' + +import { UseWriteResult } from '@/domain/hooks/useWrite' + +import { ActionHandlerState } from './types' + +export function mapWriteResultToActionState(result: UseWriteResult): ActionHandlerState { + switch (result.status.kind) { + case 'ready': + return { status: 'ready' } + + case 'disabled': + return { status: 'disabled' } + + case 'simulating': + case 'tx-mining': + case 'tx-sending': + return { status: 'loading' } + + case 'error': + return { + status: 'error', + errorKind: result.status.errorKind, + message: parseWriteErrorMessage(result.status.error), + } + + case 'success': + return { status: 'success' } + } +} + +export function parseWriteErrorMessage(error: Error | undefined): string { + if (error instanceof BaseError) { + return error.shortMessage + } + return 'Unknown error' +} diff --git a/packages/app/src/features/actions/utils/formatGasPrice.test.ts b/packages/app/src/features/actions/utils/formatGasPrice.test.ts new file mode 100644 index 000000000..6c85f3004 --- /dev/null +++ b/packages/app/src/features/actions/utils/formatGasPrice.test.ts @@ -0,0 +1,20 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { formatGasPrice } from './formatGasPrice' + +describe(formatGasPrice.name, () => { + it('formats', () => { + const tests: [number, string][] = [ + [0, '0'], + [0.000000001, '1'], + [0.00000001, '10'], + [0.0000000007234, '0.72'], + [0.000000000725, '0.73'], + [1.23, '1,230,000,000'], + ] + + tests.forEach(([value, expected]) => { + expect(formatGasPrice(NormalizedUnitNumber(value))).toEqual(expected) + }) + }) +}) diff --git a/packages/app/src/features/actions/utils/formatGasPrice.ts b/packages/app/src/features/actions/utils/formatGasPrice.ts new file mode 100644 index 000000000..259ea8baa --- /dev/null +++ b/packages/app/src/features/actions/utils/formatGasPrice.ts @@ -0,0 +1,12 @@ +import { formatGwei } from 'viem' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { toBigInt } from '@/utils/bigNumber' + +export function formatGasPrice(gasPrice: NormalizedUnitNumber): string { + const formattedGwei = formatGwei(toBigInt(gasPrice.shiftedBy(18))) + const formatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, + }) + return formatter.format(Number(formattedGwei)) +} diff --git a/packages/app/src/features/actions/views/ActionsView.stories.ts b/packages/app/src/features/actions/views/ActionsView.stories.ts new file mode 100644 index 000000000..029aae3a5 --- /dev/null +++ b/packages/app/src/features/actions/views/ActionsView.stories.ts @@ -0,0 +1,88 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { zeroAddress } from 'viem' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { ActionsView } from './ActionsView' + +const meta: Meta = { + title: 'Features/Actions/ActionsView', + component: ActionsView, + decorators: [WithTooltipProvider()], + args: { + variant: 'default', + actionHandlers: [ + { + action: { + type: 'approve', + token: tokens['WETH'], + spender: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + }, + state: { status: 'success' }, + onAction: () => {}, + }, + { + action: { + type: 'deposit', + token: tokens['ETH'], + value: NormalizedUnitNumber(1), + }, + state: { status: 'loading' }, + onAction: () => {}, + }, + { + action: { + type: 'approve', + token: tokens['wstETH'], + spender: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + }, + state: { status: 'error', message: 'Insufficient balance' }, + onAction: () => {}, + }, + { + action: { + type: 'deposit', + token: tokens['wstETH'], + value: NormalizedUnitNumber(1), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + { + action: { + type: 'borrow', + token: tokens['DAI'], + value: NormalizedUnitNumber(1), + }, + state: { status: 'ready' }, + onAction: () => {}, + }, + ], + actionsSettings: { + exchangeMaxSlippage: Percentage(0.005), + preferPermits: true, + setPreferPermits: () => {}, + }, + gasPrice: NormalizedUnitNumber(0.000000000000000001), + }, +} + +export default meta +type Story = StoryObj + +export const Extended: Story = {} +export const Compact: Story = { + args: { + variant: 'dialog', + }, +} +export const ExtendedMobile = getMobileStory(Extended) +export const CompactMobile = getMobileStory(Compact) +export const ExtendedTablet = getTabletStory(Extended) +export const CompactTablet = getTabletStory(Compact) diff --git a/packages/app/src/features/actions/views/ActionsView.tsx b/packages/app/src/features/actions/views/ActionsView.tsx new file mode 100644 index 000000000..87512141d --- /dev/null +++ b/packages/app/src/features/actions/views/ActionsView.tsx @@ -0,0 +1,83 @@ +import { cva } from 'class-variance-authority' +import { Fuel } from 'lucide-react' + +import { ActionsSettings } from '@/domain/state/actions-settings' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Panel } from '@/ui/atoms/panel/Panel' +import { Info } from '@/ui/molecules/info/Info' +import { LabeledSwitch } from '@/ui/molecules/labeled-switch/LabeledSwitch' +import { cn } from '@/ui/utils/style' + +import { ActionsGrid } from '../components/actions-grid/ActionsGrid' +import { ActionSettings } from '../components/settings-dialog/ActionSettings' +import { ActionHandler } from '../logic/types' +import { formatGasPrice } from '../utils/formatGasPrice' + +const actionsPanelVariants = cva('', { + variants: { + variant: { + default: '', + dialog: 'bg-panel-bg gap-0 p-4 md:p-4', + }, + }, +}) + +const actionsTitleVariants = cva('', { + variants: { + variant: { + default: '', + dialog: 'text-primary mb-1 font-semibold', + }, + }, +}) + +export interface ActionsViewProps { + actionHandlers: ActionHandler[] + actionsSettings: ActionsSettings + settingsDisabled: boolean + variant: 'default' | 'dialog' + gasPrice?: NormalizedUnitNumber +} + +export function ActionsView({ + actionHandlers, + actionsSettings, + variant, + gasPrice, + settingsDisabled, +}: ActionsViewProps) { + const { preferPermits, setPreferPermits } = actionsSettings + + return ( + + + + Actions + + {import.meta.env.VITE_DEV_ACTIONS_SETTINGS === '1' && {}} />} + + + +
+ +
+
Prefer permits
+ + Use permits for actions that support them. Permits are a way to save gas by allowing a contract to + execute multiple actions in a single transaction. + +
+
+
+ + {gasPrice ? `~${formatGasPrice(gasPrice)} GWEI` : 'Not available'} +
+
+
+
+ ) +} diff --git a/packages/app/src/features/compliance/ComplianceContainer.tsx b/packages/app/src/features/compliance/ComplianceContainer.tsx new file mode 100644 index 000000000..9f6b43086 --- /dev/null +++ b/packages/app/src/features/compliance/ComplianceContainer.tsx @@ -0,0 +1,28 @@ +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { AddressBlocked } from './components/AddressBlocked' +import { PageNotAvailable } from './components/PageNotAvailable' +import { RegionBlocked } from './components/RegionBlocked' +import { TermsOfService } from './components/TermsOfService' +import { VPNBlocked } from './components/VPNBlocked' +import { useCompliance } from './logic/useCompliance' + +export function ComplianceContainer() { + const { visibleModal } = useCompliance() + + return ( + + + {visibleModal.type === 'terms-of-service' && } + {visibleModal.type === 'vpn-detected' && } + {visibleModal.type === 'region-blocked' && } + {visibleModal.type === 'feature-not-available-in-region' && ( + + )} + {visibleModal.type === 'address-not-allowed' && ( + + )} + + + ) +} diff --git a/packages/app/src/features/compliance/components/AddressBlocked.stories.ts b/packages/app/src/features/compliance/components/AddressBlocked.stories.ts new file mode 100644 index 000000000..da5d0e169 --- /dev/null +++ b/packages/app/src/features/compliance/components/AddressBlocked.stories.ts @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { testAddresses } from '@/test/integration/constants' + +import { AddressBlocked } from './AddressBlocked' + +const meta: Meta = { + title: 'Features/Compliance/Components/AddressBlocked', + decorators: [withRouter()], + component: AddressBlocked, + args: { + address: testAddresses.alice, + disconnect: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/compliance/components/AddressBlocked.tsx b/packages/app/src/features/compliance/components/AddressBlocked.tsx new file mode 100644 index 000000000..a7d46bf62 --- /dev/null +++ b/packages/app/src/features/compliance/components/AddressBlocked.tsx @@ -0,0 +1,25 @@ +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { Button } from '@/ui/atoms/button/Button' +import { shortenAddress } from '@/ui/utils/shortenAddress' + +import { Banner } from './Banner' + +export interface AddressBlockedProps { + address: CheckedAddress + disconnect: () => void +} +export function AddressBlocked({ disconnect, address }: AddressBlockedProps) { + return ( + + + Address blocked + + We're sorry but address {shortenAddress(address, { startLength: 10 })} is blacklisted. + + + + + ) +} diff --git a/packages/app/src/features/compliance/components/Banner.tsx b/packages/app/src/features/compliance/components/Banner.tsx new file mode 100644 index 000000000..5061e0e74 --- /dev/null +++ b/packages/app/src/features/compliance/components/Banner.tsx @@ -0,0 +1,28 @@ +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { assets } from '@/ui/assets' + +function Banner({ children }: { children: React.ReactNode }) { + return ( + + Blocked + {children} + + ) +} + +function BannerContent({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function BannerHeader({ children }: { children: React.ReactNode }) { + return

{children}

+} + +function BannerDescription({ children }: { children: React.ReactNode }) { + return

{children}

+} + +Banner.Content = BannerContent +Banner.Header = BannerHeader +Banner.Description = BannerDescription +export { Banner } diff --git a/packages/app/src/features/compliance/components/PageNotAvailable.tsx b/packages/app/src/features/compliance/components/PageNotAvailable.tsx new file mode 100644 index 000000000..1eb4227d8 --- /dev/null +++ b/packages/app/src/features/compliance/components/PageNotAvailable.tsx @@ -0,0 +1,17 @@ +import { Banner } from './Banner' + +export interface PageNotAvailableProps { + countryCode: string +} +export function PageNotAvailable({ countryCode }: PageNotAvailableProps) { + return ( + + + Page not available in your region + + We're sorry but the page is not available in a region you're connecting from ({countryCode} country code). + + + + ) +} diff --git a/packages/app/src/features/compliance/components/RegionBlocked.stories.ts b/packages/app/src/features/compliance/components/RegionBlocked.stories.ts new file mode 100644 index 000000000..31720d505 --- /dev/null +++ b/packages/app/src/features/compliance/components/RegionBlocked.stories.ts @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { RegionBlocked } from './RegionBlocked' + +const meta: Meta = { + title: 'Features/Compliance/Components/RegionBlocked', + decorators: [withRouter()], + component: RegionBlocked, + args: { + countryCode: 'IR', + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} diff --git a/packages/app/src/features/compliance/components/RegionBlocked.tsx b/packages/app/src/features/compliance/components/RegionBlocked.tsx new file mode 100644 index 000000000..99005ad3f --- /dev/null +++ b/packages/app/src/features/compliance/components/RegionBlocked.tsx @@ -0,0 +1,17 @@ +import { Banner } from './Banner' + +export interface RegionBlockedProps { + countryCode: string +} +export function RegionBlocked({ countryCode }: RegionBlockedProps) { + return ( + + + Region blocked + + We're sorry but you're connecting from a blocked region ({countryCode} country code). + + + + ) +} diff --git a/packages/app/src/features/compliance/components/TermsOfService.stories.ts b/packages/app/src/features/compliance/components/TermsOfService.stories.ts new file mode 100644 index 000000000..e745de7c1 --- /dev/null +++ b/packages/app/src/features/compliance/components/TermsOfService.stories.ts @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { TermsOfService } from './TermsOfService' + +const meta: Meta = { + title: 'Features/Compliance/Components/TermsOfService', + decorators: [withRouter()], + component: TermsOfService, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/compliance/components/TermsOfService.tsx b/packages/app/src/features/compliance/components/TermsOfService.tsx new file mode 100644 index 000000000..403ba772a --- /dev/null +++ b/packages/app/src/features/compliance/components/TermsOfService.tsx @@ -0,0 +1,93 @@ +import React from 'react' + +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { assets } from '@/ui/assets' +import { Button } from '@/ui/atoms/button/Button' +import { Link } from '@/ui/atoms/link/Link' +import { ScrollArea } from '@/ui/atoms/scroll-area/ScrollArea' +import { links } from '@/ui/constants/links' +import { cn } from '@/ui/utils/style' + +interface ToSLinkProps { + className?: string +} +function ToSLink({ className }: ToSLinkProps) { + return ( + + Terms of Service + + ) +} + +/* eslint-disable react/jsx-key */ +const points = [ +

+ I am not the person or entities who reside in, are citizens of, are incorporated in, or have a registered office in + the United States of America or any Prohibited Localities, as defined in the . I will not in the future + access this site while located within the United States or any Prohibited Localities, as defined in the . + I am not using, and will not in the future use, a VPN to mask my physical location from a restricted territory. I am + lawfully permitted to access this site and use its services under the laws of the jurisdiction in which I reside and + am located. +

, +

+ The Site displays information publicly available on blockchain systems related to third party protocols, including + Spark, and may offer interaction methods for use with a third-party wallet application or device based on such + information, but the Site Operator cannot guarantee the accuracy of such information or that interactions will have + the intended outcome. For example, displayed asset values reflect on-chain data provided by certain third party + protocols according to such protocol's mechanics and governance procedures, including MakerDAO, and such protocol's + are outside the Site's control and are subject to change. Furthermore, such protocols may be adversely affected by + malfunctions, bugs, defects, malfunctions, hacking, theft, attacks, negligent coding or design choices, or changes + to the applicable protocol rules, which may expose you to a risk of total loss and forfeiture of all relevant + digital assets. Site Operator assumes no liability or responsibility for any of the foregoing matters, including + asset value pricing, and you are responsible for understanding the risks of the third party protocols you interact + with and keeping up to date with protocol or governance changes for such protocols. +

, +

+ Your use of the Site is conditioned on your acknowledgement and understanding of the potential risks and regulatory + issues as further described in the , and you agree to hold the Site Operator harmless from such risks. + All functions interacting with third party protocols on the Site are autonomous and if something goes wrong with the + smart contracts, there is no recourse against a private individual or legal entity. Similarly, while the Site + provides information regarding such protocols, the Site Operator is not responsible for issues with the protocols. + The Site Operator cannot access or control deposits or transactions initiated through the Site. The Site Operator + has no control over Spark, MakerDAO or other third party protocols or other frontends. Site Operator does not and + will not enter into any legal or factual relationship with any user of such protocols beyond provision and + maintenance of the Site. +

, +] +/* eslint-enable react/jsx-key */ + +export interface TermsOfServiceProps { + onAgree?: () => void +} + +export function TermsOfService({ onAgree }: TermsOfServiceProps) { + return ( + +
Terms of Service and Disclaimer
+
+ By using this site, I represent that I have read and agree to the and{' '} + + Privacy Policy + + . Undefined terms used below are in reference to definitions in the Terms of Service. +
+ +
+ {points.map((point, index) => ( + + success-img + {point} + + ))} +
+
+ +
+ ) +} diff --git a/packages/app/src/features/compliance/components/VPNBlocked.stories.ts b/packages/app/src/features/compliance/components/VPNBlocked.stories.ts new file mode 100644 index 000000000..4fffb32ba --- /dev/null +++ b/packages/app/src/features/compliance/components/VPNBlocked.stories.ts @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { VPNBlocked } from './VPNBlocked' + +const meta: Meta = { + title: 'Features/Compliance/Components/VPNBlocked', + decorators: [withRouter()], + component: VPNBlocked, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/compliance/components/VPNBlocked.tsx b/packages/app/src/features/compliance/components/VPNBlocked.tsx new file mode 100644 index 000000000..57c7ea89a --- /dev/null +++ b/packages/app/src/features/compliance/components/VPNBlocked.tsx @@ -0,0 +1,12 @@ +import { Banner } from './Banner' + +export function VPNBlocked() { + return ( + + + VPN detected + We're sorry but this app is not accessible for VPN users. + + + ) +} diff --git a/packages/app/src/features/compliance/logic/consts.ts b/packages/app/src/features/compliance/logic/consts.ts new file mode 100644 index 000000000..6d2eb0d1c --- /dev/null +++ b/packages/app/src/features/compliance/logic/consts.ts @@ -0,0 +1,34 @@ +import { paths } from '@/config/paths' + +export const blockedCountryCodes = [ + 'AF', // Afghanistan + 'BA', // Bosnia and Herzegovina + 'BY', // Belarus + 'BI', // Burundi + 'CF', // Central African Republic + 'CU', // Cuba + 'CD', // Democratic Republic of the Congo + 'ET', // Ethiopia + 'GN', // Guinea + 'GW', // Guinea-Bissau + 'HT', // Haiti + 'IR', // Iran + 'IQ', // Iraq + 'LB', // Lebanon + 'LY', // Libya + 'ML', // Mali + 'MM', // Myanmar (Burma) + 'NI', // Nicaragua + 'KP', // North Korea + 'RU', // Russia + 'SO', // Somalia + 'SD', // Sudan + 'SY', // Syria + 'UA', // Ukraine + 'VE', // Venezuela + 'YE', // Yemen + 'ZW', // Zimbabwe + 'US', // United States +] + +export const blockedPagesByCountryCode: Record = {} diff --git a/packages/app/src/features/compliance/logic/useBlockedPages.ts b/packages/app/src/features/compliance/logic/useBlockedPages.ts new file mode 100644 index 000000000..cbc3b8d61 --- /dev/null +++ b/packages/app/src/features/compliance/logic/useBlockedPages.ts @@ -0,0 +1,21 @@ +import { useVpnCheck } from '@jetstreamgg/hooks' + +import { apiUrl } from '@/config/consts' +import { paths } from '@/config/paths' + +import { blockedPagesByCountryCode } from './consts' + +export function useBlockedPages(): (keyof typeof paths)[] { + if (import.meta.env.VITE_FEATURE_AUTH_IP_AND_ADDRESS_CHECKS !== '1') { + return [] + } + /* eslint-disable react-hooks/rules-of-hooks */ + const vpnCheck = useVpnCheck({ authUrl: apiUrl }) + /* eslint-enable react-hooks/rules-of-hooks */ + + if (vpnCheck.data && blockedPagesByCountryCode[vpnCheck.data.countryCode] !== undefined) { + return blockedPagesByCountryCode[vpnCheck.data.countryCode]! + } + + return [] +} diff --git a/packages/app/src/features/compliance/logic/useCompliance.ts b/packages/app/src/features/compliance/logic/useCompliance.ts new file mode 100644 index 000000000..4ef933d9d --- /dev/null +++ b/packages/app/src/features/compliance/logic/useCompliance.ts @@ -0,0 +1,85 @@ +import { useAccount, useDisconnect } from 'wagmi' + +import { useTermsOfService } from '@/domain/state/compliance' +import { useCloseDialog } from '@/domain/state/dialogs' +import { CheckedAddress } from '@/domain/types/CheckedAddress' + +import { useIPAndAddressCheck } from './useIPAndAddressCheck' + +export type ModalInfo = + | { type: 'none' } + | { type: 'terms-of-service'; onAgreeToTermsOfService: () => void } + | { type: 'vpn-detected' } + | { type: 'region-blocked'; countryCode: string } + | { type: 'feature-not-available-in-region'; countryCode: string } + | { type: 'address-not-allowed'; address: CheckedAddress; disconnect: () => void } +export interface UseComplianceResults { + visibleModal: ModalInfo +} +export function useCompliance(): UseComplianceResults { + const { address } = useAccount() + const { agreedToTermsOfService, saveAgreedToTermsOfService } = useTermsOfService() + const closeDialog = useCloseDialog() + const { disconnect } = useDisconnect() + + const ipAndAddressChecks = useIPAndAddressCheck() + + const visibleModal: ModalInfo = (() => { + if (ipAndAddressChecks.blocked) { + if (ipAndAddressChecks.reason === 'vpn-detected') { + return { + type: 'vpn-detected', + } + } + + if (ipAndAddressChecks.reason === 'region-blocked') { + return { + type: 'region-blocked', + countryCode: ipAndAddressChecks.data.countryCode, + } + } + + if (address && ipAndAddressChecks.reason === 'address-not-allowed') { + return { + type: 'address-not-allowed', + address: CheckedAddress(address), + disconnect, + } + } + + if (ipAndAddressChecks.reason === 'page-not-available-in-region') { + return { + type: 'feature-not-available-in-region', + countryCode: ipAndAddressChecks.data.countryCode, + } + } + + return { + type: 'none', + } + } + + if ( + import.meta.env.VITE_FEATURE_TOS_REQUIRED === '1' && + !!address && + !agreedToTermsOfService(CheckedAddress(address)) + ) { + return { + type: 'terms-of-service', + onAgreeToTermsOfService: () => saveAgreedToTermsOfService(CheckedAddress(address)), + } + } + + return { + type: 'none', + } + })() + + if (visibleModal.type !== 'none') { + closeDialog() // Close any open dialog + } + + return { + visibleModal, + } +} diff --git a/packages/app/src/features/compliance/logic/useIPAndAddressCheck.ts b/packages/app/src/features/compliance/logic/useIPAndAddressCheck.ts new file mode 100644 index 000000000..5a4fae27f --- /dev/null +++ b/packages/app/src/features/compliance/logic/useIPAndAddressCheck.ts @@ -0,0 +1,51 @@ +import { useRestrictedAddressCheck, useVpnCheck } from '@jetstreamgg/hooks' +import { useAccount } from 'wagmi' + +import { apiUrl } from '@/config/consts' + +import { blockedCountryCodes } from './consts' +import { useIsCurrentPageBlocked } from './useIsCurrentPageBlocked' + +export type UseIPAndAddressCheck = + | { blocked: false } + | { blocked: true; reason: 'address-not-allowed' | 'vpn-detected' } + | { blocked: true; reason: 'region-blocked' | 'page-not-available-in-region'; data: { countryCode: string } } +export function useIPAndAddressCheck(): UseIPAndAddressCheck { + if (import.meta.env.VITE_FEATURE_AUTH_IP_AND_ADDRESS_CHECKS !== '1') { + return { blocked: false } + } + + /* eslint-disable react-hooks/rules-of-hooks */ + const { address } = useAccount() + const addressCheck = useRestrictedAddressCheck({ + address, + authUrl: apiUrl, + enabled: !!address, + refetchInterval: 5 * 60 * 1_000, // recheck every 5 minutes + }) + const vpnCheck = useVpnCheck({ authUrl: apiUrl }) + const isCurrentPageBlocked = useIsCurrentPageBlocked() + /* eslint-enable react-hooks/rules-of-hooks */ + + if (vpnCheck.data?.isConnectedToVpn) { + return { blocked: true, reason: 'vpn-detected' } + } + + if (vpnCheck.data && blockedCountryCodes.includes(vpnCheck.data.countryCode)) { + return { blocked: true, reason: 'region-blocked', data: { countryCode: vpnCheck.data.countryCode } } + } + + if (addressCheck.data?.addressAllowed === false) { + return { blocked: true, reason: 'address-not-allowed' } + } + + if (vpnCheck.data && isCurrentPageBlocked) { + return { + blocked: true, + reason: 'page-not-available-in-region', + data: { countryCode: vpnCheck.data.countryCode }, + } + } + + return { blocked: false } +} diff --git a/packages/app/src/features/compliance/logic/useIsCurrentPageBlocked.ts b/packages/app/src/features/compliance/logic/useIsCurrentPageBlocked.ts new file mode 100644 index 000000000..a5a66c50f --- /dev/null +++ b/packages/app/src/features/compliance/logic/useIsCurrentPageBlocked.ts @@ -0,0 +1,18 @@ +import { matchPath, useLocation } from 'react-router-dom' + +import { paths } from '@/config/paths' + +import { useBlockedPages } from './useBlockedPages' + +export function useIsCurrentPageBlocked(): boolean { + const blockedPages = useBlockedPages() + const { pathname } = useLocation() + + for (const blockedPage of blockedPages) { + if (matchPath(paths[blockedPage], pathname)) { + return true + } + } + + return false +} diff --git a/packages/app/src/features/dashboard/DashboardContainer.tsx b/packages/app/src/features/dashboard/DashboardContainer.tsx new file mode 100644 index 000000000..c366eee28 --- /dev/null +++ b/packages/app/src/features/dashboard/DashboardContainer.tsx @@ -0,0 +1,35 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' + +import { useOpenDialog } from '@/domain/state/dialogs' +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DashboardSkeleton } from './components/skeleton/DashboardSkeleton' +import { useDashboard } from './logic/useDashboard' +import { GuestView } from './views/GuestView' +import { PositionView } from './views/PositionView' + +function DashboardContainer() { + const { positionSummary, deposits, borrows, walletComposition, eModeCategoryId, guestMode, liquidationDetails } = + useDashboard() + const { openConnectModal = () => {} } = useConnectModal() + const openDialog = useOpenDialog() + + if (guestMode) { + return + } + + return ( + + ) +} + +const DashboardContainerWithSuspense = withSuspense(DashboardContainer, DashboardSkeleton) +export { DashboardContainerWithSuspense as DashboardContainer } diff --git a/packages/app/src/features/dashboard/components/borrow-table/BorrowTable.stories.tsx b/packages/app/src/features/dashboard/components/borrow-table/BorrowTable.stories.tsx new file mode 100644 index 000000000..0e73494a9 --- /dev/null +++ b/packages/app/src/features/dashboard/components/borrow-table/BorrowTable.stories.tsx @@ -0,0 +1,77 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { within } from '@storybook/testing-library' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { raise } from '@/utils/raise' + +import { Borrow } from '../../logic/assets' +import { BorrowTable } from './BorrowTable' + +const assets: Borrow[] = [ + { + token: tokens['DAI'], + available: NormalizedUnitNumber('22727'), + debt: NormalizedUnitNumber('50000'), + borrowAPY: Percentage(0.11), + reserveStatus: 'active', + }, + { + token: tokens['ETH'], + available: NormalizedUnitNumber('11.99'), + debt: NormalizedUnitNumber(0), + borrowAPY: Percentage(0.157), + reserveStatus: 'active', + }, + { + token: tokens['stETH'], + available: NormalizedUnitNumber('14.68'), + debt: NormalizedUnitNumber(0), + borrowAPY: Percentage(0.145), + reserveStatus: 'active', + }, + { + token: tokens['GNO'], + available: NormalizedUnitNumber('0'), + debt: NormalizedUnitNumber(10), + borrowAPY: Percentage(0.345), + reserveStatus: 'frozen', + }, + { + token: tokens['wstETH'], + available: NormalizedUnitNumber('0'), + debt: NormalizedUnitNumber(2), + borrowAPY: Percentage(0.32), + reserveStatus: 'paused', + }, +] + +const meta: Meta = { + title: 'Features/Dashboard/Components/BorrowTable', + decorators: [withRouter, WithTooltipProvider()], + component: BorrowTable, + args: { + assets, + openDialog: () => {}, + eModeCategoryId: 0, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +const WithCanvas: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const switches = await canvas.findAllByRole('switch') + ;(switches[0] ?? raise('No switch element found')).click() + }, +} + +export const Mobile = getMobileStory(WithCanvas) +export const Tablet = getTabletStory(WithCanvas) diff --git a/packages/app/src/features/dashboard/components/borrow-table/BorrowTable.tsx b/packages/app/src/features/dashboard/components/borrow-table/BorrowTable.tsx new file mode 100644 index 000000000..b2b2bf7bf --- /dev/null +++ b/packages/app/src/features/dashboard/components/borrow-table/BorrowTable.tsx @@ -0,0 +1,109 @@ +import { sortByUsdValue } from '@/domain/common/sorters' +import { EModeCategoryId } from '@/domain/e-mode/types' +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { BorrowDialog } from '@/features/dialogs/borrow/BorrowDialog' +import { EModeDialog } from '@/features/dialogs/e-mode/EModeDialog' +import { RepayDialog } from '@/features/dialogs/repay/RepayDialog' +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' +import { ApyTooltip } from '@/ui/molecules/apy-tooltip/ApyTooltip' +import { ActionsCell } from '@/ui/molecules/data-table/components/ActionsCell' +import { CompactValueCell } from '@/ui/molecules/data-table/components/CompactValueCell' +import { PercentageCell } from '@/ui/molecules/data-table/components/PercentageCell' +import { TokenWithLogo } from '@/ui/molecules/data-table/components/TokenWithLogo' +import { ResponsiveDataTable } from '@/ui/organisms/responsive-data-table/ResponsiveDataTable' + +import { Borrow } from '../../logic/assets' +import { EModeSwitch } from './components/EModeSwitch' + +export interface BorrowTableProps { + assets: Borrow[] + openDialog: OpenDialogFunction + eModeCategoryId: EModeCategoryId +} + +export function BorrowTable({ assets, openDialog, eModeCategoryId }: BorrowTableProps) { + return ( + + + Borrow + { + openDialog(EModeDialog, { userEModeCategoryId: eModeCategoryId }) + }} + /> + + + + , + }, + inWallet: { + header: 'Available', + sortable: true, + sortingFn: (a, b) => sortByUsdValue(a.original, b.original, 'available'), + headerAlign: 'right', + renderCell: ({ token, available }, mobileViewOptions) => ( + + ), + }, + deposit: { + header: 'Your borrow', + sortable: true, + sortingFn: (a, b) => sortByUsdValue(a.original, b.original, 'debt'), + headerAlign: 'right', + renderCell: ({ token, debt }, mobileViewOptions) => ( + + ), + }, + apy: { + header: APY, + headerAlign: 'right', + sortable: true, + sortingFn: (a, b) => a.original.borrowAPY.comparedTo(b.original.borrowAPY), + renderCell: ({ borrowAPY }, mobileViewOptions) => ( + + ), + }, + actions: { + header: '', + renderCell: ({ token, debt, reserveStatus }) => { + return ( + + + + + ) + }, + }, + }} + data={assets} + /> + + + ) +} diff --git a/packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.stories.ts b/packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.stories.ts new file mode 100644 index 000000000..7d813c17e --- /dev/null +++ b/packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.stories.ts @@ -0,0 +1,37 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' + +import { EModeSwitch } from './EModeSwitch' + +const meta: Meta = { + title: 'Features/Dashboard/Components/BorrowTable/Components/EModeSwitch', + decorators: [WithTooltipProvider()], + component: EModeSwitch, + args: { + onSwitchClick: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const EModeOff: Story = { + name: 'E-Mode Off', + args: { + eModeCategoryId: 0, + }, +} + +export const EModeETHCorrelated: Story = { + name: 'E-Mode ETH Correlated', + args: { + eModeCategoryId: 1, + }, +} + +export const EModeStablecoins: Story = { + name: 'E-Mode Stablecoins', + args: { + eModeCategoryId: 2, + }, +} diff --git a/packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.tsx b/packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.tsx new file mode 100644 index 000000000..9c71581ff --- /dev/null +++ b/packages/app/src/features/dashboard/components/borrow-table/components/EModeSwitch.tsx @@ -0,0 +1,24 @@ +import { eModeCategoryIdToName } from '@/domain/e-mode/constants' +import { EModeCategoryId } from '@/domain/e-mode/types' +import { Switch } from '@/ui/atoms/switch/Switch' +import { Info } from '@/ui/molecules/info/Info' + +interface EModeSwitchProps { + eModeCategoryId: EModeCategoryId + onSwitchClick: () => void +} + +export function EModeSwitch({ eModeCategoryId, onSwitchClick }: EModeSwitchProps) { + return ( +
e.stopPropagation()}> +
+

E-Mode

+ Efficiency mode (E-Mode) increases your LTV for a selected category of assets up to 97%. +
+ +

+ {eModeCategoryId !== 0 && eModeCategoryIdToName[eModeCategoryId]} +

+
+ ) +} diff --git a/packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.stories.tsx b/packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.stories.tsx new file mode 100644 index 000000000..f5e7be017 --- /dev/null +++ b/packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.stories.tsx @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react' +import { chromatic } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { CreatePositionPanel } from './CreatePositionPanel' + +const meta: Meta = { + title: 'Features/Dashboard/Components/CreatePositionPanel', + component: CreatePositionPanel, + decorators: [withRouter], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} diff --git a/packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.tsx b/packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.tsx new file mode 100644 index 000000000..d0ab1ef7f --- /dev/null +++ b/packages/app/src/features/dashboard/components/create-position-panel/CreatePositionPanel.tsx @@ -0,0 +1,29 @@ +import { paths } from '@/config/paths' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { getTokenImage } from '@/ui/assets' +import { LinkButton } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' +import { Typography } from '@/ui/atoms/typography/Typography' +import { IconStack } from '@/ui/molecules/icon-stack/IconStack' + +const TOKEN_ICON_PATHS = ['DAI', 'ETH', 'USDC', 'WBTC'].map(TokenSymbol).map(getTokenImage) + +interface CreatePositionPanelProps { + className?: string +} + +export function CreatePositionPanel({ className }: CreatePositionPanelProps) { + return ( + + +
+ + + Quickly deposit your assets and borrow DAI with our Easy Borrow Flow + +
+ Create position +
+
+ ) +} diff --git a/packages/app/src/features/dashboard/components/deposit-table/DepositTable.stories.tsx b/packages/app/src/features/dashboard/components/deposit-table/DepositTable.stories.tsx new file mode 100644 index 000000000..629226e0c --- /dev/null +++ b/packages/app/src/features/dashboard/components/deposit-table/DepositTable.stories.tsx @@ -0,0 +1,81 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { within } from '@storybook/testing-library' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { raise } from '@/utils/raise' + +import { Deposit } from '../../logic/assets' +import { DepositTable } from './DepositTable' + +const assets: Deposit[] = [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber('84.330123431'), + deposit: NormalizedUnitNumber('13.74'), + supplyAPY: Percentage(0.0145), + isUsedAsCollateral: true, + reserveStatus: 'active', + }, + { + token: tokens['stETH'], + balance: NormalizedUnitNumber('16.76212348'), + deposit: NormalizedUnitNumber('34.21'), + supplyAPY: Percentage(0.0145), + isUsedAsCollateral: true, + reserveStatus: 'active', + }, + { + token: tokens['DAI'], + balance: NormalizedUnitNumber('48.9234234'), + deposit: NormalizedUnitNumber('9.37'), + supplyAPY: Percentage(0.0145), + isUsedAsCollateral: false, + reserveStatus: 'active', + }, + { + token: tokens['GNO'], + balance: NormalizedUnitNumber('299.9234234'), + deposit: NormalizedUnitNumber('1.37'), + supplyAPY: Percentage(0.0345), + isUsedAsCollateral: false, + reserveStatus: 'frozen', + }, + { + token: tokens['wstETH'], + balance: NormalizedUnitNumber('89.923'), + deposit: NormalizedUnitNumber('5.37'), + supplyAPY: Percentage(0.012), + isUsedAsCollateral: false, + reserveStatus: 'paused', + }, +] + +const meta: Meta = { + title: 'Features/Dashboard/Components/DepositTable', + decorators: [withRouter, WithTooltipProvider()], + component: DepositTable, + args: { + assets, + openDialog: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +const WithCanvas: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const switches = await canvas.findAllByRole('switch') + ;(switches[0] ?? raise('No switch element found')).click() + }, +} + +export const Mobile = getMobileStory(WithCanvas) +export const Tablet = getTabletStory(WithCanvas) diff --git a/packages/app/src/features/dashboard/components/deposit-table/DepositTable.tsx b/packages/app/src/features/dashboard/components/deposit-table/DepositTable.tsx new file mode 100644 index 000000000..75cee41de --- /dev/null +++ b/packages/app/src/features/dashboard/components/deposit-table/DepositTable.tsx @@ -0,0 +1,118 @@ +import { sortByUsdValue } from '@/domain/common/sorters' +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { CollateralDialog } from '@/features/dialogs/collateral/CollateralDialog' +import { DepositDialog } from '@/features/dialogs/deposit/DepositDialog' +import { WithdrawDialog } from '@/features/dialogs/withdraw/WithdrawDialog' +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' +import { ApyTooltip } from '@/ui/molecules/apy-tooltip/ApyTooltip' +import { ActionsCell } from '@/ui/molecules/data-table/components/ActionsCell' +import { CompactValueCell } from '@/ui/molecules/data-table/components/CompactValueCell' +import { PercentageCell } from '@/ui/molecules/data-table/components/PercentageCell' +import { SwitchCell } from '@/ui/molecules/data-table/components/SwitchCell' +import { TokenWithLogo } from '@/ui/molecules/data-table/components/TokenWithLogo' +import { ResponsiveDataTable } from '@/ui/organisms/responsive-data-table/ResponsiveDataTable' + +import { Deposit } from '../../logic/assets' + +export interface DepositTableProps { + assets: Deposit[] + openDialog: OpenDialogFunction +} + +export function DepositTable({ assets, openDialog }: DepositTableProps) { + return ( + + + Deposit + + + + , + }, + inWallet: { + header: 'In Wallet', + sortable: true, + sortingFn: (a, b) => sortByUsdValue(a.original, b.original, 'balance'), + headerAlign: 'right', + renderCell: ({ token, balance }, mobileViewOptions) => ( + + ), + }, + deposit: { + header: 'Deposit', + sortable: true, + sortingFn: (a, b) => sortByUsdValue(a.original, b.original, 'deposit'), + headerAlign: 'right', + renderCell: ({ token, deposit }, mobileViewOptions) => ( + + ), + }, + apy: { + header: APY, + headerAlign: 'right', + sortable: true, + sortingFn: (a, b) => a.original.supplyAPY.comparedTo(b.original.supplyAPY), + renderCell: ({ supplyAPY }, mobileViewOptions) => ( + + ), + }, + collateral: { + header: 'Collateral', + headerAlign: 'right', + renderCell: ({ isUsedAsCollateral, token }, mobileViewOptions) => ( + { + e.preventDefault() + openDialog(CollateralDialog, { + useAsCollateral: !isUsedAsCollateral, + token, + }) + }} + mobileViewOptions={mobileViewOptions} + /> + ), + }, + actions: { + header: '', + renderCell: ({ token, deposit, reserveStatus }) => { + return ( + + + + + ) + }, + }, + }} + data={assets} + /> + + + ) +} diff --git a/packages/app/src/features/dashboard/components/position/Position.stories.tsx b/packages/app/src/features/dashboard/components/position/Position.stories.tsx new file mode 100644 index 000000000..48ea34140 --- /dev/null +++ b/packages/app/src/features/dashboard/components/position/Position.stories.tsx @@ -0,0 +1,285 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import type { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { Position } from './Position' + +const meta: Meta = { + title: 'Features/Dashboard/Components/Position', + component: Position, + decorators: [WithTooltipProvider()], +} + +export default meta +type Story = StoryObj + +export const RoundValues: Story = { + name: 'Round Values', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(167_600), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(50), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(25), + }, + ], + borrow: { + current: NormalizedUnitNumber(50_000), + max: NormalizedUnitNumber(75_000), + percents: { + borrowed: 29, + max: 44, + rest: 71, + }, + }, + }, + }, +} + +export const RealValues: Story = { + name: 'Real Values', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(110_412), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(32), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(17.4), + }, + ], + borrow: { + current: NormalizedUnitNumber(34780), + max: NormalizedUnitNumber(45012), + percents: { + borrowed: 31.5, + max: 40.8, + rest: 68.5, + }, + }, + }, + }, +} + +export const SmallValues: Story = { + name: 'Small Values', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(51.6), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(0.0001), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(0.023), + }, + ], + borrow: { + current: NormalizedUnitNumber(7), + max: NormalizedUnitNumber(30), + percents: { + borrowed: 13.5, + max: 58, + rest: 86.5, + }, + }, + }, + }, +} + +export const BigValues: Story = { + name: 'Big Values', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(335_260_080), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(50000), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(100000), + }, + ], + borrow: { + current: NormalizedUnitNumber(52301000), + max: NormalizedUnitNumber(100000000), + percents: { + borrowed: 15.6, + max: 30, + rest: 84.4, + }, + }, + }, + }, +} + +export const ZeroBorrow: Story = { + name: 'Zero Borrow', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(110_412), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(32), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(17.4), + }, + ], + borrow: { + current: NormalizedUnitNumber(0), + max: NormalizedUnitNumber(45012), + percents: { + borrowed: 0, + max: 41, + rest: 100, + }, + }, + }, + }, +} + +export const SmallBorrow: Story = { + name: 'Small Borrow', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(110_412), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(32), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(17.4), + }, + ], + borrow: { + current: NormalizedUnitNumber(10), + max: NormalizedUnitNumber(45012), + percents: { + borrowed: 0, + max: 41, + rest: 100, + }, + }, + }, + }, +} + +export const BigDifference: Story = { + name: 'Big Difference', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(71_522), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(32), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(0.01), + }, + ], + borrow: { + current: NormalizedUnitNumber(34780), + max: NormalizedUnitNumber(45012), + percents: { + borrowed: 48.6, + max: 62.9, + rest: 51.4, + }, + }, + }, + }, +} + +export const Empty: Story = { + name: 'Empty', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(0), + hasDeposits: false, + healthFactor: undefined, + deposits: [], + borrow: { + current: NormalizedUnitNumber(0), + max: NormalizedUnitNumber(0), + percents: { + borrowed: 0, + max: 0, + rest: 100, + }, + }, + }, + }, +} + +export const FourTokens: Story = { + name: 'Four Tokens', + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(94_878), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(16), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(17.4), + }, + { + token: tokens['DAI'], + value: NormalizedUnitNumber(12000), + }, + { + token: tokens['sDAI'], + value: NormalizedUnitNumber(8000), + }, + ], + borrow: { + current: NormalizedUnitNumber(24780), + max: NormalizedUnitNumber(35012), + percents: { + borrowed: 26.1, + max: 36.9, + rest: 73.9, + }, + }, + }, + }, +} diff --git a/packages/app/src/features/dashboard/components/position/Position.tsx b/packages/app/src/features/dashboard/components/position/Position.tsx new file mode 100644 index 000000000..8680d6f90 --- /dev/null +++ b/packages/app/src/features/dashboard/components/position/Position.tsx @@ -0,0 +1,207 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { tokenColors } from '@/ui/assets' +import { Panel } from '@/ui/atoms/panel/Panel' +import { Tooltip, TooltipContentShort, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { Typography } from '@/ui/atoms/typography/Typography' +import { Info } from '@/ui/molecules/info/Info' +import { getRandomColor } from '@/ui/utils/get-random-color' +import { testIds } from '@/ui/utils/testIds' + +import { getPositionFormattedValue, getTicks } from '../../logic/position' +import { PositionSummary } from '../../logic/types' + +export interface PositionProps { + positionSummary?: PositionSummary + numLabels?: number + ticksPerLabel?: number + xAxisFallbackMax?: NormalizedUnitNumber + className?: string +} + +export function Position({ + positionSummary, + numLabels = 5, + ticksPerLabel = 2, + xAxisFallbackMax = NormalizedUnitNumber(90_000), + className, +}: PositionProps) { + const ticks = getTicks({ + numLabels, + ticksPerLabel, + totalCollateralUSD: positionSummary?.totalCollateralUSD, + xAxisFallbackMax, + }) + + return ( + + + Your position + Amount of all your assets supplied to the protocol. + + + + + + + + ) +} + +interface TicksProps { + ticks: { + x: number + label?: string + }[] +} +function Ticks({ ticks }: TicksProps) { + return ( +
+ {ticks.map(({ label, x }) => ( +
+
+
{label && {label}}
+
+ ))} +
+ ) +} + +interface DepositedProps { + positionSummary?: PositionSummary + ticks: TicksProps['ticks'] +} + +function Deposited({ positionSummary, ticks }: DepositedProps) { + return ( +
+
+ Collateral deposited + + {getPositionFormattedValue(positionSummary?.totalCollateralUSD)} + +
+ + +
+ ) +} + +interface CollateralBarProps { + positionSummary?: PositionSummary +} + +function CollateralBar({ positionSummary }: CollateralBarProps) { + if (!positionSummary?.hasDeposits) { + return + } + + const collateralWithPercentages = positionSummary.deposits.map((c) => ({ + ...c, + x: c.token.toUSD(c.value).dividedBy(positionSummary.totalCollateralUSD).multipliedBy(100), + })) + + return ( +
+ {collateralWithPercentages.map((c, i) => ( + + +
+ + + You have deposited {USD_MOCK_TOKEN.formatUSD(c.token.toUSD(c.value))} worth of {c.token.symbol}. + + + ))} +
+ ) +} + +interface BorrowProps { + positionSummary?: PositionSummary + ticks: TicksProps['ticks'] +} + +function Borrow({ positionSummary, ticks }: BorrowProps) { + return ( +
+
+ Borrow + + {getPositionFormattedValue(positionSummary?.borrow.current)} + +
+ + +
+ ) +} + +interface BorrowBarProps { + positionSummary?: PositionSummary +} + +function BorrowBar({ positionSummary }: BorrowBarProps) { + if (!positionSummary?.hasDeposits || positionSummary.borrow.max.eq(0)) { + return + } + const { borrow } = positionSummary + + return ( +
+ {borrow.percents.borrowed !== 0 && ( + + +
+ + + You have borrowed {USD_MOCK_TOKEN.formatUSD(positionSummary.borrow.current)}. + + + )} + {borrow.percents.rest !== 0 && ( +
+ )} + + +
+
max
+
+
+ + You can borrow up to {USD_MOCK_TOKEN.formatUSD(positionSummary.borrow.max)}. + +
+
+ ) +} + +function EmptyBar() { + return ( +
+
+
+ ) +} diff --git a/packages/app/src/features/dashboard/components/skeleton/DasboardSkeleton.stories.ts b/packages/app/src/features/dashboard/components/skeleton/DasboardSkeleton.stories.ts new file mode 100644 index 000000000..34f1d8eb5 --- /dev/null +++ b/packages/app/src/features/dashboard/components/skeleton/DasboardSkeleton.stories.ts @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react' +import { chromatic } from '@storybook/viewports' + +import { DashboardSkeleton } from './DashboardSkeleton' + +const meta: Meta = { + title: 'Features/Dashboard/Components/Skeleton', + component: DashboardSkeleton, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} diff --git a/packages/app/src/features/dashboard/components/skeleton/DashboardSkeleton.tsx b/packages/app/src/features/dashboard/components/skeleton/DashboardSkeleton.tsx new file mode 100644 index 000000000..c385ce936 --- /dev/null +++ b/packages/app/src/features/dashboard/components/skeleton/DashboardSkeleton.tsx @@ -0,0 +1,17 @@ +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' +import { PageLayout } from '@/ui/layouts/PageLayout' + +export function DashboardSkeleton() { + return ( + +
+
+ + +
+ + +
+
+ ) +} diff --git a/packages/app/src/features/dashboard/components/wallet-composition/AssetTable.tsx b/packages/app/src/features/dashboard/components/wallet-composition/AssetTable.tsx new file mode 100644 index 000000000..7a681674a --- /dev/null +++ b/packages/app/src/features/dashboard/components/wallet-composition/AssetTable.tsx @@ -0,0 +1,75 @@ +import { generatePath } from 'react-router-dom' + +import { paths } from '@/config/paths' +import { formatPercentage } from '@/domain/common/format' +import { TokenWithValue } from '@/domain/common/types' +import { calculateDistribution } from '@/features/dashboard/components/wallet-composition/logic/calculate-distribution' +import { Link } from '@/ui/atoms/link/Link' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' +import { Typography } from '@/ui/atoms/typography/Typography' +import { DataTable } from '@/ui/molecules/data-table/DataTable' + +export interface AssetTableProps { + assets: TokenWithValue[] + chainId: number + scroll?: { height: number } +} +export function AssetTable({ assets, scroll, chainId }: AssetTableProps) { + const assetsWithDistribution = calculateDistribution(assets) + + return ( + ( +
+ + {token.symbol} +
+ ), + }, + amount: { + header: 'Amount', + headerAlign: 'right', + renderCell: ({ value, token }) => ( +
+
{token.format(value, { style: 'auto' })}
+
+ {token.formatUSD(value)} +
+
+ ), + }, + deposit: { + header: 'Distribution', + headerAlign: 'right', + renderCell: ({ distribution }) => ( +
+
{formatPercentage(distribution)}
+
+ ), + }, + swapCollateral: { + header: '', + headerAlign: 'right', + renderCell: ({ token }) => ( +
+
+ + Details + +
+
+ ), + }, + }} + data={assetsWithDistribution} + scroll={scroll} + /> + ) +} diff --git a/packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.stories.tsx b/packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.stories.tsx new file mode 100644 index 000000000..5a7d0ec49 --- /dev/null +++ b/packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.stories.tsx @@ -0,0 +1,118 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { WalletComposition } from './WalletComposition' + +const meta: Meta = { + title: 'Features/Dashboard/Components/WalletComposition', + component: WalletComposition, + decorators: [withRouter, WithClassname('max-w-5xl'), WithTooltipProvider()], + args: { + chainId: 1, + hasDeposits: true, + includeDeposits: true, + setIncludeDeposits: () => {}, + }, +} + +export default meta +type Story = StoryObj + +const assets = [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(132.28), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(48.32), + }, + { + token: tokens['USDC'], + value: NormalizedUnitNumber(90000), + }, + { + token: tokens['WBTC'], + value: NormalizedUnitNumber(2), + }, + { + token: tokens['sDAI'], + value: NormalizedUnitNumber(50000), + }, + { + token: tokens['DAI'], + value: NormalizedUnitNumber(50000), + }, + { + token: tokens['MKR'], + value: NormalizedUnitNumber(15), + }, + { + token: tokens['USDT'], + value: NormalizedUnitNumber(7000), + }, +] + +export const Default: Story = { + name: 'Normal', + args: { + assets: [...assets.slice(0, 4)], + }, +} +export const NormalMobile = getMobileStory(Default) +export const NormalTablet = getTabletStory(Default) + +export const TwoAssets: Story = { + name: 'Two Assets', + args: { + assets: [...assets.slice(4, 6)], + }, +} +export const TwoAssetsMobile = getMobileStory(TwoAssets) +export const TwoAssetsTablet = getTabletStory(TwoAssets) + +export const EightAssets: Story = { + name: 'Eight Assets', + args: { + assets, + }, +} +export const EightAssetsMobile = getMobileStory(EightAssets) +export const EightAssetsTablet = getTabletStory(EightAssets) + +export const NoDeposits: Story = { + name: 'No Deposits', + args: { + assets: [...assets.slice(4, 6)], + hasDeposits: false, + includeDeposits: false, + setIncludeDeposits: () => {}, + }, +} +export const NoDepositsMobile = getMobileStory(NoDeposits) +export const NoDepositsTablet = getTabletStory(NoDeposits) + +export const NoAssets: Story = { + name: 'No Assets', + args: { + assets: [], + hasDeposits: false, + }, +} +export const NoAssetsMobile = getMobileStory(NoAssets) +export const NoAssetsTablet = getTabletStory(NoAssets) + +export const OneAsset: Story = { + name: 'One Asset', + args: { + assets: [assets[0]!], + hasDeposits: false, + }, +} +export const OneAssetMobile = getMobileStory(OneAsset) +export const OneAssetTablet = getTabletStory(OneAsset) diff --git a/packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.tsx b/packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.tsx new file mode 100644 index 000000000..32fb18296 --- /dev/null +++ b/packages/app/src/features/dashboard/components/wallet-composition/WalletComposition.tsx @@ -0,0 +1,66 @@ +import { Wallet } from 'lucide-react' + +import { tokenColors } from '@/ui/assets' +import { Checkbox } from '@/ui/atoms/checkbox/Checkbox' +import { DoughnutChart } from '@/ui/atoms/doughnut-chart/DoughnutChart' +import { Panel } from '@/ui/atoms/panel/Panel' +import { Info } from '@/ui/molecules/info/Info' +import { getRandomColor } from '@/ui/utils/get-random-color' +import { useBreakpoint } from '@/ui/utils/useBreakpoint' + +import { WalletCompositionInfo } from '../../logic/wallet-composition' +import { AssetTable } from './AssetTable' + +export type WalletCompositionProps = WalletCompositionInfo + +export function WalletComposition({ + assets, + chainId, + includeDeposits, + setIncludeDeposits, + hasDeposits, +}: WalletCompositionProps) { + const chartData = assets.map((asset) => ({ + value: asset.token.toUSD(asset.value).toNumber(), + color: tokenColors[asset.token.symbol] ?? getRandomColor(), + })) + const sm = useBreakpoint('sm') + + return ( + + + Your wallet + List of assets in your wallet supported by Spark. + {hasDeposits && ( +
+ setIncludeDeposits(!!checked.valueOf())} + /> + +
+ )} +
+ + + {assets.length > 1 && } +
+ {assets.length != 0 ? ( + + ) : ( +
+ +

You don't have any assets in your wallet

+
+ )} +
+
+
+ ) +} diff --git a/packages/app/src/features/dashboard/components/wallet-composition/logic/calculate-distribution.ts b/packages/app/src/features/dashboard/components/wallet-composition/logic/calculate-distribution.ts new file mode 100644 index 000000000..cce8748e8 --- /dev/null +++ b/packages/app/src/features/dashboard/components/wallet-composition/logic/calculate-distribution.ts @@ -0,0 +1,16 @@ +import BigNumber from 'bignumber.js' + +import { TokenWithValue } from '@/domain/common/types' +import { Percentage } from '@/domain/types/NumericValues' + +export interface TokenWithDistribution extends TokenWithValue { + distribution: Percentage +} + +export function calculateDistribution(assets: TokenWithValue[]): TokenWithDistribution[] { + const totalCollateralUSD = assets.reduce((acc, curr) => acc.plus(curr.token.toUSD(curr.value)), new BigNumber(0)) + return assets.map((asset) => ({ + ...asset, + distribution: Percentage(asset.token.toUSD(asset.value).dividedBy(totalCollateralUSD)), + })) +} diff --git a/packages/app/src/features/dashboard/logic/assets.ts b/packages/app/src/features/dashboard/logic/assets.ts new file mode 100644 index 000000000..76e843c02 --- /dev/null +++ b/packages/app/src/features/dashboard/logic/assets.ts @@ -0,0 +1,143 @@ +import { NativeAssetInfo } from '@/config/chain/types' +import { assetCanBeBorrowed } from '@/domain/common/assets' +import { MarketInfo, UserPosition } from '@/domain/market-info/marketInfo' +import { ReserveStatus } from '@/domain/market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { applyTransformers } from '@/utils/applyTransformers' + +export interface Deposit { + token: Token + reserveStatus: ReserveStatus + balance: NormalizedUnitNumber + deposit: NormalizedUnitNumber + supplyAPY: Percentage + isUsedAsCollateral: boolean +} + +export interface Borrow { + token: Token + reserveStatus: ReserveStatus + available: NormalizedUnitNumber + debt: NormalizedUnitNumber + borrowAPY: Percentage +} + +export interface GetDepositsParams { + marketInfo: MarketInfo + walletInfo: WalletInfo + nativeAssetInfo: NativeAssetInfo +} +export function getDeposits({ marketInfo, walletInfo, nativeAssetInfo }: GetDepositsParams): Deposit[] { + return marketInfo.userPositions + .map((position) => { + return applyTransformers({ position, marketInfo, walletInfo, nativeAssetInfo })([ + hideDaiWhenLendingDisabled, + hideFrozenAssetIfNotDeposited, + transformNativeAssetDeposit, + transformDefaultDeposit, + ]) + }) + .filter(Boolean) +} + +interface DepositTransformerParams extends GetDepositsParams { + position: UserPosition + nativeAssetInfo: NativeAssetInfo +} + +function transformNativeAssetDeposit({ + position, + marketInfo, + walletInfo, + nativeAssetInfo, +}: DepositTransformerParams): Deposit | undefined { + if (position.reserve.token.symbol !== nativeAssetInfo.wrappedNativeAssetSymbol) { + return undefined + } + const deposit = transformDefaultDeposit({ position, marketInfo, walletInfo, nativeAssetInfo }) + + return { + ...deposit, + balance: NormalizedUnitNumber( + walletInfo + .findWalletBalanceForToken(position.reserve.token) + .plus(walletInfo.findWalletBalanceForSymbol(nativeAssetInfo.nativeAssetSymbol)), + ), + } +} + +function transformDefaultDeposit({ position, walletInfo }: DepositTransformerParams): Deposit { + return { + token: position.reserve.token, + reserveStatus: position.reserve.status, + balance: walletInfo.findWalletBalanceForToken(position.reserve.token), + deposit: position.collateralBalance, + supplyAPY: position.reserve.supplyAPY, + isUsedAsCollateral: position.reserve.usageAsCollateralEnabledOnUser, + } +} + +function hideDaiWhenLendingDisabled({ position }: DepositTransformerParams): null | undefined { + if (import.meta.env.VITE_FEATURE_DISABLE_DAI_LEND === '1' && position.reserve.token.symbol === TokenSymbol('DAI')) { + return null + } +} + +function hideFrozenAssetIfNotDeposited({ position }: DepositTransformerParams): null | undefined { + if (position.reserve.status === 'frozen' && position.collateralBalance.isZero()) { + return null + } +} + +export interface GetBorrowsParams { + marketInfo: MarketInfo + nativeAssetInfo: NativeAssetInfo +} + +export function getBorrows({ marketInfo, nativeAssetInfo }: GetBorrowsParams): Borrow[] { + return marketInfo.userPositions + .filter((position) => assetCanBeBorrowed(position.reserve) || position.borrowBalance.gt(0)) + .map((position) => { + return applyTransformers({ position, marketInfo, nativeAssetInfo })([ + transformNativeAssetBorrow, + transformDefaultBorrow, + ]) + }) + .filter(Boolean) +} + +interface BorrowTransformerParams extends GetBorrowsParams { + position: UserPosition + nativeAssetInfo: NativeAssetInfo +} + +function transformNativeAssetBorrow({ + position, + marketInfo, + nativeAssetInfo, +}: BorrowTransformerParams): Borrow | undefined { + if (position.reserve.token.symbol !== nativeAssetInfo.wrappedNativeAssetSymbol) { + return undefined + } + const borrow = transformDefaultBorrow({ position, marketInfo, nativeAssetInfo }) + + return { + ...borrow, + available: position.reserve.availableLiquidity, + debt: position.borrowBalance, + borrowAPY: position.reserve.variableBorrowApy, + } +} + +function transformDefaultBorrow({ position }: BorrowTransformerParams): Borrow { + return { + token: position.reserve.token, + reserveStatus: position.reserve.status, + available: position.reserve.availableLiquidity, + debt: position.borrowBalance, + borrowAPY: position.reserve.variableBorrowApy, + } +} diff --git a/packages/app/src/features/dashboard/logic/makeLiquidationDetails.ts b/packages/app/src/features/dashboard/logic/makeLiquidationDetails.ts new file mode 100644 index 000000000..69c3d0772 --- /dev/null +++ b/packages/app/src/features/dashboard/logic/makeLiquidationDetails.ts @@ -0,0 +1,25 @@ +import { getLiquidationDetails, LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { MarketInfo } from '@/domain/market-info/marketInfo' + +export function makeLiquidationDetails(marketInfo: MarketInfo): LiquidationDetails | undefined { + const alreadyDeposited = { + tokens: marketInfo.userPositions + .filter((position) => position.collateralBalance.gt(0) && position.reserve.usageAsCollateralEnabledOnUser) + .map((position) => position.reserve.token), + totalValueUSD: marketInfo.userPositionSummary.totalCollateralUSD, + } + const alreadyBorrowed = { + tokens: marketInfo.userPositions + .filter((position) => position.borrowBalance.gt(0)) + .map((position) => position.reserve.token), + totalValueUSD: marketInfo.userPositionSummary.totalBorrowsUSD, + } + + return getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + tokensToDeposit: [], + tokensToBorrow: [], + marketInfo, + }) +} diff --git a/packages/app/src/features/dashboard/logic/position.ts b/packages/app/src/features/dashboard/logic/position.ts new file mode 100644 index 000000000..7af5c1b60 --- /dev/null +++ b/packages/app/src/features/dashboard/logic/position.ts @@ -0,0 +1,120 @@ +import { BigNumber } from 'bignumber.js' + +import { TokenWithValue } from '@/domain/common/types' +import { MarketInfo, UserPosition } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' + +import { PositionSummary } from './types' + +type Ticks = ( + | { + label: string + x: number + } + | { + x: number + label?: undefined + } +)[] + +interface GetTicksArgs { + numLabels: number + ticksPerLabel: number + totalCollateralUSD?: NormalizedUnitNumber + xAxisFallbackMax?: NormalizedUnitNumber +} + +export function getTicks({ + numLabels, + ticksPerLabel, + totalCollateralUSD = NormalizedUnitNumber(0), + xAxisFallbackMax = NormalizedUnitNumber(90_000), +}: GetTicksArgs): Ticks { + const maxTickValue = totalCollateralUSD.gt(0) ? totalCollateralUSD : xAxisFallbackMax + + const numTicks = (numLabels - 1) * ticksPerLabel + 1 + const ticks = Array.from({ length: numTicks }).map((_, i) => { + const x = (i / (numTicks - 1)) * 100 + + const label = USD_MOCK_TOKEN.format(NormalizedUnitNumber(maxTickValue.dividedBy(numTicks - 1).multipliedBy(i)), { + style: 'compact', + }) + if (i % ticksPerLabel === 0) return { label, x } + return { x } + }) + return ticks +} + +export function getPositionFormattedValue(value?: NormalizedUnitNumber, fallback = '-'): string { + return !value || value.eq(0) ? fallback : USD_MOCK_TOKEN.formatUSD(value, { compact: true }) +} + +export interface MakePositionSummaryParams { + marketInfo: MarketInfo +} + +export function makePositionSummary({ marketInfo }: MakePositionSummaryParams): PositionSummary { + const deposits = getDeposits(marketInfo.userPositions) + const totalCollateralUSD = marketInfo.userPositionSummary.totalCollateralUSD + const hasDeposits = totalCollateralUSD.gt(0) + + const currentBorrow = marketInfo.userPositionSummary.totalBorrowsUSD + const maxBorrow = NormalizedUnitNumber( + marketInfo.userPositionSummary.totalBorrowsUSD.plus(marketInfo.userPositionSummary.availableBorrowsUSD), + ) + const { borrowPercent, restPercent, maxPercent } = getBorrowPercents(currentBorrow, maxBorrow, totalCollateralUSD) + + return { + healthFactor: marketInfo.userPositionSummary.healthFactor, + deposits, + hasDeposits, + totalCollateralUSD, + borrow: { + current: currentBorrow, + max: maxBorrow, + percents: { + borrowed: borrowPercent, + rest: restPercent, + max: maxPercent, + }, + }, + } +} + +function getDeposits(userPositions: UserPosition[]): TokenWithValue[] { + return userPositions + .filter((position) => position.reserve.usageAsCollateralEnabledOnUser) + .map((position) => ({ + token: position.reserve.token, + value: position.collateralBalance, + })) + .filter(({ value }) => value.gt(0)) + .sort((a, b) => b.token.toUSD(b.value).comparedTo(a.token.toUSD(a.value))) +} + +interface BorrowPercents { + borrowPercent: number + maxPercent: number + restPercent: number +} + +function getBorrowPercents( + currentBorrow: NormalizedUnitNumber, + maxBorrow: NormalizedUnitNumber, + totalCollateralUSD: NormalizedUnitNumber, +): BorrowPercents { + let borrowPercent = currentBorrow.dividedBy(totalCollateralUSD).multipliedBy(100).toNumber() + let restPercent = new BigNumber(100).minus(borrowPercent).toNumber() + const maxPercent = maxBorrow.dividedBy(totalCollateralUSD).multipliedBy(100).toNumber() + if (borrowPercent < 0.5) { + borrowPercent = 0 + restPercent = 100 + } + if (borrowPercent > 99.5) { + borrowPercent = 100 + restPercent = 0 + } + + return { borrowPercent, maxPercent, restPercent } +} diff --git a/packages/app/src/features/dashboard/logic/types.ts b/packages/app/src/features/dashboard/logic/types.ts new file mode 100644 index 000000000..5d34bd79d --- /dev/null +++ b/packages/app/src/features/dashboard/logic/types.ts @@ -0,0 +1,20 @@ +import BigNumber from 'bignumber.js' + +import { TokenWithValue } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +export interface PositionSummary { + deposits: TokenWithValue[] + hasDeposits: boolean + totalCollateralUSD: NormalizedUnitNumber + healthFactor: BigNumber | undefined + borrow: { + current: NormalizedUnitNumber + max: NormalizedUnitNumber + percents: { + borrowed: number + rest: number + max: number + } + } +} diff --git a/packages/app/src/features/dashboard/logic/useDashboard.ts b/packages/app/src/features/dashboard/logic/useDashboard.ts new file mode 100644 index 000000000..990f03e0a --- /dev/null +++ b/packages/app/src/features/dashboard/logic/useDashboard.ts @@ -0,0 +1,61 @@ +import { useState } from 'react' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { EModeCategoryId } from '@/domain/e-mode/types' +import { LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' + +import { Borrow, Deposit, getBorrows, getDeposits } from './assets' +import { makeLiquidationDetails } from './makeLiquidationDetails' +import { makePositionSummary } from './position' +import { PositionSummary } from './types' +import { makeWalletComposition, WalletCompositionInfo } from './wallet-composition' + +export interface UseDashboardResults { + positionSummary: PositionSummary + deposits: Deposit[] + borrows: Borrow[] + walletComposition: WalletCompositionInfo + guestMode: boolean + eModeCategoryId: EModeCategoryId + liquidationDetails?: LiquidationDetails +} + +export function useDashboard(): UseDashboardResults { + const { marketInfo } = useMarketInfo() + const walletInfo = useWalletInfo() + const [compositionWithDeposits, setCompositionWithDeposits] = useState(true) + const nativeAssetInfo = getNativeAssetInfo(marketInfo.chainId) + + const deposits = getDeposits({ + marketInfo, + walletInfo, + nativeAssetInfo, + }) + const borrows = getBorrows({ marketInfo, nativeAssetInfo }) + const positionSummary = makePositionSummary({ marketInfo }) + const walletComposition = makeWalletComposition({ + marketInfo, + walletInfo, + compositionWithDeposits, + setCompositionWithDeposits, + nativeAssetInfo, + }) + + const eModeCategoryId = ( + marketInfo.userConfiguration.eModeState.enabled ? marketInfo.userConfiguration.eModeState.category.id : 0 + ) as EModeCategoryId + + const liquidationDetails = makeLiquidationDetails(marketInfo) + + return { + positionSummary, + deposits, + borrows, + walletComposition, + eModeCategoryId, + guestMode: !walletInfo.isConnected, + liquidationDetails, + } +} diff --git a/packages/app/src/features/dashboard/logic/wallet-composition.ts b/packages/app/src/features/dashboard/logic/wallet-composition.ts new file mode 100644 index 000000000..b0d74bac3 --- /dev/null +++ b/packages/app/src/features/dashboard/logic/wallet-composition.ts @@ -0,0 +1,86 @@ +import { NativeAssetInfo } from '@/config/chain/types' +import { TokenWithValue } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { WalletBalance, WalletInfo } from '@/domain/wallet/useWalletInfo' + +interface MakeAssetListParams { + marketInfo: MarketInfo + walletInfo: WalletInfo + includeDeposits: boolean + nativeAssetInfo: NativeAssetInfo +} +function makeAssetList({ + marketInfo, + walletInfo, + includeDeposits, + nativeAssetInfo, +}: MakeAssetListParams): TokenWithValue[] { + return walletInfo.walletBalances + .map((walletBalance) => calculateCombinedBalance({ walletBalance, marketInfo, includeDeposits, nativeAssetInfo })) + .filter(({ value }) => value.gt(0)) + .sort((a, b) => b.token.toUSD(b.value).comparedTo(a.token.toUSD(a.value))) +} + +interface CalculateCombinedBalanceParams { + walletBalance: WalletBalance + marketInfo: MarketInfo + includeDeposits: boolean + nativeAssetInfo: NativeAssetInfo +} +function calculateCombinedBalance({ + walletBalance, + marketInfo, + includeDeposits, + nativeAssetInfo, +}: CalculateCombinedBalanceParams): TokenWithValue { + if (!includeDeposits || walletBalance.token.symbol === nativeAssetInfo.nativeAssetSymbol) { + return { + token: walletBalance.token, + value: walletBalance.balance, + } + } + + const deposit = marketInfo.findPositionByToken(walletBalance.token)?.collateralBalance ?? NormalizedUnitNumber(0) + return { + token: walletBalance.token, + value: NormalizedUnitNumber(walletBalance.balance.plus(deposit)), + } +} + +export interface MakeWalletCompositionParams { + marketInfo: MarketInfo + walletInfo: WalletInfo + compositionWithDeposits: boolean + setCompositionWithDeposits: (includeDeposits: boolean) => void + nativeAssetInfo: NativeAssetInfo +} + +export interface WalletCompositionInfo { + assets: TokenWithValue[] + chainId: number + includeDeposits: boolean + setIncludeDeposits: (includeDeposits: boolean) => void + hasDeposits: boolean +} + +export function makeWalletComposition({ + marketInfo, + walletInfo, + compositionWithDeposits, + setCompositionWithDeposits, + nativeAssetInfo, +}: MakeWalletCompositionParams): WalletCompositionInfo { + return { + hasDeposits: marketInfo.userPositionSummary.totalCollateralUSD.gt(0), + assets: makeAssetList({ + marketInfo, + walletInfo, + includeDeposits: compositionWithDeposits, + nativeAssetInfo, + }), + chainId: marketInfo.chainId, + includeDeposits: compositionWithDeposits, + setIncludeDeposits: setCompositionWithDeposits, + } +} diff --git a/packages/app/src/features/dashboard/views/GuestView.stories.ts b/packages/app/src/features/dashboard/views/GuestView.stories.ts new file mode 100644 index 000000000..02c768766 --- /dev/null +++ b/packages/app/src/features/dashboard/views/GuestView.stories.ts @@ -0,0 +1,27 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { chromatic } from '@storybook/viewports' + +import { GuestView } from './GuestView' + +const meta: Meta = { + title: 'Features/Dashboard/Views/GuestView', + component: GuestView, + decorators: [WithTooltipProvider()], + args: { + openConnectModal: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} diff --git a/packages/app/src/features/dashboard/views/GuestView.tsx b/packages/app/src/features/dashboard/views/GuestView.tsx new file mode 100644 index 000000000..bf577e6ba --- /dev/null +++ b/packages/app/src/features/dashboard/views/GuestView.tsx @@ -0,0 +1,23 @@ +import { assets } from '@/ui/assets' +import { PageLayout } from '@/ui/layouts/PageLayout' +import { WalletActionPanel } from '@/ui/organisms/wallet-action-panel/WalletActionPanel' + +export interface GuestViewProps { + openConnectModal: () => void +} + +export function GuestView({ openConnectModal }: GuestViewProps) { + return ( + + + + ) +} + +const icons = assets.walletIcons +const WALLET_ICONS_PATHS = [icons.metamask, icons.walletConnect, icons.coinbase, icons.enjin, icons.torus] diff --git a/packages/app/src/features/dashboard/views/PositionView.stories.ts b/packages/app/src/features/dashboard/views/PositionView.stories.ts new file mode 100644 index 000000000..41434cb3a --- /dev/null +++ b/packages/app/src/features/dashboard/views/PositionView.stories.ts @@ -0,0 +1,153 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { PositionView } from './PositionView' + +const meta: Meta = { + title: 'Features/Dashboard/Views/PositionView', + component: PositionView, + decorators: [withRouter, WithTooltipProvider()], + args: { + positionSummary: { + totalCollateralUSD: NormalizedUnitNumber(167_600), + hasDeposits: true, + healthFactor: undefined, + deposits: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(50), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(25), + }, + ], + borrow: { + current: NormalizedUnitNumber(50_000), + max: NormalizedUnitNumber(75_000), + percents: { + borrowed: 29, + max: 44, + rest: 71, + }, + }, + }, + deposits: [ + { + token: tokens['ETH'], + reserveStatus: 'active', + balance: NormalizedUnitNumber('84.330123431'), + deposit: NormalizedUnitNumber('13.74'), + supplyAPY: Percentage(0.0145), + isUsedAsCollateral: true, + }, + { + token: tokens['stETH'], + reserveStatus: 'active', + balance: NormalizedUnitNumber('16.76212348'), + deposit: NormalizedUnitNumber('34.21'), + supplyAPY: Percentage(0.0145), + isUsedAsCollateral: true, + }, + { + token: tokens['DAI'], + reserveStatus: 'active', + balance: NormalizedUnitNumber('48.9234234'), + deposit: NormalizedUnitNumber('9.37'), + supplyAPY: Percentage(0.0145), + isUsedAsCollateral: false, + }, + { + token: tokens['GNO'], + balance: NormalizedUnitNumber('299.9234234'), + deposit: NormalizedUnitNumber('1.37'), + supplyAPY: Percentage(0.0345), + isUsedAsCollateral: false, + reserveStatus: 'frozen', + }, + { + token: tokens['wstETH'], + balance: NormalizedUnitNumber('89.923'), + deposit: NormalizedUnitNumber('5.37'), + supplyAPY: Percentage(0.012), + isUsedAsCollateral: false, + reserveStatus: 'paused', + }, + ], + borrows: [ + { + token: tokens['DAI'], + reserveStatus: 'active', + available: NormalizedUnitNumber('22727'), + debt: NormalizedUnitNumber('50000'), + borrowAPY: Percentage(0.11), + }, + { + token: tokens['ETH'], + reserveStatus: 'active', + available: NormalizedUnitNumber('11.99'), + debt: NormalizedUnitNumber(0), + borrowAPY: Percentage(0.157), + }, + { + token: tokens['stETH'], + reserveStatus: 'active', + available: NormalizedUnitNumber('14.68'), + debt: NormalizedUnitNumber(0), + borrowAPY: Percentage(0.145), + }, + { + token: tokens['GNO'], + available: NormalizedUnitNumber('0'), + debt: NormalizedUnitNumber(10), + borrowAPY: Percentage(0.345), + reserveStatus: 'frozen', + }, + { + token: tokens['wstETH'], + available: NormalizedUnitNumber('0'), + debt: NormalizedUnitNumber(2), + borrowAPY: Percentage(0.32), + reserveStatus: 'paused', + }, + ], + eModeCategoryId: 0, + walletComposition: { + hasDeposits: true, + assets: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(132.28), + }, + { + token: tokens['USDC'], + value: NormalizedUnitNumber(90000), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(34.21), + }, + { + token: tokens['DAI'], + value: NormalizedUnitNumber(50000), + }, + ], + chainId: 1, + includeDeposits: true, + setIncludeDeposits: () => {}, + }, + openDialog: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/features/dashboard/views/PositionView.tsx b/packages/app/src/features/dashboard/views/PositionView.tsx new file mode 100644 index 000000000..48429b01c --- /dev/null +++ b/packages/app/src/features/dashboard/views/PositionView.tsx @@ -0,0 +1,52 @@ +import { EModeCategoryId } from '@/domain/e-mode/types' +import { LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { PageLayout } from '@/ui/layouts/PageLayout' +import { HealthFactorPanel } from '@/ui/organisms/health-factor-panel/HealthFactorPanel' + +import { BorrowTable } from '../components/borrow-table/BorrowTable' +import { CreatePositionPanel } from '../components/create-position-panel/CreatePositionPanel' +import { DepositTable } from '../components/deposit-table/DepositTable' +import { Position } from '../components/position/Position' +import { WalletComposition } from '../components/wallet-composition/WalletComposition' +import { Borrow, Deposit } from '../logic/assets' +import { PositionSummary } from '../logic/types' +import { WalletCompositionInfo } from '../logic/wallet-composition' + +export interface PositionViewProps { + positionSummary: PositionSummary + deposits: Deposit[] + borrows: Borrow[] + eModeCategoryId: EModeCategoryId + walletComposition: WalletCompositionInfo + openDialog: OpenDialogFunction + liquidationDetails: LiquidationDetails | undefined +} + +export function PositionView({ + positionSummary, + deposits, + borrows, + eModeCategoryId, + walletComposition, + openDialog, + liquidationDetails, +}: PositionViewProps) { + return ( + +
+ + + {!positionSummary.hasDeposits && } +
+ + + +
+ ) +} diff --git a/packages/app/src/features/dialogs/borrow/BorrowDialog.test-e2e.ts b/packages/app/src/features/dialogs/borrow/BorrowDialog.test-e2e.ts new file mode 100644 index 000000000..051f348e0 --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/BorrowDialog.test-e2e.ts @@ -0,0 +1,339 @@ +import { test } from '@playwright/test' + +import { borrowValidationIssueToMessage } from '@/domain/market-validators/validateBorrow' +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { BorrowPageObject } from '@/pages/Borrow.PageObject' +import { DashboardPageObject } from '@/pages/Dashboard.PageObject' +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' +import { screenshot } from '@/test/e2e/utils' + +import { DialogPageObject } from '../common/Dialog.PageObject' + +const headerRegExp = /Borrow */ + +test.describe('Borrow dialog', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + const initialBalances = { + rETH: 100, + wstETH: 100, + } + + test.describe('Position with deposit and borrow', () => { + const initialDeposits = { + rETH: 2, + wstETH: 2, + } + const daiToBorrow = 1500 + const expectedInitialHealthFactor = '5.42' + const expectedHealthFactor = '2.04' + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(initialDeposits, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + // @todo This waits for the refetch of the data after successful borrow transaction to happen. + // This is no ideal, probably we need to refactor expectDepositTable so it takes advantage from + // playwright's timeouts instead of parsing it's current state. Then we would be able to + // easily wait for the table to be updated. + await dashboardPage.expectAssetToBeInDepositTable('DAI') + }) + + test('opens dialog with selected asset', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickBorrowButtonAction('rETH') + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.expectSelectedAsset('rETH') + await borrowDialog.expectDialogHeader('Borrow rETH') + await borrowDialog.expectHealthFactorBeforeVisible() + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-default-view') + }) + + test('calculates health factor changes correctly', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickBorrowButtonAction('rETH') + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(1) + + await borrowDialog.expectRiskLevelBefore('Healthy') + await borrowDialog.expectHealthFactorBefore(expectedInitialHealthFactor) + await borrowDialog.expectRiskLevelAfter('Moderate') + await borrowDialog.expectHealthFactorAfter(expectedHealthFactor) + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-health-factor') + }) + + test('after borrow, health factor matches dashboard', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickBorrowButtonAction('rETH') + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(1) + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + + await borrowDialog.viewInDashboardAction() + await dashboardPage.expectHealthFactor(expectedHealthFactor) + }) + + test('has correct action plan for erc-20', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickBorrowButtonAction('rETH') + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(1) + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'borrow', + asset: 'rETH', + amount: 1, + }, + ], + true, + ) + }) + + test('can borrow erc-20', async ({ page }) => { + const borrow = { + asset: 'rETH', + amount: 1, + } + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickBorrowButtonAction(borrow.asset) + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(borrow.amount) + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await borrowDialog.expectSuccessPage([borrow], fork) + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-erc-20-success') + + await borrowDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [borrow.asset]: borrow.amount, + }) + }) + + test('has correct action plan for native asset', async ({ page }) => { + const borrowAmount = 1 + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickBorrowButtonAction('WETH') + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.selectAssetAction('ETH') + await borrowDialog.fillAmountAction(1) + + await borrowDialog.expectHealthFactorVisible() + + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'approveDelegation', + asset: 'ETH', + amount: borrowAmount, + }, + { + type: 'borrow', + asset: 'ETH', + amount: borrowAmount, + }, + ], + true, + ) + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-eth-action-plan') + }) + + test('can borrow native asset', async ({ page }) => { + const borrow = { + asset: 'ETH', + amount: 1, + } + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickBorrowButtonAction('WETH') + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.selectAssetAction(borrow.asset) + await borrowDialog.fillAmountAction(1) + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await borrowDialog.expectSuccessPage([borrow], fork) + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-eth-success') + + await borrowDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + WETH: borrow.amount, + }) + }) + + test('can borrow same asset again', async ({ page }) => { + const borrow = { + asset: 'DAI', + amount: 1500, + } + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickBorrowButtonAction(borrow.asset) + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(borrow.amount) + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await borrowDialog.expectSuccessPage([borrow], fork) + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-borrow-twice-success') + + await borrowDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [borrow.asset]: borrow.amount + daiToBorrow, + }) + }) + + test("can't borrow more than allowed", async ({ page }) => { + const borrowAsset = 'wstETH' + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickBorrowButtonAction(borrowAsset) + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(initialDeposits[borrowAsset] * 10) + + await borrowDialog.expectAssetInputError(borrowValidationIssueToMessage['insufficient-collateral']) + await borrowDialog.expectHealthFactorBeforeVisible() + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-exceeds-max-amount') + }) + }) + + test.describe('Position with only deposit', () => { + const initialDeposits = { + wstETH: 2, + rETH: 2, + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + // to simulate a position with only deposits, we go through the easy borrow flow + // but interrupt it before the borrow action, going directly to the dashboard + // this way we have deposit transactions executed, but no borrow transaction + // resulting in a position with only deposits + await borrowPage.fillDepositAssetAction(0, 'wstETH', initialDeposits.wstETH) + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillBorrowAssetAction(1) // doesn't matter, we're not borrowing anything + await borrowPage.fillDepositAssetAction(1, 'rETH', initialDeposits.rETH) + await borrowPage.submitAction() + + const actionsContainer = new ActionsPageObject(page) + for (let i = 0; i < 4; i++) { + await actionsContainer.acceptNextActionAction() + } + await actionsContainer.expectNextActionEnabled() + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + }) + + test('can borrow erc-20', async ({ page }) => { + const borrow = { + asset: 'wstETH', + amount: 1, + } + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickBorrowButtonAction(borrow.asset) + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(borrow.amount) + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await borrowDialog.expectSuccessPage([borrow], fork) + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-only-deposit-erc-20-success') + + await borrowDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [borrow.asset]: borrow.amount, + }) + }) + + test('can borrow USDC', async ({ page }) => { + const borrow = { + asset: 'USDC', + amount: 100, + } + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickBorrowButtonAction(borrow.asset) + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(borrow.amount) + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await borrowDialog.expectSuccessPage([borrow], fork) + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-USDC-success') + + await borrowDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [borrow.asset]: borrow.amount, + }) + }) + + test('displays health factor', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickBorrowButtonAction('rETH') + + const borrowDialog = new DialogPageObject(page, headerRegExp) + await borrowDialog.fillAmountAction(1) + await borrowDialog.expectHealthFactorAfterVisible() + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(borrowDialog.getDialog(), 'borrow-dialog-only-deposit-health-factor') + }) + }) +}) diff --git a/packages/app/src/features/dialogs/borrow/BorrowDialog.tsx b/packages/app/src/features/dialogs/borrow/BorrowDialog.tsx new file mode 100644 index 000000000..fdea3e614 --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/BorrowDialog.tsx @@ -0,0 +1,19 @@ +import { Token } from '@/domain/types/Token' +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { CommonDialogProps } from '../common/types' +import { BorrowDialogContentContainer } from './BorrowDialogContentContainer' + +export interface BorrowDialogProps extends CommonDialogProps { + token: Token +} + +export function BorrowDialog({ token, open, setOpen }: BorrowDialogProps) { + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/packages/app/src/features/dialogs/borrow/BorrowDialogContentContainer.tsx b/packages/app/src/features/dialogs/borrow/BorrowDialogContentContainer.tsx new file mode 100644 index 000000000..10738e455 --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/BorrowDialogContentContainer.tsx @@ -0,0 +1,49 @@ +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../common/components/skeletons/DialogContentSkeleton' +import { DialogContentContainerProps } from '../common/types' +import { SuccessView } from '../common/views/SuccessView' +import { useBorrowDialog } from './logic/useBorrowDialog' +import { BorrowView } from './views/BorrowView' + +function BorrowDialogContentContainer({ token, closeDialog }: DialogContentContainerProps) { + const { + objectives, + borrowOptions, + assetsToBorrowFields, + pageStatus, + form, + tokenToBorrow, + currentHealthFactor, + updatedHealthFactor, + } = useBorrowDialog({ + initialToken: token, + }) + + if (pageStatus.state === 'success') { + return ( + + ) + } + + return ( + + ) +} + +const BorrowDialogContentContainerWithSuspense = withSuspense(BorrowDialogContentContainer, DialogContentSkeleton) +export { BorrowDialogContentContainerWithSuspense as BorrowDialogContentContainer } diff --git a/packages/app/src/features/dialogs/borrow/components/BorrowOverviewPanel.tsx b/packages/app/src/features/dialogs/borrow/components/BorrowOverviewPanel.tsx new file mode 100644 index 000000000..0a1ddc3be --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/components/BorrowOverviewPanel.tsx @@ -0,0 +1,23 @@ +import BigNumber from 'bignumber.js' + +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogPanelTitle } from '@/features/dialogs/common/components/DialogPanelTitle' + +import { HealthFactorChange } from '../../common/components/HealthFactorChange' + +export interface BorrowOverviewPanelProps { + currentHealthFactor?: BigNumber + updatedHealthFactor?: BigNumber +} +export function BorrowOverviewPanel({ currentHealthFactor, updatedHealthFactor }: BorrowOverviewPanelProps) { + if (currentHealthFactor === undefined && updatedHealthFactor === undefined) { + return null + } + + return ( + + Transaction overview + + + ) +} diff --git a/packages/app/src/features/dialogs/borrow/logic/assets.ts b/packages/app/src/features/dialogs/borrow/logic/assets.ts new file mode 100644 index 000000000..c5af1fe8f --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/logic/assets.ts @@ -0,0 +1,65 @@ +import invariant from 'tiny-invariant' + +import { NativeAssetInfo } from '@/config/chain/types' +import { TokenWithBalance } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { Token } from '@/domain/types/Token' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { applyTransformers } from '@/utils/applyTransformers' + +export interface GetBorrowOptionsParams { + token: Token + marketInfo: MarketInfo + walletInfo: WalletInfo + nativeAssetInfo: NativeAssetInfo +} + +export function getBorrowOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetBorrowOptionsParams): TokenWithBalance[] { + const options = applyTransformers({ token, marketInfo, walletInfo, nativeAssetInfo })([ + getNativeAssetBorrowOptions, + getDefaultBorrowOptions, + ]) + invariant(options, `No deposit options found for token ${token.symbol}`) + + return options +} + +function getNativeAssetBorrowOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetBorrowOptionsParams): TokenWithBalance[] | undefined { + const { nativeAssetSymbol, wrappedNativeAssetSymbol } = nativeAssetInfo + + if (token.symbol !== nativeAssetSymbol && token.symbol !== wrappedNativeAssetSymbol) { + return undefined + } + const native = marketInfo.findOneReserveBySymbol(nativeAssetSymbol) + const wrapped = marketInfo.findOneReserveBySymbol(wrappedNativeAssetSymbol) + + return [ + { + token: native.token, + balance: walletInfo.findWalletBalanceForToken(native.token), + }, + { + token: wrapped.token, + balance: walletInfo.findWalletBalanceForToken(wrapped.token), + }, + ] +} + +function getDefaultBorrowOptions({ token, walletInfo }: GetBorrowOptionsParams): TokenWithBalance[] { + return [ + { + token, + balance: walletInfo.findWalletBalanceForToken(token), + }, + ] +} diff --git a/packages/app/src/features/dialogs/borrow/logic/createBorrowObjectives.ts b/packages/app/src/features/dialogs/borrow/logic/createBorrowObjectives.ts new file mode 100644 index 000000000..f0e698aa0 --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/logic/createBorrowObjectives.ts @@ -0,0 +1,17 @@ +import { Objective } from '@/features/actions/logic/types' + +import { DialogFormNormalizedData } from '../../common/logic/form' + +export interface CreateBorrowActionsParams { + formValues: DialogFormNormalizedData +} +export function createBorrowObjectives(formValues: DialogFormNormalizedData): Objective[] { + return [ + { + type: 'borrow', + token: formValues.reserve.token, + debtTokenAddress: formValues.reserve.variableDebtTokenAddress, + value: formValues.value, + }, + ] +} diff --git a/packages/app/src/features/dialogs/borrow/logic/form.ts b/packages/app/src/features/dialogs/borrow/logic/form.ts new file mode 100644 index 000000000..9aaf1c6fe --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/logic/form.ts @@ -0,0 +1,69 @@ +import { UseFormReturn } from 'react-hook-form' +import { z } from 'zod' + +import { getBorrowMaxValue } from '@/domain/action-max-value-getters/getBorrowMaxValue' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { + borrowValidationIssueToMessage, + getValidateBorrowArgs, + validateBorrow, +} from '@/domain/market-validators/validateBorrow' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +import { AssetInputSchema } from '../../common/logic/form' +import { FormFieldsForDialog } from '../../common/types' + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getBorrowDialogFormValidator(marketInfo: MarketInfo) { + return AssetInputSchema.superRefine((field, ctx) => { + const value = NormalizedUnitNumber(field.value === '' ? '0' : field.value) + const reserve = marketInfo.findOneReserveBySymbol(field.symbol) + + const validationIssue = validateBorrow(getValidateBorrowArgs(value, reserve, marketInfo)) + + if (validationIssue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: borrowValidationIssueToMessage[validationIssue], + path: ['value'], + }) + } + }) +} + +export function getFormFieldsForBorrowDialog( + form: UseFormReturn, + marketInfo: MarketInfo, + walletInfo: WalletInfo, +): FormFieldsForDialog { + // eslint-disable-next-line func-style + const changeAsset = (newSymbol: TokenSymbol): void => { + form.setValue('symbol', newSymbol) + form.setValue('value', '') + form.clearErrors() + } + + const { symbol, value } = form.getValues() + const reserve = marketInfo.findOneReserveBySymbol(symbol) + + const borrowValidationArgs = getValidateBorrowArgs(NormalizedUnitNumber(0), reserve, marketInfo) + const validationIssue = validateBorrow(borrowValidationArgs) + + const maxValue = getBorrowMaxValue({ + validationIssue, + user: borrowValidationArgs.user, + asset: borrowValidationArgs.asset, + }) + + return { + selectedAsset: { + value, + token: reserve.token, + balance: walletInfo.findWalletBalanceForSymbol(symbol), + }, + changeAsset, + maxValue, + } +} diff --git a/packages/app/src/features/dialogs/borrow/logic/useBorrowDialog.ts b/packages/app/src/features/dialogs/borrow/logic/useBorrowDialog.ts new file mode 100644 index 000000000..6c49b674d --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/logic/useBorrowDialog.ts @@ -0,0 +1,87 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import BigNumber from 'bignumber.js' +import { useState } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { useAaveDataLayer } from '@/domain/market-info/aave-data-layer/useAaveDataLayer' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { Token } from '@/domain/types/Token' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { Objective } from '@/features/actions/logic/types' + +import { AssetInputSchema, normalizeDialogFormValues } from '../../common/logic/form' +import { FormFieldsForDialog, PageState, PageStatus } from '../../common/types' +import { getBorrowOptions } from './assets' +import { createBorrowObjectives } from './createBorrowObjectives' +import { getBorrowDialogFormValidator, getFormFieldsForBorrowDialog } from './form' + +export interface UseBorrowDialogOptions { + initialToken: Token +} + +export interface UseBorrowDialogResult { + borrowOptions: TokenWithBalance[] + assetsToBorrowFields: FormFieldsForDialog + tokenToBorrow: TokenWithValue + objectives: Objective[] + pageStatus: PageStatus + form: UseFormReturn + currentHealthFactor?: BigNumber + updatedHealthFactor?: BigNumber +} + +export function useBorrowDialog({ initialToken }: UseBorrowDialogOptions): UseBorrowDialogResult { + const { aaveData } = useAaveDataLayer() + const { marketInfo } = useMarketInfo() + const walletInfo = useWalletInfo() + const nativeAssetInfo = getNativeAssetInfo(marketInfo.chainId) + + const [pageStatus, setPageStatus] = useState('form') + + const form = useForm({ + resolver: zodResolver(getBorrowDialogFormValidator(marketInfo)), + defaultValues: { + symbol: initialToken.symbol, + value: '', + }, + mode: 'onChange', + }) + + const borrowOptions = getBorrowOptions({ + token: initialToken, + marketInfo, + walletInfo, + nativeAssetInfo, + }) + const assetsToBorrowFields = getFormFieldsForBorrowDialog(form, marketInfo, walletInfo) + const tokenToBorrow = normalizeDialogFormValues(form.watch(), marketInfo) + const actions = createBorrowObjectives(tokenToBorrow) + + const updatedUserSummary = updatePositionSummary({ + borrows: [tokenToBorrow], + marketInfo, + aaveData, + nativeAssetInfo, + }) + + const currentHealthFactor = marketInfo.userPositionSummary.healthFactor + const updatedHealthFactor = !tokenToBorrow.value.eq(0) ? updatedUserSummary.healthFactor : undefined + + return { + borrowOptions, + assetsToBorrowFields, + tokenToBorrow, + objectives: actions, + pageStatus: { + state: pageStatus, + actionsEnabled: tokenToBorrow.value.gt(0) && form.formState.isValid, + goToSuccessScreen: () => setPageStatus('success'), + }, + form, + currentHealthFactor, + updatedHealthFactor, + } +} diff --git a/packages/app/src/features/dialogs/borrow/views/BorrowView.stories.tsx b/packages/app/src/features/dialogs/borrow/views/BorrowView.stories.tsx new file mode 100644 index 000000000..8e2815c01 --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/views/BorrowView.stories.tsx @@ -0,0 +1,84 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { chromatic } from '@storybook/viewports' +import BigNumber from 'bignumber.js' +import { useForm } from 'react-hook-form' +import { zeroAddress } from 'viem' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { BorrowView } from './BorrowView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Borrow', + component: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm() as any + return + }, + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider()], + args: { + selectableAssets: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(50000), + }, + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + }, + ], + assetsFields: { + selectedAsset: { + token: tokens['DAI'], + balance: NormalizedUnitNumber(50000), + value: '2000', + }, + changeAsset: () => {}, + }, + objectives: [ + { + type: 'borrow', + token: tokens['DAI'], + value: NormalizedUnitNumber(2000), + debtTokenAddress: CheckedAddress(zeroAddress), + }, + ], + borrowAsset: { + token: tokens['DAI'], + value: NormalizedUnitNumber(2000), + }, + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + currentHealthFactor: BigNumber(2.5), + updatedHealthFactor: BigNumber(1.3), + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} + +export const Tablet: Story = { + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + chromatic: { viewports: [chromatic.tablet] }, + }, +} diff --git a/packages/app/src/features/dialogs/borrow/views/BorrowView.tsx b/packages/app/src/features/dialogs/borrow/views/BorrowView.tsx new file mode 100644 index 000000000..1dec7ec47 --- /dev/null +++ b/packages/app/src/features/dialogs/borrow/views/BorrowView.tsx @@ -0,0 +1,53 @@ +import BigNumber from 'bignumber.js' +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { DialogForm } from '@/features/dialogs/common/components/form/DialogForm' +import { FormAndOverviewWrapper } from '@/features/dialogs/common/components/FormAndOverviewWrapper' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' + +import { BorrowOverviewPanel } from '../components/BorrowOverviewPanel' + +export interface BorrowViewProps { + selectableAssets: TokenWithBalance[] + borrowAsset: TokenWithValue + assetsFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + pageStatus: PageStatus + currentHealthFactor?: BigNumber + updatedHealthFactor?: BigNumber +} + +export function BorrowView({ + selectableAssets, + assetsFields, + form, + objectives, + pageStatus, + borrowAsset, + currentHealthFactor, + updatedHealthFactor, +}: BorrowViewProps) { + return ( + + {`Borrow ${borrowAsset.token.symbol}`} + + + + + + + + + ) +} diff --git a/packages/app/src/features/dialogs/collateral/CollateralDialog.PageObject.ts b/packages/app/src/features/dialogs/collateral/CollateralDialog.PageObject.ts new file mode 100644 index 000000000..c22840a34 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/CollateralDialog.PageObject.ts @@ -0,0 +1,36 @@ +import { expect, Page } from '@playwright/test' + +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { testIds } from '@/ui/utils/testIds' + +import { CollateralSetting } from '../collateral/types' +import { DialogPageObject } from '../common/Dialog.PageObject' + +export class CollateralDialogPageObject extends DialogPageObject { + constructor(page: Page) { + super(page, /.*/) + this.region = this.locateDialogByHeader('Collateral') + } + + // #region actions + async setUseAsCollateralAction(assetName: string, setting: CollateralSetting): Promise { + const actionsContainer = new ActionsPageObject(this.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + + // assertion used for waiting + if (setting === 'enabled') { + await this.expectSetUseAsCollateralSuccessPage(assetName, 'enabled') + } else { + await this.expectSetUseAsCollateralSuccessPage(assetName, 'disabled') + } + } + // #endregion actions + + // #region assertions + async expectSetUseAsCollateralSuccessPage(assetName: string, setting: CollateralSetting): Promise { + await expect(this.region.getByRole('heading', { name: 'Congrats! All done!' })).toBeVisible() + await expect(this.region.getByTestId(testIds.dialog.success)).toContainText(assetName) + await expect(this.region.getByTestId(testIds.dialog.success)).toContainText(`Collateral ${setting}`) + } + // #endregion assertions +} diff --git a/packages/app/src/features/dialogs/collateral/CollateralDialog.test-e2e.ts b/packages/app/src/features/dialogs/collateral/CollateralDialog.test-e2e.ts new file mode 100644 index 000000000..ae240c86a --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/CollateralDialog.test-e2e.ts @@ -0,0 +1,321 @@ +import { test } from '@playwright/test' + +import { setUseAsCollateralValidationIssueToMessage } from '@/domain/market-validators/validateSetUseAsCollateral' +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { BorrowPageObject } from '@/pages/Borrow.PageObject' +import { DashboardPageObject } from '@/pages/Dashboard.PageObject' +import { DEFAULT_BLOCK_NUMBER, GNO_ACTIVE_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' + +import { DialogPageObject } from '../common/Dialog.PageObject' +import { CollateralDialogPageObject } from './CollateralDialog.PageObject' + +test.describe('Collateral dialog', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + const initialBalances = { + wstETH: 100, + rETH: 100, + DAI: 10000, + GNO: 100, + } + + test.describe('Deposited multiple assets, no borrow', () => { + const initialDeposits = { + wstETH: 1, + } + const dashboardDeposits = { + DAI: 1000, // cannot be used as collateral + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositWithoutBorrowActions(initialDeposits) + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + + // Depositing DAI in dashboard + await dashboardPage.clickDepositButtonAction('DAI') + const depositDialog = new DialogPageObject(page, /Deposit/) + await depositDialog.fillAmountAction(dashboardDeposits.DAI) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await depositDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + wstETH: initialDeposits.wstETH, + DAI: dashboardDeposits.DAI, + }) + }) + + test('disables collateral', async ({ page }) => { + const collateral = 'wstETH' + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectCollateralSwitch(collateral, true) + await dashboardPage.clickCollateralSwitchAction(collateral) + + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.expectDialogHeader('Collateral') + await collateralDialog.expectHealthFactorNotVisible() + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await collateralDialog.expectSetUseAsCollateralSuccessPage(collateral, 'disabled') + + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch('wstETH', false) + }) + + test('enables collateral', async ({ page }) => { + const collateral = 'wstETH' + + // disabling collateral + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickCollateralSwitchAction(collateral) + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.setUseAsCollateralAction(collateral, 'disabled') + await dashboardPage.goToDashboardAction() + + // enabling collateral + await dashboardPage.expectCollateralSwitch(collateral, false) + await dashboardPage.clickCollateralSwitchAction(collateral) + await collateralDialog.expectDialogHeader('Collateral') + await collateralDialog.expectHealthFactorNotVisible() + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await collateralDialog.expectSetUseAsCollateralSuccessPage(collateral, 'enabled') + + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch(collateral, true) + }) + + test('cannot enable collateral for asset that cannot be used as collateral', async ({ page }) => { + const asset = 'DAI' + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectCollateralSwitch(asset, false) + await dashboardPage.clickCollateralSwitchAction(asset) + + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.expectDialogHeader('Collateral') + await collateralDialog.expectHealthFactorNotVisible() + await collateralDialog.expectAlertMessage(setUseAsCollateralValidationIssueToMessage['zero-ltv-asset']) + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActionsDisabled() + + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch(asset, false) + }) + + test('cannot enable collateral for not deposited asset', async ({ page }) => { + const asset = 'WBTC' + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectCollateralSwitch(asset, false) + await dashboardPage.clickCollateralSwitchAction(asset) + + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.expectDialogHeader('Collateral') + await collateralDialog.expectHealthFactorNotVisible() + await collateralDialog.expectAlertMessage(setUseAsCollateralValidationIssueToMessage['zero-balance-asset']) + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActionsDisabled() + + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch(asset, false) + }) + }) + + test.describe('Single collateral, DAI borrow', () => { + const initialDeposits = { + wstETH: 1, + } + const daiToBorrow = 1000 + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(initialDeposits, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + + await dashboardPage.expectDepositTable({ + wstETH: initialDeposits.wstETH, + }) + }) + + test('cannot disable sole collateral', async ({ page }) => { + const collateral = 'wstETH' + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectCollateralSwitch(collateral, true) + await dashboardPage.clickCollateralSwitchAction(collateral) + + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.expectDialogHeader('Collateral') + await collateralDialog.expectHealthFactorBefore('2.08') + await collateralDialog.expectHealthFactorAfter('0') + await collateralDialog.expectAlertMessage(setUseAsCollateralValidationIssueToMessage['exceeds-ltv']) + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActionsDisabled() + + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch(collateral, true) + }) + }) + + test.describe('Multiple collaterals, DAI borrow', () => { + const initialDeposits = { + wstETH: 1, + rETH: 0.01, + } + const daiToBorrow = 1000 + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(initialDeposits, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + + await dashboardPage.expectDepositTable({ + wstETH: initialDeposits.wstETH, + rETH: initialDeposits.rETH, + }) + }) + + test('disables collateral', async ({ page }) => { + const collateral = 'rETH' + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectCollateralSwitch(collateral, true) + await dashboardPage.clickCollateralSwitchAction(collateral) + + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.expectDialogHeader('Collateral') + await collateralDialog.expectHealthFactorBefore('2.1') + await collateralDialog.expectHealthFactorAfter('2.08') + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + + await collateralDialog.expectSetUseAsCollateralSuccessPage(collateral, 'disabled') + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch('rETH', false) + }) + + test('cannot disable collateral when second one would not cover loan', async ({ page }) => { + const collateral = 'wstETH' + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectCollateralSwitch(collateral, true) + await dashboardPage.clickCollateralSwitchAction(collateral) + + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.expectDialogHeader('Collateral') + await collateralDialog.expectHealthFactorBefore('2.1') + await collateralDialog.expectHealthFactorAfter('0.02') + await collateralDialog.expectAlertMessage(setUseAsCollateralValidationIssueToMessage['exceeds-ltv']) + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActionsDisabled() + + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch(collateral, true) + }) + }) + + test.describe('Isolation mode', () => { + const fork = setupFork(GNO_ACTIVE_BLOCK_NUMBER) + const isolatedAsset = 'GNO' + const regularAsset = 'rETH' + const initialDeposits = { + [regularAsset]: 1, + } + const dashboardDeposits = { + [isolatedAsset]: 100, + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + // Depositing regular asset at borrow page to show dashboard positions + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositWithoutBorrowActions(initialDeposits) + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + + await dashboardPage.expectDepositTable({ + [regularAsset]: initialDeposits[regularAsset], + }) + + // Depositing isolated asset at dashboard + await dashboardPage.clickDepositButtonAction(isolatedAsset) + const depositDialog = new DialogPageObject(page, /Deposit/) + await depositDialog.fillAmountAction(dashboardDeposits[isolatedAsset]) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await depositDialog.viewInDashboardAction() + + const collateralDialog = new CollateralDialogPageObject(page) + // Disabling regular asset as collateral + await dashboardPage.clickCollateralSwitchAction(regularAsset) + await collateralDialog.setUseAsCollateralAction(regularAsset, 'disabled') + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch(isolatedAsset, false) + + // Entering isolation mode + await dashboardPage.clickCollateralSwitchAction(isolatedAsset) + await collateralDialog.setUseAsCollateralAction(isolatedAsset, 'enabled') + await dashboardPage.goToDashboardAction() + + await dashboardPage.expectCollateralSwitch(isolatedAsset, true) + }) + + test('cannot enable asset as collateral in isolation mode', async ({ page }) => { + const collateral = 'rETH' + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectCollateralSwitch(collateral, false) + await dashboardPage.clickCollateralSwitchAction(collateral) + + const collateralDialog = new CollateralDialogPageObject(page) + await collateralDialog.expectAlertMessage(setUseAsCollateralValidationIssueToMessage['isolation-mode-active']) + const actionsContainer = new ActionsPageObject(collateralDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActionsDisabled() + + await dashboardPage.goToDashboardAction() + await dashboardPage.expectCollateralSwitch(collateral, false) + }) + }) +}) diff --git a/packages/app/src/features/dialogs/collateral/CollateralDialog.tsx b/packages/app/src/features/dialogs/collateral/CollateralDialog.tsx new file mode 100644 index 000000000..f8bbcf1a2 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/CollateralDialog.tsx @@ -0,0 +1,22 @@ +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { DialogProps } from '../common/types' +import { CollateralDialogContentContainer } from './CollateralDialogContentContainer' + +interface CollateralDialogProps extends DialogProps { + useAsCollateral: boolean +} + +export function CollateralDialog({ token, useAsCollateral, open, setOpen }: CollateralDialogProps) { + return ( + + + setOpen(false)} + /> + + + ) +} diff --git a/packages/app/src/features/dialogs/collateral/CollateralDialogContentContainer.tsx b/packages/app/src/features/dialogs/collateral/CollateralDialogContentContainer.tsx new file mode 100644 index 000000000..c85a57036 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/CollateralDialogContentContainer.tsx @@ -0,0 +1,46 @@ +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../common/components/skeletons/DialogContentSkeleton' +import { DialogContentContainerProps } from '../common/types' +import { useCollateralDialog } from './logic/useCollateralDialog' +import { CollateralView } from './views/CollateralView' +import { SuccessView } from './views/SuccessView' + +interface CollateralDialogContentContainerProps extends DialogContentContainerProps { + useAsCollateral: boolean +} + +function CollateralDialogContentContainer({ + useAsCollateral, + token, + closeDialog, +}: CollateralDialogContentContainerProps) { + const { objectives, collateral, validationIssue, currentHealthFactor, updatedHealthFactor, pageStatus } = + useCollateralDialog({ + useAsCollateral, + token, + }) + const collateralSetting = useAsCollateral ? 'enabled' : 'disabled' + + if (pageStatus.state === 'success') { + return + } + + return ( + + ) +} + +const CollateralDialogContentContainerWithSuspense = withSuspense( + CollateralDialogContentContainer, + DialogContentSkeleton, +) +export { CollateralDialogContentContainerWithSuspense as CollateralDialogContentContainer } diff --git a/packages/app/src/features/dialogs/collateral/components/CollateralAlert.tsx b/packages/app/src/features/dialogs/collateral/components/CollateralAlert.tsx new file mode 100644 index 000000000..ff06a1bc9 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/components/CollateralAlert.tsx @@ -0,0 +1,29 @@ +import { + SetUseAsCollateralValidationIssue, + setUseAsCollateralValidationIssueToMessage, +} from '@/domain/market-validators/validateSetUseAsCollateral' + +import { Alert } from '../../common/components/alert/Alert' +import { CollateralSetting } from '../types' + +interface CollateralAlertProps { + collateralSetting: CollateralSetting + issue: SetUseAsCollateralValidationIssue | undefined +} + +export function CollateralAlert({ collateralSetting, issue }: CollateralAlertProps) { + if (issue) { + return {setUseAsCollateralValidationIssueToMessage[issue]} + } + + if (collateralSetting === 'enabled') { + return ( + + Enabling this asset as collateral increases your borrowing power and Health Factor. However, it can get + liquidated if your health factor drops below 1. + + ) + } + + return Disabling asset as collateral affects your borrowing power and Health Factor. +} diff --git a/packages/app/src/features/dialogs/collateral/components/CollateralOverviewPanel.tsx b/packages/app/src/features/dialogs/collateral/components/CollateralOverviewPanel.tsx new file mode 100644 index 000000000..0618ef4b0 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/components/CollateralOverviewPanel.tsx @@ -0,0 +1,34 @@ +import BigNumber from 'bignumber.js' + +import { TokenWithBalance } from '@/domain/common/types' +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogPanelTitle } from '@/features/dialogs/common/components/DialogPanelTitle' +import { TransactionOverviewDetailsItem } from '@/features/dialogs/common/components/TransactionOverviewDetailsItem' + +import { HealthFactorChange } from '../../common/components/HealthFactorChange' + +export interface CollateralOverviewPanelProps { + collateral: TokenWithBalance + currentHealthFactor?: BigNumber + updatedHealthFactor?: BigNumber +} +export function CollateralOverviewPanel({ + collateral: { token, balance }, + currentHealthFactor, + updatedHealthFactor, +}: CollateralOverviewPanelProps) { + return ( + + Transaction overview + +
+

+ {token.format(balance, { style: 'auto' })} {token.symbol} +

+
{token.formatUSD(balance)}
+
+
+ +
+ ) +} diff --git a/packages/app/src/features/dialogs/collateral/logic/createCollateralObjectives.ts b/packages/app/src/features/dialogs/collateral/logic/createCollateralObjectives.ts new file mode 100644 index 000000000..dbad04acc --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/logic/createCollateralObjectives.ts @@ -0,0 +1,12 @@ +import { Token } from '@/domain/types/Token' +import { Objective } from '@/features/actions/logic/types' + +export function createCollateralObjectives(token: Token, useAsCollateral: boolean): Objective[] { + return [ + { + type: 'setUseAsCollateral', + token, + useAsCollateral, + }, + ] +} diff --git a/packages/app/src/features/dialogs/collateral/logic/getUpdatedUserSummary.ts b/packages/app/src/features/dialogs/collateral/logic/getUpdatedUserSummary.ts new file mode 100644 index 000000000..bb3b32dc8 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/logic/getUpdatedUserSummary.ts @@ -0,0 +1,35 @@ +import { NativeAssetInfo } from '@/config/chain/types' +import { AaveData } from '@/domain/market-info/aave-data-layer/query' +import { MarketInfo, UserPositionSummary } from '@/domain/market-info/marketInfo' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { Token } from '@/domain/types/Token' + +export interface GetUpdatedUserSummaryParams { + useAsCollateral: boolean + token: Token + marketInfo: MarketInfo + aaveData: AaveData + nativeAssetInfo: NativeAssetInfo +} + +export function getUpdatedUserSummary({ + useAsCollateral, + token, + marketInfo, + aaveData, + nativeAssetInfo, +}: GetUpdatedUserSummaryParams): UserPositionSummary { + const reserve = marketInfo.findOneReserveByToken(token) + + return updatePositionSummary({ + reservesWithUseAsCollateralFlag: [ + { + reserve, + useAsCollateral, + }, + ], + marketInfo, + aaveData, + nativeAssetInfo, + }) +} diff --git a/packages/app/src/features/dialogs/collateral/logic/useCollateralDialog.ts b/packages/app/src/features/dialogs/collateral/logic/useCollateralDialog.ts new file mode 100644 index 000000000..d2a7415e4 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/logic/useCollateralDialog.ts @@ -0,0 +1,75 @@ +import BigNumber from 'bignumber.js' +import { useState } from 'react' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { TokenWithBalance } from '@/domain/common/types' +import { useAaveDataLayer } from '@/domain/market-info/aave-data-layer/useAaveDataLayer' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { + getValidateSetUseAsCollateralArgs, + SetUseAsCollateralValidationIssue, + validateSetUseAsCollateral, +} from '@/domain/market-validators/validateSetUseAsCollateral' +import { Token } from '@/domain/types/Token' +import { Objective } from '@/features/actions/logic/types' + +import { PageState, PageStatus } from '../../common/types' +import { createCollateralObjectives } from './createCollateralObjectives' +import { getUpdatedUserSummary } from './getUpdatedUserSummary' + +export interface UseCollateralDialogParams { + useAsCollateral: boolean + token: Token +} + +export interface UseCollateralDialogResult { + collateral: TokenWithBalance + objectives: Objective[] + validationIssue: SetUseAsCollateralValidationIssue | undefined + pageStatus: PageStatus + currentHealthFactor?: BigNumber + updatedHealthFactor?: BigNumber +} + +export function useCollateralDialog({ useAsCollateral, token }: UseCollateralDialogParams): UseCollateralDialogResult { + const { aaveData } = useAaveDataLayer() + const { marketInfo } = useMarketInfo() + const nativeAssetInfo = getNativeAssetInfo(marketInfo.chainId) + + const [pageStatus, setPageStatus] = useState('form') + + const collateralPosition = marketInfo.findOnePositionByToken(token) + const currentHealthFactor = marketInfo.userPositionSummary.healthFactor + const collateral = { token, balance: collateralPosition.collateralBalance } + + const { healthFactor: updatedHealthFactor } = getUpdatedUserSummary({ + useAsCollateral, + token, + marketInfo, + aaveData, + nativeAssetInfo, + }) + const objectives = createCollateralObjectives(token, useAsCollateral) + + const validationIssue = validateSetUseAsCollateral( + getValidateSetUseAsCollateralArgs({ + useAsCollateral, + collateral: token, + healthFactorAfterWithdrawal: updatedHealthFactor, + marketInfo, + }), + ) + + return { + objectives, + collateral, + validationIssue, + currentHealthFactor, + updatedHealthFactor, + pageStatus: { + state: pageStatus, + actionsEnabled: !validationIssue, + goToSuccessScreen: () => setPageStatus('success'), + }, + } +} diff --git a/packages/app/src/features/dialogs/collateral/types.ts b/packages/app/src/features/dialogs/collateral/types.ts new file mode 100644 index 000000000..ca7a34a06 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/types.ts @@ -0,0 +1 @@ +export type CollateralSetting = 'enabled' | 'disabled' diff --git a/packages/app/src/features/dialogs/collateral/views/CollateralView.stories.ts b/packages/app/src/features/dialogs/collateral/views/CollateralView.stories.ts new file mode 100644 index 000000000..5e00c407a --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/views/CollateralView.stories.ts @@ -0,0 +1,77 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { CollateralView } from './CollateralView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Collateral', + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider()], + component: CollateralView, + args: { + collateral: { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + }, + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Enable: Story = { + args: { + collateralSetting: 'enabled', + currentHealthFactor: new BigNumber(1.5), + updatedHealthFactor: new BigNumber(2.3), + objectives: [ + { + type: 'setUseAsCollateral', + token: tokens['ETH'], + useAsCollateral: true, + }, + ], + }, +} + +export const Disable: Story = { + args: { + collateralSetting: 'disabled', + currentHealthFactor: new BigNumber(2.3), + updatedHealthFactor: new BigNumber(1.5), + objectives: [ + { + type: 'setUseAsCollateral', + token: tokens['ETH'], + useAsCollateral: false, + }, + ], + }, +} + +export const DisableLiquidation: Story = { + args: { + collateralSetting: 'disabled', + currentHealthFactor: new BigNumber(2.3), + updatedHealthFactor: new BigNumber(0.5), + objectives: [ + { + type: 'setUseAsCollateral', + token: tokens['ETH'], + useAsCollateral: false, + }, + ], + }, +} + +export const Mobile = getMobileStory(Enable) +export const Tablet = getTabletStory(Enable) diff --git a/packages/app/src/features/dialogs/collateral/views/CollateralView.tsx b/packages/app/src/features/dialogs/collateral/views/CollateralView.tsx new file mode 100644 index 000000000..083a43431 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/views/CollateralView.tsx @@ -0,0 +1,53 @@ +import BigNumber from 'bignumber.js' + +import { TokenWithBalance } from '@/domain/common/types' +import { SetUseAsCollateralValidationIssue } from '@/domain/market-validators/validateSetUseAsCollateral' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' + +import { CollateralAlert } from '../components/CollateralAlert' +import { CollateralOverviewPanel } from '../components/CollateralOverviewPanel' +import { CollateralSetting } from '../types' + +interface CollateralViewProps { + collateralSetting: CollateralSetting + collateral: TokenWithBalance + validationIssue: SetUseAsCollateralValidationIssue | undefined + objectives: Objective[] + pageStatus: PageStatus + currentHealthFactor?: BigNumber + updatedHealthFactor?: BigNumber +} + +export function CollateralView({ + collateralSetting, + collateral, + validationIssue, + objectives, + pageStatus, + currentHealthFactor, + updatedHealthFactor, +}: CollateralViewProps) { + return ( + + Collateral + + + + + + + + ) +} diff --git a/packages/app/src/features/dialogs/collateral/views/SuccessView.stories.ts b/packages/app/src/features/dialogs/collateral/views/SuccessView.stories.ts new file mode 100644 index 000000000..a01d5625c --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/views/SuccessView.stories.ts @@ -0,0 +1,25 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { SuccessView } from './SuccessView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Success', + component: SuccessView, + decorators: [WithClassname('max-w-xl')], + args: { + token: tokens['ETH'], + }, +} + +export default meta +type Story = StoryObj + +export const DesktopCollateral: Story = { + args: { collateralSetting: 'enabled' }, +} + +export const MobileCollateral = getMobileStory(DesktopCollateral) +export const TabletCollateral = getTabletStory(DesktopCollateral) diff --git a/packages/app/src/features/dialogs/collateral/views/SuccessView.tsx b/packages/app/src/features/dialogs/collateral/views/SuccessView.tsx new file mode 100644 index 000000000..902821ac4 --- /dev/null +++ b/packages/app/src/features/dialogs/collateral/views/SuccessView.tsx @@ -0,0 +1,34 @@ +import { Token } from '@/domain/types/Token' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +import { SuccessViewCheckmark } from '../../common/components/success-view/SuccessViewCheckmark' +import { SuccessViewContent } from '../../common/components/success-view/SuccessViewContent' +import { SuccessViewProceedButton } from '../../common/components/success-view/SuccessViewProceedButton' +import { SuccessViewSummaryPanel } from '../../common/components/success-view/SuccessViewSummaryPanel' +import { CollateralSetting } from '../types' + +export interface SuccessViewProps { + collateralSetting: CollateralSetting + token: Token + onProceed: () => void +} +export function SuccessView({ collateralSetting, token, onProceed }: SuccessViewProps) { + return ( + + + +
+ + {token.symbol} +
+

Collateral {collateralActionToVerb[collateralSetting]}

+
+ Back to Dashboard +
+ ) +} + +const collateralActionToVerb: Record = { + enabled: 'enabled', + disabled: 'disabled', +} diff --git a/packages/app/src/features/dialogs/common/Dialog.PageObject.ts b/packages/app/src/features/dialogs/common/Dialog.PageObject.ts new file mode 100644 index 000000000..8d72b229d --- /dev/null +++ b/packages/app/src/features/dialogs/common/Dialog.PageObject.ts @@ -0,0 +1,107 @@ +import { expect, Locator, Page } from '@playwright/test' + +import { expectAssets, TestTokenWithValue } from '@/test/e2e/assertions' +import { BasePageObject } from '@/test/e2e/BasePageObject' +import { ForkContext } from '@/test/e2e/setupFork' +import { calculateAssetsWorth, isPage } from '@/test/e2e/utils' +import { testIds } from '@/ui/utils/testIds' + +export class DialogPageObject extends BasePageObject { + constructor(pageOrLocator: Page | Locator, header: RegExp) { + if (isPage(pageOrLocator)) { + super(pageOrLocator) + this.region = this.locateDialogByHeader(header) + } else { + super(pageOrLocator) + } + } + + getDialog(): Locator { + return this.region + } + + // #region actions + async selectAssetAction(asset: string): Promise { + const selector = this.region.getByTestId(testIds.component.AssetSelector) + await this.selectOptionByLabelAction(selector, asset) + } + + async fillAmountAction(amount: number): Promise { + await this.region.getByRole('textbox').fill(amount.toString()) + } + + async clickMaxAmountAction(): Promise { + await this.region.getByRole('button', { name: 'MAX' }).click() + } + + async viewInDashboardAction(): Promise { + await this.region.getByRole('button', { name: 'View in dashboard' }).click() + await this.region.waitFor({ + state: 'detached', + }) + } + // #endregion actions + + // #region assertions + async expectSuccessPage(tokenWithValue: TestTokenWithValue[], fork: ForkContext): Promise { + await expect(this.region.getByText('Congrats! All done!')).toBeVisible() + + const transformed = tokenWithValue.reduce((acc, { asset, amount: value }) => ({ ...acc, [asset]: value }), {}) + + const { assetsWorth } = await calculateAssetsWorth(fork.forkUrl, transformed) + + const summary = await this.region.getByTestId(testIds.dialog.success).textContent() + expectAssets(summary!, tokenWithValue, assetsWorth) + } + + async expectRiskLevelBefore(riskLevel: string): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.before)).toContainText(riskLevel) + } + + async expectRiskLevelAfter(riskLevel: string): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.after)).toContainText(riskLevel) + } + + async expectHealthFactorBefore(healthFactor: string): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.before)).toContainText(healthFactor) + } + + async expectHealthFactorAfter(healthFactor: string): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.after)).toContainText(healthFactor) + } + + async expectSelectedAsset(asset: string): Promise { + await expect(this.region.getByTestId(testIds.component.AssetSelector)).toHaveText(asset) + } + + async expectDialogHeader(header: string): Promise { + await expect(this.region.getByRole('heading').first()).toHaveText(header) + } + + async expectAssetInputError(error: string): Promise { + await expect(this.page.getByTestId(testIds.component.AssetInput.error)).toHaveText(error) + } + + async expectAlertMessage(message: string): Promise { + await expect(this.page.getByTestId(testIds.component.Alert.message)).toHaveText(message) + } + + async expectHealthFactorBeforeVisible(): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.before)).toBeVisible() + } + + async expectHealthFactorAfterVisible(): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.after)).toBeVisible() + } + + async expectHealthFactorVisible(): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.after)).toBeVisible() + await expect(this.region.getByTestId(testIds.dialog.healthFactor.before)).toBeVisible() + } + + async expectHealthFactorNotVisible(): Promise { + await expect(this.region.getByTestId(testIds.dialog.healthFactor.after)).not.toBeVisible() + await expect(this.region.getByTestId(testIds.dialog.healthFactor.before)).not.toBeVisible() + } + // #endregion +} diff --git a/packages/app/src/features/dialogs/common/components/DialogActionsPanel.tsx b/packages/app/src/features/dialogs/common/components/DialogActionsPanel.tsx new file mode 100644 index 000000000..e50d0d216 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/DialogActionsPanel.tsx @@ -0,0 +1,5 @@ +import { ActionsContainer, ActionsContainerProps } from '@/features/actions/ActionsContainer' + +export function DialogActionsPanel(props: Omit, 'variant'>) { + return +} diff --git a/packages/app/src/features/dialogs/common/components/DialogPanel.tsx b/packages/app/src/features/dialogs/common/components/DialogPanel.tsx new file mode 100644 index 000000000..f8e14ae6b --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/DialogPanel.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from 'react' + +import { Panel } from '@/ui/atoms/panel/Panel' +import { cn } from '@/ui/utils/style' + +export interface TransactionOverviewWrapperProps { + children: ReactNode + className?: string +} +export function DialogPanel({ children, className }: TransactionOverviewWrapperProps) { + return {children} +} diff --git a/packages/app/src/features/dialogs/common/components/DialogPanelTitle.tsx b/packages/app/src/features/dialogs/common/components/DialogPanelTitle.tsx new file mode 100644 index 000000000..27eaee354 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/DialogPanelTitle.tsx @@ -0,0 +1,15 @@ +import { Typography } from '@/ui/atoms/typography/Typography' + +export interface DialogSectionTitleProps { + children: string +} + +export function DialogPanelTitle({ children }: DialogSectionTitleProps) { + return ( +
+ + {children} + +
+ ) +} diff --git a/packages/app/src/features/dialogs/common/components/FormAndOverviewWrapper.tsx b/packages/app/src/features/dialogs/common/components/FormAndOverviewWrapper.tsx new file mode 100644 index 000000000..85beac348 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/FormAndOverviewWrapper.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +interface FormAndOverviewWrapperProps { + children: ReactNode +} + +export function FormAndOverviewWrapper({ children }: FormAndOverviewWrapperProps) { + return
{children}
+} diff --git a/packages/app/src/features/dialogs/common/components/HealthFactorChange.stories.ts b/packages/app/src/features/dialogs/common/components/HealthFactorChange.stories.ts new file mode 100644 index 000000000..3ce621e7b --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/HealthFactorChange.stories.ts @@ -0,0 +1,76 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import BigNumber from 'bignumber.js' + +import { HealthFactorChange } from './HealthFactorChange' + +const meta: Meta = { + title: 'Features/Dialogs/Components/HealthFactorChange', + component: HealthFactorChange, + decorators: [WithClassname('max-w-sm')], +} + +export default meta +type Story = StoryObj + +export const RiskyToModerate: Story = { + args: { + currentHealthFactor: BigNumber(1.5), + updatedHealthFactor: BigNumber(2.5), + }, +} + +export const RiskyToHealthy: Story = { + args: { + currentHealthFactor: BigNumber(1.5), + updatedHealthFactor: BigNumber(8), + }, +} + +export const ModerateToHealthy: Story = { + args: { + currentHealthFactor: BigNumber(2.5), + updatedHealthFactor: BigNumber(8), + }, +} + +export const HealthyToModerate: Story = { + args: { + currentHealthFactor: BigNumber(8), + updatedHealthFactor: BigNumber(2.5), + }, +} + +export const HealthyToRisky: Story = { + args: { + currentHealthFactor: BigNumber(8), + updatedHealthFactor: BigNumber(1.5), + }, +} + +export const ModerateToRisky: Story = { + args: { + currentHealthFactor: BigNumber(2.5), + updatedHealthFactor: BigNumber(1.5), + }, +} + +export const HealthyToLiquidation: Story = { + args: { + currentHealthFactor: BigNumber(8), + updatedHealthFactor: BigNumber(0.5), + }, +} + +export const RiskyToNoDebt: Story = { + args: { + currentHealthFactor: BigNumber(1.5), + updatedHealthFactor: BigNumber(Infinity), + }, +} + +export const OnlyUpdatedHealthFactor: Story = { + args: { + updatedHealthFactor: BigNumber(1.5), + }, +} diff --git a/packages/app/src/features/dialogs/common/components/HealthFactorChange.tsx b/packages/app/src/features/dialogs/common/components/HealthFactorChange.tsx new file mode 100644 index 000000000..ac739ad72 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/HealthFactorChange.tsx @@ -0,0 +1,40 @@ +import BigNumber from 'bignumber.js' + +import { assets } from '@/ui/assets' +import { testIds } from '@/ui/utils/testIds' + +import { RiskIndicator } from './RiskIndicator' +import { TransactionOverviewDetailsItem } from './TransactionOverviewDetailsItem' + +interface HealthFactorChangeProps { + currentHealthFactor?: BigNumber + updatedHealthFactor?: BigNumber +} + +export function HealthFactorChange({ currentHealthFactor, updatedHealthFactor }: HealthFactorChangeProps) { + if (currentHealthFactor === undefined && updatedHealthFactor !== undefined) { + return ( + + + + ) + } + + if (currentHealthFactor !== undefined) { + return ( + +
+ + {updatedHealthFactor && ( + <> + + + + )} +
+
+ ) + } + + return null +} diff --git a/packages/app/src/features/dialogs/common/components/MultiPanelDialog.tsx b/packages/app/src/features/dialogs/common/components/MultiPanelDialog.tsx new file mode 100644 index 000000000..18f643a89 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/MultiPanelDialog.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +interface ActionViewWrapperProps { + children: ReactNode +} + +export function MultiPanelDialog({ children }: ActionViewWrapperProps) { + return
{children}
+} diff --git a/packages/app/src/features/dialogs/common/components/RiskIndicator.stories.ts b/packages/app/src/features/dialogs/common/components/RiskIndicator.stories.ts new file mode 100644 index 000000000..496895b12 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/RiskIndicator.stories.ts @@ -0,0 +1,47 @@ +import { Meta, StoryObj } from '@storybook/react' +import BigNumber from 'bignumber.js' + +import { RiskIndicator } from './RiskIndicator' + +const meta: Meta = { + title: 'Features/Dialogs/Components/RiskIndicator', + component: RiskIndicator, +} + +export default meta +type Story = StoryObj + +export const Risky: Story = { + name: 'Risky', + args: { + healthFactor: new BigNumber(1), + }, +} + +export const Moderate: Story = { + name: 'Moderate', + args: { + healthFactor: new BigNumber(2.5), + }, +} + +export const Healthy: Story = { + name: 'Healthy', + args: { + healthFactor: new BigNumber(3.5), + }, +} + +export const NoDebt: Story = { + name: 'No debt', + args: { + healthFactor: new BigNumber(Infinity), + }, +} + +export const Liquidation: Story = { + name: 'Liquidation', + args: { + healthFactor: new BigNumber(0.5), + }, +} diff --git a/packages/app/src/features/dialogs/common/components/RiskIndicator.tsx b/packages/app/src/features/dialogs/common/components/RiskIndicator.tsx new file mode 100644 index 000000000..bd988b763 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/RiskIndicator.tsx @@ -0,0 +1,40 @@ +import BigNumber from 'bignumber.js' +import { cva } from 'class-variance-authority' + +import { formatHealthFactor } from '@/domain/common/format' +import { healthFactorToRiskLevel, riskLevelToTitle } from '@/domain/common/risk' +import { Typography } from '@/ui/atoms/typography/Typography' + +export interface RiskIndicatorProps { + healthFactor: BigNumber +} + +const badgeVariants = cva('rounded-lg p-2.5 text-xs', { + variants: { + variant: { + liquidation: 'bg-product-red/10 text-product-red', + risky: 'bg-product-red/10 text-product-red', + moderate: 'bg-product-orange/10 text-product-orange', + healthy: 'bg-product-green/10 text-product-green', + 'no debt': 'bg-product-green/10 text-product-green', + unknown: '', + }, + }, +}) + +export function RiskIndicator({ healthFactor, ...props }: RiskIndicatorProps) { + const riskLevel = healthFactorToRiskLevel(healthFactor) + + return ( +
+
+ {riskLevelToTitle[riskLevel]} +
+ {formatHealthFactor(healthFactor)} +
+ ) +} diff --git a/packages/app/src/features/dialogs/common/components/TransactionOverviewDetailsItem.tsx b/packages/app/src/features/dialogs/common/components/TransactionOverviewDetailsItem.tsx new file mode 100644 index 000000000..3675d65e2 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/TransactionOverviewDetailsItem.tsx @@ -0,0 +1,12 @@ +interface TransactionOverviewDetailsItemProps { + label: string + children: React.ReactNode +} +export function TransactionOverviewDetailsItem({ label, children }: TransactionOverviewDetailsItemProps) { + return ( +
+
{label}
+
{children}
+
+ ) +} diff --git a/packages/app/src/features/dialogs/common/components/alert/Alert.stories.ts b/packages/app/src/features/dialogs/common/components/alert/Alert.stories.ts new file mode 100644 index 000000000..a900bae0b --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/alert/Alert.stories.ts @@ -0,0 +1,36 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' + +import { Alert } from './Alert' + +const meta: Meta = { + title: 'Features/Dialogs/Components/Alert', + component: Alert, + decorators: [WithClassname('max-w-xl')], +} + +export default meta +type Story = StoryObj + +export const Danger: Story = { + args: { + variant: 'danger', + children: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + }, +} + +export const Info: Story = { + args: { + variant: 'info', + children: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', + }, +} + +export const Warning: Story = { + args: { + variant: 'warning', + children: + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.', + }, +} diff --git a/packages/app/src/features/dialogs/common/components/alert/Alert.tsx b/packages/app/src/features/dialogs/common/components/alert/Alert.tsx new file mode 100644 index 000000000..5c2bdcb31 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/alert/Alert.tsx @@ -0,0 +1,44 @@ +import { cva } from 'class-variance-authority' +import { AlertTriangle } from 'lucide-react' +import { ReactNode } from 'react' + +import { testIds } from '@/ui/utils/testIds' + +type VariantsConfig = { variant: { danger: string; warning: string; info: string } } +type Variant = keyof VariantsConfig['variant'] + +interface AlertProps { + children: ReactNode + variant: Variant +} + +export function Alert({ children, variant }: AlertProps) { + return ( +
+ +

+ {children} +

+
+ ) +} + +const bgVariants = cva('flex items-center gap-4 rounded-lg px-4 py-2.5', { + variants: { + variant: { + danger: 'bg-[#FC5038]/10', + warning: 'bg-[#F4B731]/10', + info: 'bg-[#3F66EF]/10', + }, + }, +}) + +const iconVariants = cva('h-6 shrink-0', { + variants: { + variant: { + danger: 'text-[#FC4F37]', + warning: 'text-[#F4B731]', + info: 'text-main-blue', + }, + }, +}) diff --git a/packages/app/src/features/dialogs/common/components/form/DialogForm.tsx b/packages/app/src/features/dialogs/common/components/form/DialogForm.tsx new file mode 100644 index 000000000..68975b2e7 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/form/DialogForm.tsx @@ -0,0 +1,40 @@ +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { Form } from '@/ui/atoms/form/Form' +import { AssetInputProps } from '@/ui/molecules/asset-input/AssetInput' +import { AssetSelectorWithInput } from '@/ui/organisms/asset-selector-with-input/AssetSelectorWithInput' + +import { AssetInputSchema } from '../../logic/form' +import { FormFieldsForDialog } from '../../types' +import { DialogPanelTitle } from '../DialogPanelTitle' + +export interface DialogFormProps { + selectorAssets: TokenWithBalance[] + assetsFields: FormFieldsForDialog + form: UseFormReturn + variant?: AssetInputProps['variant'] + walletIconLabel?: string +} + +export function DialogForm({ selectorAssets, assetsFields, form, variant, walletIconLabel }: DialogFormProps) { + const { selectedAsset, changeAsset, maxSelectedFieldName, maxValue } = assetsFields + + return ( +
+ Amount + + + ) +} diff --git a/packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.stories.tsx b/packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.stories.tsx new file mode 100644 index 000000000..14eba15a6 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.stories.tsx @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react' +import { chromatic } from '@storybook/viewports' + +import { DialogContentSkeleton } from './DialogContentSkeleton' + +const meta: Meta = { + title: 'Features/Dialogs/Skeletons/DialogContentSkeleton', + component: DialogContentSkeleton, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} diff --git a/packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.tsx b/packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.tsx new file mode 100644 index 000000000..6a0a5f437 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/skeletons/DialogContentSkeleton.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' + +export function DialogContentSkeleton() { + return ( +
+ + + +
+ ) +} diff --git a/packages/app/src/features/dialogs/common/components/success-view/SuccessViewCheckmark.tsx b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewCheckmark.tsx new file mode 100644 index 000000000..a7fcef219 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewCheckmark.tsx @@ -0,0 +1,10 @@ +import { assets } from '@/ui/assets' + +export function SuccessViewCheckmark() { + return ( +
+ success-img +

Congrats! All done!

+
+ ) +} diff --git a/packages/app/src/features/dialogs/common/components/success-view/SuccessViewContent.tsx b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewContent.tsx new file mode 100644 index 000000000..4303e7956 --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewContent.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +interface SuccessViewContentProps { + children: ReactNode +} + +export function SuccessViewContent({ children }: SuccessViewContentProps) { + return
{children}
+} diff --git a/packages/app/src/features/dialogs/common/components/success-view/SuccessViewProceedButton.tsx b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewProceedButton.tsx new file mode 100644 index 000000000..9e1b06b4e --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewProceedButton.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from 'react' + +import { Button } from '@/ui/atoms/button/Button' + +interface SuccessViewProceedButtonProps { + onProceed: () => void + children: ReactNode +} + +export function SuccessViewProceedButton({ onProceed, children }: SuccessViewProceedButtonProps) { + return ( + + ) +} diff --git a/packages/app/src/features/dialogs/common/components/success-view/SuccessViewSummaryPanel.tsx b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewSummaryPanel.tsx new file mode 100644 index 000000000..ac01a477d --- /dev/null +++ b/packages/app/src/features/dialogs/common/components/success-view/SuccessViewSummaryPanel.tsx @@ -0,0 +1,22 @@ +import { ReactNode } from 'react' + +import { Panel } from '@/ui/atoms/panel/Panel' +import { testIds } from '@/ui/utils/testIds' + +import { DialogPanelTitle } from '../DialogPanelTitle' + +interface SuccessViewSummaryPanelProps { + title?: string + children: ReactNode +} + +export function SuccessViewSummaryPanel({ title, children }: SuccessViewSummaryPanelProps) { + return ( + + {title && {title}} +
+ {children} +
+
+ ) +} diff --git a/packages/app/src/features/dialogs/common/logic/form.ts b/packages/app/src/features/dialogs/common/logic/form.ts new file mode 100644 index 000000000..c0660fd12 --- /dev/null +++ b/packages/app/src/features/dialogs/common/logic/form.ts @@ -0,0 +1,61 @@ +import BigNumber from 'bignumber.js' +import { UseFormReturn } from 'react-hook-form' +import { z } from 'zod' + +import { MarketInfo, Reserve, UserPosition } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +export const AssetInputSchema = z.object({ + symbol: z.string().transform(TokenSymbol), + value: z.string().refine( + (data) => { + const value = parseFloat(data) + return data === '' || !isNaN(value) + }, + { + message: 'Value must be a valid number', + }, + ), + isMaxSelected: z.boolean().default(false), +}) +export type AssetInputSchema = z.infer + +export interface DialogFormNormalizedData { + position: UserPosition + reserve: Reserve + token: Token + value: NormalizedUnitNumber + isMaxSelected: boolean +} + +export function normalizeDialogFormValues(asset: AssetInputSchema, marketInfo: MarketInfo): DialogFormNormalizedData { + const token = marketInfo.findOneTokenBySymbol(asset.symbol) + const position = marketInfo.findOnePositionBySymbol(asset.symbol) + const value = NormalizedUnitNumber(asset.value === '' ? '0' : asset.value) + + return { + position, + reserve: position.reserve, + token, + value, + isMaxSelected: asset.isMaxSelected, + } +} + +export function isMaxValue(value: string, maxValue: NormalizedUnitNumber): boolean { + const normalizedValue = NormalizedUnitNumber(value === '' ? '0' : value) + return normalizedValue.eq(maxValue) +} + +export function getActionAsset( + form: UseFormReturn, + marketInfo: MarketInfo, + maxValue: NormalizedUnitNumber, +): DialogFormNormalizedData { + const formValue = form.watch() + const formAsset = normalizeDialogFormValues(formValue, marketInfo) + const assetValue = NormalizedUnitNumber(BigNumber.min(formAsset.value, maxValue)) + return { ...formAsset, value: assetValue } +} diff --git a/packages/app/src/features/dialogs/common/logic/title.ts b/packages/app/src/features/dialogs/common/logic/title.ts new file mode 100644 index 000000000..0f0eaa27a --- /dev/null +++ b/packages/app/src/features/dialogs/common/logic/title.ts @@ -0,0 +1,11 @@ +import { ObjectiveType } from '@/features/actions/logic/types' + +export const objectiveTypeToVerb: Record = { + deposit: 'Deposited', + borrow: 'Borrowed', + withdraw: 'Withdrew', + repay: 'Repaid', + setUseAsCollateral: 'Set', + setUserEMode: 'Set', + exchange: 'Deposited', +} diff --git a/packages/app/src/features/dialogs/common/logic/useUpdateFormMaxValue.ts b/packages/app/src/features/dialogs/common/logic/useUpdateFormMaxValue.ts new file mode 100644 index 000000000..41bf493ac --- /dev/null +++ b/packages/app/src/features/dialogs/common/logic/useUpdateFormMaxValue.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react' +import { UseFormReturn } from 'react-hook-form' + +import { formFormat } from '@/domain/common/format' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { usePrevious } from '@/utils/usePrevious' + +import { AssetInputSchema } from './form' + +interface useUpdateFormMaxValueParams { + isMaxSet: boolean + maxValue: NormalizedUnitNumber + token: Token + form: UseFormReturn +} + +export function useUpdateFormMaxValue({ isMaxSet, maxValue, form, token }: useUpdateFormMaxValueParams): void { + const prevMaxSet = usePrevious(isMaxSet) + const prevMaxValue = usePrevious(maxValue) + + useEffect( + function updateFormValue() { + if (prevMaxSet && prevMaxValue && !maxValue.isEqualTo(prevMaxValue)) { + form.setValue('value', formFormat(maxValue, token.decimals)) + } + }, + [isMaxSet, maxValue, prevMaxSet, prevMaxValue, form, token.decimals], + ) +} diff --git a/packages/app/src/features/dialogs/common/types.ts b/packages/app/src/features/dialogs/common/types.ts new file mode 100644 index 000000000..e639831f9 --- /dev/null +++ b/packages/app/src/features/dialogs/common/types.ts @@ -0,0 +1,33 @@ +import { TokenWithFormValue } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +export interface CommonDialogProps { + open: boolean + setOpen: (open: boolean) => void +} + +export interface DialogContentContainerProps { + token: Token + closeDialog: () => void +} + +export interface DialogProps extends CommonDialogProps { + token: Token +} + +export type PageState = 'form' | 'success' + +export interface PageStatus { + state: PageState + actionsEnabled: boolean + goToSuccessScreen: () => void +} + +export interface FormFieldsForDialog { + selectedAsset: TokenWithFormValue + maxValue?: NormalizedUnitNumber + maxSelectedFieldName?: string // can't be used with maxValue + changeAsset: (newSymbol: TokenSymbol) => void +} diff --git a/packages/app/src/features/dialogs/common/views/SuccessView.stories.ts b/packages/app/src/features/dialogs/common/views/SuccessView.stories.ts new file mode 100644 index 000000000..6a09819b9 --- /dev/null +++ b/packages/app/src/features/dialogs/common/views/SuccessView.stories.ts @@ -0,0 +1,57 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { SuccessView } from './SuccessView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Success', + component: SuccessView, + decorators: [WithClassname('max-w-xl')], + args: { + tokenWithValue: { + token: tokens['DAI'], + value: NormalizedUnitNumber(2000), + }, + onProceed: () => {}, + proceedText: 'View in dashboard', + }, +} + +export default meta +type Story = StoryObj + +export const DesktopDeposit: Story = { + args: { + objectiveType: 'deposit', + }, +} +export const MobileDeposit = getMobileStory(DesktopDeposit) +export const TabletDeposit = getTabletStory(DesktopDeposit) + +export const DesktopBorrow: Story = { + args: { + objectiveType: 'borrow', + }, +} +export const MobileBorrow = getMobileStory(DesktopBorrow) +export const TabletBorrow = getTabletStory(DesktopBorrow) + +export const DesktopRepay: Story = { + args: { + objectiveType: 'repay', + }, +} +export const MobileRepay = getMobileStory(DesktopRepay) +export const TabletRepay = getTabletStory(DesktopRepay) + +export const DesktopWithdraw: Story = { + args: { + objectiveType: 'withdraw', + }, +} +export const MobileWithdraw = getMobileStory(DesktopWithdraw) +export const TabletWithdraw = getTabletStory(DesktopWithdraw) diff --git a/packages/app/src/features/dialogs/common/views/SuccessView.tsx b/packages/app/src/features/dialogs/common/views/SuccessView.tsx new file mode 100644 index 000000000..5d96d98c3 --- /dev/null +++ b/packages/app/src/features/dialogs/common/views/SuccessView.tsx @@ -0,0 +1,39 @@ +import { TokenWithValue } from '@/domain/common/types' +import { ObjectiveType } from '@/features/actions/logic/types' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' +import { Typography } from '@/ui/atoms/typography/Typography' + +import { SuccessViewCheckmark } from '../components/success-view/SuccessViewCheckmark' +import { SuccessViewContent } from '../components/success-view/SuccessViewContent' +import { SuccessViewProceedButton } from '../components/success-view/SuccessViewProceedButton' +import { SuccessViewSummaryPanel } from '../components/success-view/SuccessViewSummaryPanel' +import { objectiveTypeToVerb } from '../logic/title' + +export interface SuccessViewProps { + objectiveType: ObjectiveType + tokenWithValue: TokenWithValue + onProceed: () => void + proceedText: string +} +export function SuccessView({ objectiveType, tokenWithValue, onProceed, proceedText }: SuccessViewProps) { + const { token, value } = tokenWithValue + + return ( + + + +
+ + {token.symbol} +
+
+ {token.format(value, { style: 'auto' })} + + {token.formatUSD(value)} + +
+
+ {proceedText} +
+ ) +} diff --git a/packages/app/src/features/dialogs/deposit/DepositDialog.test-e2e.ts b/packages/app/src/features/dialogs/deposit/DepositDialog.test-e2e.ts new file mode 100644 index 000000000..4aede758e --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/DepositDialog.test-e2e.ts @@ -0,0 +1,466 @@ +import { test } from '@playwright/test' + +import { publicTenderlyActions } from '@/domain/sandbox/publicTenderlyActions' +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { BorrowPageObject } from '@/pages/Borrow.PageObject' +import { DashboardPageObject } from '@/pages/Dashboard.PageObject' +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' +import { screenshot } from '@/test/e2e/utils' + +import { DialogPageObject } from '../common/Dialog.PageObject' + +const headerRegExp = /Deposit */ + +test.describe('Deposit dialog', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + const initialBalances = { + wstETH: 100, + rETH: 100, + ETH: 100, + } + + test.describe('Position with deposit and borrow', () => { + const initialDeposits = { + wstETH: 1, + rETH: 2, + } + const daiToBorrow = 1500 + const expectedInitialHealthFactor = '4.03' + const expectedHealthFactor = '5.35' + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(initialDeposits, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + // @todo This waits for the refetch of the data after successful borrow transaction to happen. + // This is no ideal, probably we need to refactor expectDepositTable so it takes advantage from + // playwright's timeouts instead of parsing it's current state. Then we would be able to + // easily wait for the table to be updated. + await dashboardPage.expectAssetToBeInDepositTable('DAI') + }) + + test('opens dialog with selected asset', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction('rETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.expectSelectedAsset('rETH') + await depositDialog.expectDialogHeader('Deposit rETH') + await depositDialog.expectHealthFactorBeforeVisible() + + await screenshot(depositDialog.getDialog(), 'deposit-dialog-default-view') + }) + + test('calculates health factor changes correctly', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction('rETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(1) + + await depositDialog.expectRiskLevelBefore('Healthy') + await depositDialog.expectHealthFactorBefore(expectedInitialHealthFactor) + await depositDialog.expectRiskLevelAfter('Healthy') + await depositDialog.expectHealthFactorAfter(expectedHealthFactor) + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(depositDialog.getDialog(), 'deposit-dialog-health-factor') + }) + + test('after deposit, health factor matches dashboard', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction('rETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(1) + + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + + await depositDialog.viewInDashboardAction() + await dashboardPage.expectHealthFactor(expectedHealthFactor) + }) + + test('has correct action plan for erc-20 with permit support', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + + await dashboardPage.clickDepositButtonAction('wstETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(1) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'permit', + asset: 'wstETH', + amount: 1, + }, + { + type: 'deposit', + asset: 'wstETH', + amount: 1, + }, + ], + true, + ) + }) + + test('can switch to approves in action plan', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + + await dashboardPage.clickDepositButtonAction('wstETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(1) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.switchPreferPermitsAction() + await actionsContainer.expectActions( + [ + { + type: 'approve', + asset: 'wstETH', + amount: 1, + }, + { + type: 'deposit', + asset: 'wstETH', + amount: 1, + }, + ], + true, + ) + }) + + test('has correct action plan for erc-20 with no permit support', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + + await dashboardPage.clickDepositButtonAction('rETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(1) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'approve', + asset: 'rETH', + amount: 1, + }, + { + type: 'deposit', + asset: 'rETH', + amount: 1, + }, + ], + true, + ) + }) + + test('can deposit erc-20 using permits', async ({ page }) => { + const deposit = { + asset: 'wstETH', + amount: 1, + } + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + + await dashboardPage.clickDepositButtonAction(deposit.asset) + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(deposit.amount) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await depositDialog.expectSuccessPage([deposit], fork) + + await screenshot(depositDialog.getDialog(), 'deposit-dialog-wsteth-success') + + await depositDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + ...initialDeposits, + wstETH: initialDeposits.wstETH + 1, + }) + }) + + test('can deposit erc-20 using approves', async ({ page }) => { + const deposit = { + asset: 'rETH', + amount: 1, + } + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + + await dashboardPage.clickDepositButtonAction(deposit.asset) + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(deposit.amount) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await depositDialog.expectSuccessPage([deposit], fork) + + await screenshot(depositDialog.getDialog(), 'deposit-dialog-reth-success') + + await depositDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + ...initialDeposits, + rETH: initialDeposits.rETH + 1, + }) + }) + + test('has correct action plan for native asset', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + + await dashboardPage.clickDepositButtonAction('WETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.selectAssetAction('ETH') + await depositDialog.fillAmountAction(1) + await depositDialog.expectHealthFactorVisible() + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'deposit', + asset: 'ETH', + amount: 1, + }, + ], + true, + ) + + await screenshot(depositDialog.getDialog(), 'deposit-dialog-eth-action-plan') + }) + + test('can deposit native asset', async ({ page }) => { + const deposit = { + asset: 'WETH', + amount: 1, + } + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + + await dashboardPage.clickDepositButtonAction(deposit.asset) + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.selectAssetAction('ETH') + await depositDialog.fillAmountAction(deposit.amount) + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await depositDialog.expectSuccessPage( + [ + { + asset: 'ETH', + amount: deposit.amount, + }, + ], + fork, + ) + await screenshot(depositDialog.getDialog(), 'deposit-dialog-eth-success') + + await depositDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + ...initialDeposits, + // @todo Figure out how WETH and ETH conversion should work + WETH: 1, + }) + }) + + test("can't deposit more than wallet balance", async ({ page }) => { + const depositAsset = 'rETH' + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction(depositAsset) + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(initialBalances[depositAsset] - initialDeposits[depositAsset] + 1) + + await depositDialog.expectAssetInputError('Exceeds your balance') + await depositDialog.expectHealthFactorBeforeVisible() + await screenshot(depositDialog.getDialog(), 'deposit-dialog-exceeds-balance') + }) + + test('requires new approve when the input value is increased', async ({ page }) => { + const depositAsset = 'rETH' + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction(depositAsset) + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(1) + + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptNextActionAction() + + await actionsContainer.expectNextAction( + { + type: 'deposit', + asset: depositAsset, + amount: 1, + }, + true, + ) + + await depositDialog.fillAmountAction(2) + + await actionsContainer.expectNextAction( + { + type: 'approve', + asset: depositAsset, + amount: 2, + }, + true, + ) + }) + + test('requires new permit when the input value is changed', async ({ page }) => { + const depositAsset = 'wstETH' + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction(depositAsset) + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(2) + + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptNextActionAction() + + await actionsContainer.expectNextAction( + { + type: 'deposit', + asset: depositAsset, + amount: 2, + }, + true, + ) + + await depositDialog.fillAmountAction(1) + + await actionsContainer.expectNextAction( + { + type: 'permit', + asset: depositAsset, + amount: 1, + }, + true, + ) + }) + }) + + test.describe('Position with only deposit', () => { + const initialDeposits = { + wstETH: 10, + rETH: 2, + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + // to simulate a position with only deposits, we go through the easy borrow flow + // but interrupt it before the borrow action, going directly to the dashboard + // this way we have deposit transactions executed, but no borrow transaction + // resulting in a position with only deposits + await borrowPage.fillDepositAssetAction(0, 'wstETH', initialDeposits.wstETH) + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillBorrowAssetAction(1) // doesn't matter, we're not borrowing anything + await borrowPage.fillDepositAssetAction(1, 'rETH', initialDeposits.rETH) + await borrowPage.submitAction() + + const actionsContainer = new ActionsPageObject(page) + for (let i = 0; i < 4; i++) { + await actionsContainer.acceptNextActionAction() + } + await actionsContainer.expectNextActionEnabled() + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + }) + + test('does not display health factor', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction('rETH') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.fillAmountAction(1) + + await depositDialog.expectHealthFactorNotVisible() + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(depositDialog.getDialog(), 'deposit-dialog-only-deposit-health-factor') + }) + }) + + test.describe('No position', () => { + const fork = setupFork(19588510n) // block number with WBTC supply close to cap + + test('can deposit up to max cap', async ({ page }) => { + const initialBalances = { + ETH: 1, + WBTC: 1000, + } + + await setup(page, fork, { + initialPage: 'dashboard', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickDepositButtonAction('WBTC') + + const depositDialog = new DialogPageObject(page, headerRegExp) + await depositDialog.clickMaxAmountAction() + await publicTenderlyActions.evmIncreaseTime(fork.forkUrl, 5 * 60) + + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await depositDialog.expectSuccessPage( + [ + { + asset: 'WBTC', + amount: 507.527309, + }, + ], + fork, + ) + + await depositDialog.viewInDashboardAction() + await dashboardPage.expectDepositTable({ + WBTC: 507.527309, + }) + }) + }) +}) diff --git a/packages/app/src/features/dialogs/deposit/DepositDialog.tsx b/packages/app/src/features/dialogs/deposit/DepositDialog.tsx new file mode 100644 index 000000000..421cb966e --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/DepositDialog.tsx @@ -0,0 +1,14 @@ +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { DialogProps } from '../common/types' +import { DepositDialogContentContainer } from './DepositDialogContentContainer' + +export function DepositDialog({ token, open, setOpen }: DialogProps) { + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/packages/app/src/features/dialogs/deposit/DepositDialogContentContainer.tsx b/packages/app/src/features/dialogs/deposit/DepositDialogContentContainer.tsx new file mode 100644 index 000000000..155c142c9 --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/DepositDialogContentContainer.tsx @@ -0,0 +1,49 @@ +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../common/components/skeletons/DialogContentSkeleton' +import { DialogContentContainerProps } from '../common/types' +import { SuccessView } from '../common/views/SuccessView' +import { useDepositDialog } from './logic/useDepositDialog' +import { DepositView } from './views/DepositView' + +function DepositDialogContentContainer({ token, closeDialog }: DialogContentContainerProps) { + const { + objectives, + depositableAssets, + assetsToDepositFields, + pageStatus, + form, + tokenToDeposit, + currentPositionOverview, + updatedPositionOverview, + } = useDepositDialog({ + initialToken: token, + }) + + if (pageStatus.state === 'success') { + return ( + + ) + } + + return ( + + ) +} + +const DepositDialogContentContainerWithSuspense = withSuspense(DepositDialogContentContainer, DialogContentSkeleton) +export { DepositDialogContentContainerWithSuspense as DepositDialogContentContainer } diff --git a/packages/app/src/features/dialogs/deposit/components/DepositOverviewPanel.tsx b/packages/app/src/features/dialogs/deposit/components/DepositOverviewPanel.tsx new file mode 100644 index 000000000..70d4a7721 --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/components/DepositOverviewPanel.tsx @@ -0,0 +1,30 @@ +import { formatPercentage } from '@/domain/common/format' +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogPanelTitle } from '@/features/dialogs/common/components/DialogPanelTitle' +import { TransactionOverviewDetailsItem } from '@/features/dialogs/common/components/TransactionOverviewDetailsItem' + +import { HealthFactorChange } from '../../common/components/HealthFactorChange' +import { collateralTypeToDescription } from '../logic/collateralization' +import { PositionOverview } from '../logic/types' + +export interface DepositOverviewPanelProps { + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} +export function DepositOverviewPanel({ currentPositionOverview, updatedPositionOverview }: DepositOverviewPanelProps) { + return ( + + Transaction overview + + {formatPercentage(currentPositionOverview.supplyAPY)} + + + {collateralTypeToDescription(currentPositionOverview.collateralization)} + + + + ) +} diff --git a/packages/app/src/features/dialogs/deposit/logic/assets.ts b/packages/app/src/features/dialogs/deposit/logic/assets.ts new file mode 100644 index 000000000..f7fb9d261 --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/logic/assets.ts @@ -0,0 +1,65 @@ +import invariant from 'tiny-invariant' + +import { NativeAssetInfo } from '@/config/chain/types' +import { TokenWithBalance } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { Token } from '@/domain/types/Token' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { applyTransformers } from '@/utils/applyTransformers' + +export interface GetDepositOptionsParams { + token: Token + marketInfo: MarketInfo + walletInfo: WalletInfo + nativeAssetInfo: NativeAssetInfo +} + +export function getDepositOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetDepositOptionsParams): TokenWithBalance[] { + const options = applyTransformers({ token, marketInfo, walletInfo, nativeAssetInfo })([ + getNativeAssetDepositOptions, + getDefaultDepositOptions, + ]) + invariant(options, `No deposit options found for token ${token.symbol}`) + + return options +} + +function getNativeAssetDepositOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetDepositOptionsParams): TokenWithBalance[] | undefined { + const { nativeAssetSymbol, wrappedNativeAssetSymbol } = nativeAssetInfo + + if (token.symbol !== nativeAssetSymbol && token.symbol !== wrappedNativeAssetSymbol) { + return undefined + } + const native = marketInfo.findOneReserveBySymbol(nativeAssetSymbol) + const wrapped = marketInfo.findOneReserveBySymbol(wrappedNativeAssetSymbol) + + return [ + { + token: native.token, + balance: walletInfo.findWalletBalanceForToken(native.token), + }, + { + token: wrapped.token, + balance: walletInfo.findWalletBalanceForToken(wrapped.token), + }, + ] +} + +function getDefaultDepositOptions({ token, walletInfo }: GetDepositOptionsParams): TokenWithBalance[] { + return [ + { + token, + balance: walletInfo.findWalletBalanceForToken(token), + }, + ] +} diff --git a/packages/app/src/features/dialogs/deposit/logic/collateralization.ts b/packages/app/src/features/dialogs/deposit/logic/collateralization.ts new file mode 100644 index 000000000..06e18ec7f --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/logic/collateralization.ts @@ -0,0 +1,72 @@ +import { UserConfiguration, UserPosition, UserPositionSummary } from '@/domain/market-info/marketInfo' + +export type CollateralType = 'disabled' | 'enabled' | 'unavailable' | 'isolated_enabled' | 'isolated_disabled' + +export function collateralTypeToDescription(type: CollateralType): string { + switch (type) { + case 'disabled': + return 'Disabled' + case 'enabled': + return 'Enabled' + case 'unavailable': + return 'Unavailable' + case 'isolated_enabled': + return 'Enabled (Isolated)' + case 'isolated_disabled': + return 'Disabled (Isolated)' + } +} + +export interface GetCollateralTypeArgs { + position: UserPosition + summary: UserPositionSummary + userConfiguration: UserConfiguration +} + +export function getCollateralType({ position, summary, userConfiguration }: GetCollateralTypeArgs): CollateralType { + let willBeUsedAsCollateral: CollateralType = 'enabled' + const userHasSuppliedReserve = !position.scaledATokenBalance.eq(0) + const userHasCollateral = !summary.totalCollateralUSD.gt(0) + + if (!position.reserve.usageAsCollateralEnabled) { + willBeUsedAsCollateral = 'disabled' + } else if (position.reserve.isIsolated) { + // Note: is debt ceiling only used for isolated assets? + const debtCeiling = position.reserve.debtCeiling + const isolationModeTotalDebt = position.reserve.isolationModeTotalDebt + const debtCeilingUsage = isolationModeTotalDebt.dividedBy(debtCeiling).multipliedBy(100) + const isMaxed = debtCeiling.eq(0) ? false : debtCeilingUsage.gte(99.99) + + if (isMaxed) { + willBeUsedAsCollateral = 'unavailable' + } else if (userConfiguration.isolationModeState.enabled) { + if (userHasSuppliedReserve) { + willBeUsedAsCollateral = position.reserve.usageAsCollateralEnabledOnUser + ? 'isolated_enabled' + : 'isolated_disabled' + } else { + if (userHasCollateral) { + willBeUsedAsCollateral = 'isolated_disabled' + } + } + } else { + if (userHasCollateral) { + willBeUsedAsCollateral = 'isolated_disabled' + } else { + willBeUsedAsCollateral = 'isolated_enabled' + } + } + } else { + if (userConfiguration.isolationModeState.enabled) { + willBeUsedAsCollateral = 'disabled' + } else { + if (userHasSuppliedReserve) { + willBeUsedAsCollateral = position.reserve.usageAsCollateralEnabledOnUser ? 'enabled' : 'disabled' + } else { + willBeUsedAsCollateral = 'enabled' + } + } + } + + return willBeUsedAsCollateral +} diff --git a/packages/app/src/features/dialogs/deposit/logic/form.ts b/packages/app/src/features/dialogs/deposit/logic/form.ts new file mode 100644 index 000000000..36bf296d1 --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/logic/form.ts @@ -0,0 +1,86 @@ +import { UseFormReturn } from 'react-hook-form' +import { z } from 'zod' + +import { getDepositMaxValue } from '@/domain/action-max-value-getters/getDepositMaxValue' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { depositValidationIssueToMessage, validateDeposit } from '@/domain/market-validators/validateDeposit' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +import { AssetInputSchema } from '../../common/logic/form' +import { FormFieldsForDialog } from '../../common/types' + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getDepositDialogFormValidator(walletInfo: WalletInfo, marketInfo: MarketInfo) { + return AssetInputSchema.superRefine((field, ctx) => { + const value = NormalizedUnitNumber(field.value === '' ? '0' : field.value) + const balance = walletInfo.findWalletBalanceForSymbol(field.symbol) + const supplyingReserve = marketInfo.findOneReserveBySymbol(field.symbol) + + const issue = validateDeposit({ + value, + asset: { + status: supplyingReserve.status, + totalLiquidity: supplyingReserve.totalLiquidity, + supplyCap: supplyingReserve.supplyCap, + }, + user: { balance, alreadyDepositedValueUSD: NormalizedUnitNumber('0') }, + }) + if (issue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: depositValidationIssueToMessage[issue], + path: ['value'], + }) + } + }) +} + +export interface GetFormFieldsForDepositDialogArgs { + form: UseFormReturn + marketInfo: MarketInfo + walletInfo: WalletInfo +} +export function getFormFieldsForDepositDialog({ + form, + marketInfo, + walletInfo, +}: GetFormFieldsForDepositDialogArgs): FormFieldsForDialog { + // eslint-disable-next-line func-style + const changeAsset = (newSymbol: TokenSymbol): void => { + form.setValue('symbol', newSymbol) + form.setValue('value', '') + form.clearErrors() + } + + const { symbol, value } = form.getValues() + const position = marketInfo.findOnePositionBySymbol(symbol) + + const maxValue = getDepositMaxValue({ + asset: { + status: position.reserve.status, + totalDebt: position.reserve.totalDebt, + decimals: position.reserve.token.decimals, + index: position.reserve.variableBorrowIndex, + rate: position.reserve.variableBorrowRate, + lastUpdateTimestamp: position.reserve.lastUpdateTimestamp, + totalLiquidity: position.reserve.totalLiquidity, + supplyCap: position.reserve.supplyCap, + }, + user: { + balance: walletInfo.findWalletBalanceForSymbol(symbol), + }, + timestamp: marketInfo.timestamp, + }) + + return { + selectedAsset: { + token: marketInfo.findOneTokenBySymbol(symbol), + value, + balance: walletInfo.findWalletBalanceForSymbol(symbol), + }, + changeAsset, + maxValue, + } +} diff --git a/packages/app/src/features/dialogs/deposit/logic/types.ts b/packages/app/src/features/dialogs/deposit/logic/types.ts new file mode 100644 index 000000000..61576b48e --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/logic/types.ts @@ -0,0 +1,11 @@ +import BigNumber from 'bignumber.js' + +import { Percentage } from '@/domain/types/NumericValues' + +import { CollateralType } from './collateralization' + +export interface PositionOverview { + healthFactor: BigNumber | undefined + collateralization: CollateralType + supplyAPY: Percentage +} diff --git a/packages/app/src/features/dialogs/deposit/logic/useCreateObjectives.ts b/packages/app/src/features/dialogs/deposit/logic/useCreateObjectives.ts new file mode 100644 index 000000000..0cbc122e1 --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/logic/useCreateObjectives.ts @@ -0,0 +1,17 @@ +import { lendingPoolAddress } from '@/config/contracts-generated' +import { useContractAddress } from '@/domain/hooks/useContractAddress' +import { Objective } from '@/features/actions/logic/types' + +import { DialogFormNormalizedData } from '../../common/logic/form' + +export function useCreateObjectives(formValues: DialogFormNormalizedData): Objective[] { + const lendingPool = useContractAddress(lendingPoolAddress) + return [ + { + type: 'deposit', + token: formValues.reserve.token, + value: formValues.value, + lendingPool, + }, + ] +} diff --git a/packages/app/src/features/dialogs/deposit/logic/useDepositDialog.ts b/packages/app/src/features/dialogs/deposit/logic/useDepositDialog.ts new file mode 100644 index 000000000..45159d7cc --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/logic/useDepositDialog.ts @@ -0,0 +1,107 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { useAaveDataLayer } from '@/domain/market-info/aave-data-layer/useAaveDataLayer' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { Token } from '@/domain/types/Token' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { Objective } from '@/features/actions/logic/types' + +import { AssetInputSchema, normalizeDialogFormValues } from '../../common/logic/form' +import { FormFieldsForDialog, PageState, PageStatus } from '../../common/types' +import { getDepositOptions } from './assets' +import { getCollateralType } from './collateralization' +import { getDepositDialogFormValidator, getFormFieldsForDepositDialog } from './form' +import { PositionOverview } from './types' +import { useCreateObjectives } from './useCreateObjectives' + +export interface UseDepositDialogOptions { + initialToken: Token +} + +export interface UseDepositDialogResult { + depositableAssets: TokenWithBalance[] + assetsToDepositFields: FormFieldsForDialog + tokenToDeposit: TokenWithValue + objectives: Objective[] + pageStatus: PageStatus + form: UseFormReturn + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +export function useDepositDialog({ initialToken }: UseDepositDialogOptions): UseDepositDialogResult { + const { aaveData } = useAaveDataLayer() + const { marketInfo } = useMarketInfo() + const walletInfo = useWalletInfo() + const nativeAssetInfo = getNativeAssetInfo(marketInfo.chainId) + + const [pageStatus, setPageStatus] = useState('form') + + const form = useForm({ + resolver: zodResolver(getDepositDialogFormValidator(walletInfo, marketInfo)), + defaultValues: { + symbol: initialToken.symbol, + value: '', + }, + mode: 'onChange', + }) + const assetsToDepositFields = getFormFieldsForDepositDialog({ + form, + marketInfo, + walletInfo, + }) + const tokenToDeposit = normalizeDialogFormValues(form.watch(), marketInfo) + + const depositableAssets = getDepositOptions({ + token: initialToken, + marketInfo, + walletInfo, + nativeAssetInfo, + }) + + const collateralType = getCollateralType({ + position: tokenToDeposit.position, + summary: marketInfo.userPositionSummary, + userConfiguration: marketInfo.userConfiguration, + }) + + const currentPositionOverview = { + healthFactor: marketInfo.userPositionSummary.healthFactor, + collateralization: collateralType, + supplyAPY: tokenToDeposit.reserve.supplyAPY, + } + const updatedUserSummary = updatePositionSummary({ + deposits: [tokenToDeposit], + marketInfo, + aaveData, + nativeAssetInfo, + }) + const updatedPositionOverview = tokenToDeposit.value.eq(0) + ? undefined + : { + ...currentPositionOverview, + healthFactor: updatedUserSummary.healthFactor, + } + + const objectives = useCreateObjectives(tokenToDeposit) + + return { + depositableAssets, + assetsToDepositFields, + tokenToDeposit, + objectives, + pageStatus: { + state: pageStatus, + actionsEnabled: tokenToDeposit.value.gt(0) && form.formState.isValid, + goToSuccessScreen: () => setPageStatus('success'), + }, + form, + currentPositionOverview, + updatedPositionOverview, + } +} diff --git a/packages/app/src/features/dialogs/deposit/views/DepositView.stories.tsx b/packages/app/src/features/dialogs/deposit/views/DepositView.stories.tsx new file mode 100644 index 000000000..9118915ca --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/views/DepositView.stories.tsx @@ -0,0 +1,89 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { chromatic } from '@storybook/viewports' +import BigNumber from 'bignumber.js' +import { useForm } from 'react-hook-form' +import { zeroAddress } from 'viem' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { DepositView } from './DepositView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Deposit', + component: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm() as any + return + }, + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider()], + args: { + initialToken: tokens['USDC'], + selectableAssets: [ + { + token: tokens['USDC'], + balance: NormalizedUnitNumber(50000), + }, + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(1), + }, + ], + assetsFields: { + selectedAsset: { + token: tokens['USDC'], + balance: NormalizedUnitNumber(50000), + value: '2000', + }, + changeAsset: () => {}, + }, + objectives: [ + { + type: 'deposit', + token: tokens['USDC'], + value: NormalizedUnitNumber(50000), + lendingPool: CheckedAddress(zeroAddress), + }, + ], + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + currentPositionOverview: { + healthFactor: new BigNumber(1.5), + collateralization: 'enabled', + supplyAPY: Percentage(0.04), + }, + updatedPositionOverview: { + healthFactor: new BigNumber(2.3), + collateralization: 'enabled', + supplyAPY: Percentage(0.04), + }, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} + +export const Tablet: Story = { + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + chromatic: { viewports: [chromatic.tablet] }, + }, +} diff --git a/packages/app/src/features/dialogs/deposit/views/DepositView.tsx b/packages/app/src/features/dialogs/deposit/views/DepositView.tsx new file mode 100644 index 000000000..fded0f9d6 --- /dev/null +++ b/packages/app/src/features/dialogs/deposit/views/DepositView.tsx @@ -0,0 +1,57 @@ +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { Token } from '@/domain/types/Token' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { DialogForm } from '@/features/dialogs/common/components/form/DialogForm' +import { FormAndOverviewWrapper } from '@/features/dialogs/common/components/FormAndOverviewWrapper' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' + +import { DepositOverviewPanel } from '../components/DepositOverviewPanel' +import { PositionOverview } from '../logic/types' + +export interface DepositViewProps { + initialToken: Token + selectableAssets: TokenWithBalance[] + assetsFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + pageStatus: PageStatus + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +export function DepositView({ + initialToken, + selectableAssets, + assetsFields, + form, + objectives, + pageStatus, + currentPositionOverview, + updatedPositionOverview, +}: DepositViewProps) { + return ( + + {`Deposit ${initialToken.symbol}`} + + + + + + + + + ) +} diff --git a/packages/app/src/features/dialogs/dispatcher/DialogDispatcherContainer.tsx b/packages/app/src/features/dialogs/dispatcher/DialogDispatcherContainer.tsx new file mode 100644 index 000000000..c8446d3f5 --- /dev/null +++ b/packages/app/src/features/dialogs/dispatcher/DialogDispatcherContainer.tsx @@ -0,0 +1,18 @@ +import { useStore } from '@/domain/state' +import { useCloseDialog } from '@/domain/state/dialogs' + +import { CommonDialogProps } from '../common/types' + +export function DialogDispatcherContainer() { + const openedDialog = useStore((state) => state.dialogs.openedDialog) + const closeDialog = useCloseDialog() + + if (!openedDialog) { + return null + } + + const { element, props } = openedDialog + const Element = element as React.ComponentType + + return closeDialog()} {...props} /> +} diff --git a/packages/app/src/features/dialogs/e-mode/EModeDialog.tsx b/packages/app/src/features/dialogs/e-mode/EModeDialog.tsx new file mode 100644 index 000000000..0dfa2a8d7 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/EModeDialog.tsx @@ -0,0 +1,19 @@ +import { EModeCategoryId } from '@/domain/e-mode/types' +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { CommonDialogProps } from '../common/types' +import { EModeDialogContentContainer } from './EModeDialogContentContainer' + +interface EModeDialogProps extends CommonDialogProps { + userEModeCategoryId: EModeCategoryId +} + +export function EModeDialog({ open, setOpen, userEModeCategoryId }: EModeDialogProps) { + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/packages/app/src/features/dialogs/e-mode/EModeDialogContentContainer.tsx b/packages/app/src/features/dialogs/e-mode/EModeDialogContentContainer.tsx new file mode 100644 index 000000000..bee989da3 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/EModeDialogContentContainer.tsx @@ -0,0 +1,43 @@ +import { EModeCategoryId } from '@/domain/e-mode/types' +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../common/components/skeletons/DialogContentSkeleton' +import { useEModeDialog } from './logic/useEModeDialog' +import { EModeView } from './views/EModeView' +import { SuccessView } from './views/SuccessView' + +interface EModeDialogContentContainerProps { + closeDialog: () => void + userEModeCategoryId: EModeCategoryId +} + +function EModeDialogContentContainer({ closeDialog, userEModeCategoryId }: EModeDialogContentContainerProps) { + const { + pageStatus, + selectedEModeCategoryName, + actions, + currentPositionOverview, + updatedPositionOverview, + eModeCategories, + validationIssue, + } = useEModeDialog({ userEModeCategoryId }) + + if (pageStatus.state === 'success') { + return + } + + return ( + + ) +} + +const EModeDialogContentContainerWithSuspense = withSuspense(EModeDialogContentContainer, DialogContentSkeleton) +export { EModeDialogContentContainerWithSuspense as EModeDialogContentContainer } diff --git a/packages/app/src/features/dialogs/e-mode/components/AvailableAssets.tsx b/packages/app/src/features/dialogs/e-mode/components/AvailableAssets.tsx new file mode 100644 index 000000000..e692b5c46 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/components/AvailableAssets.tsx @@ -0,0 +1,26 @@ +import { EModeCategoryName } from '@/domain/e-mode/types' +import { Token } from '@/domain/types/Token' +import { TransactionOverviewDetailsItem } from '@/features/dialogs/common/components/TransactionOverviewDetailsItem' +import { assets } from '@/ui/assets' + +export interface AvailableAssets { + categoryName: EModeCategoryName + tokens: Token[] +} +export function AvailableAssets({ categoryName, tokens }: AvailableAssets) { + if (categoryName === 'No E-Mode') { + return All assets + } + + return ( + +
+
+ {categoryName} + +
+ {tokens.map((token) => token.symbol).join(', ')} +
+
+ ) +} diff --git a/packages/app/src/features/dialogs/e-mode/components/CategoriesGrid.tsx b/packages/app/src/features/dialogs/e-mode/components/CategoriesGrid.tsx new file mode 100644 index 000000000..af8e53077 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/components/CategoriesGrid.tsx @@ -0,0 +1,14 @@ +import { ReactNode } from 'react' + +interface CategoriesGridProps { + children: ReactNode +} + +export function CategoriesGrid({ children }: CategoriesGridProps) { + return ( +
+

Category

+
{children}
+
+ ) +} diff --git a/packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.stories.ts b/packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.stories.ts new file mode 100644 index 000000000..4bca51b25 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.stories.ts @@ -0,0 +1,105 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getHoveredStory } from '@storybook/utils' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { EModeCategoryTile } from './EModeCategoryTile' + +const meta: Meta = { + title: 'Features/Dialogs/Components/EModeCategoryTile', + component: EModeCategoryTile, + decorators: [WithClassname('w-44')], +} + +export default meta +type Story = StoryObj + +export const ETHCorrelated: Story = { + args: { + eModeCategory: { + name: 'ETH Correlated', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH], + isActive: false, + isSelected: false, + onSelect: () => {}, + }, + }, +} + +export const ETHCorrelatedSelected: Story = { + args: { + eModeCategory: { + name: 'ETH Correlated', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH], + isActive: false, + isSelected: true, + onSelect: () => {}, + }, + }, +} + +export const ETHCorrelatedActive: Story = { + args: { + eModeCategory: { + name: 'ETH Correlated', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH], + isActive: true, + isSelected: false, + onSelect: () => {}, + }, + }, +} + +export const ETHCorrelatedHovered = getHoveredStory(ETHCorrelated, 'button') + +export const Stablecoins: Story = { + args: { + eModeCategory: { + name: 'Stablecoins', + tokens: [tokens.DAI, tokens.USDC, tokens.USDT], + isActive: false, + isSelected: false, + onSelect: () => {}, + }, + }, +} + +export const StablecoinsActive: Story = { + args: { + eModeCategory: { + name: 'Stablecoins', + tokens: [tokens.DAI, tokens.USDC, tokens.USDT], + isActive: true, + isSelected: false, + onSelect: () => {}, + }, + }, +} + +export const NoCategory: Story = { + args: { + eModeCategory: { + name: 'No E-Mode', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH, tokens.DAI, tokens.USDC, tokens.USDT], + isActive: false, + isSelected: false, + onSelect: () => {}, + }, + }, +} + +export const NoCategoryActive: Story = { + args: { + eModeCategory: { + name: 'No E-Mode', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH, tokens.DAI, tokens.USDC, tokens.USDT], + isActive: true, + isSelected: false, + onSelect: () => {}, + }, + }, +} + +export const Mobile = getMobileStory(ETHCorrelated) +export const Tablet = getTabletStory(ETHCorrelated) diff --git a/packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.tsx b/packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.tsx new file mode 100644 index 000000000..8527c1db3 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/components/EModeCategoryTile.tsx @@ -0,0 +1,58 @@ +import { cva } from 'class-variance-authority' + +import { getTokenImage } from '@/ui/assets' +import { IconStack } from '@/ui/molecules/icon-stack/IconStack' +import { cn } from '@/ui/utils/style' + +import { EModeCategory } from '../types' + +interface EModeCategoryTileProps { + eModeCategory: EModeCategory +} + +export function EModeCategoryTile({ eModeCategory }: EModeCategoryTileProps) { + if (eModeCategory.tokens.length === 0) { + return null + } + + const iconPaths = eModeCategory.tokens.map(({ symbol }) => getTokenImage(symbol)) + const variant = eModeCategory.isActive ? 'active' : 'inactive' + + return ( + + ) +} + +function ActivityBadge({ variant }: { variant: 'active' | 'inactive' }) { + return
{variant === 'active' ? 'Active' : 'Inactive'}
+} + +const activityBadgeVariants = cva('rounded-lg px-2.5 py-1.5 text-xs font-semibold leading-none tracking-wide', { + variants: { + variant: { + inactive: 'bg-basics-dark-grey/10 text-basics-dark-grey', + active: 'bg-basics-green/10 text-basics-green', + }, + }, +}) + +const headerVariants = cva('text-xs font-semibold sm:text-base', { + variants: { + variant: { + active: 'text-basics-black', + inactive: 'text-basics-dark-grey', + }, + }, +}) diff --git a/packages/app/src/features/dialogs/e-mode/components/EModeOverviewPanel.tsx b/packages/app/src/features/dialogs/e-mode/components/EModeOverviewPanel.tsx new file mode 100644 index 000000000..64fd3817e --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/components/EModeOverviewPanel.tsx @@ -0,0 +1,30 @@ +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogPanelTitle } from '@/features/dialogs/common/components/DialogPanelTitle' + +import { HealthFactorChange } from '../../common/components/HealthFactorChange' +import { EModeCategory, PositionOverview } from '../types' +import { AvailableAssets } from './AvailableAssets' +import { LTVChange } from './LTVChange' + +export interface EModeOverviewPanelProps { + eModeCategory: EModeCategory + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} +export function EModeOverviewPanel({ + eModeCategory, + currentPositionOverview, + updatedPositionOverview, +}: EModeOverviewPanelProps) { + return ( + + Transaction overview + + + + + ) +} diff --git a/packages/app/src/features/dialogs/e-mode/components/LTVChange.tsx b/packages/app/src/features/dialogs/e-mode/components/LTVChange.tsx new file mode 100644 index 000000000..b84801094 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/components/LTVChange.tsx @@ -0,0 +1,30 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { assets } from '@/ui/assets' + +import { TransactionOverviewDetailsItem } from '../../common/components/TransactionOverviewDetailsItem' + +interface LTVChangeProps { + currentMaxLTV: Percentage + updatedMaxLTV?: Percentage +} + +export function LTVChange({ currentMaxLTV, updatedMaxLTV }: LTVChangeProps) { + if (!updatedMaxLTV) { + return ( + + {formatPercentage(currentMaxLTV)} + + ) + } + + return ( + +
+ {formatPercentage(currentMaxLTV)} + + {formatPercentage(updatedMaxLTV)} +
+
+ ) +} diff --git a/packages/app/src/features/dialogs/e-mode/logic/createEModeObjectives.ts b/packages/app/src/features/dialogs/e-mode/logic/createEModeObjectives.ts new file mode 100644 index 000000000..8558c1bd4 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/logic/createEModeObjectives.ts @@ -0,0 +1,17 @@ +import { EModeCategoryId } from '@/domain/e-mode/types' +import { Objective } from '@/features/actions/logic/types' + +export function createEModeObjectives( + userEModeCategoryId: EModeCategoryId, + selectedEModeCategoryId: EModeCategoryId, +): Objective[] | undefined { + if (userEModeCategoryId === selectedEModeCategoryId) { + return undefined + } + return [ + { + type: 'setUserEMode', + eModeCategoryId: selectedEModeCategoryId, + }, + ] +} diff --git a/packages/app/src/features/dialogs/e-mode/logic/getEModeCategories.ts b/packages/app/src/features/dialogs/e-mode/logic/getEModeCategories.ts new file mode 100644 index 000000000..4e990e947 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/logic/getEModeCategories.ts @@ -0,0 +1,35 @@ +import { eModeCategoryIdToName } from '@/domain/e-mode/constants' +import { EModeCategoryId, EModeCategoryName } from '@/domain/e-mode/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' + +import { EModeCategory } from '../types' + +export function getEModeCategories( + marketInfo: MarketInfo, + selectedEModeCategoryId: EModeCategoryId, + setSelectedEModeCategoryId: (id: EModeCategoryId) => void, +): Record { + const reserves = marketInfo.userPositions.map((position) => position.reserve) + + const currentEModeCategoryId = marketInfo.userConfiguration.eModeState.enabled + ? marketInfo.userConfiguration.eModeState.category.id + : 0 + + function getEModeCategory(eModeCategoryId: EModeCategoryId): EModeCategory { + return { + name: eModeCategoryIdToName[eModeCategoryId], + tokens: reserves + .filter((reserve) => (eModeCategoryId === 0 ? true : eModeCategoryId === reserve.eModeCategory?.id)) + .map((reserve) => reserve.token), + isActive: currentEModeCategoryId === eModeCategoryId, + isSelected: selectedEModeCategoryId === eModeCategoryId, + onSelect: () => setSelectedEModeCategoryId(eModeCategoryId), + } + } + + return { + 'No E-Mode': getEModeCategory(0), + 'ETH Correlated': getEModeCategory(1), + Stablecoins: getEModeCategory(2), + } +} diff --git a/packages/app/src/features/dialogs/e-mode/logic/getUpdatedPositionOverview.ts b/packages/app/src/features/dialogs/e-mode/logic/getUpdatedPositionOverview.ts new file mode 100644 index 000000000..055a3f6fc --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/logic/getUpdatedPositionOverview.ts @@ -0,0 +1,39 @@ +import { NativeAssetInfo } from '@/config/chain/types' +import { EModeCategoryId } from '@/domain/e-mode/types' +import { AaveData } from '@/domain/market-info/aave-data-layer/query' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' + +import { PositionOverview } from '../types' + +export interface GetUpdatedPositionOverviewParams { + marketInfo: MarketInfo + aaveData: AaveData + selectedEModeCategoryId: EModeCategoryId + currentEModeCategoryId: EModeCategoryId + nativeAssetInfo: NativeAssetInfo +} + +export function getUpdatedPositionOverview({ + marketInfo, + aaveData, + currentEModeCategoryId, + selectedEModeCategoryId, + nativeAssetInfo, +}: GetUpdatedPositionOverviewParams): PositionOverview | undefined { + if (currentEModeCategoryId === selectedEModeCategoryId) { + return undefined + } + + const { healthFactor, maxLoanToValue: maxLTV } = updatePositionSummary({ + marketInfo, + aaveData, + eModeCategoryId: selectedEModeCategoryId, + nativeAssetInfo, + }) + + return { + healthFactor, + maxLTV, + } +} diff --git a/packages/app/src/features/dialogs/e-mode/logic/useEModeDialog.ts b/packages/app/src/features/dialogs/e-mode/logic/useEModeDialog.ts new file mode 100644 index 000000000..8c6a298b6 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/logic/useEModeDialog.ts @@ -0,0 +1,83 @@ +import { useState } from 'react' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { eModeCategoryIdToName } from '@/domain/e-mode/constants' +import { EModeCategoryId, EModeCategoryName } from '@/domain/e-mode/types' +import { useAaveDataLayer } from '@/domain/market-info/aave-data-layer/useAaveDataLayer' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { + getValidateSetUserEModeArgs, + SetUserEModeValidationIssue, + validateSetUserEMode, +} from '@/domain/market-validators/validateSetUserEMode' +import { Objective } from '@/features/actions/logic/types' + +import { PageState, PageStatus } from '../../common/types' +import { EModeCategory, PositionOverview } from '../types' +import { createEModeObjectives } from './createEModeObjectives' +import { getEModeCategories } from './getEModeCategories' +import { getUpdatedPositionOverview } from './getUpdatedPositionOverview' + +interface UseEModeDialogParams { + userEModeCategoryId: EModeCategoryId +} + +export interface UseEModeDialogResult { + eModeCategories: Record + selectedEModeCategoryName: EModeCategoryName + actions?: Objective[] + validationIssue?: SetUserEModeValidationIssue + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview + pageStatus: PageStatus +} + +export function useEModeDialog({ userEModeCategoryId }: UseEModeDialogParams): UseEModeDialogResult { + const { aaveData } = useAaveDataLayer() + const { marketInfo } = useMarketInfo() + const nativeAssetInfo = getNativeAssetInfo(marketInfo.chainId) + + const [pageStatus, setPageStatus] = useState('form') + const [selectedEModeCategoryId, setSelectedEModeCategoryId] = useState(userEModeCategoryId) + + const currentPositionOverview = { + healthFactor: marketInfo.userPositionSummary.healthFactor, + maxLTV: marketInfo.userPositionSummary.maxLoanToValue, + } + + const updatedPositionOverview = getUpdatedPositionOverview({ + marketInfo, + aaveData, + currentEModeCategoryId: userEModeCategoryId, + selectedEModeCategoryId, + nativeAssetInfo, + }) + + const eModeCategories = getEModeCategories(marketInfo, selectedEModeCategoryId, setSelectedEModeCategoryId) + + const actions = createEModeObjectives(userEModeCategoryId, selectedEModeCategoryId) + + const validationIssue = updatedPositionOverview + ? validateSetUserEMode( + getValidateSetUserEModeArgs({ + requestedEModeCategoryId: selectedEModeCategoryId, + healthFactorAfterChangingEMode: updatedPositionOverview.healthFactor, + marketInfo, + }), + ) + : undefined + + return { + eModeCategories, + selectedEModeCategoryName: eModeCategoryIdToName[selectedEModeCategoryId], + actions, + validationIssue, + currentPositionOverview, + updatedPositionOverview, + pageStatus: { + state: pageStatus, + actionsEnabled: !validationIssue, + goToSuccessScreen: () => setPageStatus('success'), + }, + } +} diff --git a/packages/app/src/features/dialogs/e-mode/types.ts b/packages/app/src/features/dialogs/e-mode/types.ts new file mode 100644 index 000000000..afd8658f1 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/types.ts @@ -0,0 +1,18 @@ +import BigNumber from 'bignumber.js' + +import { EModeCategoryName } from '@/domain/e-mode/types' +import { Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export interface PositionOverview { + healthFactor: BigNumber | undefined + maxLTV: Percentage +} + +export interface EModeCategory { + name: EModeCategoryName + tokens: Token[] + isSelected: boolean + isActive: boolean + onSelect: () => void +} diff --git a/packages/app/src/features/dialogs/e-mode/views/EModeView.stories.ts b/packages/app/src/features/dialogs/e-mode/views/EModeView.stories.ts new file mode 100644 index 000000000..d7f0c8138 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/views/EModeView.stories.ts @@ -0,0 +1,111 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import BigNumber from 'bignumber.js' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { Percentage } from '@/domain/types/NumericValues' + +import { EModeView } from './EModeView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/EMode', + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider(), withRouter], + component: EModeView, + args: { + eModeCategories: { + 'No E-Mode': { + name: 'No E-Mode', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH, tokens.DAI, tokens.USDC, tokens.USDT], + isActive: true, + isSelected: false, + onSelect: () => {}, + }, + 'ETH Correlated': { + name: 'ETH Correlated', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH], + isActive: false, + isSelected: true, + onSelect: () => {}, + }, + Stablecoins: { + name: 'Stablecoins', + tokens: [tokens.DAI, tokens.USDC, tokens.USDT], + isActive: false, + isSelected: false, + onSelect: () => {}, + }, + }, + selectedEModeCategoryName: 'ETH Correlated', + currentPositionOverview: { + healthFactor: new BigNumber(2.5), + maxLTV: Percentage(0.8), + }, + updatedPositionOverview: { + healthFactor: new BigNumber(3.1), + maxLTV: Percentage(0.9), + }, + objectives: [ + { + type: 'setUserEMode', + eModeCategoryId: 1, + }, + ], + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + }, +} + +export default meta +type Story = StoryObj + +export const SetEMode: Story = { name: 'Set E-Mode' } +export const SetEModeMobile: Story = { name: 'Set E-Mode Mobile', ...getMobileStory(SetEMode) } +export const SetEModeTablet: Story = { name: 'Set E-Mode Tablet', ...getTabletStory(SetEMode) } + +export const ValidationIssue: Story = { + name: 'Validation Issue', + args: { + validationIssue: 'exceeds-ltv', + eModeCategories: { + 'No E-Mode': { + name: 'No E-Mode', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH, tokens.DAI, tokens.USDC, tokens.USDT], + isActive: false, + isSelected: true, + onSelect: () => {}, + }, + 'ETH Correlated': { + name: 'ETH Correlated', + tokens: [tokens.ETH, tokens.rETH, tokens.wstETH], + isActive: true, + isSelected: false, + onSelect: () => {}, + }, + Stablecoins: { + name: 'Stablecoins', + tokens: [tokens.DAI, tokens.USDC, tokens.USDT], + isActive: false, + isSelected: false, + onSelect: () => {}, + }, + }, + currentPositionOverview: { + healthFactor: new BigNumber(2.5), + maxLTV: Percentage(0.9), + }, + updatedPositionOverview: { + healthFactor: new BigNumber(0.8), + maxLTV: Percentage(0.8), + }, + pageStatus: { + state: 'form', + actionsEnabled: false, + goToSuccessScreen: () => {}, + }, + }, +} diff --git a/packages/app/src/features/dialogs/e-mode/views/EModeView.tsx b/packages/app/src/features/dialogs/e-mode/views/EModeView.tsx new file mode 100644 index 000000000..b9a2d9984 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/views/EModeView.tsx @@ -0,0 +1,74 @@ +import { EModeCategoryName } from '@/domain/e-mode/types' +import { + SetUserEModeValidationIssue, + setUserEModeValidationIssueToMessage, +} from '@/domain/market-validators/validateSetUserEMode' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' +import { Link } from '@/ui/atoms/link/Link' +import { links } from '@/ui/constants/links' + +import { Alert } from '../../common/components/alert/Alert' +import { CategoriesGrid } from '../components/CategoriesGrid' +import { EModeCategoryTile } from '../components/EModeCategoryTile' +import { EModeOverviewPanel } from '../components/EModeOverviewPanel' +import { EModeCategory, PositionOverview } from '../types' + +interface EModeViewProps { + eModeCategories: Record + selectedEModeCategoryName: EModeCategoryName + validationIssue?: SetUserEModeValidationIssue + objectives?: Objective[] + pageStatus: PageStatus + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +export function EModeView({ + eModeCategories, + selectedEModeCategoryName, + validationIssue, + objectives, + pageStatus, + currentPositionOverview, + updatedPositionOverview, +}: EModeViewProps) { + return ( + + Set E-Mode Category + +

+ E-Mode allows you to borrow assets belonging to the selected category. Please visit our{' '} + + FAQ guide + {' '} + to learn more about how it works and the applied restrictions. +

+ + + {Object.values(eModeCategories).map((eModeCategory) => ( + + ))} + + + + + {validationIssue && {setUserEModeValidationIssueToMessage[validationIssue]}} + + {objectives && ( + + )} +
+ ) +} diff --git a/packages/app/src/features/dialogs/e-mode/views/SuccessView.stories.ts b/packages/app/src/features/dialogs/e-mode/views/SuccessView.stories.ts new file mode 100644 index 000000000..c17ac5072 --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/views/SuccessView.stories.ts @@ -0,0 +1,28 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { SuccessView } from './SuccessView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Success', + component: SuccessView, + decorators: [WithClassname('max-w-xl')], +} + +export default meta +type Story = StoryObj + +export const DesktopEMode: Story = { + name: 'Desktop E-Mode', + args: { eModeCategoryName: 'Stablecoins' }, +} + +export const MobileEMode: Story = { + name: 'Mobile E-Mode', + ...getMobileStory(DesktopEMode), +} +export const TabletEMode: Story = { + name: 'Tablet E-Mode', + ...getTabletStory(DesktopEMode), +} diff --git a/packages/app/src/features/dialogs/e-mode/views/SuccessView.tsx b/packages/app/src/features/dialogs/e-mode/views/SuccessView.tsx new file mode 100644 index 000000000..a6e8ce09e --- /dev/null +++ b/packages/app/src/features/dialogs/e-mode/views/SuccessView.tsx @@ -0,0 +1,23 @@ +import { EModeCategoryName } from '@/domain/e-mode/types' + +import { SuccessViewCheckmark } from '../../common/components/success-view/SuccessViewCheckmark' +import { SuccessViewContent } from '../../common/components/success-view/SuccessViewContent' +import { SuccessViewProceedButton } from '../../common/components/success-view/SuccessViewProceedButton' +import { SuccessViewSummaryPanel } from '../../common/components/success-view/SuccessViewSummaryPanel' + +export interface SuccessViewProps { + eModeCategoryName: EModeCategoryName + onProceed: () => void +} +export function SuccessView({ eModeCategoryName, onProceed }: SuccessViewProps) { + return ( + + + + {eModeCategoryName} +

Option activated

+
+ Back to Dashboard +
+ ) +} diff --git a/packages/app/src/features/dialogs/repay/RepayDialog.test-e2e.ts b/packages/app/src/features/dialogs/repay/RepayDialog.test-e2e.ts new file mode 100644 index 000000000..e6a48de4d --- /dev/null +++ b/packages/app/src/features/dialogs/repay/RepayDialog.test-e2e.ts @@ -0,0 +1,557 @@ +import { test } from '@playwright/test' + +import { repayValidationIssueToMessage } from '@/domain/market-validators/validateRepay' +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { BorrowPageObject } from '@/pages/Borrow.PageObject' +import { DashboardPageObject } from '@/pages/Dashboard.PageObject' +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' +import { screenshot } from '@/test/e2e/utils' + +import { DialogPageObject } from '../common/Dialog.PageObject' + +const headerRegExp = /Repa*/ + +test.describe('Repay dialog', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + const initialBalances = { + wstETH: 100, + rETH: 100, + WETH: 100, + DAI: 10000, + } + const expectedInitialHealthFactor = '5.65' + const expectedHealthFactor = '5.82' + + test.describe('Position with borrowed DAI', () => { + const initialDeposits = { + rETH: 10, + } as const + const daiToBorrow = 3500 + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(initialDeposits, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + // @todo This waits for the refetch of the data after successful borrow transaction to happen. + // This is no ideal, probably we need to refactor expectDepositTable so it takes advantage from + // playwright's timeouts instead of parsing it's current state. Then we would be able to + // easily wait for the table to be updated. + await dashboardPage.expectAssetToBeInDepositTable('DAI') + }) + + test('opens dialog with selected asset', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction('DAI') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.expectSelectedAsset('DAI') + await repayDialog.expectDialogHeader('Repay DAI') + await repayDialog.expectHealthFactorBeforeVisible() + + await screenshot(repayDialog.getDialog(), 'repay-dialog-default-view') + }) + + test('calculates health factor changes correctly when repaying part', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction('DAI') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(100) + + await repayDialog.expectRiskLevelBefore('Healthy') + await repayDialog.expectHealthFactorBefore(expectedInitialHealthFactor) + await repayDialog.expectRiskLevelAfter('Healthy') + await repayDialog.expectHealthFactorAfter(expectedHealthFactor) + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(repayDialog.getDialog(), 'repay-dialog-health-factor-partial-repay') + }) + + test('calculates health factor changes correctly when repaying all', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction('DAI') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.clickMaxAmountAction() + + await repayDialog.expectRiskLevelBefore('Healthy') + await repayDialog.expectHealthFactorBefore(expectedInitialHealthFactor) + await repayDialog.expectRiskLevelAfter('No debt') + await repayDialog.expectHealthFactorAfter(String.fromCharCode(0x221e)) + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(repayDialog.getDialog(), 'repay-dialog-health-factor-full-repay') + }) + + test('after repay, health factor matches dashboard', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction('DAI') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(100) + + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + + await repayDialog.viewInDashboardAction() + await dashboardPage.expectHealthFactor(expectedHealthFactor) + }) + + test('has correct action plan for DAI', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction('DAI') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(100) + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'approve', + asset: 'DAI', + amount: 100, + }, + { + type: 'repay', + asset: 'DAI', + amount: 100, + }, + ], + true, + ) + }) + + test('can repay DAI', async ({ page }) => { + const repay = { + asset: 'DAI', + amount: 100, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction(repay.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(repay.amount) + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await repayDialog.expectSuccessPage([repay], fork) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-dai-success') + + await repayDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [repay.asset]: daiToBorrow - repay.amount, + }) + }) + + test('can fully repay DAI', async ({ page }) => { + const repay = { + asset: 'DAI', + amount: 3500, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction(repay.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.clickMaxAmountAction() + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await repayDialog.expectSuccessPage([repay], fork) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-dai-success') + + await repayDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [repay.asset]: 0, + }) + }) + + // @todo: doesn't work properly because of fixed date or something + test.skip('exact approvals are not required when repaying all', async ({ page }) => { + const repay = { + asset: 'DAI', + amount: 3500, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction(repay.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.clickMaxAmountAction() + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + // (1) first approval with extra buffer + await actionsContainer.acceptNextActionAction() + await actionsContainer.expectNextActionEnabled() + + await page.reload() + await dashboardPage.clickRepayButtonAction(repay.asset) + await repayDialog.clickMaxAmountAction() + + // exact amount of debt slightly increased but approval (1) has a buffer so it should be enough + await actionsContainer.expectNextAction({ type: 'repay', asset: repay.asset, amount: repay.amount }, true) + await actionsContainer.acceptNextActionAction() + + await repayDialog.expectSuccessPage([repay], fork) + }) + }) + + test.describe('Position with multiple borrowed assets', () => { + const initialDeposits = { + wstETH: initialBalances.wstETH, // deposit whole balance + } as const + + const wstETHBorrow = { + asset: 'wstETH', + amount: 10, + } + + const WETHBorrow = { + asset: 'WETH', + amount: 10, + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositWithoutBorrowActions(initialDeposits) // deposit whole wallet balance + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + + // borrow wstETH and WETH + const borrowDialog = new DialogPageObject(page, /Borrow */) + const borrowActionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + // borrow wstETH + await dashboardPage.clickBorrowButtonAction(wstETHBorrow.asset) + await borrowDialog.fillAmountAction(wstETHBorrow.amount) + await borrowActionsContainer.acceptAllActionsAction(1) + await borrowDialog.viewInDashboardAction() + // borrow WETH + await dashboardPage.clickBorrowButtonAction(WETHBorrow.asset) + await borrowDialog.fillAmountAction(WETHBorrow.amount) + await borrowActionsContainer.acceptAllActionsAction(1) + await borrowDialog.viewInDashboardAction() + }) + + test('can change asset to aToken', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction('wstETH') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.selectAssetAction('awstETH') + await repayDialog.expectSelectedAsset('awstETH') + await repayDialog.expectDialogHeader('Repay wstETH') + await repayDialog.expectHealthFactorBeforeVisible() + }) + + test('has correct action plan for repaying erc-20 using aToken', async ({ page }) => { + const repay = { + asset: 'awstETH', + amount: 5, + } as const + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction('wstETH') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.selectAssetAction(repay.asset) + await repayDialog.fillAmountAction(repay.amount) + + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'repay', + asset: repay.asset, + amount: repay.amount, + }, + ], + true, + ) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-erc20-atoken-action-plan') + }) + + test('can repay erc-20 using aToken', async ({ page }) => { + const repay = { + asset: 'awstETH', + amount: 5, + } as const + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction('wstETH') + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.selectAssetAction(repay.asset) + await repayDialog.fillAmountAction(repay.amount) + + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-erc20-atoken-success') + + await repayDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + ['wstETH']: wstETHBorrow.amount - repay.amount, + }) + }) + + test('has correct action plan for erc-20 repay with permits', async ({ page }) => { + const repay = { + asset: 'wstETH', + amount: 5, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction(repay.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(repay.amount) + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'permit', + asset: repay.asset, + amount: repay.amount, + }, + { + type: 'repay', + asset: repay.asset, + amount: repay.amount, + }, + ], + true, + ) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-erc20-permit-action-plan') + }) + + test('has correct action plan for erc-20 repay with approves', async ({ page }) => { + const repay = { + asset: 'wstETH', + amount: 5, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction(repay.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(repay.amount) + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.switchPreferPermitsAction() + await actionsContainer.expectActions( + [ + { + type: 'approve', + asset: repay.asset, + amount: repay.amount, + }, + { + type: 'repay', + asset: repay.asset, + amount: repay.amount, + }, + ], + true, + ) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-erc20-approve-action-plan') + }) + + test('can repay erc-20 using permits', async ({ page }) => { + const repay = { + asset: 'wstETH', + amount: 5, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction(repay.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(repay.amount) + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await repayDialog.expectSuccessPage([repay], fork) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-erc20-success') + + await repayDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [repay.asset]: wstETHBorrow.amount - repay.amount, + }) + }) + + test('can repay erc-20 using approves', async ({ page }) => { + const repay = { + asset: 'wstETH', + amount: 5, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickRepayButtonAction(repay.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.fillAmountAction(repay.amount) + const actionsContainer = new ActionsPageObject(repayDialog.locatePanelByHeader('Actions')) + await actionsContainer.switchPreferPermitsAction() + await actionsContainer.acceptAllActionsAction(2) + await repayDialog.expectSuccessPage([repay], fork) + + await repayDialog.viewInDashboardAction() + + await dashboardPage.expectBorrowTable({ + [repay.asset]: wstETHBorrow.amount - repay.amount, + }) + }) + }) + + test.describe('Form validation', () => { + const initialDeposits = { + wstETH: initialBalances.wstETH, // deposit whole balance + } as const + + const wstETHBorrow = { + asset: 'wstETH', + amount: 50, + } + + const wstETHDeposit = { + asset: 'wstETH', + amount: 50, + } + + const WETHBorrow = { + asset: 'WETH', + amount: 10, + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositWithoutBorrowActions(initialDeposits) // deposit whole wallet balance + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + + // borrow wstETH and WETH + const borrowDialog = new DialogPageObject(page, /Borrow */) + const borrowActionsContainer = new ActionsPageObject(borrowDialog.locatePanelByHeader('Actions')) + // borrow wstETH + await dashboardPage.clickBorrowButtonAction(wstETHBorrow.asset) + await borrowDialog.fillAmountAction(wstETHBorrow.amount) + await borrowActionsContainer.acceptAllActionsAction(1) + await borrowDialog.viewInDashboardAction() + // borrow WETH + await dashboardPage.clickBorrowButtonAction(WETHBorrow.asset) + await borrowDialog.fillAmountAction(WETHBorrow.amount) + await borrowActionsContainer.acceptAllActionsAction(1) + await borrowDialog.viewInDashboardAction() + + // deposit wstETH to have balance not enough to later repay debt using wstETH + const depositDialog = new DialogPageObject(page, /Deposit */) + const depositActionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await dashboardPage.clickDepositButtonAction(wstETHDeposit.asset) + await depositDialog.fillAmountAction(wstETHDeposit.amount) + await depositActionsContainer.acceptAllActionsAction(2) + await depositDialog.viewInDashboardAction() + }) + + test('cannot repay repay more than owe', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction(WETHBorrow.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.expectHealthFactorBefore('2.03') + await repayDialog.fillAmountAction(WETHBorrow.amount + 1) + await repayDialog.expectAssetInputError(repayValidationIssueToMessage['exceeds-debt']) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-more-than-owe') + }) + + test('cannot repay more than wallet balance', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickRepayButtonAction(wstETHBorrow.asset) + + const repayDialog = new DialogPageObject(page, headerRegExp) + await repayDialog.expectHealthFactorBefore('2.03') + await repayDialog.fillAmountAction(1) + await repayDialog.expectAssetInputError(repayValidationIssueToMessage['exceeds-balance']) + + await screenshot(repayDialog.getDialog(), 'repay-dialog-more-than-balance') + }) + }) + + // @note Add tests when problem with native asset deposit is solved + test.describe('Position with native token debt', () => {}) + + test.describe('Position with only deposit', () => { + const initialDeposits = { + wstETH: 10, + } as const + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositWithoutBorrowActions(initialDeposits) + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + }) + + test('nothing to repay', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectBorrowedAssetsToBeEmpty() + await screenshot(page, 'repay-dialog-nothing-to-repay') + }) + }) +}) diff --git a/packages/app/src/features/dialogs/repay/RepayDialog.tsx b/packages/app/src/features/dialogs/repay/RepayDialog.tsx new file mode 100644 index 000000000..411a626db --- /dev/null +++ b/packages/app/src/features/dialogs/repay/RepayDialog.tsx @@ -0,0 +1,19 @@ +import { Token } from '@/domain/types/Token' +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { CommonDialogProps } from '../common/types' +import { RepayDialogContentContainer } from './RepayDialogContentContainer' + +export interface RepayDialogProps extends CommonDialogProps { + token: Token +} + +export function RepayDialog({ token, open, setOpen }: RepayDialogProps) { + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/packages/app/src/features/dialogs/repay/RepayDialogContentContainer.tsx b/packages/app/src/features/dialogs/repay/RepayDialogContentContainer.tsx new file mode 100644 index 000000000..1a4edc3f4 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/RepayDialogContentContainer.tsx @@ -0,0 +1,47 @@ +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../common/components/skeletons/DialogContentSkeleton' +import { DialogContentContainerProps } from '../common/types' +import { SuccessView } from '../common/views/SuccessView' +import { useRepayDialog } from './logic/useRepayDialog' +import { RepayView } from './views/RepayView' + +function RepayDialogContentContainer({ token, closeDialog }: DialogContentContainerProps) { + const { + objectives, + repayOptions, + assetsToRepayFields, + pageStatus, + form, + repaymentAsset, + currentPositionOverview, + updatedPositionOverview, + } = useRepayDialog({ initialToken: token }) + + if (pageStatus.state === 'success') { + return ( + + ) + } + + return ( + + ) +} + +const RepayDialogContentContainerWithSuspense = withSuspense(RepayDialogContentContainer, DialogContentSkeleton) +export { RepayDialogContentContainerWithSuspense as RepayDialogContentContainer } diff --git a/packages/app/src/features/dialogs/repay/components/RepayOverviewPanel.tsx b/packages/app/src/features/dialogs/repay/components/RepayOverviewPanel.tsx new file mode 100644 index 000000000..4609f107c --- /dev/null +++ b/packages/app/src/features/dialogs/repay/components/RepayOverviewPanel.tsx @@ -0,0 +1,50 @@ +import { Token } from '@/domain/types/Token' +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogPanelTitle } from '@/features/dialogs/common/components/DialogPanelTitle' + +import { HealthFactorChange } from '../../common/components/HealthFactorChange' +import { TransactionOverviewDetailsItem } from '../../common/components/TransactionOverviewDetailsItem' +import { PositionOverview } from '../logic/types' + +export interface RepayOverviewPanelProps { + debtAsset: Token + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} +export function RepayOverviewPanel({ + debtAsset, + currentPositionOverview, + updatedPositionOverview, +}: RepayOverviewPanelProps) { + return ( + + Transaction overview + + + + ) +} + +interface RemainingDebtChangeProps { + debtAsset: Token + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +function RemainingDebt({ debtAsset, currentPositionOverview, updatedPositionOverview }: RemainingDebtChangeProps) { + const currentDebt = currentPositionOverview.debt + const updatedDebt = updatedPositionOverview?.debt + + return ( + + {debtAsset.format(updatedDebt ?? currentDebt, { style: 'auto' })} {debtAsset.symbol} + + ) +} diff --git a/packages/app/src/features/dialogs/repay/logic/assets.ts b/packages/app/src/features/dialogs/repay/logic/assets.ts new file mode 100644 index 000000000..d0782b175 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/logic/assets.ts @@ -0,0 +1,81 @@ +import invariant from 'tiny-invariant' + +import { NativeAssetInfo } from '@/config/chain/types' +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { applyTransformers } from '@/utils/applyTransformers' + +export interface GetDepositOptionsParams { + token: Token + marketInfo: MarketInfo + walletInfo: WalletInfo + nativeAssetInfo: NativeAssetInfo +} + +export function getRepayOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetDepositOptionsParams): TokenWithBalance[] { + const options = applyTransformers({ token, marketInfo, walletInfo, nativeAssetInfo })([ + getNativeAssetRepayOptions, + getDefaultRepayOptions, + ]) + invariant(options, `No deposit options found for token ${token.symbol}`) + + return options +} + +function getNativeAssetRepayOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetDepositOptionsParams): TokenWithBalance[] | undefined { + const { nativeAssetSymbol, wrappedNativeAssetSymbol } = nativeAssetInfo + + if (token.symbol !== nativeAssetSymbol && token.symbol !== wrappedNativeAssetSymbol) { + return undefined + } + const native = marketInfo.findOneReserveBySymbol(nativeAssetSymbol) + const wrapped = marketInfo.findOneReserveBySymbol(wrappedNativeAssetSymbol) + + return [ + { + token: native.token, + balance: walletInfo.findWalletBalanceForToken(native.token), + }, + { + token: wrapped.token, + balance: walletInfo.findWalletBalanceForToken(wrapped.token), + }, + { + token: wrapped.aToken, + balance: wrapped.aTokenBalance, + }, + ] +} + +function getDefaultRepayOptions({ token, marketInfo, walletInfo }: GetDepositOptionsParams): TokenWithBalance[] { + const reserve = marketInfo.findOneReserveBySymbol(token.symbol) + + return [ + { + token, + balance: walletInfo.findWalletBalanceForToken(token), + }, + { + token: reserve.aToken, + balance: reserve.aTokenBalance, + }, + ] +} + +export function getTokenDebt(marketInfo: MarketInfo, repayAsset: TokenWithValue): NormalizedUnitNumber { + const position = marketInfo.findOnePositionBySymbol(repayAsset.token.symbol) + return NormalizedUnitNumber(position.borrowBalance.minus(repayAsset.value)) +} diff --git a/packages/app/src/features/dialogs/repay/logic/form.ts b/packages/app/src/features/dialogs/repay/logic/form.ts new file mode 100644 index 000000000..0d2d00e13 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/logic/form.ts @@ -0,0 +1,67 @@ +import { UseFormReturn } from 'react-hook-form' +import { z } from 'zod' + +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { repayValidationIssueToMessage, validateRepay } from '@/domain/market-validators/validateRepay' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +import { AssetInputSchema, normalizeDialogFormValues } from '../../common/logic/form' +import { FormFieldsForDialog } from '../../common/types' + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getRepayDialogFormValidator(marketInfo: MarketInfo, walletInfo: WalletInfo) { + return AssetInputSchema.superRefine((field, ctx) => { + const formRepayAsset = normalizeDialogFormValues(field, marketInfo) + const reserve = marketInfo.findOneReserveBySymbol(field.symbol) + const debt = marketInfo.findOnePositionBySymbol(field.symbol).borrowBalance + + const token = marketInfo.findOneTokenBySymbol(field.symbol) + const balance = walletInfo.findWalletBalanceForToken(token) + + const validateIssue = validateRepay({ + value: formRepayAsset.value, + asset: { + status: reserve.status, + }, + user: { + debt, + balance, + }, + }) + if (validateIssue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: repayValidationIssueToMessage[validateIssue], + path: ['value'], + }) + } + }) +} + +export function getFormFieldsForRepayDialog( + form: UseFormReturn, + marketInfo: MarketInfo, + walletInfo: WalletInfo, + maxValue: NormalizedUnitNumber, +): FormFieldsForDialog { + // eslint-disable-next-line func-style + const changeAsset = (newSymbol: TokenSymbol): void => { + form.setValue('symbol', newSymbol) + form.setValue('value', '') + form.clearErrors() + } + + const { symbol, value } = form.getValues() + + return { + selectedAsset: { + value, + token: marketInfo.findOneTokenBySymbol(symbol), + balance: walletInfo.findWalletBalanceForSymbol(symbol), + }, + changeAsset, + maxValue, + } +} diff --git a/packages/app/src/features/dialogs/repay/logic/getRepayInFullOptions.ts b/packages/app/src/features/dialogs/repay/logic/getRepayInFullOptions.ts new file mode 100644 index 000000000..d46695507 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/logic/getRepayInFullOptions.ts @@ -0,0 +1,41 @@ +import { UseFormReturn } from 'react-hook-form' + +import { getRepayMaxValue } from '@/domain/action-max-value-getters/getRepayMaxValue' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +import { AssetInputSchema, isMaxValue } from '../../common/logic/form' + +interface UseRepayInFullOptionsResult { + repayInFull: boolean + maxRepayValue: NormalizedUnitNumber +} + +export function getRepayInFullOptions( + form: UseFormReturn, + marketInfo: MarketInfo, + walletInfo: WalletInfo, +): UseRepayInFullOptionsResult { + const { symbol } = form.getValues() + const position = marketInfo.findOnePositionBySymbol(symbol) + + const maxRepayValue = getRepayMaxValue({ + user: { + debt: position.borrowBalance, + balance: walletInfo.findWalletBalanceForSymbol(symbol), + }, + asset: { + status: position.reserve.status, + }, + }) + + const repayInFull = isMaxRepay(form, maxRepayValue) + + return { repayInFull, maxRepayValue } +} + +function isMaxRepay(form: UseFormReturn, maxValue: NormalizedUnitNumber): boolean { + const { value } = form.getValues() + return isMaxValue(value, maxValue) +} diff --git a/packages/app/src/features/dialogs/repay/logic/positionOverview.ts b/packages/app/src/features/dialogs/repay/logic/positionOverview.ts new file mode 100644 index 000000000..81ddf787a --- /dev/null +++ b/packages/app/src/features/dialogs/repay/logic/positionOverview.ts @@ -0,0 +1,20 @@ +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { PositionOverview } from './types' + +export interface MakeUpdatedPositionOverviewParams { + healthFactor: BigNumber | undefined + debt: NormalizedUnitNumber +} +export function makeUpdatedPositionOverview({ + healthFactor, + debt, +}: MakeUpdatedPositionOverviewParams): PositionOverview { + return { + // @todo: change 1e-8 when repaying max is handled properly + healthFactor: !healthFactor && debt.lt(1e-8) ? new BigNumber(Infinity) : healthFactor, + debt, + } +} diff --git a/packages/app/src/features/dialogs/repay/logic/types.ts b/packages/app/src/features/dialogs/repay/logic/types.ts new file mode 100644 index 000000000..839e32b00 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/logic/types.ts @@ -0,0 +1,8 @@ +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +export interface PositionOverview { + healthFactor: BigNumber | undefined + debt: NormalizedUnitNumber +} diff --git a/packages/app/src/features/dialogs/repay/logic/useCreateRepayObjectives.ts b/packages/app/src/features/dialogs/repay/logic/useCreateRepayObjectives.ts new file mode 100644 index 000000000..982ac8355 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/logic/useCreateRepayObjectives.ts @@ -0,0 +1,38 @@ +import { lendingPoolAddress } from '@/config/contracts-generated' +import { useContractAddress } from '@/domain/hooks/useContractAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { RepayObjective } from '@/features/actions/flavours/repay/types' + +import { DialogFormNormalizedData } from '../../common/logic/form' + +const FULL_REPAY_SCALE = 1.01 +const FULL_REPAY_APPROVAL_SCALE = 1.005 + +interface UseCreateRepayObjectivesOptions { + all?: boolean +} + +export function useCreateRepayObjectives( + formValues: DialogFormNormalizedData, + { all = false }: UseCreateRepayObjectivesOptions, +): RepayObjective[] { + const lendingPool = useContractAddress(lendingPoolAddress) + const scaledFormValue = scaleFormValue(formValues.value, formValues.token.decimals, FULL_REPAY_SCALE) + // required approval has smaller gap then what we are sending to the contract that enables big time gap between approval and borrow txs + const requiredApproval = scaleFormValue(formValues.value, formValues.token.decimals, FULL_REPAY_APPROVAL_SCALE) + + return [ + { + type: 'repay', + value: all ? scaledFormValue : formValues.value, + requiredApproval: all ? requiredApproval : formValues.value, + reserve: formValues.reserve, + useAToken: formValues.token.isAToken, + lendingPool, + }, + ] +} + +function scaleFormValue(value: NormalizedUnitNumber, decimals: number, scale: number): NormalizedUnitNumber { + return NormalizedUnitNumber(value.multipliedBy(scale).toFixed(decimals)) +} diff --git a/packages/app/src/features/dialogs/repay/logic/useRepayDialog.ts b/packages/app/src/features/dialogs/repay/logic/useRepayDialog.ts new file mode 100644 index 000000000..8abc62e39 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/logic/useRepayDialog.ts @@ -0,0 +1,108 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { useConditionalFreeze } from '@/domain/hooks/useConditionalFreeze' +import { useAaveDataLayer } from '@/domain/market-info/aave-data-layer/useAaveDataLayer' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { Token } from '@/domain/types/Token' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { Objective } from '@/features/actions/logic/types' + +import { AssetInputSchema, getActionAsset } from '../../common/logic/form' +import { useUpdateFormMaxValue } from '../../common/logic/useUpdateFormMaxValue' +import { FormFieldsForDialog, PageState, PageStatus } from '../../common/types' +import { getRepayOptions, getTokenDebt } from './assets' +import { getFormFieldsForRepayDialog, getRepayDialogFormValidator } from './form' +import { getRepayInFullOptions } from './getRepayInFullOptions' +import { makeUpdatedPositionOverview } from './positionOverview' +import { PositionOverview } from './types' +import { useCreateRepayObjectives } from './useCreateRepayObjectives' + +export interface UseRepayDialogOptions { + initialToken: Token +} + +export interface UseRepayDialogResult { + repayOptions: TokenWithBalance[] + assetsToRepayFields: FormFieldsForDialog + repaymentAsset: TokenWithValue + objectives: Objective[] + pageStatus: PageStatus + form: UseFormReturn + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +export function useRepayDialog({ initialToken }: UseRepayDialogOptions): UseRepayDialogResult { + const { aaveData } = useAaveDataLayer() + const { marketInfo } = useMarketInfo() + const nativeAssetInfo = getNativeAssetInfo(marketInfo.chainId) + const walletInfo = useWalletInfo() + + const [pageStatus, setPageStatus] = useState('form') + + const form = useForm({ + resolver: zodResolver(getRepayDialogFormValidator(marketInfo, walletInfo)), + defaultValues: { + symbol: initialToken.symbol, + value: '', + }, + mode: 'onChange', + }) + + const { repayInFull, maxRepayValue } = getRepayInFullOptions(form, marketInfo, walletInfo) + useUpdateFormMaxValue({ isMaxSet: repayInFull, maxValue: maxRepayValue, token: initialToken, form }) + + const repayOptions = getRepayOptions({ + token: initialToken, + marketInfo, + walletInfo, + nativeAssetInfo, + }) + + const repaymentAsset = useConditionalFreeze(getActionAsset(form, marketInfo, maxRepayValue), pageStatus === 'success') + + const assetsToRepayFields = getFormFieldsForRepayDialog(form, marketInfo, walletInfo, maxRepayValue) + + const debt = getTokenDebt(marketInfo, repaymentAsset) + + const objectives = useCreateRepayObjectives(repaymentAsset, { all: repayInFull }) + + const currentPositionOverview = { + healthFactor: marketInfo.userPositionSummary.healthFactor, + debt, + } + + const updatedUserSummary = updatePositionSummary({ + repays: [repaymentAsset], + marketInfo, + aaveData, + nativeAssetInfo, + }) + + const updatedPositionOverview = repaymentAsset.value.eq(0) + ? undefined + : makeUpdatedPositionOverview({ + healthFactor: updatedUserSummary.healthFactor, + debt, + }) + + return { + form, + repayOptions, + assetsToRepayFields, + repaymentAsset, + objectives, + pageStatus: { + state: pageStatus, + actionsEnabled: repaymentAsset.value.gt(0) && form.formState.isValid, + goToSuccessScreen: () => setPageStatus('success'), + }, + currentPositionOverview, + updatedPositionOverview, + } +} diff --git a/packages/app/src/features/dialogs/repay/views/RepayView.stories.tsx b/packages/app/src/features/dialogs/repay/views/RepayView.stories.tsx new file mode 100644 index 000000000..3a6f2fe81 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/views/RepayView.stories.tsx @@ -0,0 +1,78 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { chromatic } from '@storybook/viewports' +import BigNumber from 'bignumber.js' +import { useForm } from 'react-hook-form' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { RepayView } from './RepayView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Repay', + component: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm() as any + return + }, + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider()], + args: { + debtAsset: tokens['DAI'], + repayOptions: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(50000), + }, + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + }, + ], + assetsToRepayFields: { + selectedAsset: { + token: tokens['DAI'], + balance: NormalizedUnitNumber(50000), + value: '2000', + }, + changeAsset: () => {}, + }, + objectives: [], + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + currentPositionOverview: { + healthFactor: BigNumber(4), + debt: NormalizedUnitNumber(5000), + }, + updatedPositionOverview: { + healthFactor: BigNumber(2), + debt: NormalizedUnitNumber(2000), + }, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} + +export const Tablet: Story = { + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + chromatic: { viewports: [chromatic.tablet] }, + }, +} diff --git a/packages/app/src/features/dialogs/repay/views/RepayView.tsx b/packages/app/src/features/dialogs/repay/views/RepayView.tsx new file mode 100644 index 000000000..2bbafcc92 --- /dev/null +++ b/packages/app/src/features/dialogs/repay/views/RepayView.tsx @@ -0,0 +1,58 @@ +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { Token } from '@/domain/types/Token' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { DialogForm } from '@/features/dialogs/common/components/form/DialogForm' +import { FormAndOverviewWrapper } from '@/features/dialogs/common/components/FormAndOverviewWrapper' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' + +import { RepayOverviewPanel } from '../components/RepayOverviewPanel' +import { PositionOverview } from '../logic/types' + +export interface RepayViewProps { + debtAsset: Token + repayOptions: TokenWithBalance[] + assetsToRepayFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + pageStatus: PageStatus + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +export function RepayView({ + debtAsset, + repayOptions, + assetsToRepayFields, + form, + objectives, + pageStatus, + currentPositionOverview, + updatedPositionOverview, +}: RepayViewProps) { + return ( + + {`Repay ${debtAsset.symbol}`} + + + + + + + + + ) +} diff --git a/packages/app/src/features/dialogs/sandbox/SandboxDialog.tsx b/packages/app/src/features/dialogs/sandbox/SandboxDialog.tsx new file mode 100644 index 000000000..3283b3517 --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/SandboxDialog.tsx @@ -0,0 +1,19 @@ +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { CommonDialogProps } from '../common/types' +import { SandboxDialogContentContainer } from './SandboxDialogContentContainer' +import { SandboxMode } from './types' + +export interface SandboxDialogProps extends CommonDialogProps { + mode: SandboxMode +} + +export function SandboxDialog({ open, setOpen, mode }: SandboxDialogProps) { + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/packages/app/src/features/dialogs/sandbox/SandboxDialogContentContainer.tsx b/packages/app/src/features/dialogs/sandbox/SandboxDialogContentContainer.tsx new file mode 100644 index 000000000..3cff5dc20 --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/SandboxDialogContentContainer.tsx @@ -0,0 +1,23 @@ +import { useSandboxDialog } from './logic/useSandboxDialog' +import { SandboxMode } from './types' +import { SandboxDialogView } from './views/SandboxDialogView' + +export interface SandboxDialogContentContainerProps { + mode: SandboxMode + closeDialog: () => void +} + +export function SandboxDialogContentContainer({ mode, closeDialog }: SandboxDialogContentContainerProps) { + const { isInSandbox, startSandbox, isPending, isSuccess, isError, error } = useSandboxDialog(mode) + return ( + + ) +} diff --git a/packages/app/src/features/dialogs/sandbox/logic/createSandbox.ts b/packages/app/src/features/dialogs/sandbox/logic/createSandbox.ts new file mode 100644 index 000000000..d03ff6c97 --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/logic/createSandbox.ts @@ -0,0 +1,43 @@ +import { Address, parseEther, parseUnits } from 'viem' + +import { apiUrl } from '@/config/consts' +import { AppConfig } from '@/config/feature-flags' +import { createTenderlyFork } from '@/domain/sandbox/createTenderlyFork' +import { publicTenderlyActions } from '@/domain/sandbox/publicTenderlyActions' +import { BaseUnitNumber } from '@/domain/types/NumericValues' + +export async function createSandbox(opts: { + originChainId: number + forkChainId: number + userAddress: Address + mintBalances: NonNullable['mintBalances'] +}): Promise { + const { rpcUrl: forkUrl } = await createTenderlyFork({ + namePrefix: 'sandbox', + originChainId: opts.originChainId, + forkChainId: opts.forkChainId, + apiUrl: `${apiUrl}/playground/create`, + }) + + await publicTenderlyActions.setBalance( + forkUrl, + opts.userAddress, + BaseUnitNumber(parseEther(opts.mintBalances.etherAmt.toString())), + ) + + // @note: tenderly doesn't support parallel calls + for (const [_, token] of Object.entries(opts.mintBalances.tokens)) { + const units = BaseUnitNumber(parseUnits(opts.mintBalances.tokenAmt.toString(), token.decimals)) + + await publicTenderlyActions.setTokenBalance(forkUrl, token.address, opts.userAddress, units) + } + + return forkUrl +} + +/** + * @returns string concatenated prefix and timestamp + */ +export function getChainIdWithPrefix(prefix: number, timestampSeconds: number): number { + return parseInt(prefix.toString() + timestampSeconds.toString()) +} diff --git a/packages/app/src/features/dialogs/sandbox/logic/createSandboxConnector.ts b/packages/app/src/features/dialogs/sandbox/logic/createSandboxConnector.ts new file mode 100644 index 000000000..deb1087e3 --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/logic/createSandboxConnector.ts @@ -0,0 +1,39 @@ +import { createWalletClient, http } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { mainnet } from 'viem/chains' +import { CreateConnectorFn } from 'wagmi' + +import { createMockConnector } from '@/domain/wallet/createMockConnector' + +export interface CreateSandboxWalletArgs { + privateKey: `0x${string}` + forkUrl: string + chainName: string + chainId: number +} + +export function createSandboxConnector({ + privateKey, + forkUrl, + chainId, + chainName, +}: CreateSandboxWalletArgs): CreateConnectorFn { + const account = privateKeyToAccount(privateKey) + + const walletClient = createWalletClient({ + transport: http(forkUrl), + chain: { + ...mainnet, + id: chainId, + name: chainName, + rpcUrls: { + default: { + http: [forkUrl], + }, + }, + }, + account, + }) + + return createMockConnector(walletClient) +} diff --git a/packages/app/src/features/dialogs/sandbox/logic/useSandboxDialog.ts b/packages/app/src/features/dialogs/sandbox/logic/useSandboxDialog.ts new file mode 100644 index 000000000..d116e9249 --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/logic/useSandboxDialog.ts @@ -0,0 +1,142 @@ +import { useMutation, UseMutationResult } from '@tanstack/react-query' +import { useRef } from 'react' +import invariant from 'tiny-invariant' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { useAccount, useConfig } from 'wagmi' +import { connect, getChains, switchChain } from 'wagmi/actions' + +import { useSandboxState } from '@/domain/sandbox/useSandboxState' +import { useStore } from '@/domain/state' +import { NotRetryableError, retry } from '@/utils/promises' +import { getTimestampInSeconds } from '@/utils/time' + +import { SandboxMode } from '../types' +import { createSandbox, getChainIdWithPrefix } from './createSandbox' +import { createSandboxConnector } from './createSandboxConnector' + +export type UseSandboxMutationResult = Omit, 'mutate'> & { + startSandbox: () => void +} + +export interface UseSandboxDialogResult extends UseSandboxMutationResult { + isInSandbox: boolean +} + +export function useSandboxDialog(mode: SandboxMode): UseSandboxDialogResult { + // @note: without this ref, callback in retry will hold the config it was created with. + // We want to get refreshed configs on every retry. + // Previously it used to work because config was global, + // so when it was updated it was reflected in callbacks as well. + const config = useConfig() + const configRef = useRef(config) + configRef.current = config + + const { address } = useAccount() + const sandboxConfig = useStore((state) => state.appConfig.sandbox) + const { setNetwork } = useStore((state) => state.sandbox) + + const { isInSandbox } = useSandboxState() + + invariant(sandboxConfig, 'It seems that sandbox feature is not enabled.') + + // eslint-disable-next-line func-style + const startSandboxAsync = async (): Promise => { + if (!address && mode === 'persisting') { + throw new Error('Connect wallet first!') + } + + const createdAt = new Date() + const forkChainId = getChainIdWithPrefix(sandboxConfig.forkChainIdPrefix, getTimestampInSeconds(createdAt)) + + if (mode === 'ephemeral') { + const privateKey = generatePrivateKey() + const account = privateKeyToAccount(privateKey) + + const forkUrl = await createSandbox({ + originChainId: sandboxConfig.originChainId, + forkChainId, + userAddress: account.address, + mintBalances: sandboxConfig.mintBalances, + }) + setNetwork({ + originChainId: sandboxConfig.originChainId, + forkChainId, + forkUrl, + createdAt, + name: sandboxConfig.chainName, + ephemeralAccountPrivateKey: privateKey, + }) + await retry( + async () => { + const chains = getChains(configRef.current) + if (!chains.some((c) => c.id === forkChainId)) { + throw new Error('Chain not configured') + } + }, + { + delay: 200, + retries: 5, + }, + ) + + const connector = createSandboxConnector({ + privateKey, + chainId: forkChainId, + forkUrl, + chainName: sandboxConfig.chainName, + }) + await connect(configRef.current, { + chainId: forkChainId, + connector, + }) + } else { + invariant(address, 'Address should be defined when not using ephemeral account.') + const forkUrl = await createSandbox({ + originChainId: sandboxConfig.originChainId, + forkChainId, + userAddress: address, + mintBalances: sandboxConfig.mintBalances, + }) + setNetwork({ + originChainId: sandboxConfig.originChainId, + forkChainId, + forkUrl, + createdAt, + name: sandboxConfig.chainName, + }) + + await retry( + async () => { + try { + await switchChain(configRef.current, { + chainId: forkChainId, + }) // this can throw with internal error when chains are not yet reloaded or with user rejected error + } catch (e: any) { + if (e.message.includes('Chain not configured')) { + throw e + } else { + throw new NotRetryableError(e) + } + } + }, + { retries: 5, delay: 200 }, + ) + } + } + + async function startSandbox(): Promise { + try { + await startSandboxAsync() + } catch (e: any) { + // eslint-disable-next-line no-console + console.error(e) + throw new Error(`Could not enter sandbox mode: ${e.message}`) + } + } + + const startSandboxMutation = useMutation({ + mutationFn: startSandbox, + }) + + return { isInSandbox, startSandbox: startSandboxMutation.mutate, ...startSandboxMutation } +} diff --git a/packages/app/src/features/dialogs/sandbox/types.ts b/packages/app/src/features/dialogs/sandbox/types.ts new file mode 100644 index 000000000..17e4e59d5 --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/types.ts @@ -0,0 +1 @@ +export type SandboxMode = 'persisting' | 'ephemeral' diff --git a/packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.stories.ts b/packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.stories.ts new file mode 100644 index 000000000..31060fdab --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.stories.ts @@ -0,0 +1,59 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { SandboxDialogView } from './SandboxDialogView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Sandbox', + component: SandboxDialogView, + decorators: [WithClassname('max-w-xl')], + args: { + startSandbox: () => {}, + isInSandbox: false, + isPending: false, + isSuccess: false, + isError: false, + error: null, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) + +export const Pending: Story = { + args: { + isPending: true, + }, +} +export const PendingMobile = getMobileStory(Pending) +export const PendingTablet = getTabletStory(Pending) + +export const Success: Story = { + args: { + isSuccess: true, + }, +} +export const SuccessMobile = getMobileStory(Success) +export const SuccessTablet = getTabletStory(Success) + +export const WithError: Story = { + args: { + isError: true, + error: new Error('Something went wrong'), + }, +} +export const WithErrorMobile = getMobileStory(WithError) +export const WithErrorTablet = getTabletStory(WithError) + +export const AlreadyInSandbox: Story = { + args: { + isInSandbox: true, + }, +} +export const InSandboxMobile = getMobileStory(AlreadyInSandbox) +export const InSandboxTablet = getTabletStory(AlreadyInSandbox) diff --git a/packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.tsx b/packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.tsx new file mode 100644 index 000000000..568c580d8 --- /dev/null +++ b/packages/app/src/features/dialogs/sandbox/views/SandboxDialogView.tsx @@ -0,0 +1,61 @@ +import { assets } from '@/ui/assets' +import MagicWand from '@/ui/assets/magic-wand.svg?react' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' +import { ActionButton } from '@/ui/molecules/action-button/ActionButton' + +import { Alert } from '../../common/components/alert/Alert' + +export interface SandboxDialogViewProps { + isInSandbox: boolean + startSandbox: () => void + closeDialog: () => void + isPending: boolean + isSuccess: boolean + isError: boolean + error: Error | null +} + +export function SandboxDialogView({ + isInSandbox, + startSandbox, + closeDialog, + isPending, + isSuccess, + isError, + error, +}: SandboxDialogViewProps) { + const onActionButtonClick = isSuccess || isInSandbox ? closeDialog : startSandbox + const isDone = isSuccess || isInSandbox + + return ( +
+ +
+ + Sandbox mode +
+
+

+ Sandbox mode is a risk-free environment where you can test the Spark App and understand how it works. When + you're ready, you can switch back to the real world. Have fun exploring! +

+
    + {[ + 'Unlimited tokens', + 'Risk free exploration', + 'No real assets involved', + 'Fast – no need to sign transactions', + ].map((item, index) => ( +
  • + success-icon + {item} +
  • + ))} +
+ {isError && error && {error.message}} + + {isDone ? 'Sandbox mode activated' : 'Activate Sandbox mode'} + +
+ ) +} diff --git a/packages/app/src/features/dialogs/savings/common/components/DepositOverviewPanel.tsx b/packages/app/src/features/dialogs/savings/common/components/DepositOverviewPanel.tsx new file mode 100644 index 000000000..7903dad61 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/common/components/DepositOverviewPanel.tsx @@ -0,0 +1,37 @@ +import { formatPercentage } from '@/domain/common/format' +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogPanelTitle } from '@/features/dialogs/common/components/DialogPanelTitle' +import { TransactionOverviewDetailsItem } from '@/features/dialogs/common/components/TransactionOverviewDetailsItem' + +import { SavingsDialogTxOverview } from '../../deposit/logic/useTransactionOverview' +import { TransactionOverviewBalanceChangeDetail } from './TransactionOverviewBalanceChangeDetail' +import { TransactionOverviewExchangeRateDetail } from './TransactionOverviewExchangeRateDetail' + +export interface DepositOverviewPanelProps { + txOverview: SavingsDialogTxOverview + showExchangeRate: boolean +} +export function DepositOverviewPanel({ + txOverview: { + DSR, + sDaiBalanceAfter, + sDaiBalanceBefore, + exchangeRatio, + exchangeRatioFromToken: inputToken, + sDaiToken, + exchangeRatioToToken: outputToken, + }, + showExchangeRate, +}: DepositOverviewPanelProps) { + return ( + + Transaction overview + + {formatPercentage(DSR)} + {showExchangeRate && ( + + )} + + + ) +} diff --git a/packages/app/src/features/dialogs/savings/common/components/TokenValue.tsx b/packages/app/src/features/dialogs/savings/common/components/TokenValue.tsx new file mode 100644 index 000000000..f1c0d3fa4 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/common/components/TokenValue.tsx @@ -0,0 +1,26 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +import { formatWithHighPrecision } from '../../utils/formatWithHighPrecision' + +export function TokenValue({ + value, + token, + variant, +}: { + value: NormalizedUnitNumber + token: Token + variant: 'compact' | 'high-precision' | 'auto-precision' +}) { + const formattedValue = + variant === 'compact' + ? token.format(value, { style: 'compact' }) + : variant === 'high-precision' + ? formatWithHighPrecision(value) + : token.format(value, { style: 'auto' }) + return ( + + {formattedValue} {token.symbol} + + ) +} diff --git a/packages/app/src/features/dialogs/savings/common/components/TransactionOverviewBalanceChangeDetail.tsx b/packages/app/src/features/dialogs/savings/common/components/TransactionOverviewBalanceChangeDetail.tsx new file mode 100644 index 000000000..2eed3919b --- /dev/null +++ b/packages/app/src/features/dialogs/savings/common/components/TransactionOverviewBalanceChangeDetail.tsx @@ -0,0 +1,27 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TransactionOverviewDetailsItem } from '@/features/dialogs/common/components/TransactionOverviewDetailsItem' +import { assets } from '@/ui/assets' + +import { TokenValue } from './TokenValue' + +export interface TransactionOverviewBalanceChangeDetailProps { + token: Token + before: NormalizedUnitNumber + after: NormalizedUnitNumber +} + +export function TransactionOverviewBalanceChangeDetail({ + token, + after, + before, +}: TransactionOverviewBalanceChangeDetailProps) { + return ( + +
+ + +
+
+ ) +} diff --git a/packages/app/src/features/dialogs/savings/common/components/TransactionOverviewExchangeRateDetail.tsx b/packages/app/src/features/dialogs/savings/common/components/TransactionOverviewExchangeRateDetail.tsx new file mode 100644 index 000000000..1011e9f2d --- /dev/null +++ b/packages/app/src/features/dialogs/savings/common/components/TransactionOverviewExchangeRateDetail.tsx @@ -0,0 +1,28 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TransactionOverviewDetailsItem } from '@/features/dialogs/common/components/TransactionOverviewDetailsItem' +import { assets } from '@/ui/assets' + +import { TokenValue } from './TokenValue' + +export interface TransactionOverviewDetailsItemProps { + toToken: Token + fromToken: Token + ratio: NormalizedUnitNumber +} + +export function TransactionOverviewExchangeRateDetail({ + fromToken, + toToken, + ratio, +}: TransactionOverviewDetailsItemProps) { + return ( + +
+ {' '} + + +
+
+ ) +} diff --git a/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.PageObject.ts b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.PageObject.ts new file mode 100644 index 000000000..0cda0fa5b --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.PageObject.ts @@ -0,0 +1,18 @@ +import { Page } from '@playwright/test' + +import { DialogPageObject } from '../../common/Dialog.PageObject' + +export class SavingsDepositDialogPageObject extends DialogPageObject { + constructor(page: Page) { + super(page, /Deposit to Savings/) + } + + // #region actions + async clickBackToSavingsButton(): Promise { + await this.page.getByRole('button', { name: 'Back to Savings' }).click() + await this.region.waitFor({ + state: 'detached', + }) + } + // #endregion +} diff --git a/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.test-e2e.ts b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.test-e2e.ts new file mode 100644 index 000000000..9197ef063 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.test-e2e.ts @@ -0,0 +1,79 @@ +import { test } from '@playwright/test' + +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { SavingsPageObject } from '@/pages/Savings.PageObject' +import { overrideLiFiRoute } from '@/test/e2e/lifi' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' + +import { SavingsDepositDialogPageObject } from './SavingsDepositDialog.PageObject' + +// Block number has to be as close as possible to the block number when query was executed +const blockNumber = 19519583n + +test.describe('Savings deposit dialog', () => { + // The tests here are not independent. + // My guess is that reverting to snapshots in tenderly does not work properly - but for now couldn't debug that. + // For now tests use different forks. + test.describe('DAI', () => { + const fork = setupFork(blockNumber) + + test('wraps DAI', async ({ page }) => { + const { account } = await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + ETH: 1, + DAI: 100, + }, + }, + }) + await overrideLiFiRoute(page, account, '100-dai-to-sdai', blockNumber) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.clickStartSavingButtonAction() + + const depositDialog = new SavingsDepositDialogPageObject(page) + await depositDialog.fillAmountAction(100) + + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await depositDialog.clickBackToSavingsButton() + + await savingsPage.expectCurrentWorth('105.3742') + }) + }) + + test.describe('USDC', () => { + const fork = setupFork(blockNumber) + + test('wraps USDC', async ({ page }) => { + const { account } = await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + ETH: 1, + USDC: 100, + }, + }, + }) + await overrideLiFiRoute(page, account, '100-usdc-to-sdai', blockNumber) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.clickDepositButtonAction('USDC') + + const depositDialog = new SavingsDepositDialogPageObject(page) + await depositDialog.fillAmountAction(100) + + const actionsContainer = new ActionsPageObject(depositDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await depositDialog.clickBackToSavingsButton() + + await savingsPage.expectCurrentWorth('105.3563') + }) + }) +}) diff --git a/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.tsx b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.tsx new file mode 100644 index 000000000..1db3c1ae8 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialog.tsx @@ -0,0 +1,25 @@ +import { MakerInfo } from '@/domain/maker-info/types' +import { Token } from '@/domain/types/Token' +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { CommonDialogProps } from '../../common/types' +import { SavingsDepositDialogContentContainer } from './SavingsDepositDialogContentContainer' + +export interface SavingsDepositDialogProps extends CommonDialogProps { + initialToken: Token + makerInfo: MakerInfo +} + +export function SavingsDepositDialog({ initialToken, makerInfo, open, setOpen }: SavingsDepositDialogProps) { + return ( + + + setOpen(false)} + /> + + + ) +} diff --git a/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialogContentContainer.tsx b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialogContentContainer.tsx new file mode 100644 index 000000000..9263d34d0 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/SavingsDepositDialogContentContainer.tsx @@ -0,0 +1,50 @@ +import { MakerInfo } from '@/domain/maker-info/types' +import { Token } from '@/domain/types/Token' +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../../common/components/skeletons/DialogContentSkeleton' +import { SuccessView } from '../../common/views/SuccessView' +import { useSavingsDepositDialog } from './logic/useSavingsDepositDialog' +import { SavingsDepositView } from './views/SavingsDepositView' + +export interface SavingsDepositContainerProps { + initialToken: Token + makerInfo: MakerInfo + closeDialog: () => void +} + +function SavingsDepositDialogContentContainer({ initialToken, makerInfo, closeDialog }: SavingsDepositContainerProps) { + const { selectableAssets, assetsFields, form, tokenToDeposit, objectives, pageStatus, txOverview } = + useSavingsDepositDialog({ + initialToken, + makerInfo, + }) + + if (pageStatus.state === 'success') { + return ( + + ) + } + + return ( + + ) +} + +const SavingsDepositDialogContentContainerWithSuspense = withSuspense( + SavingsDepositDialogContentContainer, + DialogContentSkeleton, +) +export { SavingsDepositDialogContentContainerWithSuspense as SavingsDepositDialogContentContainer } diff --git a/packages/app/src/features/dialogs/savings/deposit/logic/form.ts b/packages/app/src/features/dialogs/savings/deposit/logic/form.ts new file mode 100644 index 000000000..4bc878fe9 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/logic/form.ts @@ -0,0 +1,34 @@ +import { UseFormReturn } from 'react-hook-form' + +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog } from '@/features/dialogs/common/types' + +export function getFormFieldsForDepositDialog( + form: UseFormReturn, + marketInfo: MarketInfo, + walletInfo: WalletInfo, +): FormFieldsForDialog { + // eslint-disable-next-line func-style + const changeAsset = (newSymbol: TokenSymbol): void => { + form.setValue('symbol', newSymbol) + form.setValue('value', '') + form.clearErrors() + } + + const { symbol, value } = form.getValues() + const token = marketInfo.findOneTokenBySymbol(symbol) + const balance = walletInfo.findWalletBalanceForSymbol(symbol) + + return { + selectedAsset: { + value, + token, + balance, + }, + changeAsset, + maxValue: balance, + } +} diff --git a/packages/app/src/features/dialogs/savings/deposit/logic/objectives.ts b/packages/app/src/features/dialogs/savings/deposit/logic/objectives.ts new file mode 100644 index 000000000..2f9196776 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/logic/objectives.ts @@ -0,0 +1,39 @@ +import { SwapInfo, SwapParams } from '@/domain/exchanges/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { ExchangeObjective } from '@/features/actions/flavours/exchange/types' +import { simplifyQueryResult } from '@/features/actions/logic/simplifyQueryResult' +import { convertSharesToDai } from '@/features/savings/logic/projections' + +export interface CreateObjectivesParams { + swapInfo: SwapInfo + swapParams: SwapParams + marketInfo: MarketInfo + makerInfo: MakerInfo +} +export function createObjectives({ + swapInfo, + swapParams, + marketInfo, + makerInfo, +}: CreateObjectivesParams): ExchangeObjective[] { + const DAI = marketInfo.findOneTokenBySymbol(TokenSymbol('DAI')) + return [ + { + type: 'exchange', + swapInfo: simplifyQueryResult(swapInfo), + swapParams, + formatAsDAIValue: (amount: NormalizedUnitNumber) => + DAI.format( + convertSharesToDai({ + potParams: makerInfo.potParameters, + shares: amount, + timestamp: marketInfo.timestamp, + }), + { style: 'auto' }, + ), + }, + ] +} diff --git a/packages/app/src/features/dialogs/savings/deposit/logic/useSavingsDepositDialog.ts b/packages/app/src/features/dialogs/savings/deposit/logic/useSavingsDepositDialog.ts new file mode 100644 index 000000000..a29a69a24 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/logic/useSavingsDepositDialog.ts @@ -0,0 +1,89 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { makeAssetsInWalletList } from '@/domain/savings/makeAssetsInWalletList' +import { Token } from '@/domain/types/Token' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { Objective } from '@/features/actions/logic/types' +import { AssetInputSchema, normalizeDialogFormValues } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageState, PageStatus } from '@/features/dialogs/common/types' + +import { getFormFieldsForDepositDialog } from './form' +import { createObjectives } from './objectives' +import { useSwap } from './useSwap' +import { SavingsDialogTxOverview, useTxOverview } from './useTransactionOverview' +import { getSavingsDepositDialogFormValidator } from './validation' + +export interface UseSavingsDepositDialogParams { + initialToken: Token + makerInfo: MakerInfo +} + +export interface UseSavingsDepositDialogResults { + selectableAssets: TokenWithBalance[] + assetsFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + tokenToDeposit: TokenWithValue + pageStatus: PageStatus + txOverview: SavingsDialogTxOverview | undefined +} + +export function useSavingsDepositDialog({ + initialToken, + makerInfo, +}: UseSavingsDepositDialogParams): UseSavingsDepositDialogResults { + const { marketInfo } = useMarketInfo() + const walletInfo = useWalletInfo() + const { assets: depositOptions } = makeAssetsInWalletList({ walletInfo }) + + const [pageStatus, setPageStatus] = useState('form') + + const form = useForm({ + resolver: zodResolver(getSavingsDepositDialogFormValidator(walletInfo)), + defaultValues: { + symbol: initialToken.symbol, + value: '', + }, + mode: 'onChange', + }) + + const formValues = normalizeDialogFormValues(form.watch(), marketInfo) + const { swapInfo, swapParams } = useSwap({ formValues, marketInfo, walletInfo }) + + const objectives = createObjectives({ + swapInfo, + swapParams, + marketInfo, + makerInfo, + }) + const txOverview = useTxOverview({ + marketInfo, + swapInfo, + walletInfo, + swapParams, + makerInfo, + }) + const tokenToDeposit: TokenWithValue = { + token: formValues.token, + value: formValues.value, + } + + return { + selectableAssets: depositOptions, + assetsFields: getFormFieldsForDepositDialog(form, marketInfo, walletInfo), + form, + objectives, + tokenToDeposit, + txOverview, + pageStatus: { + state: pageStatus, + actionsEnabled: formValues.value.gt(0) && form.formState.isValid, + goToSuccessScreen: () => setPageStatus('success'), + }, + } +} diff --git a/packages/app/src/features/dialogs/savings/deposit/logic/useSwap.ts b/packages/app/src/features/dialogs/savings/deposit/logic/useSwap.ts new file mode 100644 index 000000000..afd03c76f --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/logic/useSwap.ts @@ -0,0 +1,39 @@ +import { useLifiQueryMetaEvaluator } from '@/domain/exchanges/lifi/meta' +import { useLiFiTxData } from '@/domain/exchanges/lifi/useLiFiTxData' +import { SwapInfo, SwapParams } from '@/domain/exchanges/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { useActionsSettings } from '@/domain/state' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { DialogFormNormalizedData } from '@/features/dialogs/common/logic/form' + +interface UseSwapParams { + formValues: DialogFormNormalizedData + marketInfo: MarketInfo + walletInfo: WalletInfo +} + +export function useSwap({ formValues, marketInfo }: UseSwapParams): { + swapInfo: SwapInfo + swapParams: SwapParams +} { + const queryMetaEvaluator = useLifiQueryMetaEvaluator() + const settings = useActionsSettings() + const sdai = marketInfo.findOneTokenBySymbol(TokenSymbol('sDAI')) + + const swapParams: SwapParams = { + type: 'direct', + fromToken: formValues.token, + toToken: sdai, + value: NormalizedUnitNumber(formValues.value), + maxSlippage: settings.exchangeMaxSlippage, + } + + const swapInfo = useLiFiTxData({ + swapParams, + queryMetaEvaluator, + }) + + return { swapParams, swapInfo } +} diff --git a/packages/app/src/features/dialogs/savings/deposit/logic/useTransactionOverview.ts b/packages/app/src/features/dialogs/savings/deposit/logic/useTransactionOverview.ts new file mode 100644 index 000000000..c458bc4be --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/logic/useTransactionOverview.ts @@ -0,0 +1,66 @@ +import { SwapInfo, SwapParams } from '@/domain/exchanges/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { convertSharesToDai } from '@/features/savings/logic/projections' + +interface UseTxOverviewParams { + marketInfo: MarketInfo + makerInfo: MakerInfo + swapInfo: SwapInfo + swapParams: SwapParams + walletInfo: WalletInfo +} + +export interface SavingsDialogTxOverview { + exchangeRatioFromToken: Token + exchangeRatioToToken: Token + exchangeRatio: NormalizedUnitNumber + sDaiToken: Token + sDaiBalanceBefore: NormalizedUnitNumber + sDaiBalanceAfter: NormalizedUnitNumber + DSR: Percentage +} + +export function useTxOverview({ + marketInfo, + walletInfo, + makerInfo, + swapInfo, + swapParams, +}: UseTxOverviewParams): SavingsDialogTxOverview | undefined { + const sdai = marketInfo.findOneTokenBySymbol(TokenSymbol('sDAI')) + const dai = marketInfo.findOneTokenBySymbol(TokenSymbol('DAI')) + + if (!swapInfo.data) { + return undefined + } + + const sDaiAmountBaseUnit = swapInfo.data.estimate.toAmount + const sDaiAmount = sdai.fromBaseUnit(sDaiAmountBaseUnit) + const otherTokenAmountBaseUnit = swapInfo.data.estimate.fromAmount + const otherTokenAmount = swapParams.fromToken.fromBaseUnit(otherTokenAmountBaseUnit) + + const daiAmountNormalized = convertSharesToDai({ + shares: sDaiAmount, + potParams: makerInfo.potParameters, + timestamp: marketInfo.timestamp, + }) + const tokenToDaiRatio = NormalizedUnitNumber(daiAmountNormalized.dividedBy(otherTokenAmount)) + + const sDaiBalanceBefore = walletInfo.findWalletBalanceForSymbol(TokenSymbol('sDAI')) + const sDaiBalanceAfter = NormalizedUnitNumber(sDaiBalanceBefore.plus(sDaiAmount)) + + return { + exchangeRatioFromToken: swapParams.fromToken, + exchangeRatioToToken: dai, + sDaiToken: sdai, + exchangeRatio: tokenToDaiRatio, + sDaiBalanceBefore, + sDaiBalanceAfter, + DSR: makerInfo.DSR, + } +} diff --git a/packages/app/src/features/dialogs/savings/deposit/logic/validation.ts b/packages/app/src/features/dialogs/savings/deposit/logic/validation.ts new file mode 100644 index 000000000..a55d6fa9f --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/logic/validation.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getSavingsDepositDialogFormValidator(walletInfo: WalletInfo) { + return AssetInputSchema.superRefine((field, ctx) => { + const value = NormalizedUnitNumber(field.value === '' ? '0' : field.value) + const balance = walletInfo.findWalletBalanceForSymbol(field.symbol) + + const issue = validateDeposit({ + value, + user: { balance }, + }) + if (issue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: depositValidationIssueToMessage[issue], + path: ['value'], + }) + } + }) +} + +export type DepositValidationIssue = 'exceeds-balance' | 'value-not-positive' + +export interface ValidateDepositArgs { + value: NormalizedUnitNumber + user: { + balance: NormalizedUnitNumber + } +} + +export function validateDeposit({ value, user: { balance } }: ValidateDepositArgs): DepositValidationIssue | undefined { + if (value.isLessThanOrEqualTo(0)) { + return 'value-not-positive' + } + + if (balance.lt(value)) { + return 'exceeds-balance' + } +} + +export const depositValidationIssueToMessage: Record = { + 'value-not-positive': 'Deposit value should be positive', + 'exceeds-balance': 'Exceeds your balance', +} diff --git a/packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.stories.tsx b/packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.stories.tsx new file mode 100644 index 000000000..90d36d844 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.stories.tsx @@ -0,0 +1,97 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { fakeBigInt } from '@storybook/utils' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { useForm } from 'react-hook-form' + +import { BaseUnitNumber, NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { ExchangeObjective } from '@/features/actions/flavours/exchange/types' + +import { SavingsDepositView } from './SavingsDepositView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Savings/Deposit', + component: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm() as any + return + }, + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider()], + args: { + selectableAssets: [ + { + token: tokens['USDC'], + balance: NormalizedUnitNumber(50000), + }, + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(1), + }, + ], + assetsFields: { + selectedAsset: { + token: tokens['USDC'], + balance: NormalizedUnitNumber(50000), + value: '2000', + }, + changeAsset: () => {}, + }, + objectives: [ + { + type: 'exchange', + swapParams: { + fromToken: tokens['USDC'], + toToken: tokens['sDAI'], + type: 'direct', + value: NormalizedUnitNumber(5000), + maxSlippage: Percentage(0.005), + }, + swapInfo: { + status: 'success', + data: { + fromToken: tokens['USDC'].address, + toToken: tokens['sDAI'].address, + type: 'direct', + txRequest: { + data: '0x', + from: '0x', + gasLimit: fakeBigInt, + gasPrice: fakeBigInt, + to: '0x', + value: fakeBigInt, + }, + estimate: { + feeCostsUSD: NormalizedUnitNumber(0), + fromAmount: BaseUnitNumber(5000), + toAmount: BaseUnitNumber(5000), + }, + }, + }, + } satisfies ExchangeObjective, + ], + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + txOverview: { + DSR: Percentage(0.05), + exchangeRatioToToken: tokens['DAI'], + sDaiToken: tokens['sDAI'], + exchangeRatioFromToken: tokens['USDC'], + exchangeRatio: NormalizedUnitNumber(0.9996), + sDaiBalanceBefore: NormalizedUnitNumber(5000), + sDaiBalanceAfter: NormalizedUnitNumber(10000), + }, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = getMobileStory(Desktop) + +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.tsx b/packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.tsx new file mode 100644 index 000000000..1d92f730c --- /dev/null +++ b/packages/app/src/features/dialogs/savings/deposit/views/SavingsDepositView.tsx @@ -0,0 +1,54 @@ +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { DialogForm } from '@/features/dialogs/common/components/form/DialogForm' +import { FormAndOverviewWrapper } from '@/features/dialogs/common/components/FormAndOverviewWrapper' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' + +import { DepositOverviewPanel } from '../../common/components/DepositOverviewPanel' +import { SavingsDialogTxOverview } from '../logic/useTransactionOverview' + +export interface SavingsDepositViewProps { + selectableAssets: TokenWithBalance[] + assetsFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + pageStatus: PageStatus + txOverview: SavingsDialogTxOverview | undefined +} + +export function SavingsDepositView({ + selectableAssets, + assetsFields, + form, + objectives, + pageStatus, + txOverview, +}: SavingsDepositViewProps) { + return ( + + Deposit to Savings + + + + {txOverview && ( + + )} + + + + + ) +} diff --git a/packages/app/src/features/dialogs/savings/utils/formatWithHighPrecision.ts b/packages/app/src/features/dialogs/savings/utils/formatWithHighPrecision.ts new file mode 100644 index 000000000..2fc361447 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/utils/formatWithHighPrecision.ts @@ -0,0 +1,10 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +export function formatWithHighPrecision(value: NormalizedUnitNumber): string { + const formatter = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 5, + }) + + return formatter.format(value.toNumber()) +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.PageObject.ts b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.PageObject.ts new file mode 100644 index 000000000..4c47a1781 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.PageObject.ts @@ -0,0 +1,18 @@ +import { Page } from '@playwright/test' + +import { DialogPageObject } from '../../common/Dialog.PageObject' + +export class SavingsWithdrawDialogPageObject extends DialogPageObject { + constructor(page: Page) { + super(page, /Withdraw from Savings/) + } + + // #region actions + async clickBackToSavingsButton(): Promise { + await this.page.getByRole('button', { name: 'Back to Savings' }).click() + await this.region.waitFor({ + state: 'detached', + }) + } + // #endregion +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.test-e2e.ts b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.test-e2e.ts new file mode 100644 index 000000000..acf174f1e --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.test-e2e.ts @@ -0,0 +1,144 @@ +import { test } from '@playwright/test' + +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { SavingsPageObject } from '@/pages/Savings.PageObject' +import { overrideLiFiRoute } from '@/test/e2e/lifi' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' + +import { SavingsWithdrawDialogPageObject } from './SavingsWithdrawDialog.PageObject' + +// Block number has to be as close as possible to the block number when query was executed +const blockNumber = 19532848n + +test.describe('Savings withdraw dialog', () => { + test.describe('DAI', () => { + const fork = setupFork(blockNumber) + + test('unwraps sDAI to DAI', async ({ page }) => { + const { account } = await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + ETH: 1, + sDAI: 1000, + }, + }, + }) + await overrideLiFiRoute(page, account, 'sdai-to-100-dai', blockNumber) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.clickWithdrawButtonAction() + + const withdrawDialog = new SavingsWithdrawDialogPageObject(page) + await withdrawDialog.fillAmountAction(100) + + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await withdrawDialog.clickBackToSavingsButton() + + await savingsPage.expectCurrentWorth('991.352') + await savingsPage.expectCashInWalletAssetBalance('DAI', '100.55') + }) + + test.describe('on fork', () => { + const blockNumber = 19609252n + const fork = setupFork(blockNumber) + + test('unwraps ALL sDAI to DAI', async ({ page }) => { + const { account } = await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + ETH: 1, + sDAI: 100, + }, + }, + }) + await overrideLiFiRoute(page, account, '100-sdai-to-dai', blockNumber) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.clickWithdrawButtonAction() + + const withdrawDialog = new SavingsWithdrawDialogPageObject(page) + await withdrawDialog.clickMaxAmountAction() + + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await withdrawDialog.clickBackToSavingsButton() + + await savingsPage.expectCashInWalletAssetBalance('DAI', '106.94') + }) + }) + }) + + test.describe('USDC', () => { + const fork = setupFork(blockNumber) + + test('unwraps sDAI to USDC', async ({ page }) => { + const { account } = await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + ETH: 1, + sDAI: 1000, + }, + }, + }) + await overrideLiFiRoute(page, account, 'sdai-to-100-usdc', blockNumber) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.clickWithdrawButtonAction() + + const withdrawDialog = new SavingsWithdrawDialogPageObject(page) + await withdrawDialog.selectAssetAction('USDC') + await withdrawDialog.fillAmountAction(100) + + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await withdrawDialog.clickBackToSavingsButton() + + await savingsPage.expectCurrentWorth('991.375') + await savingsPage.expectCashInWalletAssetBalance('USDC', '100.54') + }) + + test.describe('on fork', () => { + const blockNumber = 19609941n + const fork = setupFork(blockNumber) + + test('unwraps ALL sDAI to USDC', async ({ page }) => { + const { account } = await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + ETH: 1, + sDAI: 100, + }, + }, + }) + await overrideLiFiRoute(page, account, '100-sdai-to-usdc', blockNumber) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.clickWithdrawButtonAction() + + const withdrawDialog = new SavingsWithdrawDialogPageObject(page) + await withdrawDialog.selectAssetAction('USDC') + await withdrawDialog.clickMaxAmountAction() + + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await withdrawDialog.clickBackToSavingsButton() + + await savingsPage.expectCashInWalletAssetBalance('USDC', '107.35') + }) + }) + }) +}) diff --git a/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.tsx b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.tsx new file mode 100644 index 000000000..eae307e26 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialog.tsx @@ -0,0 +1,14 @@ +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { CommonDialogProps } from '../../common/types' +import { SavingsWithdrawDialogContentContainer } from './SavingsWithdrawDialogContentContainer' + +export function SavingsWithdrawDialog({ open, setOpen }: CommonDialogProps) { + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialogContentContainer.tsx b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialogContentContainer.tsx new file mode 100644 index 000000000..cf81039fa --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/SavingsWithdrawDialogContentContainer.tsx @@ -0,0 +1,43 @@ +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../../common/components/skeletons/DialogContentSkeleton' +import { SuccessView } from '../../common/views/SuccessView' +import { useSavingsWithdrawDialog } from './logic/useSavingsWithdrawDialog' +import { SavingsWithdrawView } from './views/SavingsWithdrawView' + +export interface SavingsWithdrawContainerProps { + closeDialog: () => void +} + +function SavingsWithdrawDialogContentContainer({ closeDialog }: SavingsWithdrawContainerProps) { + const { selectableAssets, assetsFields, form, tokenToWithdraw, objectives, pageStatus, txOverview } = + useSavingsWithdrawDialog() + + if (pageStatus.state === 'success') { + return ( + + ) + } + + return ( + + ) +} + +const SavingsWithdrawDialogContentContainerWithSuspense = withSuspense( + SavingsWithdrawDialogContentContainer, + DialogContentSkeleton, +) +export { SavingsWithdrawDialogContentContainerWithSuspense as SavingsWithdrawDialogContentContainer } diff --git a/packages/app/src/features/dialogs/savings/withdraw/logic/form.ts b/packages/app/src/features/dialogs/savings/withdraw/logic/form.ts new file mode 100644 index 000000000..f4970f12b --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/logic/form.ts @@ -0,0 +1,43 @@ +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog } from '@/features/dialogs/common/types' + +interface getFormFieldsForWithdrawDialogParams { + form: UseFormReturn + marketInfo: MarketInfo + sDaiWithBalance: TokenWithBalance +} + +export function getFormFieldsForWithdrawDialog({ + form, + marketInfo, + sDaiWithBalance, +}: getFormFieldsForWithdrawDialogParams): FormFieldsForDialog { + // eslint-disable-next-line func-style + const changeAsset = (newSymbol: TokenSymbol): void => { + form.setValue('symbol', newSymbol) + form.setValue('value', '') + form.setValue('isMaxSelected', false) + + form.clearErrors() + } + + const { symbol, value } = form.getValues() + const token = marketInfo.findOneTokenBySymbol(symbol) + const usdBalance = sDaiWithBalance.token.toUSD(sDaiWithBalance.balance) + + return { + selectedAsset: { + value, + token, + balance: usdBalance, + }, + changeAsset, + maxValue: undefined, + maxSelectedFieldName: 'isMaxSelected', + } +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/logic/getSDaiWithBalance.ts b/packages/app/src/features/dialogs/savings/withdraw/logic/getSDaiWithBalance.ts new file mode 100644 index 000000000..e6d06bacd --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/logic/getSDaiWithBalance.ts @@ -0,0 +1,14 @@ +import { TokenWithBalance } from '@/domain/common/types' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { raise } from '@/utils/raise' + +export function getSDaiWithBalance(walletInfo: WalletInfo): TokenWithBalance { + const sDai = + walletInfo.walletBalances.find((balance) => balance.token.symbol === TokenSymbol('sDAI')) ?? + raise('sDAI balance not found') + return { + balance: sDai.balance, + token: sDai.token, + } +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/logic/objectives.ts b/packages/app/src/features/dialogs/savings/withdraw/logic/objectives.ts new file mode 100644 index 000000000..53ed3f0b8 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/logic/objectives.ts @@ -0,0 +1,17 @@ +import { SwapInfo, SwapParams } from '@/domain/exchanges/types' +import { ExchangeObjective } from '@/features/actions/flavours/exchange/types' +import { simplifyQueryResult } from '@/features/actions/logic/simplifyQueryResult' + +export interface CreateObjectivesParams { + swapInfo: SwapInfo + swapParams: SwapParams +} +export function createObjectives({ swapInfo, swapParams }: CreateObjectivesParams): ExchangeObjective[] { + return [ + { + type: 'exchange', + swapInfo: simplifyQueryResult(swapInfo), + swapParams, + }, + ] +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/logic/useSavingsWithdrawDialog.ts b/packages/app/src/features/dialogs/savings/withdraw/logic/useSavingsWithdrawDialog.ts new file mode 100644 index 000000000..24c6fdb60 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/logic/useSavingsWithdrawDialog.ts @@ -0,0 +1,90 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import invariant from 'tiny-invariant' + +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { useConditionalFreeze } from '@/domain/hooks/useConditionalFreeze' +import { useMakerInfo } from '@/domain/maker-info/useMakerInfo' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { makeAssetsInWalletList } from '@/domain/savings/makeAssetsInWalletList' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { Objective } from '@/features/actions/logic/types' +import { AssetInputSchema, normalizeDialogFormValues } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageState, PageStatus } from '@/features/dialogs/common/types' + +import { getFormFieldsForWithdrawDialog } from './form' +import { getSDaiWithBalance } from './getSDaiWithBalance' +import { createObjectives } from './objectives' +import { useSwap } from './useSwap' +import { SavingsDialogTxOverview, useTxOverview } from './useTransactionOverview' +import { getSavingsWithdrawDialogFormValidator } from './validation' + +export interface UseSavingsWithdrawDialogResults { + selectableAssets: TokenWithBalance[] + assetsFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + tokenToWithdraw: TokenWithValue + pageStatus: PageStatus + txOverview: SavingsDialogTxOverview | undefined +} + +export function useSavingsWithdrawDialog(): UseSavingsWithdrawDialogResults { + const { marketInfo } = useMarketInfo() + const { makerInfo } = useMakerInfo() + invariant(makerInfo, 'Maker info is not available') + const walletInfo = useWalletInfo() + + const [pageStatus, setPageStatus] = useState('form') + + const { assets: withdrawOptions } = makeAssetsInWalletList({ walletInfo }) + const sDaiWithBalance = getSDaiWithBalance(walletInfo) + + const form = useForm({ + resolver: zodResolver(getSavingsWithdrawDialogFormValidator(sDaiWithBalance)), + defaultValues: { + symbol: TokenSymbol('DAI'), + value: '', + isMaxSelected: false, + }, + mode: 'onChange', + }) + const formValues = normalizeDialogFormValues(form.watch(), marketInfo) + + const { swapInfo, swapParams } = useSwap({ formValues, marketInfo, walletInfo }) + + const objectives = createObjectives({ + swapInfo, + swapParams, + }) + const txOverview = useTxOverview({ + formValues, + marketInfo, + walletInfo, + makerInfo, + swapInfo, + }) + const tokenToWithdraw = useConditionalFreeze( + { + token: formValues.token, + value: txOverview?.tokenWithdrew ?? formValues.value, + }, + pageStatus === 'success', + ) + + return { + selectableAssets: withdrawOptions, + assetsFields: getFormFieldsForWithdrawDialog({ form, marketInfo, sDaiWithBalance }), + form, + objectives, + tokenToWithdraw, + pageStatus: { + state: pageStatus, + actionsEnabled: (formValues.value.gt(0) && form.formState.isValid) || formValues.isMaxSelected, + goToSuccessScreen: () => setPageStatus('success'), + }, + txOverview, + } +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/logic/useSwap.ts b/packages/app/src/features/dialogs/savings/withdraw/logic/useSwap.ts new file mode 100644 index 000000000..5bfba7740 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/logic/useSwap.ts @@ -0,0 +1,45 @@ +import { useLifiQueryMetaEvaluator } from '@/domain/exchanges/lifi/meta' +import { useLiFiTxData } from '@/domain/exchanges/lifi/useLiFiTxData' +import { SwapInfo, SwapParams } from '@/domain/exchanges/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { useActionsSettings } from '@/domain/state' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { DialogFormNormalizedData } from '@/features/dialogs/common/logic/form' + +interface UseSwapParams { + formValues: DialogFormNormalizedData + marketInfo: MarketInfo + walletInfo: WalletInfo +} + +export function useSwap({ formValues, marketInfo, walletInfo }: UseSwapParams): { + swapInfo: SwapInfo + swapParams: SwapParams +} { + const actionsSettings = useActionsSettings() + const queryMetaEvaluator = useLifiQueryMetaEvaluator() + const sdai = marketInfo.findOneTokenBySymbol(TokenSymbol('sDAI')) + const sDaiBalance = walletInfo.findWalletBalanceForSymbol(TokenSymbol('sDAI')) + + const swapParams: SwapParams = formValues.isMaxSelected + ? { + type: 'direct', + fromToken: sdai, + toToken: formValues.token, + value: sDaiBalance, + maxSlippage: actionsSettings.exchangeMaxSlippage, + } + : { + type: 'reverse', + fromToken: sdai, + toToken: formValues.token, + value: NormalizedUnitNumber(formValues.value), + maxSlippage: actionsSettings.exchangeMaxSlippage, + } + + const swapInfo = useLiFiTxData({ swapParams, queryMetaEvaluator, enabled: true }) + + return { swapParams, swapInfo } +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/logic/useTransactionOverview.ts b/packages/app/src/features/dialogs/savings/withdraw/logic/useTransactionOverview.ts new file mode 100644 index 000000000..907866c8a --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/logic/useTransactionOverview.ts @@ -0,0 +1,70 @@ +import { SwapInfo } from '@/domain/exchanges/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { DialogFormNormalizedData } from '@/features/dialogs/common/logic/form' +import { convertSharesToDai } from '@/features/savings/logic/projections' + +interface UseTxOverviewParams { + formValues: DialogFormNormalizedData + marketInfo: MarketInfo + walletInfo: WalletInfo + makerInfo: MakerInfo + swapInfo: SwapInfo +} + +export interface SavingsDialogTxOverview { + exchangeRatioFromToken: Token + exchangeRatioToToken: Token + exchangeRatio: NormalizedUnitNumber + sDaiToken: Token + sDaiBalanceBefore: NormalizedUnitNumber + sDaiBalanceAfter: NormalizedUnitNumber + DSR: Percentage + tokenWithdrew: NormalizedUnitNumber +} + +export function useTxOverview({ + marketInfo, + formValues, + makerInfo, + walletInfo, + swapInfo, +}: UseTxOverviewParams): SavingsDialogTxOverview | undefined { + const otherToken = formValues.token + const sdai = marketInfo.findOneTokenBySymbol(TokenSymbol('sDAI')) + const dai = marketInfo.findOneTokenBySymbol(TokenSymbol('DAI')) + const sDaiBalance = walletInfo.findWalletBalanceForSymbol(TokenSymbol('sDAI')) + + if (!swapInfo.data) { + return undefined + } + + const sDaiAmountBaseUnit = swapInfo.data.estimate.fromAmount + const sDaiAmount = sdai.fromBaseUnit(sDaiAmountBaseUnit) + const otherTokenAmountBaseUnit = swapInfo.data.estimate.toAmount + const otherTokenAmount = otherToken.fromBaseUnit(otherTokenAmountBaseUnit) + + const daiAmountNormalized = convertSharesToDai({ + shares: sDaiAmount, + potParams: makerInfo.potParameters, + timestamp: marketInfo.timestamp, + }) + const daiToTokenRatio = NormalizedUnitNumber(otherTokenAmount.dividedBy(daiAmountNormalized)) + + const sDaiBalanceAfter = NormalizedUnitNumber(sDaiBalance.minus(sDaiAmount)) + + return { + exchangeRatioFromToken: dai, + exchangeRatioToToken: formValues.token, + sDaiToken: sdai, + exchangeRatio: daiToTokenRatio, + sDaiBalanceBefore: sDaiBalance, + sDaiBalanceAfter, + DSR: makerInfo.DSR, + tokenWithdrew: otherTokenAmount, + } +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/logic/validation.ts b/packages/app/src/features/dialogs/savings/withdraw/logic/validation.ts new file mode 100644 index 000000000..f2d5689e3 --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/logic/validation.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' + +import { TokenWithBalance } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getSavingsWithdrawDialogFormValidator(sDaiBalance: TokenWithBalance) { + return AssetInputSchema.superRefine((field, ctx) => { + const value = NormalizedUnitNumber(field.value === '' ? '0' : field.value) + const usdBalance = sDaiBalance.token.toUSD(sDaiBalance.balance) + + const issue = validateWithdraw({ + value, + user: { balance: usdBalance }, + }) + if (issue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: withdrawValidationIssueToMessage[issue], + path: ['value'], + }) + } + }) +} + +export type WithdrawValidationIssue = 'exceeds-balance' | 'value-not-positive' + +export interface ValidateWithdrawArgs { + value: NormalizedUnitNumber + user: { + balance: NormalizedUnitNumber + } +} + +export function validateWithdraw({ + value, + user: { balance }, +}: ValidateWithdrawArgs): WithdrawValidationIssue | undefined { + if (value.isLessThanOrEqualTo(0)) { + return 'value-not-positive' + } + + if (balance.lt(value)) { + return 'exceeds-balance' + } +} + +export const withdrawValidationIssueToMessage: Record = { + 'value-not-positive': 'Withdraw value should be positive', + 'exceeds-balance': 'Exceeds your balance', +} diff --git a/packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.stories.tsx b/packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.stories.tsx new file mode 100644 index 000000000..42ad8266c --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.stories.tsx @@ -0,0 +1,87 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { fakeBigInt } from '@storybook/utils' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { useForm } from 'react-hook-form' + +import { BaseUnitNumber, NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { SavingsWithdrawView } from './SavingsWithdrawView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Savings/Withdraw', + component: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm() as any + return + }, + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider()], + args: { + selectableAssets: [ + { + token: tokens['USDC'], + balance: NormalizedUnitNumber(50000), + }, + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(1), + }, + ], + assetsFields: { + selectedAsset: { + token: tokens['USDC'], + balance: NormalizedUnitNumber(50000), + value: '2000', + }, + changeAsset: () => {}, + }, + objectives: [ + { + type: 'exchange', + swapParams: { + fromToken: tokens['sDAI'], + toToken: tokens['USDC'], + type: 'reverse', + value: NormalizedUnitNumber(5000), + maxSlippage: Percentage(0.005), + }, + swapInfo: { + status: 'success', + data: { + fromToken: tokens['sDAI'].address, + toToken: tokens['USDC'].address, + type: 'reverse', + txRequest: { + data: '0x', + from: '0x', + gasLimit: fakeBigInt, + gasPrice: fakeBigInt, + to: '0x', + value: fakeBigInt, + }, + estimate: { + feeCostsUSD: NormalizedUnitNumber(0), + fromAmount: BaseUnitNumber(5000), + toAmount: BaseUnitNumber(5000), + }, + }, + }, + }, + ], + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = getMobileStory(Desktop) + +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.tsx b/packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.tsx new file mode 100644 index 000000000..6ca7245fb --- /dev/null +++ b/packages/app/src/features/dialogs/savings/withdraw/views/SavingsWithdrawView.tsx @@ -0,0 +1,60 @@ +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { DialogForm } from '@/features/dialogs/common/components/form/DialogForm' +import { FormAndOverviewWrapper } from '@/features/dialogs/common/components/FormAndOverviewWrapper' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' + +import { DepositOverviewPanel } from '../../common/components/DepositOverviewPanel' +import { SavingsDialogTxOverview } from '../logic/useTransactionOverview' + +export interface SavingsWithdrawViewProps { + selectableAssets: TokenWithBalance[] + assetsFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + pageStatus: PageStatus + txOverview: SavingsDialogTxOverview | undefined +} + +export function SavingsWithdrawView({ + selectableAssets, + assetsFields, + form, + objectives, + pageStatus, + txOverview, +}: SavingsWithdrawViewProps) { + return ( + + Withdraw from Savings + + + + {txOverview && ( + + )} + + + + + ) +} diff --git a/packages/app/src/features/dialogs/withdraw/WithdrawDialog.test-e2e.ts b/packages/app/src/features/dialogs/withdraw/WithdrawDialog.test-e2e.ts new file mode 100644 index 000000000..f9f9ec727 --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/WithdrawDialog.test-e2e.ts @@ -0,0 +1,411 @@ +import { test } from '@playwright/test' + +import { withdrawalValidationIssueToMessage } from '@/domain/market-validators/validateWithdraw' +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { BorrowPageObject } from '@/pages/Borrow.PageObject' +import { DashboardPageObject } from '@/pages/Dashboard.PageObject' +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' +import { screenshot } from '@/test/e2e/utils' + +import { DialogPageObject } from '../common/Dialog.PageObject' + +const headerRegExp = /Withdr*/ + +test.describe('Withdraw dialog', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + const initialBalances = { + wstETH: 100, + rETH: 100, + ETH: 100, + } + + test.describe('Position with deposit and borrow', () => { + const initialDeposits = { + wstETH: 2, + rETH: 2, + } as const + const daiToBorrow = 3500 + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(initialDeposits, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + // @todo This waits for the refetch of the data after successful borrow transaction to happen. + // This is no ideal, probably we need to refactor expectDepositTable so it takes advantage from + // playwright's timeouts instead of parsing it's current state. Then we would be able to + // easily wait for the table to be updated. + await dashboardPage.expectAssetToBeInDepositTable('DAI') + }) + + test('opens dialog with selected asset', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickWithdrawButtonAction('rETH') + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.expectSelectedAsset('rETH') + await withdrawDialog.expectDialogHeader('Withdraw rETH') + await withdrawDialog.expectHealthFactorBeforeVisible() + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-default-view') + }) + + test('calculates health factor changes correctly', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickWithdrawButtonAction('rETH') + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.fillAmountAction(1) + + await withdrawDialog.expectRiskLevelBefore('Moderate') + await withdrawDialog.expectHealthFactorBefore('2.32') + await withdrawDialog.expectRiskLevelAfter('Risky') + await withdrawDialog.expectHealthFactorAfter('1.76') + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-health-factor') + }) + + test('has correct action plan for erc-20', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickWithdrawButtonAction('rETH') + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.fillAmountAction(1) + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'withdraw', + asset: 'rETH', + amount: 1, + }, + ], + true, + ) + }) + + test('can withdraw erc-20', async ({ page }) => { + const withdraw = { + asset: 'rETH', + amount: 1, + } as const + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickWithdrawButtonAction(withdraw.asset) + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.fillAmountAction(withdraw.amount) + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await withdrawDialog.expectSuccessPage([withdraw], fork) + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-erc-20-success') + + await withdrawDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + ...initialDeposits, + [withdraw.asset]: initialDeposits[withdraw.asset] - withdraw.amount, + }) + }) + }) + + test.describe('Form validation', () => { + const initialDeposits = { + wstETH: 5, + rETH: 1, + } as const + const daiToBorrow = 4500 + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(initialDeposits, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + // @todo This waits for the refetch of the data after successful borrow transaction to happen. + // This is no ideal, probably we need to refactor expectDepositTable so it takes advantage from + // playwright's timeouts instead of parsing it's current state. Then we would be able to + // easily wait for the table to be updated. + await dashboardPage.expectAssetToBeInDepositTable('DAI') + }) + + test('cannot withdraw amount that will result in health factor under 1', async ({ page }) => { + const withdrawAsset = 'wstETH' + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + await dashboardPage.clickWithdrawButtonAction(withdrawAsset) + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.expectHealthFactorBefore('2.75') + await withdrawDialog.fillAmountAction(initialDeposits[withdrawAsset]) + await withdrawDialog.expectAssetInputError('Remaining collateral cannot support the loan') + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-cannot-support-loan') + }) + + test('cannot withdraw more than deposited', async ({ page }) => { + const withdrawAsset = 'rETH' + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectDepositTable(initialDeposits) + await dashboardPage.clickWithdrawButtonAction(withdrawAsset) + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.expectHealthFactorBefore('2.75') + await withdrawDialog.fillAmountAction(initialDeposits[withdrawAsset] + 1) + await withdrawDialog.expectAssetInputError(withdrawalValidationIssueToMessage['exceeds-balance']) + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-more-than-deposited') + }) + }) + + test.describe('Position with native deposit and borrow', () => { + const ETHdeposit = { + asset: 'ETH', + amount: 10, + } + const borrow = { + asset: 'DAI', + amount: 1000, + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + const actionsContainer = new ActionsPageObject(page) + await borrowPage.fillDepositAssetAction(0, ETHdeposit.asset, ETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.acceptAllActionsAction(2) + + await borrowPage.expectSuccessPage([ETHdeposit], borrow, fork) + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + }) + + // @note When ETH is deposited, deposit table shows WETH instead of ETH + test('has correct action plan for native asset', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickWithdrawButtonAction('WETH') + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.selectAssetAction('ETH') + await withdrawDialog.fillAmountAction(1) + await withdrawDialog.expectHealthFactorVisible() + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectActions( + [ + { + type: 'approve', + asset: 'aWETH', + amount: 1, + }, + { + type: 'withdraw', + asset: 'ETH', + amount: 1, + }, + ], + true, + ) + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-eth-action-plan') + }) + + // @note When ETH is deposited, deposit table shows WETH instead of ETH + test('can withdraw native asset', async ({ page }) => { + const withdrawAmount = 1 + + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.clickWithdrawButtonAction('WETH') + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.selectAssetAction('ETH') + await withdrawDialog.fillAmountAction(withdrawAmount) + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await withdrawDialog.expectSuccessPage( + [ + { + asset: 'ETH', + amount: withdrawAmount, + }, + ], + fork, + ) + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-eth-success') + + await withdrawDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + // @todo Figure out how WETH and ETH conversion should work + WETH: ETHdeposit.amount - withdrawAmount, + }) + }) + }) + + test.describe('Position with only deposit', () => { + const initialDeposits = { + wstETH: 10, + ETH: 2, + } as const + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...initialBalances }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + // to simulate a position with only deposits, we go through the easy borrow flow + // but interrupt it before the borrow action, going directly to the dashboard + // this way we have deposit transactions executed, but no borrow transaction + // resulting in a position with only deposits + await borrowPage.fillDepositAssetAction(0, 'wstETH', initialDeposits.wstETH) + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillBorrowAssetAction(1) // doesn't matter, we're not borrowing anything + await borrowPage.fillDepositAssetAction(1, 'ETH', initialDeposits.ETH) + await borrowPage.submitAction() + + const actionsContainer = new ActionsPageObject(page) + await actionsContainer.acceptAllActionsAction(3) + await actionsContainer.expectNextActionEnabled() + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.goToDashboardAction() + }) + + test('can withdraw erc-20', async ({ page }) => { + const withdraw = { + asset: 'wstETH', + amount: 1, + } as const + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickWithdrawButtonAction(withdraw.asset) + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.fillAmountAction(withdraw.amount) + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await withdrawDialog.expectSuccessPage([withdraw], fork) + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-only-deposit-erc-20-success') + + await withdrawDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + WETH: initialDeposits.ETH, + [withdraw.asset]: initialDeposits[withdraw.asset] - withdraw.amount, + }) + }) + + test('can fully withdraw erc-20', async ({ page }) => { + const withdraw = { + asset: 'wstETH', + amount: 10, + } as const + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickWithdrawButtonAction(withdraw.asset) + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.clickMaxAmountAction() + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(1) + await withdrawDialog.expectSuccessPage([withdraw], fork) + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-only-deposit-erc-20-success') + + await withdrawDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + WETH: initialDeposits.ETH, + [withdraw.asset]: 0, + }) + }) + + test('does not display health factor', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickWithdrawButtonAction('wstETH') + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.fillAmountAction(1) + + await withdrawDialog.expectHealthFactorNotVisible() + + // @note this is needed for deterministic screenshots + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.expectNextActionEnabled() + + await screenshot(withdrawDialog.getDialog(), 'withdraw-dialog-only-deposit-health-factor') + }) + + test('can fully withdraw native asset', async ({ page }) => { + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.clickWithdrawButtonAction('WETH') + + const withdrawDialog = new DialogPageObject(page, headerRegExp) + await withdrawDialog.selectAssetAction('ETH') + await withdrawDialog.clickMaxAmountAction() + const actionsContainer = new ActionsPageObject(withdrawDialog.locatePanelByHeader('Actions')) + await actionsContainer.acceptAllActionsAction(2) + await withdrawDialog.expectSuccessPage( + [ + { + asset: 'ETH', + amount: initialDeposits.ETH, + }, + ], + fork, + ) + + await withdrawDialog.viewInDashboardAction() + + await dashboardPage.expectDepositTable({ + WETH: 0, + wstETH: initialDeposits.wstETH, + }) + }) + }) +}) diff --git a/packages/app/src/features/dialogs/withdraw/WithdrawDialog.tsx b/packages/app/src/features/dialogs/withdraw/WithdrawDialog.tsx new file mode 100644 index 000000000..9c221127b --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/WithdrawDialog.tsx @@ -0,0 +1,19 @@ +import { Token } from '@/domain/types/Token' +import { Dialog, DialogContent } from '@/ui/atoms/dialog/Dialog' + +import { CommonDialogProps } from '../common/types' +import { WithdrawDialogContentContainer } from './WithdrawDialogContentContainer' + +export interface WithdrawDialogProps extends CommonDialogProps { + token: Token +} + +export function WithdrawDialog({ token, open, setOpen }: WithdrawDialogProps) { + return ( + + + setOpen(false)} /> + + + ) +} diff --git a/packages/app/src/features/dialogs/withdraw/WithdrawDialogContentContainer.tsx b/packages/app/src/features/dialogs/withdraw/WithdrawDialogContentContainer.tsx new file mode 100644 index 000000000..09ec07e3e --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/WithdrawDialogContentContainer.tsx @@ -0,0 +1,47 @@ +import { withSuspense } from '@/ui/utils/withSuspense' + +import { DialogContentSkeleton } from '../common/components/skeletons/DialogContentSkeleton' +import { DialogContentContainerProps } from '../common/types' +import { SuccessView } from '../common/views/SuccessView' +import { useWithdrawDialog } from './logic/useWithdrawDialog' +import { WithdrawView } from './views/WithdrawView' + +function WithdrawDialogContentContainer({ token, closeDialog }: DialogContentContainerProps) { + const { + objectives, + withdrawOptions, + assetsToWithdrawFields, + pageStatus, + form, + withdrawAsset, + currentPositionOverview, + updatedPositionOverview, + } = useWithdrawDialog({ initialToken: token }) + + if (pageStatus.state === 'success') { + return ( + + ) + } + + return ( + + ) +} + +const WithdrawDialogContentContainerWithSuspense = withSuspense(WithdrawDialogContentContainer, DialogContentSkeleton) +export { WithdrawDialogContentContainerWithSuspense as WithdrawDialogContentContainer } diff --git a/packages/app/src/features/dialogs/withdraw/components/WithdrawOverviewPanel.tsx b/packages/app/src/features/dialogs/withdraw/components/WithdrawOverviewPanel.tsx new file mode 100644 index 000000000..6596b67de --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/components/WithdrawOverviewPanel.tsx @@ -0,0 +1,59 @@ +import { formatPercentage } from '@/domain/common/format' +import { TokenWithValue } from '@/domain/common/types' +import { DialogPanel } from '@/features/dialogs/common/components/DialogPanel' +import { DialogPanelTitle } from '@/features/dialogs/common/components/DialogPanelTitle' + +import { HealthFactorChange } from '../../common/components/HealthFactorChange' +import { TransactionOverviewDetailsItem } from '../../common/components/TransactionOverviewDetailsItem' +import { PositionOverview } from '../logic/types' + +export interface WithdrawOverviewPanelProps { + withdrawAsset: TokenWithValue + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} +export function WithdrawOverviewPanel({ + withdrawAsset, + currentPositionOverview, + updatedPositionOverview, +}: WithdrawOverviewPanelProps) { + return ( + + Transaction overview + + {formatPercentage(currentPositionOverview.supplyAPY)} + + + + + ) +} + +interface RemainingSupplyChangeProps { + withdrawAsset: TokenWithValue + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +function RemainingSupply({ + withdrawAsset, + currentPositionOverview, + updatedPositionOverview, +}: RemainingSupplyChangeProps) { + const { token } = withdrawAsset + const currentSupply = currentPositionOverview.tokenSupply + const updatedSupply = updatedPositionOverview?.tokenSupply + + return ( + + {token.format(updatedSupply ?? currentSupply, { style: 'auto' })} {token.symbol} + + ) +} diff --git a/packages/app/src/features/dialogs/withdraw/logic/assets.ts b/packages/app/src/features/dialogs/withdraw/logic/assets.ts new file mode 100644 index 000000000..f128308aa --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/logic/assets.ts @@ -0,0 +1,71 @@ +import invariant from 'tiny-invariant' + +import { NativeAssetInfo } from '@/config/chain/types' +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { applyTransformers } from '@/utils/applyTransformers' + +export function getTokenSupply(marketInfo: MarketInfo, withdrawAsset: TokenWithValue): NormalizedUnitNumber { + const position = marketInfo.findOnePositionBySymbol(withdrawAsset.token.symbol) + return NormalizedUnitNumber(position.collateralBalance.minus(withdrawAsset.value)) +} + +export interface GetWithdrawOptionsParams { + token: Token + marketInfo: MarketInfo + walletInfo: WalletInfo + nativeAssetInfo: NativeAssetInfo +} + +export function getWithdrawOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetWithdrawOptionsParams): TokenWithBalance[] { + const options = applyTransformers({ token, marketInfo, walletInfo, nativeAssetInfo })([ + getNativeAssetWithdrawOptions, + getDefaultWithdrawOptions, + ]) + invariant(options, `No deposit options found for token ${token.symbol}`) + + return options +} + +function getNativeAssetWithdrawOptions({ + token, + marketInfo, + walletInfo, + nativeAssetInfo, +}: GetWithdrawOptionsParams): TokenWithBalance[] | undefined { + const { nativeAssetSymbol, wrappedNativeAssetSymbol } = nativeAssetInfo + + if (token.symbol !== nativeAssetSymbol && token.symbol !== wrappedNativeAssetSymbol) { + return undefined + } + const native = marketInfo.findOneReserveBySymbol(nativeAssetSymbol) + const wrapped = marketInfo.findOneReserveBySymbol(wrappedNativeAssetSymbol) + + return [ + { + token: native.token, + balance: walletInfo.findWalletBalanceForToken(native.token), + }, + { + token: wrapped.token, + balance: walletInfo.findWalletBalanceForToken(wrapped.token), + }, + ] +} + +function getDefaultWithdrawOptions({ token, walletInfo }: GetWithdrawOptionsParams): TokenWithBalance[] { + return [ + { + token, + balance: walletInfo.findWalletBalanceForToken(token), + }, + ] +} diff --git a/packages/app/src/features/dialogs/withdraw/logic/form.ts b/packages/app/src/features/dialogs/withdraw/logic/form.ts new file mode 100644 index 000000000..f2f399e14 --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/logic/form.ts @@ -0,0 +1,79 @@ +import { UseFormReturn } from 'react-hook-form' +import { z } from 'zod' + +import { NativeAssetInfo } from '@/config/chain/types' +import { AaveData } from '@/domain/market-info/aave-data-layer/query' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { validateWithdraw, withdrawalValidationIssueToMessage } from '@/domain/market-validators/validateWithdraw' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +import { AssetInputSchema, normalizeDialogFormValues } from '../../common/logic/form' +import { FormFieldsForDialog } from '../../common/types' + +export interface GetWithdrawDialogFormValidatorOptions { + marketInfo: MarketInfo + aaveData: AaveData + nativeAssetInfo: NativeAssetInfo +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getWithdrawDialogFormValidator({ + marketInfo, + aaveData, + nativeAssetInfo, +}: GetWithdrawDialogFormValidatorOptions) { + return AssetInputSchema.superRefine((field, ctx) => { + const formWithdrawAsset = normalizeDialogFormValues(field, marketInfo) + const reserve = marketInfo.findOneReserveBySymbol(formWithdrawAsset.token.symbol) + const deposited = marketInfo.findOnePositionBySymbol(formWithdrawAsset.token.symbol).collateralBalance + + const updatedUserSummary = updatePositionSummary({ + withdrawals: [formWithdrawAsset], + marketInfo, + aaveData, + nativeAssetInfo, + }) + + const validationIssue = validateWithdraw({ + value: formWithdrawAsset.value, + asset: { status: reserve.status, maxLtv: updatedUserSummary.maxLoanToValue }, + user: { deposited, ltvAfterWithdrawal: updatedUserSummary.loanToValue }, + }) + if (validationIssue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: withdrawalValidationIssueToMessage[validationIssue], + path: ['value'], + }) + } + }) +} + +export function getFormFieldsForWithdrawDialog( + form: UseFormReturn, + marketInfo: MarketInfo, + walletInfo: WalletInfo, + maxValue: NormalizedUnitNumber, +): FormFieldsForDialog { + // eslint-disable-next-line func-style + const changeAsset = (newSymbol: TokenSymbol): void => { + form.setValue('symbol', newSymbol) + form.setValue('value', '') + form.clearErrors() + } + + const { symbol, value } = form.getValues() + + return { + selectedAsset: { + value, + token: marketInfo.findOneTokenBySymbol(symbol), + balance: walletInfo.findWalletBalanceForSymbol(symbol), + }, + changeAsset, + maxValue, + } +} diff --git a/packages/app/src/features/dialogs/withdraw/logic/getWithdrawInFullOptions.ts b/packages/app/src/features/dialogs/withdraw/logic/getWithdrawInFullOptions.ts new file mode 100644 index 000000000..5c006c6dd --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/logic/getWithdrawInFullOptions.ts @@ -0,0 +1,34 @@ +import { UseFormReturn } from 'react-hook-form' + +import { getWithdrawMaxValue } from '@/domain/action-max-value-getters/getWithdrawMaxValue' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { AssetInputSchema, isMaxValue } from '../../common/logic/form' + +interface UseWithdrawInFullOptionsResult { + withdrawInFull: boolean + maxWithdrawValue: NormalizedUnitNumber +} + +export function getWithdrawInFullOptions( + form: UseFormReturn, + marketInfo: MarketInfo, +): UseWithdrawInFullOptionsResult { + const { symbol } = form.getValues() + const position = marketInfo.findOnePositionBySymbol(symbol) + + const maxWithdrawValue = getWithdrawMaxValue({ + user: { deposited: position.collateralBalance }, + asset: { status: position.reserve.status }, + }) + + const withdrawInFull = isMaxWithdrawal(form, maxWithdrawValue) + + return { withdrawInFull, maxWithdrawValue } +} + +export function isMaxWithdrawal(form: UseFormReturn, maxValue: NormalizedUnitNumber): boolean { + const { value } = form.getValues() + return isMaxValue(value, maxValue) +} diff --git a/packages/app/src/features/dialogs/withdraw/logic/objectives.ts b/packages/app/src/features/dialogs/withdraw/logic/objectives.ts new file mode 100644 index 000000000..0d77b078c --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/logic/objectives.ts @@ -0,0 +1,21 @@ +import { Objective } from '@/features/actions/logic/types' + +import { DialogFormNormalizedData } from '../../common/logic/form' + +interface CreateWithdrawObjectivesOptions { + all?: boolean +} + +export function createWithdrawObjectives( + formValues: DialogFormNormalizedData, + { all = false }: CreateWithdrawObjectivesOptions = {}, +): Objective[] { + return [ + { + type: 'withdraw', + reserve: formValues.reserve, + value: formValues.value, + all, + }, + ] +} diff --git a/packages/app/src/features/dialogs/withdraw/logic/types.ts b/packages/app/src/features/dialogs/withdraw/logic/types.ts new file mode 100644 index 000000000..649305cb9 --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/logic/types.ts @@ -0,0 +1,9 @@ +import BigNumber from 'bignumber.js' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +export interface PositionOverview { + healthFactor: BigNumber | undefined + tokenSupply: NormalizedUnitNumber + supplyAPY: Percentage +} diff --git a/packages/app/src/features/dialogs/withdraw/logic/useWithdrawDialog.ts b/packages/app/src/features/dialogs/withdraw/logic/useWithdrawDialog.ts new file mode 100644 index 000000000..f27905d2b --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/logic/useWithdrawDialog.ts @@ -0,0 +1,110 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useState } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { useConditionalFreeze } from '@/domain/hooks/useConditionalFreeze' +import { useAaveDataLayer } from '@/domain/market-info/aave-data-layer/useAaveDataLayer' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { Token } from '@/domain/types/Token' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { Objective } from '@/features/actions/logic/types' + +import { AssetInputSchema, getActionAsset } from '../../common/logic/form' +import { useUpdateFormMaxValue } from '../../common/logic/useUpdateFormMaxValue' +import { FormFieldsForDialog, PageState, PageStatus } from '../../common/types' +import { getTokenSupply, getWithdrawOptions } from './assets' +import { getFormFieldsForWithdrawDialog, getWithdrawDialogFormValidator } from './form' +import { getWithdrawInFullOptions } from './getWithdrawInFullOptions' +import { createWithdrawObjectives } from './objectives' +import { PositionOverview } from './types' + +export interface UseWithdrawDialogOptions { + initialToken: Token +} + +export interface UseWithdrawDialogResult { + withdrawOptions: TokenWithBalance[] + assetsToWithdrawFields: FormFieldsForDialog + withdrawAsset: TokenWithValue + objectives: Objective[] + pageStatus: PageStatus + form: UseFormReturn + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +export function useWithdrawDialog({ initialToken }: UseWithdrawDialogOptions): UseWithdrawDialogResult { + const { aaveData } = useAaveDataLayer() + const { marketInfo } = useMarketInfo() + const walletInfo = useWalletInfo() + const nativeAssetInfo = getNativeAssetInfo(marketInfo.chainId) + + const [pageStatus, setPageStatus] = useState('form') + + const form = useForm({ + resolver: zodResolver(getWithdrawDialogFormValidator({ marketInfo, aaveData, nativeAssetInfo })), + defaultValues: { + symbol: initialToken.symbol, + value: '', + }, + mode: 'onChange', + }) + + const { withdrawInFull, maxWithdrawValue } = getWithdrawInFullOptions(form, marketInfo) + useUpdateFormMaxValue({ isMaxSet: withdrawInFull, maxValue: maxWithdrawValue, token: initialToken, form }) + + const withdrawOptions = getWithdrawOptions({ + token: initialToken, + marketInfo, + walletInfo, + nativeAssetInfo, + }) + + const withdrawAsset = useConditionalFreeze( + getActionAsset(form, marketInfo, maxWithdrawValue), + pageStatus === 'success', + ) + + const assetsToWithdrawFields = getFormFieldsForWithdrawDialog(form, marketInfo, walletInfo, maxWithdrawValue) + + const tokenSupply = getTokenSupply(marketInfo, withdrawAsset) + + const objectives = createWithdrawObjectives(withdrawAsset, { all: withdrawInFull }) + + const currentPositionOverview = { + healthFactor: marketInfo.userPositionSummary.healthFactor, + tokenSupply, + supplyAPY: withdrawAsset.reserve.supplyAPY, + } + const updatedUserSummary = updatePositionSummary({ + withdrawals: [withdrawAsset], + marketInfo, + aaveData, + nativeAssetInfo, + }) + const updatedPositionOverview = withdrawAsset.value.eq(0) + ? undefined + : { + ...currentPositionOverview, + healthFactor: updatedUserSummary.healthFactor, + tokenSupply, + } + + return { + form, + withdrawOptions, + assetsToWithdrawFields, + withdrawAsset, + objectives, + pageStatus: { + state: pageStatus, + actionsEnabled: withdrawAsset.value.gt(0) && form.formState.isValid, + goToSuccessScreen: () => setPageStatus('success'), + }, + currentPositionOverview, + updatedPositionOverview, + } +} diff --git a/packages/app/src/features/dialogs/withdraw/views/WithdrawView.stories.tsx b/packages/app/src/features/dialogs/withdraw/views/WithdrawView.stories.tsx new file mode 100644 index 000000000..926a7a7d4 --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/views/WithdrawView.stories.tsx @@ -0,0 +1,93 @@ +import { WithClassname, WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { chromatic } from '@storybook/viewports' +import BigNumber from 'bignumber.js' +import { useForm } from 'react-hook-form' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { getMockReserve } from '@/test/integration/constants' + +import { WithdrawView } from './WithdrawView' + +const meta: Meta = { + title: 'Features/Dialogs/Views/Withdraw', + component: (args) => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const form = useForm() as any + return + }, + decorators: [ZeroAllowanceWagmiDecorator(), WithClassname('max-w-xl'), WithTooltipProvider()], + args: { + withdrawOptions: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(50000), + }, + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + }, + ], + assetsToWithdrawFields: { + selectedAsset: { + token: tokens['DAI'], + balance: NormalizedUnitNumber(50000), + value: '2000', + }, + changeAsset: () => {}, + }, + withdrawAsset: { + token: tokens['DAI'], + value: NormalizedUnitNumber(2000), + }, + objectives: [ + { + type: 'withdraw', + reserve: getMockReserve({ + token: tokens['DAI'], + }), + value: NormalizedUnitNumber(2000), + all: false, + }, + ], + pageStatus: { + state: 'form', + actionsEnabled: true, + goToSuccessScreen: () => {}, + }, + currentPositionOverview: { + healthFactor: BigNumber(4), + tokenSupply: NormalizedUnitNumber(2000), + supplyAPY: Percentage(0.04), + }, + updatedPositionOverview: { + healthFactor: BigNumber(2), + tokenSupply: NormalizedUnitNumber(1000), + supplyAPY: Percentage(0.04), + }, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} + +export const Tablet: Story = { + parameters: { + viewport: { + defaultViewport: 'tablet', + }, + chromatic: { viewports: [chromatic.tablet] }, + }, +} diff --git a/packages/app/src/features/dialogs/withdraw/views/WithdrawView.tsx b/packages/app/src/features/dialogs/withdraw/views/WithdrawView.tsx new file mode 100644 index 000000000..a8716bc36 --- /dev/null +++ b/packages/app/src/features/dialogs/withdraw/views/WithdrawView.tsx @@ -0,0 +1,57 @@ +import { UseFormReturn } from 'react-hook-form' + +import { TokenWithBalance, TokenWithValue } from '@/domain/common/types' +import { Objective } from '@/features/actions/logic/types' +import { DialogActionsPanel } from '@/features/dialogs/common/components/DialogActionsPanel' +import { DialogForm } from '@/features/dialogs/common/components/form/DialogForm' +import { FormAndOverviewWrapper } from '@/features/dialogs/common/components/FormAndOverviewWrapper' +import { MultiPanelDialog } from '@/features/dialogs/common/components/MultiPanelDialog' +import { AssetInputSchema } from '@/features/dialogs/common/logic/form' +import { FormFieldsForDialog, PageStatus } from '@/features/dialogs/common/types' +import { DialogTitle } from '@/ui/atoms/dialog/Dialog' + +import { WithdrawOverviewPanel } from '../components/WithdrawOverviewPanel' +import { PositionOverview } from '../logic/types' + +export interface WithdrawViewProps { + withdrawOptions: TokenWithBalance[] + withdrawAsset: TokenWithValue + assetsToWithdrawFields: FormFieldsForDialog + form: UseFormReturn + objectives: Objective[] + pageStatus: PageStatus + currentPositionOverview: PositionOverview + updatedPositionOverview?: PositionOverview +} + +export function WithdrawView({ + withdrawOptions, + assetsToWithdrawFields, + form, + objectives, + pageStatus, + withdrawAsset, + currentPositionOverview, + updatedPositionOverview, +}: WithdrawViewProps) { + return ( + + {`Withdraw ${withdrawAsset.token.symbol}`} + + + + + + + + + ) +} diff --git a/packages/app/src/features/easy-borrow/EasyBorrowContainer.tsx b/packages/app/src/features/easy-borrow/EasyBorrowContainer.tsx new file mode 100644 index 000000000..999b0164f --- /dev/null +++ b/packages/app/src/features/easy-borrow/EasyBorrowContainer.tsx @@ -0,0 +1,63 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useChainId } from 'wagmi' + +import { withSuspense } from '@/ui/utils/withSuspense' + +import { EasyBorrowSkeleton } from './components/skeleton/EasyBorrowSkeleton' +import { useEasyBorrow } from './logic/useEasyBorrow' +import { EasyBorrowView } from './views/EasyBorrowView' +import { SuccessView } from './views/SuccessView' + +function EasyBorrowContainer() { + const { + form, + assetsToDepositFields, + assetsToBorrowFields, + updatedPositionSummary, + setDesiredLoanToValue, + liquidationDetails, + pageStatus, + actions, + tokensToDeposit, + tokensToBorrow, + alreadyDeposited, + alreadyBorrowed, + assetToBorrow, + guestMode, + healthFactorPanelRef, + } = useEasyBorrow() + const { openConnectModal = () => {} } = useConnectModal() + + if (pageStatus.state === 'success') { + return + } + + return ( + + ) +} + +// @note: forces form to reset when network changes +function EasyBorrowContainerWithKey() { + const chainId = useChainId() + return +} + +const EasyBorrowContainerWithSuspense = withSuspense(EasyBorrowContainerWithKey, EasyBorrowSkeleton) + +export { EasyBorrowContainerWithSuspense as EasyBorrowContainer } diff --git a/packages/app/src/features/easy-borrow/components/BorrowRateBanner.tsx b/packages/app/src/features/easy-borrow/components/BorrowRateBanner.tsx new file mode 100644 index 000000000..6d2b998f8 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/BorrowRateBanner.tsx @@ -0,0 +1,24 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { Typography } from '@/ui/atoms/typography/Typography' + +export interface BorrowRateBannerProps { + symbol: TokenSymbol + borrowRate: Percentage +} + +export function BorrowRateBanner({ symbol, borrowRate }: BorrowRateBannerProps) { + return ( +
+ + Spark your DeFi + + + Borrow {symbol} at{' '} + {formatPercentage(borrowRate)} directly from + Maker + +
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/EasyBorrowPanel.tsx b/packages/app/src/features/easy-borrow/components/EasyBorrowPanel.tsx new file mode 100644 index 000000000..d79a71a4f --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/EasyBorrowPanel.tsx @@ -0,0 +1,82 @@ +import { Trans } from '@lingui/macro' +import { X } from 'lucide-react' +import { UseFormReturn } from 'react-hook-form' + +import { LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { UserPositionSummary } from '@/domain/market-info/marketInfo' +import { Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { ActionsContainer } from '@/features/actions/ActionsContainer' +import { Objective } from '@/features/actions/logic/types' +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' +import { Typography } from '@/ui/atoms/typography/Typography' +import { HealthFactorPanel } from '@/ui/organisms/health-factor-panel/HealthFactorPanel' + +import { FormFieldsForAssetClass } from '../logic/form/form' +import { EasyBorrowFormSchema } from '../logic/form/validation' +import { ExistingPosition, PageStatus } from '../logic/types' +import { EasyBorrowForm } from './form/EasyBorrowForm' + +export interface EasyBorrowPanelProps { + pageStatus: PageStatus + + form: UseFormReturn + assetsToBorrowFields: FormFieldsForAssetClass + assetsToDepositFields: FormFieldsForAssetClass + alreadyDeposited: ExistingPosition + alreadyBorrowed: ExistingPosition + updatedPositionSummary: UserPositionSummary + setDesiredLoanToValue: (desiredLtv: Percentage) => void + liquidationDetails?: LiquidationDetails + + objectives: Objective[] + + assetToBorrow: { + symbol: TokenSymbol + borrowRate: Percentage + } + + guestMode: boolean + openConnectModal: () => void + + healthFactorPanelRef: React.RefObject +} + +export function EasyBorrowPanel(props: EasyBorrowPanelProps) { + const { pageStatus, updatedPositionSummary, objectives: actions, liquidationDetails, healthFactorPanelRef } = props + + return ( + +
+ + Borrow {props.assetToBorrow.symbol} + + {pageStatus.state === 'confirmation' && ( + + )} +
+ + + + {pageStatus.state === 'confirmation' && ( +
+ + +
+ )} +
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/form/Borrow.tsx b/packages/app/src/features/easy-borrow/components/form/Borrow.tsx new file mode 100644 index 000000000..825582976 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/Borrow.tsx @@ -0,0 +1,54 @@ +import { Control } from 'react-hook-form' + +import { paths } from '@/config/paths' +import { TokenWithBalance } from '@/domain/common/types' +import { Link } from '@/ui/atoms/link/Link' +import { Tooltip, TooltipContentShort, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { Typography } from '@/ui/atoms/typography/Typography' +import { AssetSelector } from '@/ui/molecules/asset-selector/AssetSelector' +import { ControlledMultiSelectorAssetInput } from '@/ui/organisms/multi-selector/MultiSelector' +import { testIds } from '@/ui/utils/testIds' +import { raise } from '@/utils/raise' + +import { EasyBorrowFormSchema } from '../../logic/form/validation' +import { ExistingPosition } from '../../logic/types' +import { TokenSummary } from './TokenSummary' + +interface BorrowProps { + selectedAssets: TokenWithBalance[] + alreadyBorrowed: ExistingPosition + control: Control + disabled: boolean +} + +export function Borrow({ selectedAssets, alreadyBorrowed, control, disabled }: BorrowProps) { + const { token } = selectedAssets[0] ?? raise('No borrow token selected') + + return ( +
+ + Borrow + + + {alreadyBorrowed.tokens.length > 0 && } + +
+ + + {}} disabled={disabled} /> + + + You can only borrow {token.symbol} using Easy Borrow Flow. Head to{' '} + Dashboard to borrow other assets. + + + +
+
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/form/Deposits.tsx b/packages/app/src/features/easy-borrow/components/form/Deposits.tsx new file mode 100644 index 000000000..93e35faea --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/Deposits.tsx @@ -0,0 +1,52 @@ +import { Plus } from 'lucide-react' +import { Control } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { Button } from '@/ui/atoms/button/Button' +import { Typography } from '@/ui/atoms/typography/Typography' +import { MultiAssetSelector } from '@/ui/organisms/multi-selector/MultiSelector' +import { testIds } from '@/ui/utils/testIds' + +import { EasyBorrowFormSchema } from '../../logic/form/validation' +import { ExistingPosition } from '../../logic/types' +import { TokenSummary } from './TokenSummary' + +export interface DepositsProps { + selectedAssets: TokenWithBalance[] + allAssets: TokenWithBalance[] + assetToMaxValue: Record + addAsset: () => void + removeAsset: (index: number) => void + changeAsset: (index: number, newAssetSymbol: TokenSymbol) => void + alreadyDeposited: ExistingPosition + control: Control + disabled?: boolean +} + +export function Deposits(props: DepositsProps) { + const { selectedAssets, allAssets, addAsset, alreadyDeposited, disabled } = props + + return ( +
+
+ Deposit required + +
+ + {alreadyDeposited.tokens.length > 0 && } + + +
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/form/EasyBorrowForm.tsx b/packages/app/src/features/easy-borrow/components/form/EasyBorrowForm.tsx new file mode 100644 index 000000000..68e03104d --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/EasyBorrowForm.tsx @@ -0,0 +1,108 @@ +import { Trans } from '@lingui/macro' +import { UseFormReturn } from 'react-hook-form' + +import { UserPositionSummary } from '@/domain/market-info/marketInfo' +import { Percentage } from '@/domain/types/NumericValues' +import { assets } from '@/ui/assets' +import { Button } from '@/ui/atoms/button/Button' +import { Form } from '@/ui/atoms/form/Form' +import { nonZeroOrDefault } from '@/utils/bigNumber' + +import { FormFieldsForAssetClass } from '../../logic/form/form' +import { EasyBorrowFormSchema } from '../../logic/form/validation' +import { ExistingPosition } from '../../logic/types' +import { EasyBorrowNote } from '../note/EasyBorrowNote' +import { Borrow } from './Borrow' +import { Deposits } from './Deposits' +import { LoanToValue } from './LoanToValue' +import { LoanToValueSlider } from './LoanToValueSlider' + +interface EasyBorrowFlowProps { + form: UseFormReturn + updatedPositionSummary: UserPositionSummary + assetsToBorrowFields: FormFieldsForAssetClass + assetsToDepositFields: FormFieldsForAssetClass + alreadyDeposited: ExistingPosition + alreadyBorrowed: ExistingPosition + onSubmit: () => void + setDesiredLoanToValue: (desiredLtv: Percentage) => void + disabled: boolean // whole form is disabled when when user submitted the form and actions are in progress + borrowRate: Percentage + guestMode: boolean + openConnectModal: () => void +} + +export function EasyBorrowForm(props: EasyBorrowFlowProps) { + const { + form, + onSubmit, + assetsToBorrowFields, + assetsToDepositFields, + alreadyDeposited, + alreadyBorrowed, + updatedPositionSummary, + setDesiredLoanToValue, + disabled, + borrowRate, + guestMode, + openConnectModal, + } = props + + return ( +
+ +
+ +
+ +
+ +
+ + + + + + + + {guestMode ? ( + + ) : ( + !disabled && ( + + ) + )} + + + ) +} diff --git a/packages/app/src/features/easy-borrow/components/form/LoanToValue.tsx b/packages/app/src/features/easy-borrow/components/form/LoanToValue.tsx new file mode 100644 index 000000000..746b8a093 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/LoanToValue.tsx @@ -0,0 +1,29 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { Typography } from '@/ui/atoms/typography/Typography' +import { testIds } from '@/ui/utils/testIds' + +interface LoanToValueProps { + className?: string + loanToValue?: Percentage + maxLoanToValue?: Percentage +} +export function LoanToValue({ className, loanToValue, maxLoanToValue }: LoanToValueProps) { + if (!loanToValue || !maxLoanToValue) return null + + return ( +
+
+ Loan to Value (LTV) + + {formatPercentage(loanToValue)} + +
+ +
+ Max LTV + max {formatPercentage(maxLoanToValue)} +
+
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.stories.tsx b/packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.stories.tsx new file mode 100644 index 000000000..0920e7e08 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.stories.tsx @@ -0,0 +1,33 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { useState } from 'react' + +import { Percentage } from '@/domain/types/NumericValues' + +import { LoanToValueSlider } from './LoanToValueSlider' + +const meta: Meta = { + title: 'Features/EasyBorrow/Components/Form/LoanToValueSlider', + component: LoanToValueSlider, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + render: () => , +} + +function Component() { + const [value, setValue] = useState(Percentage(0.55)) + + return ( + setValue(v)} + /> + ) +} diff --git a/packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.tsx b/packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.tsx new file mode 100644 index 000000000..aa228954e --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/LoanToValueSlider.tsx @@ -0,0 +1,149 @@ +import { valueToBigNumber } from '@aave/math-utils' +import * as SliderPrimitive from '@radix-ui/react-slider' + +import { formatPercentage } from '@/domain/common/format' +import { MODERATE_HEALTH_FACTOR_THRESHOLD, RISKY_HEALTH_FACTOR_THRESHOLD } from '@/domain/common/risk' +import { healthFactorToLtv } from '@/domain/market-info/math' +import { Percentage } from '@/domain/types/NumericValues' +import { assets } from '@/ui/assets' +import { Typography } from '@/ui/atoms/typography/Typography' +import { cn } from '@/ui/utils/style' + +export interface LoanToValueSliderProps { + className: string + ltv: Percentage + maxAvailableLtv: Percentage + liquidationLtv: Percentage + disabled?: boolean + onLtvChange: (value: Percentage) => void +} + +export function LoanToValueSlider({ + className, + maxAvailableLtv, + liquidationLtv, + disabled, + ltv, + onLtvChange, +}: LoanToValueSliderProps) { + const steps = getSliderSteps(liquidationLtv) + const value = ltv.toNumber() * maxSliderValue + const maxSelectableValue = maxAvailableLtv.toNumber() * maxSliderValue + const liquidationValue = liquidationLtv.toNumber() * 100 + + // eslint-disable-next-line func-style + const onValueChange = (values: number[]) => { + const value = Math.min(values[0] ?? 0, maxSelectableValue) + + const newLTV = valueToBigNumber(value).dividedBy(maxSliderValue) + + onLtvChange(Percentage(newLTV)) + } + + return ( + + + {steps.map((step, index) => ( +
= step.from && + (step.noUpperLimit || (value / maxSliderValue) * 100 < step.from + step.width) + ? step.colorActive + : step.colorInactive, + index === 0 && 'rounded-s-lg', + )} + style={{ + width: `${step.width}%`, + left: `${step.from}%`, + }} + > + + {step.label} + +
+ ))} + +
+ +
+ + Liquidation + + + {formatPercentage(liquidationLtv)} + +
+ + + + + + + + + ) +} + +const maxSliderValue = 10000 + +interface Step { + label: string + width: number + from: number + colorActive: string + colorInactive: string + noUpperLimit?: boolean +} + +function getSliderSteps(liquidationThreshold: Percentage): Step[] { + const conservative: Step = { + from: 0, + width: healthFactorToLtv(MODERATE_HEALTH_FACTOR_THRESHOLD, liquidationThreshold).toNumber() * 100, + colorActive: 'bg-product-green', + colorInactive: 'bg-product-green/inactive', + label: 'Conservative', + } + + const moderate: Step = { + from: conservative.width, + width: healthFactorToLtv(RISKY_HEALTH_FACTOR_THRESHOLD, liquidationThreshold).toNumber() * 100 - conservative.width, + colorActive: 'bg-product-orange', + colorInactive: 'bg-product-orange/inactive', + label: 'Moderate', + } + + const aggressive: Step = { + from: conservative.width + moderate.width, + width: liquidationThreshold.toNumber() * 100 - conservative.width - moderate.width, + colorActive: 'bg-product-red', + colorInactive: 'bg-product-red/inactive', + label: 'Aggressive', + noUpperLimit: true, + } + + return [conservative, moderate, aggressive] +} diff --git a/packages/app/src/features/easy-borrow/components/form/TokenSummary.stories.tsx b/packages/app/src/features/easy-borrow/components/form/TokenSummary.stories.tsx new file mode 100644 index 000000000..efeb1e148 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/TokenSummary.stories.tsx @@ -0,0 +1,35 @@ +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { TokenSummary } from './TokenSummary' + +const meta: Meta = { + title: 'Features/EasyBorrow/Components/Form/TokenSummary', + component: TokenSummary, + args: { + position: { + tokens: [tokens['ETH'], tokens['DAI'], tokens['USDC']], + totalValueUSD: NormalizedUnitNumber(100_000), + }, + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const ManySymbols: Story = { + name: 'Many symbols', + args: { + position: { + tokens: [tokens['ETH'], tokens['DAI'], tokens['USDC'], tokens['USDT'], tokens['GNO']], + totalValueUSD: NormalizedUnitNumber(100_000), + }, + }, +} diff --git a/packages/app/src/features/easy-borrow/components/form/TokenSummary.tsx b/packages/app/src/features/easy-borrow/components/form/TokenSummary.tsx new file mode 100644 index 000000000..4977793e3 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/form/TokenSummary.tsx @@ -0,0 +1,29 @@ +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { getTokenImage } from '@/ui/assets' +import { Typography } from '@/ui/atoms/typography/Typography' +import { IconStack } from '@/ui/molecules/icon-stack/IconStack' + +import { ExistingPosition } from '../../logic/types' + +export interface TokenSummaryProps { + position: ExistingPosition + type: 'borrow' | 'deposit' + maxSymbols?: number +} + +export function TokenSummary({ position, type, maxSymbols = 3 }: TokenSummaryProps) { + const summary = `Already ${type === 'borrow' ? 'borrowed' : 'deposited'} ~${USD_MOCK_TOKEN.formatUSD( + position.totalValueUSD, + )}` + + const tokenIconPaths = position.tokens.map((token) => getTokenImage(token.symbol)) + + return ( +
+ + + {summary} + +
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/note/BorrowRate.tsx b/packages/app/src/features/easy-borrow/components/note/BorrowRate.tsx new file mode 100644 index 000000000..f05dcd25c --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/note/BorrowRate.tsx @@ -0,0 +1,25 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { Typography } from '@/ui/atoms/typography/Typography' +import { testIds } from '@/ui/utils/testIds' + +export interface BorrowRateProps { + borrowRate: Percentage +} + +export function BorrowRate({ borrowRate }: BorrowRateProps) { + return ( +
+ Borrow rate + + {formatPercentage(borrowRate, { skipSign: true })} + + % + + +
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/note/EasyBorrowNote.tsx b/packages/app/src/features/easy-borrow/components/note/EasyBorrowNote.tsx new file mode 100644 index 000000000..7674d86f1 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/note/EasyBorrowNote.tsx @@ -0,0 +1,15 @@ +import { Percentage } from '@/domain/types/NumericValues' + +import { BorrowRate } from './BorrowRate' + +export interface EasyBorrowNoteProps { + borrowRate: Percentage +} + +export function EasyBorrowNote({ borrowRate }: EasyBorrowNoteProps) { + return ( +
+ +
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/note/EasyBorrowSidePanel.tsx b/packages/app/src/features/easy-borrow/components/note/EasyBorrowSidePanel.tsx new file mode 100644 index 000000000..c35764c8b --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/note/EasyBorrowSidePanel.tsx @@ -0,0 +1,17 @@ +import { Percentage } from '@/domain/types/NumericValues' + +import { BorrowRate } from './BorrowRate' + +export interface EasyBorrowSidePanelProps { + borrowRate: Percentage +} + +export function EasyBorrowSidePanel({ borrowRate }: EasyBorrowSidePanelProps) { + return ( +
+
+ +
+
+ ) +} diff --git a/packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.stories.ts b/packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.stories.ts new file mode 100644 index 000000000..b41e7579b --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.stories.ts @@ -0,0 +1,23 @@ +import { Meta, StoryObj } from '@storybook/react' +import { chromatic } from '@storybook/viewports' + +import { EasyBorrowSkeleton } from './EasyBorrowSkeleton' + +const meta: Meta = { + title: 'Features/EasyBorrow/Components/Skeleton', + component: EasyBorrowSkeleton, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} diff --git a/packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.tsx b/packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.tsx new file mode 100644 index 000000000..234df7b16 --- /dev/null +++ b/packages/app/src/features/easy-borrow/components/skeleton/EasyBorrowSkeleton.tsx @@ -0,0 +1,43 @@ +import { Panel } from '@/ui/atoms/panel/Panel' +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' +import { PageLayout } from '@/ui/layouts/PageLayout' +import { useBreakpoint } from '@/ui/utils/useBreakpoint' + +export function EasyBorrowSkeleton() { + const tablet = useBreakpoint('md') + const desktop = useBreakpoint('xl') + return ( + +
+ + +
+
+ + + + + {!tablet && ( + <> + + + + )} + + + {!desktop && ( + <> + + +
+ + +
+ + )} + +
+
+
+ ) +} diff --git a/packages/app/src/features/easy-borrow/logic/assets.ts b/packages/app/src/features/easy-borrow/logic/assets.ts new file mode 100644 index 000000000..26a4140bc --- /dev/null +++ b/packages/app/src/features/easy-borrow/logic/assets.ts @@ -0,0 +1,33 @@ +import { NativeAssetInfo } from '@/config/chain/types' +import { MarketInfo, Reserve, UserPosition } from '@/domain/market-info/marketInfo' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +const blacklistedDepositableAssets = ['USDC', 'USDT', 'DAI', 'sDAI', 'XDAI'] +export function getDepositableAssets(reserves: Reserve[]): Reserve[] { + return reserves + .filter((r) => r.status === 'active' && !r.isIsolated) + .filter((r) => !blacklistedDepositableAssets.includes(r.token.symbol)) +} + +const whitelistedBorrowableAssets = ['DAI', 'WXDAI'] +export function getBorrowableAssets(reserves: Reserve[]): Reserve[] { + return reserves.filter((r) => whitelistedBorrowableAssets.includes(r.token.symbol)) +} + +export function sortByDecreasingBalances(reserves: Reserve[], walletInfo: WalletInfo): Reserve[] { + const reservesWithBalances = reserves.map((reserve) => ({ + reserve, + balance: walletInfo.findWalletBalanceForToken(reserve.token), + })) + const sortedReserves = reservesWithBalances.sort((a, b) => b.balance.minus(a.balance).toNumber()) + + return sortedReserves.map((r) => r.reserve) +} + +export function imputeNativeAsset(marketInfo: MarketInfo, nativeAssetInfo: NativeAssetInfo): UserPosition[] { + const positionsWithoutWrappedNativeAsset = marketInfo.userPositions.filter( + (p) => p.reserve.token.symbol !== nativeAssetInfo.wrappedNativeAssetSymbol, + ) + + return [...positionsWithoutWrappedNativeAsset, marketInfo.findOnePositionBySymbol(nativeAssetInfo.nativeAssetSymbol)] +} diff --git a/packages/app/src/features/easy-borrow/logic/form/form.ts b/packages/app/src/features/easy-borrow/logic/form/form.ts new file mode 100644 index 000000000..e33238aca --- /dev/null +++ b/packages/app/src/features/easy-borrow/logic/form/form.ts @@ -0,0 +1,161 @@ +import BigNumber from 'bignumber.js' +import { useFieldArray, UseFormReturn } from 'react-hook-form' + +import { getDepositMaxValue } from '@/domain/action-max-value-getters/getDepositMaxValue' +import { formFormat } from '@/domain/common/format' +import { TokenWithBalance } from '@/domain/common/types' +import { MarketInfo, Reserve, UserPositionSummary } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +import { EasyBorrowFormNormalizedData } from '../types' +import { EasyBorrowFormSchema } from './validation' + +export function getDefaultFormValues(borrowableAssets: Reserve[], depositableAssets: Reserve[]): EasyBorrowFormSchema { + // @todo apply better algorithm to select default assets, take into account balances etc + const defaultBorrowableAsset = borrowableAssets[0]! + const defaultDepositableAsset = depositableAssets[0]! + + return { + assetsToBorrow: [{ symbol: defaultBorrowableAsset.token.symbol, value: '' }], + assetsToDeposit: [{ symbol: defaultDepositableAsset.token.symbol, value: '' }], + } +} + +export interface FormFieldsForAssetClass { + selectedAssets: TokenWithBalance[] + allAssets: TokenWithBalance[] + assetToMaxValue: Record + changeAsset: (index: number, newSymbol: TokenSymbol) => void + addAsset: () => void + removeAsset: (index: number) => void +} + +export interface UseFormFieldsForAssetClassArgs { + form: UseFormReturn + marketInfo: MarketInfo + allPossibleReserves: Reserve[] + walletInfo: WalletInfo + type: 'borrow' | 'deposit' +} +export function useFormFieldsForAssetClass({ + form, + marketInfo, + allPossibleReserves, + walletInfo, + type, +}: UseFormFieldsForAssetClassArgs): FormFieldsForAssetClass { + const { append, remove } = useFieldArray({ + control: form.control, + name: getFormKeyBasedOnType(type), + }) + const fields = form.getValues(getFormKeyBasedOnType(type)) + + const reservesAvailableToPick = allPossibleReserves.filter( + (reserve) => !fields.some((f) => f.symbol === reserve.token.symbol), + ) + + const allAssets = allPossibleReserves.map((reserve): TokenWithBalance => { + return { + token: reserve.token, + balance: walletInfo.findWalletBalanceForToken(reserve.token), + } + }) + + const selectedAssets = fields.map((field): TokenWithBalance => { + const token = marketInfo.findOneTokenBySymbol(field.symbol) + + return { + token, + balance: walletInfo.findWalletBalanceForToken(token), + } + }) + + // eslint-disable-next-line func-style + const changeAsset = (index: number, newSymbol: TokenSymbol): void => { + form.setValue( + `${getFormKeyBasedOnType(type)}.${index}`, + { symbol: newSymbol, value: '' }, + { + shouldValidate: false, + }, + ) + } + + const assetToMaxValue = selectedAssets.reduce( + (acc, asset) => { + const position = marketInfo.findOnePositionBySymbol(asset.token.symbol) + const maxValue = getDepositMaxValue({ + asset: { + status: position.reserve.status, + totalDebt: position.reserve.totalDebt, + decimals: position.reserve.token.decimals, + index: position.reserve.variableBorrowIndex, + rate: position.reserve.variableBorrowRate, + lastUpdateTimestamp: position.reserve.lastUpdateTimestamp, + totalLiquidity: position.reserve.totalLiquidity, + supplyCap: position.reserve.supplyCap, + }, + user: { + balance: walletInfo.findWalletBalanceForSymbol(asset.token.symbol), + }, + timestamp: marketInfo.timestamp, + }) + acc[asset.token.symbol] = maxValue + return acc + }, + {} as Record, + ) + + // eslint-disable-next-line func-style + const addAsset = (): void => { + if (reservesAvailableToPick.length > 0) { + const reserve = reservesAvailableToPick[0]! + append({ symbol: reserve.token.symbol, value: '' }) + } + } + + return { + selectedAssets, + allAssets, + assetToMaxValue, + changeAsset, + addAsset, + removeAsset: remove, + } +} + +function getFormKeyBasedOnType(type: 'borrow' | 'deposit'): 'assetsToBorrow' | 'assetsToDeposit' { + return type === 'borrow' ? 'assetsToBorrow' : 'assetsToDeposit' +} + +interface SetDesiredLoanToValueProps { + control: UseFormReturn + formValues: EasyBorrowFormNormalizedData + userPositionSummary: UserPositionSummary + desiredLtv: Percentage +} + +export function setDesiredLoanToValue({ + control, + formValues, + userPositionSummary, + desiredLtv, +}: SetDesiredLoanToValueProps): void { + const borrowedAsset = formValues.borrows[0]! + const toAdd = userPositionSummary.totalCollateralUSD + .multipliedBy(desiredLtv) + .minus(userPositionSummary.totalBorrowsUSD) + .dividedBy(borrowedAsset.reserve.priceInUSD) + + const current = borrowedAsset.value + const result = current.plus(toAdd) + + const newBorrowedAssetUnit = BigNumber.max(0, formFormat(result)) + + control.setValue('assetsToBorrow.0.value', newBorrowedAssetUnit.toFixed(), { + shouldValidate: true, + shouldTouch: true, + }) +} diff --git a/packages/app/src/features/easy-borrow/logic/form/normalization.ts b/packages/app/src/features/easy-borrow/logic/form/normalization.ts new file mode 100644 index 000000000..8228cab4a --- /dev/null +++ b/packages/app/src/features/easy-borrow/logic/form/normalization.ts @@ -0,0 +1,24 @@ +import { ReserveWithValue } from '@/domain/common/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { EasyBorrowFormNormalizedData } from '../types' +import type { AssetInputSchema, EasyBorrowFormSchema } from './validation' + +export function normalizeFormValues( + values: EasyBorrowFormSchema, + marketInfo: MarketInfo, +): EasyBorrowFormNormalizedData { + function normalizeAsset(asset: AssetInputSchema): ReserveWithValue { + const reserve = marketInfo.findOneReserveBySymbol(asset.symbol) + return { + reserve, + value: NormalizedUnitNumber(asset.value === '' ? '0' : asset.value), + } + } + + return { + borrows: values.assetsToBorrow.map(normalizeAsset), + deposits: values.assetsToDeposit.map(normalizeAsset), + } +} diff --git a/packages/app/src/features/easy-borrow/logic/form/validation.ts b/packages/app/src/features/easy-borrow/logic/form/validation.ts new file mode 100644 index 000000000..7a66df8a8 --- /dev/null +++ b/packages/app/src/features/easy-borrow/logic/form/validation.ts @@ -0,0 +1,120 @@ +import * as z from 'zod' + +import { NativeAssetInfo } from '@/config/chain/types' +import { AaveData } from '@/domain/market-info/aave-data-layer/query' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { + borrowValidationIssueToMessage, + getValidateBorrowArgs, + validateBorrow, +} from '@/domain/market-validators/validateBorrow' +import { depositValidationIssueToMessage, validateDeposit } from '@/domain/market-validators/validateDeposit' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { parseBigNumber } from '@/utils/bigNumber' + +import { ExistingPosition } from '../types' +import { normalizeFormValues } from './normalization' + +const BaseAssetInputSchema = z.object({ + symbol: z.string().transform(TokenSymbol), + value: z.string().refine(() => true), // @note makes types consistent between input schemas and allows empty strings +}) + +export const AssetInputSchema = BaseAssetInputSchema.extend({ + value: z + .string() + .min(1, { message: 'Value is required' }) // @todo improve error messages + .refine( + (data) => { + const value = parseFloat(data) + return !isNaN(value) && value > 0 + }, + { + message: 'Value must be greater than zero', + }, + ), +}) + +export type AssetInputSchema = z.infer + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +function getDepositFieldsValidator(walletInfo: WalletInfo, alreadyDeposited: ExistingPosition, markets: MarketInfo) { + const schema = alreadyDeposited.totalValueUSD.gt(0) ? BaseAssetInputSchema : AssetInputSchema + return z.array( + schema.superRefine((field, ctx) => { + const value = NormalizedUnitNumber(parseBigNumber(field.value, 0)) + const balance = walletInfo.findWalletBalanceForSymbol(field.symbol) + const supplyingReserve = markets.findOneReserveBySymbol(field.symbol) + + const issue = validateDeposit({ + value, + asset: { + status: supplyingReserve.status, + totalLiquidity: supplyingReserve.totalLiquidity, + supplyCap: supplyingReserve.supplyCap, + }, + user: { balance, alreadyDepositedValueUSD: alreadyDeposited.totalValueUSD }, + }) + if (issue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: depositValidationIssueToMessage[issue], + path: ['value'], + }) + } + }), + ) +} + +export interface GetEasyBorrowFormValidatorOptions { + walletInfo: WalletInfo + marketInfo: MarketInfo + aaveData: AaveData + guestMode: boolean + alreadyDeposited: ExistingPosition + nativeAssetInfo: NativeAssetInfo +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getEasyBorrowFormValidator({ + walletInfo, + marketInfo, + aaveData, + guestMode, + alreadyDeposited, + nativeAssetInfo, +}: GetEasyBorrowFormValidatorOptions) { + return z + .object({ + assetsToBorrow: z.array(AssetInputSchema), + assetsToDeposit: guestMode + ? z.array(AssetInputSchema) + : getDepositFieldsValidator(walletInfo, alreadyDeposited, marketInfo), + }) + .superRefine((data, ctx) => { + const { borrows, deposits } = normalizeFormValues(data, marketInfo) + const updatedUserSummary = updatePositionSummary({ + borrows, + deposits, + marketInfo, + aaveData, + nativeAssetInfo, + }) + const value = borrows[0]!.value + const reserve = borrows[0]!.reserve + + const validationIssue = validateBorrow(getValidateBorrowArgs(value, reserve, marketInfo, updatedUserSummary)) + if (validationIssue) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: borrowValidationIssueToMessage[validationIssue], + path: ['assetsToBorrow.0.value'], + }) + } + }) +} + +export type EasyBorrowFormSchema = z.infer> diff --git a/packages/app/src/features/easy-borrow/logic/types.ts b/packages/app/src/features/easy-borrow/logic/types.ts new file mode 100644 index 000000000..851195047 --- /dev/null +++ b/packages/app/src/features/easy-borrow/logic/types.ts @@ -0,0 +1,21 @@ +import { ReserveWithValue } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export type PageState = 'form' | 'confirmation' | 'success' +export interface PageStatus { + state: PageState + onProceedToForm: () => void + goToSuccessScreen: () => void + submitForm: () => void +} + +export interface ExistingPosition { + tokens: Token[] + totalValueUSD: NormalizedUnitNumber +} + +export interface EasyBorrowFormNormalizedData { + borrows: ReserveWithValue[] // @todo: should it merge value of native base asset with wrapped native asset? + deposits: ReserveWithValue[] +} diff --git a/packages/app/src/features/easy-borrow/logic/useCreateObjectives.ts b/packages/app/src/features/easy-borrow/logic/useCreateObjectives.ts new file mode 100644 index 000000000..5156705c8 --- /dev/null +++ b/packages/app/src/features/easy-borrow/logic/useCreateObjectives.ts @@ -0,0 +1,36 @@ +import { lendingPoolAddress } from '@/config/contracts-generated' +import { useContractAddress } from '@/domain/hooks/useContractAddress' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { Objective } from '@/features/actions/logic/types' + +import { EasyBorrowFormNormalizedData } from './types' + +export function useCreateObjectives(formValues: EasyBorrowFormNormalizedData): Objective[] { + const lendingPool = useContractAddress(lendingPoolAddress) + + return [...createDepositObjectives(formValues, lendingPool), ...createBorrowObjectives(formValues)] +} + +function createDepositObjectives(formValues: EasyBorrowFormNormalizedData, lendingPool: CheckedAddress): Objective[] { + return formValues.deposits + .filter((deposit) => deposit.value.gt(0)) + .map((deposit): Objective => { + return { + type: 'deposit', + token: deposit.reserve.token, + value: deposit.value, + lendingPool, + } + }) +} + +function createBorrowObjectives(formValues: EasyBorrowFormNormalizedData): Objective[] { + return formValues.borrows.map((borrow): Objective => { + return { + type: 'borrow', + token: borrow.reserve.token, + value: borrow.value, + debtTokenAddress: borrow.reserve.variableDebtTokenAddress, + } + }) +} diff --git a/packages/app/src/features/easy-borrow/logic/useEasyBorrow.ts b/packages/app/src/features/easy-borrow/logic/useEasyBorrow.ts new file mode 100644 index 000000000..48ec7766a --- /dev/null +++ b/packages/app/src/features/easy-borrow/logic/useEasyBorrow.ts @@ -0,0 +1,212 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { useEffect, useRef, useState } from 'react' +import { useForm, UseFormReturn } from 'react-hook-form' +import invariant from 'tiny-invariant' +import { useAccount } from 'wagmi' + +import { getChainConfigEntry } from '@/config/chain' +import { TokenWithValue } from '@/domain/common/types' +import { useConditionalFreeze } from '@/domain/hooks/useConditionalFreeze' +import { useAaveDataLayer } from '@/domain/market-info/aave-data-layer/useAaveDataLayer' +import { getLiquidationDetails, LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { UserPositionSummary } from '@/domain/market-info/marketInfo' +import { updatePositionSummary } from '@/domain/market-info/updatePositionSummary' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { Objective } from '@/features/actions/logic/types' + +import { getBorrowableAssets, getDepositableAssets, imputeNativeAsset, sortByDecreasingBalances } from './assets' +import { + FormFieldsForAssetClass, + getDefaultFormValues, + setDesiredLoanToValue, + useFormFieldsForAssetClass, +} from './form/form' +import { normalizeFormValues } from './form/normalization' +import { EasyBorrowFormSchema, getEasyBorrowFormValidator } from './form/validation' +import { ExistingPosition, PageState, PageStatus } from './types' +import { useCreateObjectives } from './useCreateObjectives' + +export interface UseEasyBorrowResults { + pageStatus: PageStatus + + form: UseFormReturn + updatedPositionSummary: UserPositionSummary + assetsToBorrowFields: FormFieldsForAssetClass + assetsToDepositFields: FormFieldsForAssetClass + setDesiredLoanToValue: (desiredLtv: Percentage) => void + + actions: Objective[] + + tokensToBorrow: TokenWithValue[] + tokensToDeposit: TokenWithValue[] + alreadyDeposited: ExistingPosition + alreadyBorrowed: ExistingPosition + liquidationDetails?: LiquidationDetails + + assetToBorrow: { + symbol: TokenSymbol + borrowRate: Percentage + } + guestMode: boolean + + healthFactorPanelRef: React.RefObject +} + +export function useEasyBorrow(): UseEasyBorrowResults { + const account = useAccount() + const guestMode = !account.address + const { aaveData } = useAaveDataLayer() + const { marketInfo } = useMarketInfo() + const { + nativeAssetInfo, + meta: { defaultAssetToBorrow }, + } = getChainConfigEntry(marketInfo.chainId) + + const walletInfo = useWalletInfo() + const [pageStatus, setPageStatus] = useState('form') + const healthFactorPanelRef = useRef(null) + + const userPositions = imputeNativeAsset(marketInfo, nativeAssetInfo) + const alreadyDeposited = useConditionalFreeze( + { + tokens: userPositions + .filter((position) => position.collateralBalance.gt(0)) + .filter((position) => position.reserve.usageAsCollateralEnabledOnUser) + .map((position) => position.reserve.token), + totalValueUSD: marketInfo.userPositionSummary.totalCollateralUSD, + }, + pageStatus === 'confirmation', + ) + const alreadyBorrowed = useConditionalFreeze( + { + tokens: userPositions + .filter((position) => position.borrowBalance.gt(0)) + .map((position) => position.reserve.token), + totalValueUSD: marketInfo.userPositionSummary.totalBorrowsUSD, + }, + pageStatus === 'confirmation', + ) + + const depositableAssets = sortByDecreasingBalances( + getDepositableAssets(userPositions.map((p) => p.reserve)), + walletInfo, + ) + const borrowableAssets = getBorrowableAssets(marketInfo.reserves) + + invariant(depositableAssets.length > 0, 'No depositable assets') + invariant(borrowableAssets.length === 1, 'No borrowable assets') + + const easyBorrowForm = useForm({ + resolver: zodResolver( + getEasyBorrowFormValidator({ + walletInfo, + marketInfo, + aaveData, + guestMode, + alreadyDeposited, + nativeAssetInfo, + }), + ), + defaultValues: getDefaultFormValues(borrowableAssets, depositableAssets), + mode: 'onChange', + }) + const assetsToDepositFields = useFormFieldsForAssetClass({ + form: easyBorrowForm, + marketInfo, + allPossibleReserves: depositableAssets, + walletInfo, + type: 'deposit', + }) + const assetsToBorrowFields = useFormFieldsForAssetClass({ + form: easyBorrowForm, + marketInfo, + allPossibleReserves: borrowableAssets, + walletInfo, + type: 'borrow', + }) + const rawFormValues = easyBorrowForm.watch() + + const formValues = normalizeFormValues(rawFormValues, marketInfo) + const updatedUserSummary = useConditionalFreeze( + updatePositionSummary({ ...formValues, marketInfo, aaveData, nativeAssetInfo }), + pageStatus === 'confirmation', + ) + const assetsToDepositFieldsFrozen = useConditionalFreeze(assetsToDepositFields, pageStatus === 'confirmation') + const assetsToBorrowFieldsFrozen = useConditionalFreeze(assetsToBorrowFields, pageStatus === 'confirmation') + + const actions = useCreateObjectives(formValues) + + const tokensToBorrow = formValues.borrows.map((reserveWithValue) => ({ + token: reserveWithValue.reserve.token, + value: reserveWithValue.value, + })) + const tokensToDeposit = formValues.deposits + .filter((reserveWithValue) => reserveWithValue.value.gt(0)) + .map((reserveWithValue) => ({ + token: reserveWithValue.reserve.token, + value: reserveWithValue.value, + })) + + const liquidationDetails = useConditionalFreeze( + getLiquidationDetails({ + alreadyDeposited, + alreadyBorrowed, + marketInfo, + tokensToBorrow, + tokensToDeposit, + }), + pageStatus === 'confirmation', + ) + + const assetToBorrow = { + symbol: defaultAssetToBorrow, + borrowRate: marketInfo.findOneReserveBySymbol(defaultAssetToBorrow).variableBorrowApy, + } + + useEffect( + function revalidateFormOnNetworkChange() { + // eslint-disable-next-line no-console + easyBorrowForm.trigger().catch(console.error) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [account.chainId], + ) + useEffect(() => { + if (pageStatus === 'confirmation') { + healthFactorPanelRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + }, [pageStatus]) + + return { + form: easyBorrowForm, + updatedPositionSummary: updatedUserSummary, + assetsToDepositFields: assetsToDepositFieldsFrozen, + assetsToBorrowFields: assetsToBorrowFieldsFrozen, + setDesiredLoanToValue(desiredLtv: Percentage) { + setDesiredLoanToValue({ + control: easyBorrowForm, + formValues, + userPositionSummary: updatedUserSummary, + desiredLtv, + }) + }, + pageStatus: { + state: pageStatus, + onProceedToForm: () => setPageStatus('form'), + goToSuccessScreen: () => setPageStatus('success'), + submitForm: () => setPageStatus('confirmation'), + }, + actions, + tokensToBorrow, + tokensToDeposit, + alreadyDeposited, + alreadyBorrowed, + liquidationDetails, + assetToBorrow, + guestMode, + healthFactorPanelRef, + } +} diff --git a/packages/app/src/features/easy-borrow/views/EasyBorrowView.stories.tsx b/packages/app/src/features/easy-borrow/views/EasyBorrowView.stories.tsx new file mode 100644 index 000000000..a6ffb3762 --- /dev/null +++ b/packages/app/src/features/easy-borrow/views/EasyBorrowView.stories.tsx @@ -0,0 +1,413 @@ +import { WithTooltipProvider, ZeroAllowanceWagmiDecorator } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import BigNumber from 'bignumber.js' +import { useRef } from 'react' +import { useForm } from 'react-hook-form' +import { withRouter } from 'storybook-addon-react-router-v6' +import { zeroAddress } from 'viem' + +import { TokenWithBalance, TokenWithFormValue } from '@/domain/common/types' +import { UserPositionSummary } from '@/domain/market-info/marketInfo' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { Objective } from '@/features/actions/logic/types' + +import { EasyBorrowFormSchema } from '../logic/form/validation' +import { ExistingPosition, PageState } from '../logic/types' +import { EasyBorrowView } from './EasyBorrowView' + +interface EasyBorrowViewStoryProps { + assetsToDeposit: TokenWithFormValue[] + assetsToBorrow: TokenWithFormValue[] + alreadyDeposited: ExistingPosition + alreadyBorrowed: ExistingPosition + pageState: PageState + allAssets: TokenWithBalance[] + assetToMaxValue: Record + updatedPositionSummary: UserPositionSummary + actions: Objective[] + guestMode: boolean + assetToBorrow: { + symbol: TokenSymbol + borrowRate: Percentage + } +} + +function EasyBorrowViewStory(props: EasyBorrowViewStoryProps) { + const { + assetsToBorrow, + assetsToDeposit, + alreadyDeposited, + alreadyBorrowed, + pageState, + allAssets, + assetToMaxValue, + updatedPositionSummary, + actions, + guestMode, + assetToBorrow, + } = props + const form = useForm({ + defaultValues: { + assetsToBorrow, + assetsToDeposit, + }, + }) + + const assetsToDepositFields = { + selectedAssets: assetsToDeposit, + addAsset: () => {}, + removeAsset: () => {}, + allAssets, + assetToMaxValue, + changeAsset: () => {}, + } + const assetsToBorrowFields = { + selectedAssets: assetsToBorrow, + addAsset: () => {}, + removeAsset: () => {}, + allAssets, + assetToMaxValue, + changeAsset: () => {}, + } + + /* eslint-disable func-style */ + const setDesiredLoanToValue = () => {} + const openConnectModal = () => {} + /* eslint-enable func-style */ + + const pageStatus = { + state: pageState, + onProceedToForm: () => {}, + goToSuccessScreen: () => {}, + submitForm: () => {}, + } + const healthFactorPanelRef = useRef(null) + + return ( + + ) +} + +const meta: Meta = { + title: 'Features/EasyBorrow/Views/EasyBorrowView', + component: EasyBorrowViewStory, + decorators: [withRouter, WithTooltipProvider(), ZeroAllowanceWagmiDecorator()], + args: { + pageState: 'form', + allAssets: [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(1), + }, + { + token: tokens['wstETH'], + balance: NormalizedUnitNumber(1), + }, + { + token: tokens['rETH'], + balance: NormalizedUnitNumber(1), + }, + { + token: tokens['GNO'], + balance: NormalizedUnitNumber(1), + }, + ], + assetToMaxValue: { + [TokenSymbol('ETH')]: NormalizedUnitNumber(1), + [TokenSymbol('wstETH')]: NormalizedUnitNumber(1), + [TokenSymbol('rETH')]: NormalizedUnitNumber(1), + [TokenSymbol('GNO')]: NormalizedUnitNumber(1), + }, + assetsToBorrow: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(1000), + value: '', + }, + ], + assetsToDeposit: [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + value: '', + }, + ], + alreadyDeposited: { + tokens: [], + totalValueUSD: NormalizedUnitNumber(0), + }, + alreadyBorrowed: { + tokens: [], + totalValueUSD: NormalizedUnitNumber(0), + }, + updatedPositionSummary: { + availableBorrowsUSD: NormalizedUnitNumber(0), + currentLiquidationThreshold: Percentage(0.8), + loanToValue: Percentage(0), + healthFactor: undefined, + maxLoanToValue: Percentage(0.8), + totalBorrowsUSD: NormalizedUnitNumber(0), + totalCollateralUSD: NormalizedUnitNumber(0), + totalLiquidityUSD: NormalizedUnitNumber(0), + }, + guestMode: false, + assetToBorrow: { symbol: tokens.DAI.symbol, borrowRate: Percentage(0.0553) }, + actions: [], + }, +} + +export default meta +type Story = StoryObj + +export const InitialViewDesktop: Story = { + name: 'Initial View Desktop', +} + +export const InitialViewMobile = getMobileStory(InitialViewDesktop) +export const InitialViewTablet = getTabletStory(InitialViewDesktop) + +const depositETHArgs: Partial = { + assetsToBorrow: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(1000), + value: '1000', + }, + ], + assetsToDeposit: [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + ], + updatedPositionSummary: { + availableBorrowsUSD: NormalizedUnitNumber(1000), + currentLiquidationThreshold: Percentage(0.8), + loanToValue: Percentage(0.5), + healthFactor: new BigNumber(1.5), + maxLoanToValue: Percentage(0.8), + totalBorrowsUSD: NormalizedUnitNumber(10), + totalCollateralUSD: NormalizedUnitNumber(1000), + totalLiquidityUSD: NormalizedUnitNumber(1000), + }, +} + +export const DepositETHDesktop: Story = { + name: 'Deposit ETH desktop', + args: depositETHArgs, +} + +export const DepositETHMobile = getMobileStory(DepositETHDesktop) +export const DepositETHTablet = getTabletStory(DepositETHDesktop) + +const depositETHWithExistingPositionArgs: Partial = { + assetsToBorrow: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(1000), + value: '1000', + }, + ], + assetsToDeposit: [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + ], + alreadyDeposited: { + tokens: [tokens['ETH'], tokens['wstETH'], tokens['rETH'], tokens['GNO'], tokens['WBTC']], + totalValueUSD: NormalizedUnitNumber(1000), + }, + alreadyBorrowed: { + tokens: [tokens['DAI']], + totalValueUSD: NormalizedUnitNumber(500), + }, + updatedPositionSummary: { + availableBorrowsUSD: NormalizedUnitNumber(2000), + currentLiquidationThreshold: Percentage(0.8), + loanToValue: Percentage(0.5), + healthFactor: new BigNumber(1.5), + maxLoanToValue: Percentage(0.8), + totalBorrowsUSD: NormalizedUnitNumber(10), + totalCollateralUSD: NormalizedUnitNumber(2000), + totalLiquidityUSD: NormalizedUnitNumber(2000), + }, +} + +export const DepositETHWithExistingPositionDesktop: Story = { + name: 'Deposit ETH with existing position desktop', + args: depositETHWithExistingPositionArgs, +} + +export const DepositETHWithExistingPositionMobile = getMobileStory(DepositETHWithExistingPositionDesktop) +export const DepositETHWithExistingPositionTablet = getTabletStory(DepositETHWithExistingPositionDesktop) + +const depositETHActionsArgs: Partial = { + pageState: 'confirmation', + assetsToBorrow: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(1000), + value: '1000', + }, + ], + assetsToDeposit: [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + ], + updatedPositionSummary: { + availableBorrowsUSD: NormalizedUnitNumber(2000), + currentLiquidationThreshold: Percentage(0.8), + loanToValue: Percentage(0.5), + healthFactor: new BigNumber(1.5), + maxLoanToValue: Percentage(0.8), + totalBorrowsUSD: NormalizedUnitNumber(10), + totalCollateralUSD: NormalizedUnitNumber(2000), + totalLiquidityUSD: NormalizedUnitNumber(2000), + }, + actions: [ + { + type: 'deposit', + lendingPool: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + token: tokens['ETH'], + }, + { + type: 'borrow', + value: NormalizedUnitNumber(1000), + token: tokens['DAI'], + debtTokenAddress: CheckedAddress(zeroAddress), + }, + ], +} + +export const DepositETHActionDesktop: Story = { + name: 'Deposit ETH with actions desktop', + args: depositETHActionsArgs, +} + +export const DepositETHActionMobile = getMobileStory(DepositETHActionDesktop) +export const DepositETHActionTablet = getTabletStory(DepositETHActionDesktop) + +const depositErc20ActionArgs: Partial = { + pageState: 'confirmation', + assetsToBorrow: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(1000), + value: '1000', + }, + ], + assetsToDeposit: [ + { + token: tokens['wstETH'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + ], + updatedPositionSummary: { + availableBorrowsUSD: NormalizedUnitNumber(2000), + currentLiquidationThreshold: Percentage(0.8), + loanToValue: Percentage(0.5), + healthFactor: new BigNumber(1.5), + maxLoanToValue: Percentage(0.8), + totalBorrowsUSD: NormalizedUnitNumber(10), + totalCollateralUSD: NormalizedUnitNumber(2000), + totalLiquidityUSD: NormalizedUnitNumber(2000), + }, + actions: [ + { + type: 'deposit', + lendingPool: CheckedAddress(zeroAddress), + value: NormalizedUnitNumber(1), + token: tokens['wstETH'], + }, + { + type: 'borrow', + value: NormalizedUnitNumber(1000), + token: tokens['DAI'], + debtTokenAddress: CheckedAddress(zeroAddress), + }, + ], +} + +export const DepositErc20ActionDesktop: Story = { + name: 'Deposit ERC20 with actions desktop', + args: depositErc20ActionArgs, +} + +export const DepositErc20ActionMobile = getMobileStory(DepositErc20ActionDesktop) +export const DepositErc20ActionTablet = getTabletStory(DepositErc20ActionDesktop) + +const depositMultipleArgs: Partial = { + assetsToBorrow: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(1000), + value: '1000', + }, + ], + assetsToDeposit: [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + { + token: tokens['wstETH'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + { + token: tokens['rETH'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + { + token: tokens['GNO'], + balance: NormalizedUnitNumber(10), + value: '1', + }, + ], + updatedPositionSummary: { + availableBorrowsUSD: NormalizedUnitNumber(2000), + currentLiquidationThreshold: Percentage(0.8), + loanToValue: Percentage(0.5), + healthFactor: new BigNumber(1.5), + maxLoanToValue: Percentage(0.8), + totalBorrowsUSD: NormalizedUnitNumber(10), + totalCollateralUSD: NormalizedUnitNumber(2000), + totalLiquidityUSD: NormalizedUnitNumber(2000), + }, +} + +export const DepositMultipleDesktop: Story = { + name: 'Deposit multiple desktop', + args: depositMultipleArgs, +} + +export const DepositMultipleMobile = getMobileStory(DepositMultipleDesktop) +export const DepositMultipleTablet = getTabletStory(DepositMultipleDesktop) diff --git a/packages/app/src/features/easy-borrow/views/EasyBorrowView.tsx b/packages/app/src/features/easy-borrow/views/EasyBorrowView.tsx new file mode 100644 index 000000000..4542563cf --- /dev/null +++ b/packages/app/src/features/easy-borrow/views/EasyBorrowView.tsx @@ -0,0 +1,52 @@ +import { UseFormReturn } from 'react-hook-form' + +import { LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { UserPositionSummary } from '@/domain/market-info/marketInfo' +import { Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { Objective } from '@/features/actions/logic/types' +import { PageLayout } from '@/ui/layouts/PageLayout' + +import { BorrowRateBanner } from '../components/BorrowRateBanner' +import { EasyBorrowPanel } from '../components/EasyBorrowPanel' +import { EasyBorrowSidePanel } from '../components/note/EasyBorrowSidePanel' +import { FormFieldsForAssetClass } from '../logic/form/form' +import { EasyBorrowFormSchema } from '../logic/form/validation' +import { ExistingPosition, PageStatus } from '../logic/types' + +export interface EasyBorrowViewProps { + pageStatus: PageStatus + + form: UseFormReturn + assetsToBorrowFields: FormFieldsForAssetClass + assetsToDepositFields: FormFieldsForAssetClass + alreadyDeposited: ExistingPosition + alreadyBorrowed: ExistingPosition + updatedPositionSummary: UserPositionSummary + liquidationDetails?: LiquidationDetails + setDesiredLoanToValue: (desiredLtv: Percentage) => void + + objectives: Objective[] + + assetToBorrow: { + symbol: TokenSymbol + borrowRate: Percentage + } + guestMode: boolean + openConnectModal: () => void + + healthFactorPanelRef: React.RefObject +} + +export function EasyBorrowView(props: EasyBorrowViewProps) { + const { assetToBorrow } = props + return ( + + +
+ + +
+
+ ) +} diff --git a/packages/app/src/features/easy-borrow/views/SuccessView.stories.ts b/packages/app/src/features/easy-borrow/views/SuccessView.stories.ts new file mode 100644 index 000000000..c65c458ca --- /dev/null +++ b/packages/app/src/features/easy-borrow/views/SuccessView.stories.ts @@ -0,0 +1,64 @@ +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { chromatic } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { SuccessView } from './SuccessView' + +const meta: Meta = { + title: 'Features/EasyBorrow/Views/SuccessView', + component: SuccessView, + decorators: [withRouter], + args: { + deposited: [ + { + token: tokens['ETH'], + value: NormalizedUnitNumber(13.74), + }, + { + token: tokens['stETH'], + value: NormalizedUnitNumber(34.21), + }, + ], + borrowed: [ + { + token: tokens['DAI'], + value: NormalizedUnitNumber(50000), + }, + ], + runConfetti: false, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = { + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} + +export const OnlyBorrowed: Story = { + args: { + deposited: [], + }, +} + +export const OnlyBorrowedMobile: Story = { + args: { + deposited: [], + }, + parameters: { + viewport: { + defaultViewport: 'mobile', + }, + chromatic: { viewports: [chromatic.mobile] }, + }, +} diff --git a/packages/app/src/features/easy-borrow/views/SuccessView.tsx b/packages/app/src/features/easy-borrow/views/SuccessView.tsx new file mode 100644 index 000000000..62c0d48ce --- /dev/null +++ b/packages/app/src/features/easy-borrow/views/SuccessView.tsx @@ -0,0 +1,85 @@ +import { paths } from '@/config/paths' +import { TokenWithValue } from '@/domain/common/types' +import { assets } from '@/ui/assets' +import { LinkButton } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' +import { Typography } from '@/ui/atoms/typography/Typography' +import { PageLayout } from '@/ui/layouts/PageLayout' +import { Confetti } from '@/ui/molecules/confetti/Confetti' +import { cn } from '@/ui/utils/style' +import { testIds } from '@/ui/utils/testIds' +import { useBreakpoint } from '@/ui/utils/useBreakpoint' + +export interface SuccessViewProps { + deposited: TokenWithValue[] + borrowed: TokenWithValue[] + runConfetti: boolean +} + +export function SuccessView({ deposited, borrowed, runConfetti }: SuccessViewProps) { + const desktop = useBreakpoint('md') + + return ( + + +
+ success-img + + Congrats! All done! + + + + + Summary + + + +
+ {deposited.length > 0 && ( +
+ + Deposited + + {deposited.map((tokenWithValue) => ( + + ))} +
+ )} + +
+ + Borrowed + + {borrowed.map((tokenWithValue) => ( + + ))} +
+
+ + + View in dashboard + +
+
+
+
+ ) +} + +function Item({ token, value }: TokenWithValue) { + return ( +
+ + + {token.symbol} + +
+ {token.format(value, { style: 'auto' })} + + {token.formatUSD(value)} + +
+
+ ) +} diff --git a/packages/app/src/features/errors/ErrorContainer.tsx b/packages/app/src/features/errors/ErrorContainer.tsx new file mode 100644 index 000000000..8cd9cedba --- /dev/null +++ b/packages/app/src/features/errors/ErrorContainer.tsx @@ -0,0 +1,54 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useNavigate, useRouteError } from 'react-router-dom' +import { useAccountEffect } from 'wagmi' + +import { NotConnectedError } from '@/domain/errors/not-connected' +import { NotFoundError } from '@/domain/errors/not-found' +import { Button } from '@/ui/atoms/button/Button' +import { Typography } from '@/ui/atoms/typography/Typography' +import { ErrorLayout } from '@/ui/layouts/ErrorLayout' + +import { NotFound } from './NotFound' + +export function ErrorContainer() { + const error = useRouteError() + + if (error instanceof NotConnectedError) { + return + } + + if (error instanceof NotFoundError) { + return + } + + return +} + +export function NotConnected() { + const { openConnectModal } = useConnectModal() + const navigate = useNavigate() + useAccountEffect({ + onConnect: () => { + navigate(0) + }, + }) + + return ( + + This page is available ony for connected users + + + ) +} + +export function UnknownError() { + const navigate = useNavigate() + + return ( + + Oops + Something went wrong + + + ) +} diff --git a/packages/app/src/features/errors/NotFound.stories.ts b/packages/app/src/features/errors/NotFound.stories.ts new file mode 100644 index 000000000..f8d177f2c --- /dev/null +++ b/packages/app/src/features/errors/NotFound.stories.ts @@ -0,0 +1,18 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NotFound } from './NotFound' + +const meta: Meta = { + title: 'Features/Errors/NotFound', + decorators: [withRouter()], + component: NotFound, +} + +export default meta +type Story = StoryObj + +export const Default: Story = {} +export const Mobile: Story = getMobileStory(Default) +export const Tablet: Story = getTabletStory(Default) diff --git a/packages/app/src/features/errors/NotFound.tsx b/packages/app/src/features/errors/NotFound.tsx new file mode 100644 index 000000000..8f479c1c7 --- /dev/null +++ b/packages/app/src/features/errors/NotFound.tsx @@ -0,0 +1,15 @@ +import { paths } from '@/config/paths' +import { LinkButton } from '@/ui/atoms/button/Button' +import { ErrorLayout } from '@/ui/layouts/ErrorLayout' + +export function NotFound() { + return ( + +
+
404
+

The requested page could not be found.

+ Go to Homepage +
+
+ ) +} diff --git a/packages/app/src/features/market-details/MarketDetailsContainer.tsx b/packages/app/src/features/market-details/MarketDetailsContainer.tsx new file mode 100644 index 000000000..31aaf651e --- /dev/null +++ b/packages/app/src/features/market-details/MarketDetailsContainer.tsx @@ -0,0 +1,19 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' + +import { useOpenDialog } from '@/domain/state/dialogs' +import { withSuspense } from '@/ui/utils/withSuspense' + +import { MarketDetailsSkeleton } from './components/skeleton/MarketDetailsSkeleton' +import { useMarketDetails } from './logic/useMarketDetails' +import { MarketDetailsView } from './views/MarketDetailsView' + +function MarketDetailsContainer() { + const { openConnectModal = () => {} } = useConnectModal() + const marketDetails = useMarketDetails() + const openDialog = useOpenDialog() + + return +} + +const MarketDetailsContainerWithSuspense = withSuspense(MarketDetailsContainer, MarketDetailsSkeleton) +export { MarketDetailsContainerWithSuspense as MarketDetailsContainer } diff --git a/packages/app/src/features/market-details/components/charts/colors.ts b/packages/app/src/features/market-details/components/charts/colors.ts new file mode 100644 index 000000000..8563ce419 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/colors.ts @@ -0,0 +1,9 @@ +export const colors = { + tooltipLine: '#6A7692', + dot: '#0B2140', + dotStroke: 'white', + backgroundLine: '#D9D9D9', + axisTickLabel: '#6A7692', + primary: '#627EEA', + secondary: '#F2A52B', +} diff --git a/packages/app/src/features/market-details/components/charts/defaults.ts b/packages/app/src/features/market-details/components/charts/defaults.ts new file mode 100644 index 000000000..0eddefbd5 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/defaults.ts @@ -0,0 +1,2 @@ +export type Margins = { top: number; right: number; bottom: number; left: number } +export const defaultMargins: Margins = { top: 40, right: 20, bottom: 20, left: 40 } diff --git a/packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.stories.ts b/packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.stories.ts new file mode 100644 index 000000000..2d8e1a9ce --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.stories.ts @@ -0,0 +1,49 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { Percentage } from '@/domain/types/NumericValues' +import { bigNumberify } from '@/utils/bigNumber' + +import { InterestYieldChart } from './InterestYieldChart' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/Charts/InterestYield', + component: InterestYieldChart, +} + +export default meta +type Story = StoryObj + +export const wstETH: Story = { + name: 'wstETH', + args: { + optimalUtilizationRate: Percentage('0.45'), + utilizationRate: Percentage('0.00018168087759056403'), + variableRateSlope1: bigNumberify('45000000000000000000000000'), + variableRateSlope2: bigNumberify('800000000000000000000000000'), + baseVariableBorrowRate: bigNumberify('2500000000000000000000000'), + }, +} + +export const wstETHMobile: Story = { + ...getMobileStory(wstETH), + name: 'wstETH Mobile', +} +export const wstETHTablet: Story = { + ...getTabletStory(wstETH), + name: 'wstETH Tablet', +} + +export const DAI: Story = { + name: 'DAI', + args: { + optimalUtilizationRate: Percentage('1'), + utilizationRate: Percentage('0.97012653796557908901'), + variableRateSlope1: bigNumberify('0'), + variableRateSlope2: bigNumberify('0'), + baseVariableBorrowRate: bigNumberify('62599141818649791361008000'), + }, +} + +export const DAIMobile = getMobileStory(DAI) +export const DAITablet = getTabletStory(DAI) diff --git a/packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.tsx b/packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.tsx new file mode 100644 index 000000000..9627ec4fd --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/interest-yield/InterestYieldChart.tsx @@ -0,0 +1,70 @@ +import BigNumber from 'bignumber.js' +import { Circle } from 'lucide-react' + +import { Percentage } from '@/domain/types/NumericValues' +import { useParentSize } from '@/ui/utils/useParentSize' + +import { colors } from '../colors' +import { defaultMargins, Margins } from '../defaults' +import { Chart } from './components/Chart' +import { getYields } from './logic/getYields' + +export interface InterestYieldChartProps { + optimalUtilizationRate: Percentage + utilizationRate: Percentage + variableRateSlope1: BigNumber + variableRateSlope2: BigNumber + baseVariableBorrowRate: BigNumber +} +export function InterestYieldChart({ + optimalUtilizationRate, + utilizationRate, + variableRateSlope1, + variableRateSlope2, + baseVariableBorrowRate, +}: InterestYieldChartProps) { + const [ref, { width }] = useParentSize() + const yields = getYields({ + optimalUtilizationRate, + variableRateSlope1, + variableRateSlope2, + baseVariableBorrowRate, + }) + + const chartProps: React.ComponentProps & { margins: Margins } = { + data: yields, + width, + height: 180, + optimalUtilizationRate, + utilizationRate, + margins: { ...defaultMargins }, + } + + // if distance between optimal and current utilization rate is small, put the utilization rate label higher + if (optimalUtilizationRate.minus(utilizationRate).abs().lt(0.1)) { + chartProps.utilizationRateLabelMargin = -25 + chartProps.margins.top += 25 + chartProps.height += 25 + } + + // if optimal utilization rate is close to 100%, increase the right margin so that label fits + if (optimalUtilizationRate.minus(1).abs().lt(0.05)) { + chartProps.margins.right += 5 + } + + return ( +
+
+
+ +
Borrow APY
+
+
+ +
Utilization Rate
+
+
+ +
+ ) +} diff --git a/packages/app/src/features/market-details/components/charts/interest-yield/components/Chart.tsx b/packages/app/src/features/market-details/components/charts/interest-yield/components/Chart.tsx new file mode 100644 index 000000000..28d5c5b41 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/interest-yield/components/Chart.tsx @@ -0,0 +1,257 @@ +import { AxisBottom, AxisLeft } from '@visx/axis' +import { curveLinear } from '@visx/curve' +import { localPoint } from '@visx/event' +import { GridRows } from '@visx/grid' +import { Group } from '@visx/group' +import { scaleLinear } from '@visx/scale' +import { Bar, Line, LinePath } from '@visx/shape' +import { Text } from '@visx/text' +import { TooltipWithBounds, withTooltip } from '@visx/tooltip' +import { WithTooltipProvidedProps } from '@visx/tooltip/lib/enhancers/withTooltip' +import { extent, max, minIndex } from 'd3-array' +import { Fragment, MouseEvent, TouchEvent } from 'react' + +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' + +import { colors } from '../../colors' +import { defaultMargins, Margins } from '../../defaults' +import { GraphDataPoint } from '../types' + +export interface ChartProps { + width: number + height: number + margins?: Margins + xAxisNumTicks?: number + yAxisNumTicks?: number + data: GraphDataPoint[] + optimalUtilizationRate: Percentage + utilizationRate: Percentage + utilizationRateLabelMargin?: number +} + +function Chart({ + width, + height, + margins = defaultMargins, + xAxisNumTicks = 5, + yAxisNumTicks = 5, + showTooltip, + hideTooltip, + tooltipData, + tooltipLeft = 0, + data, + optimalUtilizationRate, + utilizationRate, + utilizationRateLabelMargin = 0, +}: ChartProps & WithTooltipProvidedProps) { + const innerWidth = width - margins.left - margins.right + const innerHeight = height - margins.top - margins.bottom + + const xValueScale = scaleLinear({ + range: [0, innerWidth], + domain: extent(data, ({ x }) => x) as [number, number], + }) + const yValueScale = scaleLinear({ + range: [innerHeight, 0], + domain: [0, (max(data, (d) => d.y) || 0) * 1.1], // 10% padding on top + nice: true, + }) + + function handleTooltip(event: TouchEvent | MouseEvent): void { + const point = localPoint(event) || { x: 0 } + const x = point.x - margins.left + const domainX = xValueScale.invert(x) + const closestElement = data[minIndex(data, (d) => Math.abs(d.x - domainX))] + showTooltip({ + tooltipData: closestElement, + tooltipLeft: x, + }) + } + + return ( + <> + + + + + xValueScale(data.x)} + y={(data) => yValueScale(data.y)} + curve={curveLinear} + /> + + ({ + fill: colors.axisTickLabel, + fontSize: 10, + textAnchor: 'middle', + dy: 4, + })} + /> + + ({ + fill: colors.axisTickLabel, + fontSize: 10, + dx: -margins.left + 10, + dy: 3, + })} + /> + + + + + + + {tooltipData && ( + + + + + + + + )} + + + + {tooltipData && ( + + + + )} + + ) +} + +function TooltipContent({ data }: { data: GraphDataPoint }) { + return ( +
+
+

Utilization Rate:

+

Borrow APY:

+
+
+

{formatPercentage(Percentage(data.x))}

+

{formatPercentage(Percentage(data.y, true))}

+
+
+ ) +} + +export interface UtilizationLineProps { + label: string + value: Percentage + xValueScale: (value: number) => number + innerHeight: number + marginY?: number +} +export function UtilizationLine({ label, value, xValueScale, innerHeight, marginY = 0 }: UtilizationLineProps) { + return ( + <> + + + {label} + + + {formatPercentage(value)} + + + ) +} + +const tickFormatter = new Intl.NumberFormat('en-US', { + maximumFractionDigits: 2, +}) + +function formatTicks(value: { valueOf(): number }) { + return `${tickFormatter.format(value.valueOf() * 100)}%` +} + +const ChartWithTooltip = withTooltip(Chart) +export { ChartWithTooltip as Chart } diff --git a/packages/app/src/features/market-details/components/charts/interest-yield/logic/getYields.ts b/packages/app/src/features/market-details/components/charts/interest-yield/logic/getYields.ts new file mode 100644 index 000000000..fe87471b9 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/interest-yield/logic/getYields.ts @@ -0,0 +1,60 @@ +import { RAY, rayDiv, rayMul, valueToBigNumber } from '@aave/math-utils' +import BigNumber from 'bignumber.js' + +import { Percentage } from '@/domain/types/NumericValues' +import { fromRay, toRay } from '@/utils/math' + +import { GraphDataPoint } from '../types' + +export interface InterestRateChartArgs { + optimalUtilizationRate: Percentage + variableRateSlope1: BigNumber + variableRateSlope2: BigNumber + baseVariableBorrowRate: BigNumber +} +export function getYields({ + optimalUtilizationRate, + variableRateSlope1, + variableRateSlope2, + baseVariableBorrowRate, +}: InterestRateChartArgs): GraphDataPoint[] { + const resolution = 200 + const step = 1 / resolution + + const optimalUtilizationRateRay = toRay(optimalUtilizationRate) + const yields: GraphDataPoint[] = [] + + for (let i = 0; i <= resolution; i++) { + const utilization = i * step + const utilizationRay = toRay(utilization) + + if (optimalUtilizationRate.gt(utilization)) { + const rate = fromRay( + baseVariableBorrowRate.plus(rayDiv(rayMul(variableRateSlope1, utilizationRay), optimalUtilizationRateRay)), + ).toNumber() + yields.push({ + x: utilization, + y: rateToYield(rate), + }) + } else { + const excess = RAY.minus(optimalUtilizationRateRay).eq(0) + ? valueToBigNumber(0) + : rayDiv(utilizationRay.minus(optimalUtilizationRateRay), RAY.minus(optimalUtilizationRateRay)) + const rate = fromRay( + baseVariableBorrowRate.plus(variableRateSlope1).plus(rayMul(variableRateSlope2, excess)), + ).toNumber() + yields.push({ + x: utilization, + y: rateToYield(rate), + }) + } + } + + return yields +} + +const SECONDS_PER_YEAR = 60 * 60 * 24 * 365 + +function rateToYield(rate: number): number { + return Math.pow(1 + rate / SECONDS_PER_YEAR, SECONDS_PER_YEAR) - 1 +} diff --git a/packages/app/src/features/market-details/components/charts/interest-yield/types.ts b/packages/app/src/features/market-details/components/charts/interest-yield/types.ts new file mode 100644 index 000000000..0f8954e27 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/interest-yield/types.ts @@ -0,0 +1,4 @@ +export interface GraphDataPoint { + x: number + y: number +} diff --git a/packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.stories.tsx b/packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.stories.tsx new file mode 100644 index 000000000..3ab5d03a3 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.stories.tsx @@ -0,0 +1,79 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { Legend } from './components/Legend' +import { MarketOverviewChart } from './MarketOverviewChart' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/Charts/MarketOverview', + decorators: [WithClassname('max-w-xs')], + component: MarketOverviewChart, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + data: [ + { value: 800_000_000, color: '#3F66EF' }, + { value: 200_000_000, color: '#33BE27' }, + ], + children: ( + + ), + }, +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) + +export const ZeroUtilization: Story = { + name: 'Zero Utilization', + args: { + data: [ + { value: 0, color: '#3F66EF' }, + { value: 1_000_000_000, color: '#33BE27' }, + ], + children: ( + + ), + }, +} +export const ZeroUtilizationMobile = getMobileStory(ZeroUtilization) +export const ZeroUtilizationTablet = getTabletStory(ZeroUtilization) + +export const FullUtilization: Story = { + name: 'Full Utilization', + args: { + data: [ + { value: 1_000_000_000, color: '#3F66EF' }, + { value: 0, color: '#33BE27' }, + ], + children: ( + + ), + }, +} +export const FullUtilizationMobile = getMobileStory(FullUtilization) +export const FullUtilizationTablet = getTabletStory(FullUtilization) diff --git a/packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.tsx b/packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.tsx new file mode 100644 index 000000000..0e164c530 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/market-overview/MarketOverviewChart.tsx @@ -0,0 +1,17 @@ +import { ReactNode } from 'react' + +import { DoughnutChart } from '@/ui/atoms/doughnut-chart/DoughnutChart' + +interface MarketOverviewChartProps { + data: { value: number; color: string }[] + children: ReactNode +} + +export function MarketOverviewChart({ data, children }: MarketOverviewChartProps) { + return ( +
+
{children}
+ +
+ ) +} diff --git a/packages/app/src/features/market-details/components/charts/market-overview/colors.ts b/packages/app/src/features/market-details/components/charts/market-overview/colors.ts new file mode 100644 index 000000000..b1b142de0 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/market-overview/colors.ts @@ -0,0 +1,5 @@ +export const colors = { + green: 'hsl(126, 41%, 59%)', + blue: 'hsl(227, 85%, 59%)', + orange: 'hsl(36, 90%, 51%)', +} diff --git a/packages/app/src/features/market-details/components/charts/market-overview/components/Legend.tsx b/packages/app/src/features/market-details/components/charts/market-overview/components/Legend.tsx new file mode 100644 index 000000000..65c1f10c2 --- /dev/null +++ b/packages/app/src/features/market-details/components/charts/market-overview/components/Legend.tsx @@ -0,0 +1,22 @@ +import { formatPercentage } from '@/domain/common/format' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +interface LegendProps { + token: Token + total: NormalizedUnitNumber + utilized: NormalizedUnitNumber + utilizationRate: Percentage +} + +export function Legend({ token, total, utilized, utilizationRate }: LegendProps) { + return ( +
+

Utilization rate

+

{formatPercentage(utilizationRate)}

+

+ {token.formatUSD(utilized, { compact: true })} of {token.formatUSD(total, { compact: true })} +

+
+ ) +} diff --git a/packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.stories.ts b/packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.stories.ts new file mode 100644 index 000000000..ae1a0bce7 --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.stories.ts @@ -0,0 +1,33 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { DaiMarketOverview } from './DaiMarketOverview' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/MarketOverview/DaiMarketOverview', + decorators: [WithClassname('max-w-xs')], + component: DaiMarketOverview, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + token: tokens['DAI'], + borrowed: NormalizedUnitNumber(823_000_000), + marketSize: NormalizedUnitNumber(1_243_000_000), + totalAvailable: NormalizedUnitNumber(420_000_000), + utilizationRate: Percentage(0.66), + instantlyAvailable: NormalizedUnitNumber(99_000_000), + makerDaoCapacity: NormalizedUnitNumber(320_000_000), + }, +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) diff --git a/packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.tsx b/packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.tsx new file mode 100644 index 000000000..7101befa0 --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/DaiMarketOverview.tsx @@ -0,0 +1,67 @@ +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { colors } from '../charts/market-overview/colors' +import { Legend } from '../charts/market-overview/components/Legend' +import { MarketOverviewChart } from '../charts/market-overview/MarketOverviewChart' +import { DetailsGrid } from './components/DetailsGrid' +import { DetailsGridItem } from './components/DetailsGridItem' +import { MarketOverviewContent } from './components/MarketOvierviewContent' + +export interface DaiMarketOverviewProps { + token: Token + borrowed: NormalizedUnitNumber + instantlyAvailable: NormalizedUnitNumber + makerDaoCapacity: NormalizedUnitNumber + marketSize: NormalizedUnitNumber + totalAvailable: NormalizedUnitNumber + utilizationRate: Percentage +} + +export function DaiMarketOverview({ + token, + marketSize, + borrowed, + instantlyAvailable, + makerDaoCapacity, + totalAvailable, + utilizationRate, +}: DaiMarketOverviewProps) { + const chartData = [ + { value: borrowed.toNumber(), color: colors.blue }, + { value: instantlyAvailable.toNumber(), color: colors.green }, + { value: makerDaoCapacity.toNumber(), color: colors.orange }, + ] + + return ( + + +

Market Overview

+ + + + + + + + + + + +
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.stories.ts b/packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.stories.ts new file mode 100644 index 000000000..640416fd8 --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.stories.ts @@ -0,0 +1,56 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { DefaultMarketOverview } from './DefaultMarketOverview' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/MarketOverview/DefaultMarketOverview', + decorators: [WithClassname('max-w-xs')], + component: DefaultMarketOverview, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + token: tokens['USDC'], + marketSize: NormalizedUnitNumber(1_243_000_000), + borrowed: NormalizedUnitNumber(823_000_000), + available: NormalizedUnitNumber(420_000_000), + utilizationRate: Percentage(0.66), + }, +} +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) + +export const FullUtilization: Story = { + name: 'Full Utilization', + args: { + token: tokens['USDC'], + marketSize: NormalizedUnitNumber(1_000_000_000), + borrowed: NormalizedUnitNumber(1_000_000_000), + available: NormalizedUnitNumber(0), + utilizationRate: Percentage(1), + }, +} +export const FullUtilizationMobile = getMobileStory(FullUtilization) +export const FullUtilizationTablet = getTabletStory(FullUtilization) + +export const ZeroUtilization: Story = { + name: 'Zero Utilization', + args: { + token: tokens['USDC'], + marketSize: NormalizedUnitNumber(1_000_000_000), + borrowed: NormalizedUnitNumber(0), + available: NormalizedUnitNumber(1_000_000_000), + utilizationRate: Percentage(0), + }, +} +export const ZeroUtilizationMobile = getMobileStory(ZeroUtilization) +export const ZeroUtilizationTablet = getTabletStory(ZeroUtilization) diff --git a/packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.tsx b/packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.tsx new file mode 100644 index 000000000..39e6b42df --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/DefaultMarketOverview.tsx @@ -0,0 +1,48 @@ +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { colors } from '../charts/market-overview/colors' +import { Legend } from '../charts/market-overview/components/Legend' +import { MarketOverviewChart } from '../charts/market-overview/MarketOverviewChart' +import { DetailsGrid } from './components/DetailsGrid' +import { DetailsGridItem } from './components/DetailsGridItem' +import { MarketOverviewContent } from './components/MarketOvierviewContent' + +export interface DefaultMarketOverviewProps { + token: Token + marketSize: NormalizedUnitNumber + borrowed: NormalizedUnitNumber + available: NormalizedUnitNumber + utilizationRate: Percentage +} + +export function DefaultMarketOverview({ + token, + marketSize, + borrowed, + available, + utilizationRate, +}: DefaultMarketOverviewProps) { + const chartData = [ + { value: borrowed.toNumber(), color: colors.blue }, + { value: available.toNumber(), color: colors.green }, + ] + + return ( + + +

Market Overview

+ + + + + + + + + +
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/market-overview/MarketOverview.tsx b/packages/app/src/features/market-details/components/market-overview/MarketOverview.tsx new file mode 100644 index 000000000..e599679d1 --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/MarketOverview.tsx @@ -0,0 +1,32 @@ +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +import { DaiMarketOverview } from './DaiMarketOverview' +import { DefaultMarketOverview } from './DefaultMarketOverview' + +export type MarketOverviewProps = { token: Token } & ( + | { + type: 'default' + marketSize: NormalizedUnitNumber + borrowed: NormalizedUnitNumber + available: NormalizedUnitNumber + utilizationRate: Percentage + } + | { + type: 'dai' + marketSize: NormalizedUnitNumber + borrowed: NormalizedUnitNumber + instantlyAvailable: NormalizedUnitNumber + makerDaoCapacity: NormalizedUnitNumber + totalAvailable: NormalizedUnitNumber + utilizationRate: Percentage + } +) + +export function MarketOverview(props: MarketOverviewProps) { + if (props.type === 'dai') { + return + } + + return +} diff --git a/packages/app/src/features/market-details/components/market-overview/components/DetailsGrid.tsx b/packages/app/src/features/market-details/components/market-overview/components/DetailsGrid.tsx new file mode 100644 index 000000000..e62059d1e --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/components/DetailsGrid.tsx @@ -0,0 +1,8 @@ +import { ReactNode } from 'react' + +interface DetailsGridProps { + children: ReactNode +} +export function DetailsGrid({ children }: DetailsGridProps) { + return
{children}
+} diff --git a/packages/app/src/features/market-details/components/market-overview/components/DetailsGridItem.tsx b/packages/app/src/features/market-details/components/market-overview/components/DetailsGridItem.tsx new file mode 100644 index 000000000..84ea6db41 --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/components/DetailsGridItem.tsx @@ -0,0 +1,38 @@ +import { cva, VariantProps } from 'class-variance-authority' + +import { formatPercentage } from '@/domain/common/format' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +type DetailsGridItemProps = ( + | { type: 'monetary'; value: NormalizedUnitNumber } + | { + type: 'percentage' + value: Percentage + } +) & { title: string; token: Token } & VariantProps + +export function DetailsGridItem({ title, token, value, type, titleVariant }: DetailsGridItemProps) { + return ( +
+

{title}

+

+ {type === 'monetary' ? token.formatUSD(value, { compact: true }) : formatPercentage(value)} +

+
+ ) +} + +const titleVariants = cva('text-sm leading-none sm:text-xs', { + variants: { + titleVariant: { + gray: 'text-zinc-500', + blue: 'text-product-blue', + green: 'text-product-green', + orange: 'text-product-orange', + }, + }, + defaultVariants: { + titleVariant: 'gray', + }, +}) diff --git a/packages/app/src/features/market-details/components/market-overview/components/MarketOvierviewContent.tsx b/packages/app/src/features/market-details/components/market-overview/components/MarketOvierviewContent.tsx new file mode 100644 index 000000000..dcab45b90 --- /dev/null +++ b/packages/app/src/features/market-details/components/market-overview/components/MarketOvierviewContent.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +interface MarketOverviewContentProps { + children: ReactNode +} + +export function MarketOverviewContent({ children }: MarketOverviewContentProps) { + return
{children}
+} diff --git a/packages/app/src/features/market-details/components/my-wallet/MyWallet.stories.tsx b/packages/app/src/features/market-details/components/my-wallet/MyWallet.stories.tsx new file mode 100644 index 000000000..2d7fc92d8 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/MyWallet.stories.tsx @@ -0,0 +1,178 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { MyWallet } from './MyWallet' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/MyWallet', + component: MyWallet, + args: { + token: tokens['wstETH'], + }, + decorators: [WithClassname('max-w-xs')], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + tokenBalance: NormalizedUnitNumber(10000), + borrow: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(20000), + }, + deposit: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(40000), + }, + }, +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) + +export const NoDeposits: Story = { + name: 'No Deposits', + args: { + tokenBalance: NormalizedUnitNumber(10000), + borrow: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(0), + }, + deposit: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(40000), + }, + }, +} + +export const ZeroBalance: Story = { + name: 'Zero Balance', + args: { + tokenBalance: NormalizedUnitNumber(0), + borrow: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(2000), + }, + deposit: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(0), + }, + }, +} + +export const NoDepositsZeroBalance: Story = { + name: 'No deposits Zero Balance', + args: { + tokenBalance: NormalizedUnitNumber(0), + borrow: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(0), + }, + deposit: { + token: tokens['wstETH'], + available: NormalizedUnitNumber(0), + }, + }, +} + +export const Dai: Story = { + name: 'DAI', + args: { + token: tokens['DAI'], + tokenBalance: NormalizedUnitNumber(10000), + deposit: { + token: tokens['DAI'], + available: NormalizedUnitNumber(10000), + }, + borrow: { + token: tokens['DAI'], + available: NormalizedUnitNumber(20000), + }, + lend: { + token: tokens['DAI'], + available: NormalizedUnitNumber(10000), + }, + openDialog: () => {}, + }, +} + +export const DaiMobile: Story = { + ...getMobileStory(Dai), + name: 'DAI (Mobile)', +} +export const DaiTablet: Story = { + ...getTabletStory(Default), + name: 'DAI (Tablet)', +} + +export const DaiNoDeposits: Story = { + name: 'DAI no deposits', + args: { + token: tokens['DAI'], + + tokenBalance: NormalizedUnitNumber(10000), + deposit: { + token: tokens['DAI'], + available: NormalizedUnitNumber(10000), + }, + borrow: { + token: tokens['DAI'], + available: NormalizedUnitNumber(10000), + }, + lend: { + token: tokens['DAI'], + available: NormalizedUnitNumber(0), + }, + openDialog: () => {}, + }, +} + +export const DaiZeroBalance: Story = { + name: 'DAI zero balance', + args: { + token: tokens['DAI'], + + tokenBalance: NormalizedUnitNumber(0), + deposit: { + token: tokens['DAI'], + available: NormalizedUnitNumber(0), + }, + borrow: { + token: tokens['DAI'], + available: NormalizedUnitNumber(2000), + }, + lend: { + token: tokens['DAI'], + available: NormalizedUnitNumber(0), + }, + openDialog: () => {}, + }, +} + +export const DaiNoDepositsZeroBalance: Story = { + name: 'DAI no deposits zero balance', + args: { + token: tokens['DAI'], + tokenBalance: NormalizedUnitNumber(0), + deposit: { + token: tokens['DAI'], + available: NormalizedUnitNumber(0), + }, + borrow: { + token: tokens['DAI'], + available: NormalizedUnitNumber(0), + }, + lend: { + token: tokens['DAI'], + available: NormalizedUnitNumber(0), + }, + openDialog: () => {}, + }, +} diff --git a/packages/app/src/features/market-details/components/my-wallet/MyWallet.tsx b/packages/app/src/features/market-details/components/my-wallet/MyWallet.tsx new file mode 100644 index 000000000..70446404a --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/MyWallet.tsx @@ -0,0 +1,61 @@ +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { BorrowDialog } from '@/features/dialogs/borrow/BorrowDialog' +import { DepositDialog } from '@/features/dialogs/deposit/DepositDialog' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { ActionRow } from './components/ActionRow' +import { BorrowRow } from './components/BorrowRow' +import { TokenBalance } from './components/TokenBalance' +import { WalletPanelContent } from './components/WalletPanelContent' + +export interface MyWalletProps { + token: Token + tokenBalance: NormalizedUnitNumber + lend?: { + available: NormalizedUnitNumber + token: Token + } + deposit: { + available: NormalizedUnitNumber + token: Token + } + borrow: { + available: NormalizedUnitNumber + token: Token + } + openDialog: OpenDialogFunction +} + +export function MyWallet({ token, tokenBalance, lend, deposit, borrow, openDialog }: MyWalletProps) { + return ( + + +

My Wallet

+ + {lend && ( + openDialog(DepositDialog, { token: lend.token })} + label="Available to lend" + buttonText="Lend" + /> + )} + openDialog(DepositDialog, { token: deposit.token })} + label={token.symbol === 'DAI' ? 'Deposit DAI as collateral' : 'Available to deposit'} + buttonText="Deposit" + /> + openDialog(BorrowDialog, { token: borrow.token })} + availableToBorrow={borrow.available} + /> +
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/my-wallet/MyWalletChainMismatch.tsx b/packages/app/src/features/market-details/components/my-wallet/MyWalletChainMismatch.tsx new file mode 100644 index 000000000..a3a9351c7 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/MyWalletChainMismatch.tsx @@ -0,0 +1,16 @@ +import { Panel } from '@/ui/atoms/panel/Panel' + +export function MyWalletChainMismatch() { + return ( + +
+
+

My Wallet

+

+ To access this asset, please switch your wallet connection to the appropriate chain. +

+
+
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.stories.tsx b/packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.stories.tsx new file mode 100644 index 000000000..6aa11aa12 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.stories.tsx @@ -0,0 +1,24 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { MyWalletDisconnected } from './MyWalletDisconnected' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/MyWallet/MyWalletDisconnected', + component: MyWalletDisconnected, + args: { + openConnectModal: () => {}, + }, + decorators: [WithClassname('max-w-xs')], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) diff --git a/packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.tsx b/packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.tsx new file mode 100644 index 000000000..7ff9eebe5 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/MyWalletDisconnected.tsx @@ -0,0 +1,22 @@ +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' + +interface MyWalletDisconnectedProps { + openConnectModal: () => void +} + +export function MyWalletDisconnected({ openConnectModal }: MyWalletDisconnectedProps) { + return ( + +
+
+

My Wallet

+

Please connect a wallet to view your personal information here.

+
+ +
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/my-wallet/MyWalletPanel.tsx b/packages/app/src/features/market-details/components/my-wallet/MyWalletPanel.tsx new file mode 100644 index 000000000..dfbd372cf --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/MyWalletPanel.tsx @@ -0,0 +1,24 @@ +import { OpenDialogFunction } from '@/domain/state/dialogs' + +import { WalletOverview } from '../../types' +import { MyWallet } from './MyWallet' +import { MyWalletChainMismatch } from './MyWalletChainMismatch' +import { MyWalletDisconnected } from './MyWalletDisconnected' + +interface MyWalletPanelProps { + openDialog: OpenDialogFunction + walletOverview: WalletOverview + openConnectModal: () => void +} + +export function MyWalletPanel({ openDialog, walletOverview, openConnectModal }: MyWalletPanelProps) { + if (walletOverview.guestMode) { + return + } + + if (walletOverview.chainMismatch) { + return + } + + return +} diff --git a/packages/app/src/features/market-details/components/my-wallet/components/ActionDetails.tsx b/packages/app/src/features/market-details/components/my-wallet/components/ActionDetails.tsx new file mode 100644 index 000000000..3c9c869e4 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/components/ActionDetails.tsx @@ -0,0 +1,20 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +interface ActionDetailsProps { + label: string + token: Token + value: NormalizedUnitNumber +} + +export function ActionDetails({ label, token, value }: ActionDetailsProps) { + return ( +
+

{label}

+

+ {token.format(value, { style: 'auto' })} {token.symbol} +

+
{token.formatUSD(value)}
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/my-wallet/components/ActionRow.tsx b/packages/app/src/features/market-details/components/my-wallet/components/ActionRow.tsx new file mode 100644 index 000000000..cbe676935 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/components/ActionRow.tsx @@ -0,0 +1,24 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Button } from '@/ui/atoms/button/Button' + +import { ActionDetails } from './ActionDetails' + +interface ActionRowProps { + token: Token + value: NormalizedUnitNumber + label: string + buttonText: string + onAction: () => void +} + +export function ActionRow({ token, value, label, buttonText, onAction }: ActionRowProps) { + return ( +
+ + +
+ ) +} diff --git a/packages/app/src/features/market-details/components/my-wallet/components/BorrowRow.tsx b/packages/app/src/features/market-details/components/my-wallet/components/BorrowRow.tsx new file mode 100644 index 000000000..34fbbc4f8 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/components/BorrowRow.tsx @@ -0,0 +1,30 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +import { ActionRow } from './ActionRow' + +interface BorrowRowProps { + token: Token + availableToBorrow: NormalizedUnitNumber + onAction: () => void +} + +export function BorrowRow({ token, availableToBorrow, onAction }: BorrowRowProps) { + if (availableToBorrow.isZero()) { + return ( +
+

To borrow you need to deposit any other asset first.

+
+ ) + } + + return ( + + ) +} diff --git a/packages/app/src/features/market-details/components/my-wallet/components/TokenBalance.tsx b/packages/app/src/features/market-details/components/my-wallet/components/TokenBalance.tsx new file mode 100644 index 000000000..d77f3eb03 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/components/TokenBalance.tsx @@ -0,0 +1,22 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +interface TokenBalanceProps { + token: Token + balance: NormalizedUnitNumber +} + +export function TokenBalance({ token, balance }: TokenBalanceProps) { + return ( +
+

Balance:

+
+ +

+ {token.format(balance, { style: 'auto' })} {token.symbol} +

+
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/my-wallet/components/WalletPanelContent.tsx b/packages/app/src/features/market-details/components/my-wallet/components/WalletPanelContent.tsx new file mode 100644 index 000000000..c83b237e0 --- /dev/null +++ b/packages/app/src/features/market-details/components/my-wallet/components/WalletPanelContent.tsx @@ -0,0 +1,9 @@ +import { ReactNode } from 'react' + +interface WalletPanelContentProps { + children: ReactNode +} + +export function WalletPanelContent({ children }: WalletPanelContentProps) { + return
{children}
+} diff --git a/packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.stories.ts b/packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.stories.ts new file mode 100644 index 000000000..db9efe015 --- /dev/null +++ b/packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.stories.ts @@ -0,0 +1,16 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { MarketDetailsSkeleton } from './MarketDetailsSkeleton' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/Skeleton', + component: MarketDetailsSkeleton, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.tsx b/packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.tsx new file mode 100644 index 000000000..6573238f5 --- /dev/null +++ b/packages/app/src/features/market-details/components/skeleton/MarketDetailsSkeleton.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' +import { PageLayout } from '@/ui/layouts/PageLayout' + +export function MarketDetailsSkeleton() { + return ( + +
+ + +
+ +
+
+ + + +
+
+ + +
+
+
+ ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.stories.ts b/packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.stories.ts new file mode 100644 index 000000000..00028485d --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.stories.ts @@ -0,0 +1,107 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { bigNumberify } from '@/utils/bigNumber' + +import { BorrowStatusPanel } from './BorrowStatusPanel' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/BorrowStatusPanel', + component: BorrowStatusPanel, + decorators: [WithTooltipProvider(), WithClassname('max-w-2xl'), withRouter], +} + +export default meta +type Story = StoryObj + +const chartProps = { + optimalUtilizationRate: Percentage('0.45'), + utilizationRate: Percentage('0.08'), + variableRateSlope1: bigNumberify('45000000000000000000000000'), + variableRateSlope2: bigNumberify('800000000000000000000000000'), + baseVariableBorrowRate: bigNumberify('2500000000000000000000000'), +} + +export const CanBeBorrowed: Story = { + name: 'Can be borrowed', + args: { + status: 'yes', + token: tokens['WBTC'], + totalBorrowed: NormalizedUnitNumber(1244), + apy: Percentage(0.01), + reserveFactor: Percentage(0.05), + borrowCap: NormalizedUnitNumber(2244), + chartProps, + }, +} + +export const CanBeBorrowedMobile = { + ...getMobileStory(CanBeBorrowed), + name: 'Can be borrowed (Mobile)', +} +export const CanBeBorrowedTablet = { + ...getTabletStory(CanBeBorrowed), + name: 'Can be borrowed (Tablet)', +} + +export const OnlyInSiloedMode: Story = { + name: 'Only in siloed mode', + args: { + status: 'only-in-siloed-mode', + token: tokens['WBTC'], + totalBorrowed: NormalizedUnitNumber(1244), + apy: Percentage(0.01), + reserveFactor: Percentage(0.05), + borrowCap: NormalizedUnitNumber(2244), + chartProps, + }, +} + +export const BorrowCapReached: Story = { + name: 'Borrow cap reached', + args: { + status: 'borrow-cap-reached', + token: tokens['WBTC'], + totalBorrowed: NormalizedUnitNumber(2244), + apy: Percentage(0.01), + reserveFactor: Percentage(0.05), + borrowCap: NormalizedUnitNumber(2244), + chartProps, + }, +} + +export const CannotBeBorrowed: Story = { + name: 'Cannot be borrowed', + args: { + status: 'no', + token: tokens['WBTC'], + totalBorrowed: NormalizedUnitNumber(0), + apy: Percentage(0), + reserveFactor: Percentage(0.05), + chartProps, + }, +} + +export const DAI: Story = { + name: 'DAI', + args: { + status: 'yes', + token: tokens['DAI'], + totalBorrowed: NormalizedUnitNumber(1244), + apy: Percentage(0.01), + reserveFactor: Percentage(0.05), + borrowCap: NormalizedUnitNumber(2244), + chartProps: { + optimalUtilizationRate: Percentage('1'), + utilizationRate: Percentage('0.97012653796557908901'), + variableRateSlope1: bigNumberify('0'), + variableRateSlope2: bigNumberify('0'), + baseVariableBorrowRate: bigNumberify('62599141818649791361008000'), + }, + showTokenBadge: true, + }, +} diff --git a/packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.tsx b/packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.tsx new file mode 100644 index 000000000..466d3d2ff --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/BorrowStatusPanel.tsx @@ -0,0 +1,84 @@ +import { formatPercentage } from '@/domain/common/format' +import { BorrowEligibilityStatus } from '@/domain/market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Panel } from '@/ui/atoms/panel/Panel' +import { ApyTooltip } from '@/ui/molecules/apy-tooltip/ApyTooltip' + +import { InterestYieldChart, InterestYieldChartProps } from '../charts/interest-yield/InterestYieldChart' +import { EmptyStatusPanel } from './components/EmptyStatusPanel' +import { Header } from './components/Header' +import { InfoTile } from './components/info-tile/InfoTile' +import { InfoTilesGrid } from './components/info-tile/InfoTilesGrid' +import { StatusIcon } from './components/status-icon/StatusIcon' +import { StatusPanelGrid } from './components/StatusPanelGrid' +import { Subheader } from './components/Subheader' +import { TokenBadge } from './components/token-badge/TokenBadge' + +interface BorrowStatusPanelProps { + status: BorrowEligibilityStatus + token: Token + totalBorrowed: NormalizedUnitNumber + borrowCap?: NormalizedUnitNumber + reserveFactor: Percentage + apy: Percentage + chartProps: InterestYieldChartProps + showTokenBadge?: boolean +} + +export function BorrowStatusPanel({ + status, + token, + totalBorrowed, + borrowCap, + reserveFactor, + apy, + chartProps, + showTokenBadge = false, +}: BorrowStatusPanelProps) { + if (status === 'no') { + return + } + + return ( + + + +
+ + {showTokenBadge && } + + + Total borrowed + + {token.format(totalBorrowed, { style: 'compact' })} {token.symbol} + + {token.formatUSD(totalBorrowed, { compact: true })} + + + + Borrow APY + + {formatPercentage(apy)} + + {borrowCap && ( + + Borrow cap + + {token.format(borrowCap, { style: 'compact' })} {token.symbol} + + {token.formatUSD(borrowCap, { compact: true })} + + )} + + Reserve factor + {formatPercentage(reserveFactor)} + + +
+ +
+ + + ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.stories.ts b/packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.stories.ts new file mode 100644 index 000000000..d2aa27550 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.stories.ts @@ -0,0 +1,105 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { CollateralStatusPanel } from './CollateralStatusPanel' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/CollateralStatusPanel', + component: CollateralStatusPanel, + decorators: [WithTooltipProvider(), WithClassname('max-w-2xl'), withRouter], +} + +export default meta +type Story = StoryObj + +export const CanBeUsedAsCollateral: Story = { + name: 'Can be used as collateral', + args: { + status: 'yes', + token: tokens['rETH'], + debtCeiling: NormalizedUnitNumber(0), + debt: NormalizedUnitNumber(1000), + maxLtv: Percentage(0.8), + liquidationThreshold: Percentage(0.825), + liquidationPenalty: Percentage(0.05), + }, +} + +export const CanBeUsedAsCollateralMobile = { + ...getMobileStory(CanBeUsedAsCollateral), + name: 'Can be used as collateral (Mobile)', +} +export const CanBeUsedAsCollateralTablet = { + ...getTabletStory(CanBeUsedAsCollateral), + name: 'Can be used as collateral (Tablet)', +} + +export const CanBeUsedAsCollateralInIsolationMode: Story = { + name: 'Only in isolation Mode', + args: { + status: 'only-in-isolation-mode', + token: tokens['rETH'], + debtCeiling: NormalizedUnitNumber(1200), + debt: NormalizedUnitNumber(1000), + maxLtv: Percentage(0.8), + liquidationThreshold: Percentage(0.825), + liquidationPenalty: Percentage(0.05), + }, +} +export const CanBeUsedAsCollateralInIsolationModeMobile = { + ...getMobileStory(CanBeUsedAsCollateralInIsolationMode), + name: 'Only in isolation Mode (Mobile)', +} +export const CanBeUsedAsCollateralInIsolationModeTablet = { + ...getTabletStory(CanBeUsedAsCollateralInIsolationMode), + name: 'Only in isolation Mode (Tablet)', +} + +export const CannotBeUsedAsCollateral: Story = { + name: 'Cannot Be Used As Collateral', + args: { + status: 'no', + token: tokens['rETH'], + debtCeiling: NormalizedUnitNumber(0), + debt: NormalizedUnitNumber(1000), + maxLtv: Percentage(0), + liquidationThreshold: Percentage(0), + liquidationPenalty: Percentage(0), + }, +} +export const CannotBeUsedAsCollateralMobile = { + ...getMobileStory(CannotBeUsedAsCollateral), + name: 'Cannot Be Used As Collateral (Mobile)', +} +export const CannotBeUsedAsCollateralTablet = { + ...getTabletStory(CannotBeUsedAsCollateral), + name: 'Cannot Be Used As Collateral (Tablet)', +} + +export const Dai: Story = { + name: 'DAI', + args: { + status: 'yes', + maxLtv: Percentage(0.8), + liquidationThreshold: Percentage(0.825), + liquidationPenalty: Percentage(0.05), + supplyReplacement: { + token: tokens.sDAI, + totalSupplied: NormalizedUnitNumber(72_000), + supplyAPY: Percentage(0.05), + }, + }, +} +export const DaiMobile = { + ...getMobileStory(Dai), + name: 'DAI (Mobile)', +} +export const DaiTablet = { + ...getTabletStory(Dai), + name: 'DAI (Tablet)', +} diff --git a/packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.tsx b/packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.tsx new file mode 100644 index 000000000..8159d9aac --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/CollateralStatusPanel.tsx @@ -0,0 +1,97 @@ +import { formatPercentage } from '@/domain/common/format' +import { CollateralEligibilityStatus } from '@/domain/market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { DebtCeilingProgress } from '@/features/markets/components/debt-ceiling-progress/DebtCeilingProgress' +import { Panel } from '@/ui/atoms/panel/Panel' +import { ApyTooltip } from '@/ui/molecules/apy-tooltip/ApyTooltip' + +import { EmptyStatusPanel } from './components/EmptyStatusPanel' +import { Header } from './components/Header' +import { InfoTile } from './components/info-tile/InfoTile' +import { InfoTilesGrid } from './components/info-tile/InfoTilesGrid' +import { StatusIcon } from './components/status-icon/StatusIcon' +import { StatusPanelGrid } from './components/StatusPanelGrid' +import { Subheader } from './components/Subheader' +import { TokenBadge } from './components/token-badge/TokenBadge' + +export interface CollateralStatusPanelProps { + status: CollateralEligibilityStatus + token: Token + debtCeiling: NormalizedUnitNumber + debt: NormalizedUnitNumber + maxLtv: Percentage + liquidationThreshold: Percentage + liquidationPenalty: Percentage + supplyReplacement?: { + token: Token + totalSupplied: NormalizedUnitNumber + supplyAPY: Percentage + } +} + +export function CollateralStatusPanel({ + status, + token, + debtCeiling, + debt, + maxLtv, + liquidationThreshold, + liquidationPenalty, + supplyReplacement, +}: CollateralStatusPanelProps) { + if (status === 'no') { + return + } + + return ( + + + +
+ + {supplyReplacement && ( + <> + + + + Total supplied + + {supplyReplacement.token.format(supplyReplacement.totalSupplied, { style: 'compact' })}{' '} + {supplyReplacement.token.symbol} + + + {supplyReplacement.token.formatUSD(supplyReplacement.totalSupplied, { compact: true })} + + + + + Deposit APY + + {formatPercentage(supplyReplacement.supplyAPY)} + + + + )} + + + Max LTV + {formatPercentage(maxLtv)} + + + Liquidation threshold + {formatPercentage(liquidationThreshold)} + + + Liquidation penalty + {formatPercentage(liquidationPenalty)} + + + + {status === 'only-in-isolation-mode' && ( + + )} + + + ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.stories.ts b/packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.stories.ts new file mode 100644 index 000000000..0ff024f98 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.stories.ts @@ -0,0 +1,54 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { Percentage } from '@/domain/types/NumericValues' + +import { EModeStatusPanel } from './EModeStatusPanel' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/EModeStatusPanel', + component: EModeStatusPanel, + decorators: [WithTooltipProvider(), WithClassname('max-w-2xl'), withRouter], + args: { + maxLtv: Percentage(0.95), + liquidationThreshold: Percentage(0.9), + liquidationPenalty: Percentage(0.02), + category: 'ETH Correlated', + eModeCategoryTokens: [tokens.WETH.symbol, tokens.wstETH.symbol, tokens.rETH.symbol], + }, +} + +export default meta +type Story = StoryObj + +export const ETHDesktop: Story = { + name: 'ETH Correlated', +} +export const ETHMobile: Story = { + ...getMobileStory(ETHDesktop), + name: 'ETH Correlated (Mobile)', +} +export const ETHTablet: Story = { + ...getTabletStory(ETHDesktop), + name: 'ETH Correlated (Tablet)', +} + +export const DAIDesktop: Story = { + name: 'DAI Correlated', + args: { + category: 'Stablecoins', + token: tokens.sDAI, + eModeCategoryTokens: [tokens.sDAI.symbol, tokens.USDC.symbol, tokens.USDT.symbol], + }, +} +export const DAIMobile: Story = { + ...getMobileStory(DAIDesktop), + name: 'DAI Correlated (Mobile)', +} +export const DAITablet: Story = { + ...getTabletStory(DAIDesktop), + name: 'DAI Correlated (Tablet)', +} diff --git a/packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.tsx b/packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.tsx new file mode 100644 index 000000000..68daeb14c --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/EModeStatusPanel.tsx @@ -0,0 +1,108 @@ +import { paths } from '@/config/paths' +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { assets } from '@/ui/assets' +import { Link, LinkProps } from '@/ui/atoms/link/Link' +import { Panel } from '@/ui/atoms/panel/Panel' +import { links } from '@/ui/constants/links' +import { cn } from '@/ui/utils/style' + +import { EModeBadge, EnabledEModeCategory } from './components/emode-badge/EModeBadge' +import { Header } from './components/Header' +import { InfoTile } from './components/info-tile/InfoTile' +import { InfoTilesGrid } from './components/info-tile/InfoTilesGrid' +import { StatusIcon } from './components/status-icon/StatusIcon' +import { StatusPanelGrid } from './components/StatusPanelGrid' +import { TokenBadge } from './components/token-badge/TokenBadge' + +export interface EModeStatusPanelProps { + maxLtv: Percentage + liquidationThreshold: Percentage + liquidationPenalty: Percentage + category: EnabledEModeCategory + eModeCategoryTokens: TokenSymbol[] + token?: Token +} + +export function EModeStatusPanel({ + maxLtv, + liquidationThreshold, + liquidationPenalty, + category, + eModeCategoryTokens, + token, +}: EModeStatusPanelProps) { + return ( + + + +
+ {token && } + + + Max LTV + + {formatPercentage(maxLtv)} + + + + Liquidation threshold + + {formatPercentage(liquidationThreshold)} + + + + Liquidation penalty + + {formatPercentage(liquidationPenalty)} + + + + Category + + + + +

+ E-Mode for {category} assets increases your LTV within the {category} category. This means that when E-Mode + is enabled, you will have higher borrowing power for assets in this category:{' '} + {eModeCategoryTokens.join(', ')}. You can enter E-Mode from your{' '} + Dashboard. To learn more about E-Mode and its applied + restrictions, visit the{' '} + + FAQ + {' '} + or the{' '} + + Aave V3 Technical Paper + + . +

+
+ + + ) +} + +function DocsLink({ to, children, ...rest }: LinkProps) { + return ( + + {children} + + ) +} + +interface WithArrowProps { + children: React.ReactNode + reverseArrow?: boolean +} +function WithArrow({ children, reverseArrow }: WithArrowProps) { + return ( +
+ + {children} +
+ ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/LendStatusPanel.stories.ts b/packages/app/src/features/market-details/components/status-panel/LendStatusPanel.stories.ts new file mode 100644 index 000000000..446b65f9d --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/LendStatusPanel.stories.ts @@ -0,0 +1,37 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { LendStatusPanel } from './LendStatusPanel' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/LendStatusPanel', + component: LendStatusPanel, + decorators: [WithTooltipProvider(), WithClassname('max-w-2xl'), withRouter], +} + +export default meta +type Story = StoryObj + +export const CanBeLent: Story = { + name: 'Can be lent', + args: { + status: 'yes', + token: tokens['DAI'], + totalLent: NormalizedUnitNumber(72_000), + apy: Percentage(0.05), + }, +} + +export const CanBeLentMobile = { + ...getMobileStory(CanBeLent), + name: 'Can be lent (Mobile)', +} +export const CanBeLentTablet = { + ...getTabletStory(CanBeLent), + name: 'Can be lent (Tablet)', +} diff --git a/packages/app/src/features/market-details/components/status-panel/LendStatusPanel.tsx b/packages/app/src/features/market-details/components/status-panel/LendStatusPanel.tsx new file mode 100644 index 000000000..6243bb777 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/LendStatusPanel.tsx @@ -0,0 +1,43 @@ +import { formatPercentage } from '@/domain/common/format' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { Header } from './components/Header' +import { InfoTile } from './components/info-tile/InfoTile' +import { InfoTilesGrid } from './components/info-tile/InfoTilesGrid' +import { StatusIcon } from './components/status-icon/StatusIcon' +import { StatusPanelGrid } from './components/StatusPanelGrid' +import { TokenBadge } from './components/token-badge/TokenBadge' + +interface LendStatusPanelProps { + status: 'yes' // only for dai + token: Token + totalLent: NormalizedUnitNumber + apy: Percentage +} + +export function LendStatusPanel({ status, token, totalLent, apy }: LendStatusPanelProps) { + return ( + + + +
+ + + + Total {token.symbol} lent + + {token.format(totalLent, { style: 'compact' })} {token.symbol} + + {token.formatUSD(totalLent, { compact: true })} + + + Lend APY + {formatPercentage(apy)} + + + + + ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.stories.ts b/packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.stories.ts new file mode 100644 index 000000000..62f2c28bc --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.stories.ts @@ -0,0 +1,54 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { SupplyStatusPanel } from './SupplyStatusPanel' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/SupplyStatusPanel', + component: SupplyStatusPanel, + decorators: [WithTooltipProvider(), WithClassname('max-w-2xl'), withRouter], +} + +export default meta +type Story = StoryObj + +export const CanBeSupplied: Story = { + name: 'Can Be Supplied', + args: { + status: 'yes', + token: tokens['rETH'], + totalSupplied: NormalizedUnitNumber(72_000), + supplyCap: NormalizedUnitNumber(112_000), + apy: Percentage(0.05), + }, +} + +export const CanBeSuppliedMobile = getMobileStory(CanBeSupplied) +export const CanBeSuppliedTablet = getTabletStory(CanBeSupplied) + +export const SupplyCapReached: Story = { + name: 'Supply Cap Reached', + args: { + status: 'supply-cap-reached', + token: tokens['rETH'], + totalSupplied: NormalizedUnitNumber(112_000), + supplyCap: NormalizedUnitNumber(112_000), + apy: Percentage(0.05), + }, +} + +export const CannotBeSupplied: Story = { + name: 'Cannot Be Supplied', + args: { + status: 'no', + token: tokens['rETH'], + totalSupplied: NormalizedUnitNumber(0), + supplyCap: NormalizedUnitNumber(0), + apy: Percentage(0), + }, +} diff --git a/packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.tsx b/packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.tsx new file mode 100644 index 000000000..8d3c890ec --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/SupplyStatusPanel.tsx @@ -0,0 +1,62 @@ +import { formatPercentage } from '@/domain/common/format' +import { SupplyAvailabilityStatus } from '@/domain/market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Panel } from '@/ui/atoms/panel/Panel' +import { ApyTooltip } from '@/ui/molecules/apy-tooltip/ApyTooltip' + +import { EmptyStatusPanel } from './components/EmptyStatusPanel' +import { Header } from './components/Header' +import { InfoTile } from './components/info-tile/InfoTile' +import { InfoTilesGrid } from './components/info-tile/InfoTilesGrid' +import { StatusIcon } from './components/status-icon/StatusIcon' +import { StatusPanelGrid } from './components/StatusPanelGrid' +import { Subheader } from './components/Subheader' + +interface SupplyStatusPanelProps { + status: SupplyAvailabilityStatus + token: Token + totalSupplied: NormalizedUnitNumber + supplyCap?: NormalizedUnitNumber + apy: Percentage +} + +export function SupplyStatusPanel({ status, token, totalSupplied, supplyCap, apy }: SupplyStatusPanelProps) { + if (status === 'no') { + return + } + + return ( + + + +
+ + + + Total supplied + + {token.format(totalSupplied, { style: 'compact' })} {token.symbol} + + {token.formatUSD(totalSupplied, { compact: true })} + + + + Deposit APY + + {formatPercentage(apy)} + + {supplyCap && ( + + Supply cap + + {token.format(supplyCap, { style: 'compact' })} {token.symbol} + + {token.formatUSD(supplyCap, { compact: true })} + + )} + + + + ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/EmptyStatusPanel.tsx b/packages/app/src/features/market-details/components/status-panel/components/EmptyStatusPanel.tsx new file mode 100644 index 000000000..fc4aaa803 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/EmptyStatusPanel.tsx @@ -0,0 +1,22 @@ +import { SupplyAvailabilityStatus } from '@/domain/market-info/reserve-status' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { Header, Variant } from './Header' +import { StatusIcon } from './status-icon/StatusIcon' +import { StatusPanelGrid } from './StatusPanelGrid' + +interface EmptyStatusPanelProps { + status: SupplyAvailabilityStatus + variant: Variant +} + +export function EmptyStatusPanel({ status, variant }: EmptyStatusPanelProps) { + return ( + + + +
+ + + ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/Header.tsx b/packages/app/src/features/market-details/components/status-panel/components/Header.tsx new file mode 100644 index 000000000..457b13c29 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/Header.tsx @@ -0,0 +1,68 @@ +import { MarketAssetStatus } from '@/domain/market-info/reserve-status' + +export type Variant = 'supply' | 'collateral' | 'borrow' | 'lend' | 'e-mode' + +interface HeaderProps { + status: MarketAssetStatus + variant: Variant +} + +export function Header({ status, variant }: HeaderProps) { + return ( +
+

{getHeaderText(status, variant)}

+ {/* @todo: Introduce info when copy is available */} + {/* Info text */} +
+ ) +} + +function getHeaderText(status: MarketAssetStatus, variant: Variant): string { + if (variant === 'supply') { + switch (status) { + case 'yes': + return 'Can be supplied' + default: + return 'Cannot be supplied' + } + } + + if (variant === 'collateral') { + switch (status) { + case 'yes': + return 'Can be used as collateral' + case 'only-in-isolation-mode': + return 'Can be used as collateral in Isolation Mode' + default: + return 'Cannot be used as collateral' + } + } + + if (variant === 'borrow') { + switch (status) { + case 'yes': + return 'Can be borrowed' + case 'only-in-siloed-mode': + return 'Can be borrowed only in Siloed Mode' + default: + return 'Cannot be borrowed' + } + } + + if (variant === 'lend') { + switch (status) { + case 'yes': + return 'Can be lent' + default: + return 'Cannot be lent' + } + } + + // variant === 'e-mode' + switch (status) { + case 'yes': + return 'Can be used in E-Mode' + default: + return 'Cannot be used in E-Mode' + } +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/StatusPanelGrid.tsx b/packages/app/src/features/market-details/components/status-panel/components/StatusPanelGrid.tsx new file mode 100644 index 000000000..689b0c084 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/StatusPanelGrid.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react' + +interface StatusPanelGridProps { + children: ReactNode +} + +export function StatusPanelGrid({ children }: StatusPanelGridProps) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/Subheader.tsx b/packages/app/src/features/market-details/components/status-panel/components/Subheader.tsx new file mode 100644 index 000000000..657be469b --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/Subheader.tsx @@ -0,0 +1,80 @@ +import { cva, VariantProps } from 'class-variance-authority' +import { ReactNode } from 'react' + +import { MarketAssetStatus } from '@/domain/market-info/reserve-status' +import { Link } from '@/ui/atoms/link/Link' +import { links } from '@/ui/constants/links' +import { cn } from '@/ui/utils/style' + +interface SubheaderProps { + status: MarketAssetStatus +} + +export function Subheader({ status }: SubheaderProps) { + if (status === 'only-in-isolation-mode') { + return ( + + In Isolation mode, you cannot use other assets as collateral for borrowing. Assets used as collateral in + Isolation mode can only be borrowed up to a specific debt ceiling.{' '} + + Learn more + + . + + ) + } + + if (status === 'only-in-siloed-mode') { + return ( + + Siloed borrowing means that the asset can be the only asset borrowed in a position.{' '} + + Learn more + + . + + ) + } + + if (status === 'supply-cap-reached') { + return ( + + Maximum amount available to supply is limited because asset supply cap is reached.{' '} + + Learn more + + . + + ) + } + + if (status === 'borrow-cap-reached') { + return ( + + Maximum amount available to borrow is limited because asset borrow cap is reached.{' '} + + Learn more + + . + + ) + } + return null +} + +interface ContentProps extends VariantProps { + children: ReactNode +} + +function Content({ children, variant }: ContentProps) { + return

{children}

+} + +const variants = cva('text-xs leading-none', { + variants: { + variant: { + orange: 'text-product-orange', + red: 'text-product-red', + }, + }, +}) diff --git a/packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.stories.ts b/packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.stories.ts new file mode 100644 index 000000000..4146c00d8 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.stories.ts @@ -0,0 +1,24 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { EModeBadge } from './EModeBadge' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/Components/EModeBadge', + component: EModeBadge, +} + +export default meta +type Story = StoryObj + +export const ETHCorrelated: Story = { + name: 'ETH Correlated', + args: { + category: 'ETH Correlated', + }, +} +export const Stablecoins: Story = { + name: 'Stablecoins', + args: { + category: 'Stablecoins', + }, +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.tsx b/packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.tsx new file mode 100644 index 000000000..f78b9c7c9 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/emode-badge/EModeBadge.tsx @@ -0,0 +1,34 @@ +import { EModeCategoryName } from '@/domain/e-mode/types' +import { assets } from '@/ui/assets' +import { cn } from '@/ui/utils/style' + +export type EnabledEModeCategory = Exclude +export interface EModeBadgeProps { + category: EnabledEModeCategory +} + +export function EModeBadge({ category }: EModeBadgeProps) { + const text = eModeCategoryToText(category) + + return ( +
+ flash + {text} +
+ ) +} + +function eModeCategoryToText(category: EnabledEModeCategory): string { + switch (category) { + case 'ETH Correlated': + return 'ETH Correlated' + case 'Stablecoins': + return 'Stablecoins' + } +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.stories.tsx b/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.stories.tsx new file mode 100644 index 000000000..88e3f3d71 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { InfoTile } from './InfoTile' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/Components/InfoTile', + component: InfoTile, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + render: () => { + return ( + + Total supplied + 167.37M + of $375.39M + + ) + }, +} + +export const WithoutComplementaryLine: Story = { + name: 'Without Complementary Line', + render: () => { + return ( + + Total supplied + 167.37M + + ) + }, +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) diff --git a/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.tsx b/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.tsx new file mode 100644 index 000000000..0fb5af27b --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTile.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react' + +import { cn } from '@/ui/utils/style' + +interface InfoTileProps { + children: ReactNode +} +export function InfoTile({ children }: InfoTileProps) { + return
{children}
+} + +function Label({ children }: InfoTileProps) { + return
{children}
+} + +function Value({ children }: InfoTileProps) { + return ( +
+ {children} +
+ ) +} + +function ComplementaryLine({ children }: InfoTileProps) { + return ( +

+ {children} +

+ ) +} + +InfoTile.Label = Label +InfoTile.Value = Value +InfoTile.ComplementaryLine = ComplementaryLine diff --git a/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTilesGrid.tsx b/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTilesGrid.tsx new file mode 100644 index 000000000..e11ef5721 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/info-tile/InfoTilesGrid.tsx @@ -0,0 +1,19 @@ +import { cn } from '@/ui/utils/style' + +interface InfoTilesGridProps { + children: React.ReactNode +} + +export function InfoTilesGrid({ children }: InfoTilesGridProps) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.stories.tsx b/packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.stories.tsx new file mode 100644 index 000000000..c77f3df4e --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.stories.tsx @@ -0,0 +1,31 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { StatusIcon } from './StatusIcon' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/Components/StatusIcon', + component: StatusIcon, +} + +export default meta +type Story = StoryObj + +export const GreenCheckmark: Story = { + name: 'Green Checkmark', + render: () => , +} + +export const OrangeCheckmark: Story = { + name: 'Orange Checkmark', + render: () => , +} + +export const GrayX: Story = { + name: 'Gray X', + render: () => , +} + +export const RedX: Story = { + name: 'Red X', + render: () => , +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.tsx b/packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.tsx new file mode 100644 index 000000000..a4bf538b2 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/status-icon/StatusIcon.tsx @@ -0,0 +1,17 @@ +import { MarketAssetStatus } from '@/domain/market-info/reserve-status' +import { getVariantFromStatus } from '@/features/markets/components/asset-status-badge/getVariantFromStatus' +import CheckCircle from '@/ui/assets/check-circle.svg?react' +import XCircle from '@/ui/assets/x-circle.svg?react' +import { IndicatorIcon } from '@/ui/atoms/indicator-icon/IndicatorIcon' + +interface StatusIconProps { + status: MarketAssetStatus +} + +export function StatusIcon({ status }: StatusIconProps) { + const variant = getVariantFromStatus(status) + if (variant === 'green' || variant === 'orange') { + return } variant={variant} className="self-center" /> + } + return } variant={variant} className="self-center" /> +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.stories.ts b/packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.stories.ts new file mode 100644 index 000000000..7d5805de3 --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.stories.ts @@ -0,0 +1,59 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { TokenBadge } from './TokenBadge' + +const meta: Meta = { + title: 'Features/MarketDetails/Components/StatusPanel/Components/TokenBadge', + component: TokenBadge, +} + +export default meta +type Story = StoryObj + +export const DaiDesktop: Story = { + name: 'DAI Desktop', + args: { + symbol: TokenSymbol('DAI'), + }, +} +export const DaiMobile = { + ...getMobileStory(DaiDesktop), + name: 'DAI Mobile', +} +export const DaiTablet = { + ...getTabletStory(DaiDesktop), + name: 'DAI Tablet', +} + +export const sDaiDesktop: Story = { + name: 'sDAI Desktop', + args: { + symbol: TokenSymbol('sDAI'), + }, +} +export const sDaiMobile = { + ...getMobileStory(sDaiDesktop), + name: 'sDAI Mobile', +} +export const sDaiTablet = { + ...getTabletStory(sDaiDesktop), + name: 'sDAI Tablet', +} + +export const OtherTokenDesktop: Story = { + name: 'Other Token Desktop', + args: { + symbol: TokenSymbol('OTHER'), + }, +} +export const OtherTokenMobile = { + ...getMobileStory(OtherTokenDesktop), + name: 'Other Token Mobile', +} +export const OtherTokenTablet = { + ...getTabletStory(OtherTokenDesktop), + name: 'Other Token Tablet', +} diff --git a/packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.tsx b/packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.tsx new file mode 100644 index 000000000..ae0ece7cf --- /dev/null +++ b/packages/app/src/features/market-details/components/status-panel/components/token-badge/TokenBadge.tsx @@ -0,0 +1,35 @@ +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { getTokenImage } from '@/ui/assets' +import { cn } from '@/ui/utils/style' + +export interface TokenBadgeProps { + symbol: TokenSymbol +} + +export function TokenBadge({ symbol }: TokenBadgeProps) { + const tokenImage = getTokenImage(symbol) + + return ( +
+ {symbol} + {symbol} +
+ ) +} + +function getTokenBgColor(symbol: TokenSymbol) { + switch (symbol) { + case 'DAI': + return 'bg-product-dai/10' + case 'sDAI': + return 'bg-product-sdai/10' + default: + return 'bg-light-blue/10' + } +} diff --git a/packages/app/src/features/market-details/logic/getReserveEModeCategoryTokens.ts b/packages/app/src/features/market-details/logic/getReserveEModeCategoryTokens.ts new file mode 100644 index 000000000..a8c23eab8 --- /dev/null +++ b/packages/app/src/features/market-details/logic/getReserveEModeCategoryTokens.ts @@ -0,0 +1,11 @@ +import { MarketInfo, Reserve } from '@/domain/market-info/marketInfo' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +export function getReserveEModeCategoryTokens(marketInfo: MarketInfo, reserve: Reserve): TokenSymbol[] { + const reserveEModeCategoryId = reserve.eModeCategory?.id + if (reserveEModeCategoryId !== 1 && reserveEModeCategoryId !== 2) return [] + + return marketInfo.reserves + .filter((r) => r.eModeCategory?.id === reserveEModeCategoryId) + .map((reserve) => reserve.token.symbol) +} diff --git a/packages/app/src/features/market-details/logic/makeDaiMarketOverview.ts b/packages/app/src/features/market-details/logic/makeDaiMarketOverview.ts new file mode 100644 index 000000000..c957841fe --- /dev/null +++ b/packages/app/src/features/market-details/logic/makeDaiMarketOverview.ts @@ -0,0 +1,70 @@ +import { eModeCategoryIdToName } from '@/domain/e-mode/constants' +import { MakerInfo } from '@/domain/maker-info/types' +import { MarketInfo, Reserve } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { MarketOverview } from '../types' +import { makeMarketOverview } from './makeMarketOverview' + +export interface MakeDaiMarketOverviewParams { + reserve: Reserve + marketInfo: MarketInfo + makerInfo: MakerInfo +} + +export function makeDaiMarketOverview({ reserve, marketInfo, makerInfo }: MakeDaiMarketOverviewParams): MarketOverview { + const baseOverview = makeMarketOverview({ reserve, marketInfo }) + const sDai = marketInfo.findOneReserveBySymbol(TokenSymbol('sDAI')) + const sDaiOverview = makeMarketOverview({ reserve: sDai, marketInfo }) + const makerDaoCapacity = NormalizedUnitNumber(makerInfo.maxDebtCeiling.minus(makerInfo.D3MCurrentDebtUSD)) + const marketSize = NormalizedUnitNumber(reserve.totalLiquidity.plus(makerDaoCapacity)) + const totalAvailable = NormalizedUnitNumber(marketSize.minus(reserve.totalDebt)) + const utilizationRate = Percentage(reserve.totalDebt.div(marketSize)) + + return { + supply: undefined, + lend: + import.meta.env.VITE_FEATURE_DISABLE_DAI_LEND === '1' + ? undefined + : { + status: 'yes', + token: reserve.token, + totalLent: reserve.totalLiquidity, + apy: reserve.supplyAPY, + }, + collateral: { + ...sDaiOverview.collateral, + status: 'yes', + supplyReplacement: { + token: sDai.token, + totalSupplied: sDai.totalLiquidity, + supplyAPY: sDai.supplyAPY, + }, + }, + borrow: { + ...baseOverview.borrow, + showTokenBadge: true, + }, + summary: { + type: 'dai', + borrowed: reserve.totalDebt, + instantlyAvailable: reserve.availableLiquidity, + makerDaoCapacity, + marketSize, + totalAvailable, + utilizationRate, + }, + ...(sDai.eModeCategory && + (sDai.eModeCategory.id === 1 || sDai.eModeCategory.id === 2) && { + eMode: { + maxLtv: sDai.eModeCategory.ltv, + liquidationThreshold: sDai.eModeCategory.liquidationThreshold, + liquidationPenalty: sDai.eModeCategory.liquidationBonus, + category: eModeCategoryIdToName[sDai.eModeCategory.id], + token: sDai.token, + eModeCategoryTokens: sDaiOverview.eMode!.eModeCategoryTokens, + }, + }), + } +} diff --git a/packages/app/src/features/market-details/logic/makeMarketOverview.ts b/packages/app/src/features/market-details/logic/makeMarketOverview.ts new file mode 100644 index 000000000..52ff2e17e --- /dev/null +++ b/packages/app/src/features/market-details/logic/makeMarketOverview.ts @@ -0,0 +1,65 @@ +import { eModeCategoryIdToName } from '@/domain/e-mode/constants' +import { MarketInfo, Reserve } from '@/domain/market-info/marketInfo' + +import { MarketOverview } from '../types' +import { getReserveEModeCategoryTokens } from './getReserveEModeCategoryTokens' + +export interface MakeMarketOverviewParams { + marketInfo: MarketInfo + reserve: Reserve +} + +export function makeMarketOverview({ reserve, marketInfo }: MakeMarketOverviewParams): MarketOverview { + const eModeCategoryId = reserve.eModeCategory?.id + const eModeCategoryTokens = getReserveEModeCategoryTokens(marketInfo, reserve) + + return { + supply: { + status: reserve.supplyAvailabilityStatus, + totalSupplied: reserve.totalLiquidity, + supplyCap: reserve.supplyCap, + apy: reserve.supplyAPY, + }, + collateral: { + status: reserve.collateralEligibilityStatus, + token: reserve.token, + debtCeiling: reserve.debtCeiling, + debt: reserve.totalDebt, + maxLtv: reserve.maxLtv, + liquidationThreshold: reserve.liquidationThreshold, + liquidationPenalty: reserve.liquidationBonus, + }, + borrow: { + status: reserve.borrowEligibilityStatus, + totalBorrowed: reserve.totalDebt, + borrowCap: reserve.borrowCap, + apy: reserve.variableBorrowApy, + reserveFactor: reserve.reserveFactor, + chartProps: { + utilizationRate: reserve.utilizationRate, + optimalUtilizationRate: reserve.optimalUtilizationRate, + variableRateSlope1: reserve.variableRateSlope1, + variableRateSlope2: reserve.variableRateSlope2, + baseVariableBorrowRate: reserve.baseVariableBorrowRate, + }, + }, + summary: { + type: 'default', + marketSize: reserve.totalLiquidity, + utilizationRate: reserve.utilizationRate, + borrowed: reserve.totalDebt, + available: reserve.availableLiquidity, + }, + ...(eModeCategoryId === 1 || eModeCategoryId === 2 + ? { + eMode: { + maxLtv: reserve.eModeCategory!.ltv, + liquidationThreshold: reserve.eModeCategory!.liquidationThreshold, + liquidationPenalty: reserve.eModeCategory!.liquidationBonus, + category: eModeCategoryIdToName[eModeCategoryId], + eModeCategoryTokens, + }, + } + : {}), + } +} diff --git a/packages/app/src/features/market-details/logic/makeWalletOverview.ts b/packages/app/src/features/market-details/logic/makeWalletOverview.ts new file mode 100644 index 000000000..f478191b6 --- /dev/null +++ b/packages/app/src/features/market-details/logic/makeWalletOverview.ts @@ -0,0 +1,156 @@ +import invariant from 'tiny-invariant' + +import { getBorrowMaxValue } from '@/domain/action-max-value-getters/getBorrowMaxValue' +import { getDepositMaxValue } from '@/domain/action-max-value-getters/getDepositMaxValue' +import { MarketInfo, Reserve } from '@/domain/market-info/marketInfo' +import { getValidateBorrowArgs, validateBorrow } from '@/domain/market-validators/validateBorrow' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' +import { applyTransformers } from '@/utils/applyTransformers' + +import { WalletOverview } from '../types' + +export interface MakeWalletOverviewParams { + reserve: Reserve + walletInfo: WalletInfo + marketInfo: MarketInfo + connectedChainId: number +} + +export function makeWalletOverview({ + reserve, + marketInfo, + walletInfo, + connectedChainId, +}: MakeWalletOverviewParams): WalletOverview { + const overview = applyTransformers({ reserve, marketInfo, walletInfo, connectedChainId })([ + makeGuestModeOverview, + makeChainMismatchOverview, + makeDaiOverview, + makeBaseWalletOverview, + ]) + invariant(overview, 'The only item was skipped by transformers.') + + return overview +} + +function makeGuestModeOverview({ reserve, walletInfo }: MakeWalletOverviewParams): WalletOverview | undefined { + if (walletInfo.isConnected) { + return undefined + } + + const token = reserve.token + + return { + guestMode: true, + chainMismatch: false, + token, + tokenBalance: walletInfo.findWalletBalanceForToken(token), + deposit: { + token, + available: NormalizedUnitNumber(0), + }, + borrow: { + token, + available: NormalizedUnitNumber(0), + }, + } +} + +function makeChainMismatchOverview({ + reserve, + marketInfo, + walletInfo, + connectedChainId, +}: MakeWalletOverviewParams): WalletOverview | undefined { + if (connectedChainId === marketInfo.chainId) { + return undefined + } + + const token = reserve.token + + return { + guestMode: false, + chainMismatch: true, + token, + tokenBalance: walletInfo.findWalletBalanceForToken(token), + deposit: { + token, + available: NormalizedUnitNumber(0), + }, + borrow: { + token, + available: NormalizedUnitNumber(0), + }, + } +} + +function makeBaseWalletOverview({ reserve, marketInfo, walletInfo }: MakeWalletOverviewParams): WalletOverview { + const token = reserve.token + const tokenBalance = walletInfo.findWalletBalanceForToken(token) + + const availableToDeposit = getDepositMaxValue({ + asset: { + status: reserve.status, + totalDebt: reserve.totalDebt, + decimals: reserve.token.decimals, + index: reserve.variableBorrowIndex, + rate: reserve.variableBorrowRate, + lastUpdateTimestamp: reserve.lastUpdateTimestamp, + totalLiquidity: reserve.totalLiquidity, + supplyCap: reserve.supplyCap, + }, + user: { + balance: tokenBalance, + }, + timestamp: marketInfo.timestamp, + }) + + const borrowValidationArgs = getValidateBorrowArgs(NormalizedUnitNumber(0), reserve, marketInfo) + const validationIssue = validateBorrow(borrowValidationArgs) + + const availableToBorrow = getBorrowMaxValue({ + validationIssue, + user: borrowValidationArgs.user, + asset: borrowValidationArgs.asset, + }) + + return { + guestMode: false, + chainMismatch: false, + token, + tokenBalance, + deposit: { + token, + available: availableToDeposit, + }, + borrow: { + token, + available: availableToBorrow, + }, + } +} + +function makeDaiOverview({ reserve, marketInfo, ...rest }: MakeWalletOverviewParams): WalletOverview | undefined { + if (reserve.token.symbol !== 'DAI') { + return undefined + } + + const baseOverview = makeBaseWalletOverview({ reserve, marketInfo, ...rest }) + const sDaiReserve = marketInfo.findOneReserveBySymbol(TokenSymbol('sDAI')) + const sDaiOverview = makeBaseWalletOverview({ reserve: sDaiReserve, marketInfo, ...rest }) + + return { + ...baseOverview, + lend: + import.meta.env.VITE_FEATURE_DISABLE_DAI_LEND !== '1' + ? { + ...baseOverview.deposit, + } + : undefined, + deposit: { + ...sDaiOverview.deposit, + }, + } +} diff --git a/packages/app/src/features/market-details/logic/useMarketDetails.ts b/packages/app/src/features/market-details/logic/useMarketDetails.ts new file mode 100644 index 000000000..4e6481fbd --- /dev/null +++ b/packages/app/src/features/market-details/logic/useMarketDetails.ts @@ -0,0 +1,63 @@ +import { useChainId } from 'wagmi' + +import { getChainConfigEntry } from '@/config/chain' +import { NotFoundError } from '@/domain/errors/not-found' +import { useMakerInfo } from '@/domain/maker-info/useMakerInfo' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { raise } from '@/utils/raise' + +import { MarketOverview, WalletOverview } from '../types' +import { makeDaiMarketOverview } from './makeDaiMarketOverview' +import { makeMarketOverview } from './makeMarketOverview' +import { makeWalletOverview } from './makeWalletOverview' +import { useMarketDetailsParams } from './useMarketDetailsParams' + +interface UseMarketDetailsResult { + token: Token + chainName: string + chainId: number + marketOverview: MarketOverview + walletOverview: WalletOverview +} + +export function useMarketDetails(): UseMarketDetailsResult { + const { asset, chainId } = useMarketDetailsParams() + const { marketInfo } = useMarketInfo({ chainId }) + const { makerInfo } = useMakerInfo({ chainId }) + const walletInfo = useWalletInfo() + const connectedChainId = useChainId() + const { meta: chainMeta } = getChainConfigEntry(chainId) + + const reserve = marketInfo.findReserveByUnderlyingAsset(CheckedAddress(asset)) ?? raise(new NotFoundError()) + + const isDaiOverview = reserve.token.symbol === TokenSymbol('DAI') && makerInfo + + const marketOverview = isDaiOverview + ? makeDaiMarketOverview({ + reserve, + marketInfo, + makerInfo, + }) + : makeMarketOverview({ + reserve, + marketInfo, + }) + const walletOverview = makeWalletOverview({ + reserve, + marketInfo, + walletInfo, + connectedChainId, + }) + + return { + token: reserve.token, + chainName: chainMeta.name, + chainId, + marketOverview, + walletOverview, + } +} diff --git a/packages/app/src/features/market-details/logic/useMarketDetailsParams.ts b/packages/app/src/features/market-details/logic/useMarketDetailsParams.ts new file mode 100644 index 000000000..973755ea3 --- /dev/null +++ b/packages/app/src/features/market-details/logic/useMarketDetailsParams.ts @@ -0,0 +1,21 @@ +import { Address, isAddress } from 'viem' +import { useChains } from 'wagmi' +import { z } from 'zod' + +import { NotFoundError } from '@/domain/errors/not-found' +import { useValidatedParams } from '@/utils/useValidatedParams' + +const marketDetailsUrlSchema = z.object({ + chainId: z.coerce.number(), + asset: z.custom
((address) => isAddress(address as string)), +}) + +export function useMarketDetailsParams(): z.infer { + const params = useValidatedParams(marketDetailsUrlSchema) + const configuredChains = useChains() + if (!configuredChains.some((chain) => chain.id === params.chainId)) { + throw new NotFoundError() + } + + return params +} diff --git a/packages/app/src/features/market-details/types.ts b/packages/app/src/features/market-details/types.ts new file mode 100644 index 000000000..19122eed8 --- /dev/null +++ b/packages/app/src/features/market-details/types.ts @@ -0,0 +1,94 @@ +import { + BorrowEligibilityStatus, + CollateralEligibilityStatus, + SupplyAvailabilityStatus, +} from '@/domain/market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { InterestYieldChartProps } from './components/charts/interest-yield/InterestYieldChart' +import { EnabledEModeCategory } from './components/status-panel/components/emode-badge/EModeBadge' + +export interface SupplyReplacementInfo { + token: Token + totalSupplied: NormalizedUnitNumber + supplyAPY: Percentage +} +export interface MarketOverview { + supply?: { + status: SupplyAvailabilityStatus + totalSupplied: NormalizedUnitNumber + supplyCap?: NormalizedUnitNumber + apy: Percentage + } + collateral: { + status: CollateralEligibilityStatus + token: Token + debtCeiling: NormalizedUnitNumber + debt: NormalizedUnitNumber + maxLtv: Percentage + liquidationThreshold: Percentage + liquidationPenalty: Percentage + supplyReplacement?: SupplyReplacementInfo + } + borrow: { + status: BorrowEligibilityStatus + totalBorrowed: NormalizedUnitNumber + borrowCap?: NormalizedUnitNumber + apy: Percentage + reserveFactor: Percentage + chartProps: InterestYieldChartProps + showTokenBadge?: boolean + } + lend?: { + status: 'yes' // only for dai + token: Token + totalLent: NormalizedUnitNumber + apy: Percentage + } + eMode?: { + maxLtv: Percentage + liquidationThreshold: Percentage + liquidationPenalty: Percentage + category: EnabledEModeCategory + eModeCategoryTokens: TokenSymbol[] + token?: Token + } + summary: + | { + type: 'default' + marketSize: NormalizedUnitNumber + borrowed: NormalizedUnitNumber + available: NormalizedUnitNumber + utilizationRate: Percentage + } + | { + type: 'dai' + marketSize: NormalizedUnitNumber + borrowed: NormalizedUnitNumber + instantlyAvailable: NormalizedUnitNumber + makerDaoCapacity: NormalizedUnitNumber + totalAvailable: NormalizedUnitNumber + utilizationRate: Percentage + } +} + +export interface WalletOverview { + guestMode: boolean + chainMismatch: boolean + token: Token + tokenBalance: NormalizedUnitNumber + lend?: { + available: NormalizedUnitNumber + token: Token + } + deposit: { + available: NormalizedUnitNumber + token: Token + } + borrow: { + available: NormalizedUnitNumber + token: Token + } +} diff --git a/packages/app/src/features/market-details/views/MarketDetailsView.stories.ts b/packages/app/src/features/market-details/views/MarketDetailsView.stories.ts new file mode 100644 index 000000000..faea2eade --- /dev/null +++ b/packages/app/src/features/market-details/views/MarketDetailsView.stories.ts @@ -0,0 +1,85 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { bigNumberify } from '@/utils/bigNumber' + +import { MarketDetailsView } from './MarketDetailsView' + +const meta: Meta = { + title: 'Features/MarketDetails/Views/MarketDetailsView', + component: MarketDetailsView, + parameters: { + layout: 'fullscreen', + }, + decorators: [WithTooltipProvider(), withRouter], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + args: { + token: tokens['rETH'], + chainName: 'Ethereum Mainnet', + chainId: 1, + walletOverview: { + guestMode: false, + chainMismatch: false, + token: tokens['rETH'], + tokenBalance: NormalizedUnitNumber(10), + deposit: { + token: tokens['rETH'], + available: NormalizedUnitNumber(10), + }, + borrow: { + token: tokens['rETH'], + available: NormalizedUnitNumber(10), + }, + }, + marketOverview: { + supply: { + status: 'yes', + totalSupplied: NormalizedUnitNumber(72_000), + supplyCap: NormalizedUnitNumber(112_000), + apy: Percentage(0.05), + }, + collateral: { + status: 'yes', + token: tokens['rETH'], + debt: NormalizedUnitNumber(1000), + debtCeiling: NormalizedUnitNumber(0), + maxLtv: Percentage(0.8), + liquidationThreshold: Percentage(0.825), + liquidationPenalty: Percentage(0.05), + }, + borrow: { + status: 'yes', + totalBorrowed: NormalizedUnitNumber(1244), + apy: Percentage(0.01), + borrowCap: NormalizedUnitNumber(2244), + reserveFactor: Percentage(0.05), + chartProps: { + optimalUtilizationRate: Percentage('0.45'), + utilizationRate: Percentage('0.08'), + variableRateSlope1: bigNumberify('45000000000000000000000000'), + variableRateSlope2: bigNumberify('800000000000000000000000000'), + baseVariableBorrowRate: bigNumberify('2500000000000000000000000'), + }, + }, + summary: { + type: 'default', + marketSize: NormalizedUnitNumber(1_243_000_000), + borrowed: NormalizedUnitNumber(823_000_000), + available: NormalizedUnitNumber(420_000_000), + utilizationRate: Percentage(0.66), + }, + }, + }, +} + +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/features/market-details/views/MarketDetailsView.tsx b/packages/app/src/features/market-details/views/MarketDetailsView.tsx new file mode 100644 index 000000000..4646200ec --- /dev/null +++ b/packages/app/src/features/market-details/views/MarketDetailsView.tsx @@ -0,0 +1,15 @@ +import { useBreakpoint } from '@/ui/utils/useBreakpoint' + +import { CompactView } from './components/CompactView' +import { FullView } from './components/FullView' +import { MarketDetailsViewProps } from './types' + +export function MarketDetailsView(props: MarketDetailsViewProps) { + const tablet = useBreakpoint('sm') + + if (tablet) { + return + } + + return +} diff --git a/packages/app/src/features/market-details/views/components/BackNav.tsx b/packages/app/src/features/market-details/views/components/BackNav.tsx new file mode 100644 index 000000000..a7fd962cb --- /dev/null +++ b/packages/app/src/features/market-details/views/components/BackNav.tsx @@ -0,0 +1,27 @@ +import { ArrowLeft, Minus } from 'lucide-react' + +import { getChainConfigEntry } from '@/config/chain' +import { paths } from '@/config/paths' +import { LinkButton } from '@/ui/atoms/button/Button' + +interface BackNavProps { + chainId: number + chainName: string +} + +export function BackNav({ chainId, chainName }: BackNavProps) { + const chainImage = getChainConfigEntry(chainId).meta.logo + + return ( +
+ }> + Back to Markets + + +
+ + {chainName} +
+
+ ) +} diff --git a/packages/app/src/features/market-details/views/components/CompactView.tsx b/packages/app/src/features/market-details/views/components/CompactView.tsx new file mode 100644 index 000000000..9057ab3d1 --- /dev/null +++ b/packages/app/src/features/market-details/views/components/CompactView.tsx @@ -0,0 +1,46 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/ui/atoms/tabs/Tabs' + +import { MarketOverview as MarketOverviewPanel } from '../../components/market-overview/MarketOverview' +import { MyWalletPanel } from '../../components/my-wallet/MyWalletPanel' +import { BorrowStatusPanel } from '../../components/status-panel/BorrowStatusPanel' +import { CollateralStatusPanel } from '../../components/status-panel/CollateralStatusPanel' +import { EModeStatusPanel } from '../../components/status-panel/EModeStatusPanel' +import { LendStatusPanel } from '../../components/status-panel/LendStatusPanel' +import { SupplyStatusPanel } from '../../components/status-panel/SupplyStatusPanel' +import { MarketDetailsViewProps } from '../types' +import { BackNav } from './BackNav' +import { Header } from './Header' + +export function CompactView({ + token, + chainName, + chainId, + marketOverview, + walletOverview, + openConnectModal, + openDialog, +}: MarketDetailsViewProps) { + return ( +
+ +
+ + + Overview + Actions + + + + {marketOverview.supply && } + {marketOverview.lend && } + + {marketOverview.eMode && } + + + + + + +
+ ) +} diff --git a/packages/app/src/features/market-details/views/components/FullView.tsx b/packages/app/src/features/market-details/views/components/FullView.tsx new file mode 100644 index 000000000..75d8958b9 --- /dev/null +++ b/packages/app/src/features/market-details/views/components/FullView.tsx @@ -0,0 +1,40 @@ +import { MarketOverview as MarketOverviewPanel } from '../../components/market-overview/MarketOverview' +import { MyWalletPanel } from '../../components/my-wallet/MyWalletPanel' +import { BorrowStatusPanel } from '../../components/status-panel/BorrowStatusPanel' +import { CollateralStatusPanel } from '../../components/status-panel/CollateralStatusPanel' +import { EModeStatusPanel } from '../../components/status-panel/EModeStatusPanel' +import { LendStatusPanel } from '../../components/status-panel/LendStatusPanel' +import { SupplyStatusPanel } from '../../components/status-panel/SupplyStatusPanel' +import { MarketDetailsViewProps } from '../types' +import { BackNav } from './BackNav' +import { Header } from './Header' + +export function FullView({ + token, + chainId, + chainName, + marketOverview, + walletOverview, + openConnectModal, + openDialog, +}: MarketDetailsViewProps) { + return ( +
+ +
+
+
+ {marketOverview.supply && } + {marketOverview.lend && } + + {marketOverview.eMode && } + +
+
+ + +
+
+
+ ) +} diff --git a/packages/app/src/features/market-details/views/components/Header.tsx b/packages/app/src/features/market-details/views/components/Header.tsx new file mode 100644 index 000000000..f891377db --- /dev/null +++ b/packages/app/src/features/market-details/views/components/Header.tsx @@ -0,0 +1,15 @@ +import { Token } from '@/domain/types/Token' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' + +interface HeaderProps { + token: Token +} + +export function Header({ token }: HeaderProps) { + return ( +
+ +

{token.symbol}

+
+ ) +} diff --git a/packages/app/src/features/market-details/views/types.ts b/packages/app/src/features/market-details/views/types.ts new file mode 100644 index 000000000..b8ce1ff44 --- /dev/null +++ b/packages/app/src/features/market-details/views/types.ts @@ -0,0 +1,14 @@ +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { Token } from '@/domain/types/Token' + +import { MarketOverview, WalletOverview } from '../types' + +export interface MarketDetailsViewProps { + token: Token + chainName: string + chainId: number + marketOverview: MarketOverview + walletOverview: WalletOverview + openConnectModal: () => void + openDialog: OpenDialogFunction +} diff --git a/packages/app/src/features/markets/MarketsContainer.tsx b/packages/app/src/features/markets/MarketsContainer.tsx new file mode 100644 index 000000000..87f0f28c0 --- /dev/null +++ b/packages/app/src/features/markets/MarketsContainer.tsx @@ -0,0 +1,22 @@ +import { withSuspense } from '@/ui/utils/withSuspense' + +import { MarketsSkeleton } from './components/skeleton/MarketsSkeleton' +import { useMarkets } from './logic/useMarkets' +import { MarketsView } from './views/MarketsView' + +function MarketsContainer() { + const { marketStats, activeAndPausedMarketEntries, frozenMarketEntries, chainId, chainName } = useMarkets() + + return ( + + ) +} + +const MarketsContainerWithSuspense = withSuspense(MarketsContainer, MarketsSkeleton) +export { MarketsContainerWithSuspense as MarketsContainer } diff --git a/packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.stories.tsx b/packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.stories.tsx new file mode 100644 index 000000000..45e0e4d4f --- /dev/null +++ b/packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.stories.tsx @@ -0,0 +1,30 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getHoveredStory } from '@storybook/utils' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { AirdropBadge } from './AirdropBadge' + +const meta: Meta = { + title: 'Features/Markets/Components/AirdropBadge', + component: AirdropBadge, + decorators: [ + WithTooltipProvider(), + withRouter, + WithClassname('bg-white flex justify-center p-8 items-end w-96 h-64'), + ], + args: { + value: NormalizedUnitNumber(24_000_000), + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const Hovered = getHoveredStory(Default, 'button') diff --git a/packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.tsx b/packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.tsx new file mode 100644 index 000000000..0eeeb5268 --- /dev/null +++ b/packages/app/src/features/markets/components/airdrop-badge/AirdropBadge.tsx @@ -0,0 +1,42 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { assets } from '@/ui/assets' +import { IconPill } from '@/ui/atoms/icon-pill/IconPill' +import { Link } from '@/ui/atoms/link/Link' +import { Tooltip, TooltipContentLong, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { TooltipContentLayout } from '@/ui/atoms/tooltip/TooltipContentLayout' +import { links } from '@/ui/constants/links' + +interface AirdropBadgeProps { + value: NormalizedUnitNumber +} + +// @todo It should take into consideration other airdrops as well, when SPK is not only one +export function AirdropBadge({ value }: AirdropBadgeProps) { + return ( + + + + + + + + + + Eligible for {USD_MOCK_TOKEN.format(value, { style: 'compact' })} Spark Airdrop + + + + + DAI borrowers with volatile assets and ETH depositors will be eligible for a future ⚡ SPK airdrop. Please + read the details on the{' '} + + Maker governance forum + + . + + + + + ) +} diff --git a/packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.stories.tsx b/packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.stories.tsx new file mode 100644 index 000000000..d250f8538 --- /dev/null +++ b/packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.stories.tsx @@ -0,0 +1,80 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { AssetStatusBadge } from './AssetStatusBadge' + +const meta: Meta = { + title: 'Features/Markets/Components/AssetStatusBadge', + component: AssetStatusBadge, + decorators: [WithTooltipProvider(), WithClassname('bg-white flex justify-center p-8 items-end w-46 md:w-96 h-56')], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'All Active', + args: { + supplyStatus: 'yes', + collateralStatus: 'yes', + borrowStatus: 'yes', + }, +} + +export const SupplyCapReached: Story = { + name: 'Supply Cap Reached', + args: { + supplyStatus: 'supply-cap-reached', + collateralStatus: 'yes', + borrowStatus: 'yes', + }, +} + +export const CollateralOff: Story = { + name: 'Collateral Off', + args: { + supplyStatus: 'yes', + collateralStatus: 'no', + borrowStatus: 'yes', + }, +} + +export const BorrowOff: Story = { + name: 'Borrow Off', + args: { + supplyStatus: 'yes', + collateralStatus: 'yes', + borrowStatus: 'no', + }, +} + +export const CollateralIsolationMode: Story = { + name: 'Collateral Isolation Mode', + args: { + supplyStatus: 'yes', + collateralStatus: 'only-in-isolation-mode', + borrowStatus: 'yes', + }, +} + +export const BorrowCapReached: Story = { + name: 'Borrow Cap Reached', + args: { + supplyStatus: 'yes', + collateralStatus: 'yes', + borrowStatus: 'borrow-cap-reached', + }, +} + +export const BorrowSiloedMode: Story = { + name: 'Borrow Siloed Mode', + args: { + supplyStatus: 'yes', + collateralStatus: 'yes', + borrowStatus: 'only-in-siloed-mode', + }, +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) diff --git a/packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.tsx b/packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.tsx new file mode 100644 index 000000000..82e33626e --- /dev/null +++ b/packages/app/src/features/markets/components/asset-status-badge/AssetStatusBadge.tsx @@ -0,0 +1,77 @@ +import { DownloadIcon, LayersIcon, UploadIcon } from 'lucide-react' + +import { + BorrowEligibilityStatus, + CollateralEligibilityStatus, + SupplyAvailabilityStatus, +} from '@/domain/market-info/reserve-status' +import { IndicatorIcon } from '@/ui/atoms/indicator-icon/IndicatorIcon' +import { Tooltip, TooltipContentLong, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' + +import { AssetStatusDescription } from './components/AssetStatusDescription' +import { getVariantFromStatus } from './getVariantFromStatus' + +export interface AssetStatusBadgeProps { + supplyStatus: SupplyAvailabilityStatus + collateralStatus: CollateralEligibilityStatus + borrowStatus: BorrowEligibilityStatus +} + +export function AssetStatusBadge({ supplyStatus, collateralStatus, borrowStatus }: AssetStatusBadgeProps) { + const supplyIcon = ( + } variant={getVariantFromStatus(supplyStatus)} /> + ) + const collateralIcon = ( + } variant={getVariantFromStatus(collateralStatus)} /> + ) + const borrowIcon = ( + } variant={getVariantFromStatus(borrowStatus)} /> + ) + + return ( + + +
+ {supplyIcon} + {collateralIcon} + {borrowIcon} +
+
+ +
+ + {supplyIcon} + {supplyStatusDescription[supplyStatus]} + + + {collateralIcon} + {collateralStatusDescription[collateralStatus]} + + + {borrowIcon} + {borrowStatusDescription[borrowStatus]} + +
+
+
+ ) +} + +const supplyStatusDescription: Record = { + yes: 'Can be supplied', + no: 'Cannot be supplied', + 'supply-cap-reached': 'Supply limit reached', +} + +const collateralStatusDescription: Record = { + yes: 'Can be used as collateral', + no: 'Cannot be used as collateral', + 'only-in-isolation-mode': 'Can be used as collateral only in isolation mode', +} + +const borrowStatusDescription: Record = { + yes: 'Can be borrowed', + no: 'Cannot be borrowed', + 'borrow-cap-reached': 'Borrow limit reached', + 'only-in-siloed-mode': 'Can be borrowed only in siloed mode', +} diff --git a/packages/app/src/features/markets/components/asset-status-badge/components/AssetStatusDescription.tsx b/packages/app/src/features/markets/components/asset-status-badge/components/AssetStatusDescription.tsx new file mode 100644 index 000000000..d979f1f98 --- /dev/null +++ b/packages/app/src/features/markets/components/asset-status-badge/components/AssetStatusDescription.tsx @@ -0,0 +1,15 @@ +import { ReactNode } from 'react' + +interface AssetStatusDescriptionProps { + children: [ReactNode, ReactNode] +} + +export function AssetStatusDescription({ children }: AssetStatusDescriptionProps) { + const [icon, description] = children + return ( +
+ {icon} +

{description}

+
+ ) +} diff --git a/packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.test.ts b/packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.test.ts new file mode 100644 index 000000000..f2ed21856 --- /dev/null +++ b/packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.test.ts @@ -0,0 +1,26 @@ +import { describe } from 'vitest' + +import { getVariantFromStatus } from './getVariantFromStatus' + +describe(getVariantFromStatus.name, () => { + it("should return green for status 'yes'", () => { + expect(getVariantFromStatus('yes')).toBe('green') + }) + + it("should return gray for status 'no'", () => { + expect(getVariantFromStatus('no')).toBe('gray') + }) + + it("should return red for status 'supply-cap-reached'", () => { + expect(getVariantFromStatus('supply-cap-reached')).toBe('red') + }) + + it("should return red for status 'borrow-cap-reached'", () => { + expect(getVariantFromStatus('borrow-cap-reached')).toBe('red') + }) + + it('should return orange for any other status', () => { + expect(getVariantFromStatus('only-in-isolation-mode')).toBe('orange') + expect(getVariantFromStatus('only-in-siloed-mode')).toBe('orange') + }) +}) diff --git a/packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.ts b/packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.ts new file mode 100644 index 000000000..9c177ad31 --- /dev/null +++ b/packages/app/src/features/markets/components/asset-status-badge/getVariantFromStatus.ts @@ -0,0 +1,16 @@ +import { MarketAssetStatus } from '@/domain/market-info/reserve-status' + +type StatusVariant = 'green' | 'gray' | 'orange' | 'red' + +export function getVariantFromStatus(status: MarketAssetStatus): StatusVariant { + if (status === 'yes') { + return 'green' + } + if (status === 'no') { + return 'gray' + } + if (status === 'supply-cap-reached' || status === 'borrow-cap-reached') { + return 'red' + } + return 'orange' +} diff --git a/packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.stories.ts b/packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.stories.ts new file mode 100644 index 000000000..c68a389d4 --- /dev/null +++ b/packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.stories.ts @@ -0,0 +1,29 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { DebtCeilingProgress } from './DebtCeilingProgress' + +const meta: Meta = { + title: 'Features/Markets/Components/DebtCeilingProgress', + component: DebtCeilingProgress, + decorators: [WithTooltipProvider(), WithClassname('max-w-2xl'), withRouter], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + token: tokens['GNO'], + debt: NormalizedUnitNumber(123), + debtCeiling: NormalizedUnitNumber(200), + }, +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) diff --git a/packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.tsx b/packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.tsx new file mode 100644 index 000000000..0f8946676 --- /dev/null +++ b/packages/app/src/features/markets/components/debt-ceiling-progress/DebtCeilingProgress.tsx @@ -0,0 +1,38 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Link } from '@/ui/atoms/link/Link' +import { Progress } from '@/ui/atoms/progress/Progress' +import { links } from '@/ui/constants/links' +import { Info } from '@/ui/molecules/info/Info' + +interface DebtCeilingProgressProps { + token: Token + debt: NormalizedUnitNumber + debtCeiling: NormalizedUnitNumber + className?: string +} + +export function DebtCeilingProgress({ token, debt, debtCeiling }: DebtCeilingProgressProps) { + const value = debt.dividedBy(debtCeiling).multipliedBy(100).toNumber() + return ( +
+
+
+

Isolated Debt Ceiling

+ + Debt ceiling limits the amount possible to borrow against this asset by protocol users. Debt ceiling is + specific to assets in isolation mode and is denoted in USD.{' '} + + Learn more + + +
+

+ {token.formatUSD(debt, { compact: true })} + of {token.formatUSD(debtCeiling, { compact: true })} +

+
+ +
+ ) +} diff --git a/packages/app/src/features/markets/components/markets-table/MarketsTable.stories.tsx b/packages/app/src/features/markets/components/markets-table/MarketsTable.stories.tsx new file mode 100644 index 000000000..357f30c91 --- /dev/null +++ b/packages/app/src/features/markets/components/markets-table/MarketsTable.stories.tsx @@ -0,0 +1,123 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { within } from '@storybook/testing-library' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { raise } from '@/utils/raise' + +import { MarketsTable } from './MarketsTable' + +const meta: Meta = { + title: 'Features/Markets/Components/MarketsTable', + component: MarketsTable, + decorators: [WithTooltipProvider(), WithClassname('max-w-6xl'), withRouter], + args: { + entries: [ + { + token: tokens['wstETH'], + reserveStatus: 'frozen', + borrowAPYDetails: { apy: Percentage(0.11), incentives: [], airdrops: [] }, + depositAPYDetails: { + apy: Percentage(0.157), + incentives: [{ token: tokens['stETH'], APR: Percentage(0.1) }], + airdrops: [], + }, + totalBorrowed: NormalizedUnitNumber(0), + totalSupplied: NormalizedUnitNumber(11.99), + marketStatus: { + supplyAvailabilityStatus: 'no', + collateralEligibilityStatus: 'no', + borrowEligibilityStatus: 'no', + }, + }, + { + token: tokens['GNO'], + reserveStatus: 'paused', + borrowAPYDetails: { apy: Percentage(0.11), incentives: [], airdrops: [] }, + depositAPYDetails: { + apy: Percentage(0.157), + incentives: [], + airdrops: [], + }, + totalBorrowed: NormalizedUnitNumber(0), + totalSupplied: NormalizedUnitNumber(11.99), + marketStatus: { + supplyAvailabilityStatus: 'no', + collateralEligibilityStatus: 'no', + borrowEligibilityStatus: 'no', + }, + }, + { + token: tokens['ETH'], + reserveStatus: 'active', + borrowAPYDetails: { apy: Percentage(0.11), incentives: [], airdrops: [] }, + depositAPYDetails: { + apy: Percentage(0.157), + airdrops: [{ id: 'SPK', amount: NormalizedUnitNumber(6_000_000) }], + incentives: [{ token: tokens['stETH'], APR: Percentage(0.1) }], + }, + totalBorrowed: NormalizedUnitNumber(0), + totalSupplied: NormalizedUnitNumber(11.99), + marketStatus: { + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'yes', + borrowEligibilityStatus: 'yes', + }, + }, + { + token: tokens['rETH'], + reserveStatus: 'active', + borrowAPYDetails: { apy: Percentage(0.11), incentives: [], airdrops: [] }, + depositAPYDetails: { + apy: Percentage(0.157), + incentives: [{ token: tokens['stETH'], APR: Percentage(0.1) }], + airdrops: [], + }, + totalBorrowed: NormalizedUnitNumber(0), + totalSupplied: NormalizedUnitNumber(11.99), + marketStatus: { + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'yes', + borrowEligibilityStatus: 'yes', + }, + }, + { + token: tokens['DAI'], + reserveStatus: 'active', + borrowAPYDetails: { + apy: Percentage(0.0553), + incentives: [], + airdrops: [{ id: 'SPK', amount: NormalizedUnitNumber(24_000_000) }], + }, + depositAPYDetails: { apy: Percentage(0.05), incentives: [], airdrops: [] }, + totalBorrowed: NormalizedUnitNumber(1257), + totalSupplied: NormalizedUnitNumber(0), + marketStatus: { + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'yes', + borrowEligibilityStatus: 'yes', + }, + }, + ], + chainId: 1, + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const Mobile = getMobileStory({ + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + const switches = await canvas.findAllByRole('switch') + ;(switches[0] ?? raise('No switch element found')).click() + }, +}) +export const Tablet = getTabletStory(Default) diff --git a/packages/app/src/features/markets/components/markets-table/MarketsTable.tsx b/packages/app/src/features/markets/components/markets-table/MarketsTable.tsx new file mode 100644 index 000000000..4bf1f17c9 --- /dev/null +++ b/packages/app/src/features/markets/components/markets-table/MarketsTable.tsx @@ -0,0 +1,127 @@ +import { generatePath } from 'react-router-dom' + +import { paths } from '@/config/paths' +import { sortByUsdValue } from '@/domain/common/sorters' +import { LinkButton } from '@/ui/atoms/button/Button' +import { ApyTooltip } from '@/ui/molecules/apy-tooltip/ApyTooltip' +import { ActionsCell } from '@/ui/molecules/data-table/components/ActionsCell' +import { CompactValueCell } from '@/ui/molecules/data-table/components/CompactValueCell' +import { ResponsiveDataTable } from '@/ui/organisms/responsive-data-table/ResponsiveDataTable' + +import { MarketEntry } from '../../types' +import { AssetStatusBadge } from '../asset-status-badge/AssetStatusBadge' +import { ApyWithRewardsCell } from './components/ApyWithRewardsCell' +import { AssetNameCell } from './components/AssetNameCell' + +export interface MarketsTableProps { + entries: MarketEntry[] + chainId: number + hideTableHeader?: boolean +} + +export function MarketsTable({ entries, chainId, hideTableHeader }: MarketsTableProps) { + return ( + , + }, + totalSupplied: { + header: 'Total supplied', + headerAlign: 'right', + sortable: true, + sortingFn: (a, b) => sortByUsdValue(a.original, b.original, 'totalSupplied'), + renderCell: ({ token, totalSupplied, reserveStatus }, mobileViewOptions) => ( + + ), + }, + depositAPY: { + header: Deposit APY, + headerAlign: 'right', + sortable: true, + sortingFn: (a, b) => a.original.depositAPYDetails.apy.comparedTo(b.original.depositAPYDetails.apy), + renderCell: ({ depositAPYDetails, reserveStatus, token }, mobileViewOptions) => ( + + ), + }, + totalBorrowed: { + header: 'Total borrowed', + headerAlign: 'right', + sortable: true, + sortingFn: (a, b) => sortByUsdValue(a.original, b.original, 'totalBorrowed'), + renderCell: ({ token, totalBorrowed, reserveStatus }, mobileViewOptions) => ( + + ), + }, + borrowAPY: { + header: Borrow APY, + headerAlign: 'right', + sortable: true, + sortingFn: (a, b) => a.original.borrowAPYDetails.apy.comparedTo(b.original.borrowAPYDetails.apy), + renderCell: ({ borrowAPYDetails, reserveStatus, token }, mobileViewOptions) => ( + + ), + }, + status: { + header: 'Status', + headerAlign: 'center', + renderCell: ({ marketStatus }) => ( +
+ +
+ ), + }, + actions: { + header: '', + renderCell: ({ token, reserveStatus }) => { + return ( + + + Details + + + ) + }, + }, + }} + data={entries} + /> + ) +} diff --git a/packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.stories.tsx b/packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.stories.tsx new file mode 100644 index 000000000..8def53e94 --- /dev/null +++ b/packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.stories.tsx @@ -0,0 +1,63 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { ApyWithRewardsCell } from './ApyWithRewardsCell' + +const meta: Meta = { + title: 'Features/Markets/Components/MarketsTable/Components/ApyWithRewardsCell', + component: ApyWithRewardsCell, + decorators: [WithTooltipProvider(), withRouter, WithClassname('w-56')], + args: { + apyDetails: { + apy: Percentage(0.157), + incentives: [], + airdrops: [], + }, + incentivizedReserve: tokens['ETH'], + reserveStatus: 'active', + }, +} + +export default meta +type Story = StoryObj + +export const WithoutIncentives: Story = { + name: 'WithoutIncentives', +} + +export const WithAirdrop: Story = { + name: 'WithAirdrop', + args: { + apyDetails: { + apy: Percentage(0.157), + incentives: [], + airdrops: [{ id: 'SPK', amount: NormalizedUnitNumber(24_000_000) }], + }, + }, +} + +export const WithRewards: Story = { + name: 'WithRewards', + args: { + apyDetails: { + apy: Percentage(0.157), + incentives: [{ token: tokens['stETH'], APR: Percentage(0.1) }], + airdrops: [], + }, + }, +} + +export const WithAirdropAndRewards: Story = { + name: 'WithAirdropAndRewards', + args: { + apyDetails: { + apy: Percentage(0.157), + airdrops: [{ id: 'SPK', amount: NormalizedUnitNumber(24_000_000) }], + incentives: [{ token: tokens['stETH'], APR: Percentage(0.1) }], + }, + }, +} diff --git a/packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.tsx b/packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.tsx new file mode 100644 index 000000000..b18f696bc --- /dev/null +++ b/packages/app/src/features/markets/components/markets-table/components/ApyWithRewardsCell.tsx @@ -0,0 +1,85 @@ +import { cva, VariantProps } from 'class-variance-authority' + +import { formatPercentage } from '@/domain/common/format' +import { ReserveStatus } from '@/domain/market-info/reserve-status' +import { Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { APYDetails } from '@/features/markets/types' +import { Typography } from '@/ui/atoms/typography/Typography' +import { MobileViewOptions } from '@/ui/molecules/data-table/types' +import { cn } from '@/ui/utils/style' + +import { AirdropBadge } from '../../airdrop-badge/AirdropBadge' +import { RewardBadge } from '../../reward-badge/RewardBadge' + +interface ApyWithRewardsCellProps extends VariantProps { + apyDetails: APYDetails + reserveStatus: ReserveStatus + incentivizedReserve: Token + mobileViewOptions?: MobileViewOptions +} + +export function ApyWithRewardsCell({ mobileViewOptions, ...rest }: ApyWithRewardsCellProps) { + if (mobileViewOptions?.isMobileView) { + return ( +
+ {mobileViewOptions.rowTitle} + +
+ ) + } + + return +} + +type CellContentProps = Omit + +function CellContent({ apyDetails, reserveStatus, incentivizedReserve, bold }: CellContentProps) { + if (reserveStatus !== 'active') { + return ( +
+ +
+ ) + } + + return ( +
+ {apyDetails.airdrops.map((airdrop, index) => ( + + ))} + {apyDetails.incentives.map((reward, index) => ( + + ))} + +
+ ) +} + +interface CellValueProps extends VariantProps { + value: Percentage + dimmed?: boolean + hideEmpty?: boolean +} + +function CellValue({ value, hideEmpty, bold, dimmed }: CellValueProps) { + return ( +
{hideEmpty && value.isZero() ? '—' : formatPercentage(value)}
+ ) +} + +const variants = cva('', { + variants: { + bold: { + true: 'font-bold', + }, + dimmed: { + true: 'text-basics-dark-grey/70', + }, + }, +}) diff --git a/packages/app/src/features/markets/components/markets-table/components/AssetNameCell.stories.tsx b/packages/app/src/features/markets/components/markets-table/components/AssetNameCell.stories.tsx new file mode 100644 index 000000000..17c4bb668 --- /dev/null +++ b/packages/app/src/features/markets/components/markets-table/components/AssetNameCell.stories.tsx @@ -0,0 +1,30 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' + +import { AssetNameCell } from './AssetNameCell' + +const meta: Meta = { + title: 'Features/Markets/Components/MarketsTable/Components/AssetNameCell', + component: AssetNameCell, + decorators: [WithTooltipProvider()], + args: { + token: tokens['rETH'], + reserveStatus: 'active', + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const Frozen: Story = { + name: 'Frozen', + args: { + token: tokens['rETH'], + reserveStatus: 'frozen', + }, +} diff --git a/packages/app/src/features/markets/components/markets-table/components/AssetNameCell.tsx b/packages/app/src/features/markets/components/markets-table/components/AssetNameCell.tsx new file mode 100644 index 000000000..c00a74503 --- /dev/null +++ b/packages/app/src/features/markets/components/markets-table/components/AssetNameCell.tsx @@ -0,0 +1,63 @@ +import { ReserveStatus } from '@/domain/market-info/reserve-status' +import { Token } from '@/domain/types/Token' +import { getTokenImage } from '@/ui/assets' +import { ColorFilter } from '@/ui/atoms/color-filter/ColorFilter' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' +import { Tooltip, TooltipContentShort, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { FrozenPill } from '@/ui/molecules/frozen-pill/FrozenPill' +import { PausedPill } from '@/ui/molecules/paused-pill/PausedPill' +import { cn } from '@/ui/utils/style' +import { useIsTruncated } from '@/ui/utils/useIsTruncated' + +interface AssetNameCellProps { + token: Token + reserveStatus: ReserveStatus + // @todo Use when implementing mobile view + // depositAPYDetails: APYDetails + // borrowAPYDetails: APYDetails + // mobileViewOptions?: MobileViewOptions +} + +export function AssetNameCell({ token, reserveStatus }: AssetNameCellProps) { + const tokenImage = getTokenImage(token.symbol) + const isPaused = reserveStatus === 'paused' + const isFrozen = reserveStatus === 'frozen' + + return ( +
+ {tokenImage && ( +
+ + + +
+ )} +
+ +

{token.symbol}

+
+ {isFrozen && } + {isPaused && } +
+ ) +} + +interface TokenNameProps { + token: Token + className?: string +} + +export function TokenName({ token, className }: TokenNameProps) { + const [tokenNameRef, isTruncated] = useIsTruncated() + + return ( + + +

+ {token.name} +

+
+ {token.name} +
+ ) +} diff --git a/packages/app/src/features/markets/components/reward-badge/RewardBadge.stories.tsx b/packages/app/src/features/markets/components/reward-badge/RewardBadge.stories.tsx new file mode 100644 index 000000000..ab8c05068 --- /dev/null +++ b/packages/app/src/features/markets/components/reward-badge/RewardBadge.stories.tsx @@ -0,0 +1,38 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getHoveredStory } from '@storybook/utils' + +import { Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { RewardBadge } from './RewardBadge' + +const meta: Meta = { + title: 'Features/Markets/Components/RewardBadge', + component: RewardBadge, + decorators: [WithTooltipProvider(), WithClassname('bg-white flex justify-center p-8 items-end w-96 h-56')], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + incentivizedReserve: tokens.DAI.symbol, + rewardApr: Percentage(0.011), + rewardToken: tokens.wstETH.symbol, + }, +} + +export const Hovered = getHoveredStory(Default, 'button') + +export const UnknownToken: Story = { + name: 'UnknownToken', + args: { + incentivizedReserve: tokens.DAI.symbol, + rewardApr: Percentage(0.011), + rewardToken: TokenSymbol('SOME'), + }, +} diff --git a/packages/app/src/features/markets/components/reward-badge/RewardBadge.tsx b/packages/app/src/features/markets/components/reward-badge/RewardBadge.tsx new file mode 100644 index 000000000..83b88402a --- /dev/null +++ b/packages/app/src/features/markets/components/reward-badge/RewardBadge.tsx @@ -0,0 +1,41 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { getTokenImage } from '@/ui/assets' +import { Tooltip, TooltipContentLong, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { TooltipContentLayout } from '@/ui/atoms/tooltip/TooltipContentLayout' + +import { TokenPill } from '../token-pill/TokenPill' + +interface RewardBadgeProps { + incentivizedReserve: TokenSymbol + rewardToken: TokenSymbol + rewardApr: Percentage +} + +export function RewardBadge({ incentivizedReserve, rewardToken, rewardApr }: RewardBadgeProps) { + const tokenImage = getTokenImage(rewardToken) + const formattedRewardApr = formatPercentage(rewardApr) + + return ( + + + + + + + + {tokenImage && } + + {rewardToken} - {formattedRewardApr} APR + + + + + Participating in the {incentivizedReserve} reserve gives annualized rewards. + + + + + ) +} diff --git a/packages/app/src/features/markets/components/skeleton/MarketsSkeleton.stories.ts b/packages/app/src/features/markets/components/skeleton/MarketsSkeleton.stories.ts new file mode 100644 index 000000000..605c225bf --- /dev/null +++ b/packages/app/src/features/markets/components/skeleton/MarketsSkeleton.stories.ts @@ -0,0 +1,17 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { MarketsSkeleton } from './MarketsSkeleton' + +const meta: Meta = { + title: 'Features/Markets/Components/Skeleton', + component: MarketsSkeleton, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} + +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/markets/components/skeleton/MarketsSkeleton.tsx b/packages/app/src/features/markets/components/skeleton/MarketsSkeleton.tsx new file mode 100644 index 000000000..caea20f0e --- /dev/null +++ b/packages/app/src/features/markets/components/skeleton/MarketsSkeleton.tsx @@ -0,0 +1,22 @@ +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' +import { PageLayout } from '@/ui/layouts/PageLayout' + +export function MarketsSkeleton() { + return ( + +
+ +
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ {Array.from({ length: 4 }).map((_, index) => ( + + ))} +
+
+ ) +} diff --git a/packages/app/src/features/markets/components/summary-tile/SummaryTile.stories.tsx b/packages/app/src/features/markets/components/summary-tile/SummaryTile.stories.tsx new file mode 100644 index 000000000..b1a0e5366 --- /dev/null +++ b/packages/app/src/features/markets/components/summary-tile/SummaryTile.stories.tsx @@ -0,0 +1,50 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { SummaryTile } from './SummaryTile' + +const meta: Meta = { + title: 'Features/Markets/Components/SummaryTile', + component: SummaryTile, + decorators: [WithTooltipProvider()], +} + +export default meta +type Story = StoryObj + +export const TotalMarketSize: Story = { + name: 'Total Market Size', + args: { + variant: 'total-market-size', + USDValue: NormalizedUnitNumber(12_300_000_000), + }, +} +export const TotalMarketSizeMobile = getMobileStory(TotalMarketSize) +export const TotalMarketSizeTablet = getTabletStory(TotalMarketSize) + +export const TotalValueLocked: Story = { + name: 'Total Value Locked', + args: { + variant: 'total-value-locked', + USDValue: NormalizedUnitNumber(8_300_000_000), + }, +} + +export const TotalAvailable: Story = { + name: 'Total Available', + args: { + variant: 'total-available', + USDValue: NormalizedUnitNumber(3_300_000_000), + }, +} + +export const TotalBorrows: Story = { + name: 'Total Borrows', + args: { + variant: 'total-borrows', + USDValue: NormalizedUnitNumber(6_300_000_000), + }, +} diff --git a/packages/app/src/features/markets/components/summary-tile/SummaryTile.tsx b/packages/app/src/features/markets/components/summary-tile/SummaryTile.tsx new file mode 100644 index 000000000..d2e47afc3 --- /dev/null +++ b/packages/app/src/features/markets/components/summary-tile/SummaryTile.tsx @@ -0,0 +1,32 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { assets } from '@/ui/assets' + +import { Tile, TileProps } from './components/Tile' + +interface SummaryTileProps { + variant: 'total-market-size' | 'total-value-locked' | 'total-available' | 'total-borrows' + USDValue: NormalizedUnitNumber +} + +export function SummaryTile({ variant, USDValue }: SummaryTileProps) { + return +} + +const tileProps: Record> = { + 'total-market-size': { + icon: assets.markets.chart, + title: 'Total market size', + }, + 'total-value-locked': { + icon: assets.markets.lock, + title: 'Total value locked', + }, + 'total-available': { + icon: assets.markets.inputOutput, + title: 'Total available', + }, + 'total-borrows': { + icon: assets.markets.output, + title: 'Total borrows', + }, +} diff --git a/packages/app/src/features/markets/components/summary-tile/components/Tile.stories.tsx b/packages/app/src/features/markets/components/summary-tile/components/Tile.stories.tsx new file mode 100644 index 000000000..f1d2e9f9f --- /dev/null +++ b/packages/app/src/features/markets/components/summary-tile/components/Tile.stories.tsx @@ -0,0 +1,30 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { assets } from '@/ui/assets' + +import { Tile } from './Tile' + +const meta: Meta = { + title: 'Features/Markets/Components/SummaryTile/Components/Tile', + component: Tile, + decorators: [WithTooltipProvider()], + args: { + icon: assets.markets.lock, + title: 'Total value locked', + USDValue: NormalizedUnitNumber(12_300_000_000), + description: 'Total value locked lengthy description', + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) diff --git a/packages/app/src/features/markets/components/summary-tile/components/Tile.tsx b/packages/app/src/features/markets/components/summary-tile/components/Tile.tsx new file mode 100644 index 000000000..8d6721706 --- /dev/null +++ b/packages/app/src/features/markets/components/summary-tile/components/Tile.tsx @@ -0,0 +1,42 @@ +import { HelpCircle } from 'lucide-react' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { Tooltip, TooltipContentShort, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { Typography } from '@/ui/atoms/typography/Typography' + +export interface TileProps { + icon: string + title: string + USDValue: NormalizedUnitNumber + description?: string +} + +export function Tile({ icon, title, USDValue, description }: TileProps) { + return ( +
+
+ {title} +
+
+
+ {title} + {description && ( + + + + + {description} + + )} +
+
+

$

+

+ {USD_MOCK_TOKEN.format(USDValue, { style: 'compact' })} +

+
+
+
+ ) +} diff --git a/packages/app/src/features/markets/components/summary-tiles/SummaryTiles.stories.tsx b/packages/app/src/features/markets/components/summary-tiles/SummaryTiles.stories.tsx new file mode 100644 index 000000000..ad16322c4 --- /dev/null +++ b/packages/app/src/features/markets/components/summary-tiles/SummaryTiles.stories.tsx @@ -0,0 +1,29 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { SummaryTiles } from './SummaryTiles' + +const meta: Meta = { + title: 'Features/Markets/Components/SummaryTiles', + component: SummaryTiles, + decorators: [WithTooltipProvider()], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + args: { + marketStats: { + totalMarketSizeUSD: NormalizedUnitNumber(2.2 * 10 ** 12), + totalValueLockedUSD: NormalizedUnitNumber(1.5 * 10 ** 12), + totalAvailableUSD: NormalizedUnitNumber(1.4 * 10 ** 12), + totalBorrowsUSD: NormalizedUnitNumber(828.48 * 10 ** 9), + }, + }, +} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/markets/components/summary-tiles/SummaryTiles.tsx b/packages/app/src/features/markets/components/summary-tiles/SummaryTiles.tsx new file mode 100644 index 000000000..901c374ba --- /dev/null +++ b/packages/app/src/features/markets/components/summary-tiles/SummaryTiles.tsx @@ -0,0 +1,19 @@ +import { MarketStats } from '../../logic/aggregate-stats' +import { SummaryTile } from '../summary-tile/SummaryTile' + +interface SummaryTilesProps { + marketStats: MarketStats +} + +export function SummaryTiles({ marketStats }: SummaryTilesProps) { + return ( +
+ + {marketStats.totalValueLockedUSD && ( + + )} + + +
+ ) +} diff --git a/packages/app/src/features/markets/components/token-pill/TokenPill.stories.tsx b/packages/app/src/features/markets/components/token-pill/TokenPill.stories.tsx new file mode 100644 index 000000000..7550c0437 --- /dev/null +++ b/packages/app/src/features/markets/components/token-pill/TokenPill.stories.tsx @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' + +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import { TokenPill } from './TokenPill' + +const meta: Meta = { + title: 'Features/Markets/Components/TokenPill', + component: TokenPill, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + tokenSymbol: tokens.wstETH.symbol, + }, +} +export const UnknownToken: Story = { + name: 'UnknownToken', + args: { + tokenSymbol: TokenSymbol('SOME'), + }, +} diff --git a/packages/app/src/features/markets/components/token-pill/TokenPill.tsx b/packages/app/src/features/markets/components/token-pill/TokenPill.tsx new file mode 100644 index 000000000..b77aac7db --- /dev/null +++ b/packages/app/src/features/markets/components/token-pill/TokenPill.tsx @@ -0,0 +1,13 @@ +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { getTokenImage } from '@/ui/assets' +import { IconPill } from '@/ui/atoms/icon-pill/IconPill' + +interface TokenPillProps { + tokenSymbol: TokenSymbol +} + +export function TokenPill({ tokenSymbol }: TokenPillProps) { + const tokenImage = getTokenImage(tokenSymbol) + + return +} diff --git a/packages/app/src/features/markets/logic/aggregate-stats.ts b/packages/app/src/features/markets/logic/aggregate-stats.ts new file mode 100644 index 000000000..d6ec21b06 --- /dev/null +++ b/packages/app/src/features/markets/logic/aggregate-stats.ts @@ -0,0 +1,41 @@ +import { MakerInfo } from '@/domain/maker-info/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { bigNumberify } from '@/utils/bigNumber' + +export interface MarketStats { + totalMarketSizeUSD: NormalizedUnitNumber + totalValueLockedUSD: NormalizedUnitNumber | undefined + totalAvailableUSD: NormalizedUnitNumber + totalBorrowsUSD: NormalizedUnitNumber +} + +export function aggregateStats(marketInfo: MarketInfo, makerInfo: MakerInfo | undefined): MarketStats { + const aggregatedValues = marketInfo.reserves.reduce( + (acc, reserve) => { + acc.totalDebtUSD = acc.totalDebtUSD.plus(reserve.totalDebtUSD) + acc.totalLiquidityUSD = acc.totalLiquidityUSD.plus(reserve.totalLiquidityUSD) + return acc + }, + { + totalLiquidityUSD: bigNumberify(0), + totalDebtUSD: bigNumberify(0), + }, + ) + const totalAvailableUSD = aggregatedValues.totalLiquidityUSD.minus(aggregatedValues.totalDebtUSD) + const daiReserve = marketInfo.findReserveBySymbol(TokenSymbol('DAI')) + const totalValueLockedUSD = + makerInfo && daiReserve + ? NormalizedUnitNumber( + totalAvailableUSD.minus(makerInfo.D3MCurrentDebtUSD.minus(daiReserve.totalVariableDebtUSD)), + ) + : undefined + + return { + totalMarketSizeUSD: NormalizedUnitNumber(aggregatedValues.totalLiquidityUSD), + totalValueLockedUSD, + totalAvailableUSD: NormalizedUnitNumber(totalAvailableUSD), + totalBorrowsUSD: NormalizedUnitNumber(aggregatedValues.totalDebtUSD), + } +} diff --git a/packages/app/src/features/markets/logic/transformers.ts b/packages/app/src/features/markets/logic/transformers.ts new file mode 100644 index 000000000..88c1e565c --- /dev/null +++ b/packages/app/src/features/markets/logic/transformers.ts @@ -0,0 +1,107 @@ +import { generatePath } from 'react-router-dom' + +import { getChainConfigEntry } from '@/config/chain' +import { getAirdropsData } from '@/config/chain/utils/airdrops' +import { paths } from '@/config/paths' +import { MarketInfo, Reserve } from '@/domain/market-info/marketInfo' +import { Percentage } from '@/domain/types/NumericValues' +import { RowClickOptions } from '@/ui/molecules/data-table/DataTable' +import { applyTransformers, Transformer, TransformerResult } from '@/utils/applyTransformers' + +import { MarketEntry } from '../types' + +export interface MarketEntryRowData extends MarketEntry { + rowClickOptions: RowClickOptions +} + +type MarketEntryTransformer = Transformer<[number, Reserve, Reserve[]], TransformerResult> + +function getTransformers(): MarketEntryTransformer[] { + return [skipInactiveReserves, mergeDaiMarkets, renameReserve, makeMarketEntry] +} + +export function transformReserves(marketInfo: MarketInfo): MarketEntry[] { + const transformers = getTransformers() + return marketInfo.reserves + .map((r) => { + return applyTransformers(marketInfo.chainId, r, marketInfo.reserves)(transformers) + }) + .filter((r): r is MarketEntryRowData => r !== null) +} + +function skipInactiveReserves(_: number, reserve: Reserve): undefined | null { + if (reserve.status === 'not-active') return null + + return undefined +} + +function renameReserve(chainId: number, reserve: Reserve): MarketEntryRowData | undefined { + const { tokenSymbolToReplacedName } = getChainConfigEntry(chainId) + if (Object.keys(tokenSymbolToReplacedName).includes(reserve.token.symbol)) { + return makeMarketEntry(chainId, { + ...reserve, + token: reserve.token.clone({ + symbol: tokenSymbolToReplacedName[reserve.token.symbol]!.symbol, + name: tokenSymbolToReplacedName[reserve.token.symbol]!.name, + }), + }) + } +} + +function mergeDaiMarkets( + chainId: number, + reserve: Reserve, + allReserves: Reserve[], +): MarketEntryRowData | undefined | null { + const sDAIMarket = allReserves.find((r) => r.token.symbol === 'sDAI') + const { tokenSymbolToReplacedName } = getChainConfigEntry(chainId) + // @note: this can happen on some domains when DAI rollout is not yet complete + if (!sDAIMarket) return + + if (reserve.token.symbol === 'DAI') { + return makeMarketEntry(chainId, { + ...reserve, + token: reserve.token.clone({ + symbol: tokenSymbolToReplacedName[reserve.token.symbol]!.symbol, + name: tokenSymbolToReplacedName[reserve.token.symbol]!.name, + }), + ...(import.meta.env.VITE_FEATURE_DISABLE_DAI_LEND === '1' && { + supplyAvailabilityStatus: 'no', + supplyAPY: Percentage(0), + }), + collateralEligibilityStatus: sDAIMarket.collateralEligibilityStatus, + }) + } + + if (reserve.token.symbol === 'sDAI') { + return null + } +} + +export function makeMarketEntry(chainId: number, reserve: Reserve): MarketEntryRowData { + const airdrops = getAirdropsData(chainId, reserve.token.symbol) + return { + token: reserve.token, + reserveStatus: reserve.status, + totalSupplied: reserve.totalLiquidity, + depositAPYDetails: { + apy: reserve.supplyAPY, + incentives: reserve.incentives.deposit, + airdrops: airdrops.deposit, + }, + totalBorrowed: reserve.totalDebt, + borrowAPYDetails: { + apy: reserve.variableBorrowApy, + incentives: reserve.incentives.borrow, + airdrops: airdrops.borrow, + }, + marketStatus: { + supplyAvailabilityStatus: reserve.supplyAvailabilityStatus, + collateralEligibilityStatus: reserve.collateralEligibilityStatus, + borrowEligibilityStatus: reserve.borrowEligibilityStatus, + }, + rowClickOptions: { + destination: generatePath(paths.marketDetails, { asset: reserve.token.address, chainId: chainId.toString() }), + }, + } +} diff --git a/packages/app/src/features/markets/logic/useMarkets.ts b/packages/app/src/features/markets/logic/useMarkets.ts new file mode 100644 index 000000000..331d41c9e --- /dev/null +++ b/packages/app/src/features/markets/logic/useMarkets.ts @@ -0,0 +1,33 @@ +import { getChainConfigEntry } from '@/config/chain' +import { useMakerInfo } from '@/domain/maker-info/useMakerInfo' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' + +import { MarketEntry } from '../types' +import { aggregateStats, MarketStats } from './aggregate-stats' +import { transformReserves } from './transformers' + +export interface UseMarketsResults { + marketStats: MarketStats + chainName: string + chainId: number + activeAndPausedMarketEntries: MarketEntry[] + frozenMarketEntries: MarketEntry[] +} + +export function useMarkets(): UseMarketsResults { + const { marketInfo } = useMarketInfo() + const { makerInfo } = useMakerInfo() + const { meta: chainMeta } = getChainConfigEntry(marketInfo.chainId) + + const marketEntries = transformReserves(marketInfo) + const activeAndPausedMarketEntries = marketEntries.filter((entry) => entry.reserveStatus !== 'frozen') + const frozenMarketEntries = marketEntries.filter((entry) => entry.reserveStatus === 'frozen') + + return { + marketStats: aggregateStats(marketInfo, makerInfo), + activeAndPausedMarketEntries, + frozenMarketEntries, + chainName: chainMeta.name, + chainId: marketInfo.chainId, + } +} diff --git a/packages/app/src/features/markets/types.ts b/packages/app/src/features/markets/types.ts new file mode 100644 index 000000000..f24537ee0 --- /dev/null +++ b/packages/app/src/features/markets/types.ts @@ -0,0 +1,32 @@ +import { AirdropEntry } from '@/config/chain/utils/airdrops' +import { Incentive } from '@/domain/market-info/incentives' +import { + BorrowEligibilityStatus, + CollateralEligibilityStatus, + ReserveStatus, + SupplyAvailabilityStatus, +} from '@/domain/market-info/reserve-status' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' + +export interface MarketStatus { + supplyAvailabilityStatus: SupplyAvailabilityStatus + collateralEligibilityStatus: CollateralEligibilityStatus + borrowEligibilityStatus: BorrowEligibilityStatus +} + +export interface MarketEntry { + token: Token + reserveStatus: ReserveStatus + totalSupplied: NormalizedUnitNumber + depositAPYDetails: APYDetails + totalBorrowed: NormalizedUnitNumber + borrowAPYDetails: APYDetails + marketStatus: MarketStatus +} + +export interface APYDetails { + apy: Percentage + incentives: Incentive[] + airdrops: AirdropEntry[] +} diff --git a/packages/app/src/features/markets/views/MarketsView.stories.ts b/packages/app/src/features/markets/views/MarketsView.stories.ts new file mode 100644 index 000000000..48c93a22a --- /dev/null +++ b/packages/app/src/features/markets/views/MarketsView.stories.ts @@ -0,0 +1,125 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { MarketsView } from './MarketsView' + +const meta: Meta = { + title: 'Features/Markets/Views/MarketsView', + component: MarketsView, + parameters: { + layout: 'fullscreen', + }, + decorators: [WithTooltipProvider(), withRouter], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + args: { + marketStats: { + totalMarketSizeUSD: NormalizedUnitNumber(2.2 * 10 ** 12), + totalValueLockedUSD: NormalizedUnitNumber(1.5 * 10 ** 12), + totalAvailableUSD: NormalizedUnitNumber(1.4 * 10 ** 12), + totalBorrowsUSD: NormalizedUnitNumber(828.48 * 10 ** 9), + }, + chainName: 'Ethereum Mainnet', + activeAndPausedMarketEntries: [ + { + token: tokens['ETH'], + reserveStatus: 'active', + borrowAPYDetails: { apy: Percentage(0.11), incentives: [], airdrops: [] }, + depositAPYDetails: { + apy: Percentage(0.157), + airdrops: [{ id: 'SPK', amount: NormalizedUnitNumber(6_000_000) }], + incentives: [{ token: tokens['stETH'], APR: Percentage(0.1) }], + }, + totalBorrowed: NormalizedUnitNumber(0), + totalSupplied: NormalizedUnitNumber(11.99), + marketStatus: { + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'yes', + borrowEligibilityStatus: 'yes', + }, + }, + { + token: tokens['DAI'], + reserveStatus: 'active', + borrowAPYDetails: { + apy: Percentage(0.11), + incentives: [], + airdrops: [{ id: 'SPK', amount: NormalizedUnitNumber(24_000_000) }], + }, + depositAPYDetails: { + apy: Percentage(0.157), + incentives: [], + airdrops: [], + }, + totalBorrowed: NormalizedUnitNumber(1257), + totalSupplied: NormalizedUnitNumber(0), + marketStatus: { + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'yes', + borrowEligibilityStatus: 'yes', + }, + }, + { + token: tokens['USDT'], + reserveStatus: 'paused', + borrowAPYDetails: { + apy: Percentage(0), + incentives: [], + airdrops: [], + }, + depositAPYDetails: { + apy: Percentage(0), + incentives: [], + airdrops: [], + }, + totalBorrowed: NormalizedUnitNumber(1257), + totalSupplied: NormalizedUnitNumber(0), + marketStatus: { + supplyAvailabilityStatus: 'no', + collateralEligibilityStatus: 'no', + borrowEligibilityStatus: 'no', + }, + }, + ], + frozenMarketEntries: [ + { + token: tokens['GNO'], + reserveStatus: 'frozen', + borrowAPYDetails: { apy: Percentage(0.11), incentives: [], airdrops: [] }, + depositAPYDetails: { apy: Percentage(0.157), incentives: [], airdrops: [] }, + totalBorrowed: NormalizedUnitNumber(0), + totalSupplied: NormalizedUnitNumber(11.99), + marketStatus: { + supplyAvailabilityStatus: 'no', + collateralEligibilityStatus: 'no', + borrowEligibilityStatus: 'no', + }, + }, + { + token: tokens['USDC'], + reserveStatus: 'frozen', + borrowAPYDetails: { apy: Percentage(0.11), incentives: [], airdrops: [] }, + depositAPYDetails: { apy: Percentage(0.157), incentives: [], airdrops: [] }, + totalBorrowed: NormalizedUnitNumber(1257), + totalSupplied: NormalizedUnitNumber(0), + marketStatus: { + supplyAvailabilityStatus: 'no', + collateralEligibilityStatus: 'no', + borrowEligibilityStatus: 'no', + }, + }, + ], + chainId: 1, + }, +} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/markets/views/MarketsView.tsx b/packages/app/src/features/markets/views/MarketsView.tsx new file mode 100644 index 000000000..055778aed --- /dev/null +++ b/packages/app/src/features/markets/views/MarketsView.tsx @@ -0,0 +1,54 @@ +import { useState } from 'react' + +import { getChainConfigEntry } from '@/config/chain' +import { Panel } from '@/ui/atoms/panel/Panel' +import { Typography } from '@/ui/atoms/typography/Typography' +import { PageLayout } from '@/ui/layouts/PageLayout' +import { LabeledSwitch } from '@/ui/molecules/labeled-switch/LabeledSwitch' + +import { MarketsTable } from '../components/markets-table/MarketsTable' +import { SummaryTiles } from '../components/summary-tiles/SummaryTiles' +import { MarketStats } from '../logic/aggregate-stats' +import { MarketEntry } from '../types' + +export interface MarketsViewProps { + marketStats: MarketStats + chainName: string + activeAndPausedMarketEntries: MarketEntry[] + frozenMarketEntries: MarketEntry[] + chainId: number +} +export function MarketsView({ + marketStats, + chainName, + activeAndPausedMarketEntries, + frozenMarketEntries, + chainId, +}: MarketsViewProps) { + const [showFrozenAssets, setShowFrozenAssets] = useState(false) + const chainImage = getChainConfigEntry(chainId).meta.logo + + return ( + +
+ Markets +
+ + {chainName} +
+
+ + + + {frozenMarketEntries.length > 0 && ( + <> + + Show Frozen Assets + + {showFrozenAssets && } + + )} + +
+ ) +} diff --git a/packages/app/src/features/navbar/Navbar.tsx b/packages/app/src/features/navbar/Navbar.tsx new file mode 100644 index 000000000..f7fe3ace3 --- /dev/null +++ b/packages/app/src/features/navbar/Navbar.tsx @@ -0,0 +1,74 @@ +import { Link } from 'react-router-dom' + +import { assets } from '@/ui/assets' +import { cn } from '@/ui/utils/style' + +import { useBlockedPages } from '../compliance/logic/useBlockedPages' +import { MobileMenuButton } from './components/MobileMenuButton' +import { NavbarActions } from './components/NavbarActions' +import { PageLinks } from './components/PageLinks' +import { useNavbar } from './logic/useNavbar' + +export interface NavbarProps { + mobileMenuCollapsed: boolean + setMobileMenuCollapsed: (collapsed: boolean) => void +} + +export function Navbar({ mobileMenuCollapsed, setMobileMenuCollapsed }: NavbarProps) { + const { + currentChain, + supportedChains, + onNetworkChange, + openConnectModal, + openDevSandboxDialog, + openSandboxDialog, + makerInfo, + connectedWalletInfo, + isSandboxEnabled, + isDevSandboxEnabled, + } = useNavbar() + + const blockedPages = useBlockedPages() + + function closeMobileMenu() { + setMobileMenuCollapsed(true) + } + + return ( + + ) +} diff --git a/packages/app/src/features/navbar/components/MobileMenuButton.tsx b/packages/app/src/features/navbar/components/MobileMenuButton.tsx new file mode 100644 index 000000000..7356f5e15 --- /dev/null +++ b/packages/app/src/features/navbar/components/MobileMenuButton.tsx @@ -0,0 +1,23 @@ +import { assets } from '@/ui/assets' +import { Button } from '@/ui/atoms/button/Button' + +export interface MobileMenuButtonProps { + mobileMenuCollapsed: boolean + setMobileMenuCollapsed: (collapsed: boolean) => void +} + +export function MobileMenuButton({ mobileMenuCollapsed, setMobileMenuCollapsed }: MobileMenuButtonProps) { + return ( + + ) +} diff --git a/packages/app/src/features/navbar/components/NavbarActionWrapper.tsx b/packages/app/src/features/navbar/components/NavbarActionWrapper.tsx new file mode 100644 index 000000000..7f1ab9698 --- /dev/null +++ b/packages/app/src/features/navbar/components/NavbarActionWrapper.tsx @@ -0,0 +1,13 @@ +export interface NavbarActionWrapperProps { + label: string + children: React.ReactNode +} + +export function NavbarActionWrapper({ label, children }: NavbarActionWrapperProps) { + return ( +
+
{label}
+ {children} +
+ ) +} diff --git a/packages/app/src/features/navbar/components/NavbarActions.tsx b/packages/app/src/features/navbar/components/NavbarActions.tsx new file mode 100644 index 000000000..8e4fd9189 --- /dev/null +++ b/packages/app/src/features/navbar/components/NavbarActions.tsx @@ -0,0 +1,55 @@ +import { cn } from '@/ui/utils/style' + +import { ConnectedWalletInfo, SupportedChain } from '../types' +import { NetworkSelector } from './network-selector/NetworkSelector' +import { SettingsDropdown } from './settings-dropdown/SettingsDropdown' +import { WalletDropdown } from './wallet-dropdown/WalletDropdown' + +export interface NavbarActionsProps { + mobileMenuCollapsed: boolean + currentChain: SupportedChain + supportedChains: SupportedChain[] + onNetworkChange: (chainId: number) => void + openConnectModal: () => void + connectedWalletInfo: ConnectedWalletInfo | undefined + openSandboxDialog: () => void + openDevSandboxDialog: () => void + isSandboxEnabled: boolean + isDevSandboxEnabled: boolean +} + +export function NavbarActions({ + mobileMenuCollapsed, + currentChain, + supportedChains, + onNetworkChange, + openConnectModal, + connectedWalletInfo, + openSandboxDialog, + openDevSandboxDialog, + isSandboxEnabled, + isDevSandboxEnabled, +}: NavbarActionsProps) { + return ( +
+ + + +
+ ) +} diff --git a/packages/app/src/features/navbar/components/PageLinks.tsx b/packages/app/src/features/navbar/components/PageLinks.tsx new file mode 100644 index 000000000..132280535 --- /dev/null +++ b/packages/app/src/features/navbar/components/PageLinks.tsx @@ -0,0 +1,51 @@ +import { Trans } from '@lingui/macro' + +import { paths } from '@/config/paths' +import { DSRBadge } from '@/features/savings/components/navbar-item/DSRBadge' +import { cn } from '@/ui/utils/style' + +import { MakerInfoQueryResults } from '../types' +import { NavLink } from './nav-link/NavLink' + +export interface PageLinksProps { + mobileMenuCollapsed: boolean + closeMobileMenu: () => void + makerInfo: MakerInfoQueryResults + blockedPages: (keyof typeof paths)[] +} + +export function PageLinks({ mobileMenuCollapsed, closeMobileMenu, makerInfo, blockedPages }: PageLinksProps) { + return ( +
+ + Borrow + + + Dashboard + + {!blockedPages.some((page) => page === 'savings') && ( // some instead of includes for better type inference + + ) : undefined + } + > + Cash & Savings + + )} + + Markets + +
+ ) +} diff --git a/packages/app/src/features/navbar/components/nav-link/NavLink.stories.tsx b/packages/app/src/features/navbar/components/nav-link/NavLink.stories.tsx new file mode 100644 index 000000000..a02a8fd8d --- /dev/null +++ b/packages/app/src/features/navbar/components/nav-link/NavLink.stories.tsx @@ -0,0 +1,55 @@ +import { WithClassname } from '@storybook/decorators' +import type { Meta, StoryObj } from '@storybook/react' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { Percentage } from '@/domain/types/NumericValues' +import { DSRBadge } from '@/features/savings/components/navbar-item/DSRBadge' + +import { NavLinkComponent } from './NavLink' + +const meta: Meta = { + title: 'Features/Navbar/Components/NavLink', + component: NavLinkComponent, + decorators: [withRouter, WithClassname('w-fit')], +} + +export default meta +type Story = StoryObj + +export const NavLinkComponentDefault: Story = { + name: 'NavLinkComponent', + args: { + selected: false, + to: '/', + children: 'Borrow', + }, +} + +export const NavItemComponentSelected: Story = { + name: 'NavLinkComponent (Selected)', + args: { + selected: true, + to: '/', + children: 'Borrow', + }, +} + +export const NavItemComponentSavings: Story = { + name: 'NavLinkComponent (Savings)', + args: { + selected: false, + to: '/', + children: 'Cash & Savings', + postfix: , + }, +} + +export const NavItemComponentSavingsLoading: Story = { + name: 'NavLinkComponent (Savings loading)', + args: { + selected: false, + to: '/', + children: 'Cash & Savings', + postfix: , + }, +} diff --git a/packages/app/src/features/navbar/components/nav-link/NavLink.tsx b/packages/app/src/features/navbar/components/nav-link/NavLink.tsx new file mode 100644 index 000000000..a807f2b8a --- /dev/null +++ b/packages/app/src/features/navbar/components/nav-link/NavLink.tsx @@ -0,0 +1,50 @@ +import React from 'react' +import { Link, useMatch } from 'react-router-dom' + +import { cn } from '@/ui/utils/style' + +export interface NavLinkProps { + to: string + children: React.ReactNode + postfix?: React.ReactNode + onClick?: () => void +} + +export function NavLink(props: NavLinkProps) { + const matched = !!useMatch(props.to) + + return +} + +export function NavLinkComponent({ children, selected, to, postfix, onClick }: NavLinkProps & { selected: boolean }) { + return ( +
+ +
+
+ {children} +
+ {postfix} +
+ + {/* if not selected still render a pseudo element to keep the height */} + + ) +} diff --git a/packages/app/src/features/navbar/components/network-selector/NetworkSelector.stories.ts b/packages/app/src/features/navbar/components/network-selector/NetworkSelector.stories.ts new file mode 100644 index 000000000..97c6662a7 --- /dev/null +++ b/packages/app/src/features/navbar/components/network-selector/NetworkSelector.stories.ts @@ -0,0 +1,39 @@ +import { Meta, StoryObj } from '@storybook/react' +import { userEvent, within } from '@storybook/testing-library' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { gnosis, mainnet } from 'viem/chains' + +import { NetworkSelector } from './NetworkSelector' + +const meta: Meta = { + title: 'Features/Navbar/Components/NetworkSelector', + component: NetworkSelector, + args: { + currentChain: { + id: mainnet.id, + name: 'Ethereum Mainnet', + }, + supportedChains: [ + { + id: mainnet.id, + name: 'Ethereum Mainnet', + }, + { + id: gnosis.id, + name: 'Gnosis Chain', + }, + ], + onNetworkChange: () => {}, + }, + play: async ({ canvasElement }) => { + const button = await within(canvasElement).findByRole('button') + await userEvent.click(button) + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/navbar/components/network-selector/NetworkSelector.tsx b/packages/app/src/features/navbar/components/network-selector/NetworkSelector.tsx new file mode 100644 index 000000000..4a7d740ee --- /dev/null +++ b/packages/app/src/features/navbar/components/network-selector/NetworkSelector.tsx @@ -0,0 +1,64 @@ +import { ChevronDown, ChevronUp } from 'lucide-react' +import React, { useState } from 'react' + +import { getChainConfigEntry } from '@/config/chain' +import { Button } from '@/ui/atoms/button/Button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/ui/atoms/dropdown/DropdownMenu' + +import { SupportedChain } from '../../types' +import { NavbarActionWrapper } from '../NavbarActionWrapper' + +export interface NetworkSelectorProps { + currentChain: SupportedChain + supportedChains: SupportedChain[] + onNetworkChange: (chainId: number) => void +} + +export function NetworkSelector({ currentChain, supportedChains, onNetworkChange }: NetworkSelectorProps) { + const [open, setOpen] = useState(false) + const chainImage = getChainConfigEntry(currentChain.id).meta.logo + + return ( + + + + + + + {supportedChains.map((chain) => ( + + onNetworkChange(chain.id)} + > +
+ + {chain.name} +
+
+ +
+ ))} +
+
+
+ ) +} + +function Chevron({ open }: { open: boolean }) { + if (open) { + return + } + return +} diff --git a/packages/app/src/features/navbar/components/settings-dropdown/BuildInfoItem.tsx b/packages/app/src/features/navbar/components/settings-dropdown/BuildInfoItem.tsx new file mode 100644 index 000000000..5dc871017 --- /dev/null +++ b/packages/app/src/features/navbar/components/settings-dropdown/BuildInfoItem.tsx @@ -0,0 +1,14 @@ +import { SettingsDropdownItem } from './SettingsDropdownItem' + +export function BuildInfoItem() { + const buildSha = import.meta.env.STORYBOOK_PREVIEW || __BUILD_SHA__ === undefined ? 'n/a' : __BUILD_SHA__ + const buildTime = import.meta.env.STORYBOOK_PREVIEW || __BUILD_TIME__ === undefined ? 'n/a' : __BUILD_TIME__ + + return ( + +
+ Built from {buildSha} at {buildTime} +
+
+ ) +} diff --git a/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropDown.stories.ts b/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropDown.stories.ts new file mode 100644 index 000000000..148df8f43 --- /dev/null +++ b/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropDown.stories.ts @@ -0,0 +1,34 @@ +import { Meta, StoryObj } from '@storybook/react' +import { userEvent, within } from '@storybook/testing-library' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { SettingsDropdown } from './SettingsDropdown' + +const meta: Meta = { + title: 'Features/Navbar/Components/SettingsDropdown', + component: SettingsDropdown, + args: { + onSandboxModeClick: () => {}, + isSandboxEnabled: true, + onDevSandBoxModeClick: () => {}, + isDevSandboxEnabled: false, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + play: async ({ canvasElement }) => { + const button = await within(canvasElement).findByRole('button') + await userEvent.click(button) + }, +} +export const Mobile: Story = { + ...getMobileStory(Desktop), + play: undefined, +} +export const Tablet: Story = { + ...getTabletStory(Desktop), + play: undefined, +} diff --git a/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdown.tsx b/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdown.tsx new file mode 100644 index 000000000..ec1555ac2 --- /dev/null +++ b/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdown.tsx @@ -0,0 +1,89 @@ +import MagicWand from '@/ui/assets/magic-wand.svg?react' +import MoreIcon from '@/ui/assets/more-icon.svg?react' +import { Button } from '@/ui/atoms/button/Button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/ui/atoms/dropdown/DropdownMenu' +import { Link } from '@/ui/atoms/link/Link' +import { useBreakpoint } from '@/ui/utils/useBreakpoint' + +import { NavbarActionWrapper } from '../NavbarActionWrapper' +import { BuildInfoItem } from './BuildInfoItem' +import { SettingsDropdownItem } from './SettingsDropdownItem' + +export interface SettingsDropdownProps { + onSandboxModeClick: () => void + isSandboxEnabled: boolean + onDevSandBoxModeClick: () => void + isDevSandboxEnabled: boolean +} + +export function SettingsDropdown({ + onSandboxModeClick, + isSandboxEnabled, + onDevSandBoxModeClick, + isDevSandboxEnabled, +}: SettingsDropdownProps) { + return ( + + + {isSandboxEnabled && ( + + + + Sandbox mode + + + Explore Spark
with unlimited tokens +
+
+ )} + + {isDevSandboxEnabled && ( + + + + Dev sandbox mode + + + Debug Spark
with unlimited tokens +
+
+ )} + + + + Terms of Service + + + +
+ + +
+
+
+ ) +} + +function DropdownWrapper({ children }: { children: React.ReactNode }) { + const isMobile = !useBreakpoint('lg') + + if (isMobile) { + return
{children}
+ } + + return ( + + + + + {children} + + ) +} diff --git a/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdownItem.tsx b/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdownItem.tsx new file mode 100644 index 000000000..8e7832d7e --- /dev/null +++ b/packages/app/src/features/navbar/components/settings-dropdown/SettingsDropdownItem.tsx @@ -0,0 +1,58 @@ +import { cva, VariantProps } from 'class-variance-authority' +import { ComponentProps } from 'react' + +import { DropdownMenuItem } from '@/ui/atoms/dropdown/DropdownMenu' +import { cn } from '@/ui/utils/style' +import { useBreakpoint } from '@/ui/utils/useBreakpoint' + +export interface SettingsDropdownItemProps extends ComponentProps { + children: React.ReactNode + variant?: VariantProps['variant'] +} + +function SettingsDropdownItem({ children, variant, ...rest }: SettingsDropdownItemProps) { + const isMobile = !useBreakpoint('lg') + + if (isMobile) { + return ( +
+
{children}
+
+ ) + } + + return ( + +
{children}
+
+ ) +} + +const settingsDropdownItemVariants = cva('group min-w-[240px] last:mb-0', { + variants: { + variant: { + default: 'cursor-pointer', + footnote: 'py-1', + }, + }, + defaultVariants: { + variant: 'default', + }, +}) + +function SettingsDropdownItemTitle({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} + +function SettingsDropdownItemContent({ children }: { children: React.ReactNode }) { + return
{children}
+} + +SettingsDropdownItem.Title = SettingsDropdownItemTitle +SettingsDropdownItem.Content = SettingsDropdownItemContent + +export { SettingsDropdownItem } diff --git a/packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.stories.ts b/packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.stories.ts new file mode 100644 index 000000000..cebb2a063 --- /dev/null +++ b/packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.stories.ts @@ -0,0 +1,110 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { userEvent, within } from '@storybook/testing-library' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { EnsName } from '@/domain/types/EnsName' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { assets } from '@/ui/assets' + +import { WalletDropdown, WalletDropdownProps } from './WalletDropdown' + +const meta: Meta = { + title: 'Features/Navbar/Components/WalletDropdown', + component: WalletDropdown, + play: async ({ canvasElement }) => { + const button = await within(canvasElement).findByRole('button') + await userEvent.click(button) + }, +} + +export default meta +type Story = StoryObj + +const args = { + connectedWalletInfo: { + dropdownTriggerInfo: { + mode: 'connected', + avatar: assets.token.mkr, + address: CheckedAddress('0x1234567890123456789012345678901234567890'), + }, + dropdownContentInfo: { + walletIcon: assets.token.eth, + address: CheckedAddress('0x1234567890123456789012345678901234567890'), + onDisconnect: () => {}, + balanceInfo: { + isLoading: false, + totalBalanceUSD: NormalizedUnitNumber(123_002), + }, + isEphemeralAccount: false, + isInSandbox: false, + blockExplorerAddressLink: '/', + }, + }, + onConnect: () => {}, +} satisfies WalletDropdownProps + +export const Connected: Story = { + args, +} + +export const ConnectedEnsName: Story = { + args: { + ...args, + connectedWalletInfo: { + ...args.connectedWalletInfo, + dropdownTriggerInfo: { + ...args.connectedWalletInfo.dropdownTriggerInfo, + ensName: EnsName('maker.ens'), + }, + }, + }, +} + +export const ConnectedEnsLongName: Story = { + args: { + ...args, + connectedWalletInfo: { + ...args.connectedWalletInfo, + dropdownTriggerInfo: { + ...args.connectedWalletInfo.dropdownTriggerInfo, + ensName: EnsName('makerdaowagmi.ens'), + }, + }, + }, +} + +export const Disconnected: Story = { + args: { + onConnect: () => {}, + }, +} + +export const Sandbox: Story = { + args: { + ...args, + connectedWalletInfo: { + dropdownContentInfo: { + ...args.connectedWalletInfo.dropdownContentInfo, + isInSandbox: true, + isEphemeralAccount: true, + }, + dropdownTriggerInfo: { + ...args.connectedWalletInfo.dropdownTriggerInfo, + mode: 'sandbox', + }, + }, + }, +} + +export const ReadOnly: Story = { + args: { + ...args, + connectedWalletInfo: { + ...args.connectedWalletInfo, + dropdownTriggerInfo: { + ...args.connectedWalletInfo.dropdownTriggerInfo, + mode: 'read-only', + }, + }, + }, +} diff --git a/packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.tsx b/packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.tsx new file mode 100644 index 000000000..b766b3d26 --- /dev/null +++ b/packages/app/src/features/navbar/components/wallet-dropdown/WalletDropdown.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react' + +import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@/ui/atoms/dropdown/DropdownMenu' + +import { WalletDropdownContentInfo, WalletDropdownTriggerInfo } from '../../types' +import { NavbarActionWrapper } from '../NavbarActionWrapper' +import { ConnectButton } from './components/ConnectButton' +import { ConnectedButton } from './components/ConnectedButton' +import { WalletDropdownContent } from './components/WalletDropdownContent' + +export interface WalletDropdownProps { + connectedWalletInfo?: { + dropdownTriggerInfo: WalletDropdownTriggerInfo + dropdownContentInfo: WalletDropdownContentInfo + } + onConnect: () => void +} + +export function WalletDropdown({ connectedWalletInfo, onConnect }: WalletDropdownProps) { + const [open, setOpen] = useState(false) + + if (!connectedWalletInfo) { + return + } + + return ( + + + + + + + { + setOpen(false) + connectedWalletInfo.dropdownContentInfo.onDisconnect() + }} + /> + + + + ) +} diff --git a/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectButton.tsx b/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectButton.tsx new file mode 100644 index 000000000..5425add16 --- /dev/null +++ b/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectButton.tsx @@ -0,0 +1,13 @@ +import { WalletButton } from './WalletButton' + +interface ConnectButtonProps { + onConnect: () => void +} + +export function ConnectButton({ onConnect }: ConnectButtonProps) { + return ( + + Connect wallet + + ) +} diff --git a/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.stories.tsx b/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.stories.tsx new file mode 100644 index 000000000..e113aacc8 --- /dev/null +++ b/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.stories.tsx @@ -0,0 +1,47 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { EnsName } from '@/domain/types/EnsName' +import { assets } from '@/ui/assets' + +import { ConnectedButton } from './ConnectedButton' + +const meta: Meta = { + title: 'Features/Navbar/Components/WalletDropdown/ConnectedButton', + component: ConnectedButton, + args: { + mode: 'connected', + avatar: assets.token.mkr, + open: true, + address: CheckedAddress('0x1234567890123456789012345678901234567890'), + }, +} + +export default meta +type Story = StoryObj + +export const Connected: Story = {} + +export const ConnectedEnsName: Story = { + args: { + ensName: EnsName('maker.ens'), + }, +} + +export const ConnectedEnsLongName: Story = { + args: { + ensName: EnsName('makerdaowagmi.ens'), + }, +} + +export const Sandbox: Story = { + args: { + mode: 'sandbox', + }, +} + +export const ReadOnly: Story = { + args: { + mode: 'read-only', + }, +} diff --git a/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.tsx b/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.tsx new file mode 100644 index 000000000..f4cedbe97 --- /dev/null +++ b/packages/app/src/features/navbar/components/wallet-dropdown/components/ConnectedButton.tsx @@ -0,0 +1,54 @@ +import { ChevronDown, ChevronUp } from 'lucide-react' +import { ButtonHTMLAttributes, forwardRef } from 'react' + +import { WalletDropdownTriggerInfo } from '@/features/navbar/types' +import Eye from '@/ui/assets/eye.svg?react' +import MagicWand from '@/ui/assets/magic-wand.svg?react' +import { shortenAddress } from '@/ui/utils/shortenAddress' + +import { WalletButton } from './WalletButton' + +export interface ConnectedButtonProps extends ButtonHTMLAttributes, WalletDropdownTriggerInfo { + open: boolean +} + +// forwarding ref so dropdown menu trigger works with asChild +export const ConnectedButton = forwardRef( + ({ mode, avatar, address, ensName, open, ...buttonProps }, ref) => { + if (mode === 'sandbox') { + return ( + + + Sandbox mode + + + ) + } + + if (mode === 'read-only') { + return ( + + + Read-only mode + + + ) + } + + return ( + + wallet-avatar +
{ensName ? ensName : shortenAddress(address)}
+ +
+ ) + }, +) +ConnectedButton.displayName = 'ConnectedButton' + +function Chevron({ open }: { open: boolean }) { + if (open) { + return + } + return +} diff --git a/packages/app/src/features/navbar/components/wallet-dropdown/components/WalletButton.tsx b/packages/app/src/features/navbar/components/wallet-dropdown/components/WalletButton.tsx new file mode 100644 index 000000000..d7315dac8 --- /dev/null +++ b/packages/app/src/features/navbar/components/wallet-dropdown/components/WalletButton.tsx @@ -0,0 +1,18 @@ +import { forwardRef } from 'react' + +import { Button, ButtonProps } from '@/ui/atoms/button/Button' +import { cn } from '@/ui/utils/style' + +export const WalletButton = forwardRef(({ className, ...rest }, ref) => { + return ( + +
+
+
Balance
+ {balanceInfo.isLoading ? ( + + ) : ( +
+ {USD_MOCK_TOKEN.formatUSD(balanceInfo.totalBalanceUSD)} +
+ )} +
+
+ {!isEphemeralAccount && blockExplorerAddressLink && ( +
+ + + View on Explorer + +
+ )} +
+ ) +} diff --git a/packages/app/src/features/navbar/logic/generateWalletAvatar.ts b/packages/app/src/features/navbar/logic/generateWalletAvatar.ts new file mode 100644 index 000000000..5501266f5 --- /dev/null +++ b/packages/app/src/features/navbar/logic/generateWalletAvatar.ts @@ -0,0 +1,33 @@ +import { Address, sha256 } from 'viem' + +const defaultAvatar = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADwAAAA8CAYAAAA6/NlyAAAAAXNSR0IArs4c6QAAAQpJREFUaEPtmTEOhDAMBMmr+QK8OlfcVSdFWmlMcDHUcWLveK0AY845j+C572DRi0vOMzt8WPBCKAlnHbRtlS29kFoPr3pQD29zZ3aQHtbDXwXGdWU3rayx+q+y4P6MWIYSZvr1j5Zwf0YsQwkz/fpHS5gySi/x6TnVb2nlhC04RbnpW5qEIZDDloYKOrSggHoYCqiHqYDtPUwLfDq+3MNPJ0z3t2CqYPd4CXcnRPOTMFWwe7yEuxOi+cWEq99zaeL/8ekV1IJXyku4uifhfrb0QkA9rId/Cji04JCpDndo0aGVEqlu/ZRcml88pdMNLThVarFOwlBAWxoK6K8WKqAehgrqYShgew9/AEyBEOCiheCTAAAAAElFTkSuQmCC' + +export function generateWalletAvatar(address: Address): string { + const hash = sha256(address.toLowerCase() as Address).slice(2) + const size = 6 + const scale = 10 + const canvas = document.createElement('canvas') + canvas.width = size * scale + canvas.height = size * scale + const ctx = canvas.getContext('2d') + + if (!ctx) { + return defaultAvatar + } + + function pseudoRandomColor(index: number): string { + const char = hash[index % hash.length]! + const x = parseInt(char, 16) + return x > 7 ? '#FFF' : '#88F' + } + + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + ctx.fillStyle = pseudoRandomColor(y * size + (x >= size / 2 ? x : size - x - 1)) + ctx.fillRect(x * scale, y * scale, scale, scale) + } + } + + return canvas.toDataURL() +} diff --git a/packages/app/src/features/navbar/logic/getWalletIcon.ts b/packages/app/src/features/navbar/logic/getWalletIcon.ts new file mode 100644 index 000000000..fca1d5b87 --- /dev/null +++ b/packages/app/src/features/navbar/logic/getWalletIcon.ts @@ -0,0 +1,11 @@ +import { Connector } from 'wagmi' + +import { assets } from '@/ui/assets' + +export function getWalletIcon(connector: Connector): string { + if (connector.name === 'WalletConnect') { + return assets.walletIcons.walletConnect + } + + return connector.icon ?? assets.walletIcons.default +} diff --git a/packages/app/src/features/navbar/logic/useDisconnect.ts b/packages/app/src/features/navbar/logic/useDisconnect.ts new file mode 100644 index 000000000..dd7c8e951 --- /dev/null +++ b/packages/app/src/features/navbar/logic/useDisconnect.ts @@ -0,0 +1,48 @@ +import { useMutation, UseMutationResult } from '@tanstack/react-query' +import { mainnet } from 'viem/chains' +import { useAccount, useDisconnect as useWagmiDisconnect } from 'wagmi' + +export interface useDisconnectArgs { + changeNetworkAsync: (chainId: number) => Promise + deleteSandbox: () => void + isInSandbox: boolean +} + +export type UseDisconnectResult = Omit, 'mutate' | 'mutateAsync'> & { + disconnect: () => void + disconnectAsync: () => Promise +} + +export function useDisconnect({ + changeNetworkAsync, + deleteSandbox, + isInSandbox, +}: useDisconnectArgs): UseDisconnectResult { + const { disconnectAsync } = useWagmiDisconnect() + const { connector } = useAccount() + + async function disconnect(): Promise { + if (!isInSandbox) { + await disconnectAsync() + return + } + + // change the network back to mainnet + await changeNetworkAsync(mainnet.id) + // the connector should be the sandbox connector + await disconnectAsync({ + connector, + }) + deleteSandbox() + } + + const mutation = useMutation({ + mutationFn: disconnect, + }) + + return { + ...mutation, + disconnect, + disconnectAsync, + } +} diff --git a/packages/app/src/features/navbar/logic/useNavbar.ts b/packages/app/src/features/navbar/logic/useNavbar.ts new file mode 100644 index 000000000..701d65180 --- /dev/null +++ b/packages/app/src/features/navbar/logic/useNavbar.ts @@ -0,0 +1,126 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useQuery } from '@tanstack/react-query' +import { useAccount, useChainId, useChains, useConfig, useEnsAvatar, useEnsName } from 'wagmi' + +import { getChainConfigEntry } from '@/config/chain' +import { useBlockExplorerAddressLink } from '@/domain/hooks/useBlockExplorerAddressLink' +import { getIsChainSupported } from '@/domain/maker-info/getIsChainSupported' +import { makerInfoQuery } from '@/domain/maker-info/makerInfoQuery' +import { useSandboxState } from '@/domain/sandbox/useSandboxState' +import { useOpenDialog } from '@/domain/state/dialogs' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { EnsName } from '@/domain/types/EnsName' +import { SandboxDialog } from '@/features/dialogs/sandbox/SandboxDialog' +import { raise } from '@/utils/raise' + +import { ConnectedWalletInfo, MakerInfoQueryResults, SupportedChain } from '../types' +import { generateWalletAvatar } from './generateWalletAvatar' +import { getWalletIcon } from './getWalletIcon' +import { useDisconnect } from './useDisconnect' +import { useNetworkChange } from './useNetworkChange' +import { useTotalBalance } from './useTotalBalance' + +export interface UseNavbarResults { + currentChain: SupportedChain + supportedChains: SupportedChain[] + onNetworkChange: (chainId: number) => void + openConnectModal: () => void + openSandboxDialog: () => void + openDevSandboxDialog: () => void + isSandboxEnabled: boolean + isDevSandboxEnabled: boolean + makerInfo: MakerInfoQueryResults + connectedWalletInfo: ConnectedWalletInfo | undefined +} + +export function useNavbar(): UseNavbarResults { + const currentChainId = useChainId() + const chains = useChains() + const { openConnectModal = () => {} } = useConnectModal() + const { data: ensName } = useEnsName() + const { data: ensAvatar } = useEnsAvatar({ + name: ensName ?? undefined, + }) + const { address, connector } = useAccount() + const blockExplorerAddressLink = useBlockExplorerAddressLink(address) + const openDialog = useOpenDialog() + + const wagmiConfig = useConfig() + const isChainSupported = getIsChainSupported(currentChainId) + const makerInfo = useQuery({ + ...makerInfoQuery({ + wagmiConfig, + chainId: currentChainId, + }), + enabled: isChainSupported, + }) + + const balanceInfo = useTotalBalance() + const { isInSandbox, isSandboxEnabled, isDevSandboxEnabled, isEphemeralAccount, deleteSandbox } = useSandboxState() + const { changeNetwork, changeNetworkAsync } = useNetworkChange() + const { disconnect } = useDisconnect({ + changeNetworkAsync, + deleteSandbox, + isInSandbox, + }) + + const supportedChains: SupportedChain[] = chains.map((chain) => { + const { meta } = getChainConfigEntry(chain.id) + return { + id: chain.id, + name: meta.name, + } + }) + const currentChain = supportedChains.find((chain) => chain.id === currentChainId) ?? { + id: currentChainId, + name: supportedChains[0]?.name ?? raise('No supported chains'), + } // this fallback object is needed when we add new chains + + const connectedWalletInfo: ConnectedWalletInfo | undefined = (() => { + if (!address || !connector) { + return undefined + } + + return { + dropdownTriggerInfo: { + mode: isInSandbox ? 'sandbox' : 'connected', + avatar: ensAvatar ?? generateWalletAvatar(address), + address: CheckedAddress(address), + ensName: ensName ? EnsName(ensName) : undefined, + }, + dropdownContentInfo: { + walletIcon: getWalletIcon(connector), + address: CheckedAddress(address), + onDisconnect: disconnect, + balanceInfo, + blockExplorerAddressLink, + isEphemeralAccount: isEphemeralAccount(address), + isInSandbox, + }, + } + })() + + function openSandboxDialog(): void { + openDialog(SandboxDialog, { mode: 'ephemeral' } as const) + } + + function openDevSandboxDialog(): void { + openDialog(SandboxDialog, { mode: 'persisting' } as const) + } + + return { + currentChain, + supportedChains, + onNetworkChange: changeNetwork, + openConnectModal, + makerInfo: { + isChainSupported, + ...makerInfo, + }, + connectedWalletInfo, + openSandboxDialog, + openDevSandboxDialog, + isSandboxEnabled, + isDevSandboxEnabled, + } +} diff --git a/packages/app/src/features/navbar/logic/useNetworkChange.ts b/packages/app/src/features/navbar/logic/useNetworkChange.ts new file mode 100644 index 000000000..ebe110dc7 --- /dev/null +++ b/packages/app/src/features/navbar/logic/useNetworkChange.ts @@ -0,0 +1,110 @@ +import { useMutation, UseMutationResult } from '@tanstack/react-query' +import { useChainId, useConnect, useConnections, useDisconnect, useSwitchAccount, useSwitchChain } from 'wagmi' + +import { createSandboxConnector } from '@/domain/sandbox/createSandboxConnector' +import { useStore } from '@/domain/state' + +export type UseNetworkChangeResult = Omit, 'mutate' | 'mutateAsync'> & { + changeNetwork: (chainId: number) => void + changeNetworkAsync: (chainId: number) => Promise +} + +export function useNetworkChange(): UseNetworkChangeResult { + const { switchChainAsync } = useSwitchChain() + const { switchAccountAsync } = useSwitchAccount() + const { disconnectAsync } = useDisconnect() + const { connectAsync } = useConnect() + + const currentChainId = useChainId() + const connections = useConnections() + const sandboxNetwork = useStore((state) => state.sandbox.network) + + async function changeNetwork(chainId: number): Promise { + const sandboxChainId = sandboxNetwork?.forkChainId + + // switching to sandbox + if (chainId === sandboxChainId) { + const sandboxConnection = connections.find((connection) => connection.chainId === sandboxChainId) + if (!sandboxConnection) { + if (!sandboxNetwork?.ephemeralAccountPrivateKey) { + throw new Error('Sandbox network is not set up') + } + + await connectAsync({ + chainId: sandboxChainId, + connector: createSandboxConnector({ + chainId: sandboxChainId, + chainName: sandboxNetwork.name, + forkUrl: sandboxNetwork.forkUrl, + privateKey: sandboxNetwork.ephemeralAccountPrivateKey, + }), + }) + return + } + + await switchAccountAsync({ + connector: sandboxConnection.connector, + }) + return + } + + // switching from sandbox + if (currentChainId === sandboxChainId) { + const sandboxConnection = connections.find((connection) => connection.chainId === sandboxChainId) + + const targetConnection = + connections.find((connection) => connection.chainId === chainId) ?? + connections.filter((connection) => connection.chainId !== sandboxChainId)[0] + + // sandbox is the only connection + if (!targetConnection) { + await disconnectAsync({ + connector: sandboxConnection?.connector, + }) + await switchChainAsync({ + chainId, + }) + return + } + + if (targetConnection.chainId !== chainId) { + try { + // try to switch to the target chain for the target connector + await switchChainAsync({ + chainId, + connector: targetConnection.connector, + }) + } catch { + // didn't manage to switch to the target chain, disconnect the sandbox wallet + await disconnectAsync({ + connector: sandboxConnection?.connector, + }) + await switchChainAsync({ + chainId, + }) + return + } + } + + await switchAccountAsync({ + connector: targetConnection.connector, + }) + return + } + + // switching when neither current nor target chain is sandbox + await switchChainAsync({ + chainId, + }) + } + + const mutation = useMutation({ + mutationFn: changeNetwork, + }) + + return { + changeNetwork: mutation.mutate, + changeNetworkAsync: mutation.mutateAsync, + ...mutation, + } +} diff --git a/packages/app/src/features/navbar/logic/useTotalBalance.ts b/packages/app/src/features/navbar/logic/useTotalBalance.ts new file mode 100644 index 000000000..db9e256b3 --- /dev/null +++ b/packages/app/src/features/navbar/logic/useTotalBalance.ts @@ -0,0 +1,54 @@ +import { useQuery } from '@tanstack/react-query' +import { useAccount, useChainId, useConfig } from 'wagmi' + +import { getNativeAssetInfo } from '@/config/chain/utils/getNativeAssetInfo' +import { aaveDataLayer } from '@/domain/market-info/aave-data-layer/query' +import { marketInfo as marketInfoSelect } from '@/domain/market-info/marketInfo' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { balances } from '@/domain/wallet/balances' + +import { BalanceInfo } from '../types' + +export function useTotalBalance(): BalanceInfo { + const wagmiConfig = useConfig() + const { address } = useAccount() + const chainId = useChainId() + const nativeAssetInfo = getNativeAssetInfo(chainId) + + const walletInfo = useQuery({ + ...balances({ + wagmiConfig, + account: address && CheckedAddress(address), + chainId, + }), + }) + + const marketInfo = useQuery({ + ...aaveDataLayer({ + wagmiConfig, + account: address && CheckedAddress(address), + chainId, + }), + select: (aaveData) => marketInfoSelect(aaveData, nativeAssetInfo, chainId), + }) + + const balancesUSD = (walletInfo.data ?? []).map(({ address, balanceBaseUnit }) => { + const token = marketInfo.data?.findOneReserveByUnderlyingAsset(address).token + if (!token) { + return NormalizedUnitNumber(0) + } + + return token.toUSD(token.fromBaseUnit(balanceBaseUnit)) + }) + + const totalBalanceUSD = balancesUSD.reduce( + (sum, balance) => NormalizedUnitNumber(sum.plus(balance)), + NormalizedUnitNumber(0), + ) + + return { + totalBalanceUSD, + isLoading: walletInfo.isLoading || marketInfo.isLoading, + } +} diff --git a/packages/app/src/features/navbar/types.ts b/packages/app/src/features/navbar/types.ts new file mode 100644 index 000000000..e473c45c0 --- /dev/null +++ b/packages/app/src/features/navbar/types.ts @@ -0,0 +1,42 @@ +import { MakerInfo } from '@/domain/maker-info/types' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { EnsName } from '@/domain/types/EnsName' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +export interface SupportedChain { + id: number + name: string +} + +export interface ConnectedWalletInfo { + dropdownTriggerInfo: WalletDropdownTriggerInfo + dropdownContentInfo: WalletDropdownContentInfo +} + +export interface WalletDropdownTriggerInfo { + mode: 'read-only' | 'sandbox' | 'connected' + avatar: string + address: CheckedAddress + ensName?: EnsName +} + +export interface WalletDropdownContentInfo { + walletIcon: string + address: CheckedAddress + onDisconnect: () => void + balanceInfo: BalanceInfo + isEphemeralAccount: boolean + isInSandbox: boolean + blockExplorerAddressLink: string | undefined +} + +export interface BalanceInfo { + totalBalanceUSD: NormalizedUnitNumber + isLoading: boolean +} + +export interface MakerInfoQueryResults { + data: MakerInfo | null | undefined + isLoading: boolean + isChainSupported: boolean +} diff --git a/packages/app/src/features/savings/SavingsContainer.tsx b/packages/app/src/features/savings/SavingsContainer.tsx new file mode 100644 index 000000000..21dce05f8 --- /dev/null +++ b/packages/app/src/features/savings/SavingsContainer.tsx @@ -0,0 +1,57 @@ +import { useChainModal, useConnectModal } from '@rainbow-me/rainbowkit' + +import { withSuspense } from '@/ui/utils/withSuspense' + +import { SavingsSkeleton } from './components/skeleton/SavingsSkeleton' +import { useSavings } from './logic/useSavings' +import { GuestView } from './views/GuestView' +import { SavingsView } from './views/SavingsView' +import { UnsupportedChainView } from './views/UnsupportedChainView' + +function SavingsContainer() { + const { guestMode, openDialog, savingsDetails } = useSavings() + const { openConnectModal = () => {} } = useConnectModal() + const { openChainModal = () => {} } = useChainModal() + + if (savingsDetails.state === 'unsupported') { + return ( + + ) + } + + const { + makerInfo, + DSR, + depositedUSD, + depositedUSDPrecision, + sDAIBalance, + currentProjections, + opportunityProjections, + assetsInWallet, + maxBalanceToken, + totalEligibleCashUSD, + } = savingsDetails + + if (guestMode) { + return + } + + return ( + + ) +} + +const SavingsContainerWithSuspense = withSuspense(SavingsContainer, SavingsSkeleton) +export { SavingsContainerWithSuspense as SavingsContainer } diff --git a/packages/app/src/features/savings/components/PageHeader.tsx b/packages/app/src/features/savings/components/PageHeader.tsx new file mode 100644 index 000000000..05809a9af --- /dev/null +++ b/packages/app/src/features/savings/components/PageHeader.tsx @@ -0,0 +1,3 @@ +export function PageHeader() { + return

Cash & Savings

+} diff --git a/packages/app/src/features/savings/components/PageLayout.tsx b/packages/app/src/features/savings/components/PageLayout.tsx new file mode 100644 index 000000000..d60181508 --- /dev/null +++ b/packages/app/src/features/savings/components/PageLayout.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react' + +interface PageLayoutProps { + children: ReactNode +} + +export function PageLayout({ children }: PageLayoutProps) { + return ( +
+ {children} +
+ ) +} diff --git a/packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.stories.ts b/packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.stories.ts new file mode 100644 index 000000000..384f7a0c8 --- /dev/null +++ b/packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.stories.ts @@ -0,0 +1,45 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { CashInWallet } from './CashInWallet' + +const meta: Meta = { + title: 'Features/Savings/Components/CashInWallet', + component: CashInWallet, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + name: 'Cash in wallet', + args: { + assets: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(22727), + }, + { + token: tokens['USDT'], + balance: NormalizedUnitNumber(22727), + }, + { + token: tokens['USDC'], + balance: NormalizedUnitNumber(0), + }, + ], + openDialog: () => {}, + }, +} + +export const Mobile: Story = { + ...getMobileStory(Desktop), + name: 'Cash in wallet (Mobile)', +} +export const Tablet: Story = { + ...getTabletStory(Desktop), + name: 'Cash in wallet (Tablet)', +} diff --git a/packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.tsx b/packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.tsx new file mode 100644 index 000000000..a3d2774dd --- /dev/null +++ b/packages/app/src/features/savings/components/cash-in-wallet/CashInWallet.tsx @@ -0,0 +1,76 @@ +import { useMemo } from 'react' + +import { TokenWithBalance } from '@/domain/common/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { SavingsDepositDialog } from '@/features/dialogs/savings/deposit/SavingsDepositDialog' +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' +import { DataTable, DataTableProps } from '@/ui/molecules/data-table/DataTable' + +export interface CashInWalletProps { + makerInfo: MakerInfo + assets: TokenWithBalance[] + openDialog: OpenDialogFunction +} + +export function CashInWallet({ makerInfo, assets, openDialog }: CashInWalletProps) { + const columnDef: DataTableProps['columnDef'] = useMemo( + () => ({ + token: { + header: 'Token', + renderCell: ({ token }) => ( +
+ + {token.symbol} +
+ ), + }, + balance: { + header: 'Balance', + headerAlign: 'right', + renderCell: ({ balance, token }) => ( +
+
+ {balance.eq(0) ? '-' : token.format(balance, { style: 'auto' })} +
+
+ ), + }, + actions: { + header: '', + renderCell: ({ token, balance }) => { + return ( +
+ +
+ ) + }, + }, + }), + [openDialog, makerInfo], + ) + + return ( + + + Cash in wallet + + + + + + ) +} diff --git a/packages/app/src/features/savings/components/navbar-item/DSRBadge.stories.ts b/packages/app/src/features/savings/components/navbar-item/DSRBadge.stories.ts new file mode 100644 index 000000000..029b5e7e4 --- /dev/null +++ b/packages/app/src/features/savings/components/navbar-item/DSRBadge.stories.ts @@ -0,0 +1,32 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { Percentage } from '@/domain/types/NumericValues' + +import { DSRBadge } from './DSRBadge' + +const meta: Meta = { + title: 'Features/Savings/Components/NavbarItem/DSRBadge', + component: DSRBadge, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + args: { + dsr: Percentage(0.05), + }, +} + +export const Loading: Story = { + args: { + isLoading: true, + }, +} + +export const TwoDecimals: Story = { + name: 'Two decimals', + args: { + dsr: Percentage(0.0497), + }, +} diff --git a/packages/app/src/features/savings/components/navbar-item/DSRBadge.tsx b/packages/app/src/features/savings/components/navbar-item/DSRBadge.tsx new file mode 100644 index 000000000..f4de15a41 --- /dev/null +++ b/packages/app/src/features/savings/components/navbar-item/DSRBadge.tsx @@ -0,0 +1,21 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' + +export interface DSRBadgeProps { + dsr?: Percentage + isLoading: boolean +} + +export function DSRBadge({ dsr, isLoading }: DSRBadgeProps) { + // @note: The colors are hardcoded because it looks like this is the only place where these specific colors are used. + return ( +
+ {isLoading ? ( + + ) : ( + dsr && formatPercentage(dsr, { minimumFractionDigits: 0 }) + )} +
+ ) +} diff --git a/packages/app/src/features/savings/components/savings-dai/SavingsDAI.stories.ts b/packages/app/src/features/savings/components/savings-dai/SavingsDAI.stories.ts new file mode 100644 index 000000000..84f54c844 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-dai/SavingsDAI.stories.ts @@ -0,0 +1,40 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import type { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { SavingsDAI } from './SavingsDAI' + +const meta: Meta = { + title: 'Features/Savings/Components/SavingsDAI', + component: SavingsDAI, + decorators: [WithTooltipProvider(), WithClassname('max-w-2xl')], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + name: 'Savings DAI', + args: { + depositedUSD: NormalizedUnitNumber(20765.7654), + depositedUSDPrecision: 4, + sDAIBalance: { balance: NormalizedUnitNumber(20000.0), token: tokens.sDAI }, + DSR: Percentage(0.05), + projections: { + thirtyDays: NormalizedUnitNumber(500), + oneYear: NormalizedUnitNumber(2500), + }, + }, +} + +export const Mobile: Story = { + ...getMobileStory(Desktop), + name: 'Savings DAI (Mobile)', +} +export const Tablet: Story = { + ...getTabletStory(Desktop), + name: 'Savings DAI (Tablet)', +} diff --git a/packages/app/src/features/savings/components/savings-dai/SavingsDAI.tsx b/packages/app/src/features/savings/components/savings-dai/SavingsDAI.tsx new file mode 100644 index 000000000..203fb8fbc --- /dev/null +++ b/packages/app/src/features/savings/components/savings-dai/SavingsDAI.tsx @@ -0,0 +1,106 @@ +import BigNumber from 'bignumber.js' + +import { formatPercentage } from '@/domain/common/format' +import { TokenWithBalance } from '@/domain/common/types' +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { SavingsWithdrawDialog } from '@/features/dialogs/savings/withdraw/SavingsWithdrawDialog' +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { Projections } from '../../types' +import { SavingsInfoTile } from '../savings-info-tile/SavingsInfoTile' +import { DSRLabel } from '../savings-opportunity/components/DSRLabel' + +export interface SavingsDAIProps { + depositedUSD: NormalizedUnitNumber + depositedUSDPrecision: number + sDAIBalance: TokenWithBalance + DSR: Percentage + projections: Projections + openDialog: OpenDialogFunction +} + +export function SavingsDAI({ + depositedUSD, + depositedUSDPrecision, + sDAIBalance, + DSR, + projections, + openDialog, +}: SavingsDAIProps) { + const compactProjections = projections.thirtyDays.gt(1_000) + + return ( + +
+
+

Savings DAI

+
+
+ +
+
+
+
+
{getWholePart(depositedUSD)}
+ {depositedUSDPrecision > 0 && ( +
+ {getFractionalPart(depositedUSD, depositedUSDPrecision)} +
+ )} +
+
+ ={sDAIBalance.token.format(sDAIBalance.balance, { style: 'auto' })} {sDAIBalance.token.symbol} +
+
+
+ + +
30-day projection
+
30-day
+
+ + + + {USD_MOCK_TOKEN.formatUSD(projections.thirtyDays, { + showCents: 'when-not-round', + compact: compactProjections, + })} + +
+ + +
1-year projection
+
1-year
+
+ + + + {USD_MOCK_TOKEN.formatUSD(projections.oneYear, { + showCents: 'when-not-round', + compact: compactProjections, + })} + +
+ + + + {formatPercentage(DSR, { minimumFractionDigits: 0 })} + + +
+
+ ) +} + +function getWholePart(value: BigNumber): string { + const formatter = new Intl.NumberFormat('en-US', { maximumFractionDigits: 0 }) + return formatter.format(value.integerValue(BigNumber.ROUND_DOWN).toNumber()) +} + +function getFractionalPart(value: BigNumber, precision: number): string { + precision = Math.max(precision, 2) + return value.minus(value.integerValue(BigNumber.ROUND_DOWN)).toFixed(precision).slice(1) +} diff --git a/packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.stories.tsx b/packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.stories.tsx new file mode 100644 index 000000000..28ceacec4 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.stories.tsx @@ -0,0 +1,163 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' + +import { SavingsInfoTile } from './SavingsInfoTile' + +const meta: Meta = { + title: 'Features/Savings/Components/SavingsInfoTile', + component: SavingsInfoTile, + decorators: [WithTooltipProvider()], +} + +export default meta +type Story = StoryObj + +export const BaseDark: Story = { + name: 'Base Dark', + render: () => { + return ( + + 30-day projection + +60$ + + ) + }, +} + +export const BaseGreen: Story = { + name: 'Base Green', + render: () => { + return ( + + 30-day projection + +$50 + + ) + }, +} + +export const MediumDark: Story = { + name: 'Medium Dark', + render: () => { + return ( + + 30-day projection + +$100 + + ) + }, +} + +export const MediumGreen: Story = { + name: 'Medium Green', + render: () => { + return ( + + 30-day projection + + +$100 + + + ) + }, +} + +export const LargeDark: Story = { + name: 'Large Dark', + render: () => { + return ( + + 30-day projection + +$100 + + ) + }, +} + +export const LargeGreen: Story = { + name: 'Large Green', + render: () => { + return ( + + 30-day projection + + +$100 + + + ) + }, +} + +export const HugeDark: Story = { + name: 'Huge Dark', + render: () => { + return ( + + 30-day projection + +$150 + + ) + }, +} + +export const HugeGreen: Story = { + name: 'Huge Green', + render: () => { + return ( + + 30-day projection + + +$150 + + + ) + }, +} + +export const NoTooltip: Story = { + name: 'No Tooltip', + render: () => { + return ( + + 30-day projection + +$50 + + ) + }, +} + +export const ValueOnTop: Story = { + name: 'Value On Top', + render: () => { + return ( + + 5% + DSR Rate + + ) + }, +} + +export const ItemsCenter: Story = { + name: 'Items Center', + render: () => { + return ( + + 30-day projection + +$50 + + ) + }, +} + +export const ItemsEnd: Story = { + name: 'Items End', + render: () => { + return ( + + 30-day projection + +$50 + + ) + }, +} diff --git a/packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.tsx b/packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.tsx new file mode 100644 index 000000000..e8d43abc5 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-info-tile/SavingsInfoTile.tsx @@ -0,0 +1,72 @@ +import { cva, VariantProps } from 'class-variance-authority' +import { ReactNode } from 'react' + +import { Info } from '@/ui/molecules/info/Info' +import { cn } from '@/ui/utils/style' + +interface SavingsInfoTileProps extends VariantProps { + children: ReactNode + className?: string +} +export function SavingsInfoTile({ children, alignItems, className }: SavingsInfoTileProps) { + return ( +
+ {children} +
+ ) +} + +interface LabelProps { + children: ReactNode + tooltipContent?: React.ReactNode +} +function Label({ children, tooltipContent: tooltipText }: LabelProps) { + return ( +
+

{children}

+ {tooltipText && {tooltipText}} +
+ ) +} + +export interface ValueProps extends VariantProps { + children: ReactNode +} +function Value({ children, size, color }: ValueProps) { + return

{children}

+} + +const savingsInfoTileVariants = cva('inline-flex flex-col gap-1', { + variants: { + alignItems: { + start: 'items-start', + center: 'items-center', + end: 'items-end', + }, + }, + defaultVariants: { + alignItems: 'start', + }, +}) + +const valueVariants = cva('text-basics-black font-semibold', { + variants: { + size: { + base: 'text-sm md:text-base', + medium: 'text-lg md:text-2xl', + large: 'text-2xl md:text-4xl', + huge: 'text-5xl leading-tight md:text-7xl', + }, + color: { + dark: 'text-basics-black', + green: 'text-sec-green', + }, + }, + defaultVariants: { + size: 'base', + color: 'dark', + }, +}) + +SavingsInfoTile.Label = Label +SavingsInfoTile.Value = Value diff --git a/packages/app/src/features/savings/components/savings-opportunity/SavingdOpportunityNoCash.stories.ts b/packages/app/src/features/savings/components/savings-opportunity/SavingdOpportunityNoCash.stories.ts new file mode 100644 index 000000000..e7be41156 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/SavingdOpportunityNoCash.stories.ts @@ -0,0 +1,23 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { Percentage } from '@/domain/types/NumericValues' + +import { SavingsOpportunityNoCash } from './SavingsOpportunityNoCash' + +const meta: Meta = { + title: 'Features/Savings/Components/SavingsOpportunityNoCash', + component: SavingsOpportunityNoCash, + decorators: [WithTooltipProvider(), WithClassname('max-w-5xl')], + args: { + DSR: Percentage(0.05), + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.stories.ts b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.stories.ts new file mode 100644 index 000000000..753aee667 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.stories.ts @@ -0,0 +1,105 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import type { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { SavingsOpportunity } from './SavingsOpportunity' + +const meta: Meta = { + title: 'Features/Savings/Components/SavingsOpportunity', + component: SavingsOpportunity, + decorators: [WithTooltipProvider(), WithClassname('max-w-lg')], +} + +export default meta +type Story = StoryObj + +export const Whale: Story = { + name: 'Savings DAI Whale', + args: { + DSR: Percentage(0.05), + projections: { + thirtyDays: NormalizedUnitNumber(500000), + oneYear: NormalizedUnitNumber(2500000), + }, + }, +} + +export const WhaleMobile: Story = { + ...getMobileStory(Whale), + name: 'Savings DAI Whale (Mobile)', +} +export const WhaleTablet: Story = { + ...getTabletStory(Whale), + name: 'Savings DAI Whale (Tablet)', +} + +export const Dust: Story = { + name: 'Savings DAI Dust', + args: { + DSR: Percentage(0.05), + projections: { + thirtyDays: NormalizedUnitNumber(1.1), + oneYear: NormalizedUnitNumber(5.5), + }, + }, +} + +export const DustMobile: Story = { + ...getMobileStory(Dust), + name: 'Savings DAI Dust (Mobile)', +} + +export const DustTablet: Story = { + ...getTabletStory(Dust), + name: 'Savings DAI Dust (Tablet)', +} + +export const Normik: Story = { + name: 'Savings DAI Normik', + args: { + DSR: Percentage(0.05), + projections: { + thirtyDays: NormalizedUnitNumber(50.5), + oneYear: NormalizedUnitNumber(2500), + }, + maxBalanceToken: { + token: tokens['DAI'], + balance: NormalizedUnitNumber(22727), + }, + openDialog: () => {}, + }, +} + +export const NormikMobile: Story = { + ...getMobileStory(Normik), + name: 'Savings DAI Normik (Mobile)', +} + +export const NormikTablet: Story = { + ...getTabletStory(Normik), + name: 'Savings DAI Normik (Tablet)', +} + +export const Degen: Story = { + name: 'Savings DAI Degen', + args: { + DSR: Percentage(0.05), + projections: { + thirtyDays: NormalizedUnitNumber(500.52), + oneYear: NormalizedUnitNumber(2500.55), + }, + }, +} + +export const DegenMobile: Story = { + ...getMobileStory(Degen), + name: 'Savings DAI Degen (Mobile)', +} + +export const DegenTablet: Story = { + ...getTabletStory(Degen), + name: 'Savings DAI Degen (Tablet)', +} diff --git a/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.tsx b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.tsx new file mode 100644 index 000000000..268ee69a2 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunity.tsx @@ -0,0 +1,100 @@ +import { formatPercentage } from '@/domain/common/format' +import { TokenWithBalance } from '@/domain/common/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { SavingsDepositDialog } from '@/features/dialogs/savings/deposit/SavingsDepositDialog' +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { Projections } from '../../types' +import { SavingsInfoTile, ValueProps } from '../savings-info-tile/SavingsInfoTile' +import { DSRLabel } from './components/DSRLabel' +import { Explainer } from './components/Explainer' + +export interface SavingsDAIProps { + makerInfo: MakerInfo + DSR: Percentage + projections: Projections + maxBalanceToken: TokenWithBalance + openDialog: OpenDialogFunction + totalEligibleCashUSD: NormalizedUnitNumber +} + +export function SavingsOpportunity({ + makerInfo, + DSR, + projections, + maxBalanceToken, + openDialog, + totalEligibleCashUSD, +}: SavingsDAIProps) { + const compactProjections = projections.thirtyDays.gt(1_000) + const savingsTileSizeVariant = getValueSizeVariant(projections.oneYear, compactProjections) + function openDepositDialog(): void { + openDialog(SavingsDepositDialog, { initialToken: maxBalanceToken.token, makerInfo }) + } + + return ( + +
+ +
+ +
+
+
+ + +
30-day projection
+
30-day
+
+ + +{formatProjectionValue(projections.thirtyDays, compactProjections)} + +
+ + +
1-year projection
+
1-year
+
+ + +{formatProjectionValue(projections.oneYear, compactProjections)} + +
+ + + + {formatPercentage(DSR, { minimumFractionDigits: 0 })} + + +
+
+ +
+
+ ) +} + +function formatProjectionValue(value: NormalizedUnitNumber, compact: boolean): string { + return USD_MOCK_TOKEN.formatUSD(value, { + showCents: 'when-not-round', + compact, + }) +} + +function getValueSizeVariant(value: NormalizedUnitNumber, compact: boolean): ValueProps['size'] { + const formattedValue = formatProjectionValue(value, compact) + if (formattedValue.length > 8) { + return 'medium' + } + return 'large' +} diff --git a/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.stories.ts b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.stories.ts new file mode 100644 index 000000000..49a5439c7 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.stories.ts @@ -0,0 +1,24 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { Percentage } from '@/domain/types/NumericValues' + +import { SavingsOpportunityGuestMode } from './SavingsOpportunityGuestMode' + +const meta: Meta = { + title: 'Features/Savings/Components/SavingsOpportunityGuestMode', + component: SavingsOpportunityGuestMode, + decorators: [WithTooltipProvider(), WithClassname('max-w-5xl')], + args: { + DSR: Percentage(0.05), + openConnectModal: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.tsx b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.tsx new file mode 100644 index 000000000..1b6392261 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityGuestMode.tsx @@ -0,0 +1,34 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { SavingsInfoTile } from '../savings-info-tile/SavingsInfoTile' +import { DSRLabel } from './components/DSRLabel' +import { Explainer } from './components/Explainer' + +interface SavingsOpportunityGuestModeProps { + DSR: Percentage + openConnectModal: () => void +} + +export function SavingsOpportunityGuestMode({ openConnectModal, DSR }: SavingsOpportunityGuestModeProps) { + return ( + +
+ + + {formatPercentage(DSR, { minimumFractionDigits: 0 })} + + + +
+ + +
+
+
+ ) +} diff --git a/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityNoCash.tsx b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityNoCash.tsx new file mode 100644 index 000000000..a7442f6c5 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/SavingsOpportunityNoCash.tsx @@ -0,0 +1,27 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { Panel } from '@/ui/atoms/panel/Panel' + +import { SavingsInfoTile } from '../savings-info-tile/SavingsInfoTile' +import { DSRLabel } from './components/DSRLabel' +import { Explainer } from './components/Explainer' + +export interface SavingsOpportunityNoCashProps { + DSR: Percentage +} + +export function SavingsOpportunityNoCash({ DSR }: SavingsOpportunityNoCashProps) { + return ( + +
+ + + {formatPercentage(DSR, { minimumFractionDigits: 0 })} + + + + +
+
+ ) +} diff --git a/packages/app/src/features/savings/components/savings-opportunity/components/DSRLabel.tsx b/packages/app/src/features/savings/components/savings-opportunity/components/DSRLabel.tsx new file mode 100644 index 000000000..106ec775d --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/components/DSRLabel.tsx @@ -0,0 +1,29 @@ +import { Link } from '@/ui/atoms/link/Link' +import { links } from '@/ui/constants/links' + +import { SavingsInfoTile } from '../../savings-info-tile/SavingsInfoTile' + +export function DSRLabel() { + return ( + +

This represents the current annual interest rate for DAI deposited into the DSR.

+

+ The DSR is set on-chain by MakerDAO and can be adjusted through MakerDAO's governance mechanisms. Keep in + mind that these protocol mechanisms are subject to change. +

+

+ For more information about DSR, you can visit{' '} + + docs + + . +

+ + } + > + DSR +
+ ) +} diff --git a/packages/app/src/features/savings/components/savings-opportunity/components/Explainer.tsx b/packages/app/src/features/savings/components/savings-opportunity/components/Explainer.tsx new file mode 100644 index 000000000..bf6afdc55 --- /dev/null +++ b/packages/app/src/features/savings/components/savings-opportunity/components/Explainer.tsx @@ -0,0 +1,26 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' + +interface ExplainerProps { + stablecoinValue?: NormalizedUnitNumber +} + +export function Explainer({ stablecoinValue }: ExplainerProps) { + return ( +
+
+

Savings opportunity

+
+

+ {stablecoinValue ? ( + <> + You have ~{USD_MOCK_TOKEN.formatUSD(stablecoinValue)} worth of + stablecoins in your wallet. Earn while you hold it! + + ) : ( + 'Deposit stablecoins into your wallet and start saving!' + )} +

+
+ ) +} diff --git a/packages/app/src/features/savings/components/skeleton/SavingsSkeleton.stories.ts b/packages/app/src/features/savings/components/skeleton/SavingsSkeleton.stories.ts new file mode 100644 index 000000000..1fab878fd --- /dev/null +++ b/packages/app/src/features/savings/components/skeleton/SavingsSkeleton.stories.ts @@ -0,0 +1,16 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { SavingsSkeleton } from './SavingsSkeleton' + +const meta: Meta = { + title: 'Features/Savings/Components/Skeleton', + component: SavingsSkeleton, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/features/savings/components/skeleton/SavingsSkeleton.tsx b/packages/app/src/features/savings/components/skeleton/SavingsSkeleton.tsx new file mode 100644 index 000000000..8616a8158 --- /dev/null +++ b/packages/app/src/features/savings/components/skeleton/SavingsSkeleton.tsx @@ -0,0 +1,16 @@ +import { Skeleton } from '@/ui/atoms/skeleton/Skeleton' + +import { PageLayout } from '../PageLayout' + +export function SavingsSkeleton() { + return ( + + +
+ + +
+ +
+ ) +} diff --git a/packages/app/src/features/savings/logic/makeSavingsOverview.ts b/packages/app/src/features/savings/logic/makeSavingsOverview.ts new file mode 100644 index 000000000..5d1458d26 --- /dev/null +++ b/packages/app/src/features/savings/logic/makeSavingsOverview.ts @@ -0,0 +1,114 @@ +import { TokenWithBalance } from '@/domain/common/types' +import { PotParams } from '@/domain/maker-info/types' +import { MarketInfo } from '@/domain/market-info/marketInfo' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { WalletInfo } from '@/domain/wallet/useWalletInfo' + +import { convertDaiToShares, convertSharesToDai } from './projections' + +export interface MakeSavingsOverviewParams { + marketInfo: MarketInfo + walletInfo: WalletInfo + eligibleCashUSD: NormalizedUnitNumber + potParams: PotParams + timestampInMs: number + stepInMs: number +} +export interface SavingsOverview { + shares: NormalizedUnitNumber + potentialShares: NormalizedUnitNumber + depositedUSD: NormalizedUnitNumber + depositedUSDPrecision: number + sDAIBalance: TokenWithBalance +} + +export function makeSavingsOverview({ + marketInfo, + walletInfo, + eligibleCashUSD, + timestampInMs, + stepInMs, + potParams, +}: MakeSavingsOverviewParams): SavingsOverview { + const timestamp = Math.floor(timestampInMs / 1000) + const sDAI = marketInfo.findOneTokenBySymbol(TokenSymbol('sDAI')) + const DAI = marketInfo.findOneTokenBySymbol(TokenSymbol('DAI')) + const shares = walletInfo.findWalletBalanceForToken(sDAI) + + const [depositedUSD, precision] = calculateSharesToDaiWithPrecision({ + shares, + potParams, + timestampInMs, + stepInMs, + }) + + const potentialShares = convertDaiToShares({ + dai: NormalizedUnitNumber(eligibleCashUSD.dividedBy(DAI.unitPriceUsd)), + timestamp, + potParams, + }) + + const sDAIBalance = { token: sDAI, balance: walletInfo.findWalletBalanceForToken(sDAI) } + + return { + shares, + potentialShares, + depositedUSD, + depositedUSDPrecision: precision, + sDAIBalance, + } +} + +interface CalculateSharesToDaiWithPrecisionParams { + shares: NormalizedUnitNumber + potParams: PotParams + timestampInMs: number + stepInMs: number +} +function calculateSharesToDaiWithPrecision({ + shares, + potParams, + timestampInMs, + stepInMs, +}: CalculateSharesToDaiWithPrecisionParams): [NormalizedUnitNumber, number] { + const current = interpolateSharesToDai({ shares, potParams, timestampInMs }) + const next = interpolateSharesToDai({ shares, potParams, timestampInMs: timestampInMs + stepInMs }) + + const precision = calculatePrecision({ current, next }) + + return [current, precision] +} + +interface InterpolateSharesToDaiParams { + shares: NormalizedUnitNumber + potParams: PotParams + timestampInMs: number +} + +function interpolateSharesToDai({ + shares, + potParams, + timestampInMs, +}: InterpolateSharesToDaiParams): NormalizedUnitNumber { + const timestamp = Math.floor(timestampInMs / 1000) + + const now = convertSharesToDai({ timestamp, shares, potParams }) + const inASecond = convertSharesToDai({ timestamp: timestamp + 1, shares, potParams }) + + const linearApproximation = NormalizedUnitNumber(now.plus(inASecond.minus(now).times((timestampInMs % 1000) / 1000))) + return linearApproximation +} + +interface CalculatePrecisionParams { + current: NormalizedUnitNumber + next: NormalizedUnitNumber +} +function calculatePrecision({ current, next }: CalculatePrecisionParams): number { + const diff = next.minus(current) + if (diff.lt(1e-12)) { + return 12 + } + + return Math.max(-Math.floor(Math.log10(diff.toNumber())), 0) +} diff --git a/packages/app/src/features/savings/logic/projections.test.ts b/packages/app/src/features/savings/logic/projections.test.ts new file mode 100644 index 000000000..a2c78e658 --- /dev/null +++ b/packages/app/src/features/savings/logic/projections.test.ts @@ -0,0 +1,90 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { bigNumberify } from '@/utils/bigNumber' + +import { convertDaiToShares, convertSharesToDai } from './projections' + +describe(convertSharesToDai.name, () => { + it('accounts for dsr', () => { + const timestamp = 1000 + const shares = NormalizedUnitNumber(100) + const fivePercentYield = convertSharesToDai({ + timestamp: timestamp + 24 * 60 * 60, + shares, + potParams: { + dsr: bigNumberify('1000000564701133626865910626'), // 5% / day + rho: bigNumberify(timestamp), + chi: bigNumberify('1000000000000000000000000000'), // 1 + }, + }) + expect(fivePercentYield.minus(NormalizedUnitNumber(105)).abs().lt(1e-18)).toEqual(true) + + const tenPercentYield = convertSharesToDai({ + timestamp: timestamp + 24 * 60 * 60, + shares, + potParams: { + dsr: bigNumberify('1000001103127689513476993127'), // 10% / day + rho: bigNumberify(timestamp), + chi: bigNumberify('1000000000000000000000000000'), // 1 + }, + }) + expect(tenPercentYield.minus(NormalizedUnitNumber(110)).abs().lt(1e-18)).toEqual(true) + }) + + it('accounts for chi', () => { + const timestamp = 1000 + const shares = NormalizedUnitNumber(100) + const fivePercentYield = convertSharesToDai({ + timestamp: timestamp + 24 * 60 * 60, + shares, + potParams: { + dsr: bigNumberify('1000000564701133626865910626'), // 5% / day + rho: bigNumberify(timestamp), + chi: bigNumberify('1050000000000000000000000000'), // 1.05 + }, + }) + expect(fivePercentYield.minus(NormalizedUnitNumber(110.25)).abs().lt(1e-18)).toEqual(true) + + const tenPercentYield = convertSharesToDai({ + timestamp: timestamp + 24 * 60 * 60, + shares, + potParams: { + dsr: bigNumberify('1000001103127689513476993127'), // 10% / day + rho: bigNumberify(timestamp), + chi: bigNumberify('1050000000000000000000000000'), // 1.05 + }, + }) + expect(tenPercentYield.minus(NormalizedUnitNumber(115.5)).abs().lt(1e-18)).toEqual(true) + }) +}) + +describe(convertDaiToShares.name, () => { + it('accounts for dsr', () => { + const timestamp = 1000 + const dai = NormalizedUnitNumber(105) + const result = convertDaiToShares({ + timestamp: timestamp + 24 * 60 * 60, + dai, + potParams: { + dsr: bigNumberify('1000000564701133626865910626'), // 5% / day + rho: bigNumberify(timestamp), + chi: bigNumberify('1000000000000000000000000000'), // 1 + }, + }) + expect(result.minus(NormalizedUnitNumber(100)).abs().lt(1e-18)).toEqual(true) + }) + + it('accounts for chi', () => { + const timestamp = 1000 + const dai = NormalizedUnitNumber(110.25) + const result = convertDaiToShares({ + timestamp: timestamp + 24 * 60 * 60, + dai, + potParams: { + dsr: bigNumberify('1000000564701133626865910626'), // 5% / day + rho: bigNumberify(timestamp), + chi: bigNumberify('1050000000000000000000000000'), // 1.05 + }, + }) + expect(result.minus(NormalizedUnitNumber(100)).abs().lt(1e-18)).toEqual(true) + }) +}) diff --git a/packages/app/src/features/savings/logic/projections.ts b/packages/app/src/features/savings/logic/projections.ts new file mode 100644 index 000000000..22c2c9b2d --- /dev/null +++ b/packages/app/src/features/savings/logic/projections.ts @@ -0,0 +1,50 @@ +import { PotParams } from '@/domain/maker-info/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { bigNumberify } from '@/utils/bigNumber' +import { fromRay, pow } from '@/utils/math' + +import { Projections } from '../types' + +const SECONDS_PER_DAY = 24 * 60 * 60 + +export interface CalculateProjectionsParams { + timestamp: number // in seconds + shares: NormalizedUnitNumber + potParams: PotParams +} +export function calculateProjections({ timestamp, shares, potParams }: CalculateProjectionsParams): Projections { + const base = convertSharesToDai({ timestamp, shares, potParams }) + const thirtyDays = NormalizedUnitNumber( + convertSharesToDai({ timestamp: timestamp + 30 * SECONDS_PER_DAY, shares, potParams }).minus(base), + ) + const oneYear = NormalizedUnitNumber( + convertSharesToDai({ timestamp: timestamp + 365 * SECONDS_PER_DAY, shares, potParams }).minus(base), + ) + + return { + thirtyDays, + oneYear, + } +} + +export interface ConvertSharesToDaiParams { + timestamp: number + shares: NormalizedUnitNumber + potParams: PotParams +} +export function convertSharesToDai({ timestamp, shares, potParams }: CalculateProjectionsParams): NormalizedUnitNumber { + const { dsr, rho, chi } = potParams + const updatedChi = fromRay(pow(fromRay(dsr), bigNumberify(timestamp).minus(rho)).multipliedBy(chi)) + return NormalizedUnitNumber(shares.multipliedBy(updatedChi)) +} + +export interface ConvertDaiToShareParams { + timestamp: number + dai: NormalizedUnitNumber + potParams: PotParams +} +export function convertDaiToShares({ timestamp, dai, potParams }: ConvertDaiToShareParams): NormalizedUnitNumber { + const { dsr, rho, chi } = potParams + const updatedChi = fromRay(pow(fromRay(dsr), bigNumberify(timestamp).minus(rho)).multipliedBy(chi)) + return NormalizedUnitNumber(dai.dividedBy(updatedChi)) +} diff --git a/packages/app/src/features/savings/logic/useSavings.ts b/packages/app/src/features/savings/logic/useSavings.ts new file mode 100644 index 000000000..7e15d77b7 --- /dev/null +++ b/packages/app/src/features/savings/logic/useSavings.ts @@ -0,0 +1,87 @@ +import { TokenWithBalance } from '@/domain/common/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { useMakerInfo } from '@/domain/maker-info/useMakerInfo' +import { useMarketInfo } from '@/domain/market-info/useMarketInfo' +import { makeAssetsInWalletList } from '@/domain/savings/makeAssetsInWalletList' +import { OpenDialogFunction, useOpenDialog } from '@/domain/state/dialogs' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { useWalletInfo } from '@/domain/wallet/useWalletInfo' +import { useTimestamp } from '@/utils/useTimestamp' + +import { Projections } from '../types' +import { makeSavingsOverview } from './makeSavingsOverview' +import { calculateProjections } from './projections' + +const stepInMs = 50 + +export interface UseSavingsResults { + guestMode: boolean + openDialog: OpenDialogFunction + savingsDetails: + | { + state: 'supported' + makerInfo: MakerInfo + DSR: Percentage + depositedUSD: NormalizedUnitNumber + depositedUSDPrecision: number + sDAIBalance: TokenWithBalance + currentProjections: Projections + opportunityProjections: Projections + assetsInWallet: TokenWithBalance[] + totalEligibleCashUSD: NormalizedUnitNumber + maxBalanceToken: TokenWithBalance + } + | { state: 'unsupported' } +} +export function useSavings(): UseSavingsResults { + const { makerInfo } = useMakerInfo() + const walletInfo = useWalletInfo() + const guestMode = !walletInfo.isConnected + const { marketInfo } = useMarketInfo() + const { timestamp, timestampInMs } = useTimestamp({ refreshIntervalInMs: stepInMs }) + + const openDialog = useOpenDialog() + + if (!makerInfo) { + return { guestMode, openDialog, savingsDetails: { state: 'unsupported' } } + } + + const { + assets: assetsInWallet, + totalUSD: totalEligibleCashUSD, + maxBalanceToken, + } = makeAssetsInWalletList({ walletInfo }) + const { shares, potentialShares, depositedUSD, depositedUSDPrecision, sDAIBalance } = makeSavingsOverview({ + marketInfo, + walletInfo, + potParams: makerInfo.potParameters, + eligibleCashUSD: totalEligibleCashUSD, + timestampInMs, + stepInMs, + }) + + const currentProjections = calculateProjections({ timestamp, shares, potParams: makerInfo.potParameters }) + const opportunityProjections = calculateProjections({ + timestamp, + shares: potentialShares, + potParams: makerInfo.potParameters, + }) + + return { + guestMode, + openDialog, + savingsDetails: { + state: 'supported', + makerInfo, + DSR: makerInfo.DSR, + depositedUSD, + depositedUSDPrecision, + sDAIBalance, + currentProjections, + opportunityProjections, + assetsInWallet, + totalEligibleCashUSD, + maxBalanceToken, + }, + } +} diff --git a/packages/app/src/features/savings/types.ts b/packages/app/src/features/savings/types.ts new file mode 100644 index 000000000..4df893099 --- /dev/null +++ b/packages/app/src/features/savings/types.ts @@ -0,0 +1,6 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +export interface Projections { + thirtyDays: NormalizedUnitNumber + oneYear: NormalizedUnitNumber +} diff --git a/packages/app/src/features/savings/views/GuestView.stories.ts b/packages/app/src/features/savings/views/GuestView.stories.ts new file mode 100644 index 000000000..6a545c504 --- /dev/null +++ b/packages/app/src/features/savings/views/GuestView.stories.ts @@ -0,0 +1,27 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { Percentage } from '@/domain/types/NumericValues' + +import { GuestView } from './GuestView' + +const meta: Meta = { + title: 'Features/Savings/Views/GuestView', + component: GuestView, + decorators: [WithTooltipProvider()], + parameters: { + layout: 'fullscreen', + }, + args: { + DSR: Percentage(0.05), + openConnectModal: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/features/savings/views/GuestView.tsx b/packages/app/src/features/savings/views/GuestView.tsx new file mode 100644 index 000000000..b06e2de9f --- /dev/null +++ b/packages/app/src/features/savings/views/GuestView.tsx @@ -0,0 +1,30 @@ +import { Percentage } from '@/domain/types/NumericValues' +import { assets } from '@/ui/assets' +import { WalletActionPanel } from '@/ui/organisms/wallet-action-panel/WalletActionPanel' + +import { PageHeader } from '../components/PageHeader' +import { PageLayout } from '../components/PageLayout' +import { SavingsOpportunityGuestMode } from '../components/savings-opportunity/SavingsOpportunityGuestMode' + +interface GuestViewProps { + DSR: Percentage + openConnectModal: () => void +} + +export function GuestView({ DSR, openConnectModal }: GuestViewProps) { + return ( + + + + + + ) +} + +const tokens = assets.token +const TOKEN_ICONS = [tokens.sdai, tokens.dai, tokens.usdc, tokens.usdt] diff --git a/packages/app/src/features/savings/views/SavingsView.stories.ts b/packages/app/src/features/savings/views/SavingsView.stories.ts new file mode 100644 index 000000000..4024d4d72 --- /dev/null +++ b/packages/app/src/features/savings/views/SavingsView.stories.ts @@ -0,0 +1,174 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { SavingsView } from './SavingsView' + +const meta: Meta = { + title: 'Features/Savings/Views/SavingsView', + component: SavingsView, + decorators: [WithTooltipProvider()], + parameters: { + layout: 'fullscreen', + }, + args: { + DSR: Percentage(0.05), + depositedUSD: NormalizedUnitNumber(20765.7654), + sDAIBalance: { balance: NormalizedUnitNumber(20000.0), token: tokens['sDAI'] }, + currentProjections: { + thirtyDays: NormalizedUnitNumber(500), + oneYear: NormalizedUnitNumber(2500), + }, + opportunityProjections: { + thirtyDays: NormalizedUnitNumber(50), + oneYear: NormalizedUnitNumber(1500), + }, + assetsInWallet: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(22727), + }, + { + token: tokens['USDT'], + balance: NormalizedUnitNumber(22727), + }, + { + token: tokens['USDC'], + balance: NormalizedUnitNumber(0), + }, + ], + maxBalanceToken: { + token: tokens['DAI'], + balance: NormalizedUnitNumber(22727), + }, + totalEligibleCashUSD: NormalizedUnitNumber(45454), + depositedUSDPrecision: 2, + openDialog: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) + +export const NoDeposit: Story = { + name: 'No deposit', + args: { + depositedUSD: NormalizedUnitNumber(0), + sDAIBalance: { balance: NormalizedUnitNumber(0), token: tokens['sDAI'] }, + currentProjections: { + thirtyDays: NormalizedUnitNumber(0), + oneYear: NormalizedUnitNumber(0), + }, + }, +} +export const NoDepositMobile = { + ...getMobileStory(NoDeposit), + name: 'No deposit (Mobile)', +} +export const NoDepositTablet = { + ...getTabletStory(NoDeposit), + name: 'No deposit (Tablet)', +} + +export const AllIn: Story = { + name: 'All in', + args: { + opportunityProjections: { + thirtyDays: NormalizedUnitNumber(0), + oneYear: NormalizedUnitNumber(0), + }, + }, +} +export const AllInMobile = { + ...getMobileStory(AllIn), + name: 'All in (Mobile)', +} +export const AllInTablet = { + ...getTabletStory(AllIn), + name: 'All in (Tablet)', +} + +export const NoDepositNoCash: Story = { + name: 'No deposit, no cash', + args: { + depositedUSD: NormalizedUnitNumber(0), + sDAIBalance: { balance: NormalizedUnitNumber(0), token: tokens['sDAI'] }, + currentProjections: { + thirtyDays: NormalizedUnitNumber(0), + oneYear: NormalizedUnitNumber(0), + }, + opportunityProjections: { + thirtyDays: NormalizedUnitNumber(0), + oneYear: NormalizedUnitNumber(0), + }, + assetsInWallet: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(0), + }, + { + token: tokens['USDT'], + balance: NormalizedUnitNumber(0), + }, + { + token: tokens['USDC'], + balance: NormalizedUnitNumber(0), + }, + ], + }, +} +export const NoDepositNoCashMobile = { + ...getMobileStory(NoDepositNoCash), + name: 'No deposit, no cash (Mobile)', +} +export const NoDepositNoCashTablet = { + ...getTabletStory(NoDepositNoCash), + name: 'No deposit, no cash (Tablet)', +} + +export const BigNumbersDesktop: Story = { + name: 'Big numbers', + args: { + depositedUSD: NormalizedUnitNumber(134395765.123482934245), + depositedUSDPrecision: 0, + sDAIBalance: { balance: NormalizedUnitNumber(134000000.0), token: tokens['sDAI'] }, + DSR: Percentage(0.05), + currentProjections: { + thirtyDays: NormalizedUnitNumber(1224300.923423423), + oneYear: NormalizedUnitNumber(6345543.32945601), + }, + opportunityProjections: { + thirtyDays: NormalizedUnitNumber(1224300.923423423), + oneYear: NormalizedUnitNumber(6345543.32945601), + }, + assetsInWallet: [ + { + token: tokens['DAI'], + balance: NormalizedUnitNumber(232134925.90911123), + }, + { + token: tokens['USDT'], + balance: NormalizedUnitNumber(601234014.134234), + }, + { + token: tokens['USDC'], + balance: NormalizedUnitNumber(12312.90345), + }, + ], + }, +} +export const BigNumbersMobile: Story = { + ...getMobileStory(BigNumbersDesktop), + name: 'Big numbers (Mobile)', +} +export const BigNumbersTablet: Story = { + ...getTabletStory(BigNumbersDesktop), + name: 'Big numbers (Tablet)', +} diff --git a/packages/app/src/features/savings/views/SavingsView.tsx b/packages/app/src/features/savings/views/SavingsView.tsx new file mode 100644 index 000000000..9e2a3c0bd --- /dev/null +++ b/packages/app/src/features/savings/views/SavingsView.tsx @@ -0,0 +1,82 @@ +import { TokenWithBalance } from '@/domain/common/types' +import { MakerInfo } from '@/domain/maker-info/types' +import { OpenDialogFunction } from '@/domain/state/dialogs' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' + +import { CashInWallet } from '../components/cash-in-wallet/CashInWallet' +import { PageHeader } from '../components/PageHeader' +import { PageLayout } from '../components/PageLayout' +import { SavingsDAI } from '../components/savings-dai/SavingsDAI' +import { SavingsOpportunity } from '../components/savings-opportunity/SavingsOpportunity' +import { SavingsOpportunityNoCash } from '../components/savings-opportunity/SavingsOpportunityNoCash' +import { Projections } from '../types' + +export interface SavingsViewProps { + makerInfo: MakerInfo + DSR: Percentage + depositedUSD: NormalizedUnitNumber + depositedUSDPrecision: number + sDAIBalance: TokenWithBalance + currentProjections: Projections + opportunityProjections: Projections + assetsInWallet: TokenWithBalance[] + maxBalanceToken: TokenWithBalance + totalEligibleCashUSD: NormalizedUnitNumber + openDialog: OpenDialogFunction +} + +export function SavingsView({ + makerInfo, + DSR, + depositedUSD, + depositedUSDPrecision, + sDAIBalance, + currentProjections, + opportunityProjections, + assetsInWallet, + totalEligibleCashUSD, + maxBalanceToken, + openDialog, +}: SavingsViewProps) { + const displaySavingsDai = depositedUSD.gt(0) + const displaySavingsOpportunity = opportunityProjections.thirtyDays.gt(0) && opportunityProjections.oneYear.gt(0) + const displaySavingsNoCash = !displaySavingsDai && !displaySavingsOpportunity + + return ( + + +
+ {displaySavingsDai && ( +
+ +
+ )} + {displaySavingsOpportunity && ( +
+ +
+ )} + {displaySavingsNoCash && ( +
+ +
+ )} +
+ +
+ ) +} diff --git a/packages/app/src/features/savings/views/UnsupportedChainView.stories.ts b/packages/app/src/features/savings/views/UnsupportedChainView.stories.ts new file mode 100644 index 000000000..a0af40c1a --- /dev/null +++ b/packages/app/src/features/savings/views/UnsupportedChainView.stories.ts @@ -0,0 +1,34 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { UnsupportedChainView } from './UnsupportedChainView' + +const meta: Meta = { + title: 'Features/Savings/Views/UnsupportedChainView', + component: UnsupportedChainView, + decorators: [WithTooltipProvider()], + parameters: { + layout: 'fullscreen', + }, + args: { + openChainModal: () => {}, + openConnectModal: () => {}, + guestMode: false, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) + +export const Disconnected: Story = { + args: { + guestMode: true, + }, +} +export const DisconnectedMobile = getMobileStory(Disconnected) +export const DisconnectedTablet = getTabletStory(Disconnected) diff --git a/packages/app/src/features/savings/views/UnsupportedChainView.tsx b/packages/app/src/features/savings/views/UnsupportedChainView.tsx new file mode 100644 index 000000000..6c2d58cbd --- /dev/null +++ b/packages/app/src/features/savings/views/UnsupportedChainView.tsx @@ -0,0 +1,28 @@ +import { assets } from '@/ui/assets' +import { WalletActionPanel } from '@/ui/organisms/wallet-action-panel/WalletActionPanel' + +import { PageHeader } from '../components/PageHeader' +import { PageLayout } from '../components/PageLayout' + +interface UnsupportedChainViewProps { + openChainModal: () => void + openConnectModal: () => void + guestMode: boolean +} + +export function UnsupportedChainView({ guestMode, openChainModal, openConnectModal }: UnsupportedChainViewProps) { + return ( + + + + + ) +} + +const tokens = assets.token +const TOKEN_ICONS = [tokens.sdai, tokens.dai, tokens.usdc, tokens.usdt] diff --git a/packages/app/src/fonts/InterVariable.woff2 b/packages/app/src/fonts/InterVariable.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..22a12b04e1abd959c00f9d8429ca78ddae3f7dde GIT binary patch literal 345588 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]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∈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 UMQ*lvlk>j@mv~dN-b!vFvP literal 0 HcmV?d00001 diff --git a/packages/app/src/global.d.ts b/packages/app/src/global.d.ts new file mode 100644 index 000000000..08eaaaffb --- /dev/null +++ b/packages/app/src/global.d.ts @@ -0,0 +1,3 @@ +/// +declare const __BUILD_SHA__: string | undefined +declare const __BUILD_TIME__: string | undefined diff --git a/packages/app/src/locales/en.po b/packages/app/src/locales/en.po new file mode 100644 index 000000000..0e249983b --- /dev/null +++ b/packages/app/src/locales/en.po @@ -0,0 +1,44 @@ +msgid "" +msgstr "" +"POT-Creation-Date: 2023-10-27 08:17+0200\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: @lingui/cli\n" +"Language: en\n" +"Project-Id-Version: \n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: \n" +"Last-Translator: \n" +"Language-Team: \n" +"Plural-Forms: \n" + +#: src/features/easy-borrow/components/form/EasyBorrowForm.tsx:82 +#: src/features/easy-borrow/EasyBorrow.tsx:53 +#: src/ui/molecules/navbar/index.tsx:77 +msgid "Borrow" +msgstr "" + +#: src/features/easy-borrow/deposit/DepositBorrow.tsx:132 +#~ msgid "Connect your wallet" +#~ msgstr "" + +#: src/ui/molecules/navbar/index.tsx:80 +msgid "Dashboard" +msgstr "" + +#: src/ui/molecules/navbar/index.tsx:86 +#~ msgid "Devpage" +#~ msgstr "" + +#: src/ui/molecule/navbar/index.tsx:67 +#~ msgid "Faucet" +#~ msgstr "" + +#: src/ui/organism/language-settings/LanguageSettings.tsx:28 +#~ msgid "Language" +#~ msgstr "" + +#: src/ui/molecules/navbar/index.tsx:83 +msgid "Markets" +msgstr "" diff --git a/packages/app/src/main.tsx b/packages/app/src/main.tsx new file mode 100644 index 000000000..bc2d96ca6 --- /dev/null +++ b/packages/app/src/main.tsx @@ -0,0 +1,13 @@ +import './css/main.css' +import './css/fonts.css' + +import React from 'react' +import ReactDOM from 'react-dom/client' + +import App from './App.tsx' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/packages/app/src/pages/Borrow.PageObject.ts b/packages/app/src/pages/Borrow.PageObject.ts new file mode 100644 index 000000000..ba020ea1b --- /dev/null +++ b/packages/app/src/pages/Borrow.PageObject.ts @@ -0,0 +1,112 @@ +import { expect } from '@playwright/test' + +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { expectAssets, TestTokenWithValue } from '@/test/e2e/assertions' +import { BasePageObject } from '@/test/e2e/BasePageObject' +import { ForkContext } from '@/test/e2e/setupFork' +import { calculateAssetsWorth } from '@/test/e2e/utils' +import { testIds } from '@/ui/utils/testIds' + +export class BorrowPageObject extends BasePageObject { + // #region actions + async fillDepositAssetAction(index: number, asset: string, amount: number): Promise { + const inputGroup = this.page + .getByTestId(testIds.easyBorrow.form.deposits) + .getByTestId(testIds.component.MultiAssetSelector.group) + .nth(index) + + const selector = inputGroup.getByTestId(testIds.component.AssetSelector) + await this.selectOptionByLabelAction(selector, asset) + + await inputGroup.getByRole('textbox').fill(amount.toString()) + } + + async fillBorrowAssetAction(amount: number): Promise { + const borrowForm = this.page.getByTestId(testIds.easyBorrow.form.borrow) + + await borrowForm.getByRole('textbox').fill(amount.toString()) + } + + async submitAction(): Promise { + await this.page.getByRole('button', { name: 'Borrow' }).click() + } + + async addNewDepositAssetAction(): Promise { + return this.page.getByRole('button', { name: 'Add more' }).click() + } + + async viewInDashboardAction(): Promise { + await this.page.getByRole('link', { name: 'View in dashboard' }).click() + } + + async depositAssetsActions(assetsToDeposit: Record, daiToBorrow: number): Promise { + const actionsContainer = new ActionsPageObject(this.locatePanelByHeader('Actions')) + await this.depositWithoutBorrowActions(assetsToDeposit, daiToBorrow, actionsContainer) + await actionsContainer.acceptAllActionsAction(1) // borrow action + } + + async depositWithoutBorrowActions( + assetsToDeposit: Record, + daiToBorrow?: number, + _actionsContainer?: ActionsPageObject, + ): Promise { + const actionsContainer = _actionsContainer ?? new ActionsPageObject(this.locatePanelByHeader('Actions')) + + let index = 0 + for (const [asset, amount] of Object.entries(assetsToDeposit)) { + if (index !== 0) { + await this.addNewDepositAssetAction() + } + await this.fillDepositAssetAction(index, asset, amount) + index++ + } + await this.fillBorrowAssetAction(daiToBorrow ?? 1) // defaulted value won't matter, if only depositing + await this.submitAction() + await actionsContainer.acceptAllActionsAction(2 * index) // omitting the borrow action + await actionsContainer.expectNextActionEnabled() + } + // #endregion actions + + // #region assertions + async expectLtv(ltv: string): Promise { + await expect(this.page.getByTestId(testIds.easyBorrow.form.ltv)).toHaveText(ltv) + } + + async expectHealthFactor(hf: string): Promise { + const locator = this.page.getByTestId(testIds.component.HealthFactorBadge.value) + await expect(locator).toHaveText(hf) + } + + async expectAssetInputInvalid(errorText: string): Promise { + const locator = this.page.getByTestId(testIds.component.AssetInput.error) + await expect(locator).toHaveText(errorText) + } + + async expectBorrowButtonActive(): Promise { + await expect(this.page.getByRole('button', { name: 'Borrow' })).toBeEnabled() + } + + async expectSuccessPage( + deposited: TestTokenWithValue[], + borrowed: TestTokenWithValue, + fork: ForkContext, + ): Promise { + await expect(this.page.getByText('Congrats! All done!')).toBeVisible() + + const transformed = [...deposited, borrowed].reduce( + (acc, { asset, amount: value }) => ({ ...acc, [asset]: value }), + {}, + ) + + const { assetsWorth } = await calculateAssetsWorth(fork.forkUrl, transformed) + + if (deposited.length > 0) { + const depositSummary = await this.page.getByTestId(testIds.easyBorrow.success.deposited).textContent() + expectAssets(depositSummary!, deposited, assetsWorth) + } + + const borrowSummary = await this.page.getByTestId(testIds.easyBorrow.success.borrowed).textContent() + expectAssets(borrowSummary!, [borrowed], assetsWorth) + } + // #endregion +} diff --git a/packages/app/src/pages/Borrow.test-e2e.ts b/packages/app/src/pages/Borrow.test-e2e.ts new file mode 100644 index 000000000..d5280345b --- /dev/null +++ b/packages/app/src/pages/Borrow.test-e2e.ts @@ -0,0 +1,488 @@ +import { Page, test } from '@playwright/test' + +import { borrowValidationIssueToMessage } from '@/domain/market-validators/validateBorrow' +import { ActionsPageObject } from '@/features/actions/ActionsContainer.PageObject' +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' +import { screenshot } from '@/test/e2e/utils' + +import { BorrowPageObject } from './Borrow.PageObject' +import { DashboardPageObject } from './Dashboard.PageObject' + +test.describe('Borrow page', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + + test.describe('deposit ETH, borrow DAI', () => { + let borrowPage: BorrowPageObject + let actionsContainer: ActionsPageObject + const deposit = { + asset: 'ETH', + amount: 1, + } + const borrow = { + asset: 'DAI', + amount: 1000, + } + const expectedLtv = '44.07%' + const expectedHealthFactor = '1.87' + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { + ETH: 10, + }, + }, + }) + + borrowPage = new BorrowPageObject(page) + actionsContainer = new ActionsPageObject(page) + }) + + test('calculates LTV correctly', async () => { + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await borrowPage.expectLtv(expectedLtv) + await borrowPage.expectHealthFactor(expectedHealthFactor) + }) + + test('builds action plan', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.expectActions([ + { + type: 'deposit', + asset: 'ETH', + amount: deposit.amount, + }, + { + type: 'borrow', + asset: 'DAI', + amount: borrow.amount, + }, + ]) + await screenshot(page, 'deposit-eth-actions-plan') + }) + + test('successfully builds position', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.acceptAllActionsAction(2) + + await borrowPage.expectSuccessPage([deposit], borrow, fork) + await screenshot(page, 'deposit-eth-success') + }) + + test('HF matches after position is created', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + await actionsContainer.acceptAllActionsAction(2) + + await expectHFOnDashboard(page, borrowPage, expectedHealthFactor) + }) + }) + + test.describe('deposit wstETH and rETH, borrow DAI', () => { + let borrowPage: BorrowPageObject + let actionsContainer: ActionsPageObject + const wstETHdeposit = { + asset: 'wstETH', + amount: 1, + } + const rETHdeposit = { + asset: 'rETH', + amount: 1, + } + const borrow = { + asset: 'DAI', + amount: 1000, + } + const expectedLTV = '19.57%' + const expectedHealthFactor = '4.06' + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { + wstETH: 10, + rETH: 10, + }, + }, + }) + + borrowPage = new BorrowPageObject(page) + actionsContainer = new ActionsPageObject(page) + }) + + test('calculates LTV correctly', async () => { + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillDepositAssetAction(0, wstETHdeposit.asset, wstETHdeposit.amount) + await borrowPage.fillDepositAssetAction(1, rETHdeposit.asset, rETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await borrowPage.expectLtv(expectedLTV) + await borrowPage.expectHealthFactor(expectedHealthFactor) + }) + + test('uses permits in action plan for assets with permit support', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, wstETHdeposit.asset, wstETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.expectActions([ + { + type: 'permit', + ...wstETHdeposit, + }, + { + type: 'deposit', + ...wstETHdeposit, + }, + { + type: 'borrow', + asset: 'DAI', + amount: borrow.amount, + }, + ]) + await screenshot(page, 'deposit-wsteth-permit-actions-plan') + }) + + test('uses approve in action plan for assets with no permit support', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, rETHdeposit.asset, rETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.expectActions([ + { + type: 'approve', + ...rETHdeposit, + }, + { + type: 'deposit', + ...rETHdeposit, + }, + { + type: 'borrow', + asset: 'DAI', + amount: borrow.amount, + }, + ]) + await screenshot(page, 'deposit-reth-approve-actions-plan') + }) + + test('can switch to approves in action plan', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, wstETHdeposit.asset, wstETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.switchPreferPermitsAction() + await actionsContainer.expectActions([ + { + type: 'approve', + ...wstETHdeposit, + }, + { + type: 'deposit', + ...wstETHdeposit, + }, + { + type: 'borrow', + asset: 'DAI', + amount: borrow.amount, + }, + ]) + await screenshot(page, 'deposit-wsteth-approve-actions-plan') + }) + + test('builds action plan for 2 assets', async ({ page }) => { + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillDepositAssetAction(0, wstETHdeposit.asset, wstETHdeposit.amount) + await borrowPage.fillDepositAssetAction(1, rETHdeposit.asset, rETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.expectActions([ + { + type: 'permit', + ...wstETHdeposit, + }, + { + type: 'deposit', + ...wstETHdeposit, + }, + { + type: 'approve', + ...rETHdeposit, + }, + { + type: 'deposit', + ...rETHdeposit, + }, + { + type: 'borrow', + asset: 'DAI', + amount: borrow.amount, + }, + ]) + await screenshot(page, 'deposit-wsteth-reth-actions-plan') + }) + + test('successfully builds position', async ({ page }) => { + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillDepositAssetAction(0, wstETHdeposit.asset, wstETHdeposit.amount) + await borrowPage.fillDepositAssetAction(1, rETHdeposit.asset, rETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.acceptAllActionsAction(5) + + await borrowPage.expectSuccessPage([wstETHdeposit, rETHdeposit], borrow, fork) + await screenshot(page, 'deposit-wsteth-reth-success') + }) + + test('successfully builds position using only approves', async ({ page }) => { + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillDepositAssetAction(0, wstETHdeposit.asset, wstETHdeposit.amount) + await borrowPage.fillDepositAssetAction(1, rETHdeposit.asset, rETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.switchPreferPermitsAction() + await actionsContainer.acceptAllActionsAction(5) + + await borrowPage.expectSuccessPage([wstETHdeposit, rETHdeposit], borrow, fork) + await screenshot(page, 'deposit-wsteth-reth-success') + }) + + test('HF matches after position is created', async ({ page }) => { + await borrowPage.addNewDepositAssetAction() + await borrowPage.fillDepositAssetAction(0, wstETHdeposit.asset, wstETHdeposit.amount) + await borrowPage.fillDepositAssetAction(1, rETHdeposit.asset, rETHdeposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + await actionsContainer.acceptAllActionsAction(5) + + await expectHFOnDashboard(page, borrowPage, expectedHealthFactor) + }) + }) + + test.describe('no new deposit, existing position, borrow DAI', () => { + let borrowPage: BorrowPageObject + let actionsContainer: ActionsPageObject + + const borrow = { + asset: 'DAI', + amount: 1000, + } + const expectedLTV = '8.04%' + const expectedHealthFactor = '9.89' + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { + ETH: 10, + rETH: 10, + }, + }, + }) + + borrowPage = new BorrowPageObject(page) + actionsContainer = new ActionsPageObject(page) + await borrowPage.depositAssetsActions({ rETH: 5 }, 1000) + await page.reload() + }) + + test('calculates LTV correctly', async () => { + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await borrowPage.expectLtv(expectedLTV) + await borrowPage.expectHealthFactor(expectedHealthFactor) + }) + + test('builds action plan', async ({ page }) => { + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.expectActions([ + { + type: 'borrow', + asset: 'DAI', + amount: borrow.amount, + }, + ]) + await screenshot(page, 'borrow-with-no-deposit-actions-plan') + }) + + test('successfully borrows', async ({ page }) => { + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + + await actionsContainer.acceptAllActionsAction(1) + + await borrowPage.expectSuccessPage([], borrow, fork) + await screenshot(page, 'borrow-with-no-deposit-success') + }) + + test('HF matches after position is created', async ({ page }) => { + await borrowPage.fillBorrowAssetAction(borrow.amount) + + await borrowPage.submitAction() + await actionsContainer.acceptAllActionsAction(1) + + await expectHFOnDashboard(page, borrowPage, expectedHealthFactor) + }) + }) + + test.describe('no wallet connected', () => { + let borrowPage: BorrowPageObject + const deposit = { + asset: 'rETH', + amount: 1, + } + const borrow = { + asset: 'DAI', + amount: 1000, + } + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'not-connected', + }, + }) + + borrowPage = new BorrowPageObject(page) + }) + + test('shows borrow rate correctly', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + await borrowPage.expectLtv('40.19%') + await screenshot(page, 'borrow-form-not-connected-correct-ltv') + }) + + test('form is interactive', async ({ page }) => { + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + await screenshot(page, 'borrow-form-not-connected-interactive') + }) + }) + + test.describe('form validation', () => { + let borrowPage: BorrowPageObject + + test.beforeEach(async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { + ETH: 10, + rETH: 10, + WBTC: 10_000, + }, + }, + }) + + borrowPage = new BorrowPageObject(page) + }) + + test('is invalid when depositing more than available', async ({ page }) => { + const deposit = { + asset: 'ETH', + amount: 100, + } + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.expectAssetInputInvalid('Exceeds your balance') + await screenshot(page, 'borrow-form-deposit-more-than-available') + }) + + test('is invalid when borrowing more than collateral', async ({ page }) => { + const deposit = { + asset: 'ETH', + amount: 1, + } + const borrow = { + asset: 'DAI', + amount: 10_000, + } + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + await borrowPage.expectAssetInputInvalid(borrowValidationIssueToMessage['insufficient-collateral']) + await screenshot(page, 'borrow-form-borrow-more-than-available') + }) + + test('is invalid when borrowing more than available', async ({ page }) => { + const deposit = { + asset: 'ETH', + amount: 1, + } + const borrow = { + asset: 'DAI', + amount: 100_000_000, + } + await borrowPage.fillDepositAssetAction(0, deposit.asset, deposit.amount) + await borrowPage.fillBorrowAssetAction(borrow.amount) + await borrowPage.expectAssetInputInvalid(borrowValidationIssueToMessage['exceeds-liquidity']) + await screenshot(page, 'borrow-form-borrow-more-than-available') + }) + + test('is valid when not depositing anything but having existing position', async ({ page }) => { + await borrowPage.depositAssetsActions({ rETH: 5 }, 1000) + await page.reload() + + await borrowPage.fillBorrowAssetAction(1000) + await borrowPage.expectBorrowButtonActive() + await screenshot(page, 'borrow-form-has-position') + }) + + test('is invalid when breaching supply cap', async () => { + await borrowPage.fillDepositAssetAction(0, 'WBTC', 10_000) + await borrowPage.expectAssetInputInvalid('Deposit cap reached') + }) + }) +}) + +async function expectHFOnDashboard( + page: Page, + borrowPage: BorrowPageObject, + expectedHealthFactor: string, +): Promise { + await borrowPage.viewInDashboardAction() + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.expectHealthFactor(expectedHealthFactor) +} diff --git a/packages/app/src/pages/Borrow.tsx b/packages/app/src/pages/Borrow.tsx new file mode 100644 index 000000000..5a002cd62 --- /dev/null +++ b/packages/app/src/pages/Borrow.tsx @@ -0,0 +1,5 @@ +import { EasyBorrowContainer } from '@/features/easy-borrow/EasyBorrowContainer' + +export function EasyBorrowPage() { + return +} diff --git a/packages/app/src/pages/Dashboard.PageObject.ts b/packages/app/src/pages/Dashboard.PageObject.ts new file mode 100644 index 000000000..b2b938658 --- /dev/null +++ b/packages/app/src/pages/Dashboard.PageObject.ts @@ -0,0 +1,220 @@ +import { expect } from '@playwright/test' +import invariant from 'tiny-invariant' +import { z } from 'zod' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { BasePageObject } from '@/test/e2e/BasePageObject' +import { buildUrl } from '@/test/e2e/setup' +import { parseTable } from '@/test/e2e/utils' +import { testIds } from '@/ui/utils/testIds' + +export class DashboardPageObject extends BasePageObject { + // #region actions + async clickDepositButtonAction(assetName: string): Promise { + const panel = this.locatePanelByHeader('Deposit') + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: assetName, exact: true }) }) + await row.getByRole('button', { name: 'Deposit' }).click() + } + + async clickWithdrawButtonAction(assetName: string): Promise { + const panel = this.locatePanelByHeader('Deposit') + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: assetName, exact: true }) }) + await row.getByRole('button', { name: 'Withdraw' }).click() + } + + async clickCollateralSwitchAction(assetName: string): Promise { + const panel = this.locatePanelByHeader('Deposit') + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: assetName, exact: true }) }) + await row.getByRole('switch').click() + } + + async clickBorrowButtonAction(assetName: string): Promise { + const panel = this.locatePanelByHeader('Borrow') + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: assetName, exact: true }) }) + await row.getByRole('button', { name: 'Borrow' }).click() + } + + async clickRepayButtonAction(assetName: string): Promise { + const panel = this.locatePanelByHeader('Borrow') + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: assetName, exact: true }) }) + await row.getByRole('button', { name: 'Repay' }).click() + } + + async parseDepositTable(): Promise { + const table = this.locatePanelByHeader('Deposit') + return parseTable(table, (row) => { + return depositTableRowSchema.parse({ + asset: row[0], + inWallet: row[1]?.trim().split(/[ $]/)[0], + deposit: row[2]?.trim().split(/[ $]/)[0], + }) + }) + } + + async parseBorrowTable(): Promise { + const table = this.locatePanelByHeader('Borrow') + return parseTable(table, (row) => { + return borrowTableRowSchema.parse({ + asset: row[0], + available: row[1]?.trim().split(/[ $]/)[0], + yourBorrow: row[2]?.trim().split(/[ $]/)[0], + }) + }) + } + + async parseWalletTable(): Promise { + const table = this.locatePanelByHeader('Your wallet') + return parseTable(table, (row) => { + return walletTableRowSchema.parse({ + asset: row[0], + amount: row[1]?.trim().split(/[ $]/)[0], + }) + }) + } + + async goToDashboardAction(): Promise { + await this.page.goto(buildUrl('dashboard')) + } + // #endregion + + // #region assertions + async expectPositionToBeEmpty(): Promise { + const deposit = this.page.getByTestId(testIds.dashboard.deposited) + const borrow = this.page.getByTestId(testIds.dashboard.borrowed) + await expect(deposit).toHaveText('-') + await expect(borrow).toHaveText('-') + } + + async expectBorrowedAssetsToBeEmpty(): Promise { + const borrow = this.page.getByTestId(testIds.dashboard.borrowed) + await expect(borrow).toHaveText('-') + } + + async expectHealthFactor(hf: string): Promise { + const locator = this.page.getByTestId(testIds.component.HealthFactorBadge.value) + await expect(locator).toHaveText(hf) + } + + async expectDepositedAssets(total: number): Promise { + const locator = this.page.getByTestId(testIds.dashboard.deposited) + await expect(locator).toHaveText(USD_MOCK_TOKEN.formatUSD(NormalizedUnitNumber(total), { compact: true })) + } + + async expectBorrowedAssets(total: number): Promise { + const locator = this.page.getByTestId(testIds.dashboard.borrowed) + await expect(locator).toHaveText(USD_MOCK_TOKEN.formatUSD(NormalizedUnitNumber(total), { compact: true })) + } + + async expectGuestScreen(): Promise { + await expect( + this.page.getByRole('heading', { name: 'This page is available ony for connected users', exact: true }), + ).toBeVisible() + await expect(this.page.getByRole('button', { name: 'Connect wallet', exact: true })).toBeVisible() + } + + async expectDepositTable(assets: Record): Promise { + await expect(async () => { + const depositTable = await this.parseDepositTable() + + for (const [asset, expectedAmount] of Object.entries(assets)) { + const row = depositTable.find((row) => row.asset === asset) + expect(row, `Couldn't find asset ${asset}`).toBeDefined() + invariant(row) + expect(expectedAmount, `Couldn't find asset ${row.asset}`).toBeDefined() + expect(row.deposit).toBe(expectedAmount) + } + + return true + }).toPass() + } + + async expectCollateralSwitch(asset: string, checked: boolean): Promise { + const panel = this.locatePanelByHeader('Deposit') + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: asset, exact: true }) }) + const switchLocator = row.getByRole('switch') + await expect(switchLocator).toHaveAttribute('aria-checked', checked ? 'true' : 'false') + } + + async expectBorrowTable(assets: Record): Promise { + await expect(async () => { + const borrowTable = await this.parseBorrowTable() + + for (const [asset, expectedAmount] of Object.entries(assets)) { + const row = borrowTable.find((row) => row.asset === asset) + expect(row, `Couldn't find asset ${asset}`).toBeDefined() + invariant(row) + expect(expectedAmount, `Couldn't find asset ${row.asset}`).toBeDefined() + expect(row.yourBorrow).toBe(expectedAmount) + } + + return true + }).toPass() + } + + async expectWalletTable(assets: Record): Promise { + await expect + .poll(async () => { + const walletTable = await this.parseWalletTable() + for (const [asset, expectedAmount] of Object.entries(assets)) { + const row = walletTable.find((row) => row.asset === asset) + // skip ETH for now as it's not supported in deposit table + if (asset === 'ETH') { + continue + } + invariant(row) + expect(expectedAmount, `Couldn't find asset ${row.asset}`).toBeDefined() + expect(row.amount).toBe(expectedAmount) + } + + return true + }) + .toBe(true) + } + + async expectAssetToBeInDepositTable(asset: string): Promise { + const table = this.locatePanelByHeader('Deposit') + await expect( + // @note For some reason you can't do table.getByRole('cell', ...) + table.getByRole('row').filter({ has: this.page.getByRole('cell', { name: asset, exact: true }) }), + ).toBeVisible() + } + + async expectAssetToBeInBorrowTable(asset: string): Promise { + const table = this.locatePanelByHeader('Borrow') + await expect( + // @note For some reason you can't do table.getByRole('cell', ...) + table.getByRole('row').filter({ has: this.page.getByRole('cell', { name: asset, exact: true }) }), + ).toBeVisible() + } + // #endregion +} + +const numberOrDash = z.string().pipe( + z.preprocess((z: unknown): string => { + if (z === '—') { + return '0' + } + return (z as string).replace(/[\,]/g, '') + }, z.coerce.number()), +) + +const depositTableRowSchema = z.object({ + asset: z.string(), + inWallet: numberOrDash, + deposit: numberOrDash, +}) +export type DepositTableRow = z.infer + +const borrowTableRowSchema = z.object({ + asset: z.string(), + available: numberOrDash, + yourBorrow: numberOrDash, +}) +export type BorrowTableRow = z.infer + +const walletTableRowSchema = z.object({ + asset: z.string(), + amount: numberOrDash, +}) +export type WalletTableRow = z.infer diff --git a/packages/app/src/pages/Dashboard.test-e2e.ts b/packages/app/src/pages/Dashboard.test-e2e.ts new file mode 100644 index 000000000..f48c8b2af --- /dev/null +++ b/packages/app/src/pages/Dashboard.test-e2e.ts @@ -0,0 +1,96 @@ +import { test } from '@playwright/test' + +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' +import { calculateAssetsWorth, screenshot } from '@/test/e2e/utils' + +import { BorrowPageObject } from './Borrow.PageObject' +import { DashboardPageObject } from './Dashboard.PageObject' + +test.describe('Dashboard', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + + test.skip('guest state', async ({ page }) => { + await setup(page, fork, { + account: { + type: 'not-connected', + }, + initialPage: 'dashboard', + }) + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.expectGuestScreen() + + await screenshot(page, 'dashboard-guest') + }) + + test('empty account', async ({ page }) => { + await setup(page, fork, { + initialPage: 'dashboard', + account: { + type: 'connected', + }, + }) + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.expectPositionToBeEmpty() + + await screenshot(page, 'dashboard-empty-account') + }) + + test('no position', async ({ page }) => { + const assetBalances = { + ETH: 1, + DAI: 200, + sDAI: 300, + USDC: 400, + WETH: 1, + } + await setup(page, fork, { + initialPage: 'dashboard', + account: { + type: 'connected', + assetBalances, + }, + }) + const dashboardPage = new DashboardPageObject(page) + + await dashboardPage.expectPositionToBeEmpty() + await dashboardPage.expectWalletTable(assetBalances) + + await screenshot(page, 'dashboard-no-position') + }) + + test('with open position', async ({ page }) => { + const assetsToDeposit = { + wstETH: 2, + rETH: 2, + } + const daiToBorrow = 1500 + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { ...assetsToDeposit, ETH: 0.1 }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositAssetsActions(assetsToDeposit, daiToBorrow) + await borrowPage.viewInDashboardAction() + + const dashboardPage = new DashboardPageObject(page) + await dashboardPage.expectHealthFactor('5.42') + await dashboardPage.expectDepositedAssets((await calculateAssetsWorth(fork.forkUrl, assetsToDeposit)).total) + await dashboardPage.expectBorrowedAssets((await calculateAssetsWorth(fork.forkUrl, { DAI: daiToBorrow })).total) + + await dashboardPage.expectDepositTable(assetsToDeposit) + await dashboardPage.expectWalletTable({ + ...assetsToDeposit, + DAI: daiToBorrow, + }) + + await screenshot(page, 'dashboard-open-position') + }) +}) diff --git a/packages/app/src/pages/Dashboard.tsx b/packages/app/src/pages/Dashboard.tsx new file mode 100644 index 000000000..1bbed1fe5 --- /dev/null +++ b/packages/app/src/pages/Dashboard.tsx @@ -0,0 +1,5 @@ +import { DashboardContainer } from '@/features/dashboard/DashboardContainer' + +export function DashboardPage() { + return +} diff --git a/packages/app/src/pages/MarketDetails.PageObject.ts b/packages/app/src/pages/MarketDetails.PageObject.ts new file mode 100644 index 000000000..a6df46910 --- /dev/null +++ b/packages/app/src/pages/MarketDetails.PageObject.ts @@ -0,0 +1,60 @@ +import { expect, Locator } from '@playwright/test' + +import { BasePageObject } from '@/test/e2e/BasePageObject' + +export type DialogType = 'Lend' | 'Deposit' | 'Borrow' + +export class MarketDetailsPageObject extends BasePageObject { + // #region locators + locateMarketOverview(): Locator { + return this.locatePanelByHeader('Market overview') + } + + locateMyWallet(): Locator { + return this.locatePanelByHeader('My wallet') + } + // #endregion + + // #region actions + async openDialogAction(type: DialogType): Promise { + await this.page.getByRole('button', { name: type }).click() + } + // #endregion + + // #region assertions + async expectMarketOverviewValue(key: string, value: string): Promise { + await expect( + this.locateMarketOverview() + .getByRole('listitem') + .filter({ has: this.page.getByText(key) }) + .getByRole('paragraph') + .last(), + ).toHaveText(value) + } + + async expectConnectWalletButton(): Promise { + await expect(this.locateMyWallet().getByRole('button', { name: 'Connect wallet' })).toBeEnabled() + } + + async expectDialogButtonToBeActive(type: DialogType): Promise { + await expect(this.locateMyWallet().getByRole('button', { name: type })).toBeEnabled() + } + + async expectDialogButtonToBeInactive(type: DialogType): Promise { + await expect(this.locateMyWallet().getByRole('button', { name: type })).toBeDisabled() + } + + async expectDialogButtonToBeInvisible(type: DialogType): Promise { + await expect(this.locateMyWallet().getByRole('button', { name: type })).not.toBeVisible() + } + + async expectBorrowNotAvailableDisclaimer(): Promise { + await expect(this.locateMyWallet().getByText('To borrow you need to deposit any other asset first.')).toBeVisible() + } + + async expectToBeLoaded(): Promise { + await expect(this.locateMarketOverview()).toBeVisible() + await expect(this.locateMyWallet()).toBeVisible() + } + // #endregion +} diff --git a/packages/app/src/pages/MarketDetails.test-e2e.ts b/packages/app/src/pages/MarketDetails.test-e2e.ts new file mode 100644 index 000000000..5973f1299 --- /dev/null +++ b/packages/app/src/pages/MarketDetails.test-e2e.ts @@ -0,0 +1,197 @@ +import { test } from '@playwright/test' + +import { DialogPageObject } from '@/features/dialogs/common/Dialog.PageObject' +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { buildUrl, setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' +import { screenshot } from '@/test/e2e/utils' + +import { BorrowPageObject } from './Borrow.PageObject' +import { MarketDetailsPageObject } from './MarketDetails.PageObject' + +const DAI = '0x6B175474E89094C44Da98b954EedeAC495271d0F' +const WETH = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2' + +test.describe('Market details', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + + test.describe('Market overview', () => { + test('DAI', async ({ page }) => { + await setup(page, fork, { + initialPage: 'marketDetails', + initialPageParams: { asset: DAI, chainId: fork.chainId.toString() }, + account: { + type: 'not-connected', + }, + }) + + const marketDetailsPage = new MarketDetailsPageObject(page) + await marketDetailsPage.expectMarketOverviewValue('Borrowed', '$779.6M') + await marketDetailsPage.expectMarketOverviewValue('Market size', '$1.216B') + await marketDetailsPage.expectMarketOverviewValue('Total available', '$436.6M') + await marketDetailsPage.expectMarketOverviewValue('Utilization rate', '64.10%') + await marketDetailsPage.expectMarketOverviewValue('Instantly available', '$55.54M') + await marketDetailsPage.expectMarketOverviewValue('MakerDAO capacity', '$381.1M') + + await screenshot(page, 'market-details-dai') + }) + + test('WETH', async ({ page }) => { + await setup(page, fork, { + initialPage: 'marketDetails', + initialPageParams: { asset: WETH, chainId: fork.chainId.toString() }, + account: { + type: 'not-connected', + }, + }) + + const marketDetailsPage = new MarketDetailsPageObject(page) + await marketDetailsPage.expectMarketOverviewValue('Market size', '$557.4M') + await marketDetailsPage.expectMarketOverviewValue('Utilization rate', '62.48%') + await marketDetailsPage.expectMarketOverviewValue('Borrowed', '$348.2M') + await marketDetailsPage.expectMarketOverviewValue('Available', '$209.1M') + + await screenshot(page, 'market-details-weth') + }) + }) + + test.describe('Dialogs', () => { + const initialDeposits = { + wstETH: 10, + } + + test('guest state', async ({ page }) => { + await setup(page, fork, { + initialPage: 'marketDetails', + initialPageParams: { asset: DAI, chainId: fork.chainId.toString() }, + account: { + type: 'not-connected', + }, + }) + + const marketDetailsPage = new MarketDetailsPageObject(page) + + await marketDetailsPage.expectToBeLoaded() + + await marketDetailsPage.expectConnectWalletButton() + await marketDetailsPage.expectDialogButtonToBeInvisible('Lend') + await marketDetailsPage.expectDialogButtonToBeInvisible('Deposit') + await marketDetailsPage.expectDialogButtonToBeInvisible('Borrow') + }) + + test("can't deposit if not enough balance", async ({ page }) => { + await setup(page, fork, { + initialPage: 'marketDetails', + initialPageParams: { asset: WETH, chainId: fork.chainId.toString() }, + account: { + type: 'connected', + }, + }) + + const marketDetailsPage = new MarketDetailsPageObject(page) + + await marketDetailsPage.expectToBeLoaded() + + await marketDetailsPage.expectDialogButtonToBeInactive('Deposit') + }) + + test("can't lend if not enough balance", async ({ page }) => { + await setup(page, fork, { + initialPage: 'marketDetails', + initialPageParams: { asset: DAI, chainId: fork.chainId.toString() }, + account: { + type: 'connected', + }, + }) + + const marketDetailsPage = new MarketDetailsPageObject(page) + + await marketDetailsPage.expectToBeLoaded() + + await marketDetailsPage.expectDialogButtonToBeInactive('Lend') + }) + + test("can't borrow if not enough balance", async ({ page }) => { + await setup(page, fork, { + initialPage: 'marketDetails', + initialPageParams: { asset: DAI, chainId: fork.chainId.toString() }, + account: { + type: 'connected', + }, + }) + + const marketDetailsPage = new MarketDetailsPageObject(page) + + await marketDetailsPage.expectToBeLoaded() + + await marketDetailsPage.expectBorrowNotAvailableDisclaimer() + await marketDetailsPage.expectDialogButtonToBeInvisible('Borrow') + }) + + test('opens dialogs for DAI', async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { + ...initialDeposits, + DAI: 1000, + sDAI: 1000, + }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositWithoutBorrowActions({ ...initialDeposits }) + + await page.goto(buildUrl('marketDetails', { asset: DAI, chainId: fork.chainId.toString() })) + + const marketDetailsPage = new MarketDetailsPageObject(page) + + await marketDetailsPage.openDialogAction('Lend') + const lendDialog = new DialogPageObject(page, /Deposit/i) + await lendDialog.expectDialogHeader('Deposit DAI') + await lendDialog.closeDialog() + + await marketDetailsPage.openDialogAction('Deposit') + const depositDialog = new DialogPageObject(page, /Deposit/i) + await depositDialog.expectDialogHeader('Deposit sDAI') + await depositDialog.closeDialog() + + await marketDetailsPage.openDialogAction('Borrow') + const borrowDialog = new DialogPageObject(page, /Borrow/i) + await borrowDialog.expectDialogHeader('Borrow DAI') + await borrowDialog.closeDialog() + }) + + test('opens dialogs for WETH', async ({ page }) => { + await setup(page, fork, { + initialPage: 'easyBorrow', + account: { + type: 'connected', + assetBalances: { + ...initialDeposits, + WETH: 10, + }, + }, + }) + + const borrowPage = new BorrowPageObject(page) + await borrowPage.depositWithoutBorrowActions({ ...initialDeposits }) + + await page.goto(buildUrl('marketDetails', { asset: WETH, chainId: fork.chainId.toString() })) + + const marketDetailsPage = new MarketDetailsPageObject(page) + + await marketDetailsPage.openDialogAction('Deposit') + const lendDialog = new DialogPageObject(page, /Deposit/i) + await lendDialog.expectDialogHeader('Deposit WETH') + await lendDialog.closeDialog() + + await marketDetailsPage.openDialogAction('Borrow') + const borrowDialog = new DialogPageObject(page, /Borrow/i) + await borrowDialog.expectDialogHeader('Borrow WETH') + await borrowDialog.closeDialog() + }) + }) +}) diff --git a/packages/app/src/pages/MarketDetails.tsx b/packages/app/src/pages/MarketDetails.tsx new file mode 100644 index 000000000..fbda48289 --- /dev/null +++ b/packages/app/src/pages/MarketDetails.tsx @@ -0,0 +1,5 @@ +import { MarketDetailsContainer } from '@/features/market-details/MarketDetailsContainer' + +export function MarketDetails() { + return +} diff --git a/packages/app/src/pages/Markets.tsx b/packages/app/src/pages/Markets.tsx new file mode 100644 index 000000000..c687e5d49 --- /dev/null +++ b/packages/app/src/pages/Markets.tsx @@ -0,0 +1,5 @@ +import { MarketsContainer } from '@/features/markets/MarketsContainer' + +export function Markets() { + return +} diff --git a/packages/app/src/pages/Root.tsx b/packages/app/src/pages/Root.tsx new file mode 100644 index 000000000..69b2470d7 --- /dev/null +++ b/packages/app/src/pages/Root.tsx @@ -0,0 +1,19 @@ +import { Suspense } from 'react' +import { Outlet } from 'react-router-dom' + +import { ComplianceContainer } from '@/features/compliance/ComplianceContainer' +import { DialogDispatcherContainer } from '@/features/dialogs/dispatcher/DialogDispatcherContainer' +import { AppLayout } from '@/ui/layouts/AppLayout' +import { FallbackLayout } from '@/ui/layouts/FallbackLayout' + +export function RootRoute() { + return ( + }> + + + + + + + ) +} diff --git a/packages/app/src/pages/Savings.PageObject.ts b/packages/app/src/pages/Savings.PageObject.ts new file mode 100644 index 000000000..5ecc60f24 --- /dev/null +++ b/packages/app/src/pages/Savings.PageObject.ts @@ -0,0 +1,77 @@ +import { expect, Locator } from '@playwright/test' + +import { BasePageObject } from '@/test/e2e/BasePageObject' + +export class SavingsPageObject extends BasePageObject { + // #region locators + locateSavingsOpportunityPanel(): Locator { + return this.locatePanelByHeader('Savings opportunity') + } + + locateSavingsDAIPanel(): Locator { + return this.locatePanelByHeader('Savings DAI') + } + + locateCashInWalletPanel(): Locator { + return this.locatePanelByHeader('Cash in wallet') + } + // #endregion + + // #region actions + async clickStartSavingButtonAction(): Promise { + await this.locateSavingsOpportunityPanel().getByRole('button', { name: 'Start saving' }).click() + } + + async clickDepositButtonAction(assetName: string): Promise { + const panel = this.locatePanelByHeader('Cash in wallet') + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: assetName, exact: true }) }) + await row.getByRole('button', { name: 'Deposit' }).click() + } + + async clickWithdrawButtonAction(): Promise { + await this.locateSavingsDAIPanel().getByRole('button', { name: 'Withdraw' }).click() + } + // #endregion + + // #region assertions + async expectDSR(value: string): Promise { + await expect(this.locateSavingsOpportunityPanel().getByRole('paragraph').filter({ hasText: value })).toBeVisible() + } + + async expectConnectWalletCTA(): Promise { + await expect( + this.locateSavingsOpportunityPanel().getByRole('button', { name: 'Connect wallet', exact: true }), + ).toBeVisible() + await expect( + this.locatePanelByHeader('Connect your wallet and start saving!').getByRole('button', { + name: 'Connect wallet', + exact: true, + }), + ).toBeVisible() + } + + async expectCurrentWorth(approximateValue: string): Promise { + await expect(this.locatePanelByHeader('Savings DAI').getByText(approximateValue)).toBeVisible() + } + + async expectCurrentProjection(value: string, type: '30-day' | '1-year'): Promise { + const title = type === '30-day' ? '30-day projection' : '1-year projection' + await expect( + this.locateSavingsDAIPanel().getByRole('generic').filter({ hasText: title }).getByText(value), + ).toBeVisible() + } + + async expectPotentialProjection(value: string, type: '30-day' | '1-year'): Promise { + const title = type === '30-day' ? '30-day projection' : '1-year projection' + await expect( + this.locateSavingsOpportunityPanel().getByRole('generic').filter({ hasText: title }).getByText(value), + ).toBeVisible() + } + + async expectCashInWalletAssetBalance(assetName: string, value: string): Promise { + const panel = this.locateCashInWalletPanel() + const row = panel.getByRole('row').filter({ has: this.page.getByRole('cell', { name: assetName, exact: true }) }) + await expect(row.getByRole('cell', { name: value })).toBeVisible() + } + // #endregion +} diff --git a/packages/app/src/pages/Savings.test-e2e.ts b/packages/app/src/pages/Savings.test-e2e.ts new file mode 100644 index 000000000..d46b22542 --- /dev/null +++ b/packages/app/src/pages/Savings.test-e2e.ts @@ -0,0 +1,76 @@ +import { test } from '@playwright/test' + +import { DEFAULT_BLOCK_NUMBER } from '@/test/e2e/constants' +import { setup } from '@/test/e2e/setup' +import { setupFork } from '@/test/e2e/setupFork' + +import { SavingsPageObject } from './Savings.PageObject' + +test.describe('Savings', () => { + const fork = setupFork(DEFAULT_BLOCK_NUMBER) + + test('guest state', async ({ page }) => { + await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'not-connected', + }, + }) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.expectDSR('5%') + await savingsPage.expectConnectWalletCTA() + }) + + test('calculates current value', async ({ page }) => { + await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + sDAI: 100, + }, + }, + }) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.expectCurrentWorth('107.1505') + }) + + test('calculates current projections', async ({ page }) => { + await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + sDAI: 100, + }, + }, + }) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.expectCurrentProjection('$0.43', '30-day') + await savingsPage.expectCurrentProjection('$5.36', '1-year') + }) + + test('calculates opportunity projections', async ({ page }) => { + await setup(page, fork, { + initialPage: 'savings', + account: { + type: 'connected', + assetBalances: { + DAI: 100, + USDC: 100, + }, + }, + }) + + const savingsPage = new SavingsPageObject(page) + + await savingsPage.expectPotentialProjection('$0.80', '30-day') + await savingsPage.expectPotentialProjection('$10.00', '1-year') + }) +}) diff --git a/packages/app/src/pages/Savings.tsx b/packages/app/src/pages/Savings.tsx new file mode 100644 index 000000000..d019e6a06 --- /dev/null +++ b/packages/app/src/pages/Savings.tsx @@ -0,0 +1,5 @@ +import { SavingsContainer } from '@/features/savings/SavingsContainer' + +export function Savings() { + return +} diff --git a/packages/app/src/reset.d.ts b/packages/app/src/reset.d.ts new file mode 100644 index 000000000..69fc8bf0a --- /dev/null +++ b/packages/app/src/reset.d.ts @@ -0,0 +1 @@ +import '@total-typescript/ts-reset' diff --git a/packages/app/src/test/e2e/BasePageObject.ts b/packages/app/src/test/e2e/BasePageObject.ts new file mode 100644 index 000000000..3e637c74c --- /dev/null +++ b/packages/app/src/test/e2e/BasePageObject.ts @@ -0,0 +1,69 @@ +import { expect, Locator, Page } from '@playwright/test' + +import { isPage } from './utils' + +/** + * BasePageObject is a class that contains common selectors and actions that are shared across the whole app. + */ +export class BasePageObject { + protected readonly page: Page + protected region: Locator + + constructor(pageOrLocator: Page | Locator) { + if (isPage(pageOrLocator)) { + this.page = pageOrLocator + this.region = pageOrLocator.locator('body') + } else { + this.page = pageOrLocator.page() + this.region = pageOrLocator + } + } + + // #region locators + locatePanelByHeader(title: string | RegExp): Locator { + return this.region + .locator('section') + .filter({ has: this.page.getByRole('heading', { name: title }) }) + .last() // @note: ensures that you get most nested panel (we can't filter for immediate children) + } + + locateDialogByHeader(title: string | RegExp): Locator { + return this.page.getByRole('dialog').filter({ has: this.page.getByRole('heading', { name: title }) }) + } + + locateAnyDialog(): Locator { + return this.page.getByRole('dialog') + } + + locateNotificationByMessage(message: string): Locator { + return this.page.locator('.toast-notifications').getByRole('status').getByText(message) + } + // #endregion + + // #region actions + async selectOptionByLabelAction(selector: Locator, label: string): Promise { + // @note raddix selector options are rendered outside the container holding selector + const locateSelectOptionByLabel = (label: string): Locator => { + return this.page.getByRole('listbox').getByText(label, { exact: true }) + } + + const currentlySelected = await selector.textContent() + if (currentlySelected === label) { + return + } + + await selector.click() + await locateSelectOptionByLabel(label).click() + } + + closeDialog(): Promise { + return this.page.keyboard.press('Escape') + } + // #endregion + + // #region assertions + async expectNotification(message: string): Promise { + await expect(this.locateNotificationByMessage(message)).toBeVisible() + } + // #endregion +} diff --git a/packages/app/src/test/e2e/TestTenderlyClient.ts b/packages/app/src/test/e2e/TestTenderlyClient.ts new file mode 100644 index 000000000..57262aa6b --- /dev/null +++ b/packages/app/src/test/e2e/TestTenderlyClient.ts @@ -0,0 +1,47 @@ +import { createTenderlyFork } from '@/domain/sandbox/createTenderlyFork' +import { solidFetch } from '@/utils/solidFetch' + +export class TestTenderlyClient { + private readonly baseUrl = 'https://api.tenderly.co/api/v1' + + constructor(private readonly opts: { apiKey: string; tenderlyAccount: string; tenderlyProject: string }) {} + + private getProjectUrl(): string { + return `${this.baseUrl}/account/${this.opts.tenderlyAccount}/project/${this.opts.tenderlyProject}` + } + + async createFork({ + originChainId, + forkChainId, + blockNumber, + namePrefix, + }: { + originChainId: number + forkChainId: number + blockNumber?: bigint + namePrefix: string + }): Promise { + const { rpcUrl } = await createTenderlyFork({ + apiUrl: `${this.getProjectUrl()}/fork`, + originChainId, + forkChainId, + namePrefix, + blockNumber, + headers: { + 'X-Access-Key': this.opts.apiKey, + }, + }) + + return rpcUrl + } + + async deleteFork(forkUrl: string): Promise { + const forkId = forkUrl.split('/').pop() + await solidFetch(`${this.getProjectUrl()}/fork/${forkId}`, { + method: 'delete', + headers: { + 'X-Access-Key': this.opts.apiKey, + }, + }) + } +} diff --git a/packages/app/src/test/e2e/assertions.ts b/packages/app/src/test/e2e/assertions.ts new file mode 100644 index 000000000..76cd31bd9 --- /dev/null +++ b/packages/app/src/test/e2e/assertions.ts @@ -0,0 +1,22 @@ +import { expect } from '@playwright/test' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' + +export interface TestTokenWithValue { + asset: string + amount: number +} + +export function expectAssets(summary: string, assets: TestTokenWithValue[], assetsWorth: Record): void { + for (const asset of assets) { + const worth = assetsWorth[asset.asset]! + const amountFormatted = new Intl.NumberFormat('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 18, // 18 is the maximum number of decimals for a token + }).format(asset.amount) + const usdValueFormatted = USD_MOCK_TOKEN.formatUSD(NormalizedUnitNumber(worth)) + + expect(summary).toMatch(`${asset.asset}${amountFormatted}${usdValueFormatted}`) + } +} diff --git a/packages/app/src/test/e2e/constants.ts b/packages/app/src/test/e2e/constants.ts new file mode 100644 index 000000000..ff20b82aa --- /dev/null +++ b/packages/app/src/test/e2e/constants.ts @@ -0,0 +1,54 @@ +/** + * App reads tokens config from the chain but we need to mint tokens in E2E tests so we maintain this list. + * + * Tokens are from mainnet network. + */ +export const TOKENS_ON_FORK = { + DAI: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + decimals: 18, + }, + USDC: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + decimals: 6, + }, + sDAI: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + decimals: 18, + }, + GNO: { + address: '0x6810e776880C02933D47DB1b9fc05908e5386b96', + decimals: 18, + }, + WETH: { + address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + decimals: 18, + }, + wstETH: { + address: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + decimals: 18, + }, + rETH: { + address: '0xae78736Cd615f374D3085123A210448E74Fc6393', + decimals: 18, + }, + WBTC: { + address: '0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599', + decimals: 8, + }, + USDT: { + address: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + decimals: 6, + }, +} as const + +export type AssetsInTests = 'ETH' | keyof typeof TOKENS_ON_FORK + +// @note At this block number: +// DAI oracle returns exactly 1 +// GNO is offboarded +export const DEFAULT_BLOCK_NUMBER = 19092430n + +export const WBTC_SUPPLY_CAP_REACHED_BLOCK_NUMBER = 19034436n + +export const GNO_ACTIVE_BLOCK_NUMBER = 18365842n diff --git a/packages/app/src/test/e2e/injectSetup.ts b/packages/app/src/test/e2e/injectSetup.ts new file mode 100644 index 000000000..7d4e7252e --- /dev/null +++ b/packages/app/src/test/e2e/injectSetup.ts @@ -0,0 +1,64 @@ +import { Page } from '@playwright/test' + +import { + PLAYWRIGHT_WALLET_ADDRESS_KEY, + PLAYWRIGHT_WALLET_FORK_URL_KEY, + PLAYWRIGHT_WALLET_PRIVATE_KEY_KEY, +} from '@/config/wagmi/config.e2e' + +import { InjectableWallet } from './setup' + +export async function injectWalletConfiguration(page: Page, wallet: InjectableWallet): Promise { + await page.addInitScript( + ({ PLAYWRIGHT_WALLET_ADDRESS_KEY, PLAYWRIGHT_WALLET_PRIVATE_KEY_KEY, wallet }) => { + if ('privateKey' in wallet) { + delete (window as any)[PLAYWRIGHT_WALLET_ADDRESS_KEY] + ;(window as any)[PLAYWRIGHT_WALLET_PRIVATE_KEY_KEY] = wallet.privateKey + } else { + delete (window as any)[PLAYWRIGHT_WALLET_PRIVATE_KEY_KEY] + ;(window as any)[PLAYWRIGHT_WALLET_ADDRESS_KEY] = wallet.address + } + }, + { + PLAYWRIGHT_WALLET_ADDRESS_KEY, + PLAYWRIGHT_WALLET_PRIVATE_KEY_KEY, + PLAYWRIGHT_WALLET_FORK_URL_KEY, + wallet, + }, + ) +} + +export async function injectNetworkConfiguration(page: Page, rpcUrl: string): Promise { + await page.addInitScript( + ({ PLAYWRIGHT_WALLET_FORK_URL_KEY, rpcUrl }) => { + ;(window as any)[PLAYWRIGHT_WALLET_FORK_URL_KEY] = rpcUrl + }, + { + PLAYWRIGHT_WALLET_FORK_URL_KEY, + rpcUrl, + }, + ) +} + +export async function injectFixedDate(page: Page, date: Date): Promise { + // setup fake Date for deterministic tests + // https://github.com/microsoft/playwright/issues/6347#issuecomment-1085850728 + const fakeNow = date.valueOf() + await page.addInitScript(` + { + // Extend Date constructor to default to fakeNow + Date = class extends Date { + constructor(...args) { + if (args.length === 0) { + super(${fakeNow}); + } else { + super(...args); + } + } + } + // Override Date.now() to start from fakeNow + const __DateNowOffset = ${fakeNow} - Date.now(); + const __DateNow = Date.now; + Date.now = () => __DateNow() + __DateNowOffset; + }`) +} diff --git a/packages/app/src/test/e2e/lifi.ts b/packages/app/src/test/e2e/lifi.ts new file mode 100644 index 000000000..7757af0b3 --- /dev/null +++ b/packages/app/src/test/e2e/lifi.ts @@ -0,0 +1,1037 @@ +import { Page } from '@playwright/test' +import assert from 'assert' +import { Address } from 'viem' + +export async function overrideLiFiRoute( + page: Page, + receiver: Address, + preset: keyof typeof lifiResponses, + expectedBlockNumber: bigint, +): Promise { + const presetValue = lifiResponses[preset] + + assert( + presetValue.block === expectedBlockNumber, + `preset ${preset}(${presetValue.block}) is not available at block ${expectedBlockNumber}`, + ) + + await page.route(presetValue.endpoint, async (route) => { + await route.fulfill({ + json: presetValue.response(receiver), + }) + }) +} + +const lifiResponses = { + '100-dai-to-sdai': { + // curl --request GET \ + // --url 'https://li.quest/v1/quote?fromChain=1&toChain=1&fromToken=0x6B175474E89094C44Da98b954EedeAC495271d0F&toToken=0x83f20f44975d03b1b09e64809b757c47f942beea&fromAddress=0x68F6148E28Ded21f92bBA55Ab1d2b39DBa0726b2&fromAmount=100000000000000000000' \ + // --header 'accept: application/json' + block: 19519583n, + endpoint: 'https://li.quest/v1/quote?*', + response: (receiver: Address) => ({ + type: 'lifi', + id: 'f5532701-e8b5-4456-a932-7bdf313bd31f', + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + action: { + fromToken: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: 1, + symbol: 'DAI', + decimals: 18, + name: 'DAI Stablecoin', + coinKey: 'DAI', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + priceUSD: '0.99985', + }, + fromAmount: '100000000000000000000', + toToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0655224570504276', + }, + fromChainId: 1, + toChainId: 1, + slippage: 0.005, + fromAddress: receiver, + toAddress: receiver, + }, + estimate: { + tool: '0x', + approvalAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAmountMin: '94929384388286060032', + toAmount: '95406416470639256314', + fromAmount: '100000000000000000000', + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '37456621733', + estimate: '315192', + limit: '409750', + amount: '11806027517267736', + amountUSD: '42.90', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3634.1', + }, + }, + ], + executionDuration: 30, + fromAmountUSD: '99.99', + toAmountUSD: '101.66', + }, + includedSteps: [ + { + id: '9d810c63-d619-45c3-833e-28f79356b7b8', + type: 'swap', + action: { + fromChainId: 1, + fromAmount: '100000000000000000000', + fromToken: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: 1, + symbol: 'DAI', + decimals: 18, + name: 'DAI Stablecoin', + coinKey: 'DAI', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + priceUSD: '0.99985', + }, + toChainId: 1, + toToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0655224570504276', + }, + slippage: 0.005, + fromAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + }, + estimate: { + tool: '0x', + fromAmount: '100000000000000000000', + toAmount: '95406416470639256314', + toAmountMin: '94929384388286060032', + approvalAddress: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + executionDuration: 30, + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '37456621733', + estimate: '164572', + limit: '213944', + amount: '6164311151843276', + amountUSD: '22.40', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3634.1', + }, + }, + ], + }, + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + }, + ], + integrator: 'lifi-staging-api', + transactionRequest: { + data: `0x4630a0d8e0b36304d9cf4b4cf2349d34092d7d0943ff49c3534aeeb53c87b8ee6e88c32300000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000${receiver + .slice(2) + .toLowerCase()}0000000000000000000000000000000000000000000000052568ec24ca4d9600000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000106c6966692d73746167696e672d61706900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078303030303030303030303030303030303030303030303030303030303030303030303030303030300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f00000000000000000000000083f20f44975d03b1b09e64809b757c47f942beea0000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001486af479b200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000056bc75e2d631000000000000000000000000000000000000000000000000000052568ec24ca4d9600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000426b175474e89094c44da98b954eedeac495271d0f0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f483f20f44975d03b1b09e64809b757c47f942beea000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000085dffaf5b75f3cb609a1c6e535df7b9d09db90e90000000000000000000000000000000002ae7f9ed49da5296f97fe6706bc58d9000000000000000000000000000000000000000000000000`, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0x8b896b0a5', + gasLimit: '0x64096', + from: receiver, + chainId: 1, + }, + }), + }, + '100-usdc-to-sdai': { + // curl --request GET \ + // --url 'https://li.quest/v1/quote?fromChain=1&toChain=1&fromToken=0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48&toToken=0x83F20F44975D03b1b09e64809B757c47f942BEeA&fromAddress=0x68F6148E28Ded21f92bBA55Ab1d2b39DBa0726b2&fromAmount=100000000' \ + // --header 'accept: application/json' + block: 19519583n, + endpoint: 'https://li.quest/v1/quote?*', + response: (receiver: Address) => ({ + type: 'lifi', + id: 'd17517a7-7d84-4fe8-9247-bf2b2e982649', + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + action: { + fromToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1', + }, + fromAmount: '100000000', + toToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0655224570504276', + }, + fromChainId: 1, + toChainId: 1, + slippage: 0.005, + fromAddress: receiver, + toAddress: receiver, + }, + estimate: { + tool: '0x', + approvalAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAmountMin: '95768761649071870400', + toAmount: '96250011707609920000', + fromAmount: '100000000', + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '53738910166', + estimate: '347293', + limit: '451481', + amount: '18663147328280638', + amountUSD: '67.82', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3634.1', + }, + }, + ], + executionDuration: 30, + fromAmountUSD: '100.00', + toAmountUSD: '102.56', + }, + includedSteps: [ + { + id: '24114fcc-c565-4d65-bfff-f6d65f2b6158', + type: 'swap', + action: { + fromChainId: 1, + fromAmount: '100000000', + fromToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1', + }, + toChainId: 1, + toToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0655224570504276', + }, + slippage: 0.005, + fromAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + }, + estimate: { + tool: '0x', + fromAmount: '100000000', + toAmount: '96250011707609920000', + toAmountMin: '95768761649071870400', + approvalAddress: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + executionDuration: 30, + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '53738910166', + estimate: '165578', + limit: '215251', + amount: '8897981267465948', + amountUSD: '32.34', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3634.1', + }, + }, + ], + }, + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + }, + ], + integrator: 'lifi-staging-api', + transactionRequest: { + data: `0x4630a0d83fad674073f4afbd0ea18bab0027e31da1ecc3f6166f5419f3da189c0e94f3ca00000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000${receiver + .slice(2) + .toLowerCase()}000000000000000000000000000000000000000000000005310efd50affb51c0000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000106c6966692d73746167696e672d61706900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078303030303030303030303030303030303030303030303030303030303030303030303030303030300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb4800000000000000000000000083f20f44975d03b1b09e64809b757c47f942beea0000000000000000000000000000000000000000000000000000000005f5e10000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001486af479b200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000005f5e100000000000000000000000000000000000000000000000005310efd50affb51c000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000042a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f483f20f44975d03b1b09e64809b757c47f942beea000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000085dffaf5b75f3cb609a1c6e535df7b9d09db90e90000000000000000000000000000000037e36e6b33bcf327abb279b154820a4a000000000000000000000000000000000000000000000000`, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0xc8316b1d6', + gasLimit: '0x6e399', + from: receiver, + chainId: 1, + }, + }), + }, + 'sdai-to-100-dai': { + //curl --request POST \ + // --url https://li.quest/v1/quote/contractCalls \ + // --header 'accept: application/json' \ + // --header 'content-type: application/json' \ + // --data ' + // { + // "fromChain": 1, + // "toChain": 1, + // "toToken": "0x6B175474E89094C44Da98b954EedeAC495271d0F", + // "fromToken": "0x83f20f44975d03b1b09e64809b757c47f942beea", + // "fromAddress": "0x68F6148E28Ded21f92bBA55Ab1d2b39DBa0726b2", + // "toAmount": "100000000000000000000", + // "contractCalls": [] + // } + // ' + block: 19532848n, + endpoint: 'https://li.quest/v1/quote/contractCalls', + response: (receiver: Address) => ({ + type: 'lifi', + id: 'd9b0de53-c14e-4d06-8493-fa72fbdf10a5', + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + action: { + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0663514131583727', + }, + fromAmount: '94333686373664217775', + toToken: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: 1, + symbol: 'DAI', + decimals: 18, + name: 'DAI Stablecoin', + coinKey: 'DAI', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + priceUSD: '0.99985', + }, + fromChainId: 1, + toChainId: 1, + slippage: 0.005, + fromAddress: receiver, + toAddress: receiver, + }, + estimate: { + tool: '0x', + approvalAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAmountMin: '100047820489544523714', + toAmount: '100550573356326154486', + fromAmount: '94333686373664217775', + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '41109586394', + estimate: '407368', + limit: '529578', + amount: '16746729990150992', + amountUSD: '59.90', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3576.94', + }, + }, + ], + executionDuration: 30, + fromAmountUSD: '100.59', + toAmountUSD: '100.54', + }, + includedSteps: [ + { + id: '59b56db9-70d7-41f7-8f64-579b1534d434', + type: 'swap', + action: { + fromChainId: 1, + fromAmount: '94333686373664217775', + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0663514131583727', + }, + toChainId: 1, + toToken: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: 1, + symbol: 'DAI', + decimals: 18, + name: 'DAI Stablecoin', + coinKey: 'DAI', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + priceUSD: '0.99985', + }, + slippage: 0.005, + fromAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + }, + estimate: { + tool: '0x', + fromAmount: '94333686373664217775', + toAmount: '100550573356326154486', + toAmountMin: '100047820489544523714', + approvalAddress: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + executionDuration: 30, + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '41109586394', + estimate: '164368', + limit: '213678', + amount: '6757100496408992', + amountUSD: '24.17', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3576.94', + }, + }, + ], + }, + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + }, + ], + transactionRequest: { + data: `0x4630a0d826f928330778c38f79e92fd335fa08184b0aabd08c4521598cd8f369319b981200000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000${receiver + .slice(2) + .toLowerCase()}0000000000000000000000000000000000000000000000056c7142a8bf595fc2000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000086c6966692d617069000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078303030303030303030303030303030303030303030303030303030303030303030303030303030300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff00000000000000000000000083f20f44975d03b1b09e64809b757c47f942beea0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000051d2493f49f5cdaaf00000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001486af479b200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000051d2493f49f5cdaaf0000000000000000000000000000000000000000000000056c7142a8bf595fc10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004283f20f44975d03b1b09e64809b757c47f942beea0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000bb86b175474e89094c44da98b954eedeac495271d0f000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000026c16b6926637cf5eb62c42991b4166add66ff9e00000000000000000000000000000000bc3a3021d9d7789380d9816cba5d78c8000000000000000000000000000000000000000000000000`, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0x9925281da', + gasLimit: '0x814aa', + from: receiver, + chainId: 1, + }, + }), + }, + 'sdai-to-100-usdc': { + // curl --request POST \ + // --url https://li.quest/v1/quote/contractCalls \ + // --header 'accept: application/json' \ + // --header 'content-type: application/json' \ + // --data ' + // { + // "fromChain": 1, + // "toChain": 1, + // "toToken": "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48", + // "fromToken": "0x83f20f44975d03b1b09e64809b757c47f942beea", + // "fromAddress": "0x68F6148E28Ded21f92bBA55Ab1d2b39DBa0726b2", + // "toAmount": "100000000", + // "contractCalls": [] + // } + // ' + block: 19532848n, + endpoint: 'https://li.quest/v1/quote/contractCalls', + response: (receiver: Address) => ({ + type: 'lifi', + id: 'e77c69b1-f5ff-41e5-9e48-767cf0d9553b', + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + action: { + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0663514131583727', + }, + fromAmount: '94313301490401710213', + toToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1', + }, + fromChainId: 1, + toChainId: 1, + slippage: 0.005, + fromAddress: receiver, + toAddress: receiver, + }, + estimate: { + tool: '0x', + approvalAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAmountMin: '100274668', + toAmount: '100778561', + fromAmount: '94313301490401710213', + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '41109586394', + estimate: '407526', + limit: '529784', + amount: '16753225304801244', + amountUSD: '59.93', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3576.94', + }, + }, + ], + executionDuration: 30, + fromAmountUSD: '100.57', + toAmountUSD: '100.78', + }, + includedSteps: [ + { + id: '9a0a9c28-bb76-4f6c-9ce8-231b8e78b41a', + type: 'swap', + action: { + fromChainId: 1, + fromAmount: '94313301490401710213', + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0663514131583727', + }, + toChainId: 1, + toToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1', + }, + slippage: 0.005, + fromAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + }, + estimate: { + tool: '0x', + fromAmount: '94313301490401710213', + toAmount: '100778561', + toAmountMin: '100274668', + approvalAddress: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + executionDuration: 30, + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '41109586394', + estimate: '164526', + limit: '213884', + amount: '6763595811059244', + amountUSD: '24.19', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3576.94', + }, + }, + ], + }, + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + }, + ], + transactionRequest: { + data: `0x4630a0d8ecb31daaf34c1e81ae04200497b15487c84f9384395935163aebf0123c0cb8d500000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000${receiver + .slice(2) + .toLowerCase()}0000000000000000000000000000000000000000000000000000000005fa11ec000000000000000000000000000000000000000000000000000000000000016000000000000000000000000000000000000000000000000000000000000000086c6966692d617069000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078303030303030303030303030303030303030303030303030303030303030303030303030303030300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff00000000000000000000000083f20f44975d03b1b09e64809b757c47f942beea000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000051cdc280321b6908500000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001486af479b200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000051cdc280321b690850000000000000000000000000000000000000000000000000000000005fa11ec0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004283f20f44975d03b1b09e64809b757c47f942beea0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f4a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000026c16b6926637cf5eb62c42991b4166add66ff9e00000000000000000000000000000000ec9aee100aa2701e94c1b24e01f386c9000000000000000000000000000000000000000000000000`, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0x9925281da', + gasLimit: '0x81578', + from: receiver, + chainId: 1, + }, + }), + }, + '100-sdai-to-dai': { + //curl 'https://li.quest/v1/quote?fromChain=1&toChain=1&fromAddress=0x8Ae54247ABee903Ea866b012394919112668b93f&fromToken=0x83F20F44975D03b1b09e64809B757c47f942BEeA&fromAmount=100000000000000000000&toToken=0x6B175474E89094C44Da98b954EedeAC495271d0F&integrator=spark_waivefee&fee=0' + endpoint: 'https://li.quest/v1/quote?*', + block: 19609252n, + response(receiver) { + return { + type: 'lifi', + id: '63342924-24ac-49cf-ba3f-75f1bd696794', + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + action: { + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0702376481177662', + }, + fromAmount: '100000000000000000000', + toToken: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: 1, + symbol: 'DAI', + decimals: 18, + name: 'DAI Stablecoin', + coinKey: 'DAI', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + priceUSD: '0.99985', + }, + fromChainId: 1, + toChainId: 1, + slippage: 0.005, + fromAddress: receiver, + toAddress: receiver, + }, + estimate: { + tool: '0x', + approvalAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAmountMin: '106406782691975684350', + toAmount: '106941490142689130000', + fromAmount: '100000000000000000000', + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '9901179375', + estimate: '407364', + limit: '529573', + amount: '4033384034917500', + amountUSD: '13.81', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3424.73', + }, + }, + ], + executionDuration: 30, + fromAmountUSD: '107.02', + toAmountUSD: '106.93', + }, + includedSteps: [ + { + id: 'fcead0a5-6af8-48cb-a5b8-d4440467094e', + type: 'swap', + action: { + fromChainId: 1, + fromAmount: '100000000000000000000', + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0702376481177662', + }, + toChainId: 1, + toToken: { + address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', + chainId: 1, + symbol: 'DAI', + decimals: 18, + name: 'DAI Stablecoin', + coinKey: 'DAI', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0x6B175474E89094C44Da98b954EedeAC495271d0F/logo.png', + priceUSD: '0.99985', + }, + slippage: 0.005, + fromAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + }, + estimate: { + tool: '0x', + fromAmount: '100000000000000000000', + toAmount: '106941490142689130000', + toAmountMin: '106406782691975684350', + approvalAddress: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + executionDuration: 30, + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '9901179375', + estimate: '164364', + limit: '213673', + amount: '1627397446792500', + amountUSD: '5.57', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3424.73', + }, + }, + ], + }, + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + }, + ], + integrator: 'spark_waivefee', + transactionRequest: { + data: `0x4630a0d8c41b1bacc95222bbbc36d075a7db4b7c87baa6be9df678748a113ac054b44c3200000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000${receiver + .slice(2) + .toLowerCase()}000000000000000000000000000000000000000000000005c4b0d5174f64e0fe0000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000000e737061726b5f7761697665666565000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078303030303030303030303030303030303030303030303030303030303030303030303030303030300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff00000000000000000000000083f20f44975d03b1b09e64809b757c47f942beea0000000000000000000000006b175474e89094c44da98b954eedeac495271d0f0000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000e0000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000001486af479b200000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000056bc75e2d63100000000000000000000000000000000000000000000000000005c4b0d5174f64e0fe0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004283f20f44975d03b1b09e64809b757c47f942beea0001f4c02aaa39b223fe8d0a0e5c4f27ead9083c756cc20001f46b175474e89094c44da98b954eedeac495271d0f000000000000000000000000000000000000000000000000000000000000869584cd00000000000000000000000026c16b6926637cf5eb62c42991b4166add66ff9e0000000000000000000000000000000083941c9bcc4296886f9db0b01557a9f4000000000000000000000000000000000000000000000000`, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0x24e2801ef', + gasLimit: '0x814a5', + from: receiver, + chainId: 1, + }, + } + }, + }, + '100-sdai-to-usdc': { + // curl 'https://li.quest/v1/quote?fromChain=1&toChain=1&fromAddress=0x908901d03233B43109fBbbc8D3b91C66e2a8867F&fromToken=0x83F20F44975D03b1b09e64809B757c47f942BEeA&fromAmount=100000000000000000000&toToken=0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48&integrator=spark_waivefee&fee=0' + endpoint: 'https://li.quest/v1/quote?*', + block: 19609941n, + response(receiver) { + return { + type: 'lifi', + id: '7d94822b-ede1-4697-90c2-54c271289270', + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + action: { + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0702778649862865', + }, + fromAmount: '100000000000000000000', + toToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1', + }, + fromChainId: 1, + toChainId: 1, + slippage: 0.005, + fromAddress: receiver, + toAddress: receiver, + }, + estimate: { + tool: '0x', + approvalAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAmountMin: '106819553', + toAmount: '107356335', + fromAmount: '100000000000000000000', + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '21486882057', + estimate: '399000', + limit: '518700', + amount: '8573265940743000', + amountUSD: '30.84', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3596.79', + }, + }, + ], + executionDuration: 30, + fromAmountUSD: '107.03', + toAmountUSD: '107.36', + }, + includedSteps: [ + { + id: '7f0f9ab2-5a2b-40f2-999f-180c369b1605', + type: 'swap', + action: { + fromChainId: 1, + fromAmount: '100000000000000000000', + fromToken: { + address: '0x83F20F44975D03b1b09e64809B757c47f942BEeA', + chainId: 1, + symbol: 'sDAI', + decimals: 18, + name: 'Savings Dai', + coinKey: 'sDAI', + logoURI: + 'https://static.debank.com/image/eth_token/logo_url/0x83f20f44975d03b1b09e64809b757c47f942beea/ba710cd443d1995d6b4781ee6d5904c0.png', + priceUSD: '1.0702778649862865', + }, + toChainId: 1, + toToken: { + address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + chainId: 1, + symbol: 'USDC', + decimals: 6, + name: 'USD Coin', + coinKey: 'USDC', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48/logo.png', + priceUSD: '1', + }, + slippage: 0.005, + fromAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + toAddress: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + }, + estimate: { + tool: '0x', + fromAmount: '100000000000000000000', + toAmount: '107356335', + toAmountMin: '106819553', + approvalAddress: '0xdef1c0ded9bec7f1a1670819833240f027b25eff', + executionDuration: 30, + feeCosts: [], + gasCosts: [ + { + type: 'SEND', + price: '21486882057', + estimate: '156000', + limit: '202800', + amount: '3351953600892000', + amountUSD: '12.06', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '3596.79', + }, + }, + ], + }, + tool: '0x', + toolDetails: { + key: '0x', + name: '0x', + logoURI: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/exchanges/zerox.png', + }, + }, + ], + integrator: 'spark_waivefee', + transactionRequest: { + data: `0x4630a0d83ab2bc90cad639f2503ac25c83a1ca36018641c58543b74c677bf4f5182bb65000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000100000000000000000000000000${receiver + .slice(2) + .toLowerCase()}00000000000000000000000000000000000000000000000000000000065defe10000000000000000000000000000000000000000000000000000000000000160000000000000000000000000000000000000000000000000000000000000000e737061726b5f7761697665666565000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002a3078303030303030303030303030303030303030303030303030303030303030303030303030303030300000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000020000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff000000000000000000000000def1c0ded9bec7f1a1670819833240f027b25eff00000000000000000000000083f20f44975d03b1b09e64809b757c47f942beea000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb480000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000000000e000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000148d9627aa400000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000056bc75e2d6310000000000000000000000000000000000000000000000000000000000000065defe10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000300000000000000000000000083f20f44975d03b1b09e64809b757c47f942beea000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48869584cd00000000000000000000000026c16b6926637cf5eb62c42991b4166add66ff9e00000000000000000000000000000000e8e3520fd4bb8a5a5c83811bf56376f7000000000000000000000000000000000000000000000000`, + to: '0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE', + value: '0x0', + gasPrice: '0x500b7cd09', + gasLimit: '0x7ea2c', + from: receiver, + chainId: 1, + }, + } + }, + }, +} satisfies Record + +interface LifiResponse { + block: bigint + endpoint: string + response: (receiver: Address) => Object +} + +export async function blockLifiApiCalls(page: Page): Promise { + // only whitelisted calls with overrideLiFiRoute will be allowed + await page.route(/li.quest/, (route) => route.abort()) +} diff --git a/packages/app/src/test/e2e/processEnv.ts b/packages/app/src/test/e2e/processEnv.ts new file mode 100644 index 000000000..da95d5d60 --- /dev/null +++ b/packages/app/src/test/e2e/processEnv.ts @@ -0,0 +1,13 @@ +import invariant from 'tiny-invariant' + +export function processEnv(key: string): string { + const value = process.env[key] + invariant(value, `${key} env not defined`) + + return value +} + +processEnv.optionalBoolean = (key: string): boolean => { + const value = process.env[key] + return value === '1' || value === 'true' +} diff --git a/packages/app/src/test/e2e/setup.ts b/packages/app/src/test/e2e/setup.ts new file mode 100644 index 000000000..ba45ce8a5 --- /dev/null +++ b/packages/app/src/test/e2e/setup.ts @@ -0,0 +1,93 @@ +import { Page } from '@playwright/test' +import { generatePath } from 'react-router-dom' +import { Address, parseEther, parseUnits } from 'viem' + +import { paths } from '@/config/paths' +import { publicTenderlyActions } from '@/domain/sandbox/publicTenderlyActions' +import { BaseUnitNumber } from '@/domain/types/NumericValues' + +import { AssetsInTests, TOKENS_ON_FORK } from './constants' +import { injectFixedDate, injectNetworkConfiguration, injectWalletConfiguration } from './injectSetup' +import { blockLifiApiCalls } from './lifi' +import { ForkContext } from './setupFork' +import { generateAccount } from './utils' + +export type InjectableWallet = { address: Address } | { privateKey: string } + +type PathParams = Parameters>[1] +export function buildUrl(key: T, pathParams?: PathParams): string { + return `http://localhost:4000${generatePath(paths[key], pathParams)}` +} + +export type AccountOptions = T extends 'connected' + ? { + type: T + assetBalances?: Partial> + } + : { type: T } + +export interface SetupOptions { + initialPage: K + initialPageParams?: PathParams + account: AccountOptions +} + +export type SetupReturn = T extends 'connected' + ? { + account: Address + getLogs: () => string[] + } + : { + getLogs: () => string[] + } + +// should be called at the beginning of any test +export async function setup( + page: Page, + forkContext: ForkContext, + options: SetupOptions, +): Promise> { + await injectNetworkConfiguration(page, forkContext.forkUrl) + await injectFixedDate(page, forkContext.simulationDate) + await blockLifiApiCalls(page) + const account = await generateAccount() + + if (options.account.type === 'connected') { + const { assetBalances } = options.account + await injectWalletConfiguration(page, account) + + if (assetBalances) { + for (const [tokenName, balance] of Object.entries(assetBalances)) { + if (tokenName === 'ETH') { + await publicTenderlyActions.setBalance( + forkContext.forkUrl, + account.address, + BaseUnitNumber(parseEther(balance.toString())), + ) + } else { + await publicTenderlyActions.setTokenBalance( + forkContext.forkUrl, + (TOKENS_ON_FORK as any)[tokenName].address, + account.address, + BaseUnitNumber(parseUnits(balance.toString(), (TOKENS_ON_FORK as any)[tokenName].decimals)), + ) + } + } + } + } + + const errorLogs = [] as string[] + + page.on('console', (message) => { + if (message.type() === 'error') { + errorLogs.push(message.text()) + } + }) + + await page.goto(buildUrl(options.initialPage, options.initialPageParams)) + + return { + account: account.address, + getLogs: () => errorLogs, + } as any +} diff --git a/packages/app/src/test/e2e/setupFork.ts b/packages/app/src/test/e2e/setupFork.ts new file mode 100644 index 000000000..20874cb0b --- /dev/null +++ b/packages/app/src/test/e2e/setupFork.ts @@ -0,0 +1,70 @@ +import { test } from '@playwright/test' + +import { publicTenderlyActions } from '@/domain/sandbox/publicTenderlyActions' + +import { processEnv } from './processEnv' +import { TestTenderlyClient } from './TestTenderlyClient' + +export interface ForkContext { + forkUrl: string + tenderlyClient: TestTenderlyClient + initialSnapshotId: string + simulationDate: Date + chainId: number +} + +const forkChainId = 1 + +// @note: https://github.com/marsfoundation/app#deterministic-time-in-e2e-tests +export const simulationDate = new Date('2024-06-04T10:21:19Z') + +/** + * Fork is shared across the whole test file and is fixed to a single block number. + * It's created once and deleted after all tests are finished but after each test it's reverted to the initial state. + */ +export function setupFork(blockNumber: bigint): ForkContext { + const apiKey = processEnv('TENDERLY_API_KEY') + const tenderlyAccount = processEnv('TENDERLY_ACCOUNT') + const tenderlyProject = processEnv('TENDERLY_PROJECT') + + const tenderlyClient = new TestTenderlyClient({ apiKey, tenderlyAccount, tenderlyProject }) + + const forkContext: ForkContext = { + tenderlyClient, + // we lie to typescript here, because it will be set in beforeAll + forkUrl: undefined as any, + initialSnapshotId: undefined as any, + simulationDate, + chainId: forkChainId, + } + + test.beforeAll(async () => { + forkContext.forkUrl = await tenderlyClient.createFork({ + namePrefix: 'e2e_test', + blockNumber, + originChainId: 1, + forkChainId, + }) + + const deltaTimeForward = Math.floor((simulationDate.getTime() - Date.now()) / 1000) + await publicTenderlyActions.evmIncreaseTime(forkContext.forkUrl, deltaTimeForward) + + forkContext.initialSnapshotId = await publicTenderlyActions.snapshot(forkContext.forkUrl) + }) + + test.beforeEach(async () => { + await publicTenderlyActions.revertToSnapshot(forkContext.forkUrl, forkContext.initialSnapshotId) + }) + + test.afterAll(async () => { + if (processEnv.optionalBoolean('TENDERLY_PERSIST_FORK')) { + return + } + + if (forkContext.forkUrl) { + await tenderlyClient.deleteFork(forkContext.forkUrl) + } + }) + + return forkContext +} diff --git a/packages/app/src/test/e2e/utils.ts b/packages/app/src/test/e2e/utils.ts new file mode 100644 index 000000000..7043304fd --- /dev/null +++ b/packages/app/src/test/e2e/utils.ts @@ -0,0 +1,154 @@ +import { Locator, Page } from '@playwright/test' +import { createPublicClient, http } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' + +import { + lendingPoolAddressProviderAddress, + uiPoolDataProviderAbi, + uiPoolDataProviderAddress, +} from '@/config/contracts-generated' + +/** + * Helper function to take deterministic screenshots. + */ +export async function screenshot(pageOrLocator: Page | Locator, name: string): Promise { + const page = isPage(pageOrLocator) ? pageOrLocator : pageOrLocator.page() + const locator = isPage(pageOrLocator) ? page.locator('html') : pageOrLocator + + // note: hide entirely elements that can change in size + const selectorsToHide = [ + `[data-testid="wallet-button"]`, // hide address because it can change + `.toast-notifications`, // hide notifications because sometimes they are fast to disappear and can't be captured deterministically + `[data-testid="react-confetti"]`, + ] + // note: mask elements that can change but not in size + const selectorsToMask: string[] = [] + + // hide elements + await page.evaluate((selectors) => { + for (const selector of selectors) { + const element: any = document.querySelector(selector) + if (!element) { + continue // skip if element not found + } + + element['__oldDisplay'] = element.style.display + element.style.display = 'none' + } + }, selectorsToHide) + + await locator.screenshot({ + path: `__screenshots-e2e__/${name}-${page.viewportSize()?.width}.png`, + animations: 'disabled', + mask: selectorsToMask.map((selector) => page.locator(selector)), + }) + + // unhide elements + await page.evaluate((selectors) => { + for (const selector of selectors) { + const element: any = document.querySelector(selector) + if (!element) { + return + } + + element.style.display = element['__oldDisplay'] + } + }, selectorsToHide) +} + +export async function waitForButtonEnabled(page: Page, name: string): Promise { + await page.waitForFunction((name) => { + const buttons = document.querySelectorAll('button') + + const button = Array.from(buttons).find((button) => { + return button.textContent?.includes(name) + }) + + return button && !button.disabled + }, name) +} + +export async function generateAccount(): Promise<{ address: `0x${string}`; privateKey: `0x${string}` }> { + const privateKey = generatePrivateKey() + return { + address: privateKeyToAccount(privateKey).address, + privateKey, + } +} + +export async function getTimestampFromBlockNumber(blockNumber: bigint, forkUrl: string): Promise { + const client = createPublicClient({ + transport: http(forkUrl, { + retryCount: 5, + }), + }) + const block = await client.getBlock({ blockNumber }) + + return Number(block.timestamp) * 1000 +} + +export async function parseTable(tableLocator: Locator, parseRow: (row: string[]) => T): Promise { + const table: T[] = [] + const rows = await tableLocator.getByRole('row').all() + let header = true + for (const row of rows) { + const cells = await row.getByRole('cell').all() + const parsedRow = [] + for (const cell of cells) { + parsedRow.push((await cell.textContent()) ?? '') + } + if (header) { + // skip header + header = false + continue + } + + table.push(parseRow(parsedRow)) + } + return table +} + +export async function calculateAssetsWorth( + forkUrl: string, + balances: Record, +): Promise<{ total: number; assetsWorth: Record }> { + const publicClient = createPublicClient({ + transport: http(forkUrl), + }) + const chainId = await publicClient.getChainId() + + const uiPoolDataProvider = uiPoolDataProviderAddress[chainId as keyof typeof uiPoolDataProviderAddress] + const lendingPoolAddressProvider = + lendingPoolAddressProviderAddress[chainId as keyof typeof lendingPoolAddressProviderAddress] + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!uiPoolDataProvider || !lendingPoolAddressProvider) { + throw new Error(`Couldn't find addresses for chain ${chainId}`) + } + + const [reserves, baseCurrencyInfo] = await publicClient.readContract({ + address: uiPoolDataProvider, + functionName: 'getReservesData', + args: [lendingPoolAddressProvider], + abi: uiPoolDataProviderAbi, + }) + + let total = 0 + const assetsWorth: Record = {} + for (const [asset, amount] of Object.entries(balances)) { + const price = reserves.find( + (reserve) => reserve.symbol === asset || (asset === 'ETH' && reserve.symbol === 'WETH'), + )?.priceInMarketReferenceCurrency + if (!price) { + throw new Error(`Couldn't find price for ${asset}`) + } + + total += Number(price) * amount + assetsWorth[asset] = (Number(price) * amount) / Number(baseCurrencyInfo.marketReferenceCurrencyPriceInUsd) + } + + return { total: total / Number(baseCurrencyInfo.marketReferenceCurrencyPriceInUsd), assetsWorth } +} + +export function isPage(pageOrLocator: Page | Locator): pageOrLocator is Page { + return 'addInitScript' in pageOrLocator +} diff --git a/packages/app/src/test/integration/TestingWrapper.tsx b/packages/app/src/test/integration/TestingWrapper.tsx new file mode 100644 index 000000000..77e11280e --- /dev/null +++ b/packages/app/src/test/integration/TestingWrapper.tsx @@ -0,0 +1,39 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useAccount, WagmiProvider } from 'wagmi' + +import { I18nTestProvider } from '@/domain/i18n/I18nTestProvider' +import { useAutoConnect } from '@/domain/wallet/useAutoConnect' + +import { queryClient as defaultQueryClient } from './query-client' +import { createWagmiTestConfig } from './wagmi-config' + +export function TestingWrapper({ + config, + children, + queryClient, +}: { + config: ReturnType + children: React.ReactNode + queryClient?: QueryClient +}) { + const waitForAccount = config.connectors.length > 0 + useAutoConnect({ config }) + + return ( + + + + {waitForAccount ? {children} : children} + + + + ) +} + +function WaitForAccountToConnect({ children }: { children: React.ReactNode }) { + const { address } = useAccount() + if (!address) { + return null + } + return <>{children} +} diff --git a/packages/app/src/test/integration/constants.ts b/packages/app/src/test/integration/constants.ts new file mode 100644 index 000000000..d8d419e7c --- /dev/null +++ b/packages/app/src/test/integration/constants.ts @@ -0,0 +1,356 @@ +import BigNumber from 'bignumber.js' +import { zeroAddress } from 'viem' + +import { NativeAssetInfo } from '@/config/chain/types' +import { AaveUserReserve } from '@/domain/market-info/aave-data-layer/query' +import { + MarketInfo, + Reserve, + UserConfiguration, + UserPosition, + UserPositionSummary, +} from '@/domain/market-info/marketInfo' +import { CheckedAddress } from '@/domain/types/CheckedAddress' +import { NormalizedUnitNumber, Percentage } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +export const testAddresses = { + alice: createDummyAddress('a11ce'), + bob: createDummyAddress('b0b'), + token: createDummyAddress('7041311'), // if you squint, it looks like "token" + token2: createDummyAddress('70413112'), + token3: createDummyAddress('70413113'), + token4: createDummyAddress('70413114'), +} + +function createDummyAddress(prefix: string): CheckedAddress { + const address = prefix + '0'.repeat(40 - prefix.length) + + return CheckedAddress('0x' + address) +} + +export function getMockAaveUserReserve(overrides: Partial = {}): AaveUserReserve { + return { + underlyingAsset: '0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0', + name: 'Wrapped liquid staked Ether 2.0', + symbol: 'wstETH', + decimals: 18, + baseLTVasCollateral: '6850', + reserveLiquidationThreshold: '7950', + reserveLiquidationBonus: '10700', + reserveFactor: '0.15', + usageAsCollateralEnabled: true, + borrowingEnabled: true, + stableBorrowRateEnabled: false, + isActive: true, + isFrozen: false, + liquidityIndex: '1000040121636005118530217371', + variableBorrowIndex: '1002639258351271728705309220', + liquidityRate: '2394836129343291714447', + variableBorrowRate: '2608029980148289558043989', + stableBorrowRate: '45000000000000000000000000', + lastUpdateTimestamp: 1707226247, + aTokenAddress: '0x12B54025C112Aa61fAce2CDB7118740875A566E9', + stableDebtTokenAddress: '0x9832D969a0c8662D98fFf334A4ba7FeE62b109C2', + variableDebtTokenAddress: '0xd5c3E3B566a42A6110513Ac7670C1a86D76E13E6', + interestRateStrategyAddress: '0x0D56700c90a690D8795D6C148aCD94b12932f4E3', + availableLiquidity: '442037025543508881719022', + totalPrincipalStableDebt: '0', + averageStableRate: '0', + stableDebtLastUpdateTimestamp: 0, + totalScaledVariableDebt: '476.790573622181008097', + priceInMarketReferenceCurrency: '262935513695', + priceOracle: '0xA9F30e6ED4098e9439B2ac8aEA2d3fc26BcEbb45', + variableRateSlope1: '45000000000000000000000000', + variableRateSlope2: '800000000000000000000000000', + stableRateSlope1: '0', + stableRateSlope2: '0', + baseStableBorrowRate: '45000000000000000000000000', + baseVariableBorrowRate: '2500000000000000000000000', + optimalUsageRatio: '450000000000000000000000000', + isPaused: false, + isSiloedBorrowing: false, + accruedToTreasury: '252777594703129452', + unbacked: '0', + isolationModeTotalDebt: '0', + flashLoanEnabled: true, + debtCeiling: '0', + debtCeilingDecimals: 2, + eModeCategoryId: 1, + borrowCap: '3000', + supplyCap: '800000', + eModeLtv: 9000, + eModeLiquidationThreshold: 9300, + eModeLiquidationBonus: 10100, + eModePriceSource: '0x0000000000000000000000000000000000000000', + eModeLabel: 'ETH', + borrowableInIsolation: false, + id: '1-0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0-0x02C3eA4e34C0cBd694D2adFa2c690EECbC1793eE', + totalDebt: '478.059161298460858355', + totalStableDebt: '0', + totalVariableDebt: '478.059161298460858355', + totalLiquidity: '442515.084704807342577377', + borrowUsageRatio: '0.00108032285863738235', + supplyUsageRatio: '0.00108032285863738235', + formattedReserveLiquidationBonus: '0.07', + formattedEModeLiquidationBonus: '0.01', + formattedEModeLiquidationThreshold: '0.93', + formattedEModeLtv: '0.9', + supplyAPY: '0.00000239483899696551', + variableBorrowAPY: '0.00261143384871612347', + stableBorrowAPY: '0.04602785987513300031', + formattedAvailableLiquidity: '442037.025543508881719022', + unborrowedLiquidity: '442037.025543508881719022', + formattedBaseLTVasCollateral: '0.685', + supplyAPR: '0.00000239483612934329', + variableBorrowAPR: '0.00260802998014828956', + stableBorrowAPR: '0.045', + formattedReserveLiquidationThreshold: '0.795', + debtCeilingUSD: '0', + isolationModeTotalDebtUSD: '0', + availableDebtCeilingUSD: '0', + isIsolated: false, + totalLiquidityUSD: '1163529311.14644956056590466781', + availableLiquidityUSD: '6631078.09932388330995314507', + totalDebtUSD: '1256987.31152611669004422558', + totalVariableDebtUSD: '1256987.31152611669004422558', + totalStableDebtUSD: '0', + formattedPriceInMarketReferenceCurrency: '2629.35513695', + priceInUSD: '2629.35513695', + borrowCapUSD: '7888065.41085', + supplyCapUSD: '2103484109.56', + unbackedUSD: '0', + aIncentivesData: [], + vIncentivesData: [], + sIncentivesData: [], + ...overrides, + } +} + +export function getMockReserve(overrides: Partial = {}): Reserve { + const priceInUsd = 2000 + const token = new Token({ + address: testAddresses.token, + symbol: TokenSymbol('wstETH'), + name: 'Wrapped liquid staked Ether 2.0', + decimals: 18, + unitPriceUsd: priceInUsd.toString(), + }) + + return { + token, + + aToken: token.clone({ symbol: TokenSymbol('aWstETH') }), + variableDebtTokenAddress: CheckedAddress(zeroAddress), + + status: 'active', + + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'yes', + borrowEligibilityStatus: 'yes', + + isIsolated: false, + eModeCategory: undefined, + + availableLiquidity: NormalizedUnitNumber(100), + availableLiquidityUSD: NormalizedUnitNumber(100 * priceInUsd), + supplyCap: undefined, + totalLiquidity: NormalizedUnitNumber(200), + totalLiquidityUSD: NormalizedUnitNumber(200 * priceInUsd), + totalDebt: NormalizedUnitNumber(100), + totalDebtUSD: NormalizedUnitNumber(100 * priceInUsd), + totalVariableDebt: NormalizedUnitNumber(100), + totalVariableDebtUSD: NormalizedUnitNumber(100 * priceInUsd), + isolationModeTotalDebt: NormalizedUnitNumber(0), + debtCeiling: NormalizedUnitNumber(200), + supplyAPY: Percentage(0.05), + maxLtv: Percentage(0.8), + liquidationThreshold: Percentage(0.8), + liquidationBonus: Percentage(0.05), + aTokenBalance: NormalizedUnitNumber(0), + + lastUpdateTimestamp: 0, + + variableBorrowIndex: new BigNumber(0), + variableBorrowRate: new BigNumber(0), + liquidityIndex: new BigNumber(0), + liquidityRate: new BigNumber(0), + variableRateSlope1: new BigNumber(0), + variableRateSlope2: new BigNumber(0), + baseVariableBorrowRate: new BigNumber(0), + optimalUtilizationRate: Percentage(0.8), + utilizationRate: Percentage(0.5), + reserveFactor: Percentage(0.05), + isBorrowableInIsolation: false, + + variableBorrowApy: Percentage(0.05), + + priceInUSD: new BigNumber(priceInUsd), + + usageAsCollateralEnabled: true, + usageAsCollateralEnabledOnUser: true, + isSiloedBorrowing: false, + + incentives: { borrow: [], deposit: [] }, + ...overrides, + } +} + +export function getMockUserPosition(overrides?: Partial): UserPosition { + return { + reserve: getMockReserve(), + scaledATokenBalance: NormalizedUnitNumber(0), + scaledVariableDebt: NormalizedUnitNumber(0), + borrowBalance: NormalizedUnitNumber(0), + collateralBalance: NormalizedUnitNumber(0), + ...overrides, + } +} + +export function getMockUserPositionSummary(overrides?: Partial): UserPositionSummary { + return { + loanToValue: Percentage(0), + maxLoanToValue: Percentage(0), + healthFactor: new BigNumber(0), + availableBorrowsUSD: NormalizedUnitNumber(0), + totalBorrowsUSD: NormalizedUnitNumber(0), + currentLiquidationThreshold: Percentage(0), + totalCollateralUSD: NormalizedUnitNumber(0), + totalLiquidityUSD: NormalizedUnitNumber(0), + ...overrides, + } +} + +export function getMockToken(overrides?: Partial[0]>): Token { + return new Token({ + address: testAddresses.token, + symbol: TokenSymbol('TKN'), + name: 'Token', + decimals: 18, + unitPriceUsd: '2000', + ...overrides, + }) +} + +export function getMockMarketInfo( + reserves: Reserve[] = [daiLikeReserve, wethLikeReserve], + userPosition?: UserPosition[], + userPositionSummary?: UserPositionSummary, + userConfiguration?: UserConfiguration, + chainId?: number, + nativeAssetInfo?: NativeAssetInfo, +): MarketInfo { + return new MarketInfo( + reserves, + userPosition ?? reserves.map((r) => getMockUserPosition({ reserve: r })), + userPositionSummary ?? getMockUserPositionSummary(), + userConfiguration ?? { + eModeState: { enabled: false }, + isolationModeState: { enabled: false }, + siloBorrowingState: { enabled: false }, + }, + {}, + new Date('2024-06-04T10:21:19Z').getTime() / 1000, + chainId ?? 1, + nativeAssetInfo ?? { + nativeAssetName: 'Ethereum', + wrappedNativeAssetSymbol: TokenSymbol('WETH'), + wrappedNativeAssetAddress: CheckedAddress('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), + nativeAssetSymbol: TokenSymbol('ETH'), + }, + ) +} + +export const daiLikeReserve = getMockReserve({ + token: new Token({ + symbol: TokenSymbol('DAI'), + name: 'Dai Stablecoin', + decimals: 18, + address: CheckedAddress('0x6B175474E89094C44Da98b954EedeAC495271d0F'), + unitPriceUsd: '1', + }), + status: 'active', + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'no', + borrowEligibilityStatus: 'yes', + isIsolated: false, + isBorrowableInIsolation: true, + eModeCategory: undefined, + isSiloedBorrowing: false, + availableLiquidity: NormalizedUnitNumber('24662247.809387867477416918'), + availableLiquidityUSD: NormalizedUnitNumber('24662247.809387867477416918'), + totalLiquidity: NormalizedUnitNumber('1022367834.197032800423989866'), + totalLiquidityUSD: NormalizedUnitNumber('1022367834.197032800423989866'), + totalDebt: NormalizedUnitNumber('997705586.387644932946572948'), + totalDebtUSD: NormalizedUnitNumber('997705586.387644932946572948'), + totalVariableDebt: NormalizedUnitNumber('997705586.387644932946572948'), + totalVariableDebtUSD: NormalizedUnitNumber('997705586.387644932946572948'), + isolationModeTotalDebt: NormalizedUnitNumber('0'), + debtCeiling: NormalizedUnitNumber('0'), + supplyAPY: Percentage('0.06299360148523566431'), + maxLtv: Percentage('0'), + variableBorrowApy: Percentage('0.06459999999999999996'), + lastUpdateTimestamp: 1708334846, + variableBorrowIndex: new BigNumber('1.035736103938278845101422738e+27'), + variableBorrowRate: new BigNumber('6.2599141818649791361008e+25'), + liquidityIndex: new BigNumber('1.028914737353923264312907136e+27'), + liquidityRate: new BigNumber('6.1089080101931682768749404e+25'), + priceInUSD: NormalizedUnitNumber('1'), + usageAsCollateralEnabled: false, + usageAsCollateralEnabledOnUser: false, + incentives: { deposit: [], borrow: [] }, +}) + +const wethLikeReserve = getMockReserve({ + token: new Token({ + symbol: TokenSymbol('WETH'), + name: 'Wrapped Ether', + decimals: 18, + address: CheckedAddress('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2'), + unitPriceUsd: '2907.40835', + }), + status: 'active', + supplyAvailabilityStatus: 'yes', + collateralEligibilityStatus: 'yes', + borrowEligibilityStatus: 'yes', + isIsolated: false, + eModeCategory: undefined, + isSiloedBorrowing: false, + availableLiquidity: NormalizedUnitNumber('125501.896724995390811222'), + availableLiquidityUSD: NormalizedUnitNumber('364885262.4790892529560601165'), + totalLiquidity: NormalizedUnitNumber('240005.463747054418237119'), + totalLiquidityUSD: NormalizedUnitNumber('697793889.34380830348699206054'), + totalDebt: NormalizedUnitNumber('114503.567022059027425897'), + totalDebtUSD: NormalizedUnitNumber('332908626.86471905053093194404'), + totalVariableDebt: NormalizedUnitNumber('114503.567022059027425897'), + totalVariableDebtUSD: NormalizedUnitNumber('332908626.86471905053093194404'), + isolationModeTotalDebt: NormalizedUnitNumber('0'), + debtCeiling: NormalizedUnitNumber('0'), + supplyAPY: Percentage('0.00771786450868526367'), + maxLtv: Percentage('0.8'), + variableBorrowApy: Percentage('0.01710779075945549687'), + lastUpdateTimestamp: 1708334829, + variableBorrowIndex: new BigNumber('1.024848053360502593632035917e+27'), + variableBorrowRate: new BigNumber('1.6963100401904001627543239e+25'), + liquidityIndex: new BigNumber('1.013900866792617715471863134e+27'), + liquidityRate: new BigNumber('7.688234151079366400606947e+24'), + priceInUSD: NormalizedUnitNumber('2907.40835'), + usageAsCollateralEnabled: true, + usageAsCollateralEnabledOnUser: true, + incentives: { + deposit: [ + { + token: new Token({ + symbol: TokenSymbol('wstETH'), + name: 'Wrapped liquid staked Ether 2.0', + decimals: 18, + address: CheckedAddress('0x7f39C581F595B53c5cb19bD0b3f8dA6c935E2Ca0'), + unitPriceUsd: '3365.40595559', + }), + APR: Percentage('0'), + }, + ], + borrow: [], + }, +}) diff --git a/packages/app/src/test/integration/expect.ts b/packages/app/src/test/integration/expect.ts new file mode 100644 index 000000000..77e9e3770 --- /dev/null +++ b/packages/app/src/test/integration/expect.ts @@ -0,0 +1,9 @@ +import { waitFor } from '@testing-library/react' + +export async function expectToStayUndefined(fn: () => any): Promise { + await expect( + waitFor(() => expect(fn()).toBeDefined(), { + timeout: 250, + }), + ).rejects.toThrow('expected undefined not to be undefined') +} diff --git a/packages/app/src/test/integration/mockTransport/handlers.ts b/packages/app/src/test/integration/mockTransport/handlers.ts new file mode 100644 index 000000000..658e72b64 --- /dev/null +++ b/packages/app/src/test/integration/mockTransport/handlers.ts @@ -0,0 +1,238 @@ +import { isDeepStrictEqual } from 'util' +import { + Abi, + ContractFunctionName, + encodeFunctionData, + EncodeFunctionDataParameters, + encodeFunctionResult, + EncodeFunctionResultParameters, +} from 'viem' + +import { TestTrigger } from '../trigger' +import { RpcHandler } from './types' +import { cleanObject, encodeRpcQuantity, encodeRpcUnformattedData, getEmptyTxData, getEmptyTxReceipt } from './utils' + +function blockNumberCall(block: number | bigint): RpcHandler { + return (method) => { + if (method === 'eth_blockNumber') { + return encodeRpcQuantity(block) + } + return undefined + } +} + +function chainIdCall(opts: { chainId: number }): RpcHandler { + return (method) => { + if (method === 'eth_chainId') { + return encodeRpcQuantity(opts.chainId) + } + return undefined + } +} + +function balanceCall(opts: { balance: bigint; address: string }): RpcHandler { + return (method, [addr]) => { + if (method === 'eth_getBalance' && addr === opts.address) { + return encodeRpcQuantity(opts.balance) + } + return undefined + } +} + +function cleanParams(params: any): any { + const { gas, ...striped } = params + return cleanObject(striped) +} + +function contractCall>( + opts: EncodeFunctionDataParameters & { + result: NonNullable['result']> | undefined // forcing to specify result + } & { to?: string; from?: string; value?: bigint }, +): RpcHandler { + return (method, [params]) => { + if (method !== 'eth_call' && method !== 'eth_estimateGas') { + return undefined + } + + const actualExpected = { + to: opts.to, + from: opts.from, + data: encodeFunctionData({ + abi: opts.abi, + functionName: opts.functionName, + args: opts.args ?? [], + } as any), + value: opts.value !== undefined ? encodeRpcQuantity(opts.value) : undefined, + } + + if (!isDeepStrictEqual(cleanParams(params), cleanObject(actualExpected))) { + return undefined + } + + return encodeFunctionResult({ + abi: opts.abi, + functionName: opts.functionName, + result: opts.result, + } as any) + } +} + +function contractCallError< + TAbi extends Abi | readonly unknown[], + TFunctionName extends ContractFunctionName | undefined = undefined, +>( + opts: EncodeFunctionDataParameters & { to?: string; from?: string; errorMessage: string }, +): RpcHandler { + return (method, [params]) => { + if (method !== 'eth_call') { + return undefined + } + + const actualExpected = { + to: opts.to, + from: opts.from, + data: encodeFunctionData({ + abi: opts.abi, + functionName: opts.functionName, + args: opts.args ?? [], + } as any), + } + + if (!isDeepStrictEqual(cleanObject(params), cleanObject(actualExpected))) { + return undefined + } + + throw new MockError(opts.errorMessage) + } +} + +function mineTransaction(opts: { blockNumber?: number; txHash?: string } = {}): RpcHandler { + const blockNumber = opts.blockNumber ?? 0 + const txHash = opts.txHash ?? '0xdeadbeef' + + return (method: string, params: any) => { + if (method === 'eth_sendTransaction') { + return encodeRpcUnformattedData(txHash) + } + + if (method === 'eth_getTransactionByHash') { + return { + ...getEmptyTxData(), + blockNumber: encodeRpcQuantity(blockNumber), + txHash, + } + } + + if (method === 'eth_getTransactionReceipt') { + return { + ...getEmptyTxReceipt(), + blockNumber: encodeRpcQuantity(blockNumber), + txHash, + } + } + + // finally block number is checked + return blockNumberCall(blockNumber)(method, params) + } +} + +function mineRevertedTransaction(opts: { blockNumber?: number; txHash?: string } = {}): RpcHandler { + const blockNumber = opts.blockNumber ?? 0 + const txHash = opts.txHash ?? '0xdeadbeef' + + return (method: string, params: any) => { + if (method === 'eth_sendTransaction') { + return encodeRpcUnformattedData(txHash) + } + + if (method === 'eth_getTransactionByHash') { + return { + ...getEmptyTxData(), + blockNumber: encodeRpcQuantity(blockNumber), + txHash, + } + } + + if (method === 'eth_getTransactionReceipt') { + // @note: this is a hack to make wagmi treat this as a reverted transaction not submission error + throw new Error('tx reverted') + } + + // finally block number is checked + return blockNumberCall(blockNumber)(method, params) + } +} + +function rejectSubmittedTransaction(opts: { blockNumber?: number; txHash?: string } = {}): RpcHandler { + const blockNumber = opts.blockNumber ?? 0 + + return (method: string, params: any) => { + if (method === 'eth_sendTransaction') { + throw new Error('user rejected') + } + + // finally block number is checked + return blockNumberCall(blockNumber)(method, params) + } +} + +export class MockError extends Error { + public readonly code: number + public readonly data: string + + constructor(public readonly message: string) { + super(message) + + this.code = 3 + this.data = encodeFunctionData({ + abi: [ + { + name: 'Error', + type: 'function', + stateMutability: 'nonpayable', + inputs: [{ internalType: 'string', name: 'reason', type: 'string' }], + outputs: [], + }, + ], + functionName: 'Error', + args: [message], + }) + } +} + +function triggerHandler(handler: RpcHandler, trigger: TestTrigger): RpcHandler { + return (method, params) => { + const response = handler(method, params) + + if (response === undefined) { + return undefined + } + + return trigger.then(() => response) + } +} + +function forceCallErrorHandler(callHandler: RpcHandler, errorMsg: string): RpcHandler { + return (method, params) => { + const response = callHandler(method, params) + + if (response === undefined) { + return undefined + } + + throw new MockError(errorMsg) + } +} + +export const handlers = { + blockNumberCall, + chainIdCall, + balanceCall, + contractCall, + contractCallError, + mineTransaction, + mineRevertedTransaction, + rejectSubmittedTransaction, + triggerHandler, + forceCallErrorHandler, +} diff --git a/packages/app/src/test/integration/mockTransport/index.ts b/packages/app/src/test/integration/mockTransport/index.ts new file mode 100644 index 000000000..45a82f1a2 --- /dev/null +++ b/packages/app/src/test/integration/mockTransport/index.ts @@ -0,0 +1,36 @@ +import { custom, CustomTransport } from 'viem' + +import { MockError } from './handlers' +import { RpcHandler, RpcResponse } from './types' + +export { handlers } from './handlers' + +export function makeMockTransport(matchers: RpcHandler[]): CustomTransport { + return custom( + { + request: async ({ method, params }): Promise => { + try { + for (const matcher of matchers) { + // @note: we pass empty array so we can destruct it in the matcher + const result = matcher(method, params || []) + + if (result !== undefined) { + return result + } + } + } catch (e) { + if (e instanceof MockError) { + throw e + } + // eslint-disable-next-line no-console + console.error('Error while mocking RPC call:', e) + } + // eslint-disable-next-line no-console + console.error('RPC request not handled:', method, params) + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + throw new Error('RPC request not handled: ' + method) + }, + }, + { retryCount: 0 }, + ) +} diff --git a/packages/app/src/test/integration/mockTransport/types.ts b/packages/app/src/test/integration/mockTransport/types.ts new file mode 100644 index 000000000..f22f18666 --- /dev/null +++ b/packages/app/src/test/integration/mockTransport/types.ts @@ -0,0 +1,4 @@ +export type RpcResponse = string | object + +// handles given rpc call or return undefined if not handled +export type RpcHandler = (method: string, params: any) => RpcResponse | Promise | undefined diff --git a/packages/app/src/test/integration/mockTransport/utils.ts b/packages/app/src/test/integration/mockTransport/utils.ts new file mode 100644 index 000000000..dd6a47b42 --- /dev/null +++ b/packages/app/src/test/integration/mockTransport/utils.ts @@ -0,0 +1,66 @@ +export function normalizeNumber(value: bigint | number): bigint { + if (typeof value === 'bigint') { + return value + } + return BigInt(value) +} + +export function encodeRpcQuantity(value: bigint | number): string { + return '0x' + normalizeNumber(value).toString(16) +} + +export function encodeRpcUnformattedData(value: string): string { + if (!value.startsWith('0x')) { + throw new Error('Unformatted data must start with 0x') + } + if (value.length % 2 !== 0) { + throw new Error('Unformatted data must have even length') + } + return value +} + +// removes undefined values from object to make comparison easier +export function cleanObject(obj: any): any { + Object.keys(obj).forEach((key) => obj[key] === undefined && delete obj[key]) + return obj +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getEmptyTxData() { + return { + blockHash: '0x0000000000000000000000000000000000000000000000000000000000000000', + blockNumber: '0x1', + from: '0x0000000000000000000000000000000000000000', + gas: '0x1', + gasPrice: '0x1', + hash: '0x1', + input: '0x', + nonce: '0x1', + to: '0x0000000000000000000000000000000000000000', + transactionIndex: '0x1', + value: '0x0', + v: '0x0', + r: '0x0000000000000000000000000000000000000000000000000000000000000000', + s: '0x0000000000000000000000000000000000000000000000000000000000000000', + } +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getEmptyTxReceipt() { + return { + blockHash: '0x0000000000000000000000000000000000000000000000000000000000000000', + blockNumber: '0x1', + contractAddress: null, + cumulativeGasUsed: '0x1', + effectiveGasPrice: '0x1', + gasUsed: '0x1', + from: '0x0000000000000000000000000000000000000000', + logs: [{}], + logsBloom: '0x0000000000000000000000000000000000000000000000000000000000000000', + status: '0x1', + to: '0x0000000000000000000000000000000000000000', + transactionHash: '0x01', + transactionIndex: '0x1', + type: '0x2', + } +} diff --git a/packages/app/src/test/integration/mocks/ResizeObserverMock.ts b/packages/app/src/test/integration/mocks/ResizeObserverMock.ts new file mode 100644 index 000000000..924f7149a --- /dev/null +++ b/packages/app/src/test/integration/mocks/ResizeObserverMock.ts @@ -0,0 +1,12 @@ +// @ts-nocheck +/* eslint-disable */ +// https://github.com/radix-ui/primitives/issues/420#issuecomment-771615182 +window.ResizeObserver = class ResizeObserver { + constructor(cb) { + this.cb = cb + } + observe() { + this.cb([{ borderBoxSize: { inlineSize: 0, blockSize: 0 } }]) + } + unobserve() {} +} diff --git a/packages/app/src/test/integration/mocks/install-mocks.ts b/packages/app/src/test/integration/mocks/install-mocks.ts new file mode 100644 index 000000000..794a23656 --- /dev/null +++ b/packages/app/src/test/integration/mocks/install-mocks.ts @@ -0,0 +1 @@ +import './ResizeObserverMock' diff --git a/packages/app/src/test/integration/object-utils.ts b/packages/app/src/test/integration/object-utils.ts new file mode 100644 index 000000000..a405bda7a --- /dev/null +++ b/packages/app/src/test/integration/object-utils.ts @@ -0,0 +1,22 @@ +export function makeFunctionsComparisonStable(obj: T): T { + return mapValuesRecursive(obj, (_key, value) => { + if (value instanceof Function) { + return value.toString() + } + return value + }) +} + +export function mapValuesRecursive(obj: T, mapper: (key: string, value: any) => any): T { + const result: any = {} + + for (const [k, v] of Object.entries(obj)) { + if (v !== null && typeof v === 'object') { + result[k] = mapValuesRecursive(v, mapper) + } else { + result[k] = mapper(k, v) + } + } + + return result +} diff --git a/packages/app/src/test/integration/query-client.ts b/packages/app/src/test/integration/query-client.ts new file mode 100644 index 000000000..3cefa9ef7 --- /dev/null +++ b/packages/app/src/test/integration/query-client.ts @@ -0,0 +1,10 @@ +import { QueryClient } from '@tanstack/react-query' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 0, + }, + }, +}) diff --git a/packages/app/src/test/integration/renderError.tsx b/packages/app/src/test/integration/renderError.tsx new file mode 100644 index 000000000..c3739f14f --- /dev/null +++ b/packages/app/src/test/integration/renderError.tsx @@ -0,0 +1,12 @@ +import { StorybookErrorBoundary } from '@storybook/ErrorBoundary' +import { render } from '@testing-library/react' +import { ReactNode } from 'react' + +/** + * Expecting a rendering error is a bit tricky. We need to use error boundaries and even then normal matchers don't work. This function is a helper to make it easier. + */ +export function expectRenderingError(reactNode: ReactNode, expectedError: string) { + const { baseElement } = render({reactNode}) + + expect(baseElement.innerHTML).toContain(expectedError) +} diff --git a/packages/app/src/test/integration/setup.ts b/packages/app/src/test/integration/setup.ts new file mode 100644 index 000000000..5dbd98fd0 --- /dev/null +++ b/packages/app/src/test/integration/setup.ts @@ -0,0 +1,37 @@ +import './mocks/install-mocks' + +import matchers from '@testing-library/jest-dom/matchers' +import { cleanup } from '@testing-library/react' +import { afterEach, expect, vitest } from 'vitest' + +import { queryClient } from './query-client' + +// extends Vitest's expect method with methods from react-testing-library +expect.extend(matchers) + +// runs a cleanup after each test case (e.g. clearing jsdom) +afterEach(async () => { + await queryClient.resetQueries() + cleanup() + // Resets BrowserRouter state + // Generally speaking, MemoryRouter should be preferred in tests but if BrowserRouter is used accidentally, this cleanup line can save lots of debugging time + window.history.pushState({}, '', '/') +}) + +// mock matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vitest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: vitest.fn(), // deprecated + removeListener: vitest.fn(), // deprecated + addEventListener: vitest.fn(), + removeEventListener: vitest.fn(), + dispatchEvent: vitest.fn(), + })), +}) + +// sometimes it's useful to increase the timeout for async tests +// configure({ asyncUtilTimeout: 60_000 }) diff --git a/packages/app/src/test/integration/setupHookRenderer.tsx b/packages/app/src/test/integration/setupHookRenderer.tsx new file mode 100644 index 000000000..911d5011b --- /dev/null +++ b/packages/app/src/test/integration/setupHookRenderer.tsx @@ -0,0 +1,41 @@ +import { QueryClient } from '@tanstack/react-query' +import { renderHook, RenderHookResult } from '@testing-library/react' + +import { CheckedAddress } from '@/domain/types/CheckedAddress' + +import { makeMockTransport } from './mockTransport' +import { RpcHandler } from './mockTransport/types' +import { TestingWrapper } from './TestingWrapper' +import { createWagmiTestConfig } from './wagmi-config' + +interface SetupHookRendererArgs any> { + hook: HOOK + account?: CheckedAddress + handlers: RpcHandler[] + extraHandlers?: RpcHandler[] + args: Parameters[0] + queryClient?: QueryClient +} + +export function setupHookRenderer any>(defaultArgs: SetupHookRendererArgs) { + return ( + overrides: Partial> = {}, + ): RenderHookResult, Parameters[0]> => { + const final = { ...defaultArgs, ...overrides } + + return renderHook(final.hook, { + initialProps: final.args, + wrapper: ({ children }) => ( + + {children} + + ), + }) + } +} diff --git a/packages/app/src/test/integration/trigger.ts b/packages/app/src/test/integration/trigger.ts new file mode 100644 index 000000000..33c2afb9c --- /dev/null +++ b/packages/app/src/test/integration/trigger.ts @@ -0,0 +1,23 @@ +export type TestTrigger = Promise + +export interface GetTestTriggerResult { + trigger: TestTrigger + release: () => void +} + +/** + * Useful for waiting for a condition to be met in a test and then changing behaviour of some kind of mock. + */ +export function getTestTrigger(): GetTestTriggerResult { + let resolveFunction: () => void + + const trigger = new Promise((resolve) => { + resolveFunction = resolve + }) + + function release(): void { + resolveFunction() + } + + return { trigger, release } +} diff --git a/packages/app/src/test/integration/wagmi-config.ts b/packages/app/src/test/integration/wagmi-config.ts new file mode 100644 index 000000000..9e45e13de --- /dev/null +++ b/packages/app/src/test/integration/wagmi-config.ts @@ -0,0 +1,64 @@ +import { Address, type Chain, createWalletClient, type Transport } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { mainnet } from 'viem/chains' +import { createConfig } from 'wagmi' + +import { createMockConnector } from '../../domain/wallet/createMockConnector' + +export type WalletOptions = { address: Address } | { privateKey: `0x${string}` } +export interface CreateWagmiTestConfigOptions { + transport: Transport + chain?: Chain + wallet?: WalletOptions +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function createWagmiTestConfig(options: CreateWagmiTestConfigOptions) { + const { transport, wallet } = options + const chain = options.chain ?? mainnet + + const connectors = wallet ? getWagmiConnectors({ chain, transport, ...wallet }) : [] + + return createConfig({ + chains: [chain], + transports: { + [chain.id]: transport, + }, + connectors, + batch: { + multicall: false, + }, + }) +} + +export interface GetWagmiConnectorsOptionsBase { + chain: Chain + transport: Transport +} + +export interface GetWagmiConnectorsOptionsWithAddress extends GetWagmiConnectorsOptionsBase { + address: Address +} + +export interface GetWagmiConnectorsOptionsWithPrivateKey extends GetWagmiConnectorsOptionsBase { + privateKey: `0x${string}` +} + +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type +export function getWagmiConnectors( + options: GetWagmiConnectorsOptionsWithAddress | GetWagmiConnectorsOptionsWithPrivateKey, +) { + const account = 'address' in options ? options.address : privateKeyToAccount(options.privateKey) + const { chain, transport } = options + + const walletClient = createWalletClient({ + transport, + chain, + pollingInterval: 100, + account, + }) + + const mockConnector = createMockConnector(walletClient) + + return [mockConnector] +} diff --git a/packages/app/src/ui/assets/actions/approve.svg b/packages/app/src/ui/assets/actions/approve.svg new file mode 100644 index 000000000..fe83699d0 --- /dev/null +++ b/packages/app/src/ui/assets/actions/approve.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/ui/assets/actions/borrow.svg b/packages/app/src/ui/assets/actions/borrow.svg new file mode 100644 index 000000000..703d2cf3e --- /dev/null +++ b/packages/app/src/ui/assets/actions/borrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/actions/deposit.svg b/packages/app/src/ui/assets/actions/deposit.svg new file mode 100644 index 000000000..c0fce4a11 --- /dev/null +++ b/packages/app/src/ui/assets/actions/deposit.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/actions/done.svg b/packages/app/src/ui/assets/actions/done.svg new file mode 100644 index 000000000..85b025dfd --- /dev/null +++ b/packages/app/src/ui/assets/actions/done.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/actions/exchange.svg b/packages/app/src/ui/assets/actions/exchange.svg new file mode 100644 index 000000000..ca613b2c5 --- /dev/null +++ b/packages/app/src/ui/assets/actions/exchange.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/actions/repay.svg b/packages/app/src/ui/assets/actions/repay.svg new file mode 100644 index 000000000..1c038f416 --- /dev/null +++ b/packages/app/src/ui/assets/actions/repay.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/actions/withdraw.svg b/packages/app/src/ui/assets/actions/withdraw.svg new file mode 100644 index 000000000..1ef16b7b2 --- /dev/null +++ b/packages/app/src/ui/assets/actions/withdraw.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/arrow-right.svg b/packages/app/src/ui/assets/arrow-right.svg new file mode 100644 index 000000000..e6f323fb7 --- /dev/null +++ b/packages/app/src/ui/assets/arrow-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/box-arrow-top-right.svg b/packages/app/src/ui/assets/box-arrow-top-right.svg new file mode 100644 index 000000000..c184f71d4 --- /dev/null +++ b/packages/app/src/ui/assets/box-arrow-top-right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/ui/assets/chains/ethereum.svg b/packages/app/src/ui/assets/chains/ethereum.svg new file mode 100644 index 000000000..62ee768f6 --- /dev/null +++ b/packages/app/src/ui/assets/chains/ethereum.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/app/src/ui/assets/chains/gnosis.svg b/packages/app/src/ui/assets/chains/gnosis.svg new file mode 100644 index 000000000..bae30c8d5 --- /dev/null +++ b/packages/app/src/ui/assets/chains/gnosis.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/app/src/ui/assets/check-circle.svg b/packages/app/src/ui/assets/check-circle.svg new file mode 100644 index 000000000..cde17354b --- /dev/null +++ b/packages/app/src/ui/assets/check-circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/chevron-down.svg b/packages/app/src/ui/assets/chevron-down.svg new file mode 100644 index 000000000..d13ae4953 --- /dev/null +++ b/packages/app/src/ui/assets/chevron-down.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/packages/app/src/ui/assets/circle-info.svg b/packages/app/src/ui/assets/circle-info.svg new file mode 100644 index 000000000..70df66921 --- /dev/null +++ b/packages/app/src/ui/assets/circle-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/close.svg b/packages/app/src/ui/assets/close.svg new file mode 100644 index 000000000..31be8b9a9 --- /dev/null +++ b/packages/app/src/ui/assets/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/down.svg b/packages/app/src/ui/assets/down.svg new file mode 100644 index 000000000..d6c8db68e --- /dev/null +++ b/packages/app/src/ui/assets/down.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/eye.svg b/packages/app/src/ui/assets/eye.svg new file mode 100644 index 000000000..3fae5c164 --- /dev/null +++ b/packages/app/src/ui/assets/eye.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/ui/assets/flash.svg b/packages/app/src/ui/assets/flash.svg new file mode 100644 index 000000000..b1f931e33 --- /dev/null +++ b/packages/app/src/ui/assets/flash.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/green-arrow-up.svg b/packages/app/src/ui/assets/green-arrow-up.svg new file mode 100644 index 000000000..1844c91b3 --- /dev/null +++ b/packages/app/src/ui/assets/green-arrow-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/index.ts b/packages/app/src/ui/assets/index.ts new file mode 100644 index 000000000..d51a08223 --- /dev/null +++ b/packages/app/src/ui/assets/index.ts @@ -0,0 +1,162 @@ +import { TokenSymbol } from '@/domain/types/TokenSymbol' + +import approve from './actions/approve.svg' +import borrow from './actions/borrow.svg' +import deposit from './actions/deposit.svg' +import done from './actions/done.svg' +import exchange from './actions/exchange.svg' +import repay from './actions/repay.svg' +import withdraw from './actions/withdraw.svg' +import arrowRight from './arrow-right.svg' +import boxArrowTopRight from './box-arrow-top-right.svg' +import ethereum from './chains/ethereum.svg' +import gnosis from './chains/gnosis.svg' +import checkCircle from './check-circle.svg' +import chevronDown from './chevron-down.svg' +import circleInfo from './circle-info.svg' +import close from './close.svg' +import down from './down.svg' +import eye from './eye.svg' +import flash from './flash.svg' +import greenArrowUp from './green-arrow-up.svg' +import lifiLogo from './lifi-logo.svg' +import link from './link.svg' +import magicWand from './magic-wand.svg' +import chart from './markets/chart.svg' +import inputOutput from './markets/input-output.svg' +import lock from './markets/lock.svg' +import output from './markets/output.svg' +import menu from './menu.svg' +import moreIcon from './more-icon.svg' +import pause from './pause.svg' +import sliderThumb from './slider-thumb.svg' +import snowflake from './snowflake.svg' +import sparkIcon from './spark-icon.svg' +import sparkLogo from './spark-logo.svg' +import success from './success.svg' +import threeDots from './three-dots.svg' +import dai from './tokens/dai.svg' +import eth from './tokens/eth.svg' +import gno from './tokens/gno.svg' +import mkr from './tokens/mkr.svg' +import reth from './tokens/reth.svg' +import sdai from './tokens/sdai.svg' +import steth from './tokens/steth.svg' +import unknown from './tokens/unknown.svg' +import usdc from './tokens/usdc.svg' +import usdt from './tokens/usdt.svg' +import wbtc from './tokens/wbtc.svg' +import weth from './tokens/weth.svg' +import wsteth from './tokens/wsteth.svg' +import wxdai from './tokens/wxdai.svg' +import xdai from './tokens/xdai.svg' +import up from './up.svg' +import wallet from './wallet.svg' +import coinbase from './wallet-icons/coinbase.svg' +import defaultWallet from './wallet-icons/default.svg' +import enjin from './wallet-icons/enjin.svg' +import metamask from './wallet-icons/metamask.svg' +import torus from './wallet-icons/torus.svg' +import walletConnect from './wallet-icons/wallet-connect.svg' +import warning from './warning.svg' +import xCircle from './x-circle.svg' + +export const assets = { + sparkLogo, + sparkIcon, + lifiLogo, + chevronDown, + sliderThumb, + circleInfo, + up, + down, + success, + wallet, + link, + threeDots, + arrowRight, + warning, + pause, + snowflake, + xCircle, + checkCircle, + flash, + greenArrowUp, + boxArrowTopRight, + magicWand, + moreIcon, + eye, + menu, + close, + markets: { + chart, + inputOutput, + lock, + output, + }, + actions: { + approve, + done, + borrow, + deposit, + withdraw, + repay, + exchange, + }, + chain: { + gnosis, + ethereum, + unknown, + }, + token: { + dai, + eth, + gno, + mkr, + reth, + sdai, + steth, + usdc, + usdt, + wbtc, + weth, + wsteth, + wxdai, + xdai, + unknown, + }, + walletIcons: { + coinbase, + enjin, + metamask, + torus, + walletConnect, + default: defaultWallet, + }, +} + +export function getTokenImage(symbol: TokenSymbol): string { + const image = assets.token[symbol.toLocaleLowerCase() as keyof typeof assets.token] + if (!image) { + return assets.token.unknown + } + + return image +} + +export const tokenColors: Record = { + DAI: '#FFC046', + ETH: '#7CC0FF', + stETH: '#8F92EC', + USDC: '#3392F8', + sDAI: '#35B552', + rETH: '#F5AC37', + MKR: '#1AAB9B', + USDT: '#26A17B', + WBTC: '#F09242', + WETH: '#627EEA', + wstETH: '#7B85D4', + WXDAI: '#0AA5E2', + XDAI: '#0DB40B', + GNO: '#3E6957', +} diff --git a/packages/app/src/ui/assets/lifi-logo.svg b/packages/app/src/ui/assets/lifi-logo.svg new file mode 100644 index 000000000..02ff5d087 --- /dev/null +++ b/packages/app/src/ui/assets/lifi-logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/link.svg b/packages/app/src/ui/assets/link.svg new file mode 100644 index 000000000..98733786d --- /dev/null +++ b/packages/app/src/ui/assets/link.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/app/src/ui/assets/magic-wand.svg b/packages/app/src/ui/assets/magic-wand.svg new file mode 100644 index 000000000..b805bac46 --- /dev/null +++ b/packages/app/src/ui/assets/magic-wand.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/app/src/ui/assets/markets/chart.svg b/packages/app/src/ui/assets/markets/chart.svg new file mode 100644 index 000000000..e9eaa81dd --- /dev/null +++ b/packages/app/src/ui/assets/markets/chart.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/markets/input-output.svg b/packages/app/src/ui/assets/markets/input-output.svg new file mode 100644 index 000000000..8b6de00aa --- /dev/null +++ b/packages/app/src/ui/assets/markets/input-output.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/markets/lock.svg b/packages/app/src/ui/assets/markets/lock.svg new file mode 100644 index 000000000..0384ae88e --- /dev/null +++ b/packages/app/src/ui/assets/markets/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/ui/assets/markets/output.svg b/packages/app/src/ui/assets/markets/output.svg new file mode 100644 index 000000000..97a1c5024 --- /dev/null +++ b/packages/app/src/ui/assets/markets/output.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/menu.svg b/packages/app/src/ui/assets/menu.svg new file mode 100644 index 000000000..6f1720940 --- /dev/null +++ b/packages/app/src/ui/assets/menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/more-icon.svg b/packages/app/src/ui/assets/more-icon.svg new file mode 100644 index 000000000..799b9ae61 --- /dev/null +++ b/packages/app/src/ui/assets/more-icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/packages/app/src/ui/assets/pause.svg b/packages/app/src/ui/assets/pause.svg new file mode 100644 index 000000000..af4c4eb7e --- /dev/null +++ b/packages/app/src/ui/assets/pause.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/slider-thumb.svg b/packages/app/src/ui/assets/slider-thumb.svg new file mode 100644 index 000000000..aff611ab7 --- /dev/null +++ b/packages/app/src/ui/assets/slider-thumb.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/snowflake.svg b/packages/app/src/ui/assets/snowflake.svg new file mode 100644 index 000000000..b7d60bd62 --- /dev/null +++ b/packages/app/src/ui/assets/snowflake.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/spark-icon.svg b/packages/app/src/ui/assets/spark-icon.svg new file mode 100644 index 000000000..23b8ccaa0 --- /dev/null +++ b/packages/app/src/ui/assets/spark-icon.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/spark-logo.svg b/packages/app/src/ui/assets/spark-logo.svg new file mode 100644 index 000000000..1228e94e9 --- /dev/null +++ b/packages/app/src/ui/assets/spark-logo.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/success.svg b/packages/app/src/ui/assets/success.svg new file mode 100644 index 000000000..668d7b79b --- /dev/null +++ b/packages/app/src/ui/assets/success.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/three-dots.svg b/packages/app/src/ui/assets/three-dots.svg new file mode 100644 index 000000000..b82b957d3 --- /dev/null +++ b/packages/app/src/ui/assets/three-dots.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/tokens/dai.svg b/packages/app/src/ui/assets/tokens/dai.svg new file mode 100644 index 000000000..5726d19ec --- /dev/null +++ b/packages/app/src/ui/assets/tokens/dai.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/eth.svg b/packages/app/src/ui/assets/tokens/eth.svg new file mode 100644 index 000000000..62ee768f6 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/eth.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/packages/app/src/ui/assets/tokens/eure.svg b/packages/app/src/ui/assets/tokens/eure.svg new file mode 100644 index 000000000..f299a426c --- /dev/null +++ b/packages/app/src/ui/assets/tokens/eure.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/gno.svg b/packages/app/src/ui/assets/tokens/gno.svg new file mode 100644 index 000000000..89e5bc147 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/gno.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app/src/ui/assets/tokens/mkr.svg b/packages/app/src/ui/assets/tokens/mkr.svg new file mode 100644 index 000000000..2bba83a52 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/mkr.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/reth.svg b/packages/app/src/ui/assets/tokens/reth.svg new file mode 100644 index 000000000..c9ddeb591 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/reth.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/sdai.svg b/packages/app/src/ui/assets/tokens/sdai.svg new file mode 100644 index 000000000..8569ca91c --- /dev/null +++ b/packages/app/src/ui/assets/tokens/sdai.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/steth.svg b/packages/app/src/ui/assets/tokens/steth.svg new file mode 100644 index 000000000..fc72b5dfd --- /dev/null +++ b/packages/app/src/ui/assets/tokens/steth.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/unknown.svg b/packages/app/src/ui/assets/tokens/unknown.svg new file mode 100644 index 000000000..098dda5cd --- /dev/null +++ b/packages/app/src/ui/assets/tokens/unknown.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/app/src/ui/assets/tokens/usdc.svg b/packages/app/src/ui/assets/tokens/usdc.svg new file mode 100644 index 000000000..151175f17 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/usdc.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/usdt.svg b/packages/app/src/ui/assets/tokens/usdt.svg new file mode 100644 index 000000000..b9c2d8ad3 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/usdt.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/wbtc.svg b/packages/app/src/ui/assets/tokens/wbtc.svg new file mode 100644 index 000000000..1093f9e9b --- /dev/null +++ b/packages/app/src/ui/assets/tokens/wbtc.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/weth.svg b/packages/app/src/ui/assets/tokens/weth.svg new file mode 100644 index 000000000..16fb80591 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/weth.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/wsteth.svg b/packages/app/src/ui/assets/tokens/wsteth.svg new file mode 100644 index 000000000..1a1fab845 --- /dev/null +++ b/packages/app/src/ui/assets/tokens/wsteth.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/wxdai.svg b/packages/app/src/ui/assets/tokens/wxdai.svg new file mode 100644 index 000000000..5726d19ec --- /dev/null +++ b/packages/app/src/ui/assets/tokens/wxdai.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/tokens/xdai.svg b/packages/app/src/ui/assets/tokens/xdai.svg new file mode 100644 index 000000000..5726d19ec --- /dev/null +++ b/packages/app/src/ui/assets/tokens/xdai.svg @@ -0,0 +1 @@ + diff --git a/packages/app/src/ui/assets/up.svg b/packages/app/src/ui/assets/up.svg new file mode 100644 index 000000000..2c6a2f57a --- /dev/null +++ b/packages/app/src/ui/assets/up.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/app/src/ui/assets/wallet-icons/coinbase.svg b/packages/app/src/ui/assets/wallet-icons/coinbase.svg new file mode 100644 index 000000000..61cfc900c --- /dev/null +++ b/packages/app/src/ui/assets/wallet-icons/coinbase.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/wallet-icons/default.svg b/packages/app/src/ui/assets/wallet-icons/default.svg new file mode 100644 index 000000000..dda49f8a5 --- /dev/null +++ b/packages/app/src/ui/assets/wallet-icons/default.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/app/src/ui/assets/wallet-icons/enjin.svg b/packages/app/src/ui/assets/wallet-icons/enjin.svg new file mode 100644 index 000000000..839780858 --- /dev/null +++ b/packages/app/src/ui/assets/wallet-icons/enjin.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/wallet-icons/metamask.svg b/packages/app/src/ui/assets/wallet-icons/metamask.svg new file mode 100644 index 000000000..72cc8f42d --- /dev/null +++ b/packages/app/src/ui/assets/wallet-icons/metamask.svg @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/wallet-icons/torus.svg b/packages/app/src/ui/assets/wallet-icons/torus.svg new file mode 100644 index 000000000..b989a3ff9 --- /dev/null +++ b/packages/app/src/ui/assets/wallet-icons/torus.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/packages/app/src/ui/assets/wallet-icons/wallet-connect.svg b/packages/app/src/ui/assets/wallet-icons/wallet-connect.svg new file mode 100644 index 000000000..25996ae11 --- /dev/null +++ b/packages/app/src/ui/assets/wallet-icons/wallet-connect.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/ui/assets/wallet.svg b/packages/app/src/ui/assets/wallet.svg new file mode 100644 index 000000000..bd58f86f8 --- /dev/null +++ b/packages/app/src/ui/assets/wallet.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/app/src/ui/assets/warning.svg b/packages/app/src/ui/assets/warning.svg new file mode 100644 index 000000000..c6e64e950 --- /dev/null +++ b/packages/app/src/ui/assets/warning.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/ui/assets/x-circle.svg b/packages/app/src/ui/assets/x-circle.svg new file mode 100644 index 000000000..0870a4053 --- /dev/null +++ b/packages/app/src/ui/assets/x-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/app/src/ui/atoms/accordion/Accordion.stories.tsx b/packages/app/src/ui/atoms/accordion/Accordion.stories.tsx new file mode 100644 index 000000000..734dabd5c --- /dev/null +++ b/packages/app/src/ui/atoms/accordion/Accordion.stories.tsx @@ -0,0 +1,60 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { within } from '@storybook/testing-library' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion' + +const meta: Meta = { + title: 'Components/Atoms/Accordion', + decorators: [WithClassname('max-w-6xl')], +} + +export default meta +type Story = StoryObj + +const content = [ + { + title: 'Item number one', + text: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Commodi quasi perferendis alias neque, excepturi qui sequi, minima amet soluta minus est ipsum quas asperiores, eius rerum? Minima dolore deleniti delectus.', + }, + { + title: 'Item number two', + text: 'Lorem ipsum dolor sit amet consectetur adipisicing elit. Possimus, animi repellendus! Nisi voluptatum iusto ipsum vero eius exercitationem et temporibus dolorum, iste aspernatur aliquid quos excepturi ullam voluptatem ab odit?', + }, +] + +export const MultipleOpenable: Story = { + render: () => { + return ( + + {content.map((item, index) => ( + + {item.title} + {item.text} + + ))} + + ) + }, + play: async ({ canvasElement }) => (await within(canvasElement).findByText('Item number one')).click(), +} + +export const SingleOpenable: Story = { + render: () => { + return ( + + {content.map((item, index) => ( + + {item.title} + {item.text} + + ))} + + ) + }, + play: async ({ canvasElement }) => (await within(canvasElement).findByText('Item number one')).click(), +} + +export const Mobile = getMobileStory(MultipleOpenable) +export const Tablet = getTabletStory(MultipleOpenable) diff --git a/packages/app/src/ui/atoms/accordion/Accordion.tsx b/packages/app/src/ui/atoms/accordion/Accordion.tsx new file mode 100644 index 000000000..bc0612c1c --- /dev/null +++ b/packages/app/src/ui/atoms/accordion/Accordion.tsx @@ -0,0 +1,51 @@ +import { Content, Header, Item, Root, Trigger } from '@radix-ui/react-accordion' +import { ChevronDown } from 'lucide-react' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +const Accordion = Root + +const AccordionItem = React.forwardRef, React.ComponentPropsWithoutRef>( + ({ className, ...props }, ref) => ( + + ), +) +AccordionItem.displayName = 'AccordionItem' + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( +

+ svg]:rotate-180', + className, + )} + {...props} + > + {children} + + +
+)) +AccordionTrigger.displayName = Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = Content.displayName + +export { Accordion, AccordionContent, AccordionItem, AccordionTrigger } diff --git a/packages/app/src/ui/atoms/button/Button.stories.tsx b/packages/app/src/ui/atoms/button/Button.stories.tsx new file mode 100644 index 000000000..f1d8e56db --- /dev/null +++ b/packages/app/src/ui/atoms/button/Button.stories.tsx @@ -0,0 +1,252 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { Candy, Fingerprint, X } from 'lucide-react' + +import { Button } from './Button' + +const meta: Meta = { + title: 'Components/Atoms/Button', + component: Button, +} + +export default meta +type Story = StoryObj + +export const PrimaryMd: Story = { + name: 'Primary:md', + args: { + variant: 'primary', + children: 'Primary Medium', + }, +} + +export const PrimaryMdHover: Story = { + name: 'Primary:md:hover', + args: { + variant: 'primary', + children: 'Primary Medium', + }, + parameters: { pseudo: { hover: true } }, +} + +export const PrimaryMdDisabled: Story = { + name: 'Primary:md:disabled', + args: { + variant: 'primary', + children: 'Primary Medium Disabled', + disabled: true, + }, +} + +export const PrimarySm: Story = { + name: 'Primary:sm', + args: { + variant: 'primary', + size: 'sm', + children: 'Primary Small', + }, +} + +export const PrimarySmHover: Story = { + name: 'Primary:sm:hover', + args: { + variant: 'primary', + size: 'sm', + children: 'Primary Small', + }, + parameters: { pseudo: { hover: true } }, +} + +export const PrimarySmDisabled: Story = { + name: 'Primary:sm:disabled', + args: { + variant: 'primary', + size: 'sm', + children: 'Primary Small Disabled', + disabled: true, + }, +} + +export const PrimaryLg: Story = { + name: 'Primary:lg', + args: { + variant: 'primary', + size: 'lg', + children: 'Button Large', + }, +} + +export const PrimaryLgHover: Story = { + name: 'Primary:lg:hover', + args: { + variant: 'primary', + size: 'lg', + children: 'Button Large', + }, + parameters: { pseudo: { hover: true } }, +} + +export const PrimaryLgDisabled: Story = { + name: 'Primary:lg:disabled', + args: { + variant: 'primary', + size: 'lg', + children: 'Primary Large Disabled', + disabled: true, + }, +} + +export const SecondaryMd: Story = { + name: 'Secondary:md', + args: { + variant: 'secondary', + children: 'Secondary Medium', + }, +} + +export const SecondaryMdHover: Story = { + name: 'Secondary:md:hover', + args: { + variant: 'secondary', + children: 'Secondary Medium', + }, + parameters: { pseudo: { hover: true } }, +} + +export const GreenMd: Story = { + name: 'Green:md', + args: { + variant: 'green', + children: 'Green Medium', + }, +} + +export const GreenMdHover: Story = { + name: 'Green:md:hover', + args: { + variant: 'green', + children: 'Green Medium', + }, + parameters: { pseudo: { hover: true } }, +} + +export const Text: Story = { + name: 'Text', + args: { + variant: 'text', + children: 'Text button', + }, +} + +export const TextDisabled: Story = { + name: 'Text:disabled', + args: { + variant: 'text', + children: 'Text button', + disabled: true, + }, +} + +export const Icon: Story = { + name: 'Icon', + args: { + variant: 'icon', + children: , + }, +} + +export const IconDisabled: Story = { + name: 'Icon:disabled', + args: { + variant: 'icon', + children: , + disabled: true, + }, +} + +export const WithPrefixIcon: Story = { + name: 'With Prefix Icon', + args: { + variant: 'primary', + children: 'With Prefix', + prefixIcon: , + }, +} + +export const WithPrefixIconSmall: Story = { + name: 'With Prefix Icon Small', + args: { + variant: 'primary', + size: 'sm', + children: 'With Prefix Small', + prefixIcon: , + }, +} + +export const WithPrefixIconLarge: Story = { + name: 'With Prefix Icon Large', + args: { + variant: 'primary', + size: 'lg', + children: 'With Prefix Large', + prefixIcon: , + }, +} + +export const WithPrefixIconDisabled: Story = { + name: 'Disabled With Prefix Icon', + args: { + variant: 'primary', + children: 'With Prefix Disabled', + prefixIcon: , + disabled: true, + }, +} + +export const WithPostfixIcon: Story = { + name: 'With Postfix Icon', + args: { + variant: 'primary', + children: 'With Postfix', + postfixIcon: , + }, +} + +export const WithPrefixAndPostfixIcons: Story = { + name: 'With Prefix and Postfix Icons', + args: { + variant: 'primary', + children: 'With Prefix and Postfix', + prefixIcon: , + postfixIcon: , + }, +} + +export const TextWithPrefixIcon: Story = { + name: 'Text With Prefix Icon', + args: { + variant: 'text', + children: 'Text With Prefix', + prefixIcon: , + }, +} + +export const TextWithPrefixIconSmall: Story = { + name: 'Text:sm With Prefix Icon', + args: { + variant: 'text', + size: 'sm', + children: 'Text With Prefix Small', + prefixIcon: , + }, +} + +export const TextNoPadding: Story = { + name: 'Text No Padding', + args: { + variant: 'text', + spaceAround: 'none', + children: 'Text No Padding', + }, +} + +// @todo rest of the stories diff --git a/packages/app/src/ui/atoms/button/Button.tsx b/packages/app/src/ui/atoms/button/Button.tsx new file mode 100644 index 000000000..47d0875aa --- /dev/null +++ b/packages/app/src/ui/atoms/button/Button.tsx @@ -0,0 +1,86 @@ +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import * as React from 'react' +import { Link, LinkProps } from 'react-router-dom' + +import { cn } from '@/ui/utils/style' + +const buttonVariants = cva( + 'ring-offset-background focus-visible:ring-ring inline-flex items-center justify-center whitespace-nowrap rounded-md border border-slate-700 border-opacity-10 text-base font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:bg-slate-700 disabled:bg-opacity-10 disabled:text-slate-500 disabled:opacity-50', + { + variants: { + variant: { + primary: 'bg-primary-bg text-primary-foreground hover:bg-primary-hover', + secondary: 'bg-secondary text-secondary-foreground hover:text-blue-700', + text: 'text-primary-bg border-none disabled:bg-transparent', + icon: 'border-none', + green: 'bg-sec-green text-basics-white hover:bg-green-700', + }, + size: { + sm: 'h-8 gap-1 rounded-lg px-3 py-2 text-xs', + md: 'h-10 gap-1.5 rounded-lg px-4 py-2', + lg: 'h-14 gap-2.5 rounded-xl px-6 py-4', + undefined: '', + }, + spaceAround: { + none: 'h-auto p-0', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + }, +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean + prefixIcon?: React.ReactNode + postfixIcon?: React.ReactNode +} + +export const Button = React.forwardRef( + ( + { className, variant, size, spaceAround, asChild = false, type = 'button', prefixIcon, postfixIcon, ...props }, + ref, + ) => { + const Comp = asChild ? Slot : 'button' + + return ( + + {prefixIcon} + {props.children} + {postfixIcon} + + ) + }, +) +Button.displayName = 'Button' + +export type LinkButtonProps = VariantProps & + LinkProps & { disabled?: boolean; prefixIcon?: React.ReactNode; postfixIcon?: React.ReactNode } + +export const LinkButton = React.forwardRef( + ({ className, variant, size, spaceAround, disabled, prefixIcon, postfixIcon, ...props }, ref) => { + return ( + <> + {disabled ? ( + + ) : ( + + {prefixIcon} + {props.children} + {postfixIcon} + + )} + + ) + }, +) +LinkButton.displayName = 'LinkButton' diff --git a/packages/app/src/ui/atoms/button/LinkButton.stories.tsx b/packages/app/src/ui/atoms/button/LinkButton.stories.tsx new file mode 100644 index 000000000..6d8b80a33 --- /dev/null +++ b/packages/app/src/ui/atoms/button/LinkButton.stories.tsx @@ -0,0 +1,208 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { ArrowLeft, Candy, X } from 'lucide-react' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { LinkButton } from './Button' + +const meta: Meta = { + title: 'Components/Atoms/LinkButton', + component: LinkButton, + decorators: [withRouter], + args: { + to: '/', + }, +} + +export default meta +type Story = StoryObj + +export const PrimaryMd: Story = { + name: 'Primary:md', + args: { + variant: 'primary', + children: 'Primary Medium', + }, +} + +export const PrimaryMdDisabled: Story = { + name: 'Primary:md:disabled', + args: { + variant: 'primary', + children: 'Primary Medium Disabled', + disabled: true, + }, +} + +export const PrimarySm: Story = { + name: 'Primary:sm', + args: { + variant: 'primary', + size: 'sm', + children: 'Primary Small', + }, +} + +export const PrimarySmDisabled: Story = { + name: 'Primary:sm:disabled', + args: { + variant: 'primary', + size: 'sm', + children: 'Primary Small Disabled', + disabled: true, + }, +} + +export const PrimaryLg: Story = { + name: 'Primary:lg', + args: { + variant: 'primary', + size: 'lg', + children: 'Button Large', + }, +} + +export const PrimaryLgDisabled: Story = { + name: 'Primary:lg:disabled', + args: { + variant: 'primary', + size: 'lg', + children: 'Primary Large Disabled', + disabled: true, + }, +} + +export const SecondaryMd: Story = { + name: 'Secondary:md', + args: { + variant: 'secondary', + children: 'Secondary Medium', + }, +} + +export const GreenMd: Story = { + name: 'Green:md', + args: { + variant: 'green', + children: 'Green Medium', + }, +} + +export const Text: Story = { + name: 'Text', + args: { + variant: 'text', + children: 'Text button', + }, +} + +export const TextDisabled: Story = { + name: 'Text:disabled', + args: { + variant: 'text', + children: 'Text button', + disabled: true, + }, +} + +export const Icon: Story = { + name: 'Icon', + args: { + variant: 'icon', + children: , + }, +} + +export const IconDisabled: Story = { + name: 'Icon:disabled', + args: { + variant: 'icon', + children: , + disabled: true, + }, +} + +export const WithPrefixIcon: Story = { + name: 'With Prefix Icon', + args: { + variant: 'primary', + children: 'With Prefix', + prefixIcon: , + }, +} + +export const WithPrefixIconSmall: Story = { + name: 'With Prefix Icon Small', + args: { + variant: 'primary', + size: 'sm', + children: 'With Prefix Small', + prefixIcon: , + }, +} + +export const WithPrefixIconLarge: Story = { + name: 'With Prefix Icon Large', + args: { + variant: 'primary', + size: 'lg', + children: 'With Prefix Large', + prefixIcon: , + }, +} + +export const WithPrefixIconDisabled: Story = { + name: 'Disabled With Prefix Icon', + args: { + variant: 'primary', + children: 'With Prefix Disabled', + prefixIcon: , + disabled: true, + }, +} + +export const WithPostfixIcon: Story = { + name: 'With Postfix Icon', + args: { + variant: 'primary', + children: 'With Postfix', + postfixIcon: , + }, +} + +export const WithPrefixAndPostfixIcons: Story = { + name: 'With Prefix and Postfix Icons', + args: { + variant: 'primary', + children: 'With Prefix and Postfix', + prefixIcon: , + postfixIcon: , + }, +} + +export const TextWithPrefixIcon: Story = { + name: 'Text With Prefix Icon', + args: { + variant: 'text', + children: 'Text With Prefix', + prefixIcon: , + }, +} + +export const TextWithPrefixIconSmall: Story = { + name: 'Text:sm With Prefix Icon', + args: { + variant: 'text', + size: 'sm', + children: 'Text With Prefix Small', + prefixIcon: , + }, +} + +export const TextNoPadding: Story = { + name: 'Text No Padding', + args: { + variant: 'text', + spaceAround: 'none', + children: 'Text No Padding', + }, +} diff --git a/packages/app/src/ui/atoms/checkbox/Checkbox.stories.tsx b/packages/app/src/ui/atoms/checkbox/Checkbox.stories.tsx new file mode 100644 index 000000000..95d9d6689 --- /dev/null +++ b/packages/app/src/ui/atoms/checkbox/Checkbox.stories.tsx @@ -0,0 +1,20 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { Checkbox } from './Checkbox' + +const meta: Meta = { + title: 'Components/Atoms/Checkbox', +} + +export default meta +type Story = StoryObj + +export const Unchecked: Story = { + name: 'Unchecked', + render: () => , +} + +export const Checked: Story = { + name: 'Checked', + render: () => , +} diff --git a/packages/app/src/ui/atoms/checkbox/Checkbox.tsx b/packages/app/src/ui/atoms/checkbox/Checkbox.tsx new file mode 100644 index 000000000..a4a9d6c09 --- /dev/null +++ b/packages/app/src/ui/atoms/checkbox/Checkbox.tsx @@ -0,0 +1,28 @@ +import * as CheckboxPrimitive from '@radix-ui/react-checkbox' +import { Check } from 'lucide-react' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +export const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + { + e.stopPropagation() + props.onClick?.(e) + }} + > + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName diff --git a/packages/app/src/ui/atoms/color-filter/ColorFilter.stories.tsx b/packages/app/src/ui/atoms/color-filter/ColorFilter.stories.tsx new file mode 100644 index 000000000..8e23e48e4 --- /dev/null +++ b/packages/app/src/ui/atoms/color-filter/ColorFilter.stories.tsx @@ -0,0 +1,50 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { assets } from '@/ui/assets' + +import { ColorFilter } from './ColorFilter' + +const meta: Meta = { + title: 'Components/Atoms/ColorFilter', + component: ColorFilter, +} + +const children = ( +
+ + + + +
+) + +export default meta +type Story = StoryObj + +export const Red: Story = { + args: { + variant: 'red', + children, + }, +} + +export const Green: Story = { + args: { + variant: 'green', + children, + }, +} + +export const Blue: Story = { + args: { + variant: 'blue', + children, + }, +} + +export const None: Story = { + args: { + variant: 'none', + children, + }, +} diff --git a/packages/app/src/ui/atoms/color-filter/ColorFilter.tsx b/packages/app/src/ui/atoms/color-filter/ColorFilter.tsx new file mode 100644 index 000000000..5342d3334 --- /dev/null +++ b/packages/app/src/ui/atoms/color-filter/ColorFilter.tsx @@ -0,0 +1,20 @@ +import { ReactNode } from 'react' + +interface ColorFilterProps { + variant: 'red' | 'green' | 'blue' | 'none' + children: ReactNode +} + +export function ColorFilter({ variant, children }: ColorFilterProps) { + if (variant === 'none') { + return <>{children} + } + + return ( + // style property used instead of tailwind to prevent changing order of filters by prettier */ + // https://tailwindcss.com/blog/automatic-class-sorting-with-prettier */ +
{children}
+ ) +} + +const variantToHueRotation = { red: '-45deg', green: '90deg', blue: '160deg' } diff --git a/packages/app/src/ui/atoms/dialog/Dialog.stories.tsx b/packages/app/src/ui/atoms/dialog/Dialog.stories.tsx new file mode 100644 index 000000000..8decc0133 --- /dev/null +++ b/packages/app/src/ui/atoms/dialog/Dialog.stories.tsx @@ -0,0 +1,61 @@ +import { Meta, StoryObj } from '@storybook/react' +import { within } from '@storybook/testing-library' + +import { Button } from '../button/Button' +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from './Dialog' + +function DialogExample() { + return ( + + + + + + + Title + Description + + +
+ Lorem ipsum dolor sit amet consectetur adipisicing elit. Quisquam, voluptatibus. +
+ + + + + +
+
+ ) +} + +const meta: Meta = { + title: 'Components/Atoms/Dialog', + component: DialogExample, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Closed', +} + +export const Opened: Story = { + name: 'Opened', + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + ;(await canvas.findByRole('button')).click() + }, +} diff --git a/packages/app/src/ui/atoms/dialog/Dialog.tsx b/packages/app/src/ui/atoms/dialog/Dialog.tsx new file mode 100644 index 000000000..af5f2eab1 --- /dev/null +++ b/packages/app/src/ui/atoms/dialog/Dialog.tsx @@ -0,0 +1,102 @@ +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +import { Typography } from '../typography/Typography' + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + showCloseButton?: boolean + } +>(({ className, children, showCloseButton = true, ...props }, ref) => ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return
+} +DialogHeader.displayName = 'DialogHeader' + +function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return
+} +DialogFooter.displayName = 'DialogFooter' + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ children, className, ...props }, ref) => ( + + {children} + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +type DialogProps = React.ComponentProps + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + type DialogProps, + DialogTitle, + DialogTrigger, +} diff --git a/packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.stories.tsx b/packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.stories.tsx new file mode 100644 index 000000000..1e13c00b8 --- /dev/null +++ b/packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.stories.tsx @@ -0,0 +1,27 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' + +import { DoughnutChart } from './DoughnutChart' + +const meta: Meta = { + title: 'Components/Atoms/DoughnutChart', + component: DoughnutChart, + decorators: [WithClassname('max-w-xl')], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + data: [ + { value: 255778, color: '#627EEA' }, + { value: 90000, color: '#3392F8' }, + { value: 64856, color: '#7CC0FF' }, + { value: 57340, color: '#3F495B' }, + { value: 50000, color: '#FFC046' }, + { value: 45800, color: '#FD1B35' }, + ], + }, +} diff --git a/packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.tsx b/packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.tsx new file mode 100644 index 000000000..ec896aeeb --- /dev/null +++ b/packages/app/src/ui/atoms/doughnut-chart/DoughnutChart.tsx @@ -0,0 +1,80 @@ +import { cn } from '@/ui/utils/style' + +export interface DoughnutChartProps { + data: { + value: number + color: string + }[] + innerRadius?: number + outerRadius?: number + className?: string +} + +export function DoughnutChart({ data, innerRadius = 160, outerRadius = 200, className }: DoughnutChartProps) { + const normalizedData = data.filter((item) => item.value > 0) + const arcs = getArcs(outerRadius, innerRadius, normalizedData) + + return ( + + {arcs.map((point) => ( + Math.PI ? '1' : '0'} 1 ${point.x2} ${point.y2} + L${point.x3} ${point.y3} + A${innerRadius} ${innerRadius} 0 ${point.angle > Math.PI ? '1' : '0'} 0 ${point.x4} ${point.y4} + Z`} + fill={point.color} + className={cn(normalizedData.length > 1 && 'stroke-white stroke-1')} + key={`${point.x1}${point.y1}${point.x2}${point.y2}`} + /> + ))} + + ) +} + +function getArcs(outerRadius: number, innerRadius: number, data: DoughnutChartProps['data']) { + const zeroAngle = 0.5 * Math.PI + // 0.00001 is added to avoid the full circle (when full circle, start and end angles are the same, and the arc is not drawn) + const fullAngle = -3.5 * Math.PI + 0.00001 + const total = data.reduce((acc, curr) => acc + curr.value, 0) + + if (total === 0) { + return [getArc(outerRadius, innerRadius, zeroAngle, fullAngle, '#E5E5E5')] + } + + if (data.length === 1) { + return [getArc(outerRadius, innerRadius, zeroAngle, fullAngle, data[0]!.color)] + } + + const sums = data.map( + ( + (sum) => (value) => + (sum += value.value) + )(0), + ) + return sums.map((sum, i) => { + const startAngle = Math.PI / 2 - ((sum - data[i]!.value) / total) * 2 * Math.PI + const endAngle = Math.PI / 2 - (sum / total) * 2 * Math.PI + return getArc(outerRadius, innerRadius, startAngle, endAngle, data[i]!.color) + }) +} + +function getArc(outerRadius: number, innerRadius: number, startAngle: number, endAngle: number, color: string) { + const [cx, cy] = [outerRadius, outerRadius] + return { + x1: cx + outerRadius * Math.cos(startAngle), + y1: cy - outerRadius * Math.sin(startAngle), + x2: cx + outerRadius * Math.cos(endAngle), + y2: cy - outerRadius * Math.sin(endAngle), + x3: cx + innerRadius * Math.cos(endAngle), + y3: cy - innerRadius * Math.sin(endAngle), + x4: cx + innerRadius * Math.cos(startAngle), + y4: cy - innerRadius * Math.sin(startAngle), + color, + angle: Math.abs(endAngle - startAngle), + } +} diff --git a/packages/app/src/ui/atoms/dropdown/DropdownMenu.stories.tsx b/packages/app/src/ui/atoms/dropdown/DropdownMenu.stories.tsx new file mode 100644 index 000000000..1032b9745 --- /dev/null +++ b/packages/app/src/ui/atoms/dropdown/DropdownMenu.stories.tsx @@ -0,0 +1,40 @@ +import { Meta, StoryObj } from '@storybook/react' +import { userEvent, within } from '@storybook/testing-library' + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from './DropdownMenu' + +const meta: Meta = { + title: 'Components/Atoms/DropdownMenu', +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => { + return ( + + Open + + My Account + + Profile + Billing + Team + Subscription + + + ) + }, + play: async ({ canvasElement }) => { + const button = await within(canvasElement).findByRole('button') + await userEvent.click(button) + }, +} diff --git a/packages/app/src/ui/atoms/dropdown/DropdownMenu.tsx b/packages/app/src/ui/atoms/dropdown/DropdownMenu.tsx new file mode 100644 index 000000000..a3e746432 --- /dev/null +++ b/packages/app/src/ui/atoms/dropdown/DropdownMenu.tsx @@ -0,0 +1,179 @@ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu' +import { Check, ChevronRight, Circle } from 'lucide-react' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +function DropdownMenuShortcut({ className, ...props }: React.HTMLAttributes) { + return +} +DropdownMenuShortcut.displayName = 'DropdownMenuShortcut' + +export { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuPortal, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} diff --git a/packages/app/src/ui/atoms/form/Form.tsx b/packages/app/src/ui/atoms/form/Form.tsx new file mode 100644 index 000000000..fa06d62c1 --- /dev/null +++ b/packages/app/src/ui/atoms/form/Form.tsx @@ -0,0 +1,144 @@ +import * as LabelPrimitive from '@radix-ui/react-label' +import { Slot } from '@radix-ui/react-slot' +import * as React from 'react' +import { Controller, ControllerProps, FieldPath, FieldValues, FormProvider, useFormContext } from 'react-hook-form' +import invariant from 'tiny-invariant' + +import { cn } from '@/ui/utils/style' + +import { Label } from '../label/Label' + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext({} as FormFieldContextValue) + +function FormField< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ ...props }: ControllerProps) { + return ( + + + + ) +} + +function useFormField() { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + invariant(fieldContext, 'useFormField should be used within ') + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext({} as FormItemContextValue) + +const FormItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const id = React.useId() + + return ( + +
+ + ) + }, +) +FormItem.displayName = 'FormItem' + +const FormLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return { + e.stopPropagation() + }} + {...props} + /> + ) + } + + return +}) +Link.displayName = 'Link' + +export { Link } diff --git a/packages/app/src/ui/atoms/panel/CollapsiblePanel.test.tsx b/packages/app/src/ui/atoms/panel/CollapsiblePanel.test.tsx new file mode 100644 index 000000000..32aa02bdd --- /dev/null +++ b/packages/app/src/ui/atoms/panel/CollapsiblePanel.test.tsx @@ -0,0 +1,59 @@ +import { act, fireEvent, render, screen } from '@testing-library/react' + +import { expectRenderingError } from '@/test/integration/renderError' + +import { CollapsiblePanel } from './CollapsiblePanel' + +describe(CollapsiblePanel.name, () => { + it('renders correctly', async () => { + render( + + + Hello! + + Content + , + ) + + expect(await screen.findByText('Hello!')).toBeVisible() + expect(await screen.findByText('Content')).toBeVisible() + }) + + it('closes', async () => { + render( + + + Hello! + + Content + , + ) + + act(() => { + fireEvent.click(screen.getByRole('switch')) + }) + expect(screen.queryByText('Content')).not.toBeInTheDocument() + }) + + it('throws on missing header', async () => { + expectRenderingError( + + Hello! + Content + , + 'Invariant failed: CollapsiblePanel.Header must be the first child of CollapsiblePanel', + ) + }) + + it('throws on missing content', async () => { + expectRenderingError( + + + Hello! + + Content + , + 'Invariant failed: CollapsiblePanel.Content must be the second child of CollapsiblePanel', + ) + }) +}) diff --git a/packages/app/src/ui/atoms/panel/CollapsiblePanel.tsx b/packages/app/src/ui/atoms/panel/CollapsiblePanel.tsx new file mode 100644 index 000000000..4c67497aa --- /dev/null +++ b/packages/app/src/ui/atoms/panel/CollapsiblePanel.tsx @@ -0,0 +1,101 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' +import { ChevronDown, ChevronUp } from 'lucide-react' +import React, { ComponentProps, forwardRef, ReactNode, useState } from 'react' +import invariant from 'tiny-invariant' + +import { cn } from '@/ui/utils/style' + +import { Typography } from '../typography/Typography' + +export interface CollapsibleRawPanelProps { + children: [ReactNode, ReactNode] + defaultOpen?: boolean + className?: string +} + +const CollapsiblePanelContext = React.createContext({ open: true }) + +export interface CollapsiblePanelType + extends React.ForwardRefExoticComponent> { + Header: typeof PanelHeader + Title: typeof PanelTitle + Content: typeof PanelContent +} + +// @note This component is an integral part of a Panel component and shouldn't be used directly +export const CollapsiblePanel: CollapsiblePanelType = forwardRef( + ({ children, defaultOpen = true, className }, ref) => { + const [Header, Body] = children + const [open, setOpen] = useState(defaultOpen) + + if (!import.meta.env.PROD) { + // Runtime checks for children structure in development + const [Header, Content] = children + invariant( + Header && (Header as any).type?.name === CollapsiblePanel.Header.name, + 'CollapsiblePanel.Header must be the first child of CollapsiblePanel', + ) + invariant( + Content && (Content as any).type?.name === CollapsiblePanel.Content.name, + 'CollapsiblePanel.Content must be the second child of CollapsiblePanel', + ) + } + + return ( + +
+ {Header} + {Body} +
+
+ ) + }, +) as CollapsiblePanelType +CollapsiblePanel.displayName = 'CollapsiblePanel' + +function PanelHeader({ className, ...rest }: JSX.IntrinsicElements['div']) { + const { open } = React.useContext(CollapsiblePanelContext) + + return ( +
+
{rest.children}
+
+ + + +
+
+ ) +} +CollapsiblePanel.Header = PanelHeader + +function PanelTitle(props: ComponentProps) { + return +} +CollapsiblePanel.Title = PanelTitle + +function PanelContent(props: CollapsiblePrimitive.CollapsibleContentProps) { + return +} + +CollapsiblePanel.Content = PanelContent diff --git a/packages/app/src/ui/atoms/panel/Panel.stories.tsx b/packages/app/src/ui/atoms/panel/Panel.stories.tsx new file mode 100644 index 000000000..83ad2be11 --- /dev/null +++ b/packages/app/src/ui/atoms/panel/Panel.stories.tsx @@ -0,0 +1,118 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { Banana } from 'lucide-react' + +import { Panel } from './Panel' + +const meta: Meta = { + title: 'Components/Atoms/Panel', + args: {}, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + render: () => { + return ( + + + Title + + Content + + ) + }, +} + +export const Mobile = getMobileStory(Default) +export const Tablet = getTabletStory(Default) + +export const WithExtraHeaderContent: Story = { + name: 'With extra header content', + render: () => { + return ( + + + Title + + + Content + + ) + }, +} + +export const AsCard: Story = { + name: 'As Card', + render: () => { + return Any content + }, +} + +export const AsGreenCard: Story = { + name: 'As Green Card', + render: () => { + return Any content + }, +} + +export const Collapsible: Story = { + name: 'Collapsible', + render: () => { + return ( + + + Title + + Content + + ) + }, +} + +export const CollapsibleClosed: Story = { + name: 'Collapsible Closed', + render: () => { + return ( + + + Title + + Content + + ) + }, +} + +export const CollapsibleMobile = getMobileStory(Collapsible) +export const CollapsibleTablet = getTabletStory(Collapsible) + +export const CollapsibleAboveMobileBreakpoint: Story = { + name: 'Collapsible Above Mobile Breakpoint', + render: () => { + return ( + + + Title + + Content + + ) + }, +} + +export const GreenAccent: Story = { + name: 'Default', + render: () => { + return ( + + + Title + + Content + + ) + }, +} diff --git a/packages/app/src/ui/atoms/panel/Panel.test.tsx b/packages/app/src/ui/atoms/panel/Panel.test.tsx new file mode 100644 index 000000000..5c4bdd277 --- /dev/null +++ b/packages/app/src/ui/atoms/panel/Panel.test.tsx @@ -0,0 +1,51 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' + +import { expectRenderingError } from '@/test/integration/renderError' + +import { Panel } from './Panel' + +const queryClient = new QueryClient() + +describe(Panel.name, () => { + it('renders correctly', async () => { + render( + + + + Hello! + + Content + + , + ) + + expect(await screen.findByText('Hello!')).toBeVisible() + }) + + it('throws on missing header', async () => { + expectRenderingError( + + + Hello! + Content + + , + 'Invariant failed: Panel.Header must be the first child of Panel', + ) + }) + + it('throws on missing content', async () => { + expectRenderingError( + + + + Hello! + + Content + + , + 'Invariant failed: Panel.Content must be the second child of Panel', + ) + }) +}) diff --git a/packages/app/src/ui/atoms/panel/Panel.tsx b/packages/app/src/ui/atoms/panel/Panel.tsx new file mode 100644 index 000000000..90e8b5c75 --- /dev/null +++ b/packages/app/src/ui/atoms/panel/Panel.tsx @@ -0,0 +1,111 @@ +import { cva, VariantProps } from 'class-variance-authority' +import { ComponentProps, createContext, forwardRef, ReactNode, useContext } from 'react' +import invariant from 'tiny-invariant' + +import { cn } from '@/ui/utils/style' +import { BreakpointKey, useBreakpoint } from '@/ui/utils/useBreakpoint' + +import { Typography } from '../typography/Typography' +import { CollapsiblePanel } from './CollapsiblePanel' + +export interface PanelProps { + children: [ReactNode, ReactNode] + className?: string + collapsibleOptions?: { collapsible: boolean; collapsibleAbove?: BreakpointKey; defaultOpen?: boolean } +} + +const PanelContext = createContext({ collapse: false }) + +export interface PanelType extends React.ForwardRefExoticComponent> { + Wrapper: typeof PanelWrapper + Header: typeof PanelHeader + Title: typeof PanelTitle + Content: typeof PanelContent +} +export const Panel = forwardRef( + ({ children, className, collapsibleOptions = { collapsible: false } }, ref) => { + const [Header, Content] = children + const { collapsible, collapsibleAbove, defaultOpen } = collapsibleOptions + const matchesQuery = useBreakpoint(collapsibleAbove ?? 'all') + + if (!import.meta.env.PROD) { + // runtime checks to ensure children structure + // @note: we might decide not to enforce header existence in the future + // @note: use name property for equality checks to make hot code reloading work + // rewrite to use invariant + invariant( + Header && (Header as any).type?.name === Panel.Header.name, + 'Panel.Header must be the first child of Panel', + ) + invariant( + Content && (Content as any).type?.name === Panel.Content.name, + 'Panel.Content must be the second child of Panel', + ) + } + + if (collapsible && matchesQuery) { + return ( + + + {Header} + {Content} + + + ) + } + + return ( + + + {children} + + + ) + }, +) as PanelType +Panel.displayName = 'Panel' + +/** + * Can be used to wrap any content without enforcing header and content structure. Replacement for Card + */ +interface PanelWrapperProps extends ComponentProps<'section'>, VariantProps {} +const PanelWrapper = forwardRef(({ className, variant, ...rest }, ref) => { + return
+}) +PanelWrapper.displayName = 'Wrapper' +Panel.Wrapper = PanelWrapper + +const panelWrapperVariants = cva('rounded-lg border shadow-sm', { + variants: { + variant: { + white: 'border-basics-border bg-white', + green: 'bg-basics-green/5 border-[#6DC275]', + }, + }, + defaultVariants: { + variant: 'white', + }, +}) + +function PanelHeader({ className, ...rest }: ComponentProps<'div'>) { + const { collapse } = useContext(PanelContext) + if (collapse) { + return + } + return
+} +Panel.Header = PanelHeader + +function PanelTitle(props: ComponentProps) { + return +} +Panel.Title = PanelTitle + +function PanelContent(props: ComponentProps<'div'>) { + const { collapse } = useContext(PanelContext) + if (collapse) { + return + } + return
+} +Panel.Content = PanelContent diff --git a/packages/app/src/ui/atoms/progress/Progress.stories.ts b/packages/app/src/ui/atoms/progress/Progress.stories.ts new file mode 100644 index 000000000..1115d3868 --- /dev/null +++ b/packages/app/src/ui/atoms/progress/Progress.stories.ts @@ -0,0 +1,37 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' + +import { Progress } from './Progress' + +const meta: Meta = { + title: 'Components/Atoms/Progress', + component: Progress, + decorators: [WithClassname('max-w-sm')], +} + +export default meta +type Story = StoryObj + +export const Zero: Story = { + args: { + value: 0, + }, +} + +export const Quarter: Story = { + args: { + value: 25, + }, +} + +export const Half: Story = { + args: { + value: 50, + }, +} + +export const Full: Story = { + args: { + value: 100, + }, +} diff --git a/packages/app/src/ui/atoms/progress/Progress.tsx b/packages/app/src/ui/atoms/progress/Progress.tsx new file mode 100644 index 000000000..60383bea0 --- /dev/null +++ b/packages/app/src/ui/atoms/progress/Progress.tsx @@ -0,0 +1,23 @@ +import * as ProgressPrimitive from '@radix-ui/react-progress' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/packages/app/src/ui/atoms/scroll-area/ScrollArea.stories.tsx b/packages/app/src/ui/atoms/scroll-area/ScrollArea.stories.tsx new file mode 100644 index 000000000..09a73bdb6 --- /dev/null +++ b/packages/app/src/ui/atoms/scroll-area/ScrollArea.stories.tsx @@ -0,0 +1,37 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { ScrollArea, ScrollBar } from './ScrollArea' + +const meta: Meta = { + title: 'Components/Atoms/ScrollArea', +} + +export default meta +type Story = StoryObj + +export const Vertical: Story = { + name: 'Vertical', + render: () => ( + +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Item {i}
+ ))} +
+
+ ), +} + +export const Horizontal: Story = { + name: 'Horizontal', + render: () => ( + +
+ {Array.from({ length: 20 }).map((_, i) => ( +
Item {i}
+ ))} +
+ +
+ ), +} diff --git a/packages/app/src/ui/atoms/scroll-area/ScrollArea.tsx b/packages/app/src/ui/atoms/scroll-area/ScrollArea.tsx new file mode 100644 index 000000000..1256b66bf --- /dev/null +++ b/packages/app/src/ui/atoms/scroll-area/ScrollArea.tsx @@ -0,0 +1,36 @@ +import * as ScrollAreaPrimitive from '@radix-ui/react-scroll-area' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +export const ScrollArea = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + +)) +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName + +export const ScrollBar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, orientation = 'vertical', ...props }, ref) => ( + + + +)) +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName diff --git a/packages/app/src/ui/atoms/select/Select.stories.tsx b/packages/app/src/ui/atoms/select/Select.stories.tsx new file mode 100644 index 000000000..195e59726 --- /dev/null +++ b/packages/app/src/ui/atoms/select/Select.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from './Select' + +function Selector() { + return ( + + ) +} + +const meta: Meta = { + title: 'Components/Atoms/Select', + component: Selector, +} + +export default meta +type Story = StoryObj + +export const Basic: Story = { + name: 'Default', +} diff --git a/packages/app/src/ui/atoms/select/Select.tsx b/packages/app/src/ui/atoms/select/Select.tsx new file mode 100644 index 000000000..c3ed9064a --- /dev/null +++ b/packages/app/src/ui/atoms/select/Select.tsx @@ -0,0 +1,100 @@ +import * as SelectPrimitive from '@radix-ui/react-select' +import * as React from 'react' + +import { assets } from '@/ui/assets' +import { cn } from '@/ui/utils/style' + +const Select = SelectPrimitive.Root + +const SelectGroup = SelectPrimitive.Group + +const SelectValue = SelectPrimitive.Value + +const SelectTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + + + + +)) +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName + +const SelectContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, position = 'popper', ...props }, ref) => ( + + + + {children} + + + +)) +SelectContent.displayName = SelectPrimitive.Content.displayName + +const SelectLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectLabel.displayName = SelectPrimitive.Label.displayName + +const SelectItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {children} + +)) +SelectItem.displayName = SelectPrimitive.Item.displayName + +const SelectSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +SelectSeparator.displayName = SelectPrimitive.Separator.displayName + +export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } diff --git a/packages/app/src/ui/atoms/skeleton/Skeleton.tsx b/packages/app/src/ui/atoms/skeleton/Skeleton.tsx new file mode 100644 index 000000000..0ce2e76c4 --- /dev/null +++ b/packages/app/src/ui/atoms/skeleton/Skeleton.tsx @@ -0,0 +1,5 @@ +import { cn } from '@/ui/utils/style' + +export function Skeleton({ className, ...props }: React.HTMLAttributes) { + return
+} diff --git a/packages/app/src/ui/atoms/switch/Switch.tsx b/packages/app/src/ui/atoms/switch/Switch.tsx new file mode 100644 index 000000000..442c063bb --- /dev/null +++ b/packages/app/src/ui/atoms/switch/Switch.tsx @@ -0,0 +1,38 @@ +import * as SwitchPrimitives from '@radix-ui/react-switch' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/packages/app/src/ui/atoms/switch/Swtich.stories.tsx b/packages/app/src/ui/atoms/switch/Swtich.stories.tsx new file mode 100644 index 000000000..cf8eca8ae --- /dev/null +++ b/packages/app/src/ui/atoms/switch/Swtich.stories.tsx @@ -0,0 +1,20 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { Switch } from './Switch' + +const meta: Meta = { + title: 'Components/Atoms/Switch', +} + +export default meta +type Story = StoryObj + +export const SwitchOff: Story = { + name: 'Switch off', + render: () => , +} + +export const SwitchOn: Story = { + name: 'Switch on', + render: () => , +} diff --git a/packages/app/src/ui/atoms/table/Table.stories.tsx b/packages/app/src/ui/atoms/table/Table.stories.tsx new file mode 100644 index 000000000..f0c759754 --- /dev/null +++ b/packages/app/src/ui/atoms/table/Table.stories.tsx @@ -0,0 +1,85 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from './Table' + +const invoices = [ + { + invoice: 'INV001', + paymentStatus: 'Paid', + totalAmount: '$250.00', + paymentMethod: 'Credit Card', + }, + { + invoice: 'INV002', + paymentStatus: 'Pending', + totalAmount: '$150.00', + paymentMethod: 'PayPal', + }, + { + invoice: 'INV003', + paymentStatus: 'Unpaid', + totalAmount: '$350.00', + paymentMethod: 'Bank Transfer', + }, + { + invoice: 'INV004', + paymentStatus: 'Paid', + totalAmount: '$450.00', + paymentMethod: 'Credit Card', + }, + { + invoice: 'INV005', + paymentStatus: 'Paid', + totalAmount: '$550.00', + paymentMethod: 'PayPal', + }, + { + invoice: 'INV006', + paymentStatus: 'Pending', + totalAmount: '$200.00', + paymentMethod: 'Bank Transfer', + }, + { + invoice: 'INV007', + paymentStatus: 'Unpaid', + totalAmount: '$300.00', + paymentMethod: 'Credit Card', + }, +] + +function TableDemo() { + return ( + + A list of your recent invoices. + + + Invoice + Status + Method + Amount + + + + {invoices.map((invoice) => ( + + {invoice.invoice} + {invoice.paymentStatus} + {invoice.paymentMethod} + {invoice.totalAmount} + + ))} + +
+ ) +} + +const meta: Meta = { + title: 'Components/Atoms/Table', +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => , +} diff --git a/packages/app/src/ui/atoms/table/Table.tsx b/packages/app/src/ui/atoms/table/Table.tsx new file mode 100644 index 000000000..382e7ad22 --- /dev/null +++ b/packages/app/src/ui/atoms/table/Table.tsx @@ -0,0 +1,66 @@ +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +const Table = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +Table.displayName = 'Table' + +const TableHeader = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +TableHeader.displayName = 'TableHeader' + +const TableBody = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +TableBody.displayName = 'TableBody' + +const TableFooter = React.forwardRef>( + ({ className, ...props }, ref) => , +) +TableFooter.displayName = 'TableFooter' + +const TableRow = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +TableRow.displayName = 'TableRow' + +const TableHead = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +TableHead.displayName = 'TableHead' + +const TableCell = React.forwardRef>( + ({ className, ...props }, ref) => ( + + ), +) +TableCell.displayName = 'TableCell' + +const TableCaption = React.forwardRef>( + ({ className, ...props }, ref) => ( +
+ ), +) +TableCaption.displayName = 'TableCaption' + +export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } diff --git a/packages/app/src/ui/atoms/tabs/Tabs.stories.tsx b/packages/app/src/ui/atoms/tabs/Tabs.stories.tsx new file mode 100644 index 000000000..a8b6f9e5f --- /dev/null +++ b/packages/app/src/ui/atoms/tabs/Tabs.stories.tsx @@ -0,0 +1,31 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { Tabs, TabsContent, TabsList, TabsTrigger } from './Tabs' + +const meta: Meta = { + title: 'Components/Atoms/Tabs', + decorators: [WithClassname('max-w-6xl')], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + render: () => { + return ( + + + First tab + Second tab + + Content of the first tab. + Content of the second tab. + + ) + }, +} + +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/ui/atoms/tabs/Tabs.tsx b/packages/app/src/ui/atoms/tabs/Tabs.tsx new file mode 100644 index 000000000..79015a3a2 --- /dev/null +++ b/packages/app/src/ui/atoms/tabs/Tabs.tsx @@ -0,0 +1,55 @@ +import * as TabsPrimitive from '@radix-ui/react-tabs' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + *]:data-[state=active]:opacity-100', // shows the blue bar for the active tab + className, + )} + {...props} + > + {children} +
+ +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsContent, TabsList, TabsTrigger } diff --git a/packages/app/src/ui/atoms/token-icon/TokenIcon.stories.ts b/packages/app/src/ui/atoms/token-icon/TokenIcon.stories.ts new file mode 100644 index 000000000..c0046604c --- /dev/null +++ b/packages/app/src/ui/atoms/token-icon/TokenIcon.stories.ts @@ -0,0 +1,38 @@ +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' + +import { TokenIcon } from './TokenIcon' + +const meta: Meta = { + title: 'Components/Atoms/TokenIcon', + component: TokenIcon, + args: { + className: 'h-12 w-12', + }, +} + +export default meta +type Story = StoryObj + +export const DAI: Story = { + args: { + token: tokens['DAI'], + }, +} +export const aDAI: Story = { + name: 'aDAI', + args: { + token: tokens['DAI'].createAToken(tokens['DAI'].address), + }, +} +export const WETH: Story = { + args: { + token: tokens['WETH'], + }, +} +export const aWETH: Story = { + name: 'aWETH', + args: { + token: tokens['WETH'].createAToken(tokens['WETH'].address), + }, +} diff --git a/packages/app/src/ui/atoms/token-icon/TokenIcon.tsx b/packages/app/src/ui/atoms/token-icon/TokenIcon.tsx new file mode 100644 index 000000000..4fe7221d6 --- /dev/null +++ b/packages/app/src/ui/atoms/token-icon/TokenIcon.tsx @@ -0,0 +1,47 @@ +import { forwardRef, SVGProps } from 'react' + +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { getTokenImage } from '@/ui/assets' + +export interface TokenIconProps extends SVGProps { + token: Token +} +export const TokenIcon = forwardRef(({ token, ...rest }, ref) => { + if (token.isAToken) { + const symbol = TokenSymbol(token.symbol.slice(1)) + + return + } + + const imageHref = getTokenImage(token.symbol) + + return ( + + + + + + + + + ) +}) +TokenIcon.displayName = 'TokenIcon' + +interface ATokenIconProps extends SVGProps { + symbol: TokenSymbol +} +const ATokenIcon = forwardRef(({ symbol, ...rest }, ref) => { + const imageHref = getTokenImage(symbol) + + return ( + + + + + + + ) +}) +ATokenIcon.displayName = 'ATokenIcon' diff --git a/packages/app/src/ui/atoms/tooltip/Tooltip.stories.tsx b/packages/app/src/ui/atoms/tooltip/Tooltip.stories.tsx new file mode 100644 index 000000000..5c8422826 --- /dev/null +++ b/packages/app/src/ui/atoms/tooltip/Tooltip.stories.tsx @@ -0,0 +1,71 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta } from '@storybook/react' +import { getHoveredStory } from '@storybook/utils' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { assets } from '@/ui/assets' + +import { Tooltip, TooltipContentLong, TooltipContentShort, TooltipTrigger } from './Tooltip' +import { TooltipContentLayout } from './TooltipContentLayout' + +const meta: Meta = { + title: 'Components/Atoms/Tooltip', + decorators: [WithTooltipProvider()], +} + +export default meta + +export const Default = getHoveredStory( + { + name: 'Default', + render: () => ( + + Hover me + Tooltip content + + ), + }, + 'button', +) + +export const LengthyText = getHoveredStory( + { + name: 'Lengthy Text', + render: () => ( + + Hover me + + Lorem ipsum dolor sit amet, consectetur adipiscing elit. Suspendisse varius enim in eros elementum tristique. + + + ), + }, + 'button', +) + +export const LengthyTextMobile = getMobileStory(LengthyText) +export const LengthyTextTablet = getTabletStory(LengthyText) + +export const LongContent = getHoveredStory( + { + name: 'Long Content', + render: () => ( + + Hover me + + + + + Paused asset + + + + This asset is planned to be offboarded due to a Spark community decision. + + + + + ), + }, + 'button', +) diff --git a/packages/app/src/ui/atoms/tooltip/Tooltip.tsx b/packages/app/src/ui/atoms/tooltip/Tooltip.tsx new file mode 100644 index 000000000..c64d2369f --- /dev/null +++ b/packages/app/src/ui/atoms/tooltip/Tooltip.tsx @@ -0,0 +1,62 @@ +import * as TooltipPrimitive from '@radix-ui/react-tooltip' +import * as React from 'react' + +import { cn } from '@/ui/utils/style' + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const baseTooltipContentClassList = cn( + 'z-50 overflow-hidden', + 'outline outline-1 outline-black/5', + 'shadow-tooltip rounded-md bg-popover', +) + +const tooltipContentShortClassList = cn( + baseTooltipContentClassList, + 'text-sm text-slate-500 max-w-[80vw] sm:max-w-[32ch] px-3 py-1.5 space-y-2', +) +const tooltipContentLongClassList = cn(baseTooltipContentClassList, 'px-5 py-4') + +const TooltipContentShort = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, children, ...props }, ref) => ( + + e.stopPropagation()} + {...props} + > + {children} + + + +)) +TooltipContentShort.displayName = 'TooltipContentShort' + +const TooltipContentLong = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, children, ...props }, ref) => ( + + e.stopPropagation()} + {...props} + > + {children} + + + +)) +TooltipContentLong.displayName = 'TooltipContentLong' + +export { Tooltip, TooltipContentLong, TooltipContentShort, TooltipProvider, TooltipTrigger } diff --git a/packages/app/src/ui/atoms/tooltip/TooltipContentLayout.tsx b/packages/app/src/ui/atoms/tooltip/TooltipContentLayout.tsx new file mode 100644 index 000000000..4db1db1bc --- /dev/null +++ b/packages/app/src/ui/atoms/tooltip/TooltipContentLayout.tsx @@ -0,0 +1,28 @@ +interface ChildrenProps { + children: React.ReactNode +} + +export function TooltipContentLayout({ children }: ChildrenProps) { + return
{children}
+} + +function Header({ children }: ChildrenProps) { + return
{children}
+} + +function Icon({ src }: { src: string }) { + return +} + +function Title({ children }: ChildrenProps) { + return

{children}

+} + +function Body({ children }: ChildrenProps) { + return

{children}

+} + +TooltipContentLayout.Header = Header +TooltipContentLayout.Icon = Icon +TooltipContentLayout.Title = Title +TooltipContentLayout.Body = Body diff --git a/packages/app/src/ui/atoms/top-banner/TopBanner.stories.ts b/packages/app/src/ui/atoms/top-banner/TopBanner.stories.ts new file mode 100644 index 000000000..bde8a286e --- /dev/null +++ b/packages/app/src/ui/atoms/top-banner/TopBanner.stories.ts @@ -0,0 +1,19 @@ +import { WithClassname } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' +import { withRouter } from 'storybook-addon-react-router-v6' + +import { TopBanner } from './TopBanner' + +const meta: Meta = { + title: 'Components/Atoms/TopBanner', + component: TopBanner, + decorators: [WithClassname('w-full'), withRouter], +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile: Story = getMobileStory(Desktop) +export const Tablet: Story = getTabletStory(Desktop) diff --git a/packages/app/src/ui/atoms/top-banner/TopBanner.tsx b/packages/app/src/ui/atoms/top-banner/TopBanner.tsx new file mode 100644 index 000000000..45bb30dfc --- /dev/null +++ b/packages/app/src/ui/atoms/top-banner/TopBanner.tsx @@ -0,0 +1,16 @@ +import { Link } from '../link/Link' + +export function TopBanner() { + return ( +
+ Welcome to the new Spark App!{' '} + + Read the announcement + + . Old app is available under{' '} + + legacy-app.spark.fi + +
+ ) +} diff --git a/packages/app/src/ui/atoms/typography/Typography.stories.tsx b/packages/app/src/ui/atoms/typography/Typography.stories.tsx new file mode 100644 index 000000000..c435369a0 --- /dev/null +++ b/packages/app/src/ui/atoms/typography/Typography.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { Typography } from './Typography' + +const meta: Meta = { + title: 'Components/Atoms/Typography', + component: Typography, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + children: 'Default', + }, +} + +export const Paragraph: Story = { + name: 'Paragraph', + args: { + variant: 'p', + children: 'Paragraph', + }, +} + +export const Span: Story = { + name: 'Span', + args: { + variant: 'span', + children: 'Span', + }, +} + +export const H1: Story = { + name: 'H1', + args: { + variant: 'h1', + children: 'H1', + }, +} + +export const H2: Story = { + name: 'H2', + args: { + variant: 'h2', + children: 'H2', + }, +} + +export const H3: Story = { + name: 'H3', + args: { + variant: 'h3', + children: 'H3', + }, +} + +export const H4: Story = { + name: 'H4', + args: { + variant: 'h4', + children: 'H4', + }, +} + +export const Prompt: Story = { + name: 'Prompt', + args: { + variant: 'prompt', + children: 'Prompt', + }, +} diff --git a/packages/app/src/ui/atoms/typography/Typography.tsx b/packages/app/src/ui/atoms/typography/Typography.tsx new file mode 100644 index 000000000..5fcf704b4 --- /dev/null +++ b/packages/app/src/ui/atoms/typography/Typography.tsx @@ -0,0 +1,41 @@ +import { cva, VariantProps } from 'class-variance-authority' +import React from 'react' + +import { cn } from '@/ui/utils/style' + +export type BaseElement = 'h1' | 'h2' | 'h3' | 'h4' | 'p' | 'span' + +const typographyVariants = cva('text-primary', { + variants: { + variant: { + h1: 'text-5xl font-semibold leading-none tracking-tight', + h2: 'text-3xl font-semibold leading-none tracking-tight', + h3: 'text-2xl font-semibold leading-none tracking-tight', + h4: 'text-base font-semibold leading-none tracking-tight', + p: 'text-base font-normal', + span: 'text-base font-normal', + prompt: 'text-prompt-foreground text-xs leading-none tracking-tight', + }, + }, + defaultVariants: { + variant: 'p', + }, +}) + +interface TypographyProps extends React.HTMLAttributes, VariantProps { + element?: BaseElement +} + +function variantToElement(variant: VariantProps['variant']): BaseElement { + if (variant === 'prompt') return 'span' + return variant ?? 'p' +} + +const Typography = React.forwardRef(({ element, variant, className, ...props }, ref) => { + const Element = element ?? variantToElement(variant) + + return +}) +Typography.displayName = 'Typography' + +export { Typography, type TypographyProps } diff --git a/packages/app/src/ui/constants/links.ts b/packages/app/src/ui/constants/links.ts new file mode 100644 index 000000000..933923363 --- /dev/null +++ b/packages/app/src/ui/constants/links.ts @@ -0,0 +1,18 @@ +export const links = { + docs: { + eMode: 'https://docs.spark.fi/defi-infrastructure/sparklend/spark-lend-features#high-efficiency-mode-e-mode', + isolationMode: 'https://docs.spark.fi/defi-infrastructure/sparklend/spark-lend-features#isolation-mode', + siloedMode: 'https://docs.spark.fi/defi-infrastructure/sparklend#siloed-borrowing', + isolationModeBorrowingPower: + 'https://docs.spark.fi/defi-infrastructure/sparklend/spark-lend-features#how-does-isolation-mode-affect-my-borrowing-power', + supplyBorrowCaps: 'https://docs.spark.fi/defi-infrastructure/sparklend#supply-and-borrow-caps', + dsr: 'https://docs.spark.fi/defi-infrastructure/sdai-overview', + healthFactor: 'https://docs.spark.fi/defi-infrastructure/sparklend/liquidations', + supplying: 'https://docs.spark.fi/defi-infrastructure/sparklend/supplying-and-earning', + borrowing: 'https://docs.spark.fi/defi-infrastructure/sparklend/borrowing', + }, + sparkAirdropFormula: 'https://forum.makerdao.com/t/proposed-spark-pre-farming-airdrop-formula/21786', + aaveTechnicalPaper: 'https://github.com/aave/aave-v3-core/blob/master/techpaper/Aave_V3_Technical_Paper.pdf', + termsOfUse: 'https://spark.fi/terms-of-use.html', + privacyPolicy: 'https://spark.fi/privacy-policy.html', +} as const diff --git a/packages/app/src/ui/layouts/AppLayout.tsx b/packages/app/src/ui/layouts/AppLayout.tsx new file mode 100644 index 000000000..4a50e501c --- /dev/null +++ b/packages/app/src/ui/layouts/AppLayout.tsx @@ -0,0 +1,23 @@ +import { cx } from 'class-variance-authority' +import { useState } from 'react' + +import { Navbar } from '@/features/navbar/Navbar' +import { cn } from '@/ui/utils/style' + +import { TopBanner } from '../atoms/top-banner/TopBanner' + +interface AppLayoutProps { + children: React.ReactNode +} + +export function AppLayout({ children }: AppLayoutProps) { + const [mobileMenuCollapsed, setMobileMenuCollapsed] = useState(true) + + return ( +
+ {import.meta.env.VITE_FEATURE_TOP_BANNER === '1' && } + +
{children}
+
+ ) +} diff --git a/packages/app/src/ui/layouts/ErrorLayout.tsx b/packages/app/src/ui/layouts/ErrorLayout.tsx new file mode 100644 index 000000000..e3045aa1c --- /dev/null +++ b/packages/app/src/ui/layouts/ErrorLayout.tsx @@ -0,0 +1,7 @@ +interface ErrorLayoutProps { + children: React.ReactNode +} + +export function ErrorLayout({ children }: ErrorLayoutProps) { + return
{children}
+} diff --git a/packages/app/src/ui/layouts/FallbackLayout.tsx b/packages/app/src/ui/layouts/FallbackLayout.tsx new file mode 100644 index 000000000..42f81eab1 --- /dev/null +++ b/packages/app/src/ui/layouts/FallbackLayout.tsx @@ -0,0 +1,13 @@ +import { Loader2 } from 'lucide-react' + +export function FallbackLayout() { + if (import.meta.env.MODE === 'development') { + throw new Error('Missing Suspense fallback! Did you forget about skeletons?') + } + + return ( +
+ +
+ ) +} diff --git a/packages/app/src/ui/layouts/PageLayout.tsx b/packages/app/src/ui/layouts/PageLayout.tsx new file mode 100644 index 000000000..f7317a1a7 --- /dev/null +++ b/packages/app/src/ui/layouts/PageLayout.tsx @@ -0,0 +1,12 @@ +import { cn } from '../utils/style' + +export interface PageLayoutProps { + children: React.ReactNode + className?: string +} + +export function PageLayout({ children, className }: PageLayoutProps) { + return ( +
{children}
+ ) +} diff --git a/packages/app/src/ui/molecules/action-button/ActionButton.stories.tsx b/packages/app/src/ui/molecules/action-button/ActionButton.stories.tsx new file mode 100644 index 000000000..6d9f43076 --- /dev/null +++ b/packages/app/src/ui/molecules/action-button/ActionButton.stories.tsx @@ -0,0 +1,27 @@ +import type { Meta, StoryObj } from '@storybook/react' + +import { ActionButton } from './ActionButton' + +const meta: Meta = { + title: 'Components/Molecules/ActionButton', + component: ActionButton, +} + +export default meta +type Story = StoryObj + +export const LoadingMd: Story = { + name: 'Loading', + args: { + children: 'Loading', + isLoading: true, + }, +} + +export const DoneMd: Story = { + name: 'Done', + args: { + children: 'Done', + isDone: true, + }, +} diff --git a/packages/app/src/ui/molecules/action-button/ActionButton.tsx b/packages/app/src/ui/molecules/action-button/ActionButton.tsx new file mode 100644 index 000000000..dafafb566 --- /dev/null +++ b/packages/app/src/ui/molecules/action-button/ActionButton.tsx @@ -0,0 +1,20 @@ +import { assets } from '@/ui/assets' + +import { Button, ButtonProps } from '../../atoms/button/Button' + +export interface ActionButtonProps extends ButtonProps { + isLoading?: boolean + isDone?: boolean +} + +export function ActionButton({ isLoading, isDone, children, ...props }: ActionButtonProps) { + const disabled = props.disabled || isLoading + + return ( + + ) +} diff --git a/packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.stories.ts b/packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.stories.ts new file mode 100644 index 000000000..eac34963b --- /dev/null +++ b/packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.stories.ts @@ -0,0 +1,27 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { ApyTooltip } from './ApyTooltip' + +const meta: Meta = { + title: 'Components/Molecules/ApyTooltip', + component: ApyTooltip, + decorators: [WithTooltipProvider()], + args: { + variant: 'supply', + children: 'Deposit APY', + }, +} + +export default meta + +type Story = StoryObj + +export const SupplyDesktop: Story = {} +export const SupplyMobile = getMobileStory(SupplyDesktop) +export const SupplyTablet = getTabletStory(SupplyDesktop) + +export const BorrowDesktop: Story = { args: { variant: 'borrow', children: 'Borrow APY' } } +export const BorrowMobile = getMobileStory(BorrowDesktop) +export const BorrowTablet = getTabletStory(BorrowDesktop) diff --git a/packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.tsx b/packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.tsx new file mode 100644 index 000000000..ac28e77a7 --- /dev/null +++ b/packages/app/src/ui/molecules/apy-tooltip/ApyTooltip.tsx @@ -0,0 +1,60 @@ +import { ReactNode } from 'react' + +import { Link } from '@/ui/atoms/link/Link' +import { links } from '@/ui/constants/links' + +import { Info } from '../info/Info' + +interface ApyTooltipProps { + children: ReactNode + variant: 'supply' | 'borrow' +} + +export function ApyTooltip({ children, variant }: ApyTooltipProps) { + return ( +
+ {children} + {variantToText[variant]} +
+ ) +} + +const variantToText: Record = { + supply: ( + <> +

+ The APY for supplying assets on Spark is a dynamic metric that adjusts based on the utilization rate of each + reserve pool. +

+

+ As the utilization rate fluctuates, the interest rates offered to suppliers also change accordingly. This means + that the APY for supplying assets is responsive to market conditions and can vary based on the demand for + borrowing within each pool. +

+

+ + Learn more + + . +

+ + ), + borrow: ( + <> +

+ The interest rate for borrowing on Spark is a live metric that adjusts with each block confirmation, reflecting + the most recent data based on the token pool's utilization rate. +

+

+ This, in turn, affects the APY for borrowers, as it influences the nominal interest rate applied to their loans. +

+

This doesn't apply to DAI as Maker Governance defines the borrowing rate.

+

+ + Learn more + + . +

+ + ), +} diff --git a/packages/app/src/ui/molecules/asset-input/AssetInput.stories.tsx b/packages/app/src/ui/molecules/asset-input/AssetInput.stories.tsx new file mode 100644 index 000000000..df1acec6d --- /dev/null +++ b/packages/app/src/ui/molecules/asset-input/AssetInput.stories.tsx @@ -0,0 +1,154 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' + +import { AssetInput } from './AssetInput' + +const meta: Meta = { + title: 'Components/Molecules/AssetInput', + component: (props) => ( +
+ +
+ ), +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const WithUSD: Story = { + name: 'With USD', + args: { + value: '100', + token: tokens['ETH'], + }, +} + +export const WithMaxButton: Story = { + name: 'With max', + args: { + setMax: () => {}, + }, +} + +export const WithMaxAndBalance: Story = { + name: 'With max and balance', + args: { + token: tokens['ETH'], + setMax: () => {}, + balance: NormalizedUnitNumber(200), + }, +} + +export const WithMaxAndZeroBalance: Story = { + name: 'With max and zero balance', + args: { + token: tokens['ETH'], + setMax: () => {}, + balance: NormalizedUnitNumber(0), + }, +} + +export const WithRemoveButton: Story = { + name: 'With remove', + args: { + onRemove: () => {}, + }, +} + +export const WithRemoveButtonAndZeroBalance: Story = { + name: 'With remove and zero balance', + args: { + token: tokens['ETH'], + onRemove: () => {}, + balance: NormalizedUnitNumber(0), + }, +} + +export const WithAll: Story = { + name: 'With all enabled', + args: { + token: tokens['ETH'], + setMax: () => {}, + onRemove: () => {}, + balance: NormalizedUnitNumber(200), + value: '100', + }, +} + +export const WithBigNumber: Story = { + name: 'With big number', + args: { + value: '123456789012345678123456789012345678', + }, +} + +export const Responsive: Story = { + name: 'Responsive', + args: { + value: '123456789012345678123456789012345678', + setMax: () => {}, + onRemove: () => {}, + }, +} + +export const MinimalSize: Story = { + name: 'MinimalSize', + args: { + value: '123456789', + setMax: () => {}, + onRemove: () => {}, + }, + render: (args) => ( +
+ +
+ ), +} +export const PredefinedSize: Story = { + name: 'PredefinedSize', + args: { + value: '1234567891234567', + setMax: () => {}, + onRemove: () => {}, + className: 'w-64', + }, +} + +export const Error: Story = { + name: 'Error', + args: { + value: '1300', + error: 'Here is a quick explainer why this risk is important and what it is all about.', + setMax: () => {}, + onRemove: () => {}, + }, +} + +export const usdVariant: Story = { + name: 'USD variant', + args: { + token: tokens.USDC, + setMax: () => {}, + balance: NormalizedUnitNumber(200), + value: '100', + variant: 'usd', + }, +} + +export const usdVariantWithWalletLabel: Story = { + name: 'USD variant with wallet label', + args: { + token: tokens.USDC, + setMax: () => {}, + balance: NormalizedUnitNumber(200), + value: '100', + variant: 'usd', + walletIconLabel: 'Savings', + }, +} diff --git a/packages/app/src/ui/molecules/asset-input/AssetInput.tsx b/packages/app/src/ui/molecules/asset-input/AssetInput.tsx new file mode 100644 index 000000000..7f14fc191 --- /dev/null +++ b/packages/app/src/ui/molecules/asset-input/AssetInput.tsx @@ -0,0 +1,136 @@ +import { X } from 'lucide-react' +import { forwardRef } from 'react' +import invariant from 'tiny-invariant' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { assets as uiAssets } from '@/ui/assets' +import { Button } from '@/ui/atoms/button/Button' +import { type InputProps } from '@/ui/atoms/input/Input' +import { Typography } from '@/ui/atoms/typography/Typography' +import { cn } from '@/ui/utils/style' +import { testIds } from '@/ui/utils/testIds' +import { parseBigNumber } from '@/utils/bigNumber' + +export type AssetInputProps = { + token?: Token + className?: string | undefined + onRemove?: () => void + setMax?: () => void + isMaxSelected?: boolean + balance?: NormalizedUnitNumber + disabled?: boolean + error?: string + value: string + variant?: 'usd' | 'crypto' + walletIconLabel?: string +} & InputProps + +export const AssetInput = forwardRef( + ( + { + token, + className, + onRemove, + setMax, + balance, + disabled, + error, + value, + onChange, + variant = 'crypto', + walletIconLabel, + isMaxSelected, + ...rest + }, + ref, + ) => { + invariant(!(balance && !token), 'token should be defined if balance is defined') + + return ( +
+
+
+ { + e.target.value = e.target.value.replace(/,/g, '.') + const value = e.target.value + if (!value || (decimalNumberRegex.test(value) && (value.split('.')[1]?.length ?? 0) <= 6)) { + onChange?.(e) + } + }} + /> + {token && ( + + {token.formatUSD(NormalizedUnitNumber(parseBigNumber(value, 0)))} + + )} +
+
+
+ {setMax && ( + + )} + {balance && ( +
+ {walletIconLabel && ( + + {walletIconLabel} + + )} + wallet + + {variant === 'crypto' + ? token!.format(balance, { style: 'compact' }) + : token?.formatUSD(balance, { compact: true })} + +
+ )} +
+ {onRemove && ( + + )} +
+
+ {error && ( + + {error} + + )} +
+ ) + }, +) + +const decimalNumberRegex = /^\d+\.?\d*$/ + +AssetInput.displayName = 'AssetInput' diff --git a/packages/app/src/ui/molecules/asset-selector/AssetSelector.stories.tsx b/packages/app/src/ui/molecules/asset-selector/AssetSelector.stories.tsx new file mode 100644 index 000000000..69df5fb7d --- /dev/null +++ b/packages/app/src/ui/molecules/asset-selector/AssetSelector.stories.tsx @@ -0,0 +1,68 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import BigNumber from 'bignumber.js' + +import { AssetSelector as AssetSelectorComponent } from './AssetSelector' + +function AssetSelector({ open, withAmount }: { open: boolean; withAmount: boolean }) { + const assets = [ + { token: tokens['ETH'], amount: new BigNumber('0.0001') }, + { token: tokens['DAI'], amount: new BigNumber('0.001') }, + { token: tokens['USDC'], amount: new BigNumber('100') }, + { token: tokens['USDT'], amount: new BigNumber('1000') }, + { token: tokens['GNO'], amount: new BigNumber('1000000') }, + { token: tokens['rETH'], amount: new BigNumber('1000000000') }, + { token: tokens['WBTC'], amount: new BigNumber('1000000000000') }, + { token: tokens['WETH'], amount: new BigNumber('100000000000000000') }, + ] + return ( + {}} + selectedAsset={tokens['ETH']} + assets={withAmount ? assets : assets.map((a) => ({ token: a.token }))} + open={open} + /> + ) +} + +const meta: Meta = { + title: 'Components/Molecules/AssetSelector', + component: AssetSelector, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const SelectorOpenWithAmount: Story = { + name: 'Open with amount', + args: { + open: true, + withAmount: true, + }, +} + +export const SelectorOpenWithoutAmount: Story = { + name: 'Open without amount', + args: { + open: true, + withAmount: false, + }, +} + +export const OneAsset: Story = { + name: 'One asset', + render: () => { + return ( + {}} + selectedAsset={tokens['DAI']} + assets={[{ token: tokens['DAI'] }]} + /> + ) + }, + args: {}, +} diff --git a/packages/app/src/ui/molecules/asset-selector/AssetSelector.tsx b/packages/app/src/ui/molecules/asset-selector/AssetSelector.tsx new file mode 100644 index 000000000..c0c692599 --- /dev/null +++ b/packages/app/src/ui/molecules/asset-selector/AssetSelector.tsx @@ -0,0 +1,81 @@ +import React from 'react' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { assets as uiAssets } from '@/ui/assets' +import { FormControl } from '@/ui/atoms/form/Form' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@/ui/atoms/select/Select' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' +import { Typography } from '@/ui/atoms/typography/Typography' +import { cn } from '@/ui/utils/style' +import { testIds } from '@/ui/utils/testIds' + +interface AssetSelectorProps { + assets: { token: Token; balance?: NormalizedUnitNumber }[] + selectedAsset?: Token + setSelectedAsset?: (newSymbol: TokenSymbol) => void + withFormControl?: boolean + disabled?: boolean + open?: boolean +} + +export function AssetSelector({ + assets, + selectedAsset, + setSelectedAsset, + withFormControl, + disabled, + open, +}: AssetSelectorProps) { + const Wrapper = withFormControl ? FormControl : React.Fragment + + if ((assets.length === 1 && selectedAsset?.symbol === assets[0]!.token.symbol) || assets.length === 0) { + return ( +
+ {selectedAsset ? ( + <> + + {selectedAsset.symbol} + + ) : ( + '-' + )} +
+ ) + } + + return ( + + ) +} diff --git a/packages/app/src/ui/molecules/confetti/Confetti.tsx b/packages/app/src/ui/molecules/confetti/Confetti.tsx new file mode 100644 index 000000000..d108f9756 --- /dev/null +++ b/packages/app/src/ui/molecules/confetti/Confetti.tsx @@ -0,0 +1,17 @@ +import * as Portal from '@radix-ui/react-portal' +import ReactConfetti, { Props as ReactConfettiProps } from 'react-confetti' + +import { useWindowSize } from '@/ui/utils/useWindowSize' + +// @note: Without explicitly setting dimensions of ReactConfetti component, +// canvas size will default to initial window size and won't update on resize +// which can cause document to be stretched after layout changes. +// Rendered in portal to be independent of parent layout +export function Confetti(props: ReactConfettiProps) { + const { width, height } = useWindowSize() + return ( + + + + ) +} diff --git a/packages/app/src/ui/molecules/data-table/DataTable.stories.tsx b/packages/app/src/ui/molecules/data-table/DataTable.stories.tsx new file mode 100644 index 000000000..a0b0b196a --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/DataTable.stories.tsx @@ -0,0 +1,89 @@ +import { Meta, StoryObj } from '@storybook/react' + +import { DataTable } from './DataTable' + +export type Payment = { + id: string + amount: number + status: 'pending' | 'processing' | 'success' | 'failed' + email: string +} + +const data: Payment[] = [ + { + id: 'm5gr84i9', + amount: 316, + status: 'success', + email: 'ken99@yahoo.com', + }, + { + id: '3u1reuv4', + amount: 242, + status: 'success', + email: 'Abe45@gmail.com', + }, + { + id: 'derv1ws0', + amount: 837, + status: 'processing', + email: 'Monserrat44@gmail.com', + }, + { + id: '5kma53ae', + amount: 874, + status: 'success', + email: 'Silas22@gmail.com', + }, + { + id: 'bhqecj4p', + amount: 721, + status: 'failed', + email: 'carmella@hotmail.com', + }, +] + +const meta: Meta = { + title: 'Components/Molecules/DataTable', + component: DataTable, + args: { + columnDef: { + id: { + header: 'ID', + renderCell: ({ id }) => id, + }, + amount: { + header: 'Amount', + sortable: true, + headerAlign: 'center', + sortingFn: (a, b) => a.original.amount - b.original.amount, + renderCell: ({ amount }) =>
{amount}
, + }, + status: { + header: 'Status', + headerAlign: 'center', + renderCell: ({ status }) =>
{status}
, + }, + email: { + header: 'Email', + headerAlign: 'right', + renderCell: ({ email }) =>
{email}
, + }, + }, + data, + gridTemplateColumnsClassName: 'grid-cols-4', + }, +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const WithoutTableHeader: Story = { + name: 'Without Table Header', + args: { + hideTableHeader: true, + }, +} diff --git a/packages/app/src/ui/molecules/data-table/DataTable.tsx b/packages/app/src/ui/molecules/data-table/DataTable.tsx new file mode 100644 index 000000000..b5c2d68be --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/DataTable.tsx @@ -0,0 +1,136 @@ +import { + ColumnDef, + flexRender, + getCoreRowModel, + getSortedRowModel, + SortingState, + useReactTable, +} from '@tanstack/react-table' +import * as React from 'react' +import { useMemo } from 'react' + +import { LinkDecorator } from '@/ui/atoms/link-decorator/LinkDecorator' +import { ScrollArea } from '@/ui/atoms/scroll-area/ScrollArea' +import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from '@/ui/atoms/table/Table' +import { cn } from '@/ui/utils/style' + +import { ColumnHeader } from './components/ColumnHeader' +import { ColumnDefinition } from './types' + +export type RowClickOptions = { destination: string; external?: boolean } +type RowType = { [k: string]: any; rowClickOptions?: RowClickOptions } + +export interface DataTableProps { + columnDef: { + [key: string]: ColumnDefinition + } + scroll?: { + height: number + } + hideTableHeader?: boolean + gridTemplateColumnsClassName?: string + data: T[] + footer?: React.ReactNode +} + +/** + * @note: Using columnDef as a dependency for columns memoizer will cause the table to re-mount on every re-render. + * If this is the problem, take care of memoizing columnDef outside of the component, so react knows that + * passed object is the same between renders, which will prevent unnecessary re-mount. + */ +export function DataTable({ + columnDef, + data, + scroll, + hideTableHeader = false, + gridTemplateColumnsClassName, + footer, +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]) + + const columns: ColumnDef[] = useMemo(() => { + return Object.keys(columnDef).map>((key) => { + const definition = columnDef[key] + return { + accessorKey: key, + sortingFn: definition?.sortingFn, + header: ({ column }) => , + cell: ({ row }) => definition?.renderCell(row.original), + } + }) + }, [columnDef]) + + const table = useReactTable({ + data, + columns, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + state: { + sorting, + }, + }) + + const Wrapper = scroll ? ScrollWrapperWithHeight({ height: scroll.height }) : React.Fragment + + return ( +
+ + + {!hideTableHeader && ( + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} + + ) + })} + + ))} + + )} + + {table.getRowModel().rows.map((row) => ( + + + {row.getAllCells().map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} + ))} + + + ))} + + {footer && {footer}} +
+
+
+ ) +} + +interface TableRowWithLinkProps { + children: React.ReactNode + rowClickOptions?: RowClickOptions +} + +function TableRowWithLink({ children, rowClickOptions }: TableRowWithLinkProps) { + if (!rowClickOptions) { + return <>{children} + } + const { destination, external } = rowClickOptions + return ( + + {children} + + ) +} + +function ScrollWrapperWithHeight({ height }: { height: number }) { + function ScrollWrapper({ children }: { children: React.ReactNode }) { + return {children} + } + + return ScrollWrapper +} diff --git a/packages/app/src/ui/molecules/data-table/components/ActionsCell.tsx b/packages/app/src/ui/molecules/data-table/components/ActionsCell.tsx new file mode 100644 index 000000000..135b5791a --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/components/ActionsCell.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from 'react' + +interface ActionsCellWrapperProps { + children: ReactNode +} + +export function ActionsCell({ children }: ActionsCellWrapperProps) { + return ( +
+
{children}
+
+ ) +} diff --git a/packages/app/src/ui/molecules/data-table/components/ColumnHeader.tsx b/packages/app/src/ui/molecules/data-table/components/ColumnHeader.tsx new file mode 100644 index 000000000..8b5037681 --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/components/ColumnHeader.tsx @@ -0,0 +1,44 @@ +import { Column } from '@tanstack/react-table' +import { ChevronDown, ChevronsUpDown, ChevronUp } from 'lucide-react' + +import { Button } from '@/ui/atoms/button/Button' +import { cn } from '@/ui/utils/style' + +import { ColumnDefinition } from '../types' + +interface ColumnHeaderProps { + column: Column + columnDefinition: ColumnDefinition | undefined +} + +export function ColumnHeader({ column, columnDefinition }: ColumnHeaderProps) { + const { headerAlign, sortable, header } = columnDefinition ?? {} + return ( +
+ +
+ ) +} diff --git a/packages/app/src/ui/molecules/data-table/components/CompactValueCell.tsx b/packages/app/src/ui/molecules/data-table/components/CompactValueCell.tsx new file mode 100644 index 000000000..75e3bf6d9 --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/components/CompactValueCell.tsx @@ -0,0 +1,64 @@ +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { Typography } from '@/ui/atoms/typography/Typography' +import { cn } from '@/ui/utils/style' + +import { MobileViewOptions } from '../types' + +interface CompactValueCellProps { + token: Token + value: NormalizedUnitNumber + compactValue?: boolean + dimmed?: boolean + hideEmpty?: boolean + mobileViewOptions?: MobileViewOptions +} + +export function CompactValueCell({ + value, + token, + compactValue, + dimmed, + hideEmpty = false, + mobileViewOptions, +}: CompactValueCellProps) { + if (mobileViewOptions?.isMobileView) { + return ( +
+ + {mobileViewOptions.rowTitle} + + +
+ ) + } + + return +} + +interface CompactValueProps { + token: Token + value: NormalizedUnitNumber + compactValue?: boolean + dimmed?: boolean + hideEmpty?: boolean + className?: string +} + +function CompactValue({ token, value, compactValue, dimmed, hideEmpty, className }: CompactValueProps) { + if (hideEmpty && value.isZero()) { + return
+ } + return ( +
+
+ {token.format(value, { style: compactValue ? 'compact' : 'auto' })} +
+
+ + {token.formatUSD(value, { compact: compactValue })} + +
+
+ ) +} diff --git a/packages/app/src/ui/molecules/data-table/components/PercentageCell.tsx b/packages/app/src/ui/molecules/data-table/components/PercentageCell.tsx new file mode 100644 index 000000000..22a8e6416 --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/components/PercentageCell.tsx @@ -0,0 +1,23 @@ +import { formatPercentage } from '@/domain/common/format' +import { Percentage } from '@/domain/types/NumericValues' +import { Typography } from '@/ui/atoms/typography/Typography' + +import { MobileViewOptions } from '../types' + +interface PercentageCellProps { + value: Percentage + mobileViewOptions?: MobileViewOptions +} + +export function PercentageCell({ value, mobileViewOptions }: PercentageCellProps) { + if (mobileViewOptions?.isMobileView) { + return ( +
+ {mobileViewOptions.rowTitle} +
{formatPercentage(value)}
+
+ ) + } + + return
{formatPercentage(value)}
+} diff --git a/packages/app/src/ui/molecules/data-table/components/SwitchCell.tsx b/packages/app/src/ui/molecules/data-table/components/SwitchCell.tsx new file mode 100644 index 000000000..a52235bd0 --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/components/SwitchCell.tsx @@ -0,0 +1,29 @@ +import { MouseEventHandler } from 'react' + +import { Switch } from '@/ui/atoms/switch/Switch' +import { Typography } from '@/ui/atoms/typography/Typography' + +import { MobileViewOptions } from '../types' + +interface SwitchCellProps { + checked: boolean + mobileViewOptions?: MobileViewOptions + onSwitchClick: MouseEventHandler +} + +export function SwitchCell({ checked, onSwitchClick, mobileViewOptions }: SwitchCellProps) { + if (mobileViewOptions?.isMobileView) { + return ( +
+ {mobileViewOptions.rowTitle} + +
+ ) + } + + return ( +
+ +
+ ) +} diff --git a/packages/app/src/ui/molecules/data-table/components/TokenWithLogo.tsx b/packages/app/src/ui/molecules/data-table/components/TokenWithLogo.tsx new file mode 100644 index 000000000..d868f8c74 --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/components/TokenWithLogo.tsx @@ -0,0 +1,31 @@ +import { ReserveStatus } from '@/domain/market-info/reserve-status' +import { Token } from '@/domain/types/Token' +import { ColorFilter } from '@/ui/atoms/color-filter/ColorFilter' +import { TokenIcon } from '@/ui/atoms/token-icon/TokenIcon' +import { cn } from '@/ui/utils/style' + +import { FrozenPill } from '../../frozen-pill/FrozenPill' +import { PausedPill } from '../../paused-pill/PausedPill' + +interface TokenWithLogoProps { + token: Token + reserveStatus: ReserveStatus +} + +export function TokenWithLogo({ token, reserveStatus }: TokenWithLogoProps) { + const isPaused = reserveStatus === 'paused' + const isFrozen = reserveStatus === 'frozen' + + return ( +
+
+ + + +
+
{token.symbol}
+ {isFrozen && } + {isPaused && } +
+ ) +} diff --git a/packages/app/src/ui/molecules/data-table/types.ts b/packages/app/src/ui/molecules/data-table/types.ts new file mode 100644 index 000000000..91d2dfca1 --- /dev/null +++ b/packages/app/src/ui/molecules/data-table/types.ts @@ -0,0 +1,15 @@ +import { SortingFn } from '@tanstack/react-table' +import { ReactNode } from 'react' + +export interface ColumnDefinition { + header: ReactNode + renderCell: (value: T, mobileViewOptions?: MobileViewOptions) => ReactNode + sortable?: boolean + sortingFn?: SortingFn + headerAlign?: 'left' | 'center' | 'right' +} + +export interface MobileViewOptions { + rowTitle: ReactNode + isMobileView: boolean +} diff --git a/packages/app/src/ui/molecules/frozen-pill/FrozenPill.stories.tsx b/packages/app/src/ui/molecules/frozen-pill/FrozenPill.stories.tsx new file mode 100644 index 000000000..23e9ffc0f --- /dev/null +++ b/packages/app/src/ui/molecules/frozen-pill/FrozenPill.stories.tsx @@ -0,0 +1,21 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getHoveredStory } from '@storybook/utils' + +import { FrozenPill } from './FrozenPill' + +const meta: Meta = { + title: 'Components/Molecules/FrozenPill', + component: FrozenPill, + decorators: [WithTooltipProvider(), WithClassname('bg-white flex justify-center p-8 items-end w-96 h-56')], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: {}, +} + +export const Hovered = getHoveredStory(Default, 'button') diff --git a/packages/app/src/ui/molecules/frozen-pill/FrozenPill.tsx b/packages/app/src/ui/molecules/frozen-pill/FrozenPill.tsx new file mode 100644 index 000000000..ca2cb86fb --- /dev/null +++ b/packages/app/src/ui/molecules/frozen-pill/FrozenPill.tsx @@ -0,0 +1,28 @@ +import { assets } from '@/ui/assets' +import { Tooltip, TooltipContentLong, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { TooltipContentLayout } from '@/ui/atoms/tooltip/TooltipContentLayout' + +import { IconPill } from '../../atoms/icon-pill/IconPill' + +export function FrozenPill() { + return ( + + + + + + + + + Frozen asset + + + + This asset is frozen by Spark community decisions, meaning that further supply / borrow, or rate swap of + these assets are unavailable. Withdrawals and debt repayments are still allowed. + + + + + ) +} diff --git a/packages/app/src/ui/molecules/icon-stack/IconStack.stories.tsx b/packages/app/src/ui/molecules/icon-stack/IconStack.stories.tsx new file mode 100644 index 000000000..a23c33d58 --- /dev/null +++ b/packages/app/src/ui/molecules/icon-stack/IconStack.stories.tsx @@ -0,0 +1,53 @@ +import { Meta, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' + +import { getTokenImage } from '@/ui/assets' + +import { IconStack } from './IconStack' + +const meta: Meta = { + title: 'Components/Molecules/IconStack', + component: IconStack, + args: { + paths: [tokens['ETH'], tokens['DAI'], tokens['USDC']].map(({ symbol }) => getTokenImage(symbol)), + }, +} + +export default meta + +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const ManyIcons: Story = { + name: 'Many icons', + args: { + paths: [tokens['ETH'], tokens['DAI'], tokens['USDC'], tokens['USDT'], tokens['GNO']].map(({ symbol }) => + getTokenImage(symbol), + ), + maxIcons: 3, + }, +} + +export const Large: Story = { + name: 'Larger icons', + args: { + paths: [tokens['ETH'], tokens['DAI'], tokens['USDC'], tokens['USDT'], tokens['GNO']].map(({ symbol }) => + getTokenImage(symbol), + ), + size: 'lg', + }, +} + +export const FirstOnTop: Story = { + name: 'First on top', + args: { + stackingOrder: 'first-on-top', + paths: [tokens['DAI'], tokens['ETH'], tokens['USDC'], tokens['USDT'], tokens['GNO']].map(({ symbol }) => + getTokenImage(symbol), + ), + size: 'lg', + }, +} diff --git a/packages/app/src/ui/molecules/icon-stack/IconStack.tsx b/packages/app/src/ui/molecules/icon-stack/IconStack.tsx new file mode 100644 index 000000000..383d77e61 --- /dev/null +++ b/packages/app/src/ui/molecules/icon-stack/IconStack.tsx @@ -0,0 +1,70 @@ +import { cva } from 'class-variance-authority' + +import { Typography } from '@/ui/atoms/typography/Typography' +import { cn } from '@/ui/utils/style' + +interface IconStackProps { + paths: string[] + maxIcons?: number + size?: 'base' | 'lg' + stackingOrder?: 'first-on-top' | 'last-on-top' + className?: string +} + +export function IconStack({ + paths: srcs, + maxIcons = Number.MAX_SAFE_INTEGER, + size = 'base', + stackingOrder = 'last-on-top', + className, +}: IconStackProps) { + if (maxIcons + 1 === srcs.length) { + // let's make sure we show +2 minimum + maxIcons = srcs.length + } + + const slicedIcons = srcs.slice(0, maxIcons) + const omittedLength = srcs.length - slicedIcons.length + + return ( +
+ {slicedIcons.map((src, index, srcs) => ( + + ))} + {omittedLength > 0 && ( + + +{omittedLength} + + )} +
+ ) +} + +const iconVariants = cva('rounded-full', { + variants: { + size: { + base: 'h-6 w-6', + lg: 'h-10 w-10', + }, + }, +}) + +const stackVariants = cva('isolate flex flex-row', { + variants: { + size: { + base: '-space-x-2', + lg: '-space-x-3', + }, + }, +}) diff --git a/packages/app/src/ui/molecules/info-pill/InfoPill.stories.tsx b/packages/app/src/ui/molecules/info-pill/InfoPill.stories.tsx new file mode 100644 index 000000000..ab3a48e53 --- /dev/null +++ b/packages/app/src/ui/molecules/info-pill/InfoPill.stories.tsx @@ -0,0 +1,21 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import type { Meta, StoryObj } from '@storybook/react' + +import { InfoPill } from './InfoPill' + +const meta: Meta = { + title: 'Components/Molecules/InfoPill', + component: InfoPill, + decorators: [WithTooltipProvider()], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + args: { + text: 'Market risk', + tooltip: 'This is a tooltip', + }, +} diff --git a/packages/app/src/ui/molecules/info-pill/InfoPill.tsx b/packages/app/src/ui/molecules/info-pill/InfoPill.tsx new file mode 100644 index 000000000..f9f2a618a --- /dev/null +++ b/packages/app/src/ui/molecules/info-pill/InfoPill.tsx @@ -0,0 +1,21 @@ +import { useState } from 'react' + +import { assets } from '@/ui/assets' +import { Tooltip, TooltipContentShort, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { Typography } from '@/ui/atoms/typography/Typography' + +export function InfoPill({ text, tooltip }: { text: string; tooltip: string }) { + // https://github.com/radix-ui/primitives/issues/955#issuecomment-1698976935 + const [open, setOpen] = useState(false) + return ( + + setOpen(true)}> +
+ {text} + info +
+
+ {tooltip} +
+ ) +} diff --git a/packages/app/src/ui/molecules/info/Info.stories.tsx b/packages/app/src/ui/molecules/info/Info.stories.tsx new file mode 100644 index 000000000..23d53e047 --- /dev/null +++ b/packages/app/src/ui/molecules/info/Info.stories.tsx @@ -0,0 +1,17 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' + +import { Info } from './Info' + +const meta: Meta = { + title: 'Components/Molecules/Info', + decorators: [WithTooltipProvider()], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + render: () => Info about the thing, +} diff --git a/packages/app/src/ui/molecules/info/Info.tsx b/packages/app/src/ui/molecules/info/Info.tsx new file mode 100644 index 000000000..f7cf8ebe5 --- /dev/null +++ b/packages/app/src/ui/molecules/info/Info.tsx @@ -0,0 +1,21 @@ +import { HelpCircle } from 'lucide-react' + +import { Tooltip, TooltipContentShort, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' + +interface InfoProps { + size?: number + children: React.ReactNode +} + +function Info({ children, size = 14 }: InfoProps) { + return ( + + + + + {children} + + ) +} + +export { Info, type InfoProps } diff --git a/packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.stories.tsx b/packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.stories.tsx new file mode 100644 index 000000000..0b1c691a8 --- /dev/null +++ b/packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.stories.tsx @@ -0,0 +1,33 @@ +import { Meta, StoryObj } from '@storybook/react' +import { Banana } from 'lucide-react' + +import { LabeledSwitch } from './LabeledSwitch' + +const meta: Meta = { + title: 'Components/Molecules/LabeledSwitch', +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', + render: () => Switch me! , +} + +export const WithCustomId: Story = { + name: 'With Custom Id', + render: () => Switch me! , +} + +export const WithIcon: Story = { + name: 'With Icon', + render: () => ( + +
+
Switch me!
+ +
+
+ ), +} diff --git a/packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.tsx b/packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.tsx new file mode 100644 index 000000000..25552ee94 --- /dev/null +++ b/packages/app/src/ui/molecules/labeled-switch/LabeledSwitch.tsx @@ -0,0 +1,20 @@ +import { ComponentProps, useId } from 'react' + +import { Label } from '@/ui/atoms/label/Label' +import { Switch } from '@/ui/atoms/switch/Switch' + +interface LabeledSwitchProps extends ComponentProps {} + +export function LabeledSwitch({ children, ...props }: LabeledSwitchProps) { + const _id = useId() + const id = props.id ?? _id + + return ( +
+ + +
+ ) +} diff --git a/packages/app/src/ui/molecules/paused-pill/PausedPill.stories.tsx b/packages/app/src/ui/molecules/paused-pill/PausedPill.stories.tsx new file mode 100644 index 000000000..c1dd57729 --- /dev/null +++ b/packages/app/src/ui/molecules/paused-pill/PausedPill.stories.tsx @@ -0,0 +1,20 @@ +import { WithClassname, WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getHoveredStory } from '@storybook/utils' + +import { PausedPill } from './PausedPill' + +const meta: Meta = { + title: 'Components/Molecules/PausedPill', + component: PausedPill, + decorators: [WithTooltipProvider(), WithClassname('bg-white flex justify-center p-8 items-end w-96 h-56')], +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + name: 'Default', +} + +export const Hovered = getHoveredStory(Default, 'button') diff --git a/packages/app/src/ui/molecules/paused-pill/PausedPill.tsx b/packages/app/src/ui/molecules/paused-pill/PausedPill.tsx new file mode 100644 index 000000000..1ca19a7b6 --- /dev/null +++ b/packages/app/src/ui/molecules/paused-pill/PausedPill.tsx @@ -0,0 +1,27 @@ +import { assets } from '@/ui/assets' +import { Tooltip, TooltipContentLong, TooltipTrigger } from '@/ui/atoms/tooltip/Tooltip' +import { TooltipContentLayout } from '@/ui/atoms/tooltip/TooltipContentLayout' + +import { IconPill } from '../../atoms/icon-pill/IconPill' + +export function PausedPill() { + return ( + + + + + + + + + Paused asset + + + + This asset is planned to be offboarded due to a Spark community decision. + + + + + ) +} diff --git a/packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.stories.tsx b/packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.stories.tsx new file mode 100644 index 000000000..5d42caa75 --- /dev/null +++ b/packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.stories.tsx @@ -0,0 +1,60 @@ +import { WithClassname } from '@storybook/decorators' +import type { Meta, StoryFn, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { useForm } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Form } from '@/ui/atoms/form/Form' + +import { AssetSelectorWithInput } from './AssetSelectorWithInput' + +const assets: TokenWithBalance[] = [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber('1'), + }, + { + token: tokens['DAI'], + balance: NormalizedUnitNumber('500'), + }, + { + token: tokens['wstETH'], + balance: NormalizedUnitNumber('7'), + }, + { + token: tokens['USDC'], + balance: NormalizedUnitNumber('300'), + }, +] + +const meta: Meta = { + title: 'Components/Organisms/AssetSelectorWithInput', + component: AssetSelectorWithInput, + decorators: [WithFormProvider, WithClassname('flex flex-row gap-4')], + args: { + selectorAssets: assets, + setSelectedAsset: () => {}, + removeSelectedAsset: () => {}, + }, +} + +export default meta +type Story = StoryObj + +function WithFormProvider(Story: StoryFn) { + const form = useForm() as any + return ( +
+ + + ) +} + +export const Default: Story = { + name: 'Default', + args: { + selectedAsset: assets[0], + fieldName: 'name', + }, +} diff --git a/packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.tsx b/packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.tsx new file mode 100644 index 000000000..c049f03e3 --- /dev/null +++ b/packages/app/src/ui/organisms/asset-selector-with-input/AssetSelectorWithInput.tsx @@ -0,0 +1,66 @@ +import { Control, FieldPath, FieldValues } from 'react-hook-form' + +import { TokenWithBalance } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { AssetInputProps } from '@/ui/molecules/asset-input/AssetInput' +import { cn } from '@/ui/utils/style' + +import { AssetSelector } from '../../molecules/asset-selector/AssetSelector' +import { ControlledMultiSelectorAssetInput } from '../multi-selector/MultiSelector' + +export interface AssetSelectorWithInputProps { + control: Control + fieldName: FieldPath + selectorAssets: TokenWithBalance[] + selectedAsset: TokenWithBalance + setSelectedAsset: (selectedAsset: TokenSymbol) => void + maxValue?: NormalizedUnitNumber + maxSelectedFieldName?: string + removeSelectedAsset?: () => void + disabled?: boolean + showError?: boolean // defaults to show error if field is touched or dirty + className?: string + variant?: AssetInputProps['variant'] + walletIconLabel?: string +} + +export function AssetSelectorWithInput({ + control, + fieldName, + selectorAssets, + selectedAsset, + setSelectedAsset, + maxValue, + removeSelectedAsset, + disabled, + showError, + className, + variant, + walletIconLabel, + maxSelectedFieldName, +}: AssetSelectorWithInputProps) { + return ( +
+ setSelectedAsset(newAsset)} + disabled={disabled} + /> + +
+ ) +} diff --git a/packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.stories.tsx b/packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.stories.tsx new file mode 100644 index 000000000..2fbc86ce0 --- /dev/null +++ b/packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.stories.tsx @@ -0,0 +1,55 @@ +import { WithTooltipProvider } from '@storybook/decorators' +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { bigNumberify } from '@/utils/bigNumber' + +import { HealthFactorPanel } from './HealthFactorPanel' + +const meta: Meta = { + title: 'Components/Organisms/HealthFactorPanel', + decorators: [WithTooltipProvider()], + component: HealthFactorPanel, + args: { + hf: bigNumberify(1.5), + }, +} + +export default meta +type Story = StoryObj + +export const Full: Story = { + args: { + hf: bigNumberify(4.13), + variant: 'full-details', + liquidationDetails: { + liquidationPrice: NormalizedUnitNumber(1262.9), + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(1895.81), + symbol: TokenSymbol('ETH'), + }, + }, + }, +} + +export const Mobile = getMobileStory(Full) +export const Tablet = getTabletStory(Full) + +export const WithLiquidationPrice: Story = { + args: { + hf: bigNumberify(2.5), + variant: 'with-liquidation-price', + liquidationDetails: { + liquidationPrice: NormalizedUnitNumber(1262.9), + tokenWithPrice: { + priceInUSD: NormalizedUnitNumber(1895.81), + symbol: TokenSymbol('ETH'), + }, + }, + }, +} + +export const WithLiquidationPriceMobile = getMobileStory(WithLiquidationPrice) +export const WithLiquidationPriceTablet = getTabletStory(WithLiquidationPrice) diff --git a/packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.tsx b/packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.tsx new file mode 100644 index 000000000..d1cfa7650 --- /dev/null +++ b/packages/app/src/ui/organisms/health-factor-panel/HealthFactorPanel.tsx @@ -0,0 +1,70 @@ +import BigNumber from 'bignumber.js' +import { forwardRef } from 'react' + +import { LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { HealthFactorBadge } from '@/ui/atoms/health-factor-badge/HealthFactorBadge' +import { HealthFactorGauge } from '@/ui/atoms/health-factor-gauge/HealthFactorGauge' +import { Link } from '@/ui/atoms/link/Link' +import { Panel } from '@/ui/atoms/panel/Panel' +import { links } from '@/ui/constants/links' +import { Info } from '@/ui/molecules/info/Info' +import { cn } from '@/ui/utils/style' + +import { LiquidationOverview } from './components/LiquidationOverview' + +export interface HealthFactorPanelProps { + hf?: BigNumber + className?: string + liquidationDetails?: LiquidationDetails + variant: 'full-details' | 'with-liquidation-price' +} + +export const HealthFactorPanel = forwardRef( + ({ hf, liquidationDetails, variant, className }, ref) => { + return ( + + +
+ Health Factor + +

+ The health factor is a number that shows how safe your assets are in the protocol. It's calculated by + comparing the value of what you've deposited to what you've borrowed. +

+

+ A higher health factor means your deposited assets are worth more (or you've borrowed less), lowering + the chance of liquidating your assets. +

+

+ Keep in mind that these calculations follow the protocol's rules, which might change over time. For more + information about Health Factor, you can visit{' '} + + docs + + . +

+
+
+ + +
+ + +
+
+ +
+ +
+
+
+ ) + }, +) +HealthFactorPanel.displayName = 'HealthFactorPanel' diff --git a/packages/app/src/ui/organisms/health-factor-panel/components/LiquidationOverview.tsx b/packages/app/src/ui/organisms/health-factor-panel/components/LiquidationOverview.tsx new file mode 100644 index 000000000..74b6fb9ac --- /dev/null +++ b/packages/app/src/ui/organisms/health-factor-panel/components/LiquidationOverview.tsx @@ -0,0 +1,76 @@ +import { ReactNode } from 'react' + +import { LiquidationDetails } from '@/domain/market-info/getLiquidationDetails' +import { USD_MOCK_TOKEN } from '@/domain/types/Token' +import { Typography } from '@/ui/atoms/typography/Typography' +import { Info } from '@/ui/molecules/info/Info' +import { cn } from '@/ui/utils/style' + +import { HealthFactorPanelProps } from '../HealthFactorPanel' + +interface LiquidationOverview { + liquidationDetails: LiquidationDetails | undefined + variant: HealthFactorPanelProps['variant'] +} + +export function LiquidationOverview({ liquidationDetails, variant }: LiquidationOverview) { + if (variant === 'with-liquidation-price') { + return liquidationDetails ? : null + } + + return ( +
+ + If the health factor drops below 1, the liquidation of your collateral might be triggered. + + {liquidationDetails && ( + + )} +
+ ) +} + +interface DetailsRowProps { + children: ReactNode + variant: HealthFactorPanelProps['variant'] +} + +function DetailsRow({ children, variant }: DetailsRowProps) { + return ( +
+ {children} +
+ ) +} + +interface LiquidationPricesProps { + liquidationDetails: LiquidationDetails + variant: HealthFactorPanelProps['variant'] + className?: string +} + +function LiquidationPrices({ liquidationDetails, variant, className }: LiquidationPricesProps) { + return ( +
+ +
+ Liquidation Price + Price of the collateral asset at which the position will be liquidated. +
+
{USD_MOCK_TOKEN.formatUSD(liquidationDetails.liquidationPrice)}
+
+ +
+ Current {liquidationDetails.tokenWithPrice.symbol} Price + Current price of the collateral asset. +
+
{USD_MOCK_TOKEN.formatUSD(liquidationDetails.tokenWithPrice.priceInUSD)}
+
+
+ ) +} diff --git a/packages/app/src/ui/organisms/multi-selector/MultiSelector.stories.tsx b/packages/app/src/ui/organisms/multi-selector/MultiSelector.stories.tsx new file mode 100644 index 000000000..1b51b60e1 --- /dev/null +++ b/packages/app/src/ui/organisms/multi-selector/MultiSelector.stories.tsx @@ -0,0 +1,96 @@ +import type { Meta, StoryFn, StoryObj } from '@storybook/react' +import { tokens } from '@storybook/tokens' +import { useForm } from 'react-hook-form' + +import { TokenWithBalance, TokenWithFormValue } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { Form } from '@/ui/atoms/form/Form' + +import { MultiAssetSelector } from './MultiSelector' + +const assets: TokenWithBalance[] = [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber('1'), + }, + { + token: tokens['DAI'], + balance: NormalizedUnitNumber('500'), + }, + { + token: tokens['wstETH'], + balance: NormalizedUnitNumber('7'), + }, + { + token: tokens['USDC'], + balance: NormalizedUnitNumber('300'), + }, +] + +const assetToMaxValue: Record = { + [TokenSymbol('ETH')]: NormalizedUnitNumber(1), + [TokenSymbol('DAI')]: NormalizedUnitNumber(500), + [TokenSymbol('wstETH')]: NormalizedUnitNumber(7), + [TokenSymbol('USDC')]: NormalizedUnitNumber(300), +} + +const selectedAssets: TokenWithFormValue[] = [ + { + token: tokens['ETH'], + balance: NormalizedUnitNumber('1'), + value: '1', + }, + { + token: tokens['DAI'], + balance: NormalizedUnitNumber('500'), + value: '10', + }, +] + +const meta: Meta = { + title: 'Components/Organisms/MultiAssetSelector', + component: MultiAssetSelector, + decorators: [WithFormProvider], + args: { + allAssets: assets, + assetToMaxValue, + changeAsset: () => {}, + removeAsset: () => {}, + selectedAssets: [], + }, +} + +export default meta +type Story = StoryObj + +function WithFormProvider(Story: StoryFn) { + const form = useForm() as any + return ( +
+ + + ) +} + +export const Default: Story = { + name: 'Default', + args: { + selectedAssets: [selectedAssets[0]!], + }, +} + +export const MultipleSelectedStory: Story = { + name: 'Multiple Selected', + args: { + selectedAssets: [selectedAssets[0]!, selectedAssets[1]!], + }, +} + +export const AllDisabledStory: Story = { + name: 'All Disabled', + args: { + selectedAssets, + disabled: true, + }, +} diff --git a/packages/app/src/ui/organisms/multi-selector/MultiSelector.tsx b/packages/app/src/ui/organisms/multi-selector/MultiSelector.tsx new file mode 100644 index 000000000..52229d088 --- /dev/null +++ b/packages/app/src/ui/organisms/multi-selector/MultiSelector.tsx @@ -0,0 +1,146 @@ +import { Control, Controller, useFormContext } from 'react-hook-form' + +import { formFormat } from '@/domain/common/format' +import { TokenWithBalance } from '@/domain/common/types' +import { NormalizedUnitNumber } from '@/domain/types/NumericValues' +import { Token } from '@/domain/types/Token' +import { TokenSymbol } from '@/domain/types/TokenSymbol' +import { AssetInput, AssetInputProps } from '@/ui/molecules/asset-input/AssetInput' +import { testIds } from '@/ui/utils/testIds' + +import { AssetSelectorWithInput } from '../asset-selector-with-input/AssetSelectorWithInput' + +export interface MultiAssetSelectorProps { + fieldName: string + selectedAssets: TokenWithBalance[] + allAssets: TokenWithBalance[] + assetToMaxValue: Record + removeAsset: (index: number) => void + changeAsset: (index: number, newAssetSymbol: TokenSymbol) => void + control: Control + disabled?: boolean + showError?: boolean +} + +export function MultiAssetSelector({ + fieldName, + selectedAssets, + allAssets, + assetToMaxValue, + removeAsset, + changeAsset, + control, + disabled, + showError, +}: MultiAssetSelectorProps) { + return ( +
+ {selectedAssets.map((asset, index) => { + return ( +
+ !selectedAssets.some((a) => a.token.symbol === s.token.symbol))} + selectedAsset={asset} + setSelectedAsset={(newAsset) => changeAsset(index, newAsset)} + removeSelectedAsset={selectedAssets.length > 1 ? () => removeAsset(index) : undefined} + maxValue={assetToMaxValue[asset.token.symbol]} + disabled={disabled} + showError={showError} + /> +
+ ) + })} +
+ ) +} + +interface ControlledMultiSelectorAssetInputProps { + fieldName: string + control: Control + token: Token + max?: NormalizedUnitNumber + maxSelectedFieldName?: string + onRemove?: () => void + balance?: NormalizedUnitNumber + disabled?: boolean + showError?: boolean // defaults to show error if field is touched or dirty + variant?: AssetInputProps['variant'] + walletIconLabel?: string +} + +export function ControlledMultiSelectorAssetInput({ + fieldName, + control, + disabled, + token, + onRemove, + balance, + max, + maxSelectedFieldName, + showError, + variant, + walletIconLabel, +}: ControlledMultiSelectorAssetInputProps) { + const { setValue, trigger } = useFormContext() + + return ( + { + showError = showError ?? (isTouched || isDirty) + const isMaxSelected = (control as any)?._formValues?.isMaxSelected // as any & ?. are needed to make storybook happy + + const setMaxValue = + max && max.gt(0) + ? () => { + setValue(fieldName, formFormat(max, token.decimals), { + shouldValidate: true, + }) + } + : undefined + + const setMaxSelectedField = maxSelectedFieldName + ? () => { + setValue(maxSelectedFieldName, !isMaxSelected, { + shouldValidate: true, + }) + if (!isMaxSelected) { + setValue(fieldName, '', { + shouldValidate: false, + }) + } + } + : undefined + + return ( + { + field.onChange(e) + if (maxSelectedFieldName) { + setValue(maxSelectedFieldName, false, { + shouldValidate: false, + }) + } + // always trigger validation of the whole form + // eslint-disable-next-line no-console + trigger().catch(console.error) + }} + /> + ) + }} + /> + ) +} diff --git a/packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.stories.tsx b/packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.stories.tsx new file mode 100644 index 000000000..b3164531b --- /dev/null +++ b/packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.stories.tsx @@ -0,0 +1,96 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { ResponsiveDataTable } from './ResponsiveDataTable' + +export type Payment = { + id: string + amount: number + status: 'pending' | 'processing' | 'success' | 'failed' + email: string +} + +const data: Payment[] = [ + { + id: 'm5gr84i9', + amount: 316, + status: 'success', + email: 'ken99@yahoo.com', + }, + { + id: '3u1reuv4', + amount: 242, + status: 'success', + email: 'Abe45@gmail.com', + }, + { + id: 'derv1ws0', + amount: 837, + status: 'processing', + email: 'Monserrat44@gmail.com', + }, + { + id: '5kma53ae', + amount: 874, + status: 'success', + email: 'Silas22@gmail.com', + }, + { + id: 'bhqecj4p', + amount: 721, + status: 'failed', + email: 'carmella@hotmail.com', + }, +] + +const meta: Meta = { + title: 'Components/Organisms/ResponsiveDataTable', + component: ResponsiveDataTable, + args: { + columnDefinition: { + id: { + header: 'ID', + renderCell: ({ id }) =>
{id}
, + }, + amount: { + header: 'Amount', + sortable: true, + headerAlign: 'center', + sortingFn: (a, b) => a.original.amount - b.original.amount, + renderCell: ({ amount }) =>
{amount}
, + }, + status: { + header: 'Status', + headerAlign: 'center', + renderCell: ({ status }) =>
{status}
, + }, + email: { + header: 'Email', + headerAlign: 'right', + renderCell: ({ email }) =>
{email}
, + }, + }, + data, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = { + name: 'Desktop', + args: { + gridTemplateColumnsClassName: 'grid-cols-4', + }, +} + +export const WithoutTableHeader: Story = { + name: 'Without Table Header', + args: { + gridTemplateColumnsClassName: 'grid-cols-4', + hideTableHeader: true, + }, +} + +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.tsx b/packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.tsx new file mode 100644 index 000000000..c383d1c4d --- /dev/null +++ b/packages/app/src/ui/organisms/responsive-data-table/ResponsiveDataTable.tsx @@ -0,0 +1,69 @@ +import { Fragment } from 'react' + +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/atoms/table/Table' +import { ColumnDefinition } from '@/ui/molecules/data-table/types' +import { useBreakpoint } from '@/ui/utils/useBreakpoint' + +import { DataTable } from '../../molecules/data-table/DataTable' +import { CollapsibleCell } from './components/CollapsibleCell' + +export interface ResponsiveDataTableProps { + columnDefinition: { [key: string]: ColumnDefinition } + scroll?: { + height: number + } + hideTableHeader?: boolean + gridTemplateColumnsClassName?: string + data: T[] +} + +export function ResponsiveDataTable({ + columnDefinition, + data, + scroll, + gridTemplateColumnsClassName, + hideTableHeader = false, +}: ResponsiveDataTableProps) { + const desktop = useBreakpoint('md') + + if (desktop) { + return ( + + ) + } + + const [rowHeaderDefinition, ...contentDefinitions] = Object.values(columnDefinition) + return ( + + {!hideTableHeader && ( + + + {[rowHeaderDefinition?.header, 'More info'].map((header, index) => ( + + {header} + + ))} + + + )} + + {data.map((value, index) => ( + + + {rowHeaderDefinition?.renderCell(value)} + {contentDefinitions.map((def, index) => ( + {def.renderCell(value, { isMobileView: true, rowTitle: def.header })} + ))} + + + ))} + +
+ ) +} diff --git a/packages/app/src/ui/organisms/responsive-data-table/components/CollapsibleCell.tsx b/packages/app/src/ui/organisms/responsive-data-table/components/CollapsibleCell.tsx new file mode 100644 index 000000000..a1e85b0d5 --- /dev/null +++ b/packages/app/src/ui/organisms/responsive-data-table/components/CollapsibleCell.tsx @@ -0,0 +1,41 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' +import { ChevronDown, ChevronUp } from 'lucide-react' +import { ReactNode, useState } from 'react' + +import { TableCell } from '@/ui/atoms/table/Table' +import { Typography } from '@/ui/atoms/typography/Typography' + +interface CollapsibleCellProps { + children: [ReactNode, ReactNode] +} + +export function CollapsibleCell({ children }: CollapsibleCellProps) { + const [TriggerContent, Content] = children + const [open, setOpen] = useState(false) + + return ( + + + +
+ {TriggerContent} +
+ +
+
+
+ +
{Content}
+
+
+
+ ) +} diff --git a/packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.stories.ts b/packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.stories.ts new file mode 100644 index 000000000..e649cc672 --- /dev/null +++ b/packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.stories.ts @@ -0,0 +1,27 @@ +import { Meta, StoryObj } from '@storybook/react' +import { getMobileStory, getTabletStory } from '@storybook/viewports' + +import { assets } from '@/ui/assets' + +import { WalletActionPanel } from './WalletActionPanel' + +const icons = assets.walletIcons +const WALLET_ICONS_PATHS = [icons.metamask, icons.walletConnect, icons.coinbase, icons.enjin, icons.torus] + +const meta: Meta = { + title: 'Components/Organisms/ConnectWalletPanel', + component: WalletActionPanel, + args: { + callToAction: 'Connect your wallet to use Spark', + actionButtonTitle: 'Connect wallet', + iconPaths: WALLET_ICONS_PATHS, + walletAction: () => {}, + }, +} + +export default meta +type Story = StoryObj + +export const Desktop: Story = {} +export const Mobile = getMobileStory(Desktop) +export const Tablet = getTabletStory(Desktop) diff --git a/packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.tsx b/packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.tsx new file mode 100644 index 000000000..162ad8c17 --- /dev/null +++ b/packages/app/src/ui/organisms/wallet-action-panel/WalletActionPanel.tsx @@ -0,0 +1,29 @@ +import { Button } from '@/ui/atoms/button/Button' +import { Panel } from '@/ui/atoms/panel/Panel' +import { IconStack } from '@/ui/molecules/icon-stack/IconStack' + +interface WalletActionPanelProps { + iconPaths: string[] + callToAction: string + actionButtonTitle: string + walletAction: () => void +} + +export function WalletActionPanel({ + walletAction, + iconPaths, + callToAction, + actionButtonTitle, +}: WalletActionPanelProps) { + return ( + + +
+ +

{callToAction}

+
+ +
+
+ ) +} diff --git a/packages/app/src/ui/utils/get-random-color.ts b/packages/app/src/ui/utils/get-random-color.ts new file mode 100644 index 000000000..25e7feb08 --- /dev/null +++ b/packages/app/src/ui/utils/get-random-color.ts @@ -0,0 +1,8 @@ +export function getRandomColor(): string { + const letters = '0123456789ABCDEF' + let color = '#' + for (let _ = 0; _ < 6; _++) { + color += letters[Math.floor(Math.random() * 16)] + } + return color +} diff --git a/packages/app/src/ui/utils/shortenAddress.test.ts b/packages/app/src/ui/utils/shortenAddress.test.ts new file mode 100644 index 000000000..b3b1151dc --- /dev/null +++ b/packages/app/src/ui/utils/shortenAddress.test.ts @@ -0,0 +1,15 @@ +import { shortenAddress } from './shortenAddress' + +const address = '0xAb5801a7D398351b8bE11C439e05C5B3259aeC9B' + +describe(shortenAddress.name, () => { + it('should shorten address when not providing options', () => { + expect(shortenAddress(address)).toBe('0xAb58...eC9B') + }) + + it('should shorten address when using custom options', () => { + expect(shortenAddress(address, { startLength: 10, endLength: 2 })).toBe('0xAb5801a7...9B') + expect(shortenAddress(address, { startLength: 2, endLength: 4 })).toBe('0x...eC9B') + expect(shortenAddress(address, { startLength: 4, endLength: 2 })).toBe('0xAb...9B') + }) +}) diff --git a/packages/app/src/ui/utils/shortenAddress.ts b/packages/app/src/ui/utils/shortenAddress.ts new file mode 100644 index 000000000..dbd4eaa4d --- /dev/null +++ b/packages/app/src/ui/utils/shortenAddress.ts @@ -0,0 +1,13 @@ +import { Address } from 'viem' + +interface ShortenAddressOptions { + startLength?: number + endLength?: number +} + +export function shortenAddress( + address: Address, + { startLength = 6, endLength = 4 }: ShortenAddressOptions = {}, +): string { + return `${address.slice(0, startLength)}...${address.slice(-endLength)}` +} diff --git a/packages/app/src/ui/utils/style.ts b/packages/app/src/ui/utils/style.ts new file mode 100644 index 000000000..8dff152c0 --- /dev/null +++ b/packages/app/src/ui/utils/style.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)) +} diff --git a/packages/app/src/ui/utils/testIds.ts b/packages/app/src/ui/utils/testIds.ts new file mode 100644 index 000000000..48894d0f8 --- /dev/null +++ b/packages/app/src/ui/utils/testIds.ts @@ -0,0 +1,66 @@ +import invariant from 'tiny-invariant' + +// @note: only allowed value here is 'true' or nested object +// actual value of data test id (string) is generated based on a path in the object tree +export const testIds = makeTestIds({ + component: { + MultiAssetSelector: { + group: true, + }, + AssetSelector: true, + HealthFactorBadge: { + value: true, + }, + AssetInput: { + error: true, + }, + Action: { + title: true, + }, + Alert: { + message: true, + }, + }, + easyBorrow: { + form: { + deposits: true, + borrow: true, + borrowRate: true, + ltv: true, + }, + success: { + deposited: true, + borrowed: true, + }, + }, + dashboard: { + deposited: true, + borrowed: true, + depositDialog: { + newTokenBalance: true, + newUSDBalance: true, + }, + }, + dialog: { + healthFactor: { + before: true, + after: true, + }, + success: true, + }, +}) + +function makeTestIds(obj: T, prefix?: string): MapValuesToString { + return Object.fromEntries( + Object.entries(obj).map(([key, value]) => { + const newPrefix = prefix ? `${prefix}-${key}` : key + if (typeof value === 'object') { + return [key, makeTestIds(value, newPrefix)] + } + invariant(value === true, "testIds value map has to be 'true' or another nested object") + return [key, newPrefix] + }), + ) +} + +type MapValuesToString = { [K in keyof T]: T[K] extends boolean ? string : MapValuesToString } diff --git a/packages/app/src/ui/utils/useBreakpoint.ts b/packages/app/src/ui/utils/useBreakpoint.ts new file mode 100644 index 000000000..91bbaf10f --- /dev/null +++ b/packages/app/src/ui/utils/useBreakpoint.ts @@ -0,0 +1,58 @@ +import { useQuery } from '@tanstack/react-query' +import React, { useEffect } from 'react' + +import { screensOverrides } from '@/config/tailwind' + +// hardcoded defualt values from tailwind +// this way we don't have to import the whole default config +const defaultBreakpoints = { + all: '0px', + sm: '640px', + md: '768px', + lg: '1024px', + xl: '1280px', + '2xl': '1536px', +} +const breakpoints = { + ...defaultBreakpoints, + ...screensOverrides, +} + +export type BreakpointKey = keyof typeof breakpoints + +function useMediaQuery(query: string): boolean { + const subscribe = React.useCallback( + (callback: () => void) => { + const matchMedia = window.matchMedia(query) + + matchMedia.addEventListener('change', callback) + return () => { + matchMedia.removeEventListener('change', callback) + } + }, + [query], + ) + function getSnapshot(): boolean { + return window.matchMedia(query).matches + } + + const { data, refetch } = useQuery({ + queryKey: ['useMediaQuery', query], + queryFn: getSnapshot, + initialData: getSnapshot(), + staleTime: 0, + }) + + useEffect(() => { + return subscribe(() => { + void refetch() + }) + }, [query, refetch, subscribe]) + + return data +} + +export function useBreakpoint(breakpoint: BreakpointKey): boolean { + const query = `(min-width: ${breakpoints[breakpoint]})` + return useMediaQuery(query) +} diff --git a/packages/app/src/ui/utils/useIsTruncated.ts b/packages/app/src/ui/utils/useIsTruncated.ts new file mode 100644 index 000000000..5feaedab1 --- /dev/null +++ b/packages/app/src/ui/utils/useIsTruncated.ts @@ -0,0 +1,24 @@ +import { RefObject, useEffect, useRef, useState } from 'react' + +type UseIsTruncatedResult = [RefObject, boolean] + +export function useIsTruncated(): UseIsTruncatedResult { + const ref = useRef(null) + const [isTruncated, setIsTruncated] = useState(false) + + useEffect(function observeTruncation() { + const observer = new ResizeObserver(() => { + if (ref.current) { + setIsTruncated(ref.current.offsetWidth < ref.current.scrollWidth) + } + }) + + if (ref.current) { + observer.observe(ref.current) + } + + return () => observer.disconnect() + }, []) + + return [ref, isTruncated] +} diff --git a/packages/app/src/ui/utils/useParentSize.ts b/packages/app/src/ui/utils/useParentSize.ts new file mode 100644 index 000000000..e84431780 --- /dev/null +++ b/packages/app/src/ui/utils/useParentSize.ts @@ -0,0 +1,25 @@ +import { RefObject, useEffect, useRef, useState } from 'react' + +type UseParentWidthReturnType = [RefObject, { width: number; height: number }] + +export function useParentSize(): UseParentWidthReturnType { + const ref = useRef(null) + const [size, setSize] = useState({ width: 0, height: 0 }) + + useEffect(function observeSize() { + const observer = new ResizeObserver((entries) => { + if (entries[0]) { + const { width, height } = entries[0].contentRect + setSize({ width, height }) + } + }) + + if (ref.current) { + observer.observe(ref.current) + } + + return () => observer.disconnect() + }, []) + + return [ref, size] +} diff --git a/packages/app/src/ui/utils/useWindowSize.ts b/packages/app/src/ui/utils/useWindowSize.ts new file mode 100644 index 000000000..d47935977 --- /dev/null +++ b/packages/app/src/ui/utils/useWindowSize.ts @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react' + +interface WindowSize { + width: number + height: number +} + +export function useWindowSize(): WindowSize { + const [windowSize, setWindowSize] = useState({ + width: window.innerWidth, + height: window.innerHeight, + }) + + useEffect(function getWindowSize() { + function handleResize(): void { + setWindowSize({ width: window.innerWidth, height: window.innerHeight }) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + return windowSize +} diff --git a/packages/app/src/ui/utils/withSuspense.tsx b/packages/app/src/ui/utils/withSuspense.tsx new file mode 100644 index 000000000..18ef8e210 --- /dev/null +++ b/packages/app/src/ui/utils/withSuspense.tsx @@ -0,0 +1,30 @@ +import { Suspense } from 'react' + +/* eslint-disable func-style */ +export function withSuspense( + WrappedComponent: React.ComponentType, + FallbackComponent: React.ComponentType<{}>, +): React.ComponentType { + // make it easy to spot missing suspense fallbacks in development + if (import.meta.env.MODE === 'development') { + const FallbackComponentOriginal = FallbackComponent + + // eslint-disable-next-line react/display-name + FallbackComponent = () => { + // eslint-disable-next-line no-console + console.log('Rendering fallback component...') + return + } + FallbackComponent.displayName = FallbackComponentOriginal.displayName || FallbackComponentOriginal.name + } + const ComponentWithSuspense = (props: T) => ( + }> + + + ) + + ComponentWithSuspense.displayName = WrappedComponent.displayName || WrappedComponent.name + + return ComponentWithSuspense +} +/* eslint-enable func-style */ diff --git a/packages/app/src/utils/applyTransformers.test.ts b/packages/app/src/utils/applyTransformers.test.ts new file mode 100644 index 000000000..b66c8ba28 --- /dev/null +++ b/packages/app/src/utils/applyTransformers.test.ts @@ -0,0 +1,28 @@ +import { vi } from 'vitest' + +import { applyTransformers } from './applyTransformers' + +describe(applyTransformers.name, () => { + it('returns result of the first matching transformer', () => { + const expectedResult = 'result' + const inputs = [1, 2, 3, 4] + const transformers = [vi.fn(() => undefined), vi.fn(() => expectedResult), vi.fn(() => undefined)] + + const result = applyTransformers(inputs)(transformers) + + expect(result).toBe(expectedResult) + expect(transformers[0]).toHaveBeenCalledOnce() + expect(transformers[1]).toHaveBeenCalledOnce() + expect(transformers[2]).not.toHaveBeenCalledOnce() + }) + + it('returns null if transformer returned it', () => { + const expectedResult = 'result' + const inputs = [1, 2, 3, 4] + const transformers = [() => null, () => expectedResult] + + const result = applyTransformers(inputs)(transformers) + + expect(result).toBe(null) + }) +}) diff --git a/packages/app/src/utils/applyTransformers.ts b/packages/app/src/utils/applyTransformers.ts new file mode 100644 index 000000000..31c933fb2 --- /dev/null +++ b/packages/app/src/utils/applyTransformers.ts @@ -0,0 +1,21 @@ +import invariant from 'tiny-invariant' + +export type TransformerResult = + | TResult + | null // input should be omitted + | undefined // execute next transformer + +export type Transformer = (...data: TData) => TransformerResult + +export function applyTransformers(...data: TData) { + return function (transformers: Transformer[]): TResult | null { + for (const transformer of transformers) { + const result = transformer(...data) + if (result !== undefined) { + return result + } + } + + invariant(false, `No transformer match for data.`) + } +} diff --git a/packages/app/src/utils/bigNumber.test.ts b/packages/app/src/utils/bigNumber.test.ts new file mode 100644 index 000000000..0e2bb6206 --- /dev/null +++ b/packages/app/src/utils/bigNumber.test.ts @@ -0,0 +1,99 @@ +import BigNumber from 'bignumber.js' + +import { bigNumberify, parseBigNumber } from './bigNumber' + +describe(bigNumberify.name, () => { + it('throws for non-numeric string', () => { + expect(() => bigNumberify('123,456')).toThrow('Value argument: 123,456 cannot be converted to BigNumber.') + expect(() => bigNumberify('non-numeric')).toThrow('Value argument: non-numeric cannot be converted to BigNumber.') + }) + + it('converts number like strings', () => { + expect(bigNumberify('-1')).toStrictEqual(BigNumber(-1)) + expect(bigNumberify('0')).toStrictEqual(BigNumber(0)) + expect(bigNumberify('1')).toStrictEqual(BigNumber(1)) + expect(bigNumberify('123')).toStrictEqual(BigNumber(123)) + expect(bigNumberify('123.45')).toStrictEqual(BigNumber(123.45)) + }) + + it('converts numbers', () => { + expect(bigNumberify(-1)).toStrictEqual(BigNumber(-1)) + expect(bigNumberify(0)).toStrictEqual(BigNumber(0)) + expect(bigNumberify(1)).toStrictEqual(BigNumber(1)) + expect(bigNumberify(123)).toStrictEqual(BigNumber(123)) + expect(bigNumberify(123.45)).toStrictEqual(BigNumber(123.45)) + }) + + it('converts bigint arguments', () => { + expect(bigNumberify(1n)).toStrictEqual(BigNumber(1)) + }) + + it('returns correct value for BigNumber arguments', () => { + expect(bigNumberify(BigNumber(1))).toStrictEqual(BigNumber(1)) + }) +}) + +describe(parseBigNumber.name, () => { + describe('without default argument', () => { + it('throws for non-numeric string', () => { + const expectedError = 'Value cannot be parsed to BigNumber.' + expect(() => parseBigNumber('123,456')).toThrow(expectedError) + expect(() => parseBigNumber('non-numeric')).toThrow(expectedError) + }) + + it('throws if arguments are undefined', () => { + expect(() => parseBigNumber(undefined)).toThrow('At least one argument must be defined.') + }) + + it('converts number like strings', () => { + expect(parseBigNumber('0')).toStrictEqual(BigNumber(0)) + expect(parseBigNumber('1')).toStrictEqual(BigNumber(1)) + expect(parseBigNumber('123')).toStrictEqual(BigNumber(123)) + expect(parseBigNumber('123.45')).toStrictEqual(BigNumber(123.45)) + }) + + it('converts numbers', () => { + expect(parseBigNumber(0)).toStrictEqual(BigNumber(0)) + expect(parseBigNumber(1)).toStrictEqual(BigNumber(1)) + expect(parseBigNumber(123)).toStrictEqual(BigNumber(123)) + expect(parseBigNumber(123.45)).toStrictEqual(BigNumber(123.45)) + }) + + it('converts bigint arguments', () => { + expect(parseBigNumber(1n)).toStrictEqual(BigNumber(1)) + }) + + it('returns correct value for BigNumber arguments', () => { + expect(parseBigNumber(new BigNumber(1))).toStrictEqual(BigNumber(1)) + }) + }) + + describe('with default argument', () => { + it('uses default argument if value cannot be converted', () => { + expect(parseBigNumber('123,456', 1)).toStrictEqual(BigNumber(1)) + expect(parseBigNumber('non-numeric', 1)).toStrictEqual(BigNumber(1)) + expect(parseBigNumber(undefined, 0)).toStrictEqual(BigNumber(0)) + expect(parseBigNumber('', 0)).toStrictEqual(BigNumber(0)) + }) + + it('converts number like strings', () => { + expect(parseBigNumber('1', 2)).toStrictEqual(BigNumber(1)) + expect(parseBigNumber('123', 234)).toStrictEqual(BigNumber(123)) + expect(parseBigNumber('123.45', 234.56)).toStrictEqual(BigNumber(123.45)) + }) + + it('converts numbers', () => { + expect(parseBigNumber(1, 2)).toStrictEqual(BigNumber(1)) + expect(parseBigNumber(123, 234)).toStrictEqual(BigNumber(123)) + expect(parseBigNumber(123.45, 234.56)).toStrictEqual(BigNumber(123.45)) + }) + + it('converts bigint arguments', () => { + expect(parseBigNumber(1n, 2)).toStrictEqual(BigNumber(1)) + }) + + it('returns correct value for BigNumber arguments', () => { + expect(parseBigNumber(BigNumber(1), 2)).toStrictEqual(BigNumber(1)) + }) + }) +}) diff --git a/packages/app/src/utils/bigNumber.ts b/packages/app/src/utils/bigNumber.ts new file mode 100644 index 000000000..8da3dfc5a --- /dev/null +++ b/packages/app/src/utils/bigNumber.ts @@ -0,0 +1,50 @@ +import BigNumber from 'bignumber.js' +import invariant from 'tiny-invariant' + +export type NumberLike = string | number | BigNumber | bigint + +/** + * Converts number-like value to BigNumber. + * + * @param value Number-like value used for a conversion (string | number | BigNumber | bigint) + * @returns BigNumber representation of value + * @throws If value argument cannot be converted to BigNumber + */ +export function bigNumberify(value: NumberLike): BigNumber { + const result = new BigNumber(value.toString()) + invariant(!result.isNaN(), `Value argument: ${value} cannot be converted to BigNumber.`) + + return result +} + +/** + * Parses number-like value to BigNumber. + * + * @param value Number-like value used for a conversion (string | number | BigNumber | bigint), empty string or undefined + * @param [defaultValue] Default used in a case when a value cannot be converted to a BigNumber + * @returns BigNumber representation of a value/defaultValue + * @throws If value argument cannot be converted to a BigNumber and no default value is provided + */ +export function parseBigNumber(value: NumberLike | undefined, defaultValue?: number): BigNumber { + invariant(value !== undefined || defaultValue !== undefined, 'At least one argument must be defined.') + + const valueResult = BigNumber(value !== undefined ? value.toString() : NaN) + const defaultValueResult = BigNumber(defaultValue !== undefined ? defaultValue.toString() : NaN) + + invariant(!valueResult.isNaN() || !defaultValueResult.isNaN(), 'Value cannot be parsed to BigNumber.') + + return valueResult.isNaN() ? defaultValueResult : valueResult +} + +export function toBigInt(value: NumberLike): bigint { + // @todo explicit check if value has decimal part + return BigInt(bigNumberify(value).toFixed()) +} + +export function nonZeroOrDefault(value: T, defaultValue: T): T { + return value.isZero() ? defaultValue : value +} + +export function toHex(value: BigNumber): string { + return `0x${value.toString(16)}` +} diff --git a/packages/app/src/utils/math.ts b/packages/app/src/utils/math.ts new file mode 100644 index 000000000..249819a69 --- /dev/null +++ b/packages/app/src/utils/math.ts @@ -0,0 +1,31 @@ +import { BigNumberValue, valueToBigNumber } from '@aave/math-utils' +import BigNumber from 'bignumber.js' + +export function toWad(value: BigNumberValue): BigNumber { + return valueToBigNumber(value).shiftedBy(18) +} + +export function fromWad(value: BigNumberValue): BigNumber { + return valueToBigNumber(value).shiftedBy(-18) +} + +export function toRay(value: BigNumberValue): BigNumber { + return valueToBigNumber(value).shiftedBy(27) +} + +export function fromRay(value: BigNumberValue): BigNumber { + return valueToBigNumber(value).shiftedBy(-27) +} + +export function toRad(value: BigNumberValue): BigNumber { + return valueToBigNumber(value).shiftedBy(45) +} + +export function fromRad(value: BigNumberValue): BigNumber { + return valueToBigNumber(value).shiftedBy(-45) +} + +// is needed because setting POW_PRECISION to 100 before the operation doesn't work in node +export function pow(a: BigNumberValue, b: BigNumberValue): BigNumber { + return BigNumber.clone({ POW_PRECISION: 100 }).prototype.pow.apply(a, [new BigNumber(b).toNumber()]) +} diff --git a/packages/app/src/utils/object.ts b/packages/app/src/utils/object.ts new file mode 100644 index 000000000..547794032 --- /dev/null +++ b/packages/app/src/utils/object.ts @@ -0,0 +1,42 @@ +/** + * Like JSON.stringify, but supports BigInt + */ +export function JSONStringifyRich(obj: any): string { + return JSON.stringify(obj, (_key, value) => { + if (typeof value === 'bigint') { + return value.toString() + } + + return value + }) +} + +export function filterOutUndefinedKeys(obj: T): T { + if (Array.isArray(obj)) { + const res = obj + .map((v) => { + if (v !== null && typeof v === 'object') { + return filterOutUndefinedKeys(v) + } + + return v + }) + .filter((v) => v !== undefined) + + return res as any + } + + const result: any = {} + + for (const [k, v] of Object.entries(obj)) { + if (v !== null && typeof v === 'object' && v.constructor === Object) { + result[k] = filterOutUndefinedKeys(v) + } else { + if (v !== undefined) { + result[k] = v + } + } + } + + return result +} diff --git a/packages/app/src/utils/promises.ts b/packages/app/src/utils/promises.ts new file mode 100644 index 000000000..5bca12811 --- /dev/null +++ b/packages/app/src/utils/promises.ts @@ -0,0 +1,30 @@ +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +export class NotRetryableError extends Error { + constructor(public readonly underlyingError: Error) { + super(underlyingError.message) + } +} + +export async function retry( + fn: () => Promise, + { retries = 5, delay = 200 }: { retries?: number; delay?: number } = {}, +): Promise { + let lastError = null + + while (retries-- >= 0) { + try { + return await fn() + } catch (e) { + if (e instanceof NotRetryableError) { + throw e.underlyingError + } + lastError = e + await sleep(delay) + } + } + + throw lastError +} diff --git a/packages/app/src/utils/raise.ts b/packages/app/src/utils/raise.ts new file mode 100644 index 000000000..1e68807a3 --- /dev/null +++ b/packages/app/src/utils/raise.ts @@ -0,0 +1,6 @@ +export function raise(error: string | Error): never { + if (error instanceof Error) { + throw error + } + throw new Error(error) +} diff --git a/packages/app/src/utils/random.ts b/packages/app/src/utils/random.ts new file mode 100644 index 000000000..3247962b1 --- /dev/null +++ b/packages/app/src/utils/random.ts @@ -0,0 +1,10 @@ +/** + * Not cryptographically secure. Do not use for anything serious. + */ + +export function randomHexId(): string { + return Math.random().toString(16).slice(2) +} +export function randomInt(): number { + return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER) +} diff --git a/packages/app/src/utils/solidFetch.ts b/packages/app/src/utils/solidFetch.ts new file mode 100644 index 000000000..b02e305fe --- /dev/null +++ b/packages/app/src/utils/solidFetch.ts @@ -0,0 +1,17 @@ +import fetchRetry from 'fetch-retry' + +export const solidFetch = fetchRetry(fetch, { + retries: 5, + retryOn(_attempt, error, response) { + const retry = error !== null || !response?.ok + if (retry) { + // eslint-disable-next-line no-console + console.log('Retrying failed fetch', { error, status: response?.status }) + } + + return retry + }, + retryDelay(attempt) { + return Math.pow(2, attempt) * 150 + }, +}) diff --git a/packages/app/src/utils/strings.test.ts b/packages/app/src/utils/strings.test.ts new file mode 100644 index 000000000..105e98e5f --- /dev/null +++ b/packages/app/src/utils/strings.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { trimCharEnd } from './strings' + +describe(trimCharEnd.name, () => { + it('removes the specified character from the end of the string', () => { + const result = trimCharEnd('helloooo', 'o') + expect(result).toBe('hell') + }) + + it('does nothing if the character does not exist at the end', () => { + const result = trimCharEnd('hello', 'l') + expect(result).toBe('hello') + }) + + it('removes all instances of the character if they are consecutive at the end', () => { + const result = trimCharEnd('bananaaa', 'a') + expect(result).toBe('banan') + }) + + it('handles empty string cases correctly', () => { + const result = trimCharEnd('', 'a') + expect(result).toBe('') + }) +}) diff --git a/packages/app/src/utils/strings.ts b/packages/app/src/utils/strings.ts new file mode 100644 index 000000000..ee43bdeee --- /dev/null +++ b/packages/app/src/utils/strings.ts @@ -0,0 +1,12 @@ +import invariant from 'tiny-invariant' + +export function trimCharEnd(str: string, char: string): string { + invariant(char.length === 1, 'char has to be a single character') + let endIndex = str.length - 1 + + while (endIndex >= 0 && str[endIndex] === char) { + endIndex-- + } + + return str.slice(0, endIndex + 1) +} diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts new file mode 100644 index 000000000..d4e500ed6 --- /dev/null +++ b/packages/app/src/utils/time.ts @@ -0,0 +1,3 @@ +export function getTimestampInSeconds(date: Date): number { + return Math.floor(date.getTime() / 1000) +} diff --git a/packages/app/src/utils/tryOrDefault.ts b/packages/app/src/utils/tryOrDefault.ts new file mode 100644 index 000000000..767cc8f31 --- /dev/null +++ b/packages/app/src/utils/tryOrDefault.ts @@ -0,0 +1,7 @@ +export function tryOrDefault(fn: () => T, defaultValue: T): T { + try { + return fn() + } catch (_) { + return defaultValue + } +} diff --git a/packages/app/src/utils/types.ts b/packages/app/src/utils/types.ts new file mode 100644 index 000000000..fb7530db9 --- /dev/null +++ b/packages/app/src/utils/types.ts @@ -0,0 +1,25 @@ +import { QueryFunction, QueryKey, UseSuspenseQueryResult } from '@tanstack/react-query' + +export interface QueryOptions { + queryKey: TQueryKey + queryFn: QueryFunction +} + +export type SuspenseQueryWith = Omit, 'data'> & R + +// removes all keys from T that are not assignable to F +export type FilterObjectValues = T extends Object ? { [K in keyof T as T[K] extends F ? K : never]: T[K] } : never + +export type RequireKeys = Required> & Omit + +export type Serializable = T extends string | boolean | number + ? T + : T extends () => any + ? never + : T extends object + ? { + [K in keyof T]: Serializable + } + : T extends any[] + ? Serializable[] + : never diff --git a/packages/app/src/utils/useDebounce.ts b/packages/app/src/utils/useDebounce.ts new file mode 100644 index 000000000..5163ef4e1 --- /dev/null +++ b/packages/app/src/utils/useDebounce.ts @@ -0,0 +1,45 @@ +import { useEffect, useRef, useState } from 'react' + +export interface UseDebounceOptions { + delay?: number +} + +export interface UseDebounceResultType { + debouncedValue: T + isDebouncing: boolean +} + +/** + * Since value might be a complex object and we don't want to do deep eq checks, we introduce key to indicate when value has changed. + */ +export function useDebounce( + value: T, + key: string, + { delay = 300 }: UseDebounceOptions = {}, +): UseDebounceResultType { + const [debouncedValue, setDebouncedValue] = useState(value) + const [isDebouncing, setIsDebouncing] = useState(false) + const firstRender = useRef(true) + + useEffect(() => { + if (firstRender.current) { + firstRender.current = false + return + } + setIsDebouncing(true) + const handler = setTimeout(() => { + setIsDebouncing(false) + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [key, delay]) + + return { + debouncedValue, + isDebouncing, + } +} diff --git a/packages/app/src/utils/usePrevious.ts b/packages/app/src/utils/usePrevious.ts new file mode 100644 index 000000000..5b18ade02 --- /dev/null +++ b/packages/app/src/utils/usePrevious.ts @@ -0,0 +1,14 @@ +import { useEffect, useRef } from 'react' + +export function usePrevious(value: T): T | undefined { + const ref = useRef() + + useEffect( + function updateRefValue() { + ref.current = value + }, + [value], + ) + + return ref.current +} diff --git a/packages/app/src/utils/useTimestamp.test.ts b/packages/app/src/utils/useTimestamp.test.ts new file mode 100644 index 000000000..3922fc99d --- /dev/null +++ b/packages/app/src/utils/useTimestamp.test.ts @@ -0,0 +1,54 @@ +import { act, waitFor } from '@testing-library/react' + +import { setupHookRenderer } from '@/test/integration/setupHookRenderer' + +import { useTimestamp } from './useTimestamp' + +const hookRenderer = setupHookRenderer({ + hook: useTimestamp, + handlers: [], + args: {}, +}) + +describe('useTimestamp', () => { + it('should return the current timestamp', async () => { + const { result } = hookRenderer() + + await waitFor(() => expect(result.current.timestamp).toBeGreaterThan(0)) + }) + + it('should return the current timestamp in milliseconds', async () => { + const { result } = hookRenderer() + + await waitFor(() => expect(result.current.timestampInMs).toBeGreaterThan(0)) + }) + + it('should not change the timestamp during the component lifecycle', async () => { + const { result, rerender } = hookRenderer() + + await waitFor(() => expect(result.current).toBeDefined()) + const { timestampInMs: initialTimestampInMs, timestamp: initialTimestamp } = result.current + + act(() => { + rerender() + }) + + await waitFor(() => { + expect(result.current.timestampInMs).toBe(initialTimestampInMs) + expect(result.current.timestamp).toBe(initialTimestamp) + }) + }) + + it('should update the timestamp after the specified refresh interval', async () => { + const refreshIntervalInMs = 10 + const { result } = hookRenderer({ args: { refreshIntervalInMs } }) + + await waitFor(() => expect(result.current).toBeDefined()) + const { timestampInMs: initialTimestampInMs, timestamp: initialTimestamp } = result.current + + await waitFor(() => { + expect(result.current.timestampInMs).toBeGreaterThan(initialTimestampInMs) + expect(result.current.timestamp).toBeGreaterThan(initialTimestamp) + }) + }) +}) diff --git a/packages/app/src/utils/useTimestamp.ts b/packages/app/src/utils/useTimestamp.ts new file mode 100644 index 000000000..e797c1660 --- /dev/null +++ b/packages/app/src/utils/useTimestamp.ts @@ -0,0 +1,39 @@ +import { useSuspenseQuery } from '@tanstack/react-query' + +interface UseTimestampOptions { + refreshIntervalInMs?: number +} + +export interface UseTimestampResults { + timestamp: number + timestampInMs: number +} + +const now = Date.now() + +// returns the current timestamp that does not change during the component lifecycle +// can be used to make calculations that depend on the current timestamp consistent over the whole app +export function useTimestamp({ refreshIntervalInMs }: UseTimestampOptions = {}): UseTimestampResults { + const { data } = useSuspenseQuery({ + queryKey: ['timestamp', refreshIntervalInMs], + queryFn: () => { + const timestampInMs = Date.now() + return { + timestampInMs, + timestamp: Math.floor(timestampInMs / 1000), + } + }, + initialData: { + timestamp: Math.floor(now / 1000), + timestampInMs: now, + }, + refetchOnWindowFocus: true, // recalculate timestamp when user returns to the app + staleTime: 1000 * 60 * 2, // 2 minutes + refetchInterval: refreshIntervalInMs, + }) + + return { + timestamp: data.timestamp, + timestampInMs: data.timestampInMs, + } +} diff --git a/packages/app/src/utils/useValidatedParams.ts b/packages/app/src/utils/useValidatedParams.ts new file mode 100644 index 000000000..9a451a58a --- /dev/null +++ b/packages/app/src/utils/useValidatedParams.ts @@ -0,0 +1,13 @@ +import { useParams } from 'react-router-dom' +import { z } from 'zod' + +import { NotFoundError } from '@/domain/errors/not-found' + +export function useValidatedParams(schema: T): z.output { + const params = useParams() + const result = schema.safeParse(params) + if (!result.success) { + throw new NotFoundError() + } + return result.data +} diff --git a/packages/app/src/vite-env.d.ts b/packages/app/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/packages/app/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/app/tailwind.config.ts b/packages/app/tailwind.config.ts new file mode 100644 index 000000000..87d3c368f --- /dev/null +++ b/packages/app/tailwind.config.ts @@ -0,0 +1,128 @@ +import type { Config } from 'tailwindcss' +import defaultTheme from 'tailwindcss/defaultTheme' +import { join } from 'path' + +export default { + darkMode: ['class'], + content: ['./pages/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './app/**/*.{ts,tsx}', './src/**/*.{ts,tsx}'], + theme: { + container: { + center: true, + padding: '2rem', + }, + extend: { + screens: { + // needs to be this way to not to break tailwind intellisense + ...require(join(__dirname, 'src/config/tailwind')).screensOverrides, + }, + opacity: { + inactive: '0.3', + }, + fontFamily: { + sans: ['Inter var', ...defaultTheme.fontFamily.sans], + }, + colors: { + basics: { + black: 'rgba(var(--basics-black) / )', + white: 'rgba(var(--basics-white) / )', + green: 'rgba(var(--basics-green) / )', + red: 'rgba(var(--basics-red) / )', + border: 'var(--basics-border)', + 'dark-grey': 'rgba(var(--basics-dark-grey) / )', + 'light-grey': 'rgba(var(--basics-light-grey) / )', + }, + main: { + blue: 'rgba(var(--main-blue) / )', + }, + sec: { + green: 'rgba(var(--sec-green) / )', + }, + border: 'hsl(var(--border))', + input: 'hsl(var(--input))', + ring: 'hsl(var(--ring))', + body: 'hsl(var(--body-background))', + background: 'hsl(var(--background))', + foreground: 'hsl(var(--foreground))', + primary: { + DEFAULT: 'var(--primary)', + bg: 'hsl(var(--primary-bg))', + foreground: 'hsl(var(--primary-foreground))', + hover: 'hsl(var(--primary-hover))', + }, + secondary: { + DEFAULT: 'hsl(var(--secondary))', + foreground: 'hsl(var(--secondary-foreground))', + }, + destructive: { + DEFAULT: 'hsl(var(--destructive))', + foreground: 'hsl(var(--destructive-foreground))', + }, + muted: { + DEFAULT: 'hsl(var(--muted))', + foreground: 'hsl(var(--muted-foreground))', + }, + accent: { + DEFAULT: 'hsl(var(--accent))', + foreground: 'hsl(var(--accent-foreground))', + }, + popover: { + DEFAULT: 'hsl(var(--popover))', + foreground: 'hsl(var(--popover-foreground))', + }, + card: { + DEFAULT: 'hsl(var(--card))', + foreground: 'hsl(var(--card-foreground))', + }, + nav: { + primary: 'rgb(var(--nav-primary))', + }, + panel: { + border: 'var(--panel-border)', + bg: 'var(--panel-bg)', + }, + 'input-background': 'var(--input-background)', + 'icon-foreground': 'rgba(var(--icon-foreground) / )', + 'product-blue': 'rgba(var(--product-blue) / )', + 'product-green': 'rgba(var(--product-green) / )', + 'product-orange': 'rgba(var(--product-orange) / )', + 'product-red': 'rgba(var(--product-red) / )', + 'product-dai': 'rgba(var(--product-dai) / )', + 'product-sdai': 'rgba(var(--product-sdai) / )', + 'prompt-foreground': 'var(--prompt-foreground)', + 'success-background': 'var(--success-background)', + spark: 'rgba(var(--spark) / )', + checkbox: 'var(--checkbox)', + error: 'rgba(var(--product-red) / )', + 'light-blue': 'rgba(var(--nav-primary) / )', + 'product-dark-blue': 'rgb(var(--product-dark-blue))', + }, + boxShadow: { + nav: '0px 20px 40px 0px var(--nav-shadow)', + tooltip: '0px 10px 40px 5px var(--tooltip-shadow)', + }, + borderRadius: { + '3xl': 'calc(var(--radius) + 16px)', + '2xl': 'calc(var(--radius) + 8px)', + xl: 'calc(var(--radius) + 4px)', + lg: 'var(--radius)', + md: 'calc(var(--radius) - 2px)', + sm: 'calc(var(--radius) - 4px)', + }, + keyframes: { + 'accordion-down': { + from: { height: '0px' }, + to: { height: 'var(--radix-accordion-content-height)' }, + }, + 'accordion-up': { + from: { height: 'var(--radix-accordion-content-height)' }, + to: { height: '0px' }, + }, + }, + animation: { + 'accordion-down': 'accordion-down 0.2s ease-out', + 'accordion-up': 'accordion-up 0.2s ease-out', + }, + }, + }, + plugins: [require('tailwindcss-animate')], +} satisfies Config diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json new file mode 100644 index 000000000..dd35b9119 --- /dev/null +++ b/packages/app/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUncheckedIndexedAccess": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"], + "@storybook/*": [".storybook/*"] + } + }, + "include": ["src", ".storybook"], + "references": [{ "path": "./tsconfig.node.json" }], + "types": ["vitest/globals"] +} diff --git a/packages/app/tsconfig.node.json b/packages/app/tsconfig.node.json new file mode 100644 index 000000000..f4dc8ce56 --- /dev/null +++ b/packages/app/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts", "tailwind.config.ts"] +} diff --git a/packages/app/vercel.json b/packages/app/vercel.json new file mode 100644 index 000000000..4104016ab --- /dev/null +++ b/packages/app/vercel.json @@ -0,0 +1,12 @@ +{ + "rewrites": [ + { + "source": "/api/:path*", + "destination": "https://api.spark.fi/v2/:path*" + }, + { + "source": "/(.*)", + "destination": "/" + } + ] +} diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts new file mode 100644 index 000000000..a266a74a6 --- /dev/null +++ b/packages/app/vite.config.ts @@ -0,0 +1,47 @@ +import { resolve } from 'path' +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react-swc' +import { lingui } from '@lingui/vite-plugin' +import svgr from 'vite-plugin-svgr' +import tsconfigPaths from 'vite-tsconfig-paths' +import { execSync } from 'child_process' + +const buildSha = execSync('git rev-parse --short HEAD').toString().trimEnd() +const buildTime = new Date().toLocaleString('en-gb') + +export default defineConfig({ + define: { + __BUILD_SHA__: JSON.stringify(buildSha), + __BUILD_TIME__: JSON.stringify(buildTime), + }, + plugins: [ + react({ + plugins: [['@lingui/swc-plugin', {}]], + }), + tsconfigPaths(), + lingui(), + svgr(), + ], + resolve: { + alias: { + /** + * This is related to problems with jsbi in production build: https://github.com/GoogleChromeLabs/jsbi/issues/70 + */ + jsbi: resolve(__dirname, '.', 'node_modules', 'jsbi', 'dist', 'jsbi-cjs.js'), + }, + }, + server: { + proxy: { + '/api': { + target: 'https://api.spark.fi/v2', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/integration/setup.ts'], + globals: true, + }, +}) diff --git a/packages/app/wagmi.config.ts b/packages/app/wagmi.config.ts new file mode 100644 index 000000000..4c21505ee --- /dev/null +++ b/packages/app/wagmi.config.ts @@ -0,0 +1,119 @@ +import { defineConfig } from '@wagmi/cli' +import { etherscan } from '@wagmi/cli/plugins' +import { goerli, mainnet } from 'wagmi/chains' +import { z } from 'zod' + +export default defineConfig({ + out: 'src/config/contracts-generated.ts', + contracts: [], + plugins: [ + etherscan({ + apiKey: z.string().parse(process.env.ETHERSCAN_API_KEY), + chainId: mainnet.id, + contracts: [ + { + name: 'LendingPoolAddressProvider', + address: { + [mainnet.id]: '0x02C3eA4e34C0cBd694D2adFa2c690EECbC1793eE', + [goerli.id]: '0x026a5B6114431d8F3eF2fA0E1B2EDdDccA9c540E', + }, + }, + { + name: 'LendingPool', + address: { + [mainnet.id]: '0xC13e21B648A5Ee794902342038FF3aDAB66BE987', + [goerli.id]: '0x26ca51Af4506DE7a6f0785D20CD776081a05fF6d', + }, + }, + { + name: 'WETHGateway', + address: { + [mainnet.id]: '0xBD7D6a9ad7865463DE44B05F04559f65e3B11704', + [goerli.id]: '0xe6fC577E87F7c977c4393300417dCC592D90acF8', + }, + }, + { + name: 'WalletBalanceProvider', + address: { + [mainnet.id]: '0xd2AeF86F51F92E8e49F42454c287AE4879D1BeDc', + [goerli.id]: '0x261135877A92B42183c998bFB8580558a28377a6', + }, + }, + { + name: 'UiPoolDataProvider', + address: { + [mainnet.id]: '0xF028c2F4b19898718fD0F77b9b881CbfdAa5e8Bb', + [goerli.id]: '0x36eddc380C7f370e5f05Da5Bd7F970a27f063e39', + }, + }, + { + name: 'UiIncentiveDataProvider', + address: { + [mainnet.id]: '0xA7F8A757C4f7696c015B595F51B2901AC0121B18', + [goerli.id]: '0x1472B7d120ab62D60f60e1D804B3858361c3C475', + }, + }, + { + name: 'Collector', + address: { + [mainnet.id]: '0xb137E7d16564c81ae2b0C8ee6B55De81dd46ECe5', + [goerli.id]: '0x0D56700c90a690D8795D6C148aCD94b12932f4E3', + }, + }, + { + name: 'Chainlog', + address: { + [mainnet.id]: '0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F', + [goerli.id]: '0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F', + }, + }, + { + name: 'SavingsDai', + address: { + [mainnet.id]: '0x83f20f44975d03b1b09e64809b757c47f942beea', + [goerli.id]: '0xd8134205b0328f5676aaefb3b2a0dc15f4029d8c', + }, + }, + { + name: 'V3Migrator', + address: { + [mainnet.id]: '0xe2a3C1ff038E14d401cA6dE0673a598C33168460', + }, + }, + { + name: 'Vat', + address: { + [mainnet.id]: '0x35D1b3F3D7966A1DFe207aa4514C12a259A0492B', + [goerli.id]: '0xB966002DDAa2Baf48369f5015329750019736031', + }, + }, + { + name: 'IAMAutoLine', + address: { + [mainnet.id]: '0xC7Bdd1F2B16447dcf3dE045C4a039A60EC2f0ba3', + [goerli.id]: '0x21DaD87779D9FfA8Ed3E1036cBEA8784cec4fB83', + }, + }, + { + name: 'Pot', + address: { + [mainnet.id]: '0x197E90f9FAD81970bA7976f33CbD77088E5D7cf7', + [goerli.id]: '0x50672F0a14B40051B65958818a7AcA3D54Bd81Af', + }, + }, + ], + }), + etherscan({ + apiKey: z.string().parse(process.env.ETHERSCAN_API_KEY), + chainId: goerli.id, + contracts: [ + { + name: 'Faucet', + address: { + [goerli.id]: '0xe2bE5BfdDbA49A86e27f3Dd95710B528D43272C2', + }, + }, + ], + }), + ], +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 000000000..e337f64e7 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,20221 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@typescript-eslint/eslint-plugin': + specifier: ^5.61.0 + version: 5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.0.2) + '@typescript-eslint/parser': + specifier: ^5.61.0 + version: 5.61.0(eslint@8.44.0)(typescript@5.0.2) + eslint: + specifier: ^8.44.0 + version: 8.44.0 + eslint-config-typestrict: + specifier: ^1.0.5 + version: 1.0.5(@typescript-eslint/eslint-plugin@5.61.0)(eslint-plugin-sonarjs@0.19.0) + eslint-plugin-import: + specifier: ^2.27.5 + version: 2.27.5(@typescript-eslint/parser@5.61.0)(eslint@8.44.0) + eslint-plugin-no-only-tests: + specifier: ^3.1.0 + version: 3.1.0 + eslint-plugin-react: + specifier: ^7.33.2 + version: 7.33.2(eslint@8.44.0) + eslint-plugin-react-hooks: + specifier: ^4.6.0 + version: 4.6.0(eslint@8.44.0) + eslint-plugin-react-refresh: + specifier: ^0.4.1 + version: 0.4.1(eslint@8.44.0) + eslint-plugin-simple-import-sort: + specifier: ^10.0.0 + version: 10.0.0(eslint@8.44.0) + eslint-plugin-sonarjs: + specifier: ^0.19.0 + version: 0.19.0(eslint@8.44.0) + eslint-plugin-unused-imports: + specifier: ^2.0.0 + version: 2.0.0(@typescript-eslint/eslint-plugin@5.61.0)(eslint@8.44.0) + prettier: + specifier: ^3.2.5 + version: 3.2.5 + prettier-plugin-tailwindcss: + specifier: ^0.5.13 + version: 0.5.13(prettier@3.2.5) + typescript: + specifier: ^5.0.2 + version: 5.0.2 + vitest: + specifier: ^0.33.0 + version: 0.33.0(jsdom@22.1.0)(less@4.2.0) + + packages/app: + dependencies: + '@aave/math-utils': + specifier: ^1.20.0 + version: 1.20.0(bignumber.js@9.1.2)(tslib@2.6.2) + '@hookform/resolvers': + specifier: ^3.3.2 + version: 3.3.2(react-hook-form@7.48.2) + '@jetstreamgg/hooks': + specifier: ^1.0.6 + version: 1.0.6(@tanstack/react-query@5.28.8)(@wagmi/core@2.6.17)(hardhat@2.22.2)(react-dom@18.2.0)(react@18.2.0)(viem@2.9.21)(wagmi@2.5.20) + '@lingui/core': + specifier: ^4.5.0 + version: 4.5.0 + '@lingui/macro': + specifier: ^4.5.0 + version: 4.5.0(@lingui/react@4.5.0)(babel-plugin-macros@3.1.0) + '@lingui/react': + specifier: ^4.5.0 + version: 4.5.0(react@18.2.0) + '@radix-ui/react-accordion': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-checkbox': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-collapsible': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dialog': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-dropdown-menu': + specifier: ^2.0.6 + version: 2.0.6(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-label': + specifier: ^2.0.2 + version: 2.0.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-progress': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-scroll-area': + specifier: ^1.0.5 + version: 1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-select': + specifier: ^2.0.0 + version: 2.0.0(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slider': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': + specifier: ^1.0.2 + version: 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-switch': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-tabs': + specifier: ^1.0.4 + version: 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.0.7 + version: 1.0.7(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@rainbow-me/rainbowkit': + specifier: ^2.0.5 + version: 2.0.5(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(viem@2.9.21)(wagmi@2.5.20) + '@size-limit/file': + specifier: ^10.0.1 + version: 10.0.1(size-limit@10.0.1) + '@tanstack/react-query': + specifier: ^5.28.6 + version: 5.28.8(react@18.2.0) + '@tanstack/react-table': + specifier: ^8.10.7 + version: 8.10.7(react-dom@18.2.0)(react@18.2.0) + '@viem/anvil': + specifier: ^0.0.6 + version: 0.0.6 + '@visx/annotation': + specifier: ^3.3.0 + version: 3.3.0(react-dom@18.2.0)(react@18.2.0) + '@visx/axis': + specifier: ^3.8.0 + version: 3.8.0(react@18.2.0) + '@visx/curve': + specifier: ^3.3.0 + version: 3.3.0 + '@visx/event': + specifier: ^3.3.0 + version: 3.3.0 + '@visx/grid': + specifier: ^3.5.0 + version: 3.5.0(react@18.2.0) + '@visx/group': + specifier: ^3.3.0 + version: 3.3.0(react@18.2.0) + '@visx/scale': + specifier: ^3.5.0 + version: 3.5.0 + '@visx/shape': + specifier: ^3.5.0 + version: 3.5.0(react@18.2.0) + '@visx/text': + specifier: ^3.3.0 + version: 3.3.0(react@18.2.0) + '@visx/tooltip': + specifier: ^3.3.0 + version: 3.3.0(react-dom@18.2.0)(react@18.2.0) + bignumber.js: + specifier: ^9.1.2 + version: 9.1.2 + class-variance-authority: + specifier: ^0.7.0 + version: 0.7.0 + clsx: + specifier: ^2.0.0 + version: 2.0.0 + d3-array: + specifier: ^3.2.4 + version: 3.2.4 + deepmerge-ts: + specifier: ^5.1.0 + version: 5.1.0 + fetch-retry: + specifier: ^5.0.6 + version: 5.0.6 + jsbi: + specifier: ^3.2.5 + version: 3.2.5 + lucide-react: + specifier: ^0.284.0 + version: 0.284.0(react@18.2.0) + react: + specifier: ^18.2.0 + version: 18.2.0 + react-confetti: + specifier: ^6.1.0 + version: 6.1.0(react@18.2.0) + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + react-hook-form: + specifier: ^7.48.2 + version: 7.48.2(react@18.2.0) + react-hot-toast: + specifier: ^2.4.1 + version: 2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0) + react-router-dom: + specifier: ^6.14.2 + version: 6.14.2(react-dom@18.2.0)(react@18.2.0) + size-limit: + specifier: ^10.0.1 + version: 10.0.1 + storybook-addon-react-router-v6: + specifier: ^2.0.8 + version: 2.0.8(@storybook/blocks@7.5.1)(@storybook/channels@7.6.17)(@storybook/components@7.6.17)(@storybook/core-events@7.6.17)(@storybook/manager-api@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/theming@7.6.17)(react-dom@18.2.0)(react-router-dom@6.14.2)(react@18.2.0) + tailwind-merge: + specifier: ^1.14.0 + version: 1.14.0 + tailwindcss-animate: + specifier: ^1.0.6 + version: 1.0.6(tailwindcss@3.4.3) + viem: + specifier: ^2.9.21 + version: 2.9.21(typescript@5.4.5)(zod@3.22.4) + wagmi: + specifier: ^2.5.20 + version: 2.5.20(@tanstack/react-query@5.28.8)(@types/react@18.2.14)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4) + zod: + specifier: ^3.22.4 + version: 3.22.4 + zustand: + specifier: ^4.4.1 + version: 4.4.1(@types/react@18.2.14)(react@18.2.0) + devDependencies: + '@lingui/cli': + specifier: ^4.5.0 + version: 4.5.0 + '@lingui/conf': + specifier: ^4.5.0 + version: 4.5.0 + '@lingui/swc-plugin': + specifier: ^4.0.4 + version: 4.0.4(@lingui/macro@4.5.0) + '@lingui/vite-plugin': + specifier: ^4.5.0 + version: 4.5.0(vite@4.5.0) + '@playwright/test': + specifier: ^1.43.0 + version: 1.43.0 + '@storybook/addon-essentials': + specifier: ^7.5.1 + version: 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-interactions': + specifier: ^7.5.1 + version: 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-links': + specifier: ^7.5.1 + version: 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-styling': + specifier: ^1.3.7 + version: 1.3.7(@types/react-dom@18.2.6)(@types/react@18.2.14)(less@4.2.0)(postcss@8.4.26)(react-dom@18.2.0)(react@18.2.0)(webpack@5.91.0) + '@storybook/blocks': + specifier: ^7.5.1 + version: 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/jest': + specifier: ^0.2.3 + version: 0.2.3(vitest@0.33.0) + '@storybook/react': + specifier: ^7.5.1 + version: 7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.4.5) + '@storybook/react-vite': + specifier: ^7.5.1 + version: 7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.4.5)(vite@4.5.0) + '@storybook/testing-library': + specifier: ^0.2.2 + version: 0.2.2 + '@testing-library/jest-dom': + specifier: ^5.16.5 + version: 5.16.5 + '@testing-library/react': + specifier: ^14.0.0 + version: 14.0.0(react-dom@18.2.0)(react@18.2.0) + '@testing-library/user-event': + specifier: ^14.4.3 + version: 14.4.3(@testing-library/dom@10.0.0) + '@total-typescript/ts-reset': + specifier: ^0.5.1 + version: 0.5.1 + '@types/d3-array': + specifier: ^3.2.1 + version: 3.2.1 + '@types/jsdom': + specifier: ^21.1.1 + version: 21.1.1 + '@types/lokijs': + specifier: ^1.5.8 + version: 1.5.8 + '@types/node': + specifier: ^20.4.10 + version: 20.4.10 + '@types/react': + specifier: ^18.2.14 + version: 18.2.14 + '@types/react-dom': + specifier: ^18.2.6 + version: 18.2.6 + '@types/testing-library__jest-dom': + specifier: ^5.14.8 + version: 5.14.8 + '@vitejs/plugin-react-swc': + specifier: ^3.3.2 + version: 3.3.2(vite@4.5.0) + '@vitest/coverage-v8': + specifier: ^0.34.6 + version: 0.34.6(vitest@0.33.0) + '@wagmi/cli': + specifier: ^2.1.1 + version: 2.1.1(typescript@5.4.5) + autoprefixer: + specifier: ^10.4.14 + version: 10.4.14(postcss@8.4.26) + axios: + specifier: ^1.6.0 + version: 1.6.0 + chromatic: + specifier: ^10.1.0 + version: 10.1.0 + dotenv: + specifier: ^16.3.1 + version: 16.3.1 + http-server: + specifier: ^14.1.1 + version: 14.1.1 + jsdom: + specifier: ^22.1.0 + version: 22.1.0 + lokijs: + specifier: ^1.5.12 + version: 1.5.12 + postcss: + specifier: ^8.4.26 + version: 8.4.26 + prop-types: + specifier: ^15.8.1 + version: 15.8.1 + serve: + specifier: ^14.2.1 + version: 14.2.1 + storybook: + specifier: ^7.5.1 + version: 7.5.1 + storybook-addon-pseudo-states: + specifier: ^2.1.2 + version: 2.1.2(@storybook/components@7.6.17)(@storybook/core-events@7.6.17)(@storybook/manager-api@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/theming@7.6.17)(react-dom@18.2.0)(react@18.2.0) + tailwindcss: + specifier: ^3.4.3 + version: 3.4.3 + tiny-invariant: + specifier: ^1.3.1 + version: 1.3.1 + ts-essentials: + specifier: ^9.4.2 + version: 9.4.2(typescript@5.4.5) + vite: + specifier: ^4.5.0 + version: 4.5.0(@types/node@20.4.10)(less@4.2.0) + vite-plugin-svgr: + specifier: ^4.2.0 + version: 4.2.0(vite@4.5.0) + vite-tsconfig-paths: + specifier: ^4.3.1 + version: 4.3.1(typescript@5.4.5)(vite@4.5.0) + +packages: + + /@aashutoshrathi/word-wrap@1.2.6: + resolution: {integrity: sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==} + engines: {node: '>=0.10.0'} + dev: true + + /@aave/math-utils@1.20.0(bignumber.js@9.1.2)(tslib@2.6.2): + resolution: {integrity: sha512-s6o2x1Gx1aqA+w0Hk8KzKsdqBA2A/iT7WGNkIAelfyxLJol2JC3Ap6tzDTlEny2zzLIfl+vpQjdWhabFMgqv4Q==} + peerDependencies: + bignumber.js: ^9.x + tslib: ^2.4.x + dependencies: + bignumber.js: 9.1.2 + tslib: 2.6.2 + dev: false + + /@adobe/css-tools@4.2.0: + resolution: {integrity: sha512-E09FiIft46CmH5Qnjb0wsW54/YQd69LsxeKUOWawmws1XWvyFGURnAChH0mlr7YPFR1ofwvUQfcL0J3lMxXqPA==} + dev: true + + /@adobe/css-tools@4.3.2: + resolution: {integrity: sha512-DA5a1C0gD/pLOvhv33YMrbf2FK3oUzwNl9oOJqE4XVjuEtt6XIakRcsd7eLiOSPkp1kTRQGICTA8cKra/vFbjw==} + dev: true + + /@adraffy/ens-normalize@1.10.0: + resolution: {integrity: sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q==} + + /@adraffy/ens-normalize@1.9.0: + resolution: {integrity: sha512-iowxq3U30sghZotgl4s/oJRci6WPBfNO5YYgk2cIOMCHr3LeGPcsZjCEr+33Q4N+oV3OABDAtA+pyvWjbvBifQ==} + dev: false + + /@alloc/quick-lru@5.2.0: + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + /@ampproject/remapping@2.2.1: + resolution: {integrity: sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.19 + + /@ampproject/remapping@2.3.0: + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + dev: false + + /@aw-web-design/x-default-browser@1.4.126: + resolution: {integrity: sha512-Xk1sIhyNC/esHGGVjL/niHLowM0csl/kFO5uawBy4IrWwy0o1G8LGt3jP6nmWGz+USxeeqbihAmp/oVZju6wug==} + hasBin: true + dependencies: + default-browser-id: 3.0.0 + dev: true + + /@babel/code-frame@7.22.13: + resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.23.4 + chalk: 2.4.2 + dev: true + + /@babel/code-frame@7.22.5: + resolution: {integrity: sha512-Xmwn266vad+6DAqEB2A6V/CcZVp62BbwVmcOJc2RPuwih1kw02TjQvWVWlcKGbBPd+8/0V5DEkOcizRGYsspYQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.23.4 + + /@babel/code-frame@7.23.4: + resolution: {integrity: sha512-r1IONyb6Ia+jYR2vvIDhdWdlTGhqbBoFqLTQidzZ4kepUFH15ejXvFHxCVbtl7BOXIudsIubf4E81xeA3h3IXA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.23.4 + chalk: 2.4.2 + + /@babel/code-frame@7.24.2: + resolution: {integrity: sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.24.2 + picocolors: 1.0.0 + + /@babel/compat-data@7.22.9: + resolution: {integrity: sha512-5UamI7xkUcJ3i9qVDS+KFDEK8/7oJ55/sJMB1Ge7IEapr7KfdfV/HErR+koZwOfd+SgtFKOKRhRakdg++DcJpQ==} + engines: {node: '>=6.9.0'} + + /@babel/compat-data@7.24.4: + resolution: {integrity: sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ==} + engines: {node: '>=6.9.0'} + dev: false + + /@babel/core@7.22.9: + resolution: {integrity: sha512-G2EgeufBcYw27U4hhoIwFcgc1XU7TlXJ3mv04oOv1WCuo900U/anZSPzEqNjwdjgffkk2Gs0AN0dW1CKVLcG7w==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.2.1 + '@babel/code-frame': 7.23.4 + '@babel/generator': 7.22.9 + '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) + '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) + '@babel/helpers': 7.22.6 + '@babel/parser': 7.23.0 + '@babel/template': 7.22.5 + '@babel/traverse': 7.22.8 + '@babel/types': 7.23.0 + convert-source-map: 1.9.0 + debug: 4.3.4(supports-color@8.1.1) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + /@babel/core@7.24.4: + resolution: {integrity: sha512-MBVlMXP+kkl5394RBLSxxk/iLTeVGuXTV3cIDXavPpMMqnSnt6apKgan/U8O3USWZCWZT/TbgfEpKa4uMgN4Dg==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.4 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.24.4) + '@babel/helpers': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + convert-source-map: 2.0.0 + debug: 4.3.4(supports-color@8.1.1) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@babel/generator@7.22.9: + resolution: {integrity: sha512-KtLMbmicyuK2Ak/FTCJVbDnkN1SlT8/kceFTiuDiiRUUSMnHMidxSCdG4ndkTOHHpoomWe/4xkvHkEOncwjYIw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.0 + '@jridgewell/gen-mapping': 0.3.3 + '@jridgewell/trace-mapping': 0.3.20 + jsesc: 2.5.2 + + /@babel/generator@7.24.4: + resolution: {integrity: sha512-Xd6+v6SnjWVx/nus+y0l1sxMOTOMBkyL4+BIdbALyatQnAe/SRVjANeDPSCYaX+i1iJmuGSKf3Z+E+V/va1Hvw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 2.5.2 + dev: false + + /@babel/helper-annotate-as-pure@7.22.5: + resolution: {integrity: sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-builder-binary-assignment-operator-visitor@7.22.5: + resolution: {integrity: sha512-m1EP3lVOPptR+2DwD125gziZNcmoNSHGmJROKoy87loWUQyJaVXDgpmruWqDARZSmtYQ+Dl25okU8+qhVzuykw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-compilation-targets@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-7qYrNM6HjpnPHJbopxmb8hSPoZ0gsX8IvUS32JGVoy+pU9e5N0nLr1VjJoR6kA4d9dmGLxNYOjeB8sUDal2WMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/compat-data': 7.22.9 + '@babel/core': 7.22.9 + '@babel/helper-validator-option': 7.22.5 + browserslist: 4.22.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + /@babel/helper-compilation-targets@7.23.6: + resolution: {integrity: sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/helper-validator-option': 7.23.5 + browserslist: 4.23.0 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: false + + /@babel/helper-create-class-features-plugin@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-Pwyi89uO4YrGKxL/eNJ8lfEH55DnRloGPOseaA8NFNL6jAUnn+KccaISiFazCj5IolPPDjGSdzQzXVzODVRqUQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-function-name': 7.22.5 + '@babel/helper-member-expression-to-functions': 7.22.5 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-replace-supers': 7.22.9(@babel/core@7.22.9) + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + semver: 6.3.1 + + /@babel/helper-create-class-features-plugin@7.24.4(@babel/core@7.22.9): + resolution: {integrity: sha512-lG75yeuUSVu0pIcbhiYMXBXANHrpUPaOfu7ryAzskCgKUHuAxRQI5ssrtmF0X9UXldPlvT0XM/A4F44OXRt6iQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.22.9) + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + semver: 6.3.1 + dev: false + + /@babel/helper-create-regexp-features-plugin@7.22.15(@babel/core@7.22.9): + resolution: {integrity: sha512-29FkPLFjn4TPEa3RE7GpW+qbE8tlsu3jntNYNfcGsc49LphF1PQIiD+vMZ1z1xVOKt+93khA9tc2JBs3kBjA7w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + regexpu-core: 5.3.2 + semver: 6.3.1 + dev: false + + /@babel/helper-create-regexp-features-plugin@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-+svjVa/tFwsNSG4NEy1h85+HQ5imbT92Q5/bgtS7P0GTQlP8WuFdqsiABmQouhiFGyV66oGxZFpeYHza1rNsKw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + regexpu-core: 5.3.2 + semver: 6.3.1 + + /@babel/helper-define-polyfill-provider@0.4.1(@babel/core@7.22.9): + resolution: {integrity: sha512-kX4oXixDxG197yhX+J3Wp+NpL2wuCFjWQAr6yX2jtCnflK9ulMI51ULFGIrWiX1jGfvAxdHp+XQCcP2bZGPs9A==} + peerDependencies: + '@babel/core': ^7.4.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + debug: 4.3.4(supports-color@8.1.1) + lodash.debounce: 4.0.8 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + + /@babel/helper-define-polyfill-provider@0.6.1(@babel/core@7.22.9): + resolution: {integrity: sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + debug: 4.3.4(supports-color@8.1.1) + lodash.debounce: 4.0.8 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + dev: false + + /@babel/helper-environment-visitor@7.22.20: + resolution: {integrity: sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==} + engines: {node: '>=6.9.0'} + dev: false + + /@babel/helper-environment-visitor@7.22.5: + resolution: {integrity: sha512-XGmhECfVA/5sAt+H+xpSg0mfrHq6FzNr9Oxh7PSEBBRUb/mL7Kz3NICXb194rCqAEdxkhPT1a88teizAFyvk8Q==} + engines: {node: '>=6.9.0'} + + /@babel/helper-function-name@7.22.5: + resolution: {integrity: sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.22.5 + '@babel/types': 7.23.4 + + /@babel/helper-function-name@7.23.0: + resolution: {integrity: sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + dev: false + + /@babel/helper-hoist-variables@7.22.5: + resolution: {integrity: sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-member-expression-to-functions@7.22.5: + resolution: {integrity: sha512-aBiH1NKMG0H2cGZqspNvsaBe6wNGjbJjuLy29aU+eDZjSbbN53BaxlpB02xm9v34pLTZ1nIQPFYn2qMZoa5BQQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-member-expression-to-functions@7.23.0: + resolution: {integrity: sha512-6gfrPwh7OuT6gZyJZvd6WbTfrqAo7vm4xCzAXOusKqq/vWdKXphTpj5klHKNmRUU6/QRGlBsyU9mAIPaWHlqJA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: false + + /@babel/helper-module-imports@7.22.5: + resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-module-imports@7.24.3: + resolution: {integrity: sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.24.0 + dev: false + + /@babel/helper-module-transforms@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-t+WA2Xn5K+rTeGtC8jCsdAH52bjggG5TKRuRrAGNM/mjIbO4GxvlLMFOEz9wXY5I2XQ60PMFsAG2WIcG82dQMQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-module-imports': 7.22.5 + '@babel/helper-simple-access': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/helper-validator-identifier': 7.22.20 + + /@babel/helper-module-transforms@7.23.3(@babel/core@7.22.9): + resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-simple-access': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/helper-validator-identifier': 7.22.20 + dev: false + + /@babel/helper-module-transforms@7.23.3(@babel/core@7.24.4): + resolution: {integrity: sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-simple-access': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/helper-validator-identifier': 7.22.20 + dev: false + + /@babel/helper-optimise-call-expression@7.22.5: + resolution: {integrity: sha512-HBwaojN0xFRx4yIvpwGqxiV2tUfl7401jlok564NgB9EHS1y6QT17FmKWm4ztqjeVdXLuC4fSvHc5ePpQjoTbw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-plugin-utils@7.22.5: + resolution: {integrity: sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==} + engines: {node: '>=6.9.0'} + + /@babel/helper-plugin-utils@7.24.0: + resolution: {integrity: sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==} + engines: {node: '>=6.9.0'} + dev: false + + /@babel/helper-remap-async-to-generator@7.22.20(@babel/core@7.22.9): + resolution: {integrity: sha512-pBGyV4uBqOns+0UvhsTO8qgl8hO89PmiDYv+/COyp1aeMcmfrfruz+/nCMFiYyFF/Knn0yfrC85ZzNFjembFTw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-wrap-function': 7.22.20 + dev: false + + /@babel/helper-remap-async-to-generator@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-8WWC4oR4Px+tr+Fp0X3RHDVfINGpF3ad1HIbrc8A77epiR6eMMc6jsgozkzT2uDiOOdoS9cLIQ+XD2XvI2WSmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-wrap-function': 7.22.9 + + /@babel/helper-replace-supers@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-LJIKvvpgPOPUThdYqcX6IXRuIcTkcAub0IaDRGCZH0p5GPUp7PhRU9QVgFcDDd51BaPkk77ZjqFwh6DZTAEmGg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-member-expression-to-functions': 7.22.5 + '@babel/helper-optimise-call-expression': 7.22.5 + + /@babel/helper-replace-supers@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-QCR1UqC9BzG5vZl8BMicmZ28RuUBnHhAMddD8yHFHDRH9lLTZ9uUPehX8ctVPT8l0TKblJidqcgUUKGVrePleQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-member-expression-to-functions': 7.23.0 + '@babel/helper-optimise-call-expression': 7.22.5 + dev: false + + /@babel/helper-simple-access@7.22.5: + resolution: {integrity: sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-skip-transparent-expression-wrappers@7.22.5: + resolution: {integrity: sha512-tK14r66JZKiC43p8Ki33yLBVJKlQDFoA8GYN67lWCDCqoL6EMMSuM9b+Iff2jHaM/RRFYl7K+iiru7hbRqNx8Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-split-export-declaration@7.22.6: + resolution: {integrity: sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.23.4 + + /@babel/helper-string-parser@7.22.5: + resolution: {integrity: sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw==} + engines: {node: '>=6.9.0'} + + /@babel/helper-string-parser@7.23.4: + resolution: {integrity: sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==} + engines: {node: '>=6.9.0'} + + /@babel/helper-string-parser@7.24.1: + resolution: {integrity: sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-identifier@7.22.20: + resolution: {integrity: sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-option@7.22.5: + resolution: {integrity: sha512-R3oB6xlIVKUnxNUxbmgq7pKjxpru24zlimpE8WK47fACIlM0II/Hm1RS8IaOI7NgCr6LNS+jl5l75m20npAziw==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-option@7.23.5: + resolution: {integrity: sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==} + engines: {node: '>=6.9.0'} + dev: false + + /@babel/helper-wrap-function@7.22.20: + resolution: {integrity: sha512-pms/UwkOpnQe/PDAEdV/d7dVCoBbB+R4FvYoHGZz+4VPcg7RtYy2KP7S2lbuWM6FCSgob5wshfGESbC/hzNXZw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-function-name': 7.23.0 + '@babel/template': 7.24.0 + '@babel/types': 7.24.0 + dev: false + + /@babel/helper-wrap-function@7.22.9: + resolution: {integrity: sha512-sZ+QzfauuUEfxSEjKFmi3qDSHgLsTPK/pEpoD/qonZKOtTPTLbf59oabPQ4rKekt9lFcj/hTZaOhWwFYrgjk+Q==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-function-name': 7.22.5 + '@babel/template': 7.22.5 + '@babel/types': 7.23.4 + + /@babel/helpers@7.22.6: + resolution: {integrity: sha512-YjDs6y/fVOYFV8hAf1rxd1QvR9wJe1pDBZ2AREKq/SDayfPzgk0PBnVuTCE5X1acEpMMNOVUqoe+OwiZGJ+OaA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.22.5 + '@babel/traverse': 7.22.8 + '@babel/types': 7.23.4 + transitivePeerDependencies: + - supports-color + + /@babel/helpers@7.24.4: + resolution: {integrity: sha512-FewdlZbSiwaVGlgT1DPANDuCHaDMiOo+D/IDYRFYjHOuv66xMSJ7fQwwODwRNAPkADIO/z1EoF/l2BCWlWABDw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@babel/highlight@7.23.4: + resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + + /@babel/highlight@7.24.2: + resolution: {integrity: sha512-Yac1ao4flkTxTteCDZLEvdxg2fZfz1v8M4QpaGypq/WPDqg3ijHYbDfs+LG5hvzSoqaSZ9/Z9lKSP3CjZjv+pA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.22.20 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.0.0 + + /@babel/parser@7.22.7: + resolution: {integrity: sha512-7NF8pOkHP5o2vpmGgNGcfAeCvOYhGLyA3Z4eBQkT1RJlWu47n63bCs93QfJ2hIAFCil7L5P2IWhs1oToVgrL0Q==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.23.0 + + /@babel/parser@7.23.0: + resolution: {integrity: sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.23.0 + + /@babel/parser@7.24.4: + resolution: {integrity: sha512-zTvEBcghmeBma9QIGunWevvBAp4/Qu9Bdq+2k0Ot4fVMD6v3dsC9WOcRSKk7tRRyBM/53yKMJko9xOatGQAwSg==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.24.0 + + /@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-NP1M5Rf+u2Gw9qfSO4ihjcTGW5zXTi36ITLd4/EoAcEhIZ0yjMqmftDNl3QC19CX7olhrjpyU454g/2W7X0jvQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-31Bb65aZaUwqCbWMnZPduIZxCBngHFlzyN6Dq6KAJjtx+lx6ohKHubc61OomYi7XwVD4Ol0XCVz4h+pYFR048g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.13.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-transform-optional-chaining': 7.22.6(@babel/core@7.22.9) + + /@babel/plugin-proposal-async-generator-functions@7.20.7(@babel/core@7.22.9): + resolution: {integrity: sha512-xMbiLsn/8RK7Wq7VeVytytS2L6qE69bXPB10YCmMdDZbKF4okCqY74pI/jJQ/8U0b/F6NrT2+14b8/P9/3AMGA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-async-generator-functions instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.22.9) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.22.9) + dev: false + + /@babel/plugin-proposal-class-properties@7.18.6(@babel/core@7.22.9): + resolution: {integrity: sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-class-properties instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-class-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-proposal-export-default-from@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-+0hrgGGV3xyYIjOrD/bUZk/iUwOIGuoANfRfVg1cPhYBxF+TIXSEcc42DqzBICmWsnAQ+SfKedY0bj8QD+LuMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-export-default-from': 7.24.1(@babel/core@7.22.9) + dev: false + + /@babel/plugin-proposal-nullish-coalescing-operator@7.18.6(@babel/core@7.22.9): + resolution: {integrity: sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-nullish-coalescing-operator instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-proposal-numeric-separator@7.18.6(@babel/core@7.22.9): + resolution: {integrity: sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-numeric-separator instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.22.9) + dev: false + + /@babel/plugin-proposal-object-rest-spread@7.20.7(@babel/core@7.22.9): + resolution: {integrity: sha512-d2S98yCiLxDVmBmE8UjGcfPvNEUbA1U5q5WxaWFUGRzJSVAZqm5W6MbPct0jxnegUZ0niLeNX+IOzEs7wYg9Dg==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-object-rest-spread instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/core': 7.22.9 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.22.9) + dev: false + + /@babel/plugin-proposal-optional-catch-binding@7.18.6(@babel/core@7.22.9): + resolution: {integrity: sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-catch-binding instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.22.9) + dev: false + + /@babel/plugin-proposal-optional-chaining@7.21.0(@babel/core@7.22.9): + resolution: {integrity: sha512-p4zeefM72gpmEe2fkUr/OnOXpWEf8nAgk7ZYVqqfFiyIG7oFfVZcCrU64hWn5xp4tQ9LkV4bTIa5rD0KANpKNA==} + engines: {node: '>=6.9.0'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-optional-chaining instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.22.9): + resolution: {integrity: sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + + /@babel/plugin-proposal-unicode-property-regex@7.18.6(@babel/core@7.22.9): + resolution: {integrity: sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w==} + engines: {node: '>=4'} + deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-unicode-property-regex instead. + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-async-generators@7.8.4(@babel/core@7.22.9): + resolution: {integrity: sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-class-properties@7.12.13(@babel/core@7.22.9): + resolution: {integrity: sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-class-static-block@7.14.5(@babel/core@7.22.9): + resolution: {integrity: sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-dynamic-import@7.8.3(@babel/core@7.22.9): + resolution: {integrity: sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-export-default-from@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-cNXSxv9eTkGUtd0PsNMK8Yx5xeScxfpWOUAxE+ZPAXXEcAMOC3fk7LRdXq5fvpra2pLx2p1YtkAhpUbB2SwaRA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-syntax-export-namespace-from@7.8.3(@babel/core@7.22.9): + resolution: {integrity: sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-flow@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-9RdCl0i+q0QExayk2nOS7853w08yLucnnPML6EN9S8fgMPVtdLDCdx/cOQ/i44Lb9UeQX9A35yaqBBOMMZxPxQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-flow@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-sxi2kLTI5DeW5vDtMUsk4mTPwvlUDbjOnoWayhynCwrw4QXRld4QEYwqzY8JmQXaJUtgUuCIurtSRH5sn4c7mA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-syntax-import-assertions@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-rdV97N7KqsRzeNGoWUOK6yUsWarLjE5Su/Snk9IYPU9CwkWHs4t+rTGOvffTR8XGkJMTAdLfO0xVnXm8wugIJg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-import-attributes@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-KwvoWDeNKPETmozyFE0P2rOLqh39EoQHNjqizrI5B8Vt0ZNS7M56s7dAiAqbYfiAYOuIzIh96z3iR2ktgu3tEg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.22.9): + resolution: {integrity: sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-json-strings@7.8.3(@babel/core@7.22.9): + resolution: {integrity: sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-jsx@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-gvyP4hZrgrs/wWMaocvxZ44Hw0b3W8Pe+cMxc8V1ULQ07oh8VNbIRaoD1LRZVTvD+0nieDKjfgKg89sD7rrKrg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-jsx@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-syntax-logical-assignment-operators@7.10.4(@babel/core@7.22.9): + resolution: {integrity: sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-nullish-coalescing-operator@7.8.3(@babel/core@7.22.9): + resolution: {integrity: sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-numeric-separator@7.10.4(@babel/core@7.22.9): + resolution: {integrity: sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-object-rest-spread@7.8.3(@babel/core@7.22.9): + resolution: {integrity: sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-optional-catch-binding@7.8.3(@babel/core@7.22.9): + resolution: {integrity: sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-optional-chaining@7.8.3(@babel/core@7.22.9): + resolution: {integrity: sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-private-property-in-object@7.14.5(@babel/core@7.22.9): + resolution: {integrity: sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-top-level-await@7.14.5(@babel/core@7.22.9): + resolution: {integrity: sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-typescript@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-1mS2o03i7t1c6VzH6fdQ3OA8tcEIxwG18zIPRp+UY1Ihv6W+XZzBCVxExF9upussPXJ0xE9XRHwMoNs1ep/nRQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-syntax-typescript@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.22.9): + resolution: {integrity: sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-arrow-functions@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-26lTNXoVRdAnsaDXPpvCNUq+OVWEVC6bx7Vvz9rC53F2bagUWW4u4ii2+h8Fejfh7RYqPxn+libeFBBck9muEw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-arrow-functions@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-ngT/3NkRhsaep9ck9uj2Xhv9+xB1zShY3tM3g6om4xxCELwCDN4g4Aq5dRn48+0hasAql7s2hdBOysCfNpr4fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-async-generator-functions@7.22.7(@babel/core@7.22.9): + resolution: {integrity: sha512-7HmE7pk/Fmke45TODvxvkxRMV9RazV+ZZzhOL9AG8G29TLrr3jkjwF7uJfxZ30EoXpO+LJkq4oA8NjO2DTnEDg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-remap-async-to-generator': 7.22.9(@babel/core@7.22.9) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.22.9) + + /@babel/plugin-transform-async-to-generator@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-b1A8D8ZzE/VhNDoV1MSJTnpKkCG5bJo+19R4o4oy03zM7ws8yEMK755j61Dc3EyvdysbqH5BOOTquJ7ZX9C6vQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-module-imports': 7.22.5 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-remap-async-to-generator': 7.22.9(@babel/core@7.22.9) + + /@babel/plugin-transform-async-to-generator@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-AawPptitRXp1y0n4ilKcGbRYWfbbzFWz2NqNu7dacYDtFtz0CMjG64b3LQsb3KIgnf4/obcUL78hfaOS7iCUfw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-remap-async-to-generator': 7.22.20(@babel/core@7.22.9) + dev: false + + /@babel/plugin-transform-block-scoped-functions@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-tdXZ2UdknEKQWKJP1KMNmuF5Lx3MymtMN/pvA+p/VEkhK8jVcQ1fzSy8KM9qRYhAf2/lV33hoMPKI/xaI9sADA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-block-scoping@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-EcACl1i5fSQ6bt+YGuU/XGCeZKStLmyVGytWkpyhCLeQVA0eu6Wtiw92V+I1T/hnezUv7j74dA/Ro69gWcU+hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-block-scoping@7.24.4(@babel/core@7.22.9): + resolution: {integrity: sha512-nIFUZIpGKDf9O9ttyRXpHFpKC+X3Y5mtshZONuEUYBomAKoM4y029Jr+uB1bHGPhNmK8YXHevDtKDOLmtRrp6g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-class-properties@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-nDkQ0NfkOhPTq8YCLiWNxp1+f9fCobEjCb0n8WdbNUBc4IB5V7P1QnX9IjpSoquKrXF5SKojHleVNs2vGeHCHQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-class-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-class-static-block@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-SPToJ5eYZLxlnp1UzdARpOGeC2GbHvr9d/UV0EukuVx8atktg194oe+C5BqQ8jRTkgLRVOPYeXRSBg1IlMoVRA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.12.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-class-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.22.9) + + /@babel/plugin-transform-classes@7.22.6(@babel/core@7.22.9): + resolution: {integrity: sha512-58EgM6nuPNG6Py4Z3zSuu0xWu2VfodiMi72Jt5Kj2FECmaYk1RrTXA45z6KBFsu9tRgwQDwIiY4FXTt+YsSFAQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-function-name': 7.22.5 + '@babel/helper-optimise-call-expression': 7.22.5 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-replace-supers': 7.22.9(@babel/core@7.22.9) + '@babel/helper-split-export-declaration': 7.22.6 + globals: 11.12.0 + + /@babel/plugin-transform-classes@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-ZTIe3W7UejJd3/3R4p7ScyyOoafetUShSf4kCqV0O7F/RiHxVj/wRaRnQlrGwflvcehNA8M42HkAiEDYZu2F1Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-replace-supers': 7.24.1(@babel/core@7.22.9) + '@babel/helper-split-export-declaration': 7.22.6 + globals: 11.12.0 + dev: false + + /@babel/plugin-transform-computed-properties@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-4GHWBgRf0krxPX+AaPtgBAlTgTeZmqDynokHOX7aqqAB4tHs3U2Y02zH6ETFdLZGcg9UQSD1WCmkVrE9ErHeOg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/template': 7.22.5 + + /@babel/plugin-transform-computed-properties@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-5pJGVIUfJpOS+pAqBQd+QMaTD2vCL/HcePooON6pDpHgRp4gNRmzyHTPIkXntwKsq3ayUFVfJaIKPw2pOkOcTw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/template': 7.24.0 + dev: false + + /@babel/plugin-transform-destructuring@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-GfqcFuGW8vnEqTUBM7UtPd5A4q797LTvvwKxXTgRsFjoqaJiEg9deBG6kWeQYkVEL569NpnmpC0Pkr/8BLKGnQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-destructuring@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-ow8jciWqNxR3RYbSNVuF4U2Jx130nwnBnhRw6N6h1bOejNkABmcI5X5oz29K4alWX7vf1C+o6gtKXikzRKkVdw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-dotall-regex@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-5/Yk9QxCQCl+sOIB1WelKnVRxTJDSAIxtJLL2/pqL14ZVlbH0fUQUZa/T5/UnQtBNgghR7mfB8ERBKyKPCi7Vw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-duplicate-keys@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-dEnYD+9BBgld5VBXHnF/DbYGp3fqGMsyxKbtD1mDyIA7AkTSpKXFhCVuj/oQVOoALfBs77DudA0BE4d5mcpmqw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-dynamic-import@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-0MC3ppTB1AMxd8fXjSrbPa7LT9hrImt+/fcj+Pg5YMD7UQyWp/02+JWpdnCymmsXwIx5Z+sYn1bwCn4ZJNvhqQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-transform-exponentiation-operator@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-vIpJFNM/FjZ4rh1myqIya9jXwrwwgFRHPjT3DkUA9ZLHuzox8jiXkOLvwm1H+PQIP3CqfC++WPKeuDi0Sjdj1g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-builder-binary-assignment-operator-visitor': 7.22.5 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-export-namespace-from@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-X4hhm7FRnPgd4nDA4b/5V280xCx6oL7Oob5+9qVS5C13Zq4bh1qq7LU0GgRU6b5dBWBvhGaXYVB4AcN6+ol6vg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-transform-flow-strip-types@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-tujNbZdxdG0/54g/oua8ISToaXTFBf8EnSb5PgQSciIXWOWKX3S4+JR7ZE9ol8FZwf9kxitzkGQ+QWeov/mCiA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-flow': 7.22.5(@babel/core@7.22.9) + + /@babel/plugin-transform-flow-strip-types@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-iIYPIWt3dUmUKKE10s3W+jsQ3icFkw0JyRVyY1B7G4yK/nngAOHLVx8xlhA6b/Jzl/Y0nis8gjqhqKtRDQqHWQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-flow': 7.24.1(@babel/core@7.22.9) + dev: false + + /@babel/plugin-transform-for-of@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-3kxQjX1dU9uudwSshyLeEipvrLjBCVthCgeTp6CzE/9JYrlAIaeekVxRpCWsDDfYTfRZRoCeZatCQvwo+wvK8A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-function-name@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-UIzQNMS0p0HHiQm3oelztj+ECwFnj+ZRV4KnguvlsD2of1whUeM6o7wGNj6oLwcDoAXQ8gEqfgC24D+VdIcevg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) + '@babel/helper-function-name': 7.22.5 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-function-name@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-BXmDZpPlh7jwicKArQASrj8n22/w6iymRnvHYYd2zO30DbE277JO20/7yXJT3QxDPtiQiOxQBbZH4TpivNXIxA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-compilation-targets': 7.23.6 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-json-strings@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-DuCRB7fu8MyTLbEQd1ew3R85nx/88yMoqo2uPSjevMj3yoN7CDM8jkgrY0wmVxfJZyJ/B9fE1iq7EQppWQmR5A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-transform-literals@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-fTLj4D79M+mepcw3dgFBTIDYpbcB9Sm0bpm4ppXPaO+U+PKFFyV9MGRvS0gvGw62sd10kT5lRMKXAADb9pWy8g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-literals@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-zn9pwz8U7nCqOYIiBaOxoQOtYmMODXTJnkxG4AtX8fPmnCRYWBOHD0qcpwS9e2VDSp1zNJYpdnFMIKb8jmwu6g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-logical-assignment-operators@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-MQQOUW1KL8X0cDWfbwYP+TbVbZm16QmQXJQ+vndPtH/BoO0lOKpVoEDMI7+PskYxH+IiE0tS8xZye0qr1lGzSA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.22.9) + + /@babel/plugin-transform-member-expression-literals@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-RZEdkNtzzYCFl9SE9ATaUMTj2hqMb4StarOJLrZRbqqU4HSBE7UlBw9WBWQiDzrJZJdUWiMTVDI6Gv/8DPvfew==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-modules-amd@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-R+PTfLTcYEmb1+kK7FNkhQ1gP4KgjpSO6HfH9+f8/yfp2Nt3ggBjiVpRwmwTlfqZLafYKJACy36yDXlEmI9HjQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-modules-commonjs@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-B4pzOXj+ONRmuaQTg05b3y/4DuFz3WcCNAXPLb2Q0GT0TrGKGxNKV4jwsXts+StaM0LQczZbOpj8o1DLPDJIiA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-simple-access': 7.22.5 + + /@babel/plugin-transform-modules-commonjs@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-szog8fFTUxBfw0b98gEWPaEqF42ZUD/T3bkynW/wtgx2p/XCP55WEsb+VosKceRSd6njipdZvNogqdtI4Q0chw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-module-transforms': 7.23.3(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-simple-access': 7.22.5 + dev: false + + /@babel/plugin-transform-modules-systemjs@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-emtEpoaTMsOs6Tzz+nbmcePl6AKVtS1yC4YNAeMun9U8YCsgadPNxnOPQ8GhHFB2qdx+LZu9LgoC0Lthuu05DQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-validator-identifier': 7.22.20 + + /@babel/plugin-transform-modules-umd@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-+S6kzefN/E1vkSsKx8kmQuqeQsvCKCd1fraCM7zXm4SFoggI099Tr4G8U81+5gtMdUeMQ4ipdQffbKLX0/7dBQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-module-transforms': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-named-capturing-groups-regex@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-YgLLKmS3aUBhHaxp5hi1WJTgOUb/NCuDHzGT9z9WTt3YG+CPRhJs6nprbStx6DnWM4dh6gt7SU3sZodbZ08adQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-new-target@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-AsF7K0Fx/cNKVyk3a+DW0JLo+Ua598/NxMRvxDnkpCIGFh43+h/v2xyhRUYf6oD8gE4QtL83C7zZVghMjHd+iw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-nullish-coalescing-operator@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-6CF8g6z1dNYZ/VXok5uYkkBBICHZPiGEl7oDnAx2Mt1hlHVHOSIKWJaXHjQJA5VB43KZnXZDIexMchY4y2PGdA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-transform-numeric-separator@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-NbslED1/6M+sXiwwtcAB/nieypGw02Ejf4KtDeMkCEpP6gWFMX1wI9WKYua+4oBneCCEmulOkRpwywypVZzs/g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.22.9) + + /@babel/plugin-transform-object-rest-spread@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-Kk3lyDmEslH9DnvCDA1s1kkd3YWQITiBOHngOtDL9Pt6BZjzqb6hiOlb8VfjiiQJ2unmegBqZu0rx5RxJb5vmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.22.9 + '@babel/core': 7.22.9 + '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-transform-parameters': 7.22.5(@babel/core@7.22.9) + + /@babel/plugin-transform-object-super@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-klXqyaT9trSjIUrcsYIfETAzmOEZL3cBYqOYLJxBHfMFFggmXOv+NYSX/Jbs9mzMVESw/WycLFPRx8ba/b2Ipw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-replace-supers': 7.22.9(@babel/core@7.22.9) + + /@babel/plugin-transform-optional-catch-binding@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-pH8orJahy+hzZje5b8e2QIlBWQvGpelS76C63Z+jhZKsmzfNaPQ+LaW6dcJ9bxTpo1mtXbgHwy765Ro3jftmUg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-transform-optional-chaining@7.22.6(@babel/core@7.22.9): + resolution: {integrity: sha512-Vd5HiWml0mDVtcLHIoEU5sw6HOUW/Zk0acLs/SAeuLzkGNOPc9DB4nkUajemhCmTIz3eiaKREZn2hQQqF79YTg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.9) + + /@babel/plugin-transform-parameters@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-AVkFUBurORBREOmHRKo06FjHYgjrabpdqRSwq6+C7R5iTCZOsM4QbcB27St0a4U6fffyAOqh3s/qEfybAhfivg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-parameters@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-8Jl6V24g+Uw5OGPeWNKrKqXPDw2YDjLc53ojwfMcKwlEoETKU9rU0mHUtcg9JntWI/QYzGAXNWEcVHZ+fR+XXg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-private-methods@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-PPjh4gyrQnGe97JTalgRGMuU4icsZFnWkzicB/fUtzlKUqvsWBKEpPPfr5a2JiyirZkHxnAqkQMO5Z5B2kK3fA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-class-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-private-methods@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-tGvisebwBO5em4PaYNqt4fkw56K2VALsAbAakY0FjTYqJp7gfdrgr7YX76Or8/cpik0W6+tj3rZ0uHU9Oil4tw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-private-property-in-object@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-/9xnaTTJcVoBtSSmrVyhtSvO3kbqS2ODoh2juEU72c3aYonNF0OMGiaz2gjukyKM2wBBYJP38S4JiE0Wfb5VMQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.22.9) + + /@babel/plugin-transform-private-property-in-object@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-pTHxDVa0BpUbvAgX3Gat+7cSciXqUcY9j2VZKTbSB6+VQGpNgNO9ailxTGHSXlqOnX1Hcx1Enme2+yv7VqP9bg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.22.9) + dev: false + + /@babel/plugin-transform-property-literals@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-TiOArgddK3mK/x1Qwf5hay2pxI6wCZnvQqrFSqbtg1GLl2JcNMitVH/YnqjP+M31pLUeTfzY1HAXFDnUBV30rQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-react-display-name@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-mvoQg2f9p2qlpDQRBC7M3c3XTr0k7cp/0+kFKKO/7Gtu0LSw16eKB+Fabe2bDT/UpsyasTBBkAnbdsLrkD5XMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-react-jsx-self@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-nTh2ogNUtxbiSbxaT4Ds6aXnXEipHweN9YRgOX/oNXdf0cCrGn/+2LozFa3lnPV5D90MkjhgckCPBrsoSc1a7g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-transform-react-jsx-self@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-kDJgnPujTmAZ/9q2CN4m2/lRsUUPDvsG3+tSHWUJIzMGTt5U/b/fwWd3RO3n+5mjLrsBrVa5eKFRVSQbi3dF1w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-react-jsx-source@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-yIiRO6yobeEIaI0RTbIr8iAK9FcBHLtZq0S89ZPjDLQXBA4xvghaKqI0etp/tF3htTM0sazJKKLz9oEiGRtu7w==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + dev: true + + /@babel/plugin-transform-react-jsx-source@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-1v202n7aUq4uXAieRTKcwPzNyphlCuqHHDcdSNc+vdhoTEZcFMh+L5yZuCmGaIO7bs1nJUNfHB89TZyoL48xNA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-react-jsx@7.23.4(@babel/core@7.22.9): + resolution: {integrity: sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-jsx': 7.24.1(@babel/core@7.22.9) + '@babel/types': 7.24.0 + dev: false + + /@babel/plugin-transform-regenerator@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-rR7KePOE7gfEtNTh9Qw+iO3Q/e4DEsoQ+hdvM6QUDH7JRJ5qxq5AA52ZzBWbI5i9lfNuvySgOGP8ZN7LAmaiPw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + regenerator-transform: 0.15.1 + + /@babel/plugin-transform-reserved-words@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-DTtGKFRQUDm8svigJzZHzb/2xatPc6TzNvAIJ5GqOKDsGFYgAskjRulbR/vGsPKq3OPqtexnz327qYpP57RFyA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-runtime@7.24.3(@babel/core@7.22.9): + resolution: {integrity: sha512-J0BuRPNlNqlMTRJ72eVptpt9VcInbxO6iP3jaxr+1NPhC0UkKL+6oeX6VXMEYdADnuqmMmsBspt4d5w8Y/TCbQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-module-imports': 7.24.3 + '@babel/helper-plugin-utils': 7.24.0 + babel-plugin-polyfill-corejs2: 0.4.10(@babel/core@7.22.9) + babel-plugin-polyfill-corejs3: 0.10.4(@babel/core@7.22.9) + babel-plugin-polyfill-regenerator: 0.6.1(@babel/core@7.22.9) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@babel/plugin-transform-shorthand-properties@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-vM4fq9IXHscXVKzDv5itkO1X52SmdFBFcMIBZ2FRn2nqVYqw6dBexUgMvAjHW+KXpPPViD/Yo3GrDEBaRC0QYA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-shorthand-properties@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-LyjVB1nsJ6gTTUKRjRWx9C1s9hE7dLfP/knKdrfeH9UPtAGjYGgxIbFfx7xyLIEWs7Xe1Gnf8EWiUqfjLhInZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-spread@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-5ZzDQIGyvN4w8+dMmpohL6MBo+l2G7tfC/O2Dg7/hjpgeWvUx8FzfeOKxGog9IimPa4YekaQ9PlDqTLOljkcxg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + + /@babel/plugin-transform-spread@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-KjmcIM+fxgY+KxPVbjelJC6hrH1CgtPmTvdXAfn3/a9CnWGSTY7nH4zm5+cjmWJybdcPSsD0++QssDsjcpe47g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + '@babel/helper-skip-transparent-expression-wrappers': 7.22.5 + dev: false + + /@babel/plugin-transform-sticky-regex@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-zf7LuNpHG0iEeiyCNwX4j3gDg1jgt1k3ZdXBKbZSoA3BbGQGvMiSvfbZRR3Dr3aeJe3ooWFZxOOG3IRStYp2Bw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-sticky-regex@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-9v0f1bRXgPVcPrngOQvLXeGNNVLc8UjMVfebo9ka0WF3/7+aVUHmaJVT3sa0XCzEFioPfPHZiOcYG9qOsH63cw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-template-literals@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-5ciOehRNf+EyUeewo8NkbQiUs4d6ZxiHo6BcBcnFlgiJfu16q0bQUw9Jvo0b0gBKFG1SMhDSjeKXSYuJLeFSMA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-typeof-symbol@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-bYkI5lMzL4kPii4HHEEChkD0rkc+nvnlR6+o/qdqR6zrm0Sv/nodmyLhlq2DO0YKLUNd2VePmPRjJXSBh9OIdA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-typescript@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-BnVR1CpKiuD0iobHPaM1iLvcwPYN2uVFAqoLVSpEDKWuOikoCv5HbKLxclhKYUXlWkX86DoZGtqI4XhbOsyrMg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-syntax-typescript': 7.22.5(@babel/core@7.22.9) + + /@babel/plugin-transform-typescript@7.24.4(@babel/core@7.22.9): + resolution: {integrity: sha512-79t3CQ8+oBGk/80SQ8MN3Bs3obf83zJ0YZjDmDaEZN8MqhMI760apl5z6a20kFeMXBwJX99VpKT8CKxEBp5H1g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-annotate-as-pure': 7.22.5 + '@babel/helper-create-class-features-plugin': 7.24.4(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.24.0 + '@babel/plugin-syntax-typescript': 7.24.1(@babel/core@7.22.9) + dev: false + + /@babel/plugin-transform-unicode-escapes@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-biEmVg1IYB/raUO5wT1tgfacCef15Fbzhkx493D3urBI++6hpJ+RFG4SrWMn0NEZLfvilqKf3QDrRVZHo08FYg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-unicode-property-regex@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-HCCIb+CbJIAE6sXn5CjFQXMwkCClcOfPCzTlilJ8cUatfzwHlWQkbtV0zD338u9dZskwvuOYTuuaMaA8J5EI5A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-unicode-regex@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-028laaOKptN5vHJf9/Arr/HiJekMd41hOEZYvNsrsXqJ7YPYuX2bQxh31fkZzGmq3YqHRJzYFFAVYvKfMPKqyg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/plugin-transform-unicode-regex@7.24.1(@babel/core@7.22.9): + resolution: {integrity: sha512-2A/94wgZgxfTsiLaQ2E36XAOdcZmGAaEEgVmxQWwZXWkGhvoHbaqXcKnU8zny4ycpu3vNqg0L/PcCiYtHtA13g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.15(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.24.0 + dev: false + + /@babel/plugin-transform-unicode-sets-regex@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-lhMfi4FC15j13eKrh3DnYHjpGj6UKQHtNKTbtc1igvAhRy4+kLhV07OpLcsN0VgDEw/MjAvJO4BdMJsHwMhzCg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-create-regexp-features-plugin': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + + /@babel/preset-env@7.22.9(@babel/core@7.22.9): + resolution: {integrity: sha512-wNi5H/Emkhll/bqPjsjQorSykrlfY5OWakd6AulLvMEytpKasMVUpVy8RL4qBIBs5Ac6/5i0/Rv0b/Fg6Eag/g==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.22.9 + '@babel/core': 7.22.9 + '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.22.9) + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-validator-option': 7.22.5 + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.22.9) + '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.22.9) + '@babel/plugin-syntax-class-properties': 7.12.13(@babel/core@7.22.9) + '@babel/plugin-syntax-class-static-block': 7.14.5(@babel/core@7.22.9) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-export-namespace-from': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-import-assertions': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-syntax-import-attributes': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.22.9) + '@babel/plugin-syntax-json-strings': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-logical-assignment-operators': 7.10.4(@babel/core@7.22.9) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-numeric-separator': 7.10.4(@babel/core@7.22.9) + '@babel/plugin-syntax-object-rest-spread': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-optional-catch-binding': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.22.9) + '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.22.9) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-transform-arrow-functions': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-async-generator-functions': 7.22.7(@babel/core@7.22.9) + '@babel/plugin-transform-async-to-generator': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-block-scoped-functions': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-block-scoping': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-class-properties': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-class-static-block': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-classes': 7.22.6(@babel/core@7.22.9) + '@babel/plugin-transform-computed-properties': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-destructuring': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-dotall-regex': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-duplicate-keys': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-dynamic-import': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-exponentiation-operator': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-export-namespace-from': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-for-of': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-function-name': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-json-strings': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-literals': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-logical-assignment-operators': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-member-expression-literals': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-modules-amd': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-modules-commonjs': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-modules-systemjs': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-modules-umd': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-new-target': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-nullish-coalescing-operator': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-numeric-separator': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-object-rest-spread': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-object-super': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-optional-catch-binding': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-optional-chaining': 7.22.6(@babel/core@7.22.9) + '@babel/plugin-transform-parameters': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-private-methods': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-private-property-in-object': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-property-literals': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-regenerator': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-reserved-words': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-shorthand-properties': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-spread': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-sticky-regex': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-template-literals': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-typeof-symbol': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-unicode-escapes': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-unicode-property-regex': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-unicode-regex': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-unicode-sets-regex': 7.22.5(@babel/core@7.22.9) + '@babel/preset-modules': 0.1.5(@babel/core@7.22.9) + '@babel/types': 7.23.4 + babel-plugin-polyfill-corejs2: 0.4.4(@babel/core@7.22.9) + babel-plugin-polyfill-corejs3: 0.8.2(@babel/core@7.22.9) + babel-plugin-polyfill-regenerator: 0.5.1(@babel/core@7.22.9) + core-js-compat: 3.31.1 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + /@babel/preset-flow@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-ta2qZ+LSiGCrP5pgcGt8xMnnkXQrq8Sa4Ulhy06BOlF5QbLw9q5hIx7bn5MrsvyTGAfh6kTOo07Q+Pfld/8Y5Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-validator-option': 7.22.5 + '@babel/plugin-transform-flow-strip-types': 7.22.5(@babel/core@7.22.9) + + /@babel/preset-modules@0.1.5(@babel/core@7.22.9): + resolution: {integrity: sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/plugin-proposal-unicode-property-regex': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-transform-dotall-regex': 7.22.5(@babel/core@7.22.9) + '@babel/types': 7.23.4 + esutils: 2.0.3 + + /@babel/preset-typescript@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-YbPaal9LxztSGhmndR46FmAbkJ/1fAsw293tSU+I5E5h+cnJ3d4GTwyUgGYmOXJYdGA+uNePle4qbaRzj2NISQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-plugin-utils': 7.22.5 + '@babel/helper-validator-option': 7.22.5 + '@babel/plugin-syntax-jsx': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-modules-commonjs': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-typescript': 7.22.9(@babel/core@7.22.9) + + /@babel/register@7.22.5(@babel/core@7.22.9): + resolution: {integrity: sha512-vV6pm/4CijSQ8Y47RH5SopXzursN35RQINfGJkmOlcpAtGuf94miFvIPhCKGQN7WGIcsgG1BHEX2KVdTYwTwUQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + clone-deep: 4.0.1 + find-cache-dir: 2.1.0 + make-dir: 2.1.0 + pirates: 4.0.6 + source-map-support: 0.5.21 + + /@babel/regjsgen@0.8.0: + resolution: {integrity: sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==} + + /@babel/runtime@7.22.6: + resolution: {integrity: sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.13.11 + + /@babel/runtime@7.23.1: + resolution: {integrity: sha512-hC2v6p8ZSI/W0HUzh3V8C5g+NwSKzKPtJwSpTjwl0o297GP9+ZLQSkdvHz46CM3LqyoXxq+5G9komY+eSqSO0g==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + dev: false + + /@babel/runtime@7.23.2: + resolution: {integrity: sha512-mM8eg4yl5D6i3lu2QKPuPH4FArvJ8KhTofbE7jwMUv9KX5mBvwPAqnV3MlyBNqdp9RyRKP6Yck8TrfYrPvX3bg==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + + /@babel/runtime@7.24.4: + resolution: {integrity: sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + + /@babel/template@7.22.5: + resolution: {integrity: sha512-X7yV7eiwAxdj9k94NEylvbVHLiVG1nvzCV2EAowhxLTwODV1jl9UzZ48leOC0sH7OnuHrIkllaBgneUykIcZaw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.22.5 + '@babel/parser': 7.22.7 + '@babel/types': 7.22.5 + + /@babel/template@7.24.0: + resolution: {integrity: sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + dev: false + + /@babel/traverse@7.22.8: + resolution: {integrity: sha512-y6LPR+wpM2I3qJrsheCTwhIinzkETbplIgPBbwvqPKc+uljeA5gP+3nP8irdYt1mjQaDnlIcG+dw8OjAco4GXw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.23.4 + '@babel/generator': 7.22.9 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-function-name': 7.22.5 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/parser': 7.23.0 + '@babel/types': 7.23.0 + debug: 4.3.4(supports-color@8.1.1) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + /@babel/traverse@7.24.1: + resolution: {integrity: sha512-xuU6o9m68KeqZbQuDt2TcKSxUw/mrsvavlEqQ1leZ/B+C9tk6E4sRWy97WaXgvq5E+nU3cXMxv3WKOCanVMCmQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/generator': 7.24.4 + '@babel/helper-environment-visitor': 7.22.20 + '@babel/helper-function-name': 7.23.0 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + debug: 4.3.4(supports-color@8.1.1) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@babel/types@7.22.5: + resolution: {integrity: sha512-zo3MIHGOkPOfoRXitsgHLjEXmlDaD/5KU1Uzuc9GNiZPhSqVxVRtxuPaSBZDsYZ9qV88AjtMtWW7ww98loJ9KA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.22.5 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + /@babel/types@7.23.0: + resolution: {integrity: sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.22.5 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + /@babel/types@7.23.4: + resolution: {integrity: sha512-7uIFwVYpoplT5jp/kVv6EF93VaJ8H+Yn5IczYiaAi98ajzjfoZfslet/e0sLh+wVBjb2qqIut1b0S26VSafsSQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.23.4 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + /@babel/types@7.24.0: + resolution: {integrity: sha512-+j7a5c253RfKh8iABBhywc8NSfP5LURe7Uh4qpsh6jc+aLJguvmIUBdjSdEMQv2bENrCR5MfRdjGo7vzS/ob7w==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.24.1 + '@babel/helper-validator-identifier': 7.22.20 + to-fast-properties: 2.0.0 + + /@base2/pretty-print-object@1.0.1: + resolution: {integrity: sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==} + dev: true + + /@bcoe/v8-coverage@0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + dev: true + + /@coinbase/wallet-sdk@3.9.1: + resolution: {integrity: sha512-cGUE8wm1/cMI8irRMVOqbFWYcnNugqCtuy2lnnHfgloBg+GRLs9RsrkOUDMdv/StfUeeKhCDyYudsXXvcL1xIA==} + dependencies: + bn.js: 5.2.1 + buffer: 6.0.3 + clsx: 1.2.1 + eth-block-tracker: 7.1.0 + eth-json-rpc-filters: 6.0.1 + eventemitter3: 5.0.1 + keccak: 3.0.4 + preact: 10.18.1 + sha.js: 2.4.11 + transitivePeerDependencies: + - supports-color + dev: false + + /@colors/colors@1.5.0: + resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + engines: {node: '>=0.1.90'} + requiresBuild: true + dev: true + optional: true + + /@discoveryjs/json-ext@0.5.7: + resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} + engines: {node: '>=10.0.0'} + dev: true + + /@emotion/babel-plugin@11.11.0: + resolution: {integrity: sha512-m4HEDZleaaCH+XgDDsPF15Ht6wTLsgDTeR3WYj9Q/k76JtWhrJjcP4+/XlG8LGT/Rol9qUfOIztXeA84ATpqPQ==} + dependencies: + '@babel/helper-module-imports': 7.24.3 + '@babel/runtime': 7.24.4 + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/serialize': 1.1.3 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + dev: false + + /@emotion/cache@11.11.0: + resolution: {integrity: sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ==} + dependencies: + '@emotion/memoize': 0.8.1 + '@emotion/sheet': 1.2.2 + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + stylis: 4.2.0 + dev: false + + /@emotion/hash@0.9.1: + resolution: {integrity: sha512-gJB6HLm5rYwSLI6PQa+X1t5CFGrv1J1TWG+sOyMCeKz2ojaj6Fnl/rZEspogG+cvqbt4AE/2eIyD2QfLKTBNlQ==} + dev: false + + /@emotion/is-prop-valid@1.2.1: + resolution: {integrity: sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==} + dependencies: + '@emotion/memoize': 0.8.1 + dev: false + + /@emotion/memoize@0.8.1: + resolution: {integrity: sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==} + dev: false + + /@emotion/react@11.11.3(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-Cnn0kuq4DoONOMcnoVsTOR8E+AdnKFf//6kUWc4LCdnxj31pZWn7rIULd6Y7/Js1PiPHzn7SKCM9vB/jBni8eA==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@emotion/babel-plugin': 11.11.0 + '@emotion/cache': 11.11.0 + '@emotion/serialize': 1.1.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@emotion/weak-memoize': 0.3.1 + '@types/react': 18.2.14 + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + dev: false + + /@emotion/serialize@1.1.3: + resolution: {integrity: sha512-iD4D6QVZFDhcbH0RAG1uVu1CwVLMWUkCvAqqlewO/rxf8+87yIBAlt4+AxMiiKPLs5hFc0owNk/sLLAOROw3cA==} + dependencies: + '@emotion/hash': 0.9.1 + '@emotion/memoize': 0.8.1 + '@emotion/unitless': 0.8.1 + '@emotion/utils': 1.2.1 + csstype: 3.1.3 + dev: false + + /@emotion/sheet@1.2.2: + resolution: {integrity: sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA==} + dev: false + + /@emotion/styled@11.11.0(@emotion/react@11.11.3)(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-hM5Nnvu9P3midq5aaXj4I+lnSfNi7Pmd4EWk1fOZ3pxookaQTNew6bp4JaCBYM4HVFZF9g7UjJmsUmC2JlxOng==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@emotion/babel-plugin': 11.11.0 + '@emotion/is-prop-valid': 1.2.1 + '@emotion/react': 11.11.3(@types/react@18.2.14)(react@18.2.0) + '@emotion/serialize': 1.1.3 + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@emotion/utils': 1.2.1 + '@types/react': 18.2.14 + react: 18.2.0 + dev: false + + /@emotion/unitless@0.8.1: + resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==} + dev: false + + /@emotion/use-insertion-effect-with-fallbacks@1.0.1(react@18.2.0): + resolution: {integrity: sha512-jT/qyKZ9rzLErtrjGgdkMBn2OP8wl0G3sQlBb3YPryvKHsjvINUhVaPFfP+fpBcOkmrVOVEEHQFJ7nbj2TH2gw==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.2.0 + + /@emotion/utils@1.2.1: + resolution: {integrity: sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg==} + dev: false + + /@emotion/weak-memoize@0.3.1: + resolution: {integrity: sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww==} + dev: false + + /@esbuild/aix-ppc64@0.19.12: + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.17.19: + resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.18.20: + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/android-arm64@0.19.12: + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.17.19: + resolution: {integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.18.20: + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/android-arm@0.19.12: + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.17.19: + resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.18.20: + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + optional: true + + /@esbuild/android-x64@0.19.12: + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.17.19: + resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.18.20: + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + optional: true + + /@esbuild/darwin-arm64@0.19.12: + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.17.19: + resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.18.20: + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + optional: true + + /@esbuild/darwin-x64@0.19.12: + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.17.19: + resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.18.20: + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + optional: true + + /@esbuild/freebsd-arm64@0.19.12: + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.17.19: + resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.18.20: + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + optional: true + + /@esbuild/freebsd-x64@0.19.12: + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.17.19: + resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.18.20: + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-arm64@0.19.12: + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.17.19: + resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.18.20: + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-arm@0.19.12: + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.17.19: + resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.18.20: + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-ia32@0.19.12: + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.17.19: + resolution: {integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.18.20: + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-loong64@0.19.12: + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.17.19: + resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.18.20: + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-mips64el@0.19.12: + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.17.19: + resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.18.20: + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-ppc64@0.19.12: + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.17.19: + resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.18.20: + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-riscv64@0.19.12: + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.17.19: + resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.18.20: + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-s390x@0.19.12: + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.17.19: + resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.18.20: + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + optional: true + + /@esbuild/linux-x64@0.19.12: + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.17.19: + resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.18.20: + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + optional: true + + /@esbuild/netbsd-x64@0.19.12: + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.17.19: + resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.18.20: + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + optional: true + + /@esbuild/openbsd-x64@0.19.12: + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.17.19: + resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.18.20: + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + optional: true + + /@esbuild/sunos-x64@0.19.12: + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.17.19: + resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.18.20: + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + optional: true + + /@esbuild/win32-arm64@0.19.12: + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.17.19: + resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.18.20: + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + optional: true + + /@esbuild/win32-ia32@0.19.12: + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.17.19: + resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.18.20: + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + optional: true + + /@esbuild/win32-x64@0.19.12: + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.44.0): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 8.44.0 + eslint-visitor-keys: 3.4.1 + dev: true + + /@eslint-community/regexpp@4.5.1: + resolution: {integrity: sha512-Z5ba73P98O1KUYCCJTUeVpja9RcGoMdncZ6T49FCUl2lN38JtCJ+3WgIDBv0AuY4WChU5PmtJmOCTlN6FZTFKQ==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.1.0: + resolution: {integrity: sha512-Lj7DECXqIVCqnqjjHMPna4vn6GJcMgul/wuS0je9OZ9gsL0zzDpKPVtcG1HaDVc+9y+qgXneTeUMbCqXJNpH1A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.4(supports-color@8.1.1) + espree: 9.6.1 + globals: 13.20.0 + ignore: 5.2.4 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/js@8.44.0: + resolution: {integrity: sha512-Ag+9YM4ocKQx9AarydN0KY2j0ErMHNIocPDrVo8zAE44xLTjEtz81OdR68/cydGtk6m6jDb5Za3r2useMzYmSw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@ethereumjs/common@3.2.0: + resolution: {integrity: sha512-pksvzI0VyLgmuEF2FA/JR/4/y6hcPq8OUail3/AvycBaW1d5VSauOZzqGvJ3RTmR4MU35lWE8KseKOsEhrFRBA==} + dependencies: + '@ethereumjs/util': 8.1.0 + crc-32: 1.2.2 + dev: false + + /@ethereumjs/rlp@4.0.1: + resolution: {integrity: sha512-tqsQiBQDQdmPWE1xkkBq4rlSW5QZpLOUJ5RJh2/9fug+q9tnUhuZoVLk7s0scUIKTOzEtR72DFBXI4WiZcMpvw==} + engines: {node: '>=14'} + hasBin: true + dev: false + + /@ethereumjs/tx@4.2.0: + resolution: {integrity: sha512-1nc6VO4jtFd172BbSnTnDQVr9IYBFl1y4xPzZdtkrkKIncBCkdbgfdRV+MiTkJYAtTxvV12GRZLqBFT1PNK6Yw==} + engines: {node: '>=14'} + dependencies: + '@ethereumjs/common': 3.2.0 + '@ethereumjs/rlp': 4.0.1 + '@ethereumjs/util': 8.1.0 + ethereum-cryptography: 2.1.3 + dev: false + + /@ethereumjs/util@8.1.0: + resolution: {integrity: sha512-zQ0IqbdX8FZ9aw11vP+dZkKDkS+kgIvQPHnSAXzP9pLu+Rfu3D3XEeLbicvoXJTYnhZiPmsZUxgdzXwNKxRPbA==} + engines: {node: '>=14'} + dependencies: + '@ethereumjs/rlp': 4.0.1 + ethereum-cryptography: 2.1.3 + micro-ftch: 0.3.1 + dev: false + + /@ethersproject/abi@5.7.0: + resolution: {integrity: sha512-351ktp42TiRcYB3H1OP8yajPeAQstMW/yCFokj/AthP9bLHzQFPlOrxOcwYEDkUAICmOHljvN4K39OMTMUa9RA==} + dependencies: + '@ethersproject/address': 5.7.0 + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/constants': 5.7.0 + '@ethersproject/hash': 5.7.0 + '@ethersproject/keccak256': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/properties': 5.7.0 + '@ethersproject/strings': 5.7.0 + dev: false + + /@ethersproject/abstract-provider@5.7.0: + resolution: {integrity: sha512-R41c9UkchKCpAqStMYUpdunjo3pkEvZC3FAwZn5S5MGbXoMQOHIdHItezTETxAO5bevtMApSyEhn9+CHcDsWBw==} + dependencies: + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/networks': 5.7.1 + '@ethersproject/properties': 5.7.0 + '@ethersproject/transactions': 5.7.0 + '@ethersproject/web': 5.7.1 + dev: false + + /@ethersproject/abstract-signer@5.7.0: + resolution: {integrity: sha512-a16V8bq1/Cz+TGCkE2OPMTOUDLS3grCpdjoJCYNnVBbdYEMSgKrU0+B90s8b6H+ByYTBZN7a3g76jdIJi7UfKQ==} + dependencies: + '@ethersproject/abstract-provider': 5.7.0 + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/properties': 5.7.0 + dev: false + + /@ethersproject/address@5.7.0: + resolution: {integrity: sha512-9wYhYt7aghVGo758POM5nqcOMaE168Q6aRLJZwUmiqSrAungkG74gSSeKEIR7ukixesdRZGPgVqme6vmxs1fkA==} + dependencies: + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/keccak256': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/rlp': 5.7.0 + dev: false + + /@ethersproject/base64@5.7.0: + resolution: {integrity: sha512-Dr8tcHt2mEbsZr/mwTPIQAf3Ai0Bks/7gTw9dSqk1mQvhW3XvRlmDJr/4n+wg1JmCl16NZue17CDh8xb/vZ0sQ==} + dependencies: + '@ethersproject/bytes': 5.7.0 + dev: false + + /@ethersproject/bignumber@5.7.0: + resolution: {integrity: sha512-n1CAdIHRWjSucQO3MC1zPSVgV/6dy/fjL9pMrPP9peL+QxEg9wOsVqwD4+818B6LUEtaXzVHQiuivzRoxPxUGw==} + dependencies: + '@ethersproject/bytes': 5.7.0 + '@ethersproject/logger': 5.7.0 + bn.js: 5.2.1 + dev: false + + /@ethersproject/bytes@5.7.0: + resolution: {integrity: sha512-nsbxwgFXWh9NyYWo+U8atvmMsSdKJprTcICAkvbBffT75qDocbuggBU0SJiVK2MuTrp0q+xvLkTnGMPK1+uA9A==} + dependencies: + '@ethersproject/logger': 5.7.0 + dev: false + + /@ethersproject/constants@5.7.0: + resolution: {integrity: sha512-DHI+y5dBNvkpYUMiRQyxRBYBefZkJfo70VUkUAsRjcPs47muV9evftfZ0PJVCXYbAiCgght0DtcF9srFQmIgWA==} + dependencies: + '@ethersproject/bignumber': 5.7.0 + dev: false + + /@ethersproject/hash@5.7.0: + resolution: {integrity: sha512-qX5WrQfnah1EFnO5zJv1v46a8HW0+E5xuBBDTwMFZLuVTx0tbU2kkx15NqdjxecrLGatQN9FGQKpb1FKdHCt+g==} + dependencies: + '@ethersproject/abstract-signer': 5.7.0 + '@ethersproject/address': 5.7.0 + '@ethersproject/base64': 5.7.0 + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/keccak256': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/properties': 5.7.0 + '@ethersproject/strings': 5.7.0 + dev: false + + /@ethersproject/keccak256@5.7.0: + resolution: {integrity: sha512-2UcPboeL/iW+pSg6vZ6ydF8tCnv3Iu/8tUmLLzWWGzxWKFFqOBQFLo6uLUv6BDrLgCDfN28RJ/wtByx+jZ4KBg==} + dependencies: + '@ethersproject/bytes': 5.7.0 + js-sha3: 0.8.0 + dev: false + + /@ethersproject/logger@5.7.0: + resolution: {integrity: sha512-0odtFdXu/XHtjQXJYA3u9G0G8btm0ND5Cu8M7i5vhEcE8/HmF4Lbdqanwyv4uQTr2tx6b7fQRmgLrsnpQlmnig==} + dev: false + + /@ethersproject/networks@5.7.1: + resolution: {integrity: sha512-n/MufjFYv3yFcUyfhnXotyDlNdFb7onmkSy8aQERi2PjNcnWQ66xXxa3XlS8nCcA8aJKJjIIMNJTC7tu80GwpQ==} + dependencies: + '@ethersproject/logger': 5.7.0 + dev: false + + /@ethersproject/properties@5.7.0: + resolution: {integrity: sha512-J87jy8suntrAkIZtecpxEPxY//szqr1mlBaYlQ0r4RCaiD2hjheqF9s1LVE8vVuJCXisjIP+JgtK/Do54ej4Sw==} + dependencies: + '@ethersproject/logger': 5.7.0 + dev: false + + /@ethersproject/rlp@5.7.0: + resolution: {integrity: sha512-rBxzX2vK8mVF7b0Tol44t5Tb8gomOHkj5guL+HhzQ1yBh/ydjGnpw6at+X6Iw0Kp3OzzzkcKp8N9r0W4kYSs9w==} + dependencies: + '@ethersproject/bytes': 5.7.0 + '@ethersproject/logger': 5.7.0 + dev: false + + /@ethersproject/sha2@5.7.0: + resolution: {integrity: sha512-gKlH42riwb3KYp0reLsFTokByAKoJdgFCwI+CCiX/k+Jm2mbNs6oOaCjYQSlI1+XBVejwH2KrmCbMAT/GnRDQw==} + dependencies: + '@ethersproject/bytes': 5.7.0 + '@ethersproject/logger': 5.7.0 + hash.js: 1.1.7 + dev: false + + /@ethersproject/signing-key@5.7.0: + resolution: {integrity: sha512-MZdy2nL3wO0u7gkB4nA/pEf8lu1TlFswPNmy8AiYkfKTdO6eXBJyUdmHO/ehm/htHw9K/qF8ujnTyUAD+Ry54Q==} + dependencies: + '@ethersproject/bytes': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/properties': 5.7.0 + bn.js: 5.2.1 + elliptic: 6.5.4 + hash.js: 1.1.7 + dev: false + + /@ethersproject/solidity@5.7.0: + resolution: {integrity: sha512-HmabMd2Dt/raavyaGukF4XxizWKhKQ24DoLtdNbBmNKUOPqwjsKQSdV9GQtj9CBEea9DlzETlVER1gYeXXBGaA==} + dependencies: + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/keccak256': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/sha2': 5.7.0 + '@ethersproject/strings': 5.7.0 + dev: false + + /@ethersproject/strings@5.7.0: + resolution: {integrity: sha512-/9nu+lj0YswRNSH0NXYqrh8775XNyEdUQAuf3f+SmOrnVewcJ5SBNAjF7lpgehKi4abvNNXyf+HX86czCdJ8Mg==} + dependencies: + '@ethersproject/bytes': 5.7.0 + '@ethersproject/constants': 5.7.0 + '@ethersproject/logger': 5.7.0 + dev: false + + /@ethersproject/transactions@5.7.0: + resolution: {integrity: sha512-kmcNicCp1lp8qanMTC3RIikGgoJ80ztTyvtsFvCYpSCfkjhD0jZ2LOrnbcuxuToLIUYYf+4XwD1rP+B/erDIhQ==} + dependencies: + '@ethersproject/address': 5.7.0 + '@ethersproject/bignumber': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/constants': 5.7.0 + '@ethersproject/keccak256': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/properties': 5.7.0 + '@ethersproject/rlp': 5.7.0 + '@ethersproject/signing-key': 5.7.0 + dev: false + + /@ethersproject/web@5.7.1: + resolution: {integrity: sha512-Gueu8lSvyjBWL4cYsWsjh6MtMwM0+H4HvqFPZfB6dV8ctbP9zFAO73VG1cMWae0FLPCtz0peKPpZY8/ugJJX2w==} + dependencies: + '@ethersproject/base64': 5.7.0 + '@ethersproject/bytes': 5.7.0 + '@ethersproject/logger': 5.7.0 + '@ethersproject/properties': 5.7.0 + '@ethersproject/strings': 5.7.0 + dev: false + + /@fal-works/esbuild-plugin-global-externals@2.1.2: + resolution: {integrity: sha512-cEee/Z+I12mZcFJshKcCqC8tuX5hG3s+d+9nZ3LabqKF1vKdF41B92pJVCBggjAGORAeOzyyDDKrZwIkLffeOQ==} + dev: true + + /@fastify/busboy@2.1.1: + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + dev: false + + /@floating-ui/core@1.5.0: + resolution: {integrity: sha512-kK1h4m36DQ0UHGj5Ah4db7R0rHemTqqO0QLvUqi1/mUUp3LuAWbWxdxSIf/XsnH9VS6rRVPLJCncjRzUvyCLXg==} + dependencies: + '@floating-ui/utils': 0.1.6 + + /@floating-ui/dom@1.5.3: + resolution: {integrity: sha512-ClAbQnEqJAKCJOEbbLo5IUlZHkNszqhuxS4fHAVxRPXPya6Ysf2G8KypnYcOTpx6I8xcgF9bbHb6g/2KpbV8qA==} + dependencies: + '@floating-ui/core': 1.5.0 + '@floating-ui/utils': 0.1.6 + + /@floating-ui/react-dom@2.0.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5qhlDvjaLmAst/rKb3VdlCinwTF4EYMiVxuuc/HVUjs46W0zgtbMmAZ1UTsDrRTxRmUEzl92mOtWbeeXL26lSQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@floating-ui/dom': 1.5.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@floating-ui/utils@0.1.6: + resolution: {integrity: sha512-OfX7E2oUDYxtBvsuS4e/jSn4Q9Qb6DzgeYtsAdkPZ47znpoNsMgZw0+tVijiv3uGNR6dgNlty6r9rzIzHjtd/A==} + + /@graphql-typed-document-node/core@3.2.0(graphql@16.8.1): + resolution: {integrity: sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==} + peerDependencies: + graphql: ^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0 + dependencies: + graphql: 16.8.1 + dev: false + + /@hapi/hoek@9.3.0: + resolution: {integrity: sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==} + dev: false + + /@hapi/topo@5.1.0: + resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==} + dependencies: + '@hapi/hoek': 9.3.0 + dev: false + + /@hookform/resolvers@3.3.2(react-hook-form@7.48.2): + resolution: {integrity: sha512-Tw+GGPnBp+5DOsSg4ek3LCPgkBOuOgS5DsDV7qsWNH9LZc433kgsWICjlsh2J9p04H2K66hsXPPb9qn9ILdUtA==} + peerDependencies: + react-hook-form: ^7.0.0 + dependencies: + react-hook-form: 7.48.2(react@18.2.0) + dev: false + + /@humanwhocodes/config-array@0.11.10: + resolution: {integrity: sha512-KVVjQmNUepDVGXNuoRRdmmEjruj0KfiGSbS8LVc12LMsWDQzRXJ0qdhN8L8uUigKpfEHRhlaQFY0ib1tnUbNeQ==} + engines: {node: '>=10.10.0'} + dependencies: + '@humanwhocodes/object-schema': 1.2.1 + debug: 4.3.4(supports-color@8.1.1) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@1.2.1: + resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + dev: true + + /@ioredis/commands@1.2.0: + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: false + + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + + /@isaacs/ttlcache@1.4.1: + resolution: {integrity: sha512-RQgQ4uQ+pLbqXfOmieB91ejmLwvSgv9nLx6sT6sD83s7umBypgg+OIBOBbEUiJXrfpnp9j0mRhYYdzp9uqq3lA==} + engines: {node: '>=12'} + dev: false + + /@istanbuljs/load-nyc-config@1.1.0: + resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} + engines: {node: '>=8'} + dependencies: + camelcase: 5.3.1 + find-up: 4.1.0 + get-package-type: 0.1.0 + js-yaml: 3.14.1 + resolve-from: 5.0.0 + dev: true + + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + dev: true + + /@jest/create-cache-key-function@29.7.0: + resolution: {integrity: sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + dev: false + + /@jest/environment@29.7.0: + resolution: {integrity: sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.12.7 + jest-mock: 29.7.0 + dev: false + + /@jest/expect-utils@29.6.1: + resolution: {integrity: sha512-o319vIf5pEMx0LmzSxxkYYxo4wrRLKHq9dP1yJU7FoPTB0LfAKSz8SWD6D/6U3v/O52t9cF5t+MeJiRsfk7zMw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + jest-get-type: 29.6.3 + dev: true + + /@jest/fake-timers@29.7.0: + resolution: {integrity: sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@sinonjs/fake-timers': 10.3.0 + '@types/node': 20.12.7 + jest-message-util: 29.7.0 + jest-mock: 29.7.0 + jest-util: 29.7.0 + dev: false + + /@jest/schemas@28.1.3: + resolution: {integrity: sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@sinclair/typebox': 0.24.51 + dev: true + + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + + /@jest/transform@29.6.1: + resolution: {integrity: sha512-URnTneIU3ZjRSaf906cvf6Hpox3hIeJXRnz3VDSw5/X93gR8ycdfSIEy19FlVx8NFmpN7fe3Gb1xF+NjXaQLWg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/core': 7.22.9 + '@jest/types': 29.6.3 + '@jridgewell/trace-mapping': 0.3.20 + babel-plugin-istanbul: 6.1.1 + chalk: 4.1.2 + convert-source-map: 2.0.0 + fast-json-stable-stringify: 2.1.0 + graceful-fs: 4.2.11 + jest-haste-map: 29.6.1 + jest-regex-util: 29.4.3 + jest-util: 29.6.1 + micromatch: 4.0.5 + pirates: 4.0.6 + slash: 3.0.0 + write-file-atomic: 4.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@jest/types@26.6.2: + resolution: {integrity: sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==} + engines: {node: '>= 10.14.2'} + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 20.12.7 + '@types/yargs': 15.0.19 + chalk: 4.1.2 + dev: false + + /@jest/types@27.5.1: + resolution: {integrity: sha512-Cx46iJ9QpwQTjIdq5VJu2QTMMs3QlEjI0x1QbBP5W1+nMzyc2XmimiRR/CbX9TO0cPTeUlxWMOu8mslYsJ8DEw==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 20.8.9 + '@types/yargs': 16.0.5 + chalk: 4.1.2 + dev: true + + /@jest/types@29.6.1: + resolution: {integrity: sha512-tPKQNMPuXgvdOn2/Lg9HNfUvjYVGolt04Hp03f5hAk878uwOLikN+JzeLY0HcVgKgFl9Hs3EIqpu3WX27XNhnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 20.10.0 + '@types/yargs': 17.0.24 + chalk: 4.1.2 + dev: true + + /@jest/types@29.6.3: + resolution: {integrity: sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + '@types/istanbul-lib-coverage': 2.0.4 + '@types/istanbul-reports': 3.0.1 + '@types/node': 20.8.9 + '@types/yargs': 17.0.24 + chalk: 4.1.2 + + /@jetstreamgg/hooks@1.0.6(@tanstack/react-query@5.28.8)(@wagmi/core@2.6.17)(hardhat@2.22.2)(react-dom@18.2.0)(react@18.2.0)(viem@2.9.21)(wagmi@2.5.20): + resolution: {integrity: sha512-FzdGO4u2zHOJaxFtZmaxWbiCafoTQOlnaP0DfQlyxvCGXXgVxu9ZkI6GufEaVBuJ4VoHfoBrq/taNmJSKUZpXQ==} + peerDependencies: + '@tanstack/react-query': ^5.17.15 + '@wagmi/core': ^2.6.5 + react: 18.2.0 + react-dom: 18.2.0 + viem: ^2.7.15 + wagmi: ^2.5.7 + dependencies: + '@jetstreamgg/utils': 0.1.8(react-dom@18.2.0)(react@18.2.0)(viem@2.9.21) + '@tanstack/react-query': 5.28.8(react@18.2.0) + '@uniswap/sdk-core': 4.2.0 + '@uniswap/v3-sdk': 3.11.0(hardhat@2.22.2) + '@wagmi/core': 2.6.17(@types/react@18.2.14)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4) + front-matter: 4.0.2 + graphql: 16.8.1 + graphql-request: 5.2.0(graphql@16.8.1) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + viem: 2.9.21(typescript@5.4.5)(zod@3.22.4) + wagmi: 2.5.20(@tanstack/react-query@5.28.8)(@types/react@18.2.14)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4) + transitivePeerDependencies: + - bufferutil + - encoding + - hardhat + - utf-8-validate + dev: false + + /@jetstreamgg/utils@0.1.8(react-dom@18.2.0)(react@18.2.0)(viem@2.9.21): + resolution: {integrity: sha512-UZkWy0JA+KoygFMy8X7baMwDtYQ/CB6TsvBYy16vX9NMmHu/Pyaa3BKtEThtz0nOo3eCYDSo7mi5NUKTR7cj9w==} + peerDependencies: + react: 18.2.0 + react-dom: 18.2.0 + viem: 1.1.8 + dependencies: + date-fns: 2.30.0 + ethers: 6.8.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tiny-invariant: 1.3.1 + viem: 2.9.21(typescript@5.4.5)(zod@3.22.4) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /@joshwooding/vite-plugin-react-docgen-typescript@0.3.0(typescript@5.4.5)(vite@4.5.0): + resolution: {integrity: sha512-2D6y7fNvFmsLmRt6UCOFJPvFoPMJGT0Uh1Wg0RaigUp7kdQPs6yYn8Dmx6GZkOH/NW0yMTwRz/p0SRMMRo50vA==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + glob: 7.2.3 + glob-promise: 4.2.2(glob@7.2.3) + magic-string: 0.27.0 + react-docgen-typescript: 2.2.2(typescript@5.4.5) + typescript: 5.4.5 + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + dev: true + + /@jridgewell/gen-mapping@0.3.3: + resolution: {integrity: sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.19 + + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/trace-mapping': 0.3.25 + + /@jridgewell/resolve-uri@3.1.1: + resolution: {integrity: sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==} + engines: {node: '>=6.0.0'} + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + /@jridgewell/source-map@0.3.6: + resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + + /@jridgewell/sourcemap-codec@1.4.15: + resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} + + /@jridgewell/trace-mapping@0.3.19: + resolution: {integrity: sha512-kf37QtfW+Hwx/buWGMPcR60iF9ziHa6r/CZJIHbmcm4+0qrXiVdxegAH0F6yddEVQ7zdkjcGCgCzUu+BcbhQxw==} + dependencies: + '@jridgewell/resolve-uri': 3.1.1 + '@jridgewell/sourcemap-codec': 1.4.15 + + /@jridgewell/trace-mapping@0.3.20: + resolution: {integrity: sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.4.15 + + /@juggle/resize-observer@3.4.0: + resolution: {integrity: sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==} + + /@lingui/babel-plugin-extract-messages@4.5.0: + resolution: {integrity: sha512-jZq3Gbi691jsHyQ4+OPnGgIqZt5eKEGnmI75akYlZpwTPxF7n+hiuKlQS+YB3xfKvcvlAED76ZAMCcwYG5fNrQ==} + engines: {node: '>=16.0.0'} + dev: true + + /@lingui/cli@4.5.0: + resolution: {integrity: sha512-MzhxNUNd+YYEmK79TwmneUow5BuLwpOlrUrZq9EyIAWUM4N6kkCVkZ8VIMYCL4TXGQ4kBQjstgDpkF8wdFRtNg==} + engines: {node: '>=16.0.0'} + hasBin: true + dependencies: + '@babel/core': 7.22.9 + '@babel/generator': 7.22.9 + '@babel/parser': 7.23.0 + '@babel/runtime': 7.23.2 + '@babel/types': 7.23.0 + '@lingui/babel-plugin-extract-messages': 4.5.0 + '@lingui/conf': 4.5.0 + '@lingui/core': 4.5.0 + '@lingui/format-po': 4.5.0 + '@lingui/message-utils': 4.5.0 + babel-plugin-macros: 3.1.0 + chalk: 4.1.2 + chokidar: 3.5.1 + cli-table: 0.3.6 + commander: 10.0.1 + convert-source-map: 2.0.0 + date-fns: 2.30.0 + esbuild: 0.17.19 + glob: 7.2.3 + inquirer: 7.3.3 + micromatch: 4.0.2 + normalize-path: 3.0.0 + ora: 5.4.1 + pathe: 1.1.1 + pkg-up: 3.1.0 + pofile: 1.1.4 + pseudolocale: 2.0.0 + ramda: 0.27.2 + source-map: 0.8.0-beta.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@lingui/conf@4.5.0: + resolution: {integrity: sha512-OBm4RQQtbpvmuazLWVpvpaOpt/xvu1PBv8WUX8QoW1vsROe/3P5BpRHRYFyMeZz5mhORJgis9lQtDTq145Ruug==} + engines: {node: '>=16.0.0'} + dependencies: + '@babel/runtime': 7.23.2 + chalk: 4.1.2 + cosmiconfig: 8.2.0 + jest-validate: 29.7.0 + jiti: 1.19.1 + lodash.get: 4.4.2 + + /@lingui/core@4.5.0: + resolution: {integrity: sha512-8zTuIXJo5Qvjato7LWE6Q4RHiO4LjTBVOoRlqfOGYDp8VZ9w9P7Z7IJgxI7UP5Z1wiuEvnMdVF9I1C4acqXGlQ==} + engines: {node: '>=16.0.0'} + dependencies: + '@babel/runtime': 7.23.2 + '@lingui/message-utils': 4.5.0 + unraw: 3.0.0 + + /@lingui/format-po@4.5.0: + resolution: {integrity: sha512-xQNzZ4RCQfh6TjzjUsyHz3B0R9FJuzhBit9R37NyMn6mL3kBTCUExpPczknm8gWZjtfFO4T8EH5eJhhC5vgJYg==} + engines: {node: '>=16.0.0'} + dependencies: + '@lingui/conf': 4.5.0 + '@lingui/message-utils': 4.5.0 + date-fns: 2.30.0 + pofile: 1.1.4 + dev: true + + /@lingui/macro@4.5.0(@lingui/react@4.5.0)(babel-plugin-macros@3.1.0): + resolution: {integrity: sha512-6qha9YXuNnta4HCR+g6J6UPaAuAFlM1duqgznh4X7hHSsFG+m6oX7/srAMfU41Z8lbDmgXc3raqHLXFSdUNbYQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + '@lingui/react': ^4.0.0 + babel-plugin-macros: 2 || 3 + dependencies: + '@babel/runtime': 7.23.2 + '@babel/types': 7.23.0 + '@lingui/conf': 4.5.0 + '@lingui/core': 4.5.0 + '@lingui/message-utils': 4.5.0 + '@lingui/react': 4.5.0(react@18.2.0) + babel-plugin-macros: 3.1.0 + + /@lingui/message-utils@4.5.0: + resolution: {integrity: sha512-iRqh2wvNtzJO3NStB77nEXEfeI53aVVjzD7/mBrEm/P0lC7sqPHk0WBQCfzE0N9xm6a+XHmHu3J+x2nnQ2OjcA==} + engines: {node: '>=16.0.0'} + dependencies: + '@messageformat/parser': 5.1.0 + + /@lingui/react@4.5.0(react@18.2.0): + resolution: {integrity: sha512-dv/oxBshyaVJ3XzbPDnWn3abhwtaS1sx8cEO2qDjs+OhW0AeWD9hyVDrduf5SBIuXFJfJQNNA8+2P2nO0lxRbQ==} + engines: {node: '>=16.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.23.2 + '@lingui/core': 4.5.0 + react: 18.2.0 + + /@lingui/swc-plugin@4.0.4(@lingui/macro@4.5.0): + resolution: {integrity: sha512-xRnR96Mqi6zwGlVfGJMfoM8QykBbUz/sSnwmcFL9BZ8Y9YBZxzLAVf4t1BbiIQsAs+pMYu/HfujTBD4y/r1ucA==} + peerDependencies: + '@lingui/macro': '4' + '@swc/core': '*' + next: '*' + peerDependenciesMeta: + '@swc/core': + optional: true + next: + optional: true + dependencies: + '@lingui/macro': 4.5.0(@lingui/react@4.5.0)(babel-plugin-macros@3.1.0) + dev: true + + /@lingui/vite-plugin@4.5.0(vite@4.5.0): + resolution: {integrity: sha512-REAzk7BgoK+jSytTlBvtPXfkE3nbWpUzy1qjupk0EfDxVtLiPIQk7EpNQ6Qk7F5Av3udWlHL7z+dBF7TY/MQBg==} + engines: {node: '>=16.0.0'} + peerDependencies: + vite: 3 - 4 + dependencies: + '@lingui/cli': 4.5.0 + '@lingui/conf': 4.5.0 + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + transitivePeerDependencies: + - supports-color + dev: true + + /@lit-labs/ssr-dom-shim@1.1.2: + resolution: {integrity: sha512-jnOD+/+dSrfTWYfSXBXlo5l5f0q1UuJo3tkbMDCYA2lKUYq79jaxqtGEvnRoh049nt1vdo1+45RinipU6FGY2g==} + dev: false + + /@lit/reactive-element@1.6.3: + resolution: {integrity: sha512-QuTgnG52Poic7uM1AN5yJ09QMe0O28e10XzSvWDz02TJiiKee4stsiownEIadWm8nYzyDAyT+gKzUoZmiWQtsQ==} + dependencies: + '@lit-labs/ssr-dom-shim': 1.1.2 + dev: false + + /@mdx-js/react@2.3.0(react@18.2.0): + resolution: {integrity: sha512-zQH//gdOmuu7nt2oJR29vFhDv88oGPmVw6BggmrHeMI+xgEkp1B2dX9/bMBSYtK0dyLX/aOmesKS09g222K1/g==} + peerDependencies: + react: '>=16' + dependencies: + '@types/mdx': 2.0.5 + '@types/react': 18.2.14 + react: 18.2.0 + dev: true + + /@messageformat/parser@5.1.0: + resolution: {integrity: sha512-jKlkls3Gewgw6qMjKZ9SFfHUpdzEVdovKFtW1qRhJ3WI4FW5R/NnGDqr8SDGz+krWDO3ki94boMmQvGke1HwUQ==} + dependencies: + moo: 0.5.2 + + /@metamask/eth-json-rpc-provider@1.0.1: + resolution: {integrity: sha512-whiUMPlAOrVGmX8aKYVPvlKyG4CpQXiNNyt74vE1xb5sPvmx5oA7B/kOi/JdBvhGQq97U1/AVdXEdk2zkP8qyA==} + engines: {node: '>=14.0.0'} + dependencies: + '@metamask/json-rpc-engine': 7.3.2 + '@metamask/safe-event-emitter': 3.0.0 + '@metamask/utils': 5.0.2 + transitivePeerDependencies: + - supports-color + dev: false + + /@metamask/eth-sig-util@4.0.1: + resolution: {integrity: sha512-tghyZKLHZjcdlDqCA3gNZmLeR0XvOE9U1qoQO9ohyAZT6Pya+H9vkBPcsyXytmYLNgVoin7CKCmweo/R43V+tQ==} + engines: {node: '>=12.0.0'} + dependencies: + ethereumjs-abi: 0.6.8 + ethereumjs-util: 6.2.1 + ethjs-util: 0.1.6 + tweetnacl: 1.0.3 + tweetnacl-util: 0.15.1 + dev: false + + /@metamask/json-rpc-engine@7.3.2: + resolution: {integrity: sha512-dVjBPlni4CoiBpESVqrxh6k4OR14w6GRXKSSXHFuITjuhALE42gNCkXTpL4cjNeOBUgTba3eGe5EI8cyc2QLRg==} + engines: {node: '>=16.0.0'} + dependencies: + '@metamask/rpc-errors': 6.1.0 + '@metamask/safe-event-emitter': 3.0.0 + '@metamask/utils': 8.3.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@metamask/object-multiplex@1.3.0: + resolution: {integrity: sha512-czcQeVYdSNtabd+NcYQnrM69MciiJyd1qvKH8WM2Id3C0ZiUUX5Xa/MK+/VUk633DBhVOwdNzAKIQ33lGyA+eQ==} + engines: {node: '>=12.0.0'} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + readable-stream: 2.3.8 + dev: false + + /@metamask/onboarding@1.0.1: + resolution: {integrity: sha512-FqHhAsCI+Vacx2qa5mAFcWNSrTcVGMNjzxVgaX8ECSny/BJ9/vgXP9V7WF/8vb9DltPeQkxr+Fnfmm6GHfmdTQ==} + dependencies: + bowser: 2.11.0 + dev: false + + /@metamask/post-message-stream@6.2.0: + resolution: {integrity: sha512-WunZ0bruClF862mvbKQGETn5SM0XKGmocPMQR1Ew6sYix9/FDzeoZnoI8RkXk01E+70FCdxhTE/r8kk5SFOuTw==} + engines: {node: '>=14.0.0'} + dependencies: + '@metamask/utils': 5.0.2 + readable-stream: 2.3.3 + transitivePeerDependencies: + - supports-color + dev: false + + /@metamask/providers@10.2.1: + resolution: {integrity: sha512-p2TXw2a1Nb8czntDGfeIYQnk4LLVbd5vlcb3GY//lylYlKdSqp+uUTegCvxiFblRDOT68jsY8Ib1VEEzVUOolA==} + engines: {node: '>=14.0.0'} + dependencies: + '@metamask/object-multiplex': 1.3.0 + '@metamask/safe-event-emitter': 2.0.0 + '@types/chrome': 0.0.136 + detect-browser: 5.3.0 + eth-rpc-errors: 4.0.3 + extension-port-stream: 2.1.1 + fast-deep-equal: 2.0.1 + is-stream: 2.0.1 + json-rpc-engine: 6.1.0 + json-rpc-middleware-stream: 4.2.3 + pump: 3.0.0 + webextension-polyfill-ts: 0.25.0 + dev: false + + /@metamask/rpc-errors@6.1.0: + resolution: {integrity: sha512-JQElKxai26FpDyRKO/yH732wI+BV90i1u6pOuDOpdADSbppB2g1pPh3AGST1zkZqEE9eIKIUw8UdBQ4rp3VTSg==} + engines: {node: '>=16.0.0'} + dependencies: + '@metamask/utils': 8.3.0 + fast-safe-stringify: 2.1.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@metamask/safe-event-emitter@2.0.0: + resolution: {integrity: sha512-/kSXhY692qiV1MXu6EeOZvg5nECLclxNXcKCxJ3cXQgYuRymRHpdx/t7JXfsK+JLjwA1e1c1/SBrlQYpusC29Q==} + dev: false + + /@metamask/safe-event-emitter@3.0.0: + resolution: {integrity: sha512-j6Z47VOmVyGMlnKXZmL0fyvWfEYtKWCA9yGZkU3FCsGZUT5lHGmvaV9JA5F2Y+010y7+ROtR3WMXIkvl/nVzqQ==} + engines: {node: '>=12.0.0'} + dev: false + + /@metamask/sdk-communication-layer@0.14.3: + resolution: {integrity: sha512-yjSbj8y7fFbQXv2HBzUX6D9C8BimkCYP6BDV7hdw53W8b/GlYCtXVxUFajQ9tuO1xPTRjR/xt/dkdr2aCi6WGw==} + dependencies: + bufferutil: 4.0.8 + cross-fetch: 3.1.8 + date-fns: 2.30.0 + eciesjs: 0.3.18 + eventemitter2: 6.4.9 + socket.io-client: 4.7.4(bufferutil@4.0.8)(utf-8-validate@6.0.3) + utf-8-validate: 6.0.3 + uuid: 8.3.2 + transitivePeerDependencies: + - encoding + - supports-color + dev: false + + /@metamask/sdk-install-modal-web@0.14.1(@types/react@18.2.14)(react-native@0.73.6): + resolution: {integrity: sha512-emT8HKbnfVwGhPxyUfMja6DWzvtJvDEBQxqCVx93H0HsyrrOzOC43iGCAosslw6o5h7gOfRKLqWmK8V7jQAS2Q==} + dependencies: + '@emotion/react': 11.11.3(@types/react@18.2.14)(react@18.2.0) + '@emotion/styled': 11.11.0(@emotion/react@11.11.3)(@types/react@18.2.14)(react@18.2.0) + i18next: 22.5.1 + qr-code-styling: 1.6.0-rc.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-i18next: 13.5.0(i18next@22.5.1)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - react-native + dev: false + + /@metamask/sdk@0.14.3(@types/react@18.2.14)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0): + resolution: {integrity: sha512-BYLs//nY2wioVSih78gOQI6sLIYY3vWkwVqXGYUgkBV+bi49bv+9S0m+hZ2cwiRaxfMYtKs0KvhAQ8weiYwDrg==} + peerDependencies: + react: ^18.2.0 + react-native: '*' + peerDependenciesMeta: + react: + optional: true + react-native: + optional: true + dependencies: + '@metamask/onboarding': 1.0.1 + '@metamask/post-message-stream': 6.2.0 + '@metamask/providers': 10.2.1 + '@metamask/sdk-communication-layer': 0.14.3 + '@metamask/sdk-install-modal-web': 0.14.1(@types/react@18.2.14)(react-native@0.73.6) + '@react-native-async-storage/async-storage': 1.22.0(react-native@0.73.6) + '@types/dom-screen-wake-lock': 1.0.3 + bowser: 2.11.0 + cross-fetch: 4.0.0 + eciesjs: 0.3.18 + eth-rpc-errors: 4.0.3 + eventemitter2: 6.4.9 + extension-port-stream: 2.1.1 + i18next: 22.5.1 + i18next-browser-languagedetector: 7.2.0 + obj-multiplex: 1.0.0 + pump: 3.0.0 + qrcode-terminal-nooctal: 0.12.1 + react: 18.2.0 + react-i18next: 13.5.0(i18next@22.5.1)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0) + react-native: 0.73.6(@babel/core@7.22.9)(@babel/preset-env@7.22.9)(react@18.2.0) + react-native-webview: 11.26.1(react-native@0.73.6)(react@18.2.0) + readable-stream: 2.3.8 + rollup-plugin-visualizer: 5.12.0 + socket.io-client: 4.7.4(bufferutil@4.0.8)(utf-8-validate@6.0.3) + util: 0.12.5 + uuid: 8.3.2 + transitivePeerDependencies: + - '@types/react' + - bufferutil + - encoding + - react-dom + - rollup + - supports-color + - utf-8-validate + dev: false + + /@metamask/utils@5.0.2: + resolution: {integrity: sha512-yfmE79bRQtnMzarnKfX7AEJBwFTxvTyw3nBQlu/5rmGXrjAeAMltoGxO62TFurxrQAFMNa/fEjIHNvungZp0+g==} + engines: {node: '>=14.0.0'} + dependencies: + '@ethereumjs/tx': 4.2.0 + '@types/debug': 4.1.9 + debug: 4.3.4(supports-color@8.1.1) + semver: 7.6.0 + superstruct: 1.0.3 + transitivePeerDependencies: + - supports-color + dev: false + + /@metamask/utils@8.3.0: + resolution: {integrity: sha512-WFVcMPEkKKRCJ8DDkZUTVbLlpwgRn98F4VM/WzN89HM8PmHMnCyk/oG0AmK/seOxtik7uC7Bbi2YBC5Z5XB2zw==} + engines: {node: '>=16.0.0'} + dependencies: + '@ethereumjs/tx': 4.2.0 + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.6 + '@types/debug': 4.1.9 + debug: 4.3.4(supports-color@8.1.1) + pony-cause: 2.1.10 + semver: 7.6.0 + superstruct: 1.0.3 + transitivePeerDependencies: + - supports-color + dev: false + + /@motionone/animation@10.16.3: + resolution: {integrity: sha512-QUGWpLbMFLhyqKlngjZhjtxM8IqiJQjLK0DF+XOF6od9nhSvlaeEpOY/UMCRVcZn/9Tr2rZO22EkuCIjYdI74g==} + dependencies: + '@motionone/easing': 10.16.3 + '@motionone/types': 10.16.3 + '@motionone/utils': 10.16.3 + tslib: 2.6.2 + dev: false + + /@motionone/dom@10.16.4: + resolution: {integrity: sha512-HPHlVo/030qpRj9R8fgY50KTN4Ko30moWRTA3L3imrsRBmob93cTYmodln49HYFbQm01lFF7X523OkKY0DX6UA==} + dependencies: + '@motionone/animation': 10.16.3 + '@motionone/generators': 10.16.4 + '@motionone/types': 10.16.3 + '@motionone/utils': 10.16.3 + hey-listen: 1.0.8 + tslib: 2.6.2 + dev: false + + /@motionone/easing@10.16.3: + resolution: {integrity: sha512-HWTMZbTmZojzwEuKT/xCdvoMPXjYSyQvuVM6jmM0yoGU6BWzsmYMeB4bn38UFf618fJCNtP9XeC/zxtKWfbr0w==} + dependencies: + '@motionone/utils': 10.16.3 + tslib: 2.6.2 + dev: false + + /@motionone/generators@10.16.4: + resolution: {integrity: sha512-geFZ3w0Rm0ZXXpctWsSf3REGywmLLujEjxPYpBR0j+ymYwof0xbV6S5kGqqsDKgyWKVWpUInqQYvQfL6fRbXeg==} + dependencies: + '@motionone/types': 10.16.3 + '@motionone/utils': 10.16.3 + tslib: 2.6.2 + dev: false + + /@motionone/svelte@10.16.4: + resolution: {integrity: sha512-zRVqk20lD1xqe+yEDZhMYgftsuHc25+9JSo+r0a0OWUJFocjSV9D/+UGhX4xgJsuwB9acPzXLr20w40VnY2PQA==} + dependencies: + '@motionone/dom': 10.16.4 + tslib: 2.6.2 + dev: false + + /@motionone/types@10.16.3: + resolution: {integrity: sha512-W4jkEGFifDq73DlaZs3HUfamV2t1wM35zN/zX7Q79LfZ2sc6C0R1baUHZmqc/K5F3vSw3PavgQ6HyHLd/MXcWg==} + dev: false + + /@motionone/utils@10.16.3: + resolution: {integrity: sha512-WNWDksJIxQkaI9p9Z9z0+K27xdqISGNFy1SsWVGaiedTHq0iaT6iZujby8fT/ZnZxj1EOaxJtSfUPCFNU5CRoA==} + dependencies: + '@motionone/types': 10.16.3 + hey-listen: 1.0.8 + tslib: 2.6.2 + dev: false + + /@motionone/vue@10.16.4: + resolution: {integrity: sha512-z10PF9JV6SbjFq+/rYabM+8CVlMokgl8RFGvieSGNTmrkQanfHn+15XBrhG3BgUfvmTeSeyShfOHpG0i9zEdcg==} + deprecated: Motion One for Vue is deprecated. Use Oku Motion instead https://oku-ui.com/motion + dependencies: + '@motionone/dom': 10.16.4 + tslib: 2.6.2 + dev: false + + /@ndelangen/get-tarball@3.0.9: + resolution: {integrity: sha512-9JKTEik4vq+yGosHYhZ1tiH/3WpUS0Nh0kej4Agndhox8pAdWhEx5knFVRcb/ya9knCRCs1rPxNrSXTDdfVqpA==} + dependencies: + gunzip-maybe: 1.4.2 + pump: 3.0.0 + tar-fs: 2.1.1 + dev: true + + /@nicolo-ribaudo/semver-v6@6.3.3: + resolution: {integrity: sha512-3Yc1fUTs69MG/uZbJlLSI3JISMn2UV2rg+1D/vROUqZyh3l6iYHCs7GMp+M40ZD7yOdDbYjJcU1oTJhrc+dGKg==} + hasBin: true + + /@noble/curves@1.0.0: + resolution: {integrity: sha512-2upgEu0iLiDVDZkNLeFV2+ht0BAVgQnEmCk6JsOch9Rp8xfkMCbvbAZlA2pBHQc73dbl+vFOXfqkf4uemdn0bw==} + dependencies: + '@noble/hashes': 1.3.0 + dev: false + + /@noble/curves@1.1.0: + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + dependencies: + '@noble/hashes': 1.3.1 + dev: false + + /@noble/curves@1.2.0: + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + dependencies: + '@noble/hashes': 1.3.2 + + /@noble/curves@1.3.0: + resolution: {integrity: sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA==} + dependencies: + '@noble/hashes': 1.3.3 + dev: false + + /@noble/hashes@1.2.0: + resolution: {integrity: sha512-FZfhjEDbT5GRswV3C6uvLPHMiVD6lQBmpoX5+eSiPaMTXte/IKqI5dykDxzZB/WBeK/CDuQRBWarPdi3FNY2zQ==} + dev: false + + /@noble/hashes@1.3.0: + resolution: {integrity: sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg==} + dev: false + + /@noble/hashes@1.3.1: + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} + dev: false + + /@noble/hashes@1.3.2: + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + + /@noble/hashes@1.3.3: + resolution: {integrity: sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA==} + engines: {node: '>= 16'} + + /@noble/secp256k1@1.7.1: + resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} + dev: false + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.15.0 + + /@nomicfoundation/edr-darwin-arm64@0.3.4: + resolution: {integrity: sha512-tjavrUFLWnkn0PI+jk0D83hP2jjbmeXT1QLd5NtIleyGrJ00ZWVl+sfuA2Lle3kzfOceoI2VTR0n1pZB4KJGbQ==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-darwin-x64@0.3.4: + resolution: {integrity: sha512-dXO0vlIoBosp8gf5/ah3dESMymjwit0Daef1E4Ew3gZ8q3LAdku0RC+YEQJi9f0I3QNfdgIrBTzibRZUoP+kVA==} + engines: {node: '>= 18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-linux-arm64-gnu@0.3.4: + resolution: {integrity: sha512-dv38qmFUaqkkeeA9S0JjerqruytTfHav7gbPLpZUAEXPlJGo49R0+HQxd45I0msbm6NAXbkmKEchTLApp1ohaA==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-linux-arm64-musl@0.3.4: + resolution: {integrity: sha512-CfEsb6gdCMVIlRSpWYTxoongEKHB60V6alE/y8mkfjIo7tA95wyiuvCtyo3fpiia3wQV7XoMYgIJHObHiKLKtA==} + engines: {node: '>= 18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-linux-x64-gnu@0.3.4: + resolution: {integrity: sha512-V0CpJA2lYWulgTR+zP11ftBAEwkpMAAki/AuMu3vd7HoPfjwIDzWDQR5KFU17qFmqAVz0ICRxsxDlvvBZ/PUxA==} + engines: {node: '>= 18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-linux-x64-musl@0.3.4: + resolution: {integrity: sha512-0sgTrwZajarukerU/QSb+oRdlQLnJdd7of8OlXq2wtpeTNTqemgCOwY2l2qImbWboMpVrYgcmGbINXNVPCmuJw==} + engines: {node: '>= 18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-win32-arm64-msvc@0.3.4: + resolution: {integrity: sha512-bOl3vhMtV0W9ozUMF5AZRBWw1183hhhx+e1YJdDLMaqNkBUFYi2CZbMYefDylq2OKQtOQ0gPLhZvn+z2D21Ztw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-win32-ia32-msvc@0.3.4: + resolution: {integrity: sha512-yKQCpAX0uB2dalsSwOkau3yfNXkwBJa/Ks2OPl9AjHqJ+E8AqvBEB9jRpfQrdPzElMsgZuN4mqE+wh+JxY+0Aw==} + engines: {node: '>= 18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr-win32-x64-msvc@0.3.4: + resolution: {integrity: sha512-fResvsL/fSucep1K5W6iOs8lqqKKovHLsAmigMzAYVovqkyZKgCGVS/D8IVxA0nxuGCOlNxFnVmwWtph3pbKWA==} + engines: {node: '>= 18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/edr@0.3.4: + resolution: {integrity: sha512-e4jzVeJ+VTKBFzNgKDbSVnGVbHYNZHIfMdgifQBugXPiIa6QEUzZqleh2+y4lhkXcCthnFyrTYe3jiEpUzr3cA==} + engines: {node: '>= 18'} + optionalDependencies: + '@nomicfoundation/edr-darwin-arm64': 0.3.4 + '@nomicfoundation/edr-darwin-x64': 0.3.4 + '@nomicfoundation/edr-linux-arm64-gnu': 0.3.4 + '@nomicfoundation/edr-linux-arm64-musl': 0.3.4 + '@nomicfoundation/edr-linux-x64-gnu': 0.3.4 + '@nomicfoundation/edr-linux-x64-musl': 0.3.4 + '@nomicfoundation/edr-win32-arm64-msvc': 0.3.4 + '@nomicfoundation/edr-win32-ia32-msvc': 0.3.4 + '@nomicfoundation/edr-win32-x64-msvc': 0.3.4 + dev: false + + /@nomicfoundation/ethereumjs-common@4.0.4: + resolution: {integrity: sha512-9Rgb658lcWsjiicr5GzNCjI1llow/7r0k50dLL95OJ+6iZJcVbi15r3Y0xh2cIO+zgX0WIHcbzIu6FeQf9KPrg==} + dependencies: + '@nomicfoundation/ethereumjs-util': 9.0.4 + transitivePeerDependencies: + - c-kzg + dev: false + + /@nomicfoundation/ethereumjs-rlp@5.0.4: + resolution: {integrity: sha512-8H1S3s8F6QueOc/X92SdrA4RDenpiAEqMg5vJH99kcQaCy/a3Q6fgseo75mgWlbanGJXSlAPtnCeG9jvfTYXlw==} + engines: {node: '>=18'} + hasBin: true + dev: false + + /@nomicfoundation/ethereumjs-tx@5.0.4: + resolution: {integrity: sha512-Xjv8wAKJGMrP1f0n2PeyfFCCojHd7iS3s/Ab7qzF1S64kxZ8Z22LCMynArYsVqiFx6rzYy548HNVEyI+AYN/kw==} + engines: {node: '>=18'} + peerDependencies: + c-kzg: ^2.1.2 + peerDependenciesMeta: + c-kzg: + optional: true + dependencies: + '@nomicfoundation/ethereumjs-common': 4.0.4 + '@nomicfoundation/ethereumjs-rlp': 5.0.4 + '@nomicfoundation/ethereumjs-util': 9.0.4 + ethereum-cryptography: 0.1.3 + dev: false + + /@nomicfoundation/ethereumjs-util@9.0.4: + resolution: {integrity: sha512-sLOzjnSrlx9Bb9EFNtHzK/FJFsfg2re6bsGqinFinH1gCqVfz9YYlXiMWwDM4C/L4ywuHFCYwfKTVr/QHQcU0Q==} + engines: {node: '>=18'} + peerDependencies: + c-kzg: ^2.1.2 + peerDependenciesMeta: + c-kzg: + optional: true + dependencies: + '@nomicfoundation/ethereumjs-rlp': 5.0.4 + ethereum-cryptography: 0.1.3 + dev: false + + /@nomicfoundation/solidity-analyzer-darwin-arm64@0.1.1: + resolution: {integrity: sha512-KcTodaQw8ivDZyF+D76FokN/HdpgGpfjc/gFCImdLUyqB6eSWVaZPazMbeAjmfhx3R0zm/NYVzxwAokFKgrc0w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-darwin-x64@0.1.1: + resolution: {integrity: sha512-XhQG4BaJE6cIbjAVtzGOGbK3sn1BO9W29uhk9J8y8fZF1DYz0Doj8QDMfpMu+A6TjPDs61lbsmeYodIDnfveSA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-freebsd-x64@0.1.1: + resolution: {integrity: sha512-GHF1VKRdHW3G8CndkwdaeLkVBi5A9u2jwtlS7SLhBc8b5U/GcoL39Q+1CSO3hYqePNP+eV5YI7Zgm0ea6kMHoA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-linux-arm64-gnu@0.1.1: + resolution: {integrity: sha512-g4Cv2fO37ZsUENQ2vwPnZc2zRenHyAxHcyBjKcjaSmmkKrFr64yvzeNO8S3GBFCo90rfochLs99wFVGT/0owpg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-linux-arm64-musl@0.1.1: + resolution: {integrity: sha512-WJ3CE5Oek25OGE3WwzK7oaopY8xMw9Lhb0mlYuJl/maZVo+WtP36XoQTb7bW/i8aAdHW5Z+BqrHMux23pvxG3w==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-linux-x64-gnu@0.1.1: + resolution: {integrity: sha512-5WN7leSr5fkUBBjE4f3wKENUy9HQStu7HmWqbtknfXkkil+eNWiBV275IOlpXku7v3uLsXTOKpnnGHJYI2qsdA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-linux-x64-musl@0.1.1: + resolution: {integrity: sha512-KdYMkJOq0SYPQMmErv/63CwGwMm5XHenEna9X9aB8mQmhDBrYrlAOSsIPgFCUSL0hjxE3xHP65/EPXR/InD2+w==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-win32-arm64-msvc@0.1.1: + resolution: {integrity: sha512-VFZASBfl4qiBYwW5xeY20exWhmv6ww9sWu/krWSesv3q5hA0o1JuzmPHR4LPN6SUZj5vcqci0O6JOL8BPw+APg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-win32-ia32-msvc@0.1.1: + resolution: {integrity: sha512-JnFkYuyCSA70j6Si6cS1A9Gh1aHTEb8kOTBApp/c7NRTFGNMH8eaInKlyuuiIbvYFhlXW4LicqyYuWNNq9hkpQ==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer-win32-x64-msvc@0.1.1: + resolution: {integrity: sha512-HrVJr6+WjIXGnw3Q9u6KQcbZCtk0caVWhCdFADySvRyUxJ8PnzlaP+MhwNE8oyT8OZ6ejHBRrrgjSqDCFXGirw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@nomicfoundation/solidity-analyzer@0.1.1: + resolution: {integrity: sha512-1LMtXj1puAxyFusBgUIy5pZk3073cNXYnXUpuNKFghHbIit/xZgbk0AokpUADbNm3gyD6bFWl3LRFh3dhVdREg==} + engines: {node: '>= 12'} + optionalDependencies: + '@nomicfoundation/solidity-analyzer-darwin-arm64': 0.1.1 + '@nomicfoundation/solidity-analyzer-darwin-x64': 0.1.1 + '@nomicfoundation/solidity-analyzer-freebsd-x64': 0.1.1 + '@nomicfoundation/solidity-analyzer-linux-arm64-gnu': 0.1.1 + '@nomicfoundation/solidity-analyzer-linux-arm64-musl': 0.1.1 + '@nomicfoundation/solidity-analyzer-linux-x64-gnu': 0.1.1 + '@nomicfoundation/solidity-analyzer-linux-x64-musl': 0.1.1 + '@nomicfoundation/solidity-analyzer-win32-arm64-msvc': 0.1.1 + '@nomicfoundation/solidity-analyzer-win32-ia32-msvc': 0.1.1 + '@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.1 + dev: false + + /@openzeppelin/contracts@3.4.1-solc-0.7-2: + resolution: {integrity: sha512-tAG9LWg8+M2CMu7hIsqHPaTyG4uDzjr6mhvH96LvOpLZZj6tgzTluBt+LsCf1/QaYrlis6pITvpIaIhE+iZB+Q==} + dev: false + + /@openzeppelin/contracts@3.4.2-solc-0.7: + resolution: {integrity: sha512-W6QmqgkADuFcTLzHL8vVoNBtkwjvQRpYIAom7KiUNoLKghyx3FgH0GBjt8NRvigV1ZmMOBllvE1By1C+bi8WpA==} + dev: false + + /@parcel/watcher-android-arm64@2.3.0: + resolution: {integrity: sha512-f4o9eA3dgk0XRT3XhB0UWpWpLnKgrh1IwNJKJ7UJek7eTYccQ8LR7XUWFKqw6aEq5KUNlCcGvSzKqSX/vtWVVA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-darwin-arm64@2.3.0: + resolution: {integrity: sha512-mKY+oijI4ahBMc/GygVGvEdOq0L4DxhYgwQqYAz/7yPzuGi79oXrZG52WdpGA1wLBPrYb0T8uBaGFo7I6rvSKw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-darwin-x64@2.3.0: + resolution: {integrity: sha512-20oBj8LcEOnLE3mgpy6zuOq8AplPu9NcSSSfyVKgfOhNAc4eF4ob3ldj0xWjGGbOF7Dcy1Tvm6ytvgdjlfUeow==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-freebsd-x64@2.3.0: + resolution: {integrity: sha512-7LftKlaHunueAEiojhCn+Ef2CTXWsLgTl4hq0pkhkTBFI3ssj2bJXmH2L67mKpiAD5dz66JYk4zS66qzdnIOgw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-linux-arm-glibc@2.3.0: + resolution: {integrity: sha512-1apPw5cD2xBv1XIHPUlq0cO6iAaEUQ3BcY0ysSyD9Kuyw4MoWm1DV+W9mneWI+1g6OeP6dhikiFE6BlU+AToTQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-linux-arm64-glibc@2.3.0: + resolution: {integrity: sha512-mQ0gBSQEiq1k/MMkgcSB0Ic47UORZBmWoAWlMrTW6nbAGoLZP+h7AtUM7H3oDu34TBFFvjy4JCGP43JlylkTQA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-linux-arm64-musl@2.3.0: + resolution: {integrity: sha512-LXZAExpepJew0Gp8ZkJ+xDZaTQjLHv48h0p0Vw2VMFQ8A+RKrAvpFuPVCVwKJCr5SE+zvaG+Etg56qXvTDIedw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-linux-x64-glibc@2.3.0: + resolution: {integrity: sha512-P7Wo91lKSeSgMTtG7CnBS6WrA5otr1K7shhSjKHNePVmfBHDoAOHYRXgUmhiNfbcGk0uMCHVcdbfxtuiZCHVow==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-linux-x64-musl@2.3.0: + resolution: {integrity: sha512-+kiRE1JIq8QdxzwoYY+wzBs9YbJ34guBweTK8nlzLKimn5EQ2b2FSC+tAOpq302BuIMjyuUGvBiUhEcLIGMQ5g==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-wasm@2.3.0: + resolution: {integrity: sha512-ejBAX8H0ZGsD8lSICDNyMbSEtPMWgDL0WFCt/0z7hyf5v8Imz4rAM8xY379mBsECkq/Wdqa5WEDLqtjZ+6NxfA==} + engines: {node: '>= 10.0.0'} + dependencies: + is-glob: 4.0.3 + micromatch: 4.0.5 + napi-wasm: 1.1.0 + dev: false + bundledDependencies: + - napi-wasm + + /@parcel/watcher-win32-arm64@2.3.0: + resolution: {integrity: sha512-35gXCnaz1AqIXpG42evcoP2+sNL62gZTMZne3IackM+6QlfMcJLy3DrjuL6Iks7Czpd3j4xRBzez3ADCj1l7Aw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-win32-ia32@2.3.0: + resolution: {integrity: sha512-FJS/IBQHhRpZ6PiCjFt1UAcPr0YmCLHRbTc00IBTrelEjlmmgIVLeOx4MSXzx2HFEy5Jo5YdhGpxCuqCyDJ5ow==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher-win32-x64@2.3.0: + resolution: {integrity: sha512-dLx+0XRdMnVI62kU3wbXvbIRhLck4aE28bIGKbRGS7BJNt54IIj9+c/Dkqb+7DJEbHUZAX1bwaoM8PqVlHJmCA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@parcel/watcher@2.3.0: + resolution: {integrity: sha512-pW7QaFiL11O0BphO+bq3MgqeX/INAk9jgBldVDYjlQPO4VddoZnF22TcF9onMhnLVHuNqBJeRf+Fj7eezi/+rQ==} + engines: {node: '>= 10.0.0'} + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.5 + node-addon-api: 7.0.0 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.3.0 + '@parcel/watcher-darwin-arm64': 2.3.0 + '@parcel/watcher-darwin-x64': 2.3.0 + '@parcel/watcher-freebsd-x64': 2.3.0 + '@parcel/watcher-linux-arm-glibc': 2.3.0 + '@parcel/watcher-linux-arm64-glibc': 2.3.0 + '@parcel/watcher-linux-arm64-musl': 2.3.0 + '@parcel/watcher-linux-x64-glibc': 2.3.0 + '@parcel/watcher-linux-x64-musl': 2.3.0 + '@parcel/watcher-win32-arm64': 2.3.0 + '@parcel/watcher-win32-ia32': 2.3.0 + '@parcel/watcher-win32-x64': 2.3.0 + dev: false + + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + optional: true + + /@playwright/test@1.43.0: + resolution: {integrity: sha512-Ebw0+MCqoYflop7wVKj711ccbNlrwTBCtjY5rlbiY9kHL2bCYxq+qltK6uPsVBGGAOb033H2VO0YobcQVxoW7Q==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright: 1.43.0 + dev: true + + /@radix-ui/number@1.0.1: + resolution: {integrity: sha512-T5gIdVO2mmPW3NNhjNgEP3cqMXjXL9UbO0BzWcXfvdBs+BohbQxvd/K5hSVKmn9/lbTdsQVKbUcP5WLCwvUbBg==} + dependencies: + '@babel/runtime': 7.24.4 + + /@radix-ui/primitive@1.0.1: + resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + dependencies: + '@babel/runtime': 7.24.4 + + /@radix-ui/react-accordion@1.1.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-fDG7jcoNKVjSK6yfmuAs0EnPDro0WMXIhMtXdTBWqEioVW206ku+4Lw07e+13lUkFkpoEQ2PdeMIAGpdqEAmDg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collapsible': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-arrow@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-wSP+pHsB/jQRaL6voubsQ/ZlrGBHHrOjmBnr19hxYgtS0WvAFwZhK2WP/YY5yF9uKECCEEDGxuLxq1NBK51wFA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-checkbox@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-CBuGQa52aAYnADZVt/KBQzXrwx6TqnlwtcIPGtVt5JkkzQwMOLJjPukimhfKEr4GQNd43C+djUh5Ikopj8pSLg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-collection@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-context@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-dialog@1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.5.5(@types/react@18.2.14)(react@18.2.0) + dev: false + + /@radix-ui/react-direction@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-dismissable-layer@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-7UpBa/RKMoHJYjie1gkF1DlK8l1fdU/VKDpoS3rCCo8YBJR294GwcEHyxHw72yvphJ7ld0AXEcSLAzY2F/WyCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-dropdown-menu@2.0.6(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-i6TuFOoWmLWq+M/eCLGd/bQ2HfAX1RJgvrBQ6AQLmzfvsLdefxbWu8G9zczcPFfcSPehz9GcpF6K9QYreFV8hA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-menu': 2.0.6(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-focus-scope@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-upXdPfqI4islj2CslyfUBNlaJCPybbqRHAi1KER7Isel9Q2AtSJ0zRBZv8mWQiFXD2nyAJ4BhC3yXgZ6kMBSrQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-id@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-label@2.0.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-N5ehvlM7qoTLx7nWPodsPYPgMzA5WM8zZChQg8nyFJKnDO5WHdba1vv5/H6IO5LtJMfD2Q3wh1qHFGNtK0w3bQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.6 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-menu@2.0.6(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BVkFLS+bUC8HcImkRKPSiVumA1VPOOEC5WBMiT+QAVsPzW1FJzI9KnqgGxVDPBcql5xXrHkD3JOVoXWEXD8SYA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.5.5(@types/react@18.2.14)(react@18.2.0) + dev: false + + /@radix-ui/react-popper@1.1.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-1CnGGfFi/bbqtJZZ0P/NQY20xdG3E0LALJaLUEoKwPLwl6PPPfbeiCqMVQnhoFRAxjJj4RpBRJzDmUgsex2tSg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@floating-ui/react-dom': 2.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/rect': 1.0.1 + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-popper@1.1.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-cKpopj/5RHZWjrbF2846jBNacjQVwkP068DfmgrNJXpvVWrOvlAmE9xSiy5OqeE+Gi8D9fP+oDhUnPqNMY8/5w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@floating-ui/react-dom': 2.0.2(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-arrow': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-rect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/rect': 1.0.1 + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-portal@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-xLYZeHrWoPmA5mEKEfZZevoVRK/Q43GfzRXkWV6qawIWWK8t6ifIiLQdd7rmQ4Vk1bmI21XhqF9BN3jWf+phpA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-roving-focus@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-scroll-area@1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-b6PAgH4GQf9QEn8zbT2XUHpW5z8BzqEc7Kl11TwDrvuTrxlkcjTD5qa/bxgKr+nmuXKu4L/W5UZ4mlP/VG/5Gw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-select@1.2.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-zI7McXr8fNaSrUY9mZe4x/HC0jTLY9fWNhO1oLWYMQGDXuV4UCivIGTxwioSzO0ZCYX9iSLyWmAh/1TOmX3Cnw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-popper': 1.1.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.5.5(@types/react@18.2.14)(react@18.2.0) + + /@radix-ui/react-select@2.0.0(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-RH5b7af4oHtkcHS7pG6Sgv5rk5Wxa7XI8W5gvB1N/yiuDGZxko1ynvOiVhFM7Cis2A8zxF9bTOUVbRDzPepe6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.22.6 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + aria-hidden: 1.2.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.5.5(@types/react@18.2.14)(react@18.2.0) + dev: false + + /@radix-ui/react-separator@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-slider@1.1.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.1 + '@radix-ui/number': 1.0.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-slot@1.0.2(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.22.6 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-mxm87F88HyHztsI7N+ZUmEoARGkC22YVW5CaC+Byc+HRpuvCrOBPTAnXgf+tZ/7i0Sg/eOePGdMhUKhPaQEqow==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-size': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-tabs@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-toolbar@1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-tBgmM/O7a07xbaEkYJWYTXkIdU/1pW4/KZORR43toC/4XWyBCURK0ei9kMUdp+gTPPKBgYLxXmRSH1EVcIDp8Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-separator': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle-group': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.23.2 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-popper': 1.1.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-slot': 1.0.2(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-use-previous@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-cV5La9DPwiQ7S0gf/0qiD6YgNqM5Fk97Kdrlc5yBcrF3jyEZQwm7vYFqMo4IfeHgJXsRaMvLABFtd0OVEmZhDw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-use-rect@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-Cq5DLuSiuYVKNU8orzJMbl15TXilTnJKUCltMVQg53BQOF1/C5toAaGrowkgksdBQ9H+SRL23g0HDmg9tvmxXw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/rect': 1.0.1 + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-use-size@1.0.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-ibay+VqrgcaI6veAojjofPATwledXiSmX+C0KrBk/xgpX9rBzPV3OsfwlhQdUOFbh+LKQorLYT+xTXW9V8yd0g==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.14)(react@18.2.0) + '@types/react': 18.2.14 + react: 18.2.0 + + /@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.4 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@radix-ui/rect@1.0.1: + resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==} + dependencies: + '@babel/runtime': 7.24.4 + + /@rainbow-me/rainbowkit@2.0.5(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0)(viem@2.9.21)(wagmi@2.5.20): + resolution: {integrity: sha512-JVBgl0J1EvYXrGxDJmqEVMFrjE3gGidHyacFilKu/zJdaHFGXogsmCG51DdaU3gsas0Aqbq9kqK13qk49VSfAg==} + engines: {node: '>=12.4'} + peerDependencies: + react: '>=17' + react-dom: '>=17' + viem: 2.x + wagmi: 2.x + dependencies: + '@vanilla-extract/css': 1.14.0 + '@vanilla-extract/dynamic': 2.1.0 + '@vanilla-extract/sprinkles': 1.6.1(@vanilla-extract/css@1.14.0) + clsx: 2.1.0 + qrcode: 1.5.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-remove-scroll: 2.5.7(@types/react@18.2.14)(react@18.2.0) + ua-parser-js: 1.0.37 + viem: 2.9.21(typescript@5.4.5)(zod@3.22.4) + wagmi: 2.5.20(@tanstack/react-query@5.28.8)(@types/react@18.2.14)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@react-native-async-storage/async-storage@1.22.0(react-native@0.73.6): + resolution: {integrity: sha512-b5KD010iiZnot86RbAaHpLuHwmPW2qA3SSN/OSZhd1kBoINEQEVBuv+uFtcaTxAhX27bT0wd13GOb2IOSDUXSA==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.60 <1.0 + dependencies: + merge-options: 3.0.4 + react-native: 0.73.6(@babel/core@7.22.9)(@babel/preset-env@7.22.9)(react@18.2.0) + dev: false + + /@react-native-community/cli-clean@12.3.6: + resolution: {integrity: sha512-gUU29ep8xM0BbnZjwz9MyID74KKwutq9x5iv4BCr2im6nly4UMf1B1D+V225wR7VcDGzbgWjaezsJShLLhC5ig==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + execa: 5.1.1 + transitivePeerDependencies: + - encoding + dev: false + + /@react-native-community/cli-config@12.3.6: + resolution: {integrity: sha512-JGWSYQ9EAK6m2v0abXwFLEfsqJ1zkhzZ4CV261QZF9MoUNB6h57a274h1MLQR9mG6Tsh38wBUuNfEPUvS1vYew==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + cosmiconfig: 5.2.1 + deepmerge: 4.3.1 + glob: 7.2.3 + joi: 17.12.3 + transitivePeerDependencies: + - encoding + dev: false + + /@react-native-community/cli-debugger-ui@12.3.6: + resolution: {integrity: sha512-SjUKKsx5FmcK9G6Pb6UBFT0s9JexVStK5WInmANw75Hm7YokVvHEgtprQDz2Uvy5znX5g2ujzrkIU//T15KQzA==} + dependencies: + serve-static: 1.15.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@react-native-community/cli-doctor@12.3.6: + resolution: {integrity: sha512-fvBDv2lTthfw4WOQKkdTop2PlE9GtfrlNnpjB818MhcdEnPjfQw5YaTUcnNEGsvGomdCs1MVRMgYXXwPSN6OvQ==} + dependencies: + '@react-native-community/cli-config': 12.3.6 + '@react-native-community/cli-platform-android': 12.3.6 + '@react-native-community/cli-platform-ios': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + command-exists: 1.2.9 + deepmerge: 4.3.1 + envinfo: 7.12.0 + execa: 5.1.1 + hermes-profile-transformer: 0.0.6 + node-stream-zip: 1.15.0 + ora: 5.4.1 + semver: 7.6.0 + strip-ansi: 5.2.0 + wcwidth: 1.0.1 + yaml: 2.4.1 + transitivePeerDependencies: + - encoding + dev: false + + /@react-native-community/cli-hermes@12.3.6: + resolution: {integrity: sha512-sNGwfOCl8OAIjWCkwuLpP8NZbuO0dhDI/2W7NeOGDzIBsf4/c4MptTrULWtGIH9okVPLSPX0NnRyGQ+mSwWyuQ==} + dependencies: + '@react-native-community/cli-platform-android': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + hermes-profile-transformer: 0.0.6 + transitivePeerDependencies: + - encoding + dev: false + + /@react-native-community/cli-platform-android@12.3.6: + resolution: {integrity: sha512-DeDDAB8lHpuGIAPXeeD9Qu2+/wDTFPo99c8uSW49L0hkmZJixzvvvffbGQAYk32H0TmaI7rzvzH+qzu7z3891g==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + execa: 5.1.1 + fast-xml-parser: 4.3.6 + glob: 7.2.3 + logkitty: 0.7.1 + transitivePeerDependencies: + - encoding + dev: false + + /@react-native-community/cli-platform-ios@12.3.6: + resolution: {integrity: sha512-3eZ0jMCkKUO58wzPWlvAPRqezVKm9EPZyaPyHbRPWU8qw7JqkvnRlWIaYDGpjCJgVW4k2hKsEursLtYKb188tg==} + dependencies: + '@react-native-community/cli-tools': 12.3.6 + chalk: 4.1.2 + execa: 5.1.1 + fast-xml-parser: 4.3.6 + glob: 7.2.3 + ora: 5.4.1 + transitivePeerDependencies: + - encoding + dev: false + + /@react-native-community/cli-plugin-metro@12.3.6: + resolution: {integrity: sha512-3jxSBQt4fkS+KtHCPSyB5auIT+KKIrPCv9Dk14FbvOaEh9erUWEm/5PZWmtboW1z7CYeNbFMeXm9fM2xwtVOpg==} + dev: false + + /@react-native-community/cli-server-api@12.3.6: + resolution: {integrity: sha512-80NIMzo8b2W+PL0Jd7NjiJW9mgaT8Y8wsIT/lh6mAvYH7mK0ecDJUYUTAAv79Tbo1iCGPAr3T295DlVtS8s4yQ==} + dependencies: + '@react-native-community/cli-debugger-ui': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + compression: 1.7.4 + connect: 3.7.0 + errorhandler: 1.5.1 + nocache: 3.0.4 + pretty-format: 26.6.2 + serve-static: 1.15.0 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /@react-native-community/cli-tools@12.3.6: + resolution: {integrity: sha512-FPEvZn19UTMMXUp/piwKZSh8cMEfO8G3KDtOwo53O347GTcwNrKjgZGtLSPELBX2gr+YlzEft3CoRv2Qmo83fQ==} + dependencies: + appdirsjs: 1.2.7 + chalk: 4.1.2 + find-up: 5.0.0 + mime: 2.6.0 + node-fetch: 2.7.0 + open: 6.4.0 + ora: 5.4.1 + semver: 7.6.0 + shell-quote: 1.8.1 + sudo-prompt: 9.2.1 + transitivePeerDependencies: + - encoding + dev: false + + /@react-native-community/cli-types@12.3.6: + resolution: {integrity: sha512-xPqTgcUtZowQ8WKOkI9TLGBwH2bGggOC4d2FFaIRST3gTcjrEeGRNeR5aXCzJFIgItIft8sd7p2oKEdy90+01Q==} + dependencies: + joi: 17.12.3 + dev: false + + /@react-native-community/cli@12.3.6: + resolution: {integrity: sha512-647OSi6xBb8FbwFqX9zsJxOzu685AWtrOUWHfOkbKD+5LOpGORw+GQo0F9rWZnB68rLQyfKUZWJeaD00pGv5fw==} + engines: {node: '>=18'} + hasBin: true + dependencies: + '@react-native-community/cli-clean': 12.3.6 + '@react-native-community/cli-config': 12.3.6 + '@react-native-community/cli-debugger-ui': 12.3.6 + '@react-native-community/cli-doctor': 12.3.6 + '@react-native-community/cli-hermes': 12.3.6 + '@react-native-community/cli-plugin-metro': 12.3.6 + '@react-native-community/cli-server-api': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + '@react-native-community/cli-types': 12.3.6 + chalk: 4.1.2 + commander: 9.5.0 + deepmerge: 4.3.1 + execa: 5.1.1 + find-up: 4.1.0 + fs-extra: 8.1.0 + graceful-fs: 4.2.11 + prompts: 2.4.2 + semver: 7.6.0 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /@react-native/assets-registry@0.73.1: + resolution: {integrity: sha512-2FgAbU7uKM5SbbW9QptPPZx8N9Ke2L7bsHb+EhAanZjFZunA9PaYtyjUQ1s7HD+zDVqOQIvjkpXSv7Kejd2tqg==} + engines: {node: '>=18'} + dev: false + + /@react-native/babel-plugin-codegen@0.73.4(@babel/preset-env@7.22.9): + resolution: {integrity: sha512-XzRd8MJGo4Zc5KsphDHBYJzS1ryOHg8I2gOZDAUCGcwLFhdyGu1zBNDJYH2GFyDrInn9TzAbRIf3d4O+eltXQQ==} + engines: {node: '>=18'} + dependencies: + '@react-native/codegen': 0.73.3(@babel/preset-env@7.22.9) + transitivePeerDependencies: + - '@babel/preset-env' + - supports-color + dev: false + + /@react-native/babel-preset@0.73.21(@babel/core@7.22.9)(@babel/preset-env@7.22.9): + resolution: {integrity: sha512-WlFttNnySKQMeujN09fRmrdWqh46QyJluM5jdtDNrkl/2Hx6N4XeDUGhABvConeK95OidVO7sFFf7sNebVXogA==} + engines: {node: '>=18'} + peerDependencies: + '@babel/core': '*' + dependencies: + '@babel/core': 7.22.9 + '@babel/plugin-proposal-async-generator-functions': 7.20.7(@babel/core@7.22.9) + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-proposal-export-default-from': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-proposal-numeric-separator': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-proposal-object-rest-spread': 7.20.7(@babel/core@7.22.9) + '@babel/plugin-proposal-optional-catch-binding': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.22.9) + '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-export-default-from': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-syntax-flow': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.22.9) + '@babel/plugin-transform-arrow-functions': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-async-to-generator': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-block-scoping': 7.24.4(@babel/core@7.22.9) + '@babel/plugin-transform-classes': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-computed-properties': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-destructuring': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-flow-strip-types': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-function-name': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-literals': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-modules-commonjs': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-named-capturing-groups-regex': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-parameters': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-private-methods': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-private-property-in-object': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-react-display-name': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-react-jsx': 7.23.4(@babel/core@7.22.9) + '@babel/plugin-transform-react-jsx-self': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-react-jsx-source': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-runtime': 7.24.3(@babel/core@7.22.9) + '@babel/plugin-transform-shorthand-properties': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-spread': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-sticky-regex': 7.24.1(@babel/core@7.22.9) + '@babel/plugin-transform-typescript': 7.24.4(@babel/core@7.22.9) + '@babel/plugin-transform-unicode-regex': 7.24.1(@babel/core@7.22.9) + '@babel/template': 7.24.0 + '@react-native/babel-plugin-codegen': 0.73.4(@babel/preset-env@7.22.9) + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.22.9) + react-refresh: 0.14.0 + transitivePeerDependencies: + - '@babel/preset-env' + - supports-color + dev: false + + /@react-native/codegen@0.73.3(@babel/preset-env@7.22.9): + resolution: {integrity: sha512-sxslCAAb8kM06vGy9Jyh4TtvjhcP36k/rvj2QE2Jdhdm61KvfafCATSIsOfc0QvnduWFcpXUPvAVyYwuv7PYDg==} + engines: {node: '>=18'} + peerDependencies: + '@babel/preset-env': ^7.1.6 + dependencies: + '@babel/parser': 7.24.4 + '@babel/preset-env': 7.22.9(@babel/core@7.22.9) + flow-parser: 0.206.0 + glob: 7.2.3 + invariant: 2.2.4 + jscodeshift: 0.14.0(@babel/preset-env@7.22.9) + mkdirp: 0.5.6 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@react-native/community-cli-plugin@0.73.17(@babel/core@7.22.9)(@babel/preset-env@7.22.9): + resolution: {integrity: sha512-F3PXZkcHg+1ARIr6FRQCQiB7ZAA+MQXGmq051metRscoLvgYJwj7dgC8pvgy0kexzUkHu5BNKrZeySzUft3xuQ==} + engines: {node: '>=18'} + dependencies: + '@react-native-community/cli-server-api': 12.3.6 + '@react-native-community/cli-tools': 12.3.6 + '@react-native/dev-middleware': 0.73.8 + '@react-native/metro-babel-transformer': 0.73.15(@babel/core@7.22.9)(@babel/preset-env@7.22.9) + chalk: 4.1.2 + execa: 5.1.1 + metro: 0.80.8 + metro-config: 0.80.8 + metro-core: 0.80.8 + node-fetch: 2.7.0 + readline: 1.3.0 + transitivePeerDependencies: + - '@babel/core' + - '@babel/preset-env' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /@react-native/debugger-frontend@0.73.3: + resolution: {integrity: sha512-RgEKnWuoo54dh7gQhV7kvzKhXZEhpF9LlMdZolyhGxHsBqZ2gXdibfDlfcARFFifPIiaZ3lXuOVVa4ei+uPgTw==} + engines: {node: '>=18'} + dev: false + + /@react-native/dev-middleware@0.73.8: + resolution: {integrity: sha512-oph4NamCIxkMfUL/fYtSsE+JbGOnrlawfQ0kKtDQ5xbOjPKotKoXqrs1eGwozNKv7FfQ393stk1by9a6DyASSg==} + engines: {node: '>=18'} + dependencies: + '@isaacs/ttlcache': 1.4.1 + '@react-native/debugger-frontend': 0.73.3 + chrome-launcher: 0.15.2 + chromium-edge-launcher: 1.0.0 + connect: 3.7.0 + debug: 2.6.9 + node-fetch: 2.7.0 + open: 7.4.2 + serve-static: 1.15.0 + temp-dir: 2.0.0 + ws: 6.2.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /@react-native/gradle-plugin@0.73.4: + resolution: {integrity: sha512-PMDnbsZa+tD55Ug+W8CfqXiGoGneSSyrBZCMb5JfiB3AFST3Uj5e6lw8SgI/B6SKZF7lG0BhZ6YHZsRZ5MlXmg==} + engines: {node: '>=18'} + dev: false + + /@react-native/js-polyfills@0.73.1: + resolution: {integrity: sha512-ewMwGcumrilnF87H4jjrnvGZEaPFCAC4ebraEK+CurDDmwST/bIicI4hrOAv+0Z0F7DEK4O4H7r8q9vH7IbN4g==} + engines: {node: '>=18'} + dev: false + + /@react-native/metro-babel-transformer@0.73.15(@babel/core@7.22.9)(@babel/preset-env@7.22.9): + resolution: {integrity: sha512-LlkSGaXCz+xdxc9819plmpsl4P4gZndoFtpjN3GMBIu6f7TBV0GVbyJAU4GE8fuAWPVSVL5ArOcdkWKSbI1klw==} + engines: {node: '>=18'} + peerDependencies: + '@babel/core': '*' + dependencies: + '@babel/core': 7.22.9 + '@react-native/babel-preset': 0.73.21(@babel/core@7.22.9)(@babel/preset-env@7.22.9) + hermes-parser: 0.15.0 + nullthrows: 1.1.1 + transitivePeerDependencies: + - '@babel/preset-env' + - supports-color + dev: false + + /@react-native/normalize-colors@0.73.2: + resolution: {integrity: sha512-bRBcb2T+I88aG74LMVHaKms2p/T8aQd8+BZ7LuuzXlRfog1bMWWn/C5i0HVuvW4RPtXQYgIlGiXVDy9Ir1So/w==} + dev: false + + /@react-native/virtualized-lists@0.73.4(react-native@0.73.6): + resolution: {integrity: sha512-HpmLg1FrEiDtrtAbXiwCgXFYyloK/dOIPIuWW3fsqukwJEWAiTzm1nXGJ7xPU5XTHiWZ4sKup5Ebaj8z7iyWog==} + engines: {node: '>=18'} + peerDependencies: + react-native: '*' + dependencies: + invariant: 2.2.4 + nullthrows: 1.1.1 + react-native: 0.73.6(@babel/core@7.22.9)(@babel/preset-env@7.22.9)(react@18.2.0) + dev: false + + /@remix-run/router@1.7.2: + resolution: {integrity: sha512-7Lcn7IqGMV+vizMPoEl5F0XDshcdDYtMI6uJLQdQz5CfZAwy3vvGKYSUk789qndt5dEC4HfSjviSYlSoHGL2+A==} + engines: {node: '>=14'} + dev: false + + /@rollup/pluginutils@5.0.5: + resolution: {integrity: sha512-6aEYR910NyP73oHiJglti74iRyOwgFU4x3meH/H8OJx6Ry0j6cOVZ5X/wTvub7G7Ao6qaHBEaNsV3GLJkSsF+Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.3 + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@safe-global/safe-apps-provider@0.18.1(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-V4a05A3EgJcriqtDoJklDz1BOinWhC6P0hjUSxshA4KOZM7rGPCTto/usXs09zr1vvL28evl/NldSTv97j2bmg==} + dependencies: + '@safe-global/safe-apps-sdk': 8.1.0(typescript@5.4.5)(zod@3.22.4) + events: 3.3.0 + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + dev: false + + /@safe-global/safe-apps-sdk@8.1.0(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-XJbEPuaVc7b9n23MqlF6c+ToYIS3f7P2Sel8f3cSBQ9WORE4xrSuvhMpK9fDSFqJ7by/brc+rmJR/5HViRr0/w==} + dependencies: + '@safe-global/safe-gateway-typescript-sdk': 3.12.0 + viem: 1.9.5(typescript@5.4.5)(zod@3.22.4) + transitivePeerDependencies: + - bufferutil + - typescript + - utf-8-validate + - zod + dev: false + + /@safe-global/safe-gateway-typescript-sdk@3.12.0: + resolution: {integrity: sha512-hExCo62lScVC9/ztVqYEYL2pFxcqLTvB8fj0WtdP5FWrvbtEgD0pbVolchzD5bf85pbzvEwdAxSVS7EdCZxTNw==} + engines: {node: '>=16'} + dev: false + + /@scure/base@1.1.6: + resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==} + + /@scure/bip32@1.1.5: + resolution: {integrity: sha512-XyNh1rB0SkEqd3tXcXMi+Xe1fvg+kUIcoRIEujP1Jgv7DqW2r9lg3Ah0NkFaCs9sTkQAQA8kw7xiRXzENi9Rtw==} + dependencies: + '@noble/hashes': 1.2.0 + '@noble/secp256k1': 1.7.1 + '@scure/base': 1.1.6 + dev: false + + /@scure/bip32@1.3.0: + resolution: {integrity: sha512-bcKpo1oj54hGholplGLpqPHRbIsnbixFtc06nwuNM5/dwSXOq/AAYoIBRsBmnZJSdfeNW5rnff7NTAz3ZCqR9Q==} + dependencies: + '@noble/curves': 1.0.0 + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.6 + dev: false + + /@scure/bip32@1.3.2: + resolution: {integrity: sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA==} + dependencies: + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.6 + + /@scure/bip32@1.3.3: + resolution: {integrity: sha512-LJaN3HwRbfQK0X1xFSi0Q9amqOgzQnnDngIt+ZlsBC3Bm7/nE7K0kwshZHyaru79yIVRv/e1mQAjZyuZG6jOFQ==} + dependencies: + '@noble/curves': 1.3.0 + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.6 + dev: false + + /@scure/bip39@1.1.1: + resolution: {integrity: sha512-t+wDck2rVkh65Hmv280fYdVdY25J9YeEUIgn2LG1WM6gxFkGzcksoDiUkWVpVp3Oex9xGC68JU2dSbUfwZ2jPg==} + dependencies: + '@noble/hashes': 1.2.0 + '@scure/base': 1.1.6 + dev: false + + /@scure/bip39@1.2.0: + resolution: {integrity: sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg==} + dependencies: + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.6 + dev: false + + /@scure/bip39@1.2.1: + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + dependencies: + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.6 + + /@scure/bip39@1.2.2: + resolution: {integrity: sha512-HYf9TUXG80beW+hGAt3TRM8wU6pQoYur9iNypTROm42dorCGmLnFe3eWjz3gOq6G62H2WRh0FCzAR1PI+29zIA==} + dependencies: + '@noble/hashes': 1.3.3 + '@scure/base': 1.1.6 + dev: false + + /@sentry/core@5.30.0: + resolution: {integrity: sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==} + engines: {node: '>=6'} + dependencies: + '@sentry/hub': 5.30.0 + '@sentry/minimal': 5.30.0 + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + tslib: 1.14.1 + dev: false + + /@sentry/hub@5.30.0: + resolution: {integrity: sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==} + engines: {node: '>=6'} + dependencies: + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + tslib: 1.14.1 + dev: false + + /@sentry/minimal@5.30.0: + resolution: {integrity: sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw==} + engines: {node: '>=6'} + dependencies: + '@sentry/hub': 5.30.0 + '@sentry/types': 5.30.0 + tslib: 1.14.1 + dev: false + + /@sentry/node@5.30.0: + resolution: {integrity: sha512-Br5oyVBF0fZo6ZS9bxbJZG4ApAjRqAnqFFurMVJJdunNb80brh7a5Qva2kjhm+U6r9NJAB5OmDyPkA1Qnt+QVg==} + engines: {node: '>=6'} + dependencies: + '@sentry/core': 5.30.0 + '@sentry/hub': 5.30.0 + '@sentry/tracing': 5.30.0 + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + cookie: 0.4.2 + https-proxy-agent: 5.0.1 + lru_map: 0.3.3 + tslib: 1.14.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@sentry/tracing@5.30.0: + resolution: {integrity: sha512-dUFowCr0AIMwiLD7Fs314Mdzcug+gBVo/+NCMyDw8tFxJkwWAKl7Qa2OZxLQ0ZHjakcj1hNKfCQJ9rhyfOl4Aw==} + engines: {node: '>=6'} + dependencies: + '@sentry/hub': 5.30.0 + '@sentry/minimal': 5.30.0 + '@sentry/types': 5.30.0 + '@sentry/utils': 5.30.0 + tslib: 1.14.1 + dev: false + + /@sentry/types@5.30.0: + resolution: {integrity: sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==} + engines: {node: '>=6'} + dev: false + + /@sentry/utils@5.30.0: + resolution: {integrity: sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==} + engines: {node: '>=6'} + dependencies: + '@sentry/types': 5.30.0 + tslib: 1.14.1 + dev: false + + /@sideway/address@4.1.5: + resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==} + dependencies: + '@hapi/hoek': 9.3.0 + dev: false + + /@sideway/formula@3.0.1: + resolution: {integrity: sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==} + dev: false + + /@sideway/pinpoint@2.0.0: + resolution: {integrity: sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==} + dev: false + + /@sinclair/typebox@0.24.51: + resolution: {integrity: sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==} + dev: true + + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + + /@sinonjs/commons@3.0.1: + resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} + dependencies: + type-detect: 4.0.8 + dev: false + + /@sinonjs/fake-timers@10.3.0: + resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==} + dependencies: + '@sinonjs/commons': 3.0.1 + dev: false + + /@size-limit/file@10.0.1(size-limit@10.0.1): + resolution: {integrity: sha512-xQMh/Hy8QgyHaac+DSSKcwmcJlQglDTY67L5ouUf78SlDxiKQrr8QMiYdCj7Zl5KM+gmee/eIbl6v2qbpf/Vxw==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + size-limit: 10.0.1 + dependencies: + semver: 7.5.4 + size-limit: 10.0.1 + dev: false + + /@socket.io/component-emitter@3.1.0: + resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==} + dev: false + + /@stablelib/aead@1.0.1: + resolution: {integrity: sha512-q39ik6sxGHewqtO0nP4BuSe3db5G1fEJE8ukvngS2gLkBXyy6E7pLubhbYgnkDFv6V8cWaxcE4Xn0t6LWcJkyg==} + dev: false + + /@stablelib/binary@1.0.1: + resolution: {integrity: sha512-ClJWvmL6UBM/wjkvv/7m5VP3GMr9t0osr4yVgLZsLCOz4hGN9gIAFEqnJ0TsSMAN+n840nf2cHZnA5/KFqHC7Q==} + dependencies: + '@stablelib/int': 1.0.1 + dev: false + + /@stablelib/bytes@1.0.1: + resolution: {integrity: sha512-Kre4Y4kdwuqL8BR2E9hV/R5sOrUj6NanZaZis0V6lX5yzqC3hBuVSDXUIBqQv/sCpmuWRiHLwqiT1pqqjuBXoQ==} + dev: false + + /@stablelib/chacha20poly1305@1.0.1: + resolution: {integrity: sha512-MmViqnqHd1ymwjOQfghRKw2R/jMIGT3wySN7cthjXCBdO+qErNPUBnRzqNpnvIwg7JBCg3LdeCZZO4de/yEhVA==} + dependencies: + '@stablelib/aead': 1.0.1 + '@stablelib/binary': 1.0.1 + '@stablelib/chacha': 1.0.1 + '@stablelib/constant-time': 1.0.1 + '@stablelib/poly1305': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/chacha@1.0.1: + resolution: {integrity: sha512-Pmlrswzr0pBzDofdFuVe1q7KdsHKhhU24e8gkEwnTGOmlC7PADzLVxGdn2PoNVBBabdg0l/IfLKg6sHAbTQugg==} + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/constant-time@1.0.1: + resolution: {integrity: sha512-tNOs3uD0vSJcK6z1fvef4Y+buN7DXhzHDPqRLSXUel1UfqMB1PWNsnnAezrKfEwTLpN0cGH2p9NNjs6IqeD0eg==} + dev: false + + /@stablelib/ed25519@1.0.3: + resolution: {integrity: sha512-puIMWaX9QlRsbhxfDc5i+mNPMY+0TmQEskunY1rZEBPi1acBCVQAhnsk/1Hk50DGPtVsZtAWQg4NHGlVaO9Hqg==} + dependencies: + '@stablelib/random': 1.0.2 + '@stablelib/sha512': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/hash@1.0.1: + resolution: {integrity: sha512-eTPJc/stDkdtOcrNMZ6mcMK1e6yBbqRBaNW55XA1jU8w/7QdnCF0CmMmOD1m7VSkBR44PWrMHU2l6r8YEQHMgg==} + dev: false + + /@stablelib/hkdf@1.0.1: + resolution: {integrity: sha512-SBEHYE16ZXlHuaW5RcGk533YlBj4grMeg5TooN80W3NpcHRtLZLLXvKyX0qcRFxf+BGDobJLnwkvgEwHIDBR6g==} + dependencies: + '@stablelib/hash': 1.0.1 + '@stablelib/hmac': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/hmac@1.0.1: + resolution: {integrity: sha512-V2APD9NSnhVpV/QMYgCVMIYKiYG6LSqw1S65wxVoirhU/51ACio6D4yDVSwMzuTJXWZoVHbDdINioBwKy5kVmA==} + dependencies: + '@stablelib/constant-time': 1.0.1 + '@stablelib/hash': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/int@1.0.1: + resolution: {integrity: sha512-byr69X/sDtDiIjIV6m4roLVWnNNlRGzsvxw+agj8CIEazqWGOQp2dTYgQhtyVXV9wpO6WyXRQUzLV/JRNumT2w==} + dev: false + + /@stablelib/keyagreement@1.0.1: + resolution: {integrity: sha512-VKL6xBwgJnI6l1jKrBAfn265cspaWBPAPEc62VBQrWHLqVgNRE09gQ/AnOEyKUWrrqfD+xSQ3u42gJjLDdMDQg==} + dependencies: + '@stablelib/bytes': 1.0.1 + dev: false + + /@stablelib/poly1305@1.0.1: + resolution: {integrity: sha512-1HlG3oTSuQDOhSnLwJRKeTRSAdFNVB/1djy2ZbS35rBSJ/PFqx9cf9qatinWghC2UbfOYD8AcrtbUQl8WoxabA==} + dependencies: + '@stablelib/constant-time': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/random@1.0.2: + resolution: {integrity: sha512-rIsE83Xpb7clHPVRlBj8qNe5L8ISQOzjghYQm/dZ7VaM2KHYwMW5adjQjrzTZCchFnNCNhkwtnOBa9HTMJCI8w==} + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/sha256@1.0.1: + resolution: {integrity: sha512-GIIH3e6KH+91FqGV42Kcj71Uefd/QEe7Dy42sBTeqppXV95ggCcxLTk39bEr+lZfJmp+ghsR07J++ORkRELsBQ==} + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/hash': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/sha512@1.0.1: + resolution: {integrity: sha512-13gl/iawHV9zvDKciLo1fQ8Bgn2Pvf7OV6amaRVKiq3pjQ3UmEpXxWiAfV8tYjUpeZroBxtyrwtdooQT/i3hzw==} + dependencies: + '@stablelib/binary': 1.0.1 + '@stablelib/hash': 1.0.1 + '@stablelib/wipe': 1.0.1 + dev: false + + /@stablelib/wipe@1.0.1: + resolution: {integrity: sha512-WfqfX/eXGiAd3RJe4VU2snh/ZPwtSjLG4ynQ/vYzvghTh7dHFcI1wl+nrkWG6lGhukOxOsUHfv8dUXr58D0ayg==} + dev: false + + /@stablelib/x25519@1.0.3: + resolution: {integrity: sha512-KnTbKmUhPhHavzobclVJQG5kuivH+qDLpe84iRqX3CLrKp881cF160JvXJ+hjn1aMyCwYOKeIZefIH/P5cJoRw==} + dependencies: + '@stablelib/keyagreement': 1.0.1 + '@stablelib/random': 1.0.2 + '@stablelib/wipe': 1.0.1 + dev: false + + /@storybook/addon-actions@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-GieD3ru6EslKvwol1cE4lvszQCLB/AkQdnLofnqy1nnYso+hRxmPAw9/O+pWfpUBFdjXsQ7GX09+wEUpOJzepw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + dequal: 2.0.3 + lodash: 4.17.21 + polished: 4.2.2 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-inspector: 6.0.2(react@18.2.0) + telejson: 7.2.0 + ts-dedent: 2.2.0 + uuid: 9.0.0 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: true + + /@storybook/addon-backgrounds@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-XZoyJw/WoUlVvQHPTbSAZjKy2SEUjaSmAWgcRync25vp+q0obthjx6UnZHEUuH8Ud07HA3FYzlFtMicH5y/OIQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: true + + /@storybook/addon-controls@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Xag1e7TZo04LjUenfobkShpKMxTtwa4xM4bXQA8LjaAGZQ7jipbQ4PE73a17K59S2vqq89VAhkuMJWiyaOFqpw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/blocks': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-common': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/node-logger': 7.5.1 + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + lodash: 4.17.21 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - encoding + - supports-color + dev: true + + /@storybook/addon-docs@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-+wE67oWIhGK9+kv2sxoY2KDXm3v62RfEgxiksdhtffTP/joOK3p88S0lO+8g0G4xfNGUnBhPtzGMuUxWwaH2Pw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@jest/transform': 29.6.1 + '@mdx-js/react': 2.3.0(react@18.2.0) + '@storybook/blocks': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/csf-plugin': 7.5.1 + '@storybook/csf-tools': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/mdx2-csf': 1.1.0 + '@storybook/node-logger': 7.5.1 + '@storybook/postinstall': 7.5.1 + '@storybook/preview-api': 7.5.1 + '@storybook/react-dom-shim': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + fs-extra: 11.1.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + remark-external-links: 8.0.0 + remark-slug: 6.1.0 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - encoding + - supports-color + dev: true + + /@storybook/addon-essentials@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-/jaUZXV+mE/2G5PgEpFKm4lFEHluWn6GFR/pg+hphvHOzBGA3Y75JMgUfJ5CDYHB1dAVSf9JrPOd8Eb1tpESfA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/addon-actions': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-backgrounds': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-controls': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-docs': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-highlight': 7.5.1 + '@storybook/addon-measure': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-outline': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-toolbars': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/addon-viewport': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-common': 7.5.1 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/node-logger': 7.5.1 + '@storybook/preview-api': 7.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - encoding + - supports-color + dev: true + + /@storybook/addon-highlight@7.5.1: + resolution: {integrity: sha512-js9OV17kpjRowuaGAPfI9aOn/zzt8P589ACZE+/eYBO9jT65CADwAUxg//Uq0/he+Ac9495pcK3BcYyDeym7/g==} + dependencies: + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/preview-api': 7.5.1 + dev: true + + /@storybook/addon-interactions@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-m9yohFYil+UBwYKFxHYdsAsn8PBCPl6HY/FSgfrDc5PiqT1Ya7paXopimyy9ok+VQt/RC8sEWIm809ONEoxosw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-common': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/instrumenter': 7.5.1 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + jest-mock: 27.5.1 + polished: 4.2.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - encoding + - supports-color + dev: true + + /@storybook/addon-links@7.5.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-KDiQYAVNXxuVTB3QLFZxHlfT8q4KnlNKY+0OODvgD5o1FqFpIyUiR5mIBL4SZMRj2EtwrR3KmZ2UPccFZdu9vw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/csf': 0.1.1 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/router': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + ts-dedent: 2.2.0 + dev: true + + /@storybook/addon-measure@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-yR6oELJe0UHYxRijd1YMuGaQRlZ3uABjmrXaFCPnd6agahgTwIJLiK4XamtkVur//LaiJMvtmM2XXrkJ1BvNJw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/types': 7.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + tiny-invariant: 1.3.1 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: true + + /@storybook/addon-outline@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-IMi5Bo34/Q5YUG5uD8ZUTBwlpGrkDIV+PUgkyNIbmn9OgozoCH80Fs7YlGluRFODQISpHwio9qvSFRGdSNT56A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/types': 7.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + ts-dedent: 2.2.0 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: true + + /@storybook/addon-styling@1.3.7(@types/react-dom@18.2.6)(@types/react@18.2.14)(less@4.2.0)(postcss@8.4.26)(react-dom@18.2.0)(react@18.2.0)(webpack@5.91.0): + resolution: {integrity: sha512-JSBZMOrSw/3rlq5YoEI7Qyq703KSNP0Jd+gxTWu3/tP6245mpjn2dXnR8FvqVxCi+FG4lt2kQyPzgsuwEw1SSA==} + hasBin: true + peerDependencies: + less: ^3.5.0 || ^4.0.0 + postcss: ^7.0.0 || ^8.0.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + webpack: ^5.0.0 + peerDependenciesMeta: + less: + optional: true + postcss: + optional: true + react: + optional: true + react-dom: + optional: true + webpack: + optional: true + dependencies: + '@babel/template': 7.22.5 + '@babel/types': 7.23.0 + '@storybook/api': 7.1.0(react-dom@18.2.0)(react@18.2.0) + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-common': 7.0.27 + '@storybook/core-events': 7.5.1 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/node-logger': 7.0.27 + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + css-loader: 6.8.1(webpack@5.91.0) + less: 4.2.0 + less-loader: 11.1.3(less@4.2.0)(webpack@5.91.0) + postcss: 8.4.26 + postcss-loader: 7.3.3(postcss@8.4.26)(webpack@5.91.0) + prettier: 2.8.8 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + resolve-url-loader: 5.0.0 + sass-loader: 13.3.2(webpack@5.91.0) + style-loader: 3.3.3(webpack@5.91.0) + webpack: 5.91.0(esbuild@0.18.20) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - encoding + - fibers + - node-sass + - sass + - sass-embedded + - supports-color + dev: true + + /@storybook/addon-toolbars@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-T88hEEQicV6eCovr5TN2nFgKt7wU0o7pAunP5cU01iiVRj63+oQiVIBB8Xtm4tN+/DsqtyP0BTa6rFwt2ULy8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: true + + /@storybook/addon-viewport@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-L57lOGB3LfKgAdLinaZojRQ9W9w2RC0iP9bVaXwrRVeJdpNayfuW4Kh1C8dmacZroB4Zp2U/nEjkSmdcp6uUWg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + memoizerific: 1.11.3 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: true + + /@storybook/api@7.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-EvCdZRSNDqPzbeD07qZ/oP9LHsH+wDOP3sn8VC40F7AR98sGbN9O2gD4qtQkGBdwFEYhTHeXaF1QXfEdDPQZdw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/client-logger': 7.1.0 + '@storybook/manager-api': 7.1.0(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@storybook/blocks@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-7b69p6kDdgmlejEMM2mW6/Lz4OmU/R3Qr+TpKnPcV5iS7ADxRQEQCTEMoQ5RyLJf0vDRh/7Ljn/RMo8Ux3X7JA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/channels': 7.5.1 + '@storybook/client-logger': 7.5.1 + '@storybook/components': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.5.1 + '@storybook/csf': 0.1.1 + '@storybook/docs-tools': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/manager-api': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.5.1 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + '@types/lodash': 4.14.195 + color-convert: 2.0.1 + dequal: 2.0.3 + lodash: 4.17.21 + markdown-to-jsx: 7.2.1(react@18.2.0) + memoizerific: 1.11.3 + polished: 4.2.2 + react: 18.2.0 + react-colorful: 5.6.1(react-dom@18.2.0)(react@18.2.0) + react-dom: 18.2.0(react@18.2.0) + telejson: 7.2.0 + tocbot: 4.21.3 + ts-dedent: 2.2.0 + util-deprecate: 1.0.2 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - encoding + - supports-color + + /@storybook/builder-manager@7.5.1: + resolution: {integrity: sha512-a02kg/DCcYgiTz+7rw4KdvQzif+2lZ+NIFF5U5u8SDoCQuoe3wRT6QBrFYQTxJexA4WfO6cpyRLDJ1rx6NLo8A==} + dependencies: + '@fal-works/esbuild-plugin-global-externals': 2.1.2 + '@storybook/core-common': 7.5.1 + '@storybook/manager': 7.5.1 + '@storybook/node-logger': 7.5.1 + '@types/ejs': 3.1.2 + '@types/find-cache-dir': 3.2.1 + '@yarnpkg/esbuild-plugin-pnp': 3.0.0-rc.15(esbuild@0.18.20) + browser-assert: 1.2.1 + ejs: 3.1.9 + esbuild: 0.18.20 + esbuild-plugin-alias: 0.2.1 + express: 4.18.2 + find-cache-dir: 3.3.2 + fs-extra: 11.1.1 + process: 0.11.10 + util: 0.12.5 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@storybook/builder-vite@7.5.1(typescript@5.4.5)(vite@4.5.0): + resolution: {integrity: sha512-fsF4LsxroVvjBJoI5AvRA6euhpYrb5euii5kPzrsWXLOn6gDBK0jQ0looep/io7J45MisDjRTPp14A02pi1bkw==} + peerDependencies: + '@preact/preset-vite': '*' + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + vite-plugin-glimmerx: '*' + peerDependenciesMeta: + '@preact/preset-vite': + optional: true + typescript: + optional: true + vite-plugin-glimmerx: + optional: true + dependencies: + '@storybook/channels': 7.5.1 + '@storybook/client-logger': 7.5.1 + '@storybook/core-common': 7.5.1 + '@storybook/csf-plugin': 7.5.1 + '@storybook/node-logger': 7.5.1 + '@storybook/preview': 7.5.1 + '@storybook/preview-api': 7.5.1 + '@storybook/types': 7.5.1 + '@types/find-cache-dir': 3.2.1 + browser-assert: 1.2.1 + es-module-lexer: 0.9.3 + express: 4.18.2 + find-cache-dir: 3.3.2 + fs-extra: 11.1.1 + magic-string: 0.30.5 + rollup: 3.29.4 + typescript: 5.4.5 + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@storybook/channels@7.0.27: + resolution: {integrity: sha512-YppvPa1qMyC+oCQJ3tf7Quzpf2NnBlvIRLPJiGAMssUwX5qE0iKe9lTtkNwMaNxEvzz6rDxewSlz+f/MWr4gPw==} + dev: true + + /@storybook/channels@7.1.0: + resolution: {integrity: sha512-8uzjWdVG2IK18P8n6H+olAs+jnZr+HeYs1t2xiRy4NVSLhBffB71ut5F+pcWZfdDe3gyX8Tfvy68NloTNt9POg==} + dependencies: + '@storybook/client-logger': 7.1.0 + '@storybook/core-events': 7.1.0 + '@storybook/global': 5.0.0 + qs: 6.11.2 + telejson: 7.2.0 + tiny-invariant: 1.3.1 + dev: true + + /@storybook/channels@7.5.1: + resolution: {integrity: sha512-7hTGHqvtdFTqRx8LuCznOpqPBYfUeMUt/0IIp7SFuZT585yMPxrYoaK//QmLEWnPb80B8HVTSQi7caUkJb32LA==} + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + qs: 6.11.2 + telejson: 7.2.0 + tiny-invariant: 1.3.1 + + /@storybook/channels@7.6.17: + resolution: {integrity: sha512-GFG40pzaSxk1hUr/J/TMqW5AFDDPUSu+HkeE/oqSWJbOodBOLJzHN6CReJS6y1DjYSZLNFt1jftPWZZInG/XUA==} + dependencies: + '@storybook/client-logger': 7.6.17 + '@storybook/core-events': 7.6.17 + '@storybook/global': 5.0.0 + qs: 6.12.0 + telejson: 7.2.0 + tiny-invariant: 1.3.3 + + /@storybook/cli@7.5.1: + resolution: {integrity: sha512-qKIJs8gqXTy0eSEbt0OW5nsJqiV/2+N1eWoiBiIxoZ+8b0ACXIAUcE/N6AsEDUqIq8AMK7lebqjEfIAt2Sp7Mg==} + hasBin: true + dependencies: + '@babel/core': 7.22.9 + '@babel/preset-env': 7.22.9(@babel/core@7.22.9) + '@babel/types': 7.23.0 + '@ndelangen/get-tarball': 3.0.9 + '@storybook/codemod': 7.5.1 + '@storybook/core-common': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/core-server': 7.5.1 + '@storybook/csf-tools': 7.5.1 + '@storybook/node-logger': 7.5.1 + '@storybook/telemetry': 7.5.1 + '@storybook/types': 7.5.1 + '@types/semver': 7.5.0 + '@yarnpkg/fslib': 2.10.3 + '@yarnpkg/libzip': 2.3.0 + chalk: 4.1.2 + commander: 6.2.1 + cross-spawn: 7.0.3 + detect-indent: 6.1.0 + envinfo: 7.10.0 + execa: 5.1.1 + express: 4.18.2 + find-up: 5.0.0 + fs-extra: 11.1.1 + get-npm-tarball-url: 2.0.3 + get-port: 5.1.1 + giget: 1.1.2 + globby: 11.1.0 + jscodeshift: 0.14.0(@babel/preset-env@7.22.9) + leven: 3.1.0 + ora: 5.4.1 + prettier: 2.8.8 + prompts: 2.4.2 + puppeteer-core: 2.1.1 + read-pkg-up: 7.0.1 + semver: 7.5.4 + simple-update-notifier: 2.0.0 + strip-json-comments: 3.1.1 + tempy: 1.0.1 + ts-dedent: 2.2.0 + util-deprecate: 1.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /@storybook/client-logger@7.1.0: + resolution: {integrity: sha512-br5GNTxNFmDZA4ESaCMn2VJ9ZW3ejbILEGoadOJjP2ZD40luSRNtTtWjeNiA+7762OvHMYVGwG0tnqk98f5nfg==} + dependencies: + '@storybook/global': 5.0.0 + dev: true + + /@storybook/client-logger@7.5.1: + resolution: {integrity: sha512-XxbLvg0aQRoBrzxYLcVYCbjDkGbkU8Rfb74XbV2CLiO2bIbFPmA1l1Nwbp+wkCGA+O6Z1zwzSl6wcKKqZ6XZCg==} + dependencies: + '@storybook/global': 5.0.0 + + /@storybook/client-logger@7.6.17: + resolution: {integrity: sha512-6WBYqixAXNAXlSaBWwgljWpAu10tPRBJrcFvx2gPUne58EeMM20Gi/iHYBz2kMCY+JLAgeIH7ZxInqwO8vDwiQ==} + dependencies: + '@storybook/global': 5.0.0 + + /@storybook/codemod@7.5.1: + resolution: {integrity: sha512-PqHGOz/CZnRG9pWgshezCacu524CrXOJrCOwMUP9OMpH0Jk/NhBkHaBZrB8wMjn5hekTj0UmRa/EN8wJm9CCUQ==} + dependencies: + '@babel/core': 7.22.9 + '@babel/preset-env': 7.22.9(@babel/core@7.22.9) + '@babel/types': 7.23.4 + '@storybook/csf': 0.1.1 + '@storybook/csf-tools': 7.5.1 + '@storybook/node-logger': 7.5.1 + '@storybook/types': 7.5.1 + '@types/cross-spawn': 6.0.4 + cross-spawn: 7.0.3 + globby: 11.1.0 + jscodeshift: 0.14.0(@babel/preset-env@7.22.9) + lodash: 4.17.21 + prettier: 2.8.8 + recast: 0.23.3 + transitivePeerDependencies: + - supports-color + dev: true + + /@storybook/components@7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-fdzzxGBV/Fj9pYwfYL3RZsVUHeBqlfLMBP/L6mPmjaZSwHFqkaRZZUajZc57lCtI+TOy2gY6WH3cPavEtqtgLw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@radix-ui/react-select': 1.2.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toolbar': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/client-logger': 7.5.1 + '@storybook/csf': 0.1.1 + '@storybook/global': 5.0.0 + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + use-resize-observer: 9.1.0(react-dom@18.2.0)(react@18.2.0) + util-deprecate: 1.0.2 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + /@storybook/components@7.6.17(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-lbh7GynMidA+CZcJnstVku6Nhs+YkqjYaZ+mKPugvlVhGVWv0DaaeQFVuZ8cJtUGJ/5FFU4Y+n+gylYUHkGBMA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@radix-ui/react-select': 1.2.2(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toolbar': 1.0.4(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/client-logger': 7.6.17 + '@storybook/csf': 0.1.4 + '@storybook/global': 5.0.0 + '@storybook/theming': 7.6.17(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.6.17 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + use-resize-observer: 9.1.0(react-dom@18.2.0)(react@18.2.0) + util-deprecate: 1.0.2 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + /@storybook/core-client@7.5.1: + resolution: {integrity: sha512-K651UnNKkW8U078CH5rcUqf0siGcfEhwya2yQN5RBb/H78HSLBLdYgzKqxaKtmz+S8DFyWhrgbXZLdBjavozJg==} + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/preview-api': 7.5.1 + dev: true + + /@storybook/core-common@7.0.27: + resolution: {integrity: sha512-nlHXpn3CghCwkeIffZ7/PzcraCDXNZz+cnR4L8vtgJn1n6W7y92mxfF8gkRHuiYHWHbPWRVP9M5vAmVoiNMxjw==} + dependencies: + '@storybook/node-logger': 7.0.27 + '@storybook/types': 7.0.27 + '@types/node': 16.18.59 + '@types/node-fetch': 2.6.4 + '@types/pretty-hrtime': 1.0.1 + chalk: 4.1.2 + esbuild: 0.17.19 + esbuild-register: 3.4.2(esbuild@0.17.19) + file-system-cache: 2.3.0 + find-up: 5.0.0 + fs-extra: 11.1.1 + glob: 8.1.0 + glob-promise: 6.0.3(glob@8.1.0) + handlebars: 4.7.7 + lazy-universal-dotenv: 4.0.0 + node-fetch: 2.7.0 + picomatch: 2.3.1 + pkg-dir: 5.0.0 + pretty-hrtime: 1.0.3 + resolve-from: 5.0.0 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@storybook/core-common@7.5.1: + resolution: {integrity: sha512-/rQ0/xvxFHSGCgIkK74HrgDMnzfYtDYTCoSod/qCTojfs9aciX+JYgvo5ChPnI/LEKWwxRTkrE7pl2u5+C4XGA==} + dependencies: + '@storybook/core-events': 7.5.1 + '@storybook/node-logger': 7.5.1 + '@storybook/types': 7.5.1 + '@types/find-cache-dir': 3.2.1 + '@types/node': 18.17.1 + '@types/node-fetch': 2.6.4 + '@types/pretty-hrtime': 1.0.1 + chalk: 4.1.2 + esbuild: 0.18.20 + esbuild-register: 3.5.0(esbuild@0.18.20) + file-system-cache: 2.3.0 + find-cache-dir: 3.3.2 + find-up: 5.0.0 + fs-extra: 11.1.1 + glob: 10.3.3 + handlebars: 4.7.7 + lazy-universal-dotenv: 4.0.0 + node-fetch: 2.7.0 + picomatch: 2.3.1 + pkg-dir: 5.0.0 + pretty-hrtime: 1.0.3 + resolve-from: 5.0.0 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - encoding + - supports-color + + /@storybook/core-events@7.1.0: + resolution: {integrity: sha512-b0kZ5ElPZj3NPqWhGsHHuLn0riA4wJXJ5mNBOe2scd8Cw52ELQr5rVHOMROhONOgpOaZBZ+QZd/MDvJDRyxTQw==} + dev: true + + /@storybook/core-events@7.5.1: + resolution: {integrity: sha512-2eyaUhTfmEEqOEZVoCXVITCBn6N7QuZCG2UNxv0l//ED+7MuMiFhVw7kS7H3WOVk65R7gb8qbKFTNX8HFTgBHg==} + dependencies: + ts-dedent: 2.2.0 + + /@storybook/core-events@7.6.17: + resolution: {integrity: sha512-AriWMCm/k1cxlv10f+jZ1wavThTRpLaN3kY019kHWbYT9XgaSuLU67G7GPr3cGnJ6HuA6uhbzu8qtqVCd6OfXA==} + dependencies: + ts-dedent: 2.2.0 + + /@storybook/core-server@7.5.1: + resolution: {integrity: sha512-DD4BXCH91aZJoFuu0cQwG1ZUmE59kG5pazuE3S89zH1GwKS1jWyeAv4EwEfvynT5Ah1ctd8QdCZCSXVzjq0qcw==} + dependencies: + '@aw-web-design/x-default-browser': 1.4.126 + '@discoveryjs/json-ext': 0.5.7 + '@storybook/builder-manager': 7.5.1 + '@storybook/channels': 7.5.1 + '@storybook/core-common': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/csf': 0.1.1 + '@storybook/csf-tools': 7.5.1 + '@storybook/docs-mdx': 0.1.0 + '@storybook/global': 5.0.0 + '@storybook/manager': 7.5.1 + '@storybook/node-logger': 7.5.1 + '@storybook/preview-api': 7.5.1 + '@storybook/telemetry': 7.5.1 + '@storybook/types': 7.5.1 + '@types/detect-port': 1.3.3 + '@types/node': 18.17.1 + '@types/pretty-hrtime': 1.0.1 + '@types/semver': 7.5.0 + better-opn: 3.0.2 + chalk: 4.1.2 + cli-table3: 0.6.3 + compression: 1.7.4 + detect-port: 1.5.1 + express: 4.18.2 + fs-extra: 11.1.1 + globby: 11.1.0 + ip: 2.0.0 + lodash: 4.17.21 + open: 8.4.2 + pretty-hrtime: 1.0.3 + prompts: 2.4.2 + read-pkg-up: 7.0.1 + semver: 7.5.4 + telejson: 7.2.0 + tiny-invariant: 1.3.1 + ts-dedent: 2.2.0 + util: 0.12.5 + util-deprecate: 1.0.2 + watchpack: 2.4.0 + ws: 8.14.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /@storybook/csf-plugin@7.5.1: + resolution: {integrity: sha512-jhV2aCZhSIXUiQDcHtuCg3dyYMzjYHTwLb4cJtkNw4sXqQoTGydTSWYwWigcHFfKGoyQp82rSgE1hE4YYx6iew==} + dependencies: + '@storybook/csf-tools': 7.5.1 + unplugin: 1.5.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@storybook/csf-tools@7.5.1: + resolution: {integrity: sha512-YChGbT1/odLS4RLb2HtK7ixM7mH5s7G5nOsWGKXalbza4SFKZIU2UzllEUsA+X8YfxMHnCD5TC3xLfK0ByxmzQ==} + dependencies: + '@babel/generator': 7.22.9 + '@babel/parser': 7.23.0 + '@babel/traverse': 7.22.8 + '@babel/types': 7.23.0 + '@storybook/csf': 0.1.1 + '@storybook/types': 7.5.1 + fs-extra: 11.1.1 + recast: 0.23.3 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /@storybook/csf@0.1.1: + resolution: {integrity: sha512-4hE3AlNVxR60Wc5KSC68ASYzUobjPqtSKyhV6G+ge0FIXU55N5nTY7dXGRZHQGDBPq+XqchMkIdlkHPRs8nTHg==} + dependencies: + type-fest: 2.19.0 + + /@storybook/csf@0.1.4: + resolution: {integrity: sha512-B9UI/lsQMjF+oEfZCI6YXNoeuBcGZoOP5x8yKbe2tIEmsMjSztFKkpPzi5nLCnBk/MBtl6QJeI3ksJnbsWPkOw==} + dependencies: + type-fest: 2.19.0 + + /@storybook/docs-mdx@0.1.0: + resolution: {integrity: sha512-JDaBR9lwVY4eSH5W8EGHrhODjygPd6QImRbwjAuJNEnY0Vw4ie3bPkeGfnacB3OBW6u/agqPv2aRlR46JcAQLg==} + dev: true + + /@storybook/docs-tools@7.5.1: + resolution: {integrity: sha512-tDtQGeKU5Kc2XoqZ5vpeGQrOkRg2UoDiSRS6cLy+M/sMB03Annq0ZngnJXaMiv0DLi2zpWSgWqPgYA3TJTZHBw==} + dependencies: + '@storybook/core-common': 7.5.1 + '@storybook/preview-api': 7.5.1 + '@storybook/types': 7.5.1 + '@types/doctrine': 0.0.3 + doctrine: 3.0.0 + lodash: 4.17.21 + transitivePeerDependencies: + - encoding + - supports-color + + /@storybook/expect@28.1.3-5: + resolution: {integrity: sha512-lS1oJnY1qTAxnH87C765NdfvGhksA6hBcbUVI5CHiSbNsEtr456wtg/z+dT9XlPriq1D5t2SgfNL9dBAoIGyIA==} + dependencies: + '@types/jest': 28.1.3 + dev: true + + /@storybook/global@5.0.0: + resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} + + /@storybook/instrumenter@7.5.1: + resolution: {integrity: sha512-bxRoWVVLlevqTFappXj1JfZlvEceBiBPdQQqTTeeA09VL3UyFWDpPFRn8Wf2C43Vt4V18w+krMyb1KfTk37ROQ==} + dependencies: + '@storybook/channels': 7.5.1 + '@storybook/client-logger': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/preview-api': 7.5.1 + dev: true + + /@storybook/jest@0.2.3(vitest@0.33.0): + resolution: {integrity: sha512-ov5izrmbAFObzKeh9AOC5MlmFxAcf0o5i6YFGae9sDx6DGh6alXsRM+chIbucVkUwVHVlSzdfbLDEFGY/ShaYw==} + dependencies: + '@storybook/expect': 28.1.3-5 + '@testing-library/jest-dom': 6.2.0(@types/jest@28.1.3)(vitest@0.33.0) + '@types/jest': 28.1.3 + jest-mock: 27.5.1 + transitivePeerDependencies: + - '@jest/globals' + - jest + - vitest + dev: true + + /@storybook/manager-api@7.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-a4UtzWcN/a12Kr4Z5B0KO05t3w3BtXapLRUERxiwB769ab/XJ6MmIyFY7mybKty3RZhmBWaO/oSfgrOwCeP/Gw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/channels': 7.1.0 + '@storybook/client-logger': 7.1.0 + '@storybook/core-events': 7.1.0 + '@storybook/csf': 0.1.1 + '@storybook/global': 5.0.0 + '@storybook/router': 7.1.0(react-dom@18.2.0)(react@18.2.0) + '@storybook/theming': 7.1.0(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.1.0 + dequal: 2.0.3 + lodash: 4.17.21 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + semver: 7.5.4 + store2: 2.14.2 + telejson: 7.2.0 + ts-dedent: 2.2.0 + dev: true + + /@storybook/manager-api@7.5.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ygwJywluhhE1dpA0jC2D/3NFhMXzFCt+iW4m3cOwexYTuiDWF66AbGOFBx9peE7Wk/Z9doKkf9E3v11enwaidA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/channels': 7.5.1 + '@storybook/client-logger': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/csf': 0.1.1 + '@storybook/global': 5.0.0 + '@storybook/router': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/theming': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + dequal: 2.0.3 + lodash: 4.17.21 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + semver: 7.5.4 + store2: 2.14.2 + telejson: 7.2.0 + ts-dedent: 2.2.0 + + /@storybook/manager-api@7.6.17(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-IJIV1Yc6yw1dhCY4tReHCfBnUKDqEBnMyHp3mbXpsaHxnxJZrXO45WjRAZIKlQKhl/Ge1CrnznmHRCmYgqmrWg==} + dependencies: + '@storybook/channels': 7.6.17 + '@storybook/client-logger': 7.6.17 + '@storybook/core-events': 7.6.17 + '@storybook/csf': 0.1.4 + '@storybook/global': 5.0.0 + '@storybook/router': 7.6.17 + '@storybook/theming': 7.6.17(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.6.17 + dequal: 2.0.3 + lodash: 4.17.21 + memoizerific: 1.11.3 + store2: 2.14.3 + telejson: 7.2.0 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - react + - react-dom + + /@storybook/manager@7.5.1: + resolution: {integrity: sha512-Jo83sj7KvsZ78vvqjH72ErmQ31Frx6GBLbpeYXZtbAXWl0/LHsxAEVz0Mke+DixzWDyP0/cn+Nw8QUfA+Oz1fg==} + dev: true + + /@storybook/mdx2-csf@1.1.0: + resolution: {integrity: sha512-TXJJd5RAKakWx4BtpwvSNdgTDkKM6RkXU8GK34S/LhidQ5Pjz3wcnqb0TxEkfhK/ztbP8nKHqXFwLfa2CYkvQw==} + dev: true + + /@storybook/node-logger@7.0.27: + resolution: {integrity: sha512-idoK+sDaTTPuxHcKhxn+l27Omhxvr1TQ0ALw1h8ehyMbW8TZBdWvYLYfmiWeI3+NQtmeudzxhKSVYTmAY4qDJw==} + dependencies: + '@types/npmlog': 4.1.4 + chalk: 4.1.2 + npmlog: 5.0.1 + pretty-hrtime: 1.0.3 + dev: true + + /@storybook/node-logger@7.5.1: + resolution: {integrity: sha512-xRMdL5YPe8C9sgJ1R0QD3YbiLjDGrfQk91+GplRD8N9FVCT5dki55Bv5Kp0FpemLYYg6uxAZL5nHmsZHKDKQoA==} + + /@storybook/postinstall@7.5.1: + resolution: {integrity: sha512-+LFUe2nNbmmLPKNt34RXSSC1r40yGGOoP/qlaPFwNOgQN2AZUrfqk6ZYnw6LjmcuHpQInZ4y4WDgbzg6QQL3+w==} + dev: true + + /@storybook/preview-api@7.5.1: + resolution: {integrity: sha512-8xjUbuGmHLmw8tfTUCjXSvMM9r96JaexPFmHdwW6XLe71KKdWp8u96vRDRE5648cd+/of15OjaRtakRKqluA/A==} + dependencies: + '@storybook/channels': 7.5.1 + '@storybook/client-logger': 7.5.1 + '@storybook/core-events': 7.5.1 + '@storybook/csf': 0.1.1 + '@storybook/global': 5.0.0 + '@storybook/types': 7.5.1 + '@types/qs': 6.9.9 + dequal: 2.0.3 + lodash: 4.17.21 + memoizerific: 1.11.3 + qs: 6.11.2 + synchronous-promise: 2.0.17 + ts-dedent: 2.2.0 + util-deprecate: 1.0.2 + + /@storybook/preview-api@7.6.17: + resolution: {integrity: sha512-wLfDdI9RWo1f2zzFe54yRhg+2YWyxLZvqdZnSQ45mTs4/7xXV5Wfbv3QNTtcdw8tT3U5KRTrN1mTfTCiRJc0Kw==} + dependencies: + '@storybook/channels': 7.6.17 + '@storybook/client-logger': 7.6.17 + '@storybook/core-events': 7.6.17 + '@storybook/csf': 0.1.4 + '@storybook/global': 5.0.0 + '@storybook/types': 7.6.17 + '@types/qs': 6.9.14 + dequal: 2.0.3 + lodash: 4.17.21 + memoizerific: 1.11.3 + qs: 6.12.0 + synchronous-promise: 2.0.17 + ts-dedent: 2.2.0 + util-deprecate: 1.0.2 + + /@storybook/preview@7.5.1: + resolution: {integrity: sha512-nfZC103z9Cy27FrJKUr2IjDuVt8Mvn1Z5gZ0TtJihoK7sfLTv29nd/XU9zzrb/epM3o8UEzc63xZZsMaToDbAw==} + dev: true + + /@storybook/react-dom-shim@7.5.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bzTIfLm91O9h3rPYJLtRbmsPARerY3z7MoyvadGp8TikvIvf+WyT/vHujw+20SxnqiZVq5Jv65FFlxc46GGB1Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@storybook/react-vite@7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.4.5)(vite@4.5.0): + resolution: {integrity: sha512-996/CtOqTjDWMKBGcHG8pwIVlORnoknLD+OTkPXl+aAl9oM9jUtc7psVKLJKGHSHTlVElM2wMTwIHnJ4yeP7bw==} + engines: {node: '>=16'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.4.5)(vite@4.5.0) + '@rollup/pluginutils': 5.0.5 + '@storybook/builder-vite': 7.5.1(typescript@5.4.5)(vite@4.5.0) + '@storybook/react': 7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.4.5) + '@vitejs/plugin-react': 3.1.0(vite@4.5.0) + magic-string: 0.30.5 + react: 18.2.0 + react-docgen: 6.0.4 + react-dom: 18.2.0(react@18.2.0) + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + transitivePeerDependencies: + - '@preact/preset-vite' + - encoding + - rollup + - supports-color + - typescript + - vite-plugin-glimmerx + dev: true + + /@storybook/react@7.5.1(react-dom@18.2.0)(react@18.2.0)(typescript@5.4.5): + resolution: {integrity: sha512-IG97c30fFSmPyGpJ1awHC/+9XnCTqleeOQwROXjroMHSm8m/JTWpHMVLyM1x7b6VAnBhNHWJ+oXLZe/hXkXfpA==} + engines: {node: '>=16.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/core-client': 7.5.1 + '@storybook/docs-tools': 7.5.1 + '@storybook/global': 5.0.0 + '@storybook/preview-api': 7.5.1 + '@storybook/react-dom-shim': 7.5.1(react-dom@18.2.0)(react@18.2.0) + '@storybook/types': 7.5.1 + '@types/escodegen': 0.0.6 + '@types/estree': 0.0.51 + '@types/node': 18.17.1 + acorn: 7.4.1 + acorn-jsx: 5.3.2(acorn@7.4.1) + acorn-walk: 7.2.0 + escodegen: 2.1.0 + html-tags: 3.3.1 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-element-to-jsx-string: 15.0.0(react-dom@18.2.0)(react@18.2.0) + ts-dedent: 2.2.0 + type-fest: 2.19.0 + typescript: 5.4.5 + util-deprecate: 1.0.2 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@storybook/router@7.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-zZUFV84bIjhKADrV7ZzHPOBtxumeonUU1Nbq7X+k6AWsurpUAdlpQrM+H+37eWIeFONX8Rfc0EUTrx+WUAq1hA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/client-logger': 7.1.0 + memoizerific: 1.11.3 + qs: 6.11.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@storybook/router@7.5.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BvKo+IxWwo3dfIG1+vLtZLT4qqkNHL5GTIozTyX04uqt9ByYZL6SJEzxEa1Xn6Qq/fbdQwzCanNHbTlwiTMf7Q==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@storybook/client-logger': 7.5.1 + memoizerific: 1.11.3 + qs: 6.11.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@storybook/router@7.6.17: + resolution: {integrity: sha512-GnyC0j6Wi5hT4qRhSyT8NPtJfGmf82uZw97LQRWeyYu5gWEshUdM7aj40XlNiScd5cZDp0owO1idduVF2k2l2A==} + dependencies: + '@storybook/client-logger': 7.6.17 + memoizerific: 1.11.3 + qs: 6.12.0 + + /@storybook/telemetry@7.5.1: + resolution: {integrity: sha512-z9PGouNqvZ2F7vD79qDF4PN7iW3kE3MO7YX0iKTmzgLi4ImKuXIJRF04GRH8r+WYghnbomAyA4o6z9YJMdNuVw==} + dependencies: + '@storybook/client-logger': 7.5.1 + '@storybook/core-common': 7.5.1 + '@storybook/csf-tools': 7.5.1 + chalk: 4.1.2 + detect-package-manager: 2.0.1 + fetch-retry: 5.0.6 + fs-extra: 11.1.1 + read-pkg-up: 7.0.1 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@storybook/testing-library@0.2.2: + resolution: {integrity: sha512-L8sXFJUHmrlyU2BsWWZGuAjv39Jl1uAqUHdxmN42JY15M4+XCMjGlArdCCjDe1wpTSW6USYISA9axjZojgtvnw==} + dependencies: + '@testing-library/dom': 9.3.3 + '@testing-library/user-event': 14.4.3(@testing-library/dom@9.3.3) + ts-dedent: 2.2.0 + dev: true + + /@storybook/theming@7.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bO56c7NFlK7sfjsCbV56VLU59HHvQTW/HVu8RxUuoY+0WutyGAq6uZCmtQnMMGORzxh0p/uU2dSBVYEfW8QoTQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@storybook/client-logger': 7.1.0 + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@storybook/theming@7.5.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ETLAOn10hI4Mkmjsr0HGcM6HbzaURrrPBYmfXOrdbrzEVN+AHW4FlvP9d8fYyP1gdjPE1F39XvF0jYgt1zXiHQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@storybook/client-logger': 7.5.1 + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@storybook/theming@7.6.17(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-ZbaBt3KAbmBtfjNqgMY7wPMBshhSJlhodyMNQypv+95xLD/R+Az6aBYbpVAOygLaUQaQk4ar7H/Ww6lFIoiFbA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@emotion/use-insertion-effect-with-fallbacks': 1.0.1(react@18.2.0) + '@storybook/client-logger': 7.6.17 + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /@storybook/types@7.0.27: + resolution: {integrity: sha512-pmJuIm+kGaZiDMyl2i5KFS9iGWrpW1jVcp9OMtHeK20LBzY5Hxq/JMc3E+fbVNkAX2hVlVGbbVUNPTvd9AjbrA==} + dependencies: + '@storybook/channels': 7.0.27 + '@types/babel__core': 7.20.3 + '@types/express': 4.17.20 + file-system-cache: 2.3.0 + dev: true + + /@storybook/types@7.1.0: + resolution: {integrity: sha512-ify1+BypgEFefkKCqBfh9fTWnkZcEqeDvLlOxbEV82C2ozg0yPlDP9VLe1eN5XM5Biigs6ZQ6WuQysl0VlCaEw==} + dependencies: + '@storybook/channels': 7.1.0 + '@types/babel__core': 7.20.3 + '@types/express': 4.17.20 + file-system-cache: 2.3.0 + dev: true + + /@storybook/types@7.5.1: + resolution: {integrity: sha512-ZcMSaqFNx1E+G00nRDUi8kKL7gxJVlnCvbKLNj3V85guy4DkIYAZr31yDqze07gDWbjvKoHIp3tKpgE+2i8upQ==} + dependencies: + '@storybook/channels': 7.5.1 + '@types/babel__core': 7.20.3 + '@types/express': 4.17.20 + file-system-cache: 2.3.0 + + /@storybook/types@7.6.17: + resolution: {integrity: sha512-GRY0xEJQ0PrL7DY2qCNUdIfUOE0Gsue6N+GBJw9ku1IUDFLJRDOF+4Dx2BvYcVCPI5XPqdWKlEyZdMdKjiQN7Q==} + dependencies: + '@storybook/channels': 7.6.17 + '@types/babel__core': 7.20.5 + '@types/express': 4.17.21 + file-system-cache: 2.3.0 + + /@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.22.9): + resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-plugin-remove-jsx-attribute@8.0.0(@babel/core@7.22.9): + resolution: {integrity: sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0(@babel/core@7.22.9): + resolution: {integrity: sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0(@babel/core@7.22.9): + resolution: {integrity: sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-plugin-svg-dynamic-title@8.0.0(@babel/core@7.22.9): + resolution: {integrity: sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-plugin-svg-em-dimensions@8.0.0(@babel/core@7.22.9): + resolution: {integrity: sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-plugin-transform-react-native-svg@8.1.0(@babel/core@7.22.9): + resolution: {integrity: sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-plugin-transform-svg-component@8.0.0(@babel/core@7.22.9): + resolution: {integrity: sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==} + engines: {node: '>=12'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + dev: true + + /@svgr/babel-preset@8.1.0(@babel/core@7.22.9): + resolution: {integrity: sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==} + engines: {node: '>=14'} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@svgr/babel-plugin-add-jsx-attribute': 8.0.0(@babel/core@7.22.9) + '@svgr/babel-plugin-remove-jsx-attribute': 8.0.0(@babel/core@7.22.9) + '@svgr/babel-plugin-remove-jsx-empty-expression': 8.0.0(@babel/core@7.22.9) + '@svgr/babel-plugin-replace-jsx-attribute-value': 8.0.0(@babel/core@7.22.9) + '@svgr/babel-plugin-svg-dynamic-title': 8.0.0(@babel/core@7.22.9) + '@svgr/babel-plugin-svg-em-dimensions': 8.0.0(@babel/core@7.22.9) + '@svgr/babel-plugin-transform-react-native-svg': 8.1.0(@babel/core@7.22.9) + '@svgr/babel-plugin-transform-svg-component': 8.0.0(@babel/core@7.22.9) + dev: true + + /@svgr/core@8.1.0: + resolution: {integrity: sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==} + engines: {node: '>=14'} + dependencies: + '@babel/core': 7.22.9 + '@svgr/babel-preset': 8.1.0(@babel/core@7.22.9) + camelcase: 6.3.0 + cosmiconfig: 8.2.0 + snake-case: 3.0.4 + transitivePeerDependencies: + - supports-color + dev: true + + /@svgr/hast-util-to-babel-ast@8.0.0: + resolution: {integrity: sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==} + engines: {node: '>=14'} + dependencies: + '@babel/types': 7.23.4 + entities: 4.5.0 + dev: true + + /@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0): + resolution: {integrity: sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==} + engines: {node: '>=14'} + peerDependencies: + '@svgr/core': '*' + dependencies: + '@babel/core': 7.22.9 + '@svgr/babel-preset': 8.1.0(@babel/core@7.22.9) + '@svgr/core': 8.1.0 + '@svgr/hast-util-to-babel-ast': 8.0.0 + svg-parser: 2.0.4 + transitivePeerDependencies: + - supports-color + dev: true + + /@swc/core-darwin-arm64@1.3.69: + resolution: {integrity: sha512-IjZTf12zIPWkV3D7toaLDoJPSkLhQ4fDH8G6/yCJUI27cBFOI3L8LXqptYmISoN5yYdrcnNpdqdapD09JPuNJg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-darwin-x64@1.3.69: + resolution: {integrity: sha512-/wBO0Rn5oS5dJI/L9kJRkPAdksVwl5H9nleW/NM3A40N98VV8T7h/i1nO051mxIjq0R6qXVGOWFbBoLrPYucJg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm-gnueabihf@1.3.69: + resolution: {integrity: sha512-NShCjMv6Xn8ckMKBRqmprXvUF14+jXY0TcNKXwjYErzoIUFOnG72M36HxT4QEeAtKZ4Eg4CZFE4zlJ27fDp1gg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-gnu@1.3.69: + resolution: {integrity: sha512-VRPOJj4idopSHIj1bOVXX0SgaB18R8yZNunb7eXS5ZcjVxAcdvqyIz3RdQX1zaJFCGzcdPLzBRP32DZWWGE8Ng==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-musl@1.3.69: + resolution: {integrity: sha512-QxeSiZqo5x1X8vq8oUWLibq+IZJcxl9vy0sLUmzdjF2b/Z+qxKP3gutxnb2tzJaHqPVBbEZaILERIGy1qWdumQ==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-gnu@1.3.69: + resolution: {integrity: sha512-b+DUlVxYox3BwD3PyTwhLvqtu6TYZtW+S6O0FnttH11o4skHN0XyJ/cUZSI0X2biSmfDsizRDUt1PWPFM+F7SA==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-musl@1.3.69: + resolution: {integrity: sha512-QXjsI+f8n9XPZHUvmGgkABpzN4M9kdSbhqBOZmv3o0AsDGNCA4uVowQqgZoPFAqlJTpwHeDmrv5sQ13HN+LOGw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-arm64-msvc@1.3.69: + resolution: {integrity: sha512-wn7A8Ws1fyviuCUB2Vg6IotiZeuqiO1Mz3d+YDae2EYyNpj1kNHvjBip8GHkfGzZG+jVrvG6NHsDo0KO/pGb8A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-ia32-msvc@1.3.69: + resolution: {integrity: sha512-LsFBXtXqxEcVaaOGEZ9X3qdMzobVoJqKv8DnksuDsWcBk+9WCeTz2u/iB+7yZ2HGuPXkCqTRqhFo6FX9aC00kQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-x64-msvc@1.3.69: + resolution: {integrity: sha512-ieBscU0gUgKjaseFI07tAaGqHvKyweNknPeSYEZOasVZUczhD6fK2GRnVREhv2RB2qdKC/VGFBsgRDMgzq1VLw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core@1.3.69: + resolution: {integrity: sha512-Khc/DE9D5+2tYTHgAIp5DZARbs8kldWg3b0Jp6l8FQLjelcLFmlQWSwKhVZrgv4oIbgZydIp8jInsvTalMHqnQ==} + engines: {node: '>=10'} + requiresBuild: true + peerDependencies: + '@swc/helpers': ^0.5.0 + peerDependenciesMeta: + '@swc/helpers': + optional: true + optionalDependencies: + '@swc/core-darwin-arm64': 1.3.69 + '@swc/core-darwin-x64': 1.3.69 + '@swc/core-linux-arm-gnueabihf': 1.3.69 + '@swc/core-linux-arm64-gnu': 1.3.69 + '@swc/core-linux-arm64-musl': 1.3.69 + '@swc/core-linux-x64-gnu': 1.3.69 + '@swc/core-linux-x64-musl': 1.3.69 + '@swc/core-win32-arm64-msvc': 1.3.69 + '@swc/core-win32-ia32-msvc': 1.3.69 + '@swc/core-win32-x64-msvc': 1.3.69 + dev: true + + /@tanstack/query-core@5.28.8: + resolution: {integrity: sha512-cx64XHeB0kvKxFt22ibvegPeOxnaWVFUbAuhXoIrb7+XePEexHWoB9Kq5n9qroNPkRwQZwgFAP9HNbQz5ohoIg==} + dev: false + + /@tanstack/react-query@5.28.8(react@18.2.0): + resolution: {integrity: sha512-4XYhoRmcThqziB32HsyiBLNXJcukaeGfYwAQ+fZqUUE3ZP4oB/Zy41UJdql+TUg98+vsezfbixxAwAbGHfc5Hg==} + peerDependencies: + react: ^18.0.0 + dependencies: + '@tanstack/query-core': 5.28.8 + react: 18.2.0 + dev: false + + /@tanstack/react-table@8.10.7(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-bXhjA7xsTcsW8JPTTYlUg/FuBpn8MNjiEPhkNhIGCUR6iRQM2+WEco4OBpvDeVcR9SE+bmWLzdfiY7bCbCSVuA==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + '@tanstack/table-core': 8.10.7 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@tanstack/table-core@8.10.7: + resolution: {integrity: sha512-KQk5OMg5OH6rmbHZxuNROvdI+hKDIUxANaHlV+dPlNN7ED3qYQ/WkpY2qlXww1SIdeMlkIhpN/2L00rof0fXFw==} + engines: {node: '>=12'} + dev: false + + /@testing-library/dom@10.0.0: + resolution: {integrity: sha512-PmJPnogldqoVFf+EwbHvbBJ98MmqASV8kLrBYgsDNxQcFMeIS7JFL48sfyXvuMtgmWO/wMhh25odr+8VhDmn4g==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/runtime': 7.24.4 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/dom@9.3.1: + resolution: {integrity: sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==} + engines: {node: '>=14'} + dependencies: + '@babel/code-frame': 7.22.5 + '@babel/runtime': 7.24.4 + '@types/aria-query': 5.0.1 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/dom@9.3.3: + resolution: {integrity: sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==} + engines: {node: '>=14'} + dependencies: + '@babel/code-frame': 7.22.13 + '@babel/runtime': 7.24.4 + '@types/aria-query': 5.0.3 + aria-query: 5.1.3 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/jest-dom@5.16.5: + resolution: {integrity: sha512-N5ixQ2qKpi5OLYfwQmUb/5mSV9LneAcaUfp32pn4yCnpb8r/Yz0pXFPck21dIicKmi+ta5WRAknkZCfA8refMA==} + engines: {node: '>=8', npm: '>=6', yarn: '>=1'} + dependencies: + '@adobe/css-tools': 4.2.0 + '@babel/runtime': 7.22.6 + '@types/testing-library__jest-dom': 5.14.8 + aria-query: 5.1.3 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.5.16 + lodash: 4.17.21 + redent: 3.0.0 + dev: true + + /@testing-library/jest-dom@6.2.0(@types/jest@28.1.3)(vitest@0.33.0): + resolution: {integrity: sha512-+BVQlJ9cmEn5RDMUS8c2+TU6giLvzaHZ8sU/x0Jj7fk+6/46wPdwlgOPcpxS17CjcanBi/3VmGMqVr2rmbUmNw==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + peerDependencies: + '@jest/globals': '>= 28' + '@types/jest': '>= 28' + jest: '>= 28' + vitest: '>= 0.32' + peerDependenciesMeta: + '@jest/globals': + optional: true + '@types/jest': + optional: true + jest: + optional: true + vitest: + optional: true + dependencies: + '@adobe/css-tools': 4.3.2 + '@babel/runtime': 7.24.4 + '@types/jest': 28.1.3 + aria-query: 5.1.3 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + vitest: 0.33.0(jsdom@22.1.0)(less@4.2.0) + dev: true + + /@testing-library/react@14.0.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-S04gSNJbYE30TlIMLTzv6QCTzt9AqIF5y6s6SzVFILNcNvbV/jU96GeiTPillGQo+Ny64M/5PV7klNYYgv5Dfg==} + engines: {node: '>=14'} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@babel/runtime': 7.22.6 + '@testing-library/dom': 9.3.1 + '@types/react-dom': 18.2.6 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /@testing-library/user-event@14.4.3(@testing-library/dom@10.0.0): + resolution: {integrity: sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 10.0.0 + dev: true + + /@testing-library/user-event@14.4.3(@testing-library/dom@9.3.3): + resolution: {integrity: sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 9.3.3 + dev: true + + /@tootallnate/once@2.0.0: + resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==} + engines: {node: '>= 10'} + dev: true + + /@total-typescript/ts-reset@0.5.1: + resolution: {integrity: sha512-AqlrT8YA1o7Ff5wPfMOL0pvL+1X+sw60NN6CcOCqs658emD6RfiXhF7Gu9QcfKBH7ELY2nInLhKSCWVoNL70MQ==} + dev: true + + /@types/aria-query@5.0.1: + resolution: {integrity: sha512-XTIieEY+gvJ39ChLcB4If5zHtPxt3Syj5rgZR+e1ctpmK8NjPf0zFqsz4JpLJT0xla9GFDKjy8Cpu331nrmE1Q==} + dev: true + + /@types/aria-query@5.0.3: + resolution: {integrity: sha512-0Z6Tr7wjKJIk4OUEjVUQMtyunLDy339vcMaj38Kpj6jM2OE1p3S4kXExKZ7a3uXQAPCoy3sbrP1wibDKaf39oA==} + dev: true + + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + + /@types/babel__core@7.20.3: + resolution: {integrity: sha512-54fjTSeSHwfan8AyHWrKbfBWiEUrNTZsUwPTDSNaaP1QDQIZbeNUg3a59E9D+375MzUw/x1vx2/0F5LBz+AeYA==} + dependencies: + '@babel/parser': 7.23.0 + '@babel/types': 7.23.0 + '@types/babel__generator': 7.6.6 + '@types/babel__template': 7.4.3 + '@types/babel__traverse': 7.20.3 + + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.5 + + /@types/babel__generator@7.6.6: + resolution: {integrity: sha512-66BXMKb/sUWbMdBNdMvajU7i/44RkrA3z/Yt1c7R5xejt8qh84iU54yUWCtm0QwGJlDcf/gg4zd/x4mpLAlb/w==} + dependencies: + '@babel/types': 7.23.4 + + /@types/babel__generator@7.6.8: + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + dependencies: + '@babel/types': 7.24.0 + + /@types/babel__template@7.4.3: + resolution: {integrity: sha512-ciwyCLeuRfxboZ4isgdNZi/tkt06m8Tw6uGbBSBgWrnnZGNXiEyM27xc/PjXGQLqlZ6ylbgHMnm7ccF9tCkOeQ==} + dependencies: + '@babel/parser': 7.23.0 + '@babel/types': 7.23.4 + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + + /@types/babel__traverse@7.20.3: + resolution: {integrity: sha512-Lsh766rGEFbaxMIDH7Qa+Yha8cMVI3qAK6CHt3OR0YfxOIn5Z54iHiyDRycHrBqeIiqGa20Kpsv1cavfBKkRSw==} + dependencies: + '@babel/types': 7.23.0 + + /@types/babel__traverse@7.20.5: + resolution: {integrity: sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==} + dependencies: + '@babel/types': 7.24.0 + + /@types/bn.js@4.11.6: + resolution: {integrity: sha512-pqr857jrp2kPuO9uRjZ3PwnJTjoQy+fcdxvBTvHm6dkmEL9q+hDD/2j/0ELOBPtPnS8LjCX0gI9nbl8lVkadpg==} + dependencies: + '@types/node': 20.12.7 + dev: false + + /@types/bn.js@5.1.5: + resolution: {integrity: sha512-V46N0zwKRF5Q00AZ6hWtN0T8gGmDUaUzLWQvHFo5yThtVwK/VCenFY3wXVbOvNfajEpsTfQM4IN9k/d6gUVX3A==} + dependencies: + '@types/node': 20.12.7 + dev: false + + /@types/body-parser@1.19.4: + resolution: {integrity: sha512-N7UDG0/xiPQa2D/XrVJXjkWbpqHCd2sBaB32ggRF2l83RhPfamgKGF8gwwqyksS95qUS5ZYF9aF+lLPRlwI2UA==} + dependencies: + '@types/connect': 3.4.37 + '@types/node': 20.10.0 + + /@types/body-parser@1.19.5: + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.12.7 + + /@types/chai-subset@1.3.4: + resolution: {integrity: sha512-CCWNXrJYSUIojZ1149ksLl3AN9cmZ5djf+yUoVVV+NuYrtydItQVlL2ZDqyC6M6O9LWRnVf8yYDxbXHO2TfQZg==} + dependencies: + '@types/chai': 4.3.9 + dev: true + + /@types/chai@4.3.9: + resolution: {integrity: sha512-69TtiDzu0bcmKQv3yg1Zx409/Kd7r0b5F1PfpYJfSHzLGtB53547V4u+9iqKYsTu/O2ai6KTb0TInNpvuQ3qmg==} + dev: true + + /@types/chrome@0.0.136: + resolution: {integrity: sha512-XDEiRhLkMd+SB7Iw3ZUIj/fov3wLd4HyTdLltVszkgl1dBfc3Rb7oPMVZ2Mz2TLqnF7Ow+StbR8E7r9lqpb4DA==} + dependencies: + '@types/filesystem': 0.0.35 + '@types/har-format': 1.2.15 + dev: false + + /@types/connect@3.4.37: + resolution: {integrity: sha512-zBUSRqkfZ59OcwXon4HVxhx5oWCJmc0OtBTK05M+p0dYjgN6iTwIL2T/WbsQZrEsdnwaF9cWQ+azOnpPvIqY3Q==} + dependencies: + '@types/node': 20.10.0 + + /@types/connect@3.4.38: + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + dependencies: + '@types/node': 20.12.7 + + /@types/cross-spawn@6.0.4: + resolution: {integrity: sha512-GGLpeThc2Bu8FBGmVn76ZU3lix17qZensEI4/MPty0aZpm2CHfgEMis31pf5X5EiudYKcPAsWciAsCALoPo5dw==} + dependencies: + '@types/node': 20.10.0 + dev: true + + /@types/d3-array@3.0.3: + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + dev: false + + /@types/d3-array@3.2.1: + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + dev: true + + /@types/d3-color@3.1.0: + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + dev: false + + /@types/d3-delaunay@6.0.1: + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + dev: false + + /@types/d3-format@3.0.1: + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + dev: false + + /@types/d3-geo@3.1.0: + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + dependencies: + '@types/geojson': 7946.0.14 + dev: false + + /@types/d3-interpolate@3.0.1: + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + dependencies: + '@types/d3-color': 3.1.0 + dev: false + + /@types/d3-path@1.0.11: + resolution: {integrity: sha512-4pQMp8ldf7UaB/gR8Fvvy69psNHkTpD/pVw3vmEi8iZAB9EPMBruB1JvHO4BIq9QkUUd2lV1F5YXpMNj7JPBpw==} + dev: false + + /@types/d3-scale@4.0.2: + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + dependencies: + '@types/d3-time': 3.0.0 + dev: false + + /@types/d3-shape@1.3.12: + resolution: {integrity: sha512-8oMzcd4+poSLGgV0R1Q1rOlx/xdmozS4Xab7np0eamFFUYq71AU9pOCJEFnkXW2aI/oXdVYJzw6pssbSut7Z9Q==} + dependencies: + '@types/d3-path': 1.0.11 + dev: false + + /@types/d3-time-format@2.1.0: + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + dev: false + + /@types/d3-time@3.0.0: + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + dev: false + + /@types/debug@4.1.9: + resolution: {integrity: sha512-8Hz50m2eoS56ldRlepxSBa6PWEVCtzUo/92HgLc2qTMnotJNIm7xP+UZhyWoYsyOdd5dxZ+NZLb24rsKyFs2ow==} + dependencies: + '@types/ms': 0.7.32 + dev: false + + /@types/detect-port@1.3.3: + resolution: {integrity: sha512-bV/jQlAJ/nPY3XqSatkGpu+nGzou+uSwrH1cROhn+jBFg47yaNH+blW4C7p9KhopC7QxCv/6M86s37k8dMk0Yg==} + dev: true + + /@types/doctrine@0.0.3: + resolution: {integrity: sha512-w5jZ0ee+HaPOaX25X2/2oGR/7rgAQSYII7X7pp0m9KgBfMP7uKfMfTvcpl5Dj+eDBbpxKGiqE+flqDr6XTd2RA==} + + /@types/doctrine@0.0.6: + resolution: {integrity: sha512-KlEqPtaNBHBJ2/fVA4yLdD0Tc8zw34pKU4K5SHBIEwtLJ8xxumIC1xeG+4S+/9qhVj2MqC7O3Ld8WvDG4HqlgA==} + dev: true + + /@types/dom-screen-wake-lock@1.0.3: + resolution: {integrity: sha512-3Iten7X3Zgwvk6kh6/NRdwN7WbZ760YgFCsF5AxDifltUQzW1RaW+WRmcVtgwFzLjaNu64H+0MPJ13yRa8g3Dw==} + dev: false + + /@types/ejs@3.1.2: + resolution: {integrity: sha512-ZmiaE3wglXVWBM9fyVC17aGPkLo/UgaOjEiI2FXQfyczrCefORPxIe+2dVmnmk3zkVIbizjrlQzmPGhSYGXG5g==} + dev: true + + /@types/emscripten@1.39.9: + resolution: {integrity: sha512-ILdWj4XYtNOqxJaW22NEQx2gJsLfV5ncxYhhGX1a1H1lXl2Ta0gUz7QOnOoF1xQbJwWDjImi8gXN9mKdIf6n9g==} + dev: true + + /@types/escodegen@0.0.6: + resolution: {integrity: sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig==} + dev: true + + /@types/eslint-scope@3.7.7: + resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} + dependencies: + '@types/eslint': 8.56.9 + '@types/estree': 1.0.5 + dev: true + + /@types/eslint@8.56.9: + resolution: {integrity: sha512-W4W3KcqzjJ0sHg2vAq9vfml6OhsJ53TcUjUqfzzZf/EChUtwspszj/S0pzMxnfRcO55/iGq47dscXw71Fxc4Zg==} + dependencies: + '@types/estree': 1.0.5 + '@types/json-schema': 7.0.15 + dev: true + + /@types/estree@0.0.51: + resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==} + dev: true + + /@types/estree@1.0.3: + resolution: {integrity: sha512-CS2rOaoQ/eAgAfcTfq6amKG7bsN+EMcgGY4FAFQdvSj2y1ixvOZTUA9mOtCai7E1SYu283XNw7urKK30nP3wkQ==} + dev: true + + /@types/estree@1.0.5: + resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + dev: true + + /@types/express-serve-static-core@4.17.39: + resolution: {integrity: sha512-BiEUfAiGCOllomsRAZOiMFP7LAnrifHpt56pc4Z7l9K6ACyN06Ns1JLMBxwkfLOjJRlSf06NwWsT7yzfpaVpyQ==} + dependencies: + '@types/node': 20.10.0 + '@types/qs': 6.9.9 + '@types/range-parser': 1.2.6 + '@types/send': 0.17.3 + + /@types/express-serve-static-core@4.19.0: + resolution: {integrity: sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==} + dependencies: + '@types/node': 20.12.7 + '@types/qs': 6.9.14 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + + /@types/express@4.17.20: + resolution: {integrity: sha512-rOaqlkgEvOW495xErXMsmyX3WKBInbhG5eqojXYi3cGUaLoRDlXa5d52fkfWZT963AZ3v2eZ4MbKE6WpDAGVsw==} + dependencies: + '@types/body-parser': 1.19.4 + '@types/express-serve-static-core': 4.17.39 + '@types/qs': 6.9.9 + '@types/serve-static': 1.15.4 + + /@types/express@4.17.21: + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.0 + '@types/qs': 6.9.14 + '@types/serve-static': 1.15.7 + + /@types/filesystem@0.0.35: + resolution: {integrity: sha512-1eKvCaIBdrD2mmMgy5dwh564rVvfEhZTWVQQGRNn0Nt4ZEnJ0C8oSUCzvMKRA4lGde5oEVo+q2MrTTbV/GHDCQ==} + dependencies: + '@types/filewriter': 0.0.33 + dev: false + + /@types/filewriter@0.0.33: + resolution: {integrity: sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g==} + dev: false + + /@types/find-cache-dir@3.2.1: + resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==} + + /@types/geojson@7946.0.14: + resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} + dev: false + + /@types/glob@7.2.0: + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 20.10.0 + dev: true + + /@types/glob@8.1.0: + resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==} + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 20.10.0 + dev: true + + /@types/graceful-fs@4.1.6: + resolution: {integrity: sha512-Sig0SNORX9fdW+bQuTEovKj3uHcUL6LQKbCrrqb1X7J6/ReAbhCXRAhc+SMejhLELFj2QcyuxmUooZ4bt5ReSw==} + dependencies: + '@types/node': 20.10.0 + dev: true + + /@types/har-format@1.2.15: + resolution: {integrity: sha512-RpQH4rXLuvTXKR0zqHq3go0RVXYv/YVqv4TnPH95VbwUxZdQlK1EtcMvQvMpDngHbt13Csh9Z4qT9AbkiQH5BA==} + dev: false + + /@types/http-errors@2.0.3: + resolution: {integrity: sha512-pP0P/9BnCj1OVvQR2lF41EkDG/lWWnDyA203b/4Fmi2eTyORnBtcDoKDwjWQthELrBvWkMOrvSOnZ8OVlW6tXA==} + + /@types/http-errors@2.0.4: + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + + /@types/istanbul-lib-coverage@2.0.4: + resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} + + /@types/istanbul-lib-coverage@2.0.6: + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + dev: false + + /@types/istanbul-lib-report@3.0.0: + resolution: {integrity: sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.4 + + /@types/istanbul-lib-report@3.0.3: + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + dev: false + + /@types/istanbul-reports@3.0.1: + resolution: {integrity: sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw==} + dependencies: + '@types/istanbul-lib-report': 3.0.0 + + /@types/istanbul-reports@3.0.4: + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + dependencies: + '@types/istanbul-lib-report': 3.0.3 + dev: false + + /@types/jest@28.1.3: + resolution: {integrity: sha512-Tsbjk8Y2hkBaY/gJsataeb4q9Mubw9EOz7+4RjPkzD5KjTvHHs7cpws22InaoXxAVAhF5HfFbzJjo6oKWqSZLw==} + dependencies: + jest-matcher-utils: 28.1.3 + pretty-format: 28.1.3 + dev: true + + /@types/jest@29.5.3: + resolution: {integrity: sha512-1Nq7YrO/vJE/FYnqYyw0FS8LdrjExSgIiHyKg7xPpn+yi8Q4huZryKnkJatN1ZRH89Kw2v33/8ZMB7DuZeSLlA==} + dependencies: + expect: 29.6.1 + pretty-format: 29.6.1 + dev: true + + /@types/jsdom@21.1.1: + resolution: {integrity: sha512-cZFuoVLtzKP3gmq9eNosUL1R50U+USkbLtUQ1bYVgl/lKp0FZM7Cq4aIHAL8oIvQ17uSHi7jXPtfDOdjPwBE7A==} + dependencies: + '@types/node': 20.4.10 + '@types/tough-cookie': 4.0.2 + parse5: 7.1.2 + dev: true + + /@types/json-schema@7.0.12: + resolution: {integrity: sha512-Hr5Jfhc9eYOQNPYO5WLDq/n4jqijdHNlDXjuAQkkt+mWdQR+XJToOHrsD4cPaMXpn6KO7y2+wM8AZEs8VpBLVA==} + dev: true + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + + /@types/json5@0.0.29: + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + dev: true + + /@types/lodash@4.14.195: + resolution: {integrity: sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==} + + /@types/lokijs@1.5.8: + resolution: {integrity: sha512-HN4vmoYHqF0mx91Cci6xaH1uN1JAMbakqNFXggpbd2L/RTUMrvx//dJTJehEtEF+a/qXfLbVSeO6p3Oegx/hDg==} + dev: true + + /@types/lru-cache@5.1.1: + resolution: {integrity: sha512-ssE3Vlrys7sdIzs5LOxCzTVMsU7i9oa/IaW92wF32JFb3CVczqOkru2xspuKczHEbG3nvmPY7IFqVmGGHdNbYw==} + dev: false + + /@types/mdx@2.0.5: + resolution: {integrity: sha512-76CqzuD6Q7LC+AtbPqrvD9AqsN0k8bsYo2bM2J8pmNldP1aIPAbzUQ7QbobyXL4eLr1wK5x8FZFe8eF/ubRuBg==} + dev: true + + /@types/mime-types@2.1.1: + resolution: {integrity: sha512-vXOTGVSLR2jMw440moWTC7H19iUyLtP3Z1YTj7cSsubOICinjMxFeb/V57v9QdyyPGbbWolUFSSmSiRSn94tFw==} + dev: true + + /@types/mime@1.3.4: + resolution: {integrity: sha512-1Gjee59G25MrQGk8bsNvC6fxNiRgUlGn2wlhGf95a59DrprnnHk80FIMMFG9XHMdrfsuA119ht06QPDXA1Z7tw==} + + /@types/mime@1.3.5: + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + + /@types/mime@3.0.3: + resolution: {integrity: sha512-i8MBln35l856k5iOhKk2XJ4SeAWg75mLIpZB4v6imOagKL6twsukBZGDMNhdOVk7yRFTMPpfILocMos59Q1otQ==} + + /@types/minimatch@5.1.2: + resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + dev: true + + /@types/ms@0.7.32: + resolution: {integrity: sha512-xPSg0jm4mqgEkNhowKgZFBNtwoEwF6gJ4Dhww+GFpm3IgtNseHQZ5IqdNwnquZEoANxyDAKDRAdVo4Z72VvD/g==} + dev: false + + /@types/node-fetch@2.6.4: + resolution: {integrity: sha512-1ZX9fcN4Rvkvgv4E6PAY5WXUFWFcRWxZa3EW83UjycOB9ljJCedb2CupIP4RZMEwF/M3eTcCihbBRgwtGbg5Rg==} + dependencies: + '@types/node': 20.8.9 + form-data: 3.0.1 + + /@types/node@16.18.59: + resolution: {integrity: sha512-PJ1w2cNeKUEdey4LiPra0ZuxZFOGvetswE8qHRriV/sUkL5Al4tTmPV9D2+Y/TPIxTHHgxTfRjZVKWhPw/ORhQ==} + dev: true + + /@types/node@18.15.13: + resolution: {integrity: sha512-N+0kuo9KgrUQ1Sn/ifDXsvg0TTleP7rIy4zOBGECxAljqvqfqpTfzx0Q1NUedOixRMBfe2Whhb056a42cWs26Q==} + dev: false + + /@types/node@18.17.1: + resolution: {integrity: sha512-xlR1jahfizdplZYRU59JlUx9uzF1ARa8jbhM11ccpCJya8kvos5jwdm2ZAgxSCwOl0fq21svP18EVwPBXMQudw==} + + /@types/node@20.10.0: + resolution: {integrity: sha512-D0WfRmU9TQ8I9PFx9Yc+EBHw+vSpIub4IDvQivcp26PtPrdMGAq5SDcpXEo/epqa/DXotVpekHiLNTg3iaKXBQ==} + dependencies: + undici-types: 5.26.5 + + /@types/node@20.12.7: + resolution: {integrity: sha512-wq0cICSkRLVaf3UGLMGItu/PtdY7oaXaI/RVU+xliKVOtRna3PRY57ZDfztpDL0n11vfymMUnXv8QwYCO7L1wg==} + dependencies: + undici-types: 5.26.5 + + /@types/node@20.4.10: + resolution: {integrity: sha512-vwzFiiy8Rn6E0MtA13/Cxxgpan/N6UeNYR9oUu6kuJWxu6zCk98trcDp8CBhbtaeuq9SykCmXkFr2lWLoPcvLg==} + dev: true + + /@types/node@20.8.9: + resolution: {integrity: sha512-UzykFsT3FhHb1h7yD4CA4YhBHq545JC0YnEz41xkipN88eKQtL6rSgocL5tbAP6Ola9Izm/Aw4Ora8He4x0BHg==} + dependencies: + undici-types: 5.26.5 + + /@types/normalize-package-data@2.4.1: + resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==} + dev: true + + /@types/npmlog@4.1.4: + resolution: {integrity: sha512-WKG4gTr8przEZBiJ5r3s8ZIAoMXNbOgQ+j/d5O4X3x6kZJRLNvyUJuUK/KoG3+8BaOHPhp2m7WC6JKKeovDSzQ==} + dev: true + + /@types/parse-json@4.0.1: + resolution: {integrity: sha512-3YmXzzPAdOTVljVMkTMBdBEvlOLg2cDQaDhnnhT3nT9uDbnJzjWhKlzb+desT12Y7tGqaN6d+AbozcKzyL36Ng==} + + /@types/pbkdf2@3.1.2: + resolution: {integrity: sha512-uRwJqmiXmh9++aSu1VNEn3iIxWOhd8AHXNSdlaLfdAAdSTY9jYVeGWnzejM3dvrkbqE3/hyQkQQ29IFATEGlew==} + dependencies: + '@types/node': 20.12.7 + dev: false + + /@types/pretty-hrtime@1.0.1: + resolution: {integrity: sha512-VjID5MJb1eGKthz2qUerWT8+R4b9N+CHvGCzg9fn4kWZgaF9AhdYikQio3R7wV8YY1NsQKPaCwKz1Yff+aHNUQ==} + + /@types/prop-types@15.7.5: + resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} + + /@types/qs@6.9.14: + resolution: {integrity: sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==} + + /@types/qs@6.9.9: + resolution: {integrity: sha512-wYLxw35euwqGvTDx6zfY1vokBFnsK0HNrzc6xNHchxfO2hpuRg74GbkEW7e3sSmPvj0TjCDT1VCa6OtHXnubsg==} + + /@types/range-parser@1.2.6: + resolution: {integrity: sha512-+0autS93xyXizIYiyL02FCY8N+KkKPhILhcUSA276HxzreZ16kl+cmwvV2qAM/PuCCwPXzOXOWhiPcw20uSFcA==} + + /@types/range-parser@1.2.7: + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + /@types/react-dom@18.2.6: + resolution: {integrity: sha512-2et4PDvg6PVCyS7fuTc4gPoksV58bW0RwSxWKcPRcHZf0PRUGq03TKcD/rUHe3azfV6/5/biUBJw+HhCQjaP0A==} + dependencies: + '@types/react': 18.2.14 + + /@types/react@18.2.14: + resolution: {integrity: sha512-A0zjq+QN/O0Kpe30hA1GidzyFjatVvrpIvWLxD+xv67Vt91TWWgco9IvrJBkeyHm1trGaFS/FSGqPlhyeZRm0g==} + dependencies: + '@types/prop-types': 15.7.5 + '@types/scheduler': 0.16.3 + csstype: 3.1.2 + + /@types/resolve@1.20.4: + resolution: {integrity: sha512-BKGK0T1VgB1zD+PwQR4RRf0ais3NyvH1qjLUrHI5SEiccYaJrhLstLuoXFWJ+2Op9whGizSPUMGPJY/Qtb/A2w==} + dev: true + + /@types/scheduler@0.16.3: + resolution: {integrity: sha512-5cJ8CB4yAx7BH1oMvdU0Jh9lrEXyPkar6F9G/ERswkCuvP4KQZfZkSjcMbAICCpQTN4OuZn8tz0HiKv9TGZgrQ==} + + /@types/secp256k1@4.0.6: + resolution: {integrity: sha512-hHxJU6PAEUn0TP4S/ZOzuTUvJWuZ6eIKeNKb5RBpODvSl6hp1Wrw4s7ATY50rklRCScUDpHzVA/DQdSjJ3UoYQ==} + dependencies: + '@types/node': 20.12.7 + dev: false + + /@types/semver@7.5.0: + resolution: {integrity: sha512-G8hZ6XJiHnuhQKR7ZmysCeJWE08o8T0AXtk5darsCaTVsYZhhgUrq53jizaR2FvsoeCwJhlmwTjkXBY5Pn/ZHw==} + dev: true + + /@types/send@0.17.3: + resolution: {integrity: sha512-/7fKxvKUoETxjFUsuFlPB9YndePpxxRAOfGC/yJdc9kTjTeP5kRCTzfnE8kPUKCeyiyIZu0YQ76s50hCedI1ug==} + dependencies: + '@types/mime': 1.3.4 + '@types/node': 20.10.0 + + /@types/send@0.17.4: + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.12.7 + + /@types/serve-static@1.15.4: + resolution: {integrity: sha512-aqqNfs1XTF0HDrFdlY//+SGUxmdSUbjeRXb5iaZc3x0/vMbYmdw9qvOgHWOyyLFxSSRnUuP5+724zBgfw8/WAw==} + dependencies: + '@types/http-errors': 2.0.3 + '@types/mime': 3.0.3 + '@types/node': 20.10.0 + + /@types/serve-static@1.15.7: + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 20.12.7 + '@types/send': 0.17.4 + + /@types/stack-utils@2.0.1: + resolution: {integrity: sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==} + dev: true + + /@types/stack-utils@2.0.3: + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + dev: false + + /@types/testing-library__jest-dom@5.14.8: + resolution: {integrity: sha512-NRfJE9Cgpmu4fx716q9SYmU4jxxhYRU1BQo239Txt/9N3EC745XZX1Yl7h/SBIDlo1ANVOCRB4YDXjaQdoKCHQ==} + dependencies: + '@types/jest': 29.5.3 + dev: true + + /@types/tough-cookie@4.0.2: + resolution: {integrity: sha512-Q5vtl1W5ue16D+nIaW8JWebSSraJVlK+EthKn7e7UcD4KWsaSJ8BqGPXNaPghgtcn/fhvrN17Tv8ksUsQpiplw==} + dev: true + + /@types/trusted-types@2.0.4: + resolution: {integrity: sha512-IDaobHimLQhjwsQ/NMwRVfa/yL7L/wriQPMhw1ZJall0KX6E1oxk29XMDeilW5qTIg5aoiqf5Udy8U/51aNoQQ==} + dev: false + + /@types/unist@2.0.7: + resolution: {integrity: sha512-cputDpIbFgLUaGQn6Vqg3/YsJwxUwHLO13v3i5ouxT4lat0khip9AEWxtERujXV9wxIB1EyF97BSJFt6vpdI8g==} + dev: true + + /@types/ws@8.5.5: + resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} + dependencies: + '@types/node': 20.12.7 + dev: false + + /@types/yargs-parser@21.0.0: + resolution: {integrity: sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA==} + + /@types/yargs-parser@21.0.3: + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + dev: false + + /@types/yargs@15.0.19: + resolution: {integrity: sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==} + dependencies: + '@types/yargs-parser': 21.0.3 + dev: false + + /@types/yargs@16.0.5: + resolution: {integrity: sha512-AxO/ADJOBFJScHbWhq2xAhlWP24rY4aCEG/NFaMvbT3X2MgRsLjhjQwsn0Zi5zn0LG9jUhCCZMeX9Dkuw6k+vQ==} + dependencies: + '@types/yargs-parser': 21.0.0 + dev: true + + /@types/yargs@17.0.24: + resolution: {integrity: sha512-6i0aC7jV6QzQB8ne1joVZ0eSFIstHsCrobmOtghM11yGlH0j43FKL2UhWdELkyps0zuf7qVTUVCCR+tgSlyLLw==} + dependencies: + '@types/yargs-parser': 21.0.0 + + /@typescript-eslint/eslint-plugin@5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.0.2): + resolution: {integrity: sha512-A5l/eUAug103qtkwccSCxn8ZRwT+7RXWkFECdA4Cvl1dOlDUgTpAOfSEElZn2uSUxhdDpnCdetrf0jvU4qrL+g==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/parser': ^5.0.0 + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@eslint-community/regexpp': 4.5.1 + '@typescript-eslint/parser': 5.61.0(eslint@8.44.0)(typescript@5.0.2) + '@typescript-eslint/scope-manager': 5.61.0 + '@typescript-eslint/type-utils': 5.61.0(eslint@8.44.0)(typescript@5.0.2) + '@typescript-eslint/utils': 5.61.0(eslint@8.44.0)(typescript@5.0.2) + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.44.0 + graphemer: 1.4.0 + ignore: 5.2.4 + natural-compare-lite: 1.4.0 + semver: 7.5.4 + tsutils: 3.21.0(typescript@5.0.2) + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@5.61.0(eslint@8.44.0)(typescript@5.0.2): + resolution: {integrity: sha512-yGr4Sgyh8uO6fSi9hw3jAFXNBHbCtKKFMdX2IkT3ZqpKmtAq3lHS4ixB/COFuAIJpwl9/AqF7j72ZDWYKmIfvg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 5.61.0 + '@typescript-eslint/types': 5.61.0 + '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.0.2) + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.44.0 + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@5.61.0: + resolution: {integrity: sha512-W8VoMjoSg7f7nqAROEmTt6LoBpn81AegP7uKhhW5KzYlehs8VV0ZW0fIDVbcZRcaP3aPSW+JZFua+ysQN+m/Nw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.61.0 + '@typescript-eslint/visitor-keys': 5.61.0 + dev: true + + /@typescript-eslint/type-utils@5.61.0(eslint@8.44.0)(typescript@5.0.2): + resolution: {integrity: sha512-kk8u//r+oVK2Aj3ph/26XdH0pbAkC2RiSjUYhKD+PExemG4XSjpGFeyZ/QM8lBOa7O8aGOU+/yEbMJgQv/DnCg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: '*' + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.0.2) + '@typescript-eslint/utils': 5.61.0(eslint@8.44.0)(typescript@5.0.2) + debug: 4.3.4(supports-color@8.1.1) + eslint: 8.44.0 + tsutils: 3.21.0(typescript@5.0.2) + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@5.61.0: + resolution: {integrity: sha512-ldyueo58KjngXpzloHUog/h9REmHl59G1b3a5Sng1GfBo14BkS3ZbMEb3693gnP1k//97lh7bKsp6/V/0v1veQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.61.0(typescript@5.0.2): + resolution: {integrity: sha512-Fud90PxONnnLZ36oR5ClJBLTLfU4pIWBmnvGwTbEa2cXIqj70AEDEmOmpkFComjBZ/037ueKrOdHuYmSFVD7Rw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.61.0 + '@typescript-eslint/visitor-keys': 5.61.0 + debug: 4.3.4(supports-color@8.1.1) + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.5.4 + tsutils: 3.21.0(typescript@5.0.2) + typescript: 5.0.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@5.61.0(eslint@8.44.0)(typescript@5.0.2): + resolution: {integrity: sha512-mV6O+6VgQmVE6+xzlA91xifndPW9ElFW8vbSF0xCT/czPXVhwDewKila1jOyRwa9AE19zKnrr7Cg5S3pJVrTWQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.44.0) + '@types/json-schema': 7.0.12 + '@types/semver': 7.5.0 + '@typescript-eslint/scope-manager': 5.61.0 + '@typescript-eslint/types': 5.61.0 + '@typescript-eslint/typescript-estree': 5.61.0(typescript@5.0.2) + eslint: 8.44.0 + eslint-scope: 5.1.1 + semver: 7.5.4 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@5.61.0: + resolution: {integrity: sha512-50XQ5VdbWrX06mQXhy93WywSFZZGsv3EOjq+lqp6WC2t+j3mb6A9xYVdrRxafvK88vg9k9u+CT4l6D8PEatjKg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.61.0 + eslint-visitor-keys: 3.4.1 + dev: true + + /@uniswap/lib@4.0.1-alpha: + resolution: {integrity: sha512-f6UIliwBbRsgVLxIaBANF6w09tYqc6Y/qXdsrbEmXHyFA7ILiKrIwRFXe1yOg8M3cksgVsO9N7yuL2DdCGQKBA==} + engines: {node: '>=10'} + dev: false + + /@uniswap/sdk-core@4.2.0: + resolution: {integrity: sha512-yXAMLHZRYYuh6KpN2nOlLTYBjGiopmI9WUB4Z0tyNkW4ZZub54cUt22eibpGbZAhRAMxclox9IPIs6wwrM3soQ==} + engines: {node: '>=10'} + dependencies: + '@ethersproject/address': 5.7.0 + big.js: 5.2.2 + decimal.js-light: 2.5.1 + jsbi: 3.2.5 + tiny-invariant: 1.3.1 + toformat: 2.0.0 + dev: false + + /@uniswap/swap-router-contracts@1.3.1(hardhat@2.22.2): + resolution: {integrity: sha512-mh/YNbwKb7Mut96VuEtL+Z5bRe0xVIbjjiryn+iMMrK2sFKhR4duk/86mEz0UO5gSx4pQIw9G5276P5heY/7Rg==} + engines: {node: '>=10'} + dependencies: + '@openzeppelin/contracts': 3.4.2-solc-0.7 + '@uniswap/v2-core': 1.0.1 + '@uniswap/v3-core': 1.0.1 + '@uniswap/v3-periphery': 1.4.4 + dotenv: 14.3.2 + hardhat-watcher: 2.5.0(hardhat@2.22.2) + transitivePeerDependencies: + - hardhat + dev: false + + /@uniswap/v2-core@1.0.1: + resolution: {integrity: sha512-MtybtkUPSyysqLY2U210NBDeCHX+ltHt3oADGdjqoThZaFRDKwM6k1Nb3F0A3hk5hwuQvytFWhrWHOEq6nVJ8Q==} + engines: {node: '>=10'} + dev: false + + /@uniswap/v3-core@1.0.0: + resolution: {integrity: sha512-kSC4djMGKMHj7sLMYVnn61k9nu+lHjMIxgg9CDQT+s2QYLoA56GbSK9Oxr+qJXzzygbkrmuY6cwgP6cW2JXPFA==} + engines: {node: '>=10'} + dev: false + + /@uniswap/v3-core@1.0.1: + resolution: {integrity: sha512-7pVk4hEm00j9tc71Y9+ssYpO6ytkeI0y7WE9P6UcmNzhxPePwyAxImuhVsTqWK9YFvzgtvzJHi64pBl4jUzKMQ==} + engines: {node: '>=10'} + dev: false + + /@uniswap/v3-periphery@1.4.4: + resolution: {integrity: sha512-S4+m+wh8HbWSO3DKk4LwUCPZJTpCugIsHrWR86m/OrUyvSqGDTXKFfc2sMuGXCZrD1ZqO3rhQsKgdWg3Hbb2Kw==} + engines: {node: '>=10'} + dependencies: + '@openzeppelin/contracts': 3.4.2-solc-0.7 + '@uniswap/lib': 4.0.1-alpha + '@uniswap/v2-core': 1.0.1 + '@uniswap/v3-core': 1.0.1 + base64-sol: 1.0.1 + dev: false + + /@uniswap/v3-sdk@3.11.0(hardhat@2.22.2): + resolution: {integrity: sha512-gz6Q6SlN34AXvxhyz181F90D4OuIkxLnzBAucEzB9Fv3Z+3orHZY/SpGaD02nP1VsNQVu/DQvOsdkPUDGn1Y9Q==} + engines: {node: '>=10'} + dependencies: + '@ethersproject/abi': 5.7.0 + '@ethersproject/solidity': 5.7.0 + '@uniswap/sdk-core': 4.2.0 + '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.2) + '@uniswap/v3-periphery': 1.4.4 + '@uniswap/v3-staker': 1.0.0 + tiny-invariant: 1.3.1 + tiny-warning: 1.0.3 + transitivePeerDependencies: + - hardhat + dev: false + + /@uniswap/v3-staker@1.0.0: + resolution: {integrity: sha512-JV0Qc46Px5alvg6YWd+UIaGH9lDuYG/Js7ngxPit1SPaIP30AlVer1UYB7BRYeUVVxE+byUyIeN5jeQ7LLDjIw==} + engines: {node: '>=10'} + deprecated: Please upgrade to 1.0.1 + dependencies: + '@openzeppelin/contracts': 3.4.1-solc-0.7-2 + '@uniswap/v3-core': 1.0.0 + '@uniswap/v3-periphery': 1.4.4 + dev: false + + /@vanilla-extract/css@1.14.0: + resolution: {integrity: sha512-rYfm7JciWZ8PFzBM/HDiE2GLnKI3xJ6/vdmVJ5BSgcCZ5CxRlM9Cjqclni9lGzF3eMOijnUhCd/KV8TOzyzbMA==} + dependencies: + '@emotion/hash': 0.9.1 + '@vanilla-extract/private': 1.0.3 + chalk: 4.1.2 + css-what: 6.1.0 + cssesc: 3.0.0 + csstype: 3.1.3 + deep-object-diff: 1.1.9 + deepmerge: 4.3.1 + media-query-parser: 2.0.2 + modern-ahocorasick: 1.0.1 + outdent: 0.8.0 + dev: false + + /@vanilla-extract/dynamic@2.1.0: + resolution: {integrity: sha512-8zl0IgBYRtgD1h+56Zu13wHTiMTJSVEa4F7RWX9vTB/5Xe2KtjoiqApy/szHPVFA56c+ex6A4GpCQjT1bKXbYw==} + dependencies: + '@vanilla-extract/private': 1.0.3 + dev: false + + /@vanilla-extract/private@1.0.3: + resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==} + dev: false + + /@vanilla-extract/sprinkles@1.6.1(@vanilla-extract/css@1.14.0): + resolution: {integrity: sha512-N/RGKwGAAidBupZ436RpuweRQHEFGU+mvAqBo8PRMAjJEmHoPDttV8RObaMLrJHWLqvX+XUMinHUnD0hFRQISw==} + peerDependencies: + '@vanilla-extract/css': ^1.0.0 + dependencies: + '@vanilla-extract/css': 1.14.0 + dev: false + + /@viem/anvil@0.0.6: + resolution: {integrity: sha512-OjKR/+FVwzuygXYFqP8MBal1SXG8bT2gbZwqqB0XuLw81LNBBvmE/Repm6+5kkBh4IUj0PhYdrqOsnayS14Gtg==} + dependencies: + execa: 7.2.0 + get-port: 6.1.2 + http-proxy: 1.18.1 + ws: 8.13.0 + transitivePeerDependencies: + - bufferutil + - debug + - utf-8-validate + dev: false + + /@visx/annotation@3.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-v0htpd/sT1kdU1N7frqmj078UByJXUwPQJT9LENv0ypssjGyRgvZERjkgSUuMKMjZquOBs/f6XOzxF4mLV57sA==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.14 + '@visx/drag': 3.3.0(react@18.2.0) + '@visx/group': 3.3.0(react@18.2.0) + '@visx/text': 3.3.0(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + react-use-measure: 2.1.1(react-dom@18.2.0)(react@18.2.0) + transitivePeerDependencies: + - react-dom + dev: false + + /@visx/axis@3.8.0(react@18.2.0): + resolution: {integrity: sha512-CFIxPnRlIWIz8N+5n4DTSOQQ2Yb0D35YPylEkmk/c7J4haLCEhyI44JaOg6OYOk6ofCOsu9Fqe6dFAOP+MP1IQ==} + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.14 + '@visx/group': 3.3.0(react@18.2.0) + '@visx/point': 3.3.0 + '@visx/scale': 3.5.0 + '@visx/shape': 3.5.0(react@18.2.0) + '@visx/text': 3.3.0(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/bounds@3.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-gESmN+4N2NkeUzqQEDZaS63umkGfMp9XjQcKBqtOR64mjjQtamh3lNVRWvKjJ2Zb421RbYHWq22Wv9nay6ZUOg==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + react-dom: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.14 + '@types/react-dom': 18.2.6 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@visx/curve@3.3.0: + resolution: {integrity: sha512-G1l1rzGWwIs8ka3mBhO/gj8uYK6XdU/3bwRSoiZ+MockMahQFPog0bUkuVgPwwzPSJfsA/E5u53Y/DNesnHQxg==} + dependencies: + '@types/d3-shape': 1.3.12 + d3-shape: 1.3.7 + dev: false + + /@visx/drag@3.3.0(react@18.2.0): + resolution: {integrity: sha512-fLNsorq6GyANCqAE/dToG0q7YoGVxihGC9FZQUp0MCV1wMJIJ45ximhrl5NDng2ytbpWnBmXu8M8hdsdFuvIXw==} + peerDependencies: + react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.14 + '@visx/event': 3.3.0 + '@visx/point': 3.3.0 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/event@3.3.0: + resolution: {integrity: sha512-fKalbNgNz2ooVOTXhvcOx5IlEQDgVfX66rI7bgZhBxI2/scy+5rWcXJXpwkheRF68SMx9R93SjKW6tmiD0h+jA==} + dependencies: + '@types/react': 18.2.14 + '@visx/point': 3.3.0 + dev: false + + /@visx/grid@3.5.0(react@18.2.0): + resolution: {integrity: sha512-i1pdobTE223ItMiER3q4ojIaZWja3vg46TkS6FotnBZ4c0VRDHSrALQPdi0na+YEgppASWCQ2WrI/vD6mIkhSg==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.14 + '@visx/curve': 3.3.0 + '@visx/group': 3.3.0(react@18.2.0) + '@visx/point': 3.3.0 + '@visx/scale': 3.5.0 + '@visx/shape': 3.5.0(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/group@3.3.0(react@18.2.0): + resolution: {integrity: sha512-yKepDKwJqlzvnvPS0yDuW13XNrYJE4xzT6xM7J++441nu6IybWWwextyap8ey+kU651cYDb+q1Oi6aHvQwyEyw==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.14 + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/point@3.3.0: + resolution: {integrity: sha512-03eBBIJarkmX79WbeEGTUZwmS5/MUuabbiM9KfkGS9pETBTWkp1DZtEHZdp5z34x5TDQVLSi0rk1Plg3/8RtDg==} + dev: false + + /@visx/scale@3.5.0: + resolution: {integrity: sha512-xo3zrXV2IZxrMq9Y9RUVJUpd93h3NO/r/y3GVi5F9AsbOzOhsLIbsPkunhO9mpUSR8LZ9TiumLEBrY+3frRBSg==} + dependencies: + '@visx/vendor': 3.5.0 + dev: false + + /@visx/shape@3.5.0(react@18.2.0): + resolution: {integrity: sha512-DP3t9jBQ7dSE3e6ptA1xO4QAIGxO55GrY/6P+S6YREuQGjZgq20TLYLAsiaoPEzFSS4tp0m12ZTPivWhU2VBTw==} + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/d3-path': 1.0.11 + '@types/d3-shape': 1.3.12 + '@types/lodash': 4.14.195 + '@types/react': 18.2.14 + '@visx/curve': 3.3.0 + '@visx/group': 3.3.0(react@18.2.0) + '@visx/scale': 3.5.0 + classnames: 2.5.1 + d3-path: 1.0.9 + d3-shape: 1.3.7 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + + /@visx/text@3.3.0(react@18.2.0): + resolution: {integrity: sha512-fOimcsf0GtQE9whM5MdA/xIkHMaV29z7qNqNXysUDE8znSMKsN+ott7kSg2ljAEE89CQo3WKHkPNettoVsa84w==} + peerDependencies: + react: ^16.3.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/lodash': 4.14.195 + '@types/react': 18.2.14 + classnames: 2.5.1 + lodash: 4.17.21 + prop-types: 15.8.1 + react: 18.2.0 + reduce-css-calc: 1.3.0 + dev: false + + /@visx/tooltip@3.3.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-0ovbxnvAphEU/RVJprWHdOJT7p3YfBDpwXclXRuhIY2EkH59g8sDHatDcYwiNPeqk61jBh1KACRZxqToMuutlg==} + peerDependencies: + react: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + react-dom: ^16.8.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.14 + '@visx/bounds': 3.3.0(react-dom@18.2.0)(react@18.2.0) + classnames: 2.5.1 + prop-types: 15.8.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-use-measure: 2.1.1(react-dom@18.2.0)(react@18.2.0) + dev: false + + /@visx/vendor@3.5.0: + resolution: {integrity: sha512-yt3SEZRVmt36+APsCISSO9eSOtzQkBjt+QRxNRzcTWuzwMAaF3PHCCSe31++kkpgY9yFoF+Gfes1TBe5NlETiQ==} + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-scale': 4.0.2 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-scale: 4.0.2 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + dev: false + + /@vitejs/plugin-react-swc@3.3.2(vite@4.5.0): + resolution: {integrity: sha512-VJFWY5sfoZerQRvJrh518h3AcQt6f/yTuWn4/TRB+dqmYU0NX1qz7qM5Wfd+gOQqUzQW4gxKqKN3KpE/P3+zrA==} + peerDependencies: + vite: ^4 + dependencies: + '@swc/core': 1.3.69 + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + transitivePeerDependencies: + - '@swc/helpers' + dev: true + + /@vitejs/plugin-react@3.1.0(vite@4.5.0): + resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.1.0-beta.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.22.9) + '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.22.9) + magic-string: 0.27.0 + react-refresh: 0.14.0 + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + transitivePeerDependencies: + - supports-color + dev: true + + /@vitest/coverage-v8@0.34.6(vitest@0.33.0): + resolution: {integrity: sha512-fivy/OK2d/EsJFoEoxHFEnNGTg+MmdZBAVK9Ka4qhXR2K3J0DS08vcGVwzDtXSuUMabLv4KtPcpSKkcMXFDViw==} + peerDependencies: + vitest: '>=0.32.0 <1' + dependencies: + '@ampproject/remapping': 2.2.1 + '@bcoe/v8-coverage': 0.2.3 + istanbul-lib-coverage: 3.2.0 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 4.0.1 + istanbul-reports: 3.1.5 + magic-string: 0.30.1 + picocolors: 1.0.0 + std-env: 3.3.3 + test-exclude: 6.0.0 + v8-to-istanbul: 9.1.0 + vitest: 0.33.0(jsdom@22.1.0)(less@4.2.0) + transitivePeerDependencies: + - supports-color + dev: true + + /@vitest/expect@0.33.0: + resolution: {integrity: sha512-sVNf+Gla3mhTCxNJx+wJLDPp/WcstOe0Ksqz4Vec51MmgMth/ia0MGFEkIZmVGeTL5HtjYR4Wl/ZxBxBXZJTzQ==} + dependencies: + '@vitest/spy': 0.33.0 + '@vitest/utils': 0.33.0 + chai: 4.3.10 + dev: true + + /@vitest/runner@0.33.0: + resolution: {integrity: sha512-UPfACnmCB6HKRHTlcgCoBh6ppl6fDn+J/xR8dTufWiKt/74Y9bHci5CKB8tESSV82zKYtkBJo9whU3mNvfaisg==} + dependencies: + '@vitest/utils': 0.33.0 + p-limit: 4.0.0 + pathe: 1.1.1 + dev: true + + /@vitest/snapshot@0.33.0: + resolution: {integrity: sha512-tJjrl//qAHbyHajpFvr8Wsk8DIOODEebTu7pgBrP07iOepR5jYkLFiqLq2Ltxv+r0uptUb4izv1J8XBOwKkVYA==} + dependencies: + magic-string: 0.30.5 + pathe: 1.1.1 + pretty-format: 29.7.0 + dev: true + + /@vitest/spy@0.33.0: + resolution: {integrity: sha512-Kv+yZ4hnH1WdiAkPUQTpRxW8kGtH8VRTnus7ZTGovFYM1ZezJpvGtb9nPIjPnptHbsyIAxYZsEpVPYgtpjGnrg==} + dependencies: + tinyspy: 2.2.0 + dev: true + + /@vitest/utils@0.33.0: + resolution: {integrity: sha512-pF1w22ic965sv+EN6uoePkAOTkAPWM03Ri/jXNyMIKBb/XHLDPfhLvf/Fa9g0YECevAIz56oVYXhodLvLQ/awA==} + dependencies: + diff-sequences: 29.6.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + dev: true + + /@wagmi/cli@2.1.1(typescript@5.4.5): + resolution: {integrity: sha512-AzE/CrltZAMUP/Qknb27n50Stc34TBSbGOszhcPEVQVxZUG5pVHw3vOpcJdBtHo7Y2FW7uvo4DGZCMKhs7IXAw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + abitype: 0.9.10(typescript@5.4.5)(zod@3.22.4) + bundle-require: 4.0.2(esbuild@0.19.12) + cac: 6.7.14 + change-case: 4.1.2 + chokidar: 3.5.3 + dedent: 0.7.0 + dotenv: 16.3.1 + dotenv-expand: 10.0.0 + esbuild: 0.19.12 + execa: 8.0.1 + find-up: 6.3.0 + fs-extra: 11.1.1 + globby: 13.2.2 + ora: 6.3.1 + pathe: 1.1.1 + picocolors: 1.0.0 + prettier: 3.2.5 + typescript: 5.4.5 + viem: 2.9.21(typescript@5.4.5)(zod@3.22.4) + zod: 3.22.4 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: true + + /@wagmi/connectors@4.1.26(@types/react@18.2.14)(@wagmi/core@2.6.17)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4): + resolution: {integrity: sha512-0bANLzi4gZcszPnCj3l7+DPztCG+L+W1Zm/a02YmEh2MaQC/blBsbAdb2JALdW66HJJE8m4cNZjPJPTsS2/MQQ==} + peerDependencies: + '@wagmi/core': 2.6.17 + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@coinbase/wallet-sdk': 3.9.1 + '@metamask/sdk': 0.14.3(@types/react@18.2.14)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0) + '@safe-global/safe-apps-provider': 0.18.1(typescript@5.4.5)(zod@3.22.4) + '@safe-global/safe-apps-sdk': 8.1.0(typescript@5.4.5)(zod@3.22.4) + '@wagmi/core': 2.6.17(@types/react@18.2.14)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4) + '@walletconnect/ethereum-provider': 2.11.2(@types/react@18.2.14)(react@18.2.0) + '@walletconnect/modal': 2.6.2(@types/react@18.2.14)(react@18.2.0) + typescript: 5.4.5 + viem: 2.9.21(typescript@5.4.5)(zod@3.22.4) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - react + - react-dom + - react-native + - rollup + - supports-color + - utf-8-validate + - zod + dev: false + + /@wagmi/core@2.6.17(@types/react@18.2.14)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4): + resolution: {integrity: sha512-Ghr7PlD5HO1YJrsaC52j/csgaigBAiTR7cFiwrY7WdwvWLsR5na4Dv6KfHTU3d3al0CKDLanQdRS5nB4mX1M+g==} + peerDependencies: + '@tanstack/query-core': '>=5.0.0' + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + '@tanstack/query-core': + optional: true + typescript: + optional: true + dependencies: + eventemitter3: 5.0.1 + mipd: 0.0.5(typescript@5.4.5)(zod@3.22.4) + typescript: 5.4.5 + viem: 2.9.21(typescript@5.4.5)(zod@3.22.4) + zustand: 4.4.1(@types/react@18.2.14)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - bufferutil + - immer + - react + - utf-8-validate + - zod + dev: false + + /@walletconnect/core@2.11.2: + resolution: {integrity: sha512-bB4SiXX8hX3/hyBfVPC5gwZCXCl+OPj+/EDVM71iAO3TDsh78KPbrVAbDnnsbHzZVHlsMohtXX3j5XVsheN3+g==} + dependencies: + '@walletconnect/heartbeat': 1.2.1 + '@walletconnect/jsonrpc-provider': 1.0.13 + '@walletconnect/jsonrpc-types': 1.0.3 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/jsonrpc-ws-connection': 1.0.14 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.0.1 + '@walletconnect/relay-api': 1.0.9 + '@walletconnect/relay-auth': 1.0.4 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.11.2 + '@walletconnect/utils': 2.11.2 + events: 3.3.0 + isomorphic-unfetch: 3.1.0 + lodash.isequal: 4.5.0 + uint8arrays: 3.1.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /@walletconnect/environment@1.0.1: + resolution: {integrity: sha512-T426LLZtHj8e8rYnKfzsw1aG6+M0BT1ZxayMdv/p8yM0MU+eJDISqNY3/bccxRr4LrF9csq02Rhqt08Ibl0VRg==} + dependencies: + tslib: 1.14.1 + dev: false + + /@walletconnect/ethereum-provider@2.11.2(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-BUDqee0Uy2rCZVkW5Ao3q6Ado/3fePYnFdryVF+YL6bPhj+xQZ5OfKodl+uvs7Rwq++O5wTX2RqOTzpW7+v+Mg==} + dependencies: + '@walletconnect/jsonrpc-http-connection': 1.0.7 + '@walletconnect/jsonrpc-provider': 1.0.13 + '@walletconnect/jsonrpc-types': 1.0.3 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/modal': 2.6.2(@types/react@18.2.14)(react@18.2.0) + '@walletconnect/sign-client': 2.11.2 + '@walletconnect/types': 2.11.2 + '@walletconnect/universal-provider': 2.11.2 + '@walletconnect/utils': 2.11.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@types/react' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - react + - supports-color + - utf-8-validate + dev: false + + /@walletconnect/events@1.0.1: + resolution: {integrity: sha512-NPTqaoi0oPBVNuLv7qPaJazmGHs5JGyO8eEAk5VGKmJzDR7AHzD4k6ilox5kxk1iwiOnFopBOOMLs86Oa76HpQ==} + dependencies: + keyvaluestorage-interface: 1.0.0 + tslib: 1.14.1 + dev: false + + /@walletconnect/heartbeat@1.2.1: + resolution: {integrity: sha512-yVzws616xsDLJxuG/28FqtZ5rzrTA4gUjdEMTbWB5Y8V1XHRmqq4efAxCw5ie7WjbXFSUyBHaWlMR+2/CpQC5Q==} + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/time': 1.0.2 + tslib: 1.14.1 + dev: false + + /@walletconnect/jsonrpc-http-connection@1.0.7: + resolution: {integrity: sha512-qlfh8fCfu8LOM9JRR9KE0s0wxP6ZG9/Jom8M0qsoIQeKF3Ni0FyV4V1qy/cc7nfI46SLQLSl4tgWSfLiE1swyQ==} + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + cross-fetch: 3.1.8 + tslib: 1.14.1 + transitivePeerDependencies: + - encoding + dev: false + + /@walletconnect/jsonrpc-provider@1.0.13: + resolution: {integrity: sha512-K73EpThqHnSR26gOyNEL+acEex3P7VWZe6KE12ZwKzAt2H4e5gldZHbjsu2QR9cLeJ8AXuO7kEMOIcRv1QEc7g==} + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + tslib: 1.14.1 + dev: false + + /@walletconnect/jsonrpc-types@1.0.3: + resolution: {integrity: sha512-iIQ8hboBl3o5ufmJ8cuduGad0CQm3ZlsHtujv9Eu16xq89q+BG7Nh5VLxxUgmtpnrePgFkTwXirCTkwJH1v+Yw==} + dependencies: + keyvaluestorage-interface: 1.0.0 + tslib: 1.14.1 + dev: false + + /@walletconnect/jsonrpc-utils@1.0.8: + resolution: {integrity: sha512-vdeb03bD8VzJUL6ZtzRYsFMq1eZQcM3EAzT0a3st59dyLfJ0wq+tKMpmGH7HlB7waD858UWgfIcudbPFsbzVdw==} + dependencies: + '@walletconnect/environment': 1.0.1 + '@walletconnect/jsonrpc-types': 1.0.3 + tslib: 1.14.1 + dev: false + + /@walletconnect/jsonrpc-ws-connection@1.0.14: + resolution: {integrity: sha512-Jsl6fC55AYcbkNVkwNM6Jo+ufsuCQRqViOQ8ZBPH9pRREHH9welbBiszuTLqEJiQcO/6XfFDl6bzCJIkrEi8XA==} + dependencies: + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/safe-json': 1.0.2 + events: 3.3.0 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /@walletconnect/keyvaluestorage@1.1.1: + resolution: {integrity: sha512-V7ZQq2+mSxAq7MrRqDxanTzu2RcElfK1PfNYiaVnJgJ7Q7G7hTVwF8voIBx92qsRyGHZihrwNPHuZd1aKkd0rA==} + peerDependencies: + '@react-native-async-storage/async-storage': 1.x + peerDependenciesMeta: + '@react-native-async-storage/async-storage': + optional: true + dependencies: + '@walletconnect/safe-json': 1.0.2 + idb-keyval: 6.2.1 + unstorage: 1.10.1(idb-keyval@6.2.1) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@upstash/redis' + - '@vercel/kv' + - supports-color + dev: false + + /@walletconnect/logger@2.0.1: + resolution: {integrity: sha512-SsTKdsgWm+oDTBeNE/zHxxr5eJfZmE9/5yp/Ku+zJtcTAjELb3DXueWkDXmE9h8uHIbJzIb5wj5lPdzyrjT6hQ==} + dependencies: + pino: 7.11.0 + tslib: 1.14.1 + dev: false + + /@walletconnect/modal-core@2.6.2(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-cv8ibvdOJQv2B+nyxP9IIFdxvQznMz8OOr/oR/AaUZym4hjXNL/l1a2UlSQBXrVjo3xxbouMxLb3kBsHoYP2CA==} + dependencies: + valtio: 1.11.2(@types/react@18.2.14)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - react + dev: false + + /@walletconnect/modal-ui@2.6.2(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-rbdstM1HPGvr7jprQkyPggX7rP4XiCG85ZA+zWBEX0dVQg8PpAgRUqpeub4xQKDgY7pY/xLRXSiCVdWGqvG2HA==} + dependencies: + '@walletconnect/modal-core': 2.6.2(@types/react@18.2.14)(react@18.2.0) + lit: 2.8.0 + motion: 10.16.2 + qrcode: 1.5.3 + transitivePeerDependencies: + - '@types/react' + - react + dev: false + + /@walletconnect/modal@2.6.2(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-eFopgKi8AjKf/0U4SemvcYw9zlLpx9njVN8sf6DAkowC2Md0gPU/UNEbH1Wwj407pEKnEds98pKWib1NN1ACoA==} + dependencies: + '@walletconnect/modal-core': 2.6.2(@types/react@18.2.14)(react@18.2.0) + '@walletconnect/modal-ui': 2.6.2(@types/react@18.2.14)(react@18.2.0) + transitivePeerDependencies: + - '@types/react' + - react + dev: false + + /@walletconnect/relay-api@1.0.9: + resolution: {integrity: sha512-Q3+rylJOqRkO1D9Su0DPE3mmznbAalYapJ9qmzDgK28mYF9alcP3UwG/og5V7l7CFOqzCLi7B8BvcBUrpDj0Rg==} + dependencies: + '@walletconnect/jsonrpc-types': 1.0.3 + tslib: 1.14.1 + dev: false + + /@walletconnect/relay-auth@1.0.4: + resolution: {integrity: sha512-kKJcS6+WxYq5kshpPaxGHdwf5y98ZwbfuS4EE/NkQzqrDFm5Cj+dP8LofzWvjrrLkZq7Afy7WrQMXdLy8Sx7HQ==} + dependencies: + '@stablelib/ed25519': 1.0.3 + '@stablelib/random': 1.0.2 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + tslib: 1.14.1 + uint8arrays: 3.1.1 + dev: false + + /@walletconnect/safe-json@1.0.2: + resolution: {integrity: sha512-Ogb7I27kZ3LPC3ibn8ldyUr5544t3/STow9+lzz7Sfo808YD7SBWk7SAsdBFlYgP2zDRy2hS3sKRcuSRM0OTmA==} + dependencies: + tslib: 1.14.1 + dev: false + + /@walletconnect/sign-client@2.11.2: + resolution: {integrity: sha512-MfBcuSz2GmMH+P7MrCP46mVE5qhP0ZyWA0FyIH6/WuxQ6G+MgKsGfaITqakpRPsykWOJq8tXMs3XvUPDU413OQ==} + dependencies: + '@walletconnect/core': 2.11.2 + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.1 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 2.0.1 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.11.2 + '@walletconnect/utils': 2.11.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /@walletconnect/time@1.0.2: + resolution: {integrity: sha512-uzdd9woDcJ1AaBZRhqy5rNC9laqWGErfc4dxA9a87mPdKOgWMD85mcFo9dIYIts/Jwocfwn07EC6EzclKubk/g==} + dependencies: + tslib: 1.14.1 + dev: false + + /@walletconnect/types@2.11.2: + resolution: {integrity: sha512-p632MFB+lJbip2cvtXPBQslpUdiw1sDtQ5y855bOlAGquay+6fZ4h1DcDePeKQDQM3P77ax2a9aNPZxV6y/h1Q==} + dependencies: + '@walletconnect/events': 1.0.1 + '@walletconnect/heartbeat': 1.2.1 + '@walletconnect/jsonrpc-types': 1.0.3 + '@walletconnect/keyvaluestorage': 1.1.1 + '@walletconnect/logger': 2.0.1 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/kv' + - supports-color + dev: false + + /@walletconnect/universal-provider@2.11.2: + resolution: {integrity: sha512-cNtIn5AVoDxKAJ4PmB8m5adnf5mYQMUamEUPKMVvOPscfGtIMQEh9peKsh2AN5xcRVDbgluC01Id545evFyymw==} + dependencies: + '@walletconnect/jsonrpc-http-connection': 1.0.7 + '@walletconnect/jsonrpc-provider': 1.0.13 + '@walletconnect/jsonrpc-types': 1.0.3 + '@walletconnect/jsonrpc-utils': 1.0.8 + '@walletconnect/logger': 2.0.1 + '@walletconnect/sign-client': 2.11.2 + '@walletconnect/types': 2.11.2 + '@walletconnect/utils': 2.11.2 + events: 3.3.0 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /@walletconnect/utils@2.11.2: + resolution: {integrity: sha512-LyfdmrnZY6dWqlF4eDrx5jpUwsB2bEPjoqR5Z6rXPiHJKUOdJt7az+mNOn5KTSOlRpd1DmozrBrWr+G9fFLYVw==} + dependencies: + '@stablelib/chacha20poly1305': 1.0.1 + '@stablelib/hkdf': 1.0.1 + '@stablelib/random': 1.0.2 + '@stablelib/sha256': 1.0.1 + '@stablelib/x25519': 1.0.3 + '@walletconnect/relay-api': 1.0.9 + '@walletconnect/safe-json': 1.0.2 + '@walletconnect/time': 1.0.2 + '@walletconnect/types': 2.11.2 + '@walletconnect/window-getters': 1.0.1 + '@walletconnect/window-metadata': 1.0.1 + detect-browser: 5.3.0 + query-string: 7.1.3 + uint8arrays: 3.1.1 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@upstash/redis' + - '@vercel/kv' + - supports-color + dev: false + + /@walletconnect/window-getters@1.0.1: + resolution: {integrity: sha512-vHp+HqzGxORPAN8gY03qnbTMnhqIwjeRJNOMOAzePRg4xVEEE2WvYsI9G2NMjOknA8hnuYbU3/hwLcKbjhc8+Q==} + dependencies: + tslib: 1.14.1 + dev: false + + /@walletconnect/window-metadata@1.0.1: + resolution: {integrity: sha512-9koTqyGrM2cqFRW517BPY/iEtUDx2r1+Pwwu5m7sJ7ka79wi3EyqhqcICk/yDmv6jAS1rjKgTKXlEhanYjijcA==} + dependencies: + '@walletconnect/window-getters': 1.0.1 + tslib: 1.14.1 + dev: false + + /@webassemblyjs/ast@1.12.1: + resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} + dependencies: + '@webassemblyjs/helper-numbers': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + dev: true + + /@webassemblyjs/floating-point-hex-parser@1.11.6: + resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} + dev: true + + /@webassemblyjs/helper-api-error@1.11.6: + resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} + dev: true + + /@webassemblyjs/helper-buffer@1.12.1: + resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} + dev: true + + /@webassemblyjs/helper-numbers@1.11.6: + resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.11.6 + '@webassemblyjs/helper-api-error': 1.11.6 + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/helper-wasm-bytecode@1.11.6: + resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} + dev: true + + /@webassemblyjs/helper-wasm-section@1.12.1: + resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/wasm-gen': 1.12.1 + dev: true + + /@webassemblyjs/ieee754@1.11.6: + resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} + dependencies: + '@xtuc/ieee754': 1.2.0 + dev: true + + /@webassemblyjs/leb128@1.11.6: + resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} + dependencies: + '@xtuc/long': 4.2.2 + dev: true + + /@webassemblyjs/utf8@1.11.6: + resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} + dev: true + + /@webassemblyjs/wasm-edit@1.12.1: + resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/helper-wasm-section': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-opt': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + '@webassemblyjs/wast-printer': 1.12.1 + dev: true + + /@webassemblyjs/wasm-gen@1.12.1: + resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + dev: true + + /@webassemblyjs/wasm-opt@1.12.1: + resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-buffer': 1.12.1 + '@webassemblyjs/wasm-gen': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + dev: true + + /@webassemblyjs/wasm-parser@1.12.1: + resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/helper-api-error': 1.11.6 + '@webassemblyjs/helper-wasm-bytecode': 1.11.6 + '@webassemblyjs/ieee754': 1.11.6 + '@webassemblyjs/leb128': 1.11.6 + '@webassemblyjs/utf8': 1.11.6 + dev: true + + /@webassemblyjs/wast-printer@1.12.1: + resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} + dependencies: + '@webassemblyjs/ast': 1.12.1 + '@xtuc/long': 4.2.2 + dev: true + + /@xtuc/ieee754@1.2.0: + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + dev: true + + /@xtuc/long@4.2.2: + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + dev: true + + /@yarnpkg/esbuild-plugin-pnp@3.0.0-rc.15(esbuild@0.18.20): + resolution: {integrity: sha512-kYzDJO5CA9sy+on/s2aIW0411AklfCi8Ck/4QDivOqsMKpStZA2SsR+X27VTggGwpStWaLrjJcDcdDMowtG8MA==} + engines: {node: '>=14.15.0'} + peerDependencies: + esbuild: '>=0.10.0' + dependencies: + esbuild: 0.18.20 + tslib: 2.6.2 + dev: true + + /@yarnpkg/fslib@2.10.3: + resolution: {integrity: sha512-41H+Ga78xT9sHvWLlFOZLIhtU6mTGZ20pZ29EiZa97vnxdohJD2AF42rCoAoWfqUz486xY6fhjMH+DYEM9r14A==} + engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} + dependencies: + '@yarnpkg/libzip': 2.3.0 + tslib: 1.14.1 + dev: true + + /@yarnpkg/libzip@2.3.0: + resolution: {integrity: sha512-6xm38yGVIa6mKm/DUCF2zFFJhERh/QWp1ufm4cNUvxsONBmfPg8uZ9pZBdOmF6qFGr/HlT6ABBkCSx/dlEtvWg==} + engines: {node: '>=12 <14 || 14.2 - 14.9 || >14.10.0'} + dependencies: + '@types/emscripten': 1.39.9 + tslib: 1.14.1 + dev: true + + /@zeit/schemas@2.29.0: + resolution: {integrity: sha512-g5QiLIfbg3pLuYUJPlisNKY+epQJTcMDsOnVNkscrDP1oi7vmJnzOANYJI/1pZcVJ6umUkBv3aFtlg1UvUHGzA==} + dev: true + + /abab@2.0.6: + resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==} + dev: true + + /abitype@0.9.10(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-FIS7U4n7qwAT58KibwYig5iFG4K61rbhAqaQh/UWj8v1Y8mjX3F8TC9gd8cz9yT1TYel9f8nS5NO5kZp2RW0jQ==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + dependencies: + typescript: 5.4.5 + zod: 3.22.4 + dev: true + + /abitype@0.9.3(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-dz4qCQLurx97FQhnb/EIYTk/ldQ+oafEDUqC0VVIeQS1Q48/YWt/9YNfMmp9SLFqN41ktxny3c8aYxHjmFIB/w==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.19.1 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + dependencies: + typescript: 5.4.5 + zod: 3.22.4 + dev: false + + /abitype@1.0.0(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-NMeMah//6bJ56H5XRj8QCV4AwuW6hB6zqz2LnhhLdcWVQOsXki6/Pn3APeqxCma62nXIcmZWdu1DlHWS74umVQ==} + peerDependencies: + typescript: '>=5.0.4' + zod: ^3 >=3.22.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + dependencies: + typescript: 5.4.5 + zod: 3.22.4 + + /abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + dependencies: + event-target-shim: 5.0.1 + dev: false + + /accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + + /acorn-import-assertions@1.9.0(acorn@8.11.3): + resolution: {integrity: sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==} + peerDependencies: + acorn: ^8 + dependencies: + acorn: 8.11.3 + dev: true + + /acorn-jsx@5.3.2(acorn@7.4.1): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 7.4.1 + dev: true + + /acorn-jsx@5.3.2(acorn@8.11.2): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.11.2 + dev: true + + /acorn-walk@7.2.0: + resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn-walk@8.3.0: + resolution: {integrity: sha512-FS7hV565M5l1R08MXqo8odwMTB02C2UqzB17RVgu9EyuYFBqJZ3/ZY97sQD5FewVu1UyDFc1yztUDrAwT0EypA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /acorn@8.11.2: + resolution: {integrity: sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==} + engines: {node: '>=0.4.0'} + hasBin: true + + /acorn@8.11.3: + resolution: {integrity: sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==} + engines: {node: '>=0.4.0'} + hasBin: true + + /address@1.2.2: + resolution: {integrity: sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==} + engines: {node: '>= 10.0.0'} + dev: true + + /adjust-sourcemap-loader@4.0.0: + resolution: {integrity: sha512-OXwN5b9pCUXNQHJpwwD2qP40byEmSgzj8B4ydSN0uMNYWiFmJ6x6KwUllMmfk8Rwu/HJDFR7U8ubsWBoN0Xp0A==} + engines: {node: '>=8.9'} + dependencies: + loader-utils: 2.0.4 + regex-parser: 2.2.11 + dev: true + + /adm-zip@0.4.16: + resolution: {integrity: sha512-TFi4HBKSGfIKsK5YCkKaaFG2m4PEDyViZmEwof3MTIgzimHLto6muaHVpbrljdIvIrFZzEq/p4nafOeLcYegrg==} + engines: {node: '>=0.3.0'} + dev: false + + /aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + dev: false + + /agent-base@5.1.1: + resolution: {integrity: sha512-TMeqbNl2fMW0nMjTEPOwe3J/PRFP4vqeoNuQMG0HlMrtm5QxKqdvAkZ1pRBQ/ulIyDD5Yq0nJ7YbdD8ey0TO3g==} + engines: {node: '>= 6.0.0'} + dev: true + + /agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + dependencies: + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + /aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + + /ajv-keywords@3.5.2(ajv@6.12.6): + resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + peerDependencies: + ajv: ^6.9.1 + dependencies: + ajv: 6.12.6 + dev: true + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ajv@8.11.0: + resolution: {integrity: sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: true + + /anser@1.4.10: + resolution: {integrity: sha512-hCv9AqTQ8ycjpSd3upOJd7vFwW1JaoYQ7tpham03GJ1ca8/65rqn0RpaWpItOAd6ylW9wAw6luXYPJIyPFVOww==} + dev: false + + /ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + dependencies: + string-width: 4.2.3 + + /ansi-colors@4.1.1: + resolution: {integrity: sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==} + engines: {node: '>=6'} + dev: false + + /ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: false + + /ansi-escapes@4.3.2: + resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.21.3 + + /ansi-fragments@0.2.1: + resolution: {integrity: sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==} + dependencies: + colorette: 1.4.0 + slice-ansi: 2.1.0 + strip-ansi: 5.2.0 + dev: false + + /ansi-regex@4.1.1: + resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} + engines: {node: '>=6'} + dev: false + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + /ansi-regex@6.0.1: + resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==} + engines: {node: '>=12'} + + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + /ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + + /any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + /anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.1 + + /app-root-dir@1.0.2: + resolution: {integrity: sha512-jlpIfsOoNoafl92Sz//64uQHGSyMrD2vYG5d8o2a4qGvyNCvXur7bzIsWtAC/6flI2RYAp3kv8rsfBtaLm7w0g==} + + /appdirsjs@1.2.7: + resolution: {integrity: sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==} + dev: false + + /aproba@2.0.0: + resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} + dev: true + + /arch@2.2.0: + resolution: {integrity: sha512-Of/R0wqp83cgHozfIYLbBMnej79U/SVGOOyuB3VVFv1NRM/PSFMK12x9KVtiYzJqmnU5WR2qp0Z5rHb7sWGnFQ==} + + /are-we-there-yet@2.0.0: + resolution: {integrity: sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==} + engines: {node: '>=10'} + dependencies: + delegates: 1.0.0 + readable-stream: 3.6.2 + dev: true + + /arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + /aria-hidden@1.2.3: + resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==} + engines: {node: '>=10'} + dependencies: + tslib: 2.6.0 + + /aria-query@5.1.3: + resolution: {integrity: sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==} + dependencies: + deep-equal: 2.2.2 + dev: true + + /aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + dev: true + + /array-buffer-byte-length@1.0.0: + resolution: {integrity: sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==} + dependencies: + call-bind: 1.0.2 + is-array-buffer: 3.0.2 + dev: true + + /array-flatten@1.1.1: + resolution: {integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=} + dev: true + + /array-includes@3.1.6: + resolution: {integrity: sha512-sgTbLvL6cNnw24FnbaDyjmvddQ2ML8arZsgaJhoABMoplz/4QRhtrYS+alr1BUM1Bwp6dhx8vVCBSLG+StwOFw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + get-intrinsic: 1.2.1 + is-string: 1.0.7 + dev: true + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /array.prototype.flat@1.3.1: + resolution: {integrity: sha512-roTU0KWIOmJ4DRLmwKd19Otg0/mT3qPNt0Qb3GWW8iObuZXxrjB/pzn0R3hqpRSWg4HCwqx+0vwOnWnvlOyeIA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + es-shim-unscopables: 1.0.0 + dev: true + + /array.prototype.flatmap@1.3.1: + resolution: {integrity: sha512-8UGn9O1FDVvMNB0UlLv4voxRMze7+FpHyF5mSMRjWHUMlpoDViniy05870VlxhfgTnLbpuwTzvD76MTtWxB/mQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + es-shim-unscopables: 1.0.0 + dev: true + + /array.prototype.tosorted@1.1.2: + resolution: {integrity: sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + es-shim-unscopables: 1.0.0 + get-intrinsic: 1.2.1 + dev: true + + /arraybuffer.prototype.slice@1.0.1: + resolution: {integrity: sha512-09x0ZWFEjj4WD8PDbykUwo3t9arLn8NIzmmYEJFpYekOAQjpkGSyrQhNoRTcwwcFRu+ycWF78QZ63oWTqSjBcw==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.0 + call-bind: 1.0.2 + define-properties: 1.2.0 + get-intrinsic: 1.2.1 + is-array-buffer: 3.0.2 + is-shared-array-buffer: 1.0.2 + dev: true + + /asap@2.0.6: + resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + dev: false + + /assert@2.0.0: + resolution: {integrity: sha512-se5Cd+js9dXJnu6Ag2JFc00t+HmHOen+8Q+L7O9zI0PqQXr20uk2J0XQqMxZEeo5U50o8Nvmmx7dZrl+Ufr35A==} + dependencies: + es6-object-assign: 1.1.0 + is-nan: 1.3.2 + object-is: 1.1.5 + util: 0.12.5 + dev: true + + /assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + dev: true + + /ast-types@0.15.2: + resolution: {integrity: sha512-c27loCv9QkZinsa5ProX751khO9DJl/AcB5c2KNtA6NRvHKS0PgLfcftz72KVq504vB0Gku5s2kUZzDBvQWvHg==} + engines: {node: '>=4'} + dependencies: + tslib: 2.6.2 + + /ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + dependencies: + tslib: 2.6.2 + dev: true + + /astral-regex@1.0.0: + resolution: {integrity: sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==} + engines: {node: '>=4'} + dev: false + + /async-limiter@1.0.1: + resolution: {integrity: sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==} + + /async-mutex@0.2.6: + resolution: {integrity: sha512-Hs4R+4SPgamu6rSGW8C7cV9gaWUKEHykfzCCvIRuaVv636Ju10ZdeUbvb4TBEW0INuq2DHZqXbK4Nd3yG4RaRw==} + dependencies: + tslib: 2.6.2 + dev: false + + /async@2.6.4: + resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + dependencies: + lodash: 4.17.21 + dev: true + + /async@3.2.4: + resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} + dev: true + + /asynciterator.prototype@1.0.0: + resolution: {integrity: sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==} + dependencies: + has-symbols: 1.0.3 + dev: true + + /asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + /atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + dev: false + + /autoprefixer@10.4.14(postcss@8.4.26): + resolution: {integrity: sha512-FQzyfOsTlwVzjHxKEqRIAdJx9niO6VCBCoEwax/VLSoQF29ggECcPuBqUMZ+u8jCZOPSy8b8/8KnuFbp0SaFZQ==} + engines: {node: ^10 || ^12 || >=14} + hasBin: true + peerDependencies: + postcss: ^8.1.0 + dependencies: + browserslist: 4.21.9 + caniuse-lite: 1.0.30001516 + fraction.js: 4.2.0 + normalize-range: 0.1.2 + picocolors: 1.0.0 + postcss: 8.4.26 + postcss-value-parser: 4.2.0 + dev: true + + /available-typed-arrays@1.0.5: + resolution: {integrity: sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==} + engines: {node: '>= 0.4'} + + /axios@1.6.0: + resolution: {integrity: sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==} + dependencies: + follow-redirects: 1.15.3 + form-data: 4.0.0 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + dev: true + + /babel-core@7.0.0-bridge.0(@babel/core@7.22.9): + resolution: {integrity: sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + + /babel-plugin-istanbul@6.1.1: + resolution: {integrity: sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==} + engines: {node: '>=8'} + dependencies: + '@babel/helper-plugin-utils': 7.22.5 + '@istanbuljs/load-nyc-config': 1.1.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-instrument: 5.2.1 + test-exclude: 6.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + dependencies: + '@babel/runtime': 7.24.4 + cosmiconfig: 7.1.0 + resolve: 1.22.8 + + /babel-plugin-polyfill-corejs2@0.4.10(@babel/core@7.22.9): + resolution: {integrity: sha512-rpIuu//y5OX6jVU+a5BCn1R5RSZYWAl2Nar76iwaOdycqb6JPxediskWFMMl7stfwNJR4b7eiQvh5fB5TEQJTQ==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/compat-data': 7.24.4 + '@babel/core': 7.22.9 + '@babel/helper-define-polyfill-provider': 0.6.1(@babel/core@7.22.9) + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: false + + /babel-plugin-polyfill-corejs2@0.4.4(@babel/core@7.22.9): + resolution: {integrity: sha512-9WeK9snM1BfxB38goUEv2FLnA6ja07UMfazFHzCXUb3NyDZAwfXvQiURQ6guTTMeHcOsdknULm1PDhs4uWtKyA==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/compat-data': 7.22.9 + '@babel/core': 7.22.9 + '@babel/helper-define-polyfill-provider': 0.4.1(@babel/core@7.22.9) + '@nicolo-ribaudo/semver-v6': 6.3.3 + transitivePeerDependencies: + - supports-color + + /babel-plugin-polyfill-corejs3@0.10.4(@babel/core@7.22.9): + resolution: {integrity: sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-define-polyfill-provider': 0.6.1(@babel/core@7.22.9) + core-js-compat: 3.36.1 + transitivePeerDependencies: + - supports-color + dev: false + + /babel-plugin-polyfill-corejs3@0.8.2(@babel/core@7.22.9): + resolution: {integrity: sha512-Cid+Jv1BrY9ReW9lIfNlNpsI53N+FN7gE+f73zLAUbr9C52W4gKLWSByx47pfDJsEysojKArqOtOKZSVIIUTuQ==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-define-polyfill-provider': 0.4.1(@babel/core@7.22.9) + core-js-compat: 3.31.1 + transitivePeerDependencies: + - supports-color + + /babel-plugin-polyfill-regenerator@0.5.1(@babel/core@7.22.9): + resolution: {integrity: sha512-L8OyySuI6OSQ5hFy9O+7zFjyr4WhAfRjLIOkhQGYl+emwJkd/S4XXT1JpfrgR1jrQ1NcGiOh+yAdGlF8pnC3Jw==} + peerDependencies: + '@babel/core': ^7.0.0-0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-define-polyfill-provider': 0.4.1(@babel/core@7.22.9) + transitivePeerDependencies: + - supports-color + + /babel-plugin-polyfill-regenerator@0.6.1(@babel/core@7.22.9): + resolution: {integrity: sha512-JfTApdE++cgcTWjsiCQlLyFBMbTUft9ja17saCc93lgV33h4tuCVj7tlvu//qpLwaG+3yEz7/KhahGrUMkVq9g==} + peerDependencies: + '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 + dependencies: + '@babel/core': 7.22.9 + '@babel/helper-define-polyfill-provider': 0.6.1(@babel/core@7.22.9) + transitivePeerDependencies: + - supports-color + dev: false + + /babel-plugin-transform-flow-enums@0.0.2(@babel/core@7.22.9): + resolution: {integrity: sha512-g4aaCrDDOsWjbm0PUUeVnkcVd6AKJsVc/MbnPhEotEpkeJQP6b8nzewohQi7+QS8UyPehOhGWn0nOwjvWpmMvQ==} + dependencies: + '@babel/plugin-syntax-flow': 7.24.1(@babel/core@7.22.9) + transitivePeerDependencies: + - '@babel/core' + dev: false + + /balanced-match@0.4.2: + resolution: {integrity: sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==} + dev: false + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + /base-x@3.0.9: + resolution: {integrity: sha512-H7JU6iBHTal1gp56aKoaa//YUxEaAOUiydvrV/pILqIHXTtqxSkATOnDA2u+jZ/61sD+L/412+7kzXRtWukhpQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + /base64-sol@1.0.1: + resolution: {integrity: sha512-ld3cCNMeXt4uJXmLZBHFGMvVpK9KsLVEhPpFRXnvSVAqABKbuNZg/+dsq3NuM+wxFLb/UrVkz7m1ciWmkMfTbg==} + dev: false + + /basic-auth@2.0.1: + resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==} + engines: {node: '>= 0.8'} + dependencies: + safe-buffer: 5.1.2 + dev: true + + /better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + dependencies: + open: 8.4.2 + dev: true + + /big-integer@1.6.51: + resolution: {integrity: sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==} + engines: {node: '>=0.6'} + dev: true + + /big.js@5.2.2: + resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + + /bignumber.js@9.1.2: + resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==} + dev: false + + /binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + + /bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + /bl@5.1.0: + resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} + dependencies: + buffer: 6.0.3 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: true + + /blakejs@1.2.1: + resolution: {integrity: sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==} + dev: false + + /bn.js@4.12.0: + resolution: {integrity: sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==} + dev: false + + /bn.js@5.2.1: + resolution: {integrity: sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==} + dev: false + + /body-parser@1.20.1: + resolution: {integrity: sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.11.0 + raw-body: 2.5.1 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /bowser@2.11.0: + resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==} + dev: false + + /boxen@5.1.2: + resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==} + engines: {node: '>=10'} + dependencies: + ansi-align: 3.0.1 + camelcase: 6.3.0 + chalk: 4.1.2 + cli-boxes: 2.2.1 + string-width: 4.2.3 + type-fest: 0.20.2 + widest-line: 3.1.0 + wrap-ansi: 7.0.0 + dev: false + + /boxen@7.0.0: + resolution: {integrity: sha512-j//dBVuyacJbvW+tvZ9HuH03fZ46QcaKvvhZickZqtB271DxJ7SNRSNxrV/dZX0085m7hISRZWbzWlJvx/rHSg==} + engines: {node: '>=14.16'} + dependencies: + ansi-align: 3.0.1 + camelcase: 7.0.1 + chalk: 5.3.0 + cli-boxes: 3.0.0 + string-width: 5.1.2 + type-fest: 2.19.0 + widest-line: 4.0.1 + wrap-ansi: 8.1.0 + dev: true + + /bplist-parser@0.2.0: + resolution: {integrity: sha512-z0M+byMThzQmD9NILRniCUXYsYpjwnlO8N5uCFaCqIOpqRsJCrQL9NK3JsD67CN5a08nF5oIL2bD6loTdHOuKw==} + engines: {node: '>= 5.10.0'} + dependencies: + big-integer: 1.6.51 + dev: true + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + + /braces@3.0.2: + resolution: {integrity: sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.0.1 + + /brorand@1.1.0: + resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} + dev: false + + /browser-assert@1.2.1: + resolution: {integrity: sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==} + dev: true + + /browser-stdout@1.3.1: + resolution: {integrity: sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==} + dev: false + + /browserify-aes@1.2.0: + resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + dependencies: + buffer-xor: 1.0.3 + cipher-base: 1.0.4 + create-hash: 1.2.0 + evp_bytestokey: 1.0.3 + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + + /browserify-zlib@0.1.4: + resolution: {integrity: sha512-19OEpq7vWgsH6WkvkBJQDFvJS1uPcbFOQ4v9CU839dO+ZZXUZO6XpE6hNCqvlIIj+4fZvRiJ6DsAQ382GwiyTQ==} + dependencies: + pako: 0.2.9 + dev: true + + /browserslist@4.21.9: + resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001516 + electron-to-chromium: 1.4.461 + node-releases: 2.0.13 + update-browserslist-db: 1.0.11(browserslist@4.21.9) + dev: true + + /browserslist@4.22.1: + resolution: {integrity: sha512-FEVc202+2iuClEhZhrWy6ZiAcRLvNMyYcxZ8raemul1DYVOVdFsbqckWLdsixQZCpJlwe77Z3UTalE7jsjnKfQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001554 + electron-to-chromium: 1.4.568 + node-releases: 2.0.13 + update-browserslist-db: 1.0.13(browserslist@4.22.1) + + /browserslist@4.23.0: + resolution: {integrity: sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001609 + electron-to-chromium: 1.4.735 + node-releases: 2.0.14 + update-browserslist-db: 1.0.13(browserslist@4.23.0) + + /bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + dependencies: + base-x: 3.0.9 + dev: false + + /bs58check@2.1.2: + resolution: {integrity: sha512-0TS1jicxdU09dwJMNZtVAfzPi6Q6QeN0pM1Fkzrjn+XYHvzMKPU3pHVpva+769iNVSfIYWf7LJ6WR+BuuMf8cA==} + dependencies: + bs58: 4.0.1 + create-hash: 1.2.0 + safe-buffer: 5.2.1 + dev: false + + /bser@2.1.1: + resolution: {integrity: sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==} + dependencies: + node-int64: 0.4.0 + + /buffer-crc32@0.2.13: + resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + dev: true + + /buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + + /buffer-xor@1.0.3: + resolution: {integrity: sha512-571s0T7nZWK6vB67HI5dyUF7wXiNcfaPPPTl6zYCNApANjIvYJTg7hlud/+cJpdAhS7dVzqMLmfhfHR3rAcOjQ==} + dev: false + + /buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + /buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + /bufferutil@4.0.8: + resolution: {integrity: sha512-4T53u4PdgsXqKaIctwF8ifXlRTTmEPJ8iEPWFdGZvcf7sbwYo6FKFEX9eNNAnzFZ7EzJAQ3CJeOtCRA4rDp7Pw==} + engines: {node: '>=6.14.2'} + requiresBuild: true + dependencies: + node-gyp-build: 4.8.0 + dev: false + + /bundle-require@4.0.2(esbuild@0.19.12): + resolution: {integrity: sha512-jwzPOChofl67PSTW2SGubV9HBQAhhR2i6nskiOThauo9dzwDUgOWQScFVaJkjEfYX+UXiD+LEx8EblQMc2wIag==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.17' + dependencies: + esbuild: 0.19.12 + load-tsconfig: 0.2.5 + dev: true + + /bytes-iec@3.1.1: + resolution: {integrity: sha512-fey6+4jDK7TFtFg/klGSvNKJctyU7n2aQdnM+CO0ruLPbqqMOM8Tio0Pc+deqUeVKX1tL5DQep1zQ7+37aTAsA==} + engines: {node: '>= 0.8'} + dev: false + + /bytes@3.0.0: + resolution: {integrity: sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==} + engines: {node: '>= 0.8'} + + /bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + /cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + dev: true + + /call-bind@1.0.2: + resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==} + dependencies: + function-bind: 1.1.2 + get-intrinsic: 1.2.1 + + /call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + + /caller-callsite@2.0.0: + resolution: {integrity: sha512-JuG3qI4QOftFsZyOn1qq87fq5grLIyk1JYd5lJmdA+fG7aQ9pA/i3JIJGcO3q0MrRcHlOt1U+ZeHW8Dq9axALQ==} + engines: {node: '>=4'} + dependencies: + callsites: 2.0.0 + dev: false + + /caller-path@2.0.0: + resolution: {integrity: sha512-MCL3sf6nCSXOwCTzvPKhN18TU7AHTvdtam8DAogxcrJ8Rjfbbg7Lgng64H9Iy+vUV6VGFClN/TyxBkAebLRR4A==} + engines: {node: '>=4'} + dependencies: + caller-callsite: 2.0.0 + dev: false + + /callsites@2.0.0: + resolution: {integrity: sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==} + engines: {node: '>=4'} + dev: false + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + /camel-case@4.1.2: + resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} + dependencies: + pascal-case: 3.1.2 + tslib: 2.6.2 + dev: true + + /camelcase-css@2.0.1: + resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} + engines: {node: '>= 6'} + + /camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + + /camelcase@6.3.0: + resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} + engines: {node: '>=10'} + + /camelcase@7.0.1: + resolution: {integrity: sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==} + engines: {node: '>=14.16'} + dev: true + + /caniuse-lite@1.0.30001516: + resolution: {integrity: sha512-Wmec9pCBY8CWbmI4HsjBeQLqDTqV91nFVR83DnZpYyRnPI1wePDsTg0bGLPC5VU/3OIZV1fmxEea1b+tFKe86g==} + dev: true + + /caniuse-lite@1.0.30001554: + resolution: {integrity: sha512-A2E3U//MBwbJVzebddm1YfNp7Nud5Ip+IPn4BozBmn4KqVX7AvluoIDFWjsv5OkGnKUXQVmMSoMKLa3ScCblcQ==} + + /caniuse-lite@1.0.30001609: + resolution: {integrity: sha512-JFPQs34lHKx1B5t1EpQpWH4c+29zIyn/haGsbpfq3suuV9v56enjFt23zqijxGTMwy1p/4H2tjnQMY+p1WoAyA==} + + /capital-case@1.0.4: + resolution: {integrity: sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + upper-case-first: 2.0.2 + dev: true + + /chai@4.3.10: + resolution: {integrity: sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==} + engines: {node: '>=4'} + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.3 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.0.8 + dev: true + + /chalk-template@0.4.0: + resolution: {integrity: sha512-/ghrgmhfY8RaSdeo43hNXxpoHAtxdbskUHjPpfqUWGttFgycUhYPGx3YZBCnUCvOa7Doivn1IZec3DEGFoMgLg==} + engines: {node: '>=12'} + dependencies: + chalk: 4.1.2 + dev: true + + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + /chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + /chalk@5.0.1: + resolution: {integrity: sha512-Fo07WOYGqMfCWHOzSXOt2CxDbC6skS/jO9ynEcmpANMoPrD+W1r1K6Vx7iNm+AQmETU1Xr2t+n8nzkV9t6xh3w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: true + + /chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: true + + /change-case@4.1.2: + resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} + dependencies: + camel-case: 4.1.2 + capital-case: 1.0.4 + constant-case: 3.0.4 + dot-case: 3.0.4 + header-case: 2.0.4 + no-case: 3.0.4 + param-case: 3.0.4 + pascal-case: 3.1.2 + path-case: 3.0.4 + sentence-case: 3.0.4 + snake-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /chardet@0.7.0: + resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + dev: true + + /check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + dependencies: + get-func-name: 2.0.2 + dev: true + + /chokidar@3.5.1: + resolution: {integrity: sha512-9+s+Od+W0VJJzawDma/gvBNQqkTiqYTWLuZoyAsivsI4AaWTCzHG06/TMjsf1cYe9Cb97UCEhjz7HvnPk2p/tw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.5.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /chokidar@3.5.3: + resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + /chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + dependencies: + anymatch: 3.1.3 + braces: 3.0.2 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + + /chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + dev: true + + /chownr@2.0.0: + resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} + engines: {node: '>=10'} + dev: true + + /chromatic@10.1.0: + resolution: {integrity: sha512-S+ztO8f1k/LckuzJKCqaTs6AfUQ0eLNT9kEoyCUwX7gkJnveQo5JStCfY55v30zogjRkHJpwqzEfSXl6AwO2tQ==} + hasBin: true + dev: true + + /chrome-launcher@0.15.2: + resolution: {integrity: sha512-zdLEwNo3aUVzIhKhTtXfxhdvZhUghrnmkvcAq2NoDd+LeOHKf03H5jwZ8T/STsAlzyALkBVK552iaG1fGf1xVQ==} + engines: {node: '>=12.13.0'} + hasBin: true + dependencies: + '@types/node': 20.12.7 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + transitivePeerDependencies: + - supports-color + dev: false + + /chrome-trace-event@1.0.3: + resolution: {integrity: sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==} + engines: {node: '>=6.0'} + dev: true + + /chromium-edge-launcher@1.0.0: + resolution: {integrity: sha512-pgtgjNKZ7i5U++1g1PWv75umkHvhVTDOQIZ+sjeUX9483S7Y6MUvO0lrd7ShGlQlFHMN4SwKTCq/X8hWrbv2KA==} + dependencies: + '@types/node': 20.12.7 + escape-string-regexp: 4.0.0 + is-wsl: 2.2.0 + lighthouse-logger: 1.4.2 + mkdirp: 1.0.4 + rimraf: 3.0.2 + transitivePeerDependencies: + - supports-color + dev: false + + /ci-info@2.0.0: + resolution: {integrity: sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==} + dev: false + + /ci-info@3.8.0: + resolution: {integrity: sha512-eXTggHWSooYhq49F2opQhuHWgzucfF2YgODK4e1566GQs5BIfP30B0oenwBJHfWxAs2fyPB1s7Mg949zLf61Yw==} + engines: {node: '>=8'} + dev: true + + /ci-info@3.9.0: + resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} + engines: {node: '>=8'} + dev: false + + /cipher-base@1.0.4: + resolution: {integrity: sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==} + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + + /citty@0.1.5: + resolution: {integrity: sha512-AS7n5NSc0OQVMV9v6wt3ByujNIrne0/cTjiC2MYqhvao57VNfiuVksTSr2p17nVOhEr2KtqiAkGwHcgMC/qUuQ==} + dependencies: + consola: 3.2.3 + dev: false + + /class-variance-authority@0.7.0: + resolution: {integrity: sha512-jFI8IQw4hczaL4ALINxqLEXQbWcNjoSkloa4IaufXCJr6QawJyw7tuRysRsrE8w2p/4gGaxKIt/hX3qz/IbD1A==} + dependencies: + clsx: 2.0.0 + dev: false + + /classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + dev: false + + /clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + + /cli-boxes@2.2.1: + resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} + engines: {node: '>=6'} + dev: false + + /cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + dev: true + + /cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + + /cli-cursor@4.0.0: + resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + restore-cursor: 4.0.0 + dev: true + + /cli-spinners@2.9.0: + resolution: {integrity: sha512-4/aL9X3Wh0yiMQlE+eeRhWP6vclO3QRtw1JHKIT0FFUs5FjpFmESqtMvYZ0+lbzBw900b95mS0hohy+qn2VK/g==} + engines: {node: '>=6'} + + /cli-table3@0.6.3: + resolution: {integrity: sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==} + engines: {node: 10.* || >= 12.*} + dependencies: + string-width: 4.2.3 + optionalDependencies: + '@colors/colors': 1.5.0 + dev: true + + /cli-table@0.3.6: + resolution: {integrity: sha512-ZkNZbnZjKERTY5NwC2SeMeLeifSPq/pubeRoTpdr3WchLlnZg6hEgvHkK5zL7KNFdd9PmHN8lxrENUwI3cE8vQ==} + engines: {node: '>= 0.2.0'} + dependencies: + colors: 1.0.3 + dev: true + + /cli-width@3.0.0: + resolution: {integrity: sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==} + engines: {node: '>= 10'} + dev: true + + /clipboardy@3.0.0: + resolution: {integrity: sha512-Su+uU5sr1jkUy1sGRpLKjKrvEOVXgSgiSInwa/qeID6aJ07yh+5NWc3h2QfjHjBnfX4LhtFcuAWKUsJ3r+fjbg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + arch: 2.2.0 + execa: 5.1.1 + is-wsl: 2.2.0 + + /cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + dev: false + + /cliui@7.0.4: + resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: false + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: false + + /clone-deep@4.0.1: + resolution: {integrity: sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==} + engines: {node: '>=6'} + dependencies: + is-plain-object: 2.0.4 + kind-of: 6.0.3 + shallow-clone: 3.0.1 + + /clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + + /clsx@1.2.1: + resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + engines: {node: '>=6'} + dev: false + + /clsx@2.0.0: + resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} + engines: {node: '>=6'} + dev: false + + /clsx@2.1.0: + resolution: {integrity: sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg==} + engines: {node: '>=6'} + dev: false + + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: false + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + /color-support@1.1.3: + resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} + hasBin: true + dev: true + + /colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + dev: false + + /colorette@2.0.20: + resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + dev: true + + /colors@1.0.3: + resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} + engines: {node: '>=0.1.90'} + dev: true + + /combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + dependencies: + delayed-stream: 1.0.0 + + /command-exists@1.2.9: + resolution: {integrity: sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==} + dev: false + + /commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + dev: true + + /commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + /commander@3.0.2: + resolution: {integrity: sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==} + dev: false + + /commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + /commander@6.2.1: + resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} + engines: {node: '>= 6'} + dev: true + + /commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + dev: false + + /commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + + /compare-versions@6.1.0: + resolution: {integrity: sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg==} + dev: false + + /compressible@2.0.18: + resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + + /compression@1.7.4: + resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==} + engines: {node: '>= 0.8.0'} + dependencies: + accepts: 1.3.8 + bytes: 3.0.0 + compressible: 2.0.18 + debug: 2.6.9 + on-headers: 1.0.2 + safe-buffer: 5.1.2 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + /concat-map@0.0.1: + resolution: {integrity: sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=} + + /concat-stream@1.6.2: + resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} + engines: {'0': node >= 0.8} + dependencies: + buffer-from: 1.1.2 + inherits: 2.0.4 + readable-stream: 2.3.8 + typedarray: 0.0.6 + dev: true + + /connect@3.7.0: + resolution: {integrity: sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==} + engines: {node: '>= 0.10.0'} + dependencies: + debug: 2.6.9 + finalhandler: 1.1.2 + parseurl: 1.3.3 + utils-merge: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /consola@3.2.3: + resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} + engines: {node: ^14.18.0 || >=16.10.0} + dev: false + + /console-control-strings@1.1.0: + resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + dev: true + + /constant-case@3.0.4: + resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + upper-case: 2.0.2 + dev: true + + /content-disposition@0.5.2: + resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==} + engines: {node: '>= 0.6'} + dev: true + + /content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + dev: true + + /convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + /cookie-es@1.0.0: + resolution: {integrity: sha512-mWYvfOLrfEc996hlKcdABeIiPHUPC6DM2QYZdGGOvhOTbA3tjm2eBwqlJpoFdjC89NI4Qt6h0Pu06Mp+1Pj5OQ==} + dev: false + + /cookie-signature@1.0.6: + resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=} + dev: true + + /cookie@0.4.2: + resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==} + engines: {node: '>= 0.6'} + dev: false + + /cookie@0.5.0: + resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} + engines: {node: '>= 0.6'} + dev: true + + /copy-anything@2.0.6: + resolution: {integrity: sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==} + dependencies: + is-what: 3.14.1 + dev: true + + /core-js-compat@3.31.1: + resolution: {integrity: sha512-wIDWd2s5/5aJSdpOJHfSibxNODxoGoWOBHt8JSPB41NOE94M7kuTPZCYLOlTtuoXTsBPKobpJ6T+y0SSy5L9SA==} + dependencies: + browserslist: 4.22.1 + + /core-js-compat@3.36.1: + resolution: {integrity: sha512-Dk997v9ZCt3X/npqzyGdTlq6t7lDBhZwGvV94PKzDArjp7BTRm7WlDAXYd/OWdeFHO8OChQYRJNJvUCqCbrtKA==} + dependencies: + browserslist: 4.23.0 + dev: false + + /core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + + /corser@2.0.1: + resolution: {integrity: sha512-utCYNzRSQIZNPIcGZdQc92UVJYAhtGAteCFg0yRaFm8f0P+CPtyGyHXJcGXnffjCybUCEx3FQ2G7U3/o9eIkVQ==} + engines: {node: '>= 0.4.0'} + dev: true + + /cosmiconfig@5.2.1: + resolution: {integrity: sha512-H65gsXo1SKjf8zmrJ67eJk8aIRKV5ff2D4uKZIBZShbhGSpEmsQOPW/SKMKYhSTrqR7ufy6RP69rPogdaPh/kA==} + engines: {node: '>=4'} + dependencies: + import-fresh: 2.0.0 + is-directory: 0.3.1 + js-yaml: 3.14.1 + parse-json: 4.0.0 + dev: false + + /cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.1 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + + /cosmiconfig@8.2.0: + resolution: {integrity: sha512-3rTMnFJA1tCOPwRxtgF4wd7Ab2qvDbL8jX+3smjIbS4HlZBagTlpERbdN7iAbWlrfxE3M8c27kTwTawQ7st+OQ==} + engines: {node: '>=14'} + dependencies: + import-fresh: 3.3.0 + js-yaml: 4.1.0 + parse-json: 5.2.0 + path-type: 4.0.0 + + /crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + dev: false + + /create-hash@1.2.0: + resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} + dependencies: + cipher-base: 1.0.4 + inherits: 2.0.4 + md5.js: 1.3.5 + ripemd160: 2.0.2 + sha.js: 2.4.11 + dev: false + + /create-hmac@1.1.7: + resolution: {integrity: sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==} + dependencies: + cipher-base: 1.0.4 + create-hash: 1.2.0 + inherits: 2.0.4 + ripemd160: 2.0.2 + safe-buffer: 5.2.1 + sha.js: 2.4.11 + dev: false + + /cross-fetch@3.1.8: + resolution: {integrity: sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + + /cross-fetch@4.0.0: + resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + /crypto-random-string@2.0.0: + resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==} + engines: {node: '>=8'} + dev: true + + /css-loader@6.8.1(webpack@5.91.0): + resolution: {integrity: sha512-xDAXtEVGlD0gJ07iclwWVkLoZOpEvAWaSyf6W18S2pOC//K8+qUDIx8IIT3D+HjnmkJPQeesOPv5aiUaJsCM2g==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + dependencies: + icss-utils: 5.1.0(postcss@8.4.31) + postcss: 8.4.31 + postcss-modules-extract-imports: 3.0.0(postcss@8.4.31) + postcss-modules-local-by-default: 4.0.3(postcss@8.4.31) + postcss-modules-scope: 3.0.0(postcss@8.4.31) + postcss-modules-values: 4.0.0(postcss@8.4.31) + postcss-value-parser: 4.2.0 + semver: 7.5.4 + webpack: 5.91.0(esbuild@0.18.20) + dev: true + + /css-what@6.1.0: + resolution: {integrity: sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==} + engines: {node: '>= 6'} + dev: false + + /css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + + /cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + /cssstyle@3.0.0: + resolution: {integrity: sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==} + engines: {node: '>=14'} + dependencies: + rrweb-cssom: 0.6.0 + dev: true + + /csstype@3.1.2: + resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} + + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + dev: false + + /d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + dependencies: + delaunator: 5.0.1 + dev: false + + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.0 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false + + /d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + dependencies: + d3-path: 1.0.9 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + + /data-urls@4.0.0: + resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} + engines: {node: '>=14'} + dependencies: + abab: 2.0.6 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + dev: true + + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.24.4 + + /dayjs@1.11.10: + resolution: {integrity: sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==} + dev: false + + /debounce@1.2.1: + resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + dev: false + + /debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.0.0 + + /debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + + /debug@4.3.4(supports-color@8.1.1): + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.2 + supports-color: 8.1.1 + + /decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + dev: false + + /decamelize@4.0.0: + resolution: {integrity: sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==} + engines: {node: '>=10'} + dev: false + + /decimal.js-light@2.5.1: + resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + dev: false + + /decimal.js@10.4.3: + resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} + dev: true + + /decode-uri-component@0.2.2: + resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} + engines: {node: '>=0.10'} + dev: false + + /dedent@0.7.0: + resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==} + dev: true + + /deep-eql@4.1.3: + resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} + engines: {node: '>=6'} + dependencies: + type-detect: 4.0.8 + dev: true + + /deep-equal@2.2.2: + resolution: {integrity: sha512-xjVyBf0w5vH0I42jdAZzOKVldmPgSulmiyPRywoyq7HXC9qdgo17kxJE+rdnif5Tz6+pIrpJI8dCpMNLIGkUiA==} + dependencies: + array-buffer-byte-length: 1.0.0 + call-bind: 1.0.2 + es-get-iterator: 1.1.3 + get-intrinsic: 1.2.1 + is-arguments: 1.1.1 + is-array-buffer: 3.0.2 + is-date-object: 1.0.5 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.2 + isarray: 2.0.5 + object-is: 1.1.5 + object-keys: 1.1.1 + object.assign: 4.1.4 + regexp.prototype.flags: 1.5.0 + side-channel: 1.0.4 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.1 + which-typed-array: 1.1.10 + dev: true + + /deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + dev: true + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /deep-object-diff@1.1.9: + resolution: {integrity: sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==} + dev: false + + /deepmerge-ts@5.1.0: + resolution: {integrity: sha512-eS8dRJOckyo9maw9Tu5O5RUi/4inFLrnoLkBe3cPfDMx3WZioXtmOew4TXQaxq7Rhl4xjDtR7c6x8nNTxOvbFw==} + engines: {node: '>=16.0.0'} + dev: false + + /deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: false + + /default-browser-id@3.0.0: + resolution: {integrity: sha512-OZ1y3y0SqSICtE8DE4S8YOE9UZOJ8wO16fKWVP5J1Qz42kV9jcnMVFrEE/noXb/ss3Q4pZIH79kxofzyNNtUNA==} + engines: {node: '>=12'} + dependencies: + bplist-parser: 0.2.0 + untildify: 4.0.0 + dev: true + + /defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + dependencies: + clone: 1.0.4 + + /define-data-property@1.1.1: + resolution: {integrity: sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.1 + gopd: 1.0.1 + has-property-descriptors: 1.0.0 + dev: true + + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + /define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + /define-properties@1.2.0: + resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} + engines: {node: '>= 0.4'} + dependencies: + has-property-descriptors: 1.0.0 + object-keys: 1.1.1 + dev: true + + /define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.1 + has-property-descriptors: 1.0.0 + object-keys: 1.1.1 + dev: true + + /defu@6.1.2: + resolution: {integrity: sha512-+uO4+qr7msjNNWKYPHqN/3+Dx3NFkmIzayk2L1MyZQlvgZb/J1A0fo410dpKrN2SnqFjt8n4JL8fDJE0wIgjFQ==} + dev: true + + /defu@6.1.3: + resolution: {integrity: sha512-Vy2wmG3NTkmHNg/kzpuvHhkqeIx3ODWqasgCRbKtbXEN0G+HpEEv9BtJLp7ZG1CZloFaC41Ah3ZFbq7aqCqMeQ==} + dev: false + + /del@6.1.1: + resolution: {integrity: sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg==} + engines: {node: '>=10'} + dependencies: + globby: 11.1.0 + graceful-fs: 4.2.11 + is-glob: 4.0.3 + is-path-cwd: 2.2.0 + is-path-inside: 3.0.3 + p-map: 4.0.0 + rimraf: 3.0.2 + slash: 3.0.0 + dev: true + + /delaunator@5.0.1: + resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} + dependencies: + robust-predicates: 3.0.2 + dev: false + + /delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + /delegates@1.0.0: + resolution: {integrity: sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==} + dev: true + + /denodeify@1.2.1: + resolution: {integrity: sha512-KNTihKNmQENUZeKu5fzfpzRqR5S2VMp4gl9RFHiWzj9DfvYQPMJ6XHKNaQxaGCXwPk6y9yme3aUoaiAe+KX+vg==} + dev: false + + /denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dev: false + + /depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + /deprecated-react-native-prop-types@5.0.0: + resolution: {integrity: sha512-cIK8KYiiGVOFsKdPMmm1L3tA/Gl+JopXL6F5+C7x39MyPsQYnP57Im/D6bNUzcborD7fcMwiwZqcBdBXXZucYQ==} + engines: {node: '>=18'} + dependencies: + '@react-native/normalize-colors': 0.73.2 + invariant: 2.2.4 + prop-types: 15.8.1 + dev: false + + /dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + /destr@2.0.2: + resolution: {integrity: sha512-65AlobnZMiCET00KaFFjUefxDX0khFA/E4myqZ7a6Sq1yZtR8+FVIvilVX66vF2uobSumxooYZChiRPCKNqhmg==} + dev: false + + /destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + + /detect-browser@5.3.0: + resolution: {integrity: sha512-53rsFbGdwMwlF7qvCt0ypLM5V5/Mbl0szB7GPN8y9NCcbknYOeVVXdrXEq+90IwAfrrzt6Hd+u2E2ntakICU8w==} + dev: false + + /detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + dev: true + + /detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + dev: false + + /detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + /detect-package-manager@2.0.1: + resolution: {integrity: sha512-j/lJHyoLlWi6G1LDdLgvUtz60Zo5GEj+sVYtTVXnYLDPuzgC3llMxonXym9zIwhhUII8vjdw0LXxavpLqTbl1A==} + engines: {node: '>=12'} + dependencies: + execa: 5.1.1 + dev: true + + /detect-port@1.5.1: + resolution: {integrity: sha512-aBzdj76lueB6uUst5iAs7+0H/oOjqI5D16XUWxlWMIMROhcM0rfsNVk93zTngq1dDNpoXRr++Sus7ETAExppAQ==} + hasBin: true + dependencies: + address: 1.2.2 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + + /didyoumean@1.2.2: + resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + + /diff-sequences@28.1.1: + resolution: {integrity: sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dev: true + + /diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /diff@5.0.0: + resolution: {integrity: sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==} + engines: {node: '>=0.3.1'} + dev: false + + /dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dev: false + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + + /dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + /doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dev: true + + /domexception@4.0.0: + resolution: {integrity: sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==} + engines: {node: '>=12'} + dependencies: + webidl-conversions: 7.0.0 + dev: true + + /dot-case@3.0.4: + resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /dotenv-expand@10.0.0: + resolution: {integrity: sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==} + engines: {node: '>=12'} + + /dotenv@14.3.2: + resolution: {integrity: sha512-vwEppIphpFdvaMCaHfCEv9IgwcxMljMw2TnAQBB4VWPvzXQLTb82jwmdOKzlEVUL3gNFT4l4TPKO+Bn+sqcrVQ==} + engines: {node: '>=12'} + dev: false + + /dotenv@16.3.1: + resolution: {integrity: sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==} + engines: {node: '>=12'} + + /duplexify@3.7.1: + resolution: {integrity: sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==} + dependencies: + end-of-stream: 1.4.4 + inherits: 2.0.4 + readable-stream: 2.3.8 + stream-shift: 1.0.1 + dev: true + + /duplexify@4.1.2: + resolution: {integrity: sha512-fz3OjcNCHmRP12MJoZMPglx8m4rrFP8rovnk4vT8Fs+aonZoCwGg10dSsQsfP/E62eZcPTMSMP6686fu9Qlqtw==} + dependencies: + end-of-stream: 1.4.4 + inherits: 2.0.4 + readable-stream: 3.6.2 + stream-shift: 1.0.1 + dev: false + + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + /eciesjs@0.3.18: + resolution: {integrity: sha512-RQhegEtLSyIiGJmFTZfvCTHER/fymipXFVx6OwSRYD6hOuy+6Kjpk0dGvIfP9kxn/smBpxQy71uxpGO406ITCw==} + dependencies: + '@types/secp256k1': 4.0.6 + futoin-hkdf: 1.5.3 + secp256k1: 5.0.0 + dev: false + + /ee-first@1.1.1: + resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=} + + /ejs@3.1.9: + resolution: {integrity: sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==} + engines: {node: '>=0.10.0'} + hasBin: true + dependencies: + jake: 10.8.7 + dev: true + + /electron-to-chromium@1.4.461: + resolution: {integrity: sha512-1JkvV2sgEGTDXjdsaQCeSwYYuhLRphRpc+g6EHTFELJXEiznLt3/0pZ9JuAOQ5p2rI3YxKTbivtvajirIfhrEQ==} + dev: true + + /electron-to-chromium@1.4.568: + resolution: {integrity: sha512-3TCOv8+BY6Ltpt1/CmGBMups2IdKOyfEmz4J8yIS4xLSeMm0Rf+psSaxLuswG9qMKt+XbNbmADybtXGpTFlbDg==} + + /electron-to-chromium@1.4.735: + resolution: {integrity: sha512-pkYpvwg8VyOTQAeBqZ7jsmpCjko1Qc6We1ZtZCjRyYbT5v4AIUKDy5cQTRotQlSSZmMr8jqpEt6JtOj5k7lR7A==} + + /elliptic@6.5.4: + resolution: {integrity: sha512-iLhC6ULemrljPZb+QutR5TQGB+pdW6KGD5RSegS+8sorOZT+rdQFbsQFJgvN3eRqNALqJer4oQ16YvJHlU8hzQ==} + dependencies: + bn.js: 4.12.0 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + + /elliptic@6.5.5: + resolution: {integrity: sha512-7EjbcmUm17NQFu4Pmgmq2olYMj8nwMnpcddByChSUjArp8F5DQWcIcpriwO4ZToLNAJig0yiyjswfyGNje/ixw==} + dependencies: + bn.js: 4.12.0 + brorand: 1.1.0 + hash.js: 1.1.7 + hmac-drbg: 1.0.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + /emojis-list@3.0.0: + resolution: {integrity: sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==} + engines: {node: '>= 4'} + dev: true + + /encode-utf8@1.0.3: + resolution: {integrity: sha512-ucAnuBEhUK4boH2HjVYG5Q2mQyPorvv0u/ocS+zhdw0S8AlHYY+GOFhP1Gio5z4icpP2ivFSvhtFjQi8+T9ppw==} + dev: false + + /encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + + /end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} + dependencies: + once: 1.4.0 + + /engine.io-client@6.5.3(bufferutil@4.0.8)(utf-8-validate@6.0.3): + resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==} + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.4(supports-color@8.1.1) + engine.io-parser: 5.2.2 + ws: 8.11.0(bufferutil@4.0.8)(utf-8-validate@6.0.3) + xmlhttprequest-ssl: 2.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /engine.io-parser@5.2.2: + resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==} + engines: {node: '>=10.0.0'} + dev: false + + /enhanced-resolve@5.16.0: + resolution: {integrity: sha512-O+QWCviPNSSLAD9Ucn8Awv+poAkqn3T1XY5/N7kR7rQO9yfSGWkYZDwpJ+iKF7B8rxaQKWngSqACpgzeapSyoA==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: true + + /enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + dev: false + + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + + /env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + dev: false + + /envinfo@7.10.0: + resolution: {integrity: sha512-ZtUjZO6l5mwTHvc1L9+1q5p/R3wTopcfqMW8r5t8SJSKqeVI/LtajORwRFEKpEFuekjD0VBjwu1HMxL4UalIRw==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /envinfo@7.12.0: + resolution: {integrity: sha512-Iw9rQJBGpJRd3rwXm9ft/JiGoAZmLxxJZELYDQoPRZ4USVhkKtIcNBPw6U+/K2mBpaqM25JSV6Yl4Az9vO2wJg==} + engines: {node: '>=4'} + hasBin: true + dev: false + + /errno@0.1.8: + resolution: {integrity: sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==} + hasBin: true + requiresBuild: true + dependencies: + prr: 1.0.1 + dev: true + optional: true + + /error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + + /error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + dependencies: + stackframe: 1.3.4 + dev: false + + /errorhandler@1.5.1: + resolution: {integrity: sha512-rcOwbfvP1WTViVoUjcfZicVzjhjTuhSMntHh6mW3IrEiyE6mJyXvsToJUJGlGlw/2xU9P5whlWNGlIDVeCiT4A==} + engines: {node: '>= 0.8'} + dependencies: + accepts: 1.3.8 + escape-html: 1.0.3 + dev: false + + /es-abstract@1.22.1: + resolution: {integrity: sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.0 + arraybuffer.prototype.slice: 1.0.1 + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + es-set-tostringtag: 2.0.1 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.5 + get-intrinsic: 1.2.1 + get-symbol-description: 1.0.0 + globalthis: 1.0.3 + gopd: 1.0.1 + has: 1.0.3 + has-property-descriptors: 1.0.0 + has-proto: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.5 + is-array-buffer: 3.0.2 + is-callable: 1.2.7 + is-negative-zero: 2.0.2 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.2 + is-string: 1.0.7 + is-typed-array: 1.1.10 + is-weakref: 1.0.2 + object-inspect: 1.12.3 + object-keys: 1.1.1 + object.assign: 4.1.4 + regexp.prototype.flags: 1.5.0 + safe-array-concat: 1.0.0 + safe-regex-test: 1.0.0 + string.prototype.trim: 1.2.7 + string.prototype.trimend: 1.0.6 + string.prototype.trimstart: 1.0.6 + typed-array-buffer: 1.0.0 + typed-array-byte-length: 1.0.0 + typed-array-byte-offset: 1.0.0 + typed-array-length: 1.0.4 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.10 + dev: true + + /es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + /es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + is-arguments: 1.1.1 + is-map: 2.0.2 + is-set: 2.0.2 + is-string: 1.0.7 + isarray: 2.0.5 + stop-iteration-iterator: 1.0.0 + dev: true + + /es-iterator-helpers@1.0.15: + resolution: {integrity: sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==} + dependencies: + asynciterator.prototype: 1.0.0 + call-bind: 1.0.2 + define-properties: 1.2.1 + es-abstract: 1.22.1 + es-set-tostringtag: 2.0.1 + function-bind: 1.1.1 + get-intrinsic: 1.2.1 + globalthis: 1.0.3 + has-property-descriptors: 1.0.0 + has-proto: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.5 + iterator.prototype: 1.1.2 + safe-array-concat: 1.0.1 + dev: true + + /es-module-lexer@0.9.3: + resolution: {integrity: sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ==} + dev: true + + /es-module-lexer@1.5.0: + resolution: {integrity: sha512-pqrTKmwEIgafsYZAGw9kszYzmagcE/n4dbgwGWLEXg7J4QFJVQRBld8j3Q3GNez79jzxZshq0bcT962QHOghjw==} + dev: true + + /es-set-tostringtag@2.0.1: + resolution: {integrity: sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.1 + has: 1.0.3 + has-tostringtag: 1.0.0 + dev: true + + /es-shim-unscopables@1.0.0: + resolution: {integrity: sha512-Jm6GPcCdC30eMLbZ2x8z2WuRwAws3zTBBKuusffYVUrNj/GVSUAZ+xKMaUpfNDR5IbyNA5LJbaecoUVbmUcB1w==} + dependencies: + has: 1.0.3 + dev: true + + /es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + dev: true + + /es6-object-assign@1.1.0: + resolution: {integrity: sha512-MEl9uirslVwqQU369iHNWZXsI8yaZYGg/D65aOgZkeyFJwHYSxilf7rQzXKI7DdDuBPrBXbfk3sl9hJhmd5AUw==} + dev: true + + /esbuild-plugin-alias@0.2.1: + resolution: {integrity: sha512-jyfL/pwPqaFXyKnj8lP8iLk6Z0m099uXR45aSN8Av1XD4vhvQutxxPzgA2bTcAwQpa1zCXDcWOlhFgyP3GKqhQ==} + dev: true + + /esbuild-register@3.4.2(esbuild@0.17.19): + resolution: {integrity: sha512-kG/XyTDyz6+YDuyfB9ZoSIOOmgyFCH+xPRtsCa8W85HLRV5Csp+o3jWVbOSHgSLfyLc5DmP+KFDNwty4mEjC+Q==} + peerDependencies: + esbuild: '>=0.12 <1' + dependencies: + debug: 4.3.4(supports-color@8.1.1) + esbuild: 0.17.19 + transitivePeerDependencies: + - supports-color + dev: true + + /esbuild-register@3.5.0(esbuild@0.18.20): + resolution: {integrity: sha512-+4G/XmakeBAsvJuDugJvtyF1x+XJT4FMocynNpxrvEBViirpfUn2PgNpCHedfWhF4WokNsO/OvMKrmJOIJsI5A==} + peerDependencies: + esbuild: '>=0.12 <1' + dependencies: + debug: 4.3.4(supports-color@8.1.1) + esbuild: 0.18.20 + transitivePeerDependencies: + - supports-color + + /esbuild@0.17.19: + resolution: {integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.17.19 + '@esbuild/android-arm64': 0.17.19 + '@esbuild/android-x64': 0.17.19 + '@esbuild/darwin-arm64': 0.17.19 + '@esbuild/darwin-x64': 0.17.19 + '@esbuild/freebsd-arm64': 0.17.19 + '@esbuild/freebsd-x64': 0.17.19 + '@esbuild/linux-arm': 0.17.19 + '@esbuild/linux-arm64': 0.17.19 + '@esbuild/linux-ia32': 0.17.19 + '@esbuild/linux-loong64': 0.17.19 + '@esbuild/linux-mips64el': 0.17.19 + '@esbuild/linux-ppc64': 0.17.19 + '@esbuild/linux-riscv64': 0.17.19 + '@esbuild/linux-s390x': 0.17.19 + '@esbuild/linux-x64': 0.17.19 + '@esbuild/netbsd-x64': 0.17.19 + '@esbuild/openbsd-x64': 0.17.19 + '@esbuild/sunos-x64': 0.17.19 + '@esbuild/win32-arm64': 0.17.19 + '@esbuild/win32-ia32': 0.17.19 + '@esbuild/win32-x64': 0.17.19 + dev: true + + /esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + /esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + dev: true + + /escalade@3.1.1: + resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} + engines: {node: '>=6'} + + /escalade@3.1.2: + resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} + engines: {node: '>=6'} + dev: false + + /escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + /escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + /escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + dev: true + + /eslint-config-typestrict@1.0.5(@typescript-eslint/eslint-plugin@5.61.0)(eslint-plugin-sonarjs@0.19.0): + resolution: {integrity: sha512-6W48TD8kXMpj9lUTBoDWFKI+qRpgPQPKy9NPIf2cP56HiT6RBO9g7uvApvvl0DtfmAKP1kXbbI+Mg6xVROrXZA==} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^5 + eslint-plugin-sonarjs: '*' + dependencies: + '@typescript-eslint/eslint-plugin': 5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.0.2) + eslint-plugin-sonarjs: 0.19.0(eslint@8.44.0) + dev: true + + /eslint-import-resolver-node@0.3.7: + resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} + dependencies: + debug: 3.2.7 + is-core-module: 2.12.1 + resolve: 1.22.2 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-module-utils@2.8.0(@typescript-eslint/parser@5.61.0)(eslint-import-resolver-node@0.3.7)(eslint@8.44.0): + resolution: {integrity: sha512-aWajIYfsqCKRDgUfjEXNN/JlrzauMuSEy5sbd7WXbtW3EH6A6MpwEh42c7qD+MqQo9QMJ6fWLAeIJynx0g6OAw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + dependencies: + '@typescript-eslint/parser': 5.61.0(eslint@8.44.0)(typescript@5.0.2) + debug: 3.2.7 + eslint: 8.44.0 + eslint-import-resolver-node: 0.3.7 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.61.0)(eslint@8.44.0): + resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + dependencies: + '@typescript-eslint/parser': 5.61.0(eslint@8.44.0)(typescript@5.0.2) + array-includes: 3.1.6 + array.prototype.flat: 1.3.1 + array.prototype.flatmap: 1.3.1 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.44.0 + eslint-import-resolver-node: 0.3.7 + eslint-module-utils: 2.8.0(@typescript-eslint/parser@5.61.0)(eslint-import-resolver-node@0.3.7)(eslint@8.44.0) + has: 1.0.3 + is-core-module: 2.12.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.values: 1.1.6 + resolve: 1.22.2 + semver: 6.3.1 + tsconfig-paths: 3.14.2 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + + /eslint-plugin-no-only-tests@3.1.0: + resolution: {integrity: sha512-Lf4YW/bL6Un1R6A76pRZyE1dl1vr31G/ev8UzIc/geCgFWyrKil8hVjYqWVKGB/UIGmb6Slzs9T0wNezdSVegw==} + engines: {node: '>=5.0.0'} + dev: true + + /eslint-plugin-react-hooks@4.6.0(eslint@8.44.0): + resolution: {integrity: sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + dependencies: + eslint: 8.44.0 + dev: true + + /eslint-plugin-react-refresh@0.4.1(eslint@8.44.0): + resolution: {integrity: sha512-QgrvtRJkmV+m4w953LS146+6RwEe5waouubFVNLBfOjXJf6MLczjymO8fOcKj9jMS8aKkTCMJqiPu2WEeFI99A==} + peerDependencies: + eslint: '>=7' + dependencies: + eslint: 8.44.0 + dev: true + + /eslint-plugin-react@7.33.2(eslint@8.44.0): + resolution: {integrity: sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 + dependencies: + array-includes: 3.1.6 + array.prototype.flatmap: 1.3.1 + array.prototype.tosorted: 1.1.2 + doctrine: 2.1.0 + es-iterator-helpers: 1.0.15 + eslint: 8.44.0 + estraverse: 5.3.0 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.7 + object.fromentries: 2.0.7 + object.hasown: 1.1.3 + object.values: 1.1.6 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.10 + dev: true + + /eslint-plugin-simple-import-sort@10.0.0(eslint@8.44.0): + resolution: {integrity: sha512-AeTvO9UCMSNzIHRkg8S6c3RPy5YEwKWSQPx3DYghLedo2ZQxowPFLGDN1AZ2evfg6r6mjBSZSLxLFsWSu3acsw==} + peerDependencies: + eslint: '>=5.0.0' + dependencies: + eslint: 8.44.0 + dev: true + + /eslint-plugin-sonarjs@0.19.0(eslint@8.44.0): + resolution: {integrity: sha512-6+s5oNk5TFtVlbRxqZN7FIGmjdPCYQKaTzFPmqieCmsU1kBYDzndTeQav0xtQNwZJWu5awWfTGe8Srq9xFOGnw==} + engines: {node: '>=14'} + peerDependencies: + eslint: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + eslint: 8.44.0 + dev: true + + /eslint-plugin-unused-imports@2.0.0(@typescript-eslint/eslint-plugin@5.61.0)(eslint@8.44.0): + resolution: {integrity: sha512-3APeS/tQlTrFa167ThtP0Zm0vctjr4M44HMpeg1P4bK6wItarumq0Ma82xorMKdFsWpphQBlRPzw/pxiVELX1A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/eslint-plugin': ^5.0.0 + eslint: ^8.0.0 + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + dependencies: + '@typescript-eslint/eslint-plugin': 5.61.0(@typescript-eslint/parser@5.61.0)(eslint@8.44.0)(typescript@5.0.2) + eslint: 8.44.0 + eslint-rule-composer: 0.3.0 + dev: true + + /eslint-rule-composer@0.3.0: + resolution: {integrity: sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==} + engines: {node: '>=4.0.0'} + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.2.1: + resolution: {integrity: sha512-CvefSOsDdaYYvxChovdrPo/ZGt8d5lrJWleAc1diXRKhHGiTYEI26cvo8Kle/wGnsizoCJjK73FMg1/IkIwiNA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.1: + resolution: {integrity: sha512-pZnmmLwYzf+kWaM/Qgrvpen51upAktaaiI01nsJD/Yr3lMOdNtq0cxkrrg16w64VtisN6okbs7Q8AfGqj4c9fA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.44.0: + resolution: {integrity: sha512-0wpHoUbDUHgNCyvFB5aXLiQVfK9B0at6gUvzy83k4kAsQ/u769TQDX6iKC+aO4upIHO9WSaA3QoXYQDHbNwf1A==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.44.0) + '@eslint-community/regexpp': 4.5.1 + '@eslint/eslintrc': 2.1.0 + '@eslint/js': 8.44.0 + '@humanwhocodes/config-array': 0.11.10 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.4(supports-color@8.1.1) + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.1 + eslint-visitor-keys: 3.4.1 + espree: 9.6.1 + esquery: 1.5.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.20.0 + graphemer: 1.4.0 + ignore: 5.2.4 + import-fresh: 3.3.0 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.3 + strip-ansi: 6.0.1 + strip-json-comments: 3.1.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.11.2 + acorn-jsx: 5.3.2(acorn@8.11.2) + eslint-visitor-keys: 3.4.1 + dev: true + + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + /esquery@1.5.0: + resolution: {integrity: sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + /etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + /eth-block-tracker@7.1.0: + resolution: {integrity: sha512-8YdplnuE1IK4xfqpf4iU7oBxnOYAc35934o083G8ao+8WM8QQtt/mVlAY6yIAdY1eMeLqg4Z//PZjJGmWGPMRg==} + engines: {node: '>=14.0.0'} + dependencies: + '@metamask/eth-json-rpc-provider': 1.0.1 + '@metamask/safe-event-emitter': 3.0.0 + '@metamask/utils': 5.0.2 + json-rpc-random-id: 1.0.1 + pify: 3.0.0 + transitivePeerDependencies: + - supports-color + dev: false + + /eth-json-rpc-filters@6.0.1: + resolution: {integrity: sha512-ITJTvqoCw6OVMLs7pI8f4gG92n/St6x80ACtHodeS+IXmO0w+t1T5OOzfSt7KLSMLRkVUoexV7tztLgDxg+iig==} + engines: {node: '>=14.0.0'} + dependencies: + '@metamask/safe-event-emitter': 3.0.0 + async-mutex: 0.2.6 + eth-query: 2.1.2 + json-rpc-engine: 6.1.0 + pify: 5.0.0 + dev: false + + /eth-query@2.1.2: + resolution: {integrity: sha512-srES0ZcvwkR/wd5OQBRA1bIJMww1skfGS0s8wlwK3/oNP4+wnds60krvu5R1QbpRQjMmpG5OMIWro5s7gvDPsA==} + dependencies: + json-rpc-random-id: 1.0.1 + xtend: 4.0.2 + dev: false + + /eth-rpc-errors@4.0.3: + resolution: {integrity: sha512-Z3ymjopaoft7JDoxZcEb3pwdGh7yiYMhOwm2doUt6ASXlMavpNlK6Cre0+IMl2VSGyEU9rkiperQhp5iRxn5Pg==} + dependencies: + fast-safe-stringify: 2.1.1 + dev: false + + /ethereum-cryptography@0.1.3: + resolution: {integrity: sha512-w8/4x1SGGzc+tO97TASLja6SLd3fRIK2tLVcV2Gx4IB21hE19atll5Cq9o3d0ZmAYC/8aw0ipieTSiekAea4SQ==} + dependencies: + '@types/pbkdf2': 3.1.2 + '@types/secp256k1': 4.0.6 + blakejs: 1.2.1 + browserify-aes: 1.2.0 + bs58check: 2.1.2 + create-hash: 1.2.0 + create-hmac: 1.1.7 + hash.js: 1.1.7 + keccak: 3.0.4 + pbkdf2: 3.1.2 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + scrypt-js: 3.0.1 + secp256k1: 4.0.3 + setimmediate: 1.0.5 + dev: false + + /ethereum-cryptography@1.2.0: + resolution: {integrity: sha512-6yFQC9b5ug6/17CQpCyE3k9eKBMdhyVjzUy1WkiuY/E4vj/SXDBbCw8QEIaXqf0Mf2SnY6RmpDcwlUmBSS0EJw==} + dependencies: + '@noble/hashes': 1.2.0 + '@noble/secp256k1': 1.7.1 + '@scure/bip32': 1.1.5 + '@scure/bip39': 1.1.1 + dev: false + + /ethereum-cryptography@2.1.3: + resolution: {integrity: sha512-BlwbIL7/P45W8FGW2r7LGuvoEZ+7PWsniMvQ4p5s2xCyw9tmaDlpfsN9HjAucbF+t/qpVHwZUisgfK24TCW8aA==} + dependencies: + '@noble/curves': 1.3.0 + '@noble/hashes': 1.3.3 + '@scure/bip32': 1.3.3 + '@scure/bip39': 1.2.2 + dev: false + + /ethereumjs-abi@0.6.8: + resolution: {integrity: sha512-Tx0r/iXI6r+lRsdvkFDlut0N08jWMnKRZ6Gkq+Nmw75lZe4e6o3EkSnkaBP5NF6+m5PTGAr9JP43N3LyeoglsA==} + dependencies: + bn.js: 4.12.0 + ethereumjs-util: 6.2.1 + dev: false + + /ethereumjs-util@6.2.1: + resolution: {integrity: sha512-W2Ktez4L01Vexijrm5EB6w7dg4n/TgpoYU4avuT5T3Vmnw/eCRtiBrJfQYS/DCSvDIOLn2k57GcHdeBcgVxAqw==} + dependencies: + '@types/bn.js': 4.11.6 + bn.js: 4.12.0 + create-hash: 1.2.0 + elliptic: 6.5.5 + ethereum-cryptography: 0.1.3 + ethjs-util: 0.1.6 + rlp: 2.2.7 + dev: false + + /ethers@6.8.0: + resolution: {integrity: sha512-zrFbmQRlraM+cU5mE4CZTLBurZTs2gdp2ld0nG/f3ecBK+x6lZ69KSxBqZ4NjclxwfTxl5LeNufcBbMsTdY53Q==} + engines: {node: '>=14.0.0'} + dependencies: + '@adraffy/ens-normalize': 1.10.0 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 18.15.13 + aes-js: 4.0.0-beta.5 + tslib: 2.4.0 + ws: 8.5.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /ethjs-util@0.1.6: + resolution: {integrity: sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==} + engines: {node: '>=6.5.0', npm: '>=3'} + dependencies: + is-hex-prefixed: 1.0.0 + strip-hex-prefix: 1.0.0 + dev: false + + /event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + dev: false + + /eventemitter2@6.4.9: + resolution: {integrity: sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg==} + dev: false + + /eventemitter3@4.0.7: + resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + + /eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + dev: false + + /events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + + /evp_bytestokey@1.0.3: + resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + dependencies: + md5.js: 1.3.5 + safe-buffer: 5.2.1 + dev: false + + /execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + /execa@7.2.0: + resolution: {integrity: sha512-UduyVP7TLB5IcAQl+OzLyLcS/l32W/GLg+AhHJ+ow40FOk2U3SAllPwR44v4vmdFwIWqpdwxxpQbF1n5ta9seA==} + engines: {node: ^14.18.0 || ^16.14.0 || >=18.0.0} + dependencies: + cross-spawn: 7.0.3 + get-stream: 6.0.1 + human-signals: 4.3.1 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.1.0 + onetime: 6.0.0 + signal-exit: 3.0.7 + strip-final-newline: 3.0.0 + dev: false + + /execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.1.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + dev: true + + /expect@29.6.1: + resolution: {integrity: sha512-XEdDLonERCU1n9uR56/Stx9OqojaLAQtZf9PrCHH9Hl8YXiEIka3H4NXJ3NOIBmQJTg7+j7buh34PMHfJujc8g==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/expect-utils': 29.6.1 + '@types/node': 20.8.9 + jest-get-type: 29.6.3 + jest-matcher-utils: 29.6.1 + jest-message-util: 29.6.1 + jest-util: 29.6.1 + dev: true + + /express@4.18.2: + resolution: {integrity: sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==} + engines: {node: '>= 0.10.0'} + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.1 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.5.0 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.2.0 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.1 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.7 + proxy-addr: 2.0.7 + qs: 6.11.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.18.0 + serve-static: 1.15.0 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + dev: true + + /extension-port-stream@2.1.1: + resolution: {integrity: sha512-qknp5o5rj2J9CRKfVB8KJr+uXQlrojNZzdESUPhKYLXf97TPcGf6qWWKmpsNNtUyOdzFhab1ON0jzouNxHHvow==} + engines: {node: '>=12.0.0'} + dependencies: + webextension-polyfill: 0.10.0 + dev: false + + /external-editor@3.1.0: + resolution: {integrity: sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==} + engines: {node: '>=4'} + dependencies: + chardet: 0.7.0 + iconv-lite: 0.4.24 + tmp: 0.0.33 + dev: true + + /extract-files@9.0.0: + resolution: {integrity: sha512-CvdFfHkC95B4bBBk36hcEmvdR2awOdhhVUYH6S/zrVj3477zven/fJMYg7121h4T1xHZC+tetUpubpAhxwI7hQ==} + engines: {node: ^10.17.0 || ^12.0.0 || >= 13.7.0} + dev: false + + /extract-zip@1.7.0: + resolution: {integrity: sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==} + hasBin: true + dependencies: + concat-stream: 1.6.2 + debug: 2.6.9 + mkdirp: 0.5.6 + yauzl: 2.10.0 + transitivePeerDependencies: + - supports-color + dev: true + + /fast-deep-equal@2.0.1: + resolution: {integrity: sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w==} + dev: false + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + dev: true + + /fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.5 + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fast-redact@3.3.0: + resolution: {integrity: sha512-6T5V1QK1u4oF+ATxs1lWUmlEk6P2T9HqJG3e2DnHOdVgZy2rFJBoEnrIedcTXlkAHU/zKC+7KETJ+KGGKwxgMQ==} + engines: {node: '>=6'} + dev: false + + /fast-safe-stringify@2.1.1: + resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + dev: false + + /fast-url-parser@1.1.3: + resolution: {integrity: sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ==} + dependencies: + punycode: 1.4.1 + dev: true + + /fast-xml-parser@4.3.6: + resolution: {integrity: sha512-M2SovcRxD4+vC493Uc2GZVcZaj66CCJhWurC4viynVSTvrpErCShNcDz1lAho6n9REQKvL/ll4A4/fw6Y9z8nw==} + hasBin: true + dependencies: + strnum: 1.0.5 + dev: false + + /fastq@1.15.0: + resolution: {integrity: sha512-wBrocU2LCXXa+lWBt8RoIRD89Fi8OdABODa/kEnyeyjS5aZO5/GNvI5sEINADqP/h8M29UHTHUb53sUu5Ihqdw==} + dependencies: + reusify: 1.0.4 + + /fb-watchman@2.0.2: + resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + dependencies: + bser: 2.1.1 + + /fd-slicer@1.1.0: + resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==} + dependencies: + pend: 1.2.0 + dev: true + + /fetch-retry@5.0.6: + resolution: {integrity: sha512-3yurQZ2hD9VISAhJJP9bpYFNQrHHBXE2JxxjY5aLEcDi46RmAzJE2OC9FAde0yis5ElW0jTTzs0zfg/Cca4XqQ==} + + /figures@3.2.0: + resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} + engines: {node: '>=8'} + dependencies: + escape-string-regexp: 1.0.5 + dev: true + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.0.4 + dev: true + + /file-system-cache@2.3.0: + resolution: {integrity: sha512-l4DMNdsIPsVnKrgEXbJwDJsA5mB8rGwHYERMgqQx/xAUtChPJMre1bXBzDEqqVbWv9AIbFezXMxeEkZDSrXUOQ==} + dependencies: + fs-extra: 11.1.1 + ramda: 0.29.0 + + /filelist@1.0.4: + resolution: {integrity: sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==} + dependencies: + minimatch: 5.1.6 + dev: true + + /fill-range@7.0.1: + resolution: {integrity: sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + + /filter-obj@1.1.0: + resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} + engines: {node: '>=0.10.0'} + dev: false + + /finalhandler@1.1.2: + resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} + engines: {node: '>= 0.8'} + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.3.0 + parseurl: 1.3.3 + statuses: 1.5.0 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: false + + /finalhandler@1.2.0: + resolution: {integrity: sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==} + engines: {node: '>= 0.8'} + dependencies: + debug: 2.6.9 + encodeurl: 1.0.2 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /find-cache-dir@2.1.0: + resolution: {integrity: sha512-Tq6PixE0w/VMFfCgbONnkiQIVol/JJL7nRMi20fqzA4NRs9AfeqMGeRdPi3wIhYkxjeBaWh2rxwapn5Tu3IqOQ==} + engines: {node: '>=6'} + dependencies: + commondir: 1.0.1 + make-dir: 2.1.0 + pkg-dir: 3.0.0 + + /find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + + /find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + dev: false + + /find-up@2.1.0: + resolution: {integrity: sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ==} + engines: {node: '>=4'} + dependencies: + locate-path: 2.0.0 + dev: false + + /find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + dependencies: + locate-path: 3.0.0 + + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + /find-up@6.3.0: + resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + locate-path: 7.2.0 + path-exists: 5.0.0 + dev: true + + /flat-cache@3.0.4: + resolution: {integrity: sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.2.7 + rimraf: 3.0.2 + dev: true + + /flat@5.0.2: + resolution: {integrity: sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==} + hasBin: true + dev: false + + /flatted@3.2.7: + resolution: {integrity: sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==} + dev: true + + /flow-enums-runtime@0.0.6: + resolution: {integrity: sha512-3PYnM29RFXwvAN6Pc/scUfkI7RwhQ/xqyLUyPNlXUp9S40zI8nup9tUSrTLSVnWGBN38FNiGWbwZOB6uR4OGdw==} + dev: false + + /flow-parser@0.206.0: + resolution: {integrity: sha512-HVzoK3r6Vsg+lKvlIZzaWNBVai+FXTX1wdYhz/wVlH13tb/gOdLXmlTqy6odmTBhT5UoWUbq0k8263Qhr9d88w==} + engines: {node: '>=0.4.0'} + dev: false + + /flow-parser@0.212.0: + resolution: {integrity: sha512-45eNySEs7n692jLN+eHQ6zvC9e1cqu9Dq1PpDHTcWRri2HFEs8is8Anmp1RcIhYxA5TZYD6RuESG2jdj6nkDJQ==} + engines: {node: '>=0.4.0'} + + /follow-redirects@1.15.3: + resolution: {integrity: sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + /follow-redirects@1.15.6(debug@4.3.4): + resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + dependencies: + debug: 4.3.4(supports-color@8.1.1) + dev: false + + /for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + dependencies: + is-callable: 1.2.7 + + /foreground-child@3.1.1: + resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + + /form-data@3.0.1: + resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + + /form-data@4.0.0: + resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} + engines: {node: '>= 6'} + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + mime-types: 2.1.35 + dev: true + + /forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + dev: true + + /fp-ts@1.19.3: + resolution: {integrity: sha512-H5KQDspykdHuztLTg+ajGN0Z2qUjcEf3Ybxc6hLt0k7/zPkn29XnKnxlBPyW2XIddWrGaJBzBl4VLYOtk39yZg==} + dev: false + + /fraction.js@4.2.0: + resolution: {integrity: sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA==} + dev: true + + /fresh@0.5.2: + resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=} + engines: {node: '>= 0.6'} + + /front-matter@4.0.2: + resolution: {integrity: sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==} + dependencies: + js-yaml: 3.14.1 + dev: false + + /fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + dev: true + + /fs-extra@0.30.0: + resolution: {integrity: sha512-UvSPKyhMn6LEd/WpUaV9C9t3zATuqoqfWc3QdPhPLb58prN9tqYPlPWi8Krxi44loBoUzlobqZ3+8tGpxxSzwA==} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 2.4.0 + klaw: 1.3.1 + path-is-absolute: 1.0.1 + rimraf: 2.7.1 + dev: false + + /fs-extra@11.1.1: + resolution: {integrity: sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.0 + + /fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + dev: false + + /fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + dev: false + + /fs-minipass@2.1.0: + resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + + /fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + optional: true + + /function-bind@1.1.1: + resolution: {integrity: sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==} + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + /function.prototype.name@1.1.5: + resolution: {integrity: sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + functions-have-names: 1.2.3 + dev: true + + /functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + dev: true + + /futoin-hkdf@1.5.3: + resolution: {integrity: sha512-SewY5KdMpaoCeh7jachEWFsh1nNlaDjNHZXWqL5IGwtpEYHTgkr2+AMCgNwKWkcc0wpSYrZfR7he4WdmHFtDxQ==} + engines: {node: '>=8'} + dev: false + + /gauge@3.0.2: + resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==} + engines: {node: '>=10'} + dependencies: + aproba: 2.0.0 + color-support: 1.1.3 + console-control-strings: 1.1.0 + has-unicode: 2.0.1 + object-assign: 4.1.1 + signal-exit: 3.0.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wide-align: 1.1.5 + dev: true + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: false + + /get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + dev: true + + /get-intrinsic@1.2.1: + resolution: {integrity: sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==} + dependencies: + function-bind: 1.1.2 + has: 1.0.3 + has-proto: 1.0.1 + has-symbols: 1.0.3 + + /get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + /get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + /get-npm-tarball-url@2.0.3: + resolution: {integrity: sha512-R/PW6RqyaBQNWYaSyfrh54/qtcnOp22FHCCiRhSSZj0FP3KQWCsxxt0DzIdVTbwTqe9CtQfvl/FPD4UIPt4pqw==} + engines: {node: '>=12.17'} + dev: true + + /get-package-type@0.1.0: + resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} + engines: {node: '>=8.0.0'} + dev: true + + /get-port-please@3.1.1: + resolution: {integrity: sha512-3UBAyM3u4ZBVYDsxOQfJDxEa6XTbpBDrOjp4mf7ExFRt5BKs/QywQQiJsh2B+hxcZLSapWqCRvElUe8DnKcFHA==} + dev: false + + /get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + dev: true + + /get-port@6.1.2: + resolution: {integrity: sha512-BrGGraKm2uPqurfGVj/z97/zv8dPleC6x9JBNRTrDNtCkkRF4rPwrQXFgL7+I+q8QSdU4ntLQX2D7KIxSy8nGw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + + /get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + /get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + dev: true + + /get-symbol-description@1.0.0: + resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + dev: true + + /giget@1.1.2: + resolution: {integrity: sha512-HsLoS07HiQ5oqvObOI+Qb2tyZH4Gj5nYGfF9qQcZNrPw+uEFhdXtgJr01aO2pWadGHucajYDLxxbtQkm97ON2A==} + hasBin: true + dependencies: + colorette: 2.0.20 + defu: 6.1.2 + https-proxy-agent: 5.0.1 + mri: 1.2.0 + node-fetch-native: 1.2.0 + pathe: 1.1.1 + tar: 6.1.15 + transitivePeerDependencies: + - supports-color + dev: true + + /github-slugger@1.5.0: + resolution: {integrity: sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==} + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + + /glob-promise@4.2.2(glob@7.2.3): + resolution: {integrity: sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw==} + engines: {node: '>=12'} + peerDependencies: + glob: ^7.1.6 + dependencies: + '@types/glob': 7.2.0 + glob: 7.2.3 + dev: true + + /glob-promise@6.0.3(glob@8.1.0): + resolution: {integrity: sha512-m+kxywR5j/2Z2V9zvHKfwwL5Gp7gIFEBX+deTB9w2lJB+wSuw9kcS43VfvTAMk8TXL5JCl/cCjsR+tgNVspGyA==} + engines: {node: '>=16'} + peerDependencies: + glob: ^8.0.3 + dependencies: + '@types/glob': 8.1.0 + glob: 8.1.0 + dev: true + + /glob-to-regexp@0.4.1: + resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} + dev: true + + /glob@10.3.12: + resolution: {integrity: sha512-TCNv8vJ+xz4QiqTpfOJA7HvYv+tNIRHKfUWw/q+v2jdgN4ebz+KY9tGx5J4rHP0o84mNP+ApH66HRX8us3Khqg==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.3.6 + minimatch: 9.0.4 + minipass: 7.0.4 + path-scurry: 1.10.2 + + /glob@10.3.3: + resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + foreground-child: 3.1.1 + jackspeak: 2.2.2 + minimatch: 9.0.3 + minipass: 5.0.0 + path-scurry: 1.10.1 + + /glob@7.2.0: + resolution: {integrity: sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: false + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + + /glob@8.1.0: + resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} + engines: {node: '>=12'} + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 5.1.6 + once: 1.4.0 + + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + /globals@13.20.0: + resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globalthis@1.0.3: + resolution: {integrity: sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.0 + dev: true + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /globby@13.2.2: + resolution: {integrity: sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.2.4 + merge2: 1.4.1 + slash: 4.0.0 + + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + + /goober@2.1.13(csstype@3.1.3): + resolution: {integrity: sha512-jFj3BQeleOoy7t93E9rZ2de+ScC4lQICLwiAQmKMg9F6roKGaLSHoCDYKkWlSafg138jejvq/mTdvmnwDQgqoQ==} + peerDependencies: + csstype: ^3.0.10 + dependencies: + csstype: 3.1.3 + dev: false + + /gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + dependencies: + get-intrinsic: 1.2.1 + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + /graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + dev: true + + /graphql-request@5.2.0(graphql@16.8.1): + resolution: {integrity: sha512-pLhKIvnMyBERL0dtFI3medKqWOz/RhHdcgbZ+hMMIb32mEPa5MJSzS4AuXxfI4sRAu6JVVk5tvXuGfCWl9JYWQ==} + peerDependencies: + graphql: 14 - 16 + dependencies: + '@graphql-typed-document-node/core': 3.2.0(graphql@16.8.1) + cross-fetch: 3.1.8 + extract-files: 9.0.0 + form-data: 3.0.1 + graphql: 16.8.1 + transitivePeerDependencies: + - encoding + dev: false + + /graphql@16.8.1: + resolution: {integrity: sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + dev: false + + /gunzip-maybe@1.4.2: + resolution: {integrity: sha512-4haO1M4mLO91PW57BMsDFf75UmwoRX0GkdD+Faw+Lr+r/OZrOCS0pIBwOL1xCKQqnQzbNFGgK2V2CpBUPeFNTw==} + hasBin: true + dependencies: + browserify-zlib: 0.1.4 + is-deflate: 1.0.0 + is-gzip: 1.0.0 + peek-stream: 1.1.3 + pumpify: 1.5.1 + through2: 2.0.5 + dev: true + + /h3@1.9.0: + resolution: {integrity: sha512-+F3ZqrNV/CFXXfZ2lXBINHi+rM4Xw3CDC5z2CDK3NMPocjonKipGLLDSkrqY9DOrioZNPTIdDMWfQKm//3X2DA==} + dependencies: + cookie-es: 1.0.0 + defu: 6.1.3 + destr: 2.0.2 + iron-webcrypto: 1.0.0 + radix3: 1.1.0 + ufo: 1.5.3 + uncrypto: 0.1.3 + unenv: 1.8.0 + dev: false + + /handlebars@4.7.7: + resolution: {integrity: sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==} + engines: {node: '>=0.4.7'} + hasBin: true + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.17.4 + + /hardhat-watcher@2.5.0(hardhat@2.22.2): + resolution: {integrity: sha512-Su2qcSMIo2YO2PrmJ0/tdkf+6pSt8zf9+4URR5edMVti6+ShI8T3xhPrwugdyTOFuyj8lKHrcTZNKUFYowYiyA==} + peerDependencies: + hardhat: ^2.0.0 + dependencies: + chokidar: 3.6.0 + hardhat: 2.22.2(typescript@5.4.5) + dev: false + + /hardhat@2.22.2(typescript@5.4.5): + resolution: {integrity: sha512-0xZ7MdCZ5sJem4MrvpQWLR3R3zGDoHw5lsR+pBFimqwagimIOn3bWuZv69KA+veXClwI1s/zpqgwPwiFrd4Dxw==} + hasBin: true + peerDependencies: + ts-node: '*' + typescript: '*' + peerDependenciesMeta: + ts-node: + optional: true + typescript: + optional: true + dependencies: + '@ethersproject/abi': 5.7.0 + '@metamask/eth-sig-util': 4.0.1 + '@nomicfoundation/edr': 0.3.4 + '@nomicfoundation/ethereumjs-common': 4.0.4 + '@nomicfoundation/ethereumjs-tx': 5.0.4 + '@nomicfoundation/ethereumjs-util': 9.0.4 + '@nomicfoundation/solidity-analyzer': 0.1.1 + '@sentry/node': 5.30.0 + '@types/bn.js': 5.1.5 + '@types/lru-cache': 5.1.1 + adm-zip: 0.4.16 + aggregate-error: 3.1.0 + ansi-escapes: 4.3.2 + boxen: 5.1.2 + chalk: 2.4.2 + chokidar: 3.6.0 + ci-info: 2.0.0 + debug: 4.3.4(supports-color@8.1.1) + enquirer: 2.4.1 + env-paths: 2.2.1 + ethereum-cryptography: 1.2.0 + ethereumjs-abi: 0.6.8 + find-up: 2.1.0 + fp-ts: 1.19.3 + fs-extra: 7.0.1 + glob: 7.2.0 + immutable: 4.3.5 + io-ts: 1.10.4 + keccak: 3.0.4 + lodash: 4.17.21 + mnemonist: 0.38.5 + mocha: 10.4.0 + p-map: 4.0.0 + raw-body: 2.5.2 + resolve: 1.17.0 + semver: 6.3.1 + solc: 0.7.3(debug@4.3.4) + source-map-support: 0.5.21 + stacktrace-parser: 0.1.10 + tsort: 0.0.1 + typescript: 5.4.5 + undici: 5.28.4 + uuid: 8.3.2 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - c-kzg + - supports-color + - utf-8-validate + dev: false + + /has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + dev: true + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + /has-property-descriptors@1.0.0: + resolution: {integrity: sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==} + dependencies: + get-intrinsic: 1.2.1 + dev: true + + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.0 + + /has-proto@1.0.1: + resolution: {integrity: sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==} + engines: {node: '>= 0.4'} + + /has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + /has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + /has-tostringtag@1.0.0: + resolution: {integrity: sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + + /has-unicode@2.0.1: + resolution: {integrity: sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==} + dev: true + + /has@1.0.3: + resolution: {integrity: sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==} + engines: {node: '>= 0.4.0'} + dependencies: + function-bind: 1.1.1 + + /hash-base@3.1.0: + resolution: {integrity: sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==} + engines: {node: '>=4'} + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + safe-buffer: 5.2.1 + dev: false + + /hash.js@1.1.7: + resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + dev: false + + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + + /he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + /header-case@2.0.4: + resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} + dependencies: + capital-case: 1.0.4 + tslib: 2.6.2 + dev: true + + /hermes-estree@0.15.0: + resolution: {integrity: sha512-lLYvAd+6BnOqWdnNbP/Q8xfl8LOGw4wVjfrNd9Gt8eoFzhNBRVD95n4l2ksfMVOoxuVyegs85g83KS9QOsxbVQ==} + dev: false + + /hermes-estree@0.20.1: + resolution: {integrity: sha512-SQpZK4BzR48kuOg0v4pb3EAGNclzIlqMj3Opu/mu7bbAoFw6oig6cEt/RAi0zTFW/iW6Iz9X9ggGuZTAZ/yZHg==} + dev: false + + /hermes-parser@0.15.0: + resolution: {integrity: sha512-Q1uks5rjZlE9RjMMjSUCkGrEIPI5pKJILeCtK1VmTj7U4pf3wVPoo+cxfu+s4cBAPy2JzikIIdCZgBoR6x7U1Q==} + dependencies: + hermes-estree: 0.15.0 + dev: false + + /hermes-parser@0.20.1: + resolution: {integrity: sha512-BL5P83cwCogI8D7rrDCgsFY0tdYUtmFP9XaXtl2IQjC+2Xo+4okjfXintlTxcIwl4qeGddEl28Z11kbVIw0aNA==} + dependencies: + hermes-estree: 0.20.1 + dev: false + + /hermes-profile-transformer@0.0.6: + resolution: {integrity: sha512-cnN7bQUm65UWOy6cbGcCcZ3rpwW8Q/j4OP5aWRhEry4Z2t2aR1cjrbp0BS+KiBN0smvP1caBgAuxutvyvJILzQ==} + engines: {node: '>=8'} + dependencies: + source-map: 0.7.4 + dev: false + + /hey-listen@1.0.8: + resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==} + dev: false + + /hmac-drbg@1.0.1: + resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} + dependencies: + hash.js: 1.1.7 + minimalistic-assert: 1.0.1 + minimalistic-crypto-utils: 1.0.1 + dev: false + + /hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 + dev: false + + /hosted-git-info@2.8.9: + resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + dev: true + + /html-encoding-sniffer@3.0.0: + resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} + engines: {node: '>=12'} + dependencies: + whatwg-encoding: 2.0.0 + dev: true + + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + + /html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + dependencies: + void-elements: 3.1.0 + dev: false + + /html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + dev: true + + /http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + + /http-proxy-agent@5.0.0: + resolution: {integrity: sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==} + engines: {node: '>= 6'} + dependencies: + '@tootallnate/once': 2.0.0 + agent-base: 6.0.2 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + + /http-proxy@1.18.1: + resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} + engines: {node: '>=8.0.0'} + dependencies: + eventemitter3: 4.0.7 + follow-redirects: 1.15.3 + requires-port: 1.0.0 + transitivePeerDependencies: + - debug + + /http-server@14.1.1: + resolution: {integrity: sha512-+cbxadF40UXd9T01zUHgA+rlo2Bg1Srer4+B4NwIHdaGxAGGv59nYRnGGDJ9LBk7alpS0US+J+bLLdQOOkJq4A==} + engines: {node: '>=12'} + hasBin: true + dependencies: + basic-auth: 2.0.1 + chalk: 4.1.2 + corser: 2.0.1 + he: 1.2.0 + html-encoding-sniffer: 3.0.0 + http-proxy: 1.18.1 + mime: 1.6.0 + minimist: 1.2.8 + opener: 1.5.2 + portfinder: 1.0.32 + secure-compare: 3.0.1 + union: 0.5.0 + url-join: 4.0.1 + transitivePeerDependencies: + - debug + - supports-color + dev: true + + /http-shutdown@1.2.2: + resolution: {integrity: sha512-S9wWkJ/VSY9/k4qcjG318bqJNruzE4HySUhFYknwmu6LBP97KLLfwNf+n4V1BHurvFNkSKLFnK/RsuUnRTf9Vw==} + engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} + dev: false + + /https-proxy-agent@4.0.0: + resolution: {integrity: sha512-zoDhWrkR3of1l9QAL8/scJZyLu8j/gBkcwcaQOZh7Gyh/+uJQzGVETdgT30akuwkpL8HTRfssqI3BZuV18teDg==} + engines: {node: '>= 6.0.0'} + dependencies: + agent-base: 5.1.1 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: true + + /https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + dependencies: + agent-base: 6.0.2 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + + /human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + /human-signals@4.3.1: + resolution: {integrity: sha512-nZXjEF2nbo7lIw3mgYjItAfgQXog3OjJogSbKa2CQIIvSGWcKgeJnQlNXip6NglNzYH45nSRiEVimMvYL8DDqQ==} + engines: {node: '>=14.18.0'} + dev: false + + /human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + dev: true + + /i18next-browser-languagedetector@7.2.0: + resolution: {integrity: sha512-U00DbDtFIYD3wkWsr2aVGfXGAj2TgnELzOX9qv8bT0aJtvPV9CRO77h+vgmHFBMe7LAxdwvT/7VkCWGya6L3tA==} + dependencies: + '@babel/runtime': 7.24.4 + dev: false + + /i18next@22.5.1: + resolution: {integrity: sha512-8TGPgM3pAD+VRsMtUMNknRz3kzqwp/gPALrWMsDnmC1mKqJwpWyooQRLMcbTwq8z8YwSmuj+ZYvc+xCuEpkssA==} + dependencies: + '@babel/runtime': 7.24.4 + dev: false + + /iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + + /iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + + /icss-utils@5.1.0(postcss@8.4.31): + resolution: {integrity: sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.31 + dev: true + + /idb-keyval@6.2.1: + resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + dev: false + + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + /ignore@5.2.4: + resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==} + engines: {node: '>= 4'} + + /image-size@0.5.5: + resolution: {integrity: sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==} + engines: {node: '>=0.10.0'} + hasBin: true + requiresBuild: true + dev: true + optional: true + + /image-size@1.1.1: + resolution: {integrity: sha512-541xKlUw6jr/6gGuk92F+mYM5zaFAc5ahphvkqvNe2bQ6gVBkd6bfrmVJ2t4KDAfikAYZyIqTnktX3i6/aQDrQ==} + engines: {node: '>=16.x'} + hasBin: true + dependencies: + queue: 6.0.2 + dev: false + + /immutable@4.3.5: + resolution: {integrity: sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==} + dev: false + + /import-fresh@2.0.0: + resolution: {integrity: sha512-eZ5H8rcgYazHbKC3PG4ClHNykCSxtAhxSSEM+2mb+7evD2CKF5V7c0dNum7AdpDh0ZdICwZY9sRSn8f+KH96sg==} + engines: {node: '>=4'} + dependencies: + caller-path: 2.0.0 + resolve-from: 3.0.0 + dev: false + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: true + + /inquirer@7.3.3: + resolution: {integrity: sha512-JG3eIAj5V9CwcGvuOmoo6LB9kbAYT8HXffUl6memuszlwDC/qvFAJw49XJ5NROSFNPxp3iQg1GqkFhaY/CR0IA==} + engines: {node: '>=8.0.0'} + dependencies: + ansi-escapes: 4.3.2 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-width: 3.0.0 + external-editor: 3.1.0 + figures: 3.2.0 + lodash: 4.17.21 + mute-stream: 0.0.8 + run-async: 2.4.1 + rxjs: 6.6.7 + string-width: 4.2.3 + strip-ansi: 6.0.1 + through: 2.3.8 + dev: true + + /internal-slot@1.0.5: + resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.1 + has: 1.0.3 + side-channel: 1.0.4 + dev: true + + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + + /io-ts@1.10.4: + resolution: {integrity: sha512-b23PteSnYXSONJ6JQXRAlvJhuw8KOtkqa87W4wDtvMrud/DTJd5X+NpOOI+O/zZwVq6v0VLAaJ+1EDViKEuN9g==} + dependencies: + fp-ts: 1.19.3 + dev: false + + /ioredis@5.3.2: + resolution: {integrity: sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.3.4(supports-color@8.1.1) + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: false + + /ip@2.0.0: + resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==} + dev: true + + /ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + dev: true + + /iron-webcrypto@1.0.0: + resolution: {integrity: sha512-anOK1Mktt8U1Xi7fCM3RELTuYbnFikQY5VtrDj7kPgpejV7d43tWKhzgioO0zpkazLEL/j/iayRqnJhrGfqUsg==} + dev: false + + /is-absolute-url@3.0.3: + resolution: {integrity: sha512-opmNIX7uFnS96NtPmhWQgQx6/NYFgsUXYMllcfzwWKUMwfo8kku1TvE6hkNcH+Q1ts5cMVrsY7j0bxXQDciu9Q==} + engines: {node: '>=8'} + dev: true + + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + + /is-array-buffer@3.0.2: + resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + is-typed-array: 1.1.10 + dev: true + + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + /is-async-function@2.0.0: + resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + + /is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + dependencies: + has-bigints: 1.0.2 + dev: true + + /is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + dependencies: + binary-extensions: 2.3.0 + + /is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: true + + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + /is-core-module@2.12.1: + resolution: {integrity: sha512-Q4ZuBAe2FUsKtyQJoQHlvP8OvBERxO3jEmy1I7hcRXcJBGGHFh/aJBswbXuS9sgrDH2QUO8ilkwNPHvHMd8clg==} + dependencies: + has: 1.0.3 + dev: true + + /is-core-module@2.13.1: + resolution: {integrity: sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==} + dependencies: + hasown: 2.0.2 + + /is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + + /is-deflate@1.0.0: + resolution: {integrity: sha512-YDoFpuZWu1VRXlsnlYMzKyVRITXj7Ej/V9gXQ2/pAe7X1J7M/RNOqaIYi6qUn+B7nGyB9pDXrv02dsB58d2ZAQ==} + dev: true + + /is-directory@0.3.1: + resolution: {integrity: sha512-yVChGzahRFvbkscn2MlwGismPO12i9+znNruC5gVEntG3qu0xQMzsGg/JFbrsqDOHtHFPci+V5aP5T9I+yeKqw==} + engines: {node: '>=0.10.0'} + dev: false + + /is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + /is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + dependencies: + call-bind: 1.0.2 + dev: true + + /is-fullwidth-code-point@2.0.0: + resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} + engines: {node: '>=4'} + dev: false + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + /is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + + /is-gzip@1.0.0: + resolution: {integrity: sha512-rcfALRIb1YewtnksfRIHGcIY93QnK8BIQ/2c9yDYcG/Y6+vRoJuTWBmmSEbyLLYtXm7q35pHOHbZFQBaLrhlWQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-hex-prefixed@1.0.0: + resolution: {integrity: sha1-fY035q135dEnFIkTxXPggtd39VQ=} + engines: {node: '>=6.5.0', npm: '>=3'} + dev: false + + /is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + + /is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + dev: true + + /is-map@2.0.2: + resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} + dev: true + + /is-nan@1.3.2: + resolution: {integrity: sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.1 + dev: true + + /is-negative-zero@2.0.2: + resolution: {integrity: sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==} + engines: {node: '>= 0.4'} + dev: true + + /is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + /is-path-cwd@2.2.0: + resolution: {integrity: sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ==} + engines: {node: '>=6'} + dev: true + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + dev: false + + /is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + dependencies: + isobject: 3.0.1 + + /is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: true + + /is-port-reachable@4.0.0: + resolution: {integrity: sha512-9UoipoxYmSk6Xy7QFgRv2HDyaysmgSG75TFQs6S+3pDM7ZhKTF/bskZV+0UlABHzKjNVhPjYCLfeZUEg1wXxig==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + dev: true + + /is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + has-tostringtag: 1.0.0 + dev: true + + /is-set@2.0.2: + resolution: {integrity: sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==} + dev: true + + /is-shared-array-buffer@1.0.2: + resolution: {integrity: sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==} + dependencies: + call-bind: 1.0.2 + dev: true + + /is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + /is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.0 + dev: true + + /is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: true + + /is-typed-array@1.1.10: + resolution: {integrity: sha512-PJqgEHiWZvMpaFZ3uTc8kHPM4+4ADTlDniuQL7cU/UDA0Ql7F70yGfHph3cLNe+c9toaigv+DFzTJKhc2CtO6A==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.0 + + /is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + + /is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + dev: true + + /is-weakmap@2.0.1: + resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} + dev: true + + /is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + dependencies: + call-bind: 1.0.2 + dev: true + + /is-weakset@2.0.2: + resolution: {integrity: sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + dev: true + + /is-what@3.14.1: + resolution: {integrity: sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==} + dev: true + + /is-wsl@1.1.0: + resolution: {integrity: sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==} + engines: {node: '>=4'} + dev: false + + /is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + + /isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + /isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + + /isomorphic-unfetch@3.1.0: + resolution: {integrity: sha512-geDJjpoZ8N0kWexiwkX8F9NkTsXhetLPVbZFQ+JTW239QNOwvB0gniuR1Wc6f0AMTn7/mFGyXvHTifrCp/GH8Q==} + dependencies: + node-fetch: 2.7.0 + unfetch: 4.2.0 + transitivePeerDependencies: + - encoding + dev: false + + /isomorphic-ws@5.0.0(ws@8.12.0): + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + dependencies: + ws: 8.12.0 + dev: false + + /isows@1.0.3(ws@8.13.0): + resolution: {integrity: sha512-2cKei4vlmg2cxEjm3wVSqn8pcoRF/LX/wpifuuNquFO4SQmPwarClT+SUCA2lt+l581tTeZIPIZuIDo2jWN1fg==} + peerDependencies: + ws: '*' + dependencies: + ws: 8.13.0 + + /istanbul-lib-coverage@3.2.0: + resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-instrument@5.2.1: + resolution: {integrity: sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==} + engines: {node: '>=8'} + dependencies: + '@babel/core': 7.22.9 + '@babel/parser': 7.23.0 + '@istanbuljs/schema': 0.1.3 + istanbul-lib-coverage: 3.2.0 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.0 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-lib-source-maps@4.0.1: + resolution: {integrity: sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==} + engines: {node: '>=10'} + dependencies: + debug: 4.3.4(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.0 + source-map: 0.6.1 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-reports@3.1.5: + resolution: {integrity: sha512-nUsEMa9pBt/NOHqbcbeJEgqIlY/K7rVWUX6Lql2orY5e9roQOthbR3vtY4zzf2orPELg80fnxxk9zUyPlgwD1w==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + + /iterator.prototype@1.1.2: + resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + dependencies: + define-properties: 1.2.1 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + reflect.getprototypeof: 1.0.4 + set-function-name: 2.0.1 + dev: true + + /jackspeak@2.2.2: + resolution: {integrity: sha512-mgNtVv4vUuaKA97yxUHoA3+FkuhtxkjdXEWOyB/N76fjy0FjezEt34oy3epBtvCvS+7DyKwqCFWx/oJLV5+kCg==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} + engines: {node: '>=14'} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + /jake@10.8.7: + resolution: {integrity: sha512-ZDi3aP+fG/LchyBzUM804VjddnwfSfsdeYkwt8NcbKRvo4rFkjhs456iLFn3k2ZUWvNe4i48WACDbza8fhq2+w==} + engines: {node: '>=10'} + hasBin: true + dependencies: + async: 3.2.4 + chalk: 4.1.2 + filelist: 1.0.4 + minimatch: 3.1.2 + dev: true + + /jest-diff@28.1.3: + resolution: {integrity: sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 28.1.1 + jest-get-type: 28.0.2 + pretty-format: 28.1.3 + dev: true + + /jest-diff@29.6.1: + resolution: {integrity: sha512-FsNCvinvl8oVxpNLttNQX7FAq7vR+gMDGj90tiP7siWw1UdakWUGqrylpsYrpvj908IYckm5Y0Q7azNAozU1Kg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + diff-sequences: 29.6.3 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + dev: true + + /jest-environment-node@29.7.0: + resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/environment': 29.7.0 + '@jest/fake-timers': 29.7.0 + '@jest/types': 29.6.3 + '@types/node': 20.12.7 + jest-mock: 29.7.0 + jest-util: 29.7.0 + dev: false + + /jest-get-type@28.0.2: + resolution: {integrity: sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dev: true + + /jest-get-type@29.6.3: + resolution: {integrity: sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + + /jest-haste-map@29.6.1: + resolution: {integrity: sha512-0m7f9PZXxOCk1gRACiVgX85knUKPKLPg4oRCjLoqIm9brTHXaorMA0JpmtmVkQiT8nmXyIVoZd/nnH1cfC33ig==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/graceful-fs': 4.1.6 + '@types/node': 20.10.0 + anymatch: 3.1.3 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + jest-regex-util: 29.4.3 + jest-util: 29.6.1 + jest-worker: 29.6.1 + micromatch: 4.0.5 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /jest-matcher-utils@28.1.3: + resolution: {integrity: sha512-kQeJ7qHemKfbzKoGjHHrRKH6atgxMk8Enkk2iPQ3XwO6oE/KYD8lMYOziCkeSB9G4adPM4nR1DE8Tf5JeWH6Bw==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 28.1.3 + jest-get-type: 28.0.2 + pretty-format: 28.1.3 + dev: true + + /jest-matcher-utils@29.6.1: + resolution: {integrity: sha512-SLaztw9d2mfQQKHmJXKM0HCbl2PPVld/t9Xa6P9sgiExijviSp7TnZZpw2Fpt+OI3nwUO/slJbOfzfUMKKC5QA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + chalk: 4.1.2 + jest-diff: 29.6.1 + jest-get-type: 29.6.3 + pretty-format: 29.7.0 + dev: true + + /jest-message-util@29.6.1: + resolution: {integrity: sha512-KoAW2zAmNSd3Gk88uJ56qXUWbFk787QKmjjJVOjtGFmmGSZgDBrlIL4AfQw1xyMYPNVD7dNInfIbur9B2rd/wQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.23.4 + '@jest/types': 29.6.1 + '@types/stack-utils': 2.0.1 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + dev: true + + /jest-message-util@29.7.0: + resolution: {integrity: sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@babel/code-frame': 7.24.2 + '@jest/types': 29.6.3 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + pretty-format: 29.7.0 + slash: 3.0.0 + stack-utils: 2.0.6 + dev: false + + /jest-mock@27.5.1: + resolution: {integrity: sha512-K4jKbY1d4ENhbrG2zuPWaQBvDly+iZ2yAW+T1fATN78hc0sInwn7wZB8XtlNnvHug5RMwV897Xm4LqmPM4e2Og==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + '@jest/types': 27.5.1 + '@types/node': 20.8.9 + dev: true + + /jest-mock@29.7.0: + resolution: {integrity: sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.12.7 + jest-util: 29.7.0 + dev: false + + /jest-regex-util@29.4.3: + resolution: {integrity: sha512-O4FglZaMmWXbGHSQInfXewIsd1LMn9p3ZXB/6r4FOkyhX2/iP/soMG98jGvk/A3HAN78+5VWcBGO0BJAPRh4kg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /jest-util@29.6.1: + resolution: {integrity: sha512-NRFCcjc+/uO3ijUVyNOQJluf8PtGCe/W6cix36+M3cTFgiYqFOOW5MgN4JOOcvbUhcKTYVd1CvHz/LWi8d16Mg==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.1 + '@types/node': 20.10.0 + chalk: 4.1.2 + ci-info: 3.8.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + dev: true + + /jest-util@29.7.0: + resolution: {integrity: sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + '@types/node': 20.12.7 + chalk: 4.1.2 + ci-info: 3.9.0 + graceful-fs: 4.2.11 + picomatch: 2.3.1 + dev: false + + /jest-validate@29.7.0: + resolution: {integrity: sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/types': 29.6.3 + camelcase: 6.3.0 + chalk: 4.1.2 + jest-get-type: 29.6.3 + leven: 3.1.0 + pretty-format: 29.7.0 + + /jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/node': 20.12.7 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + + /jest-worker@29.6.1: + resolution: {integrity: sha512-U+Wrbca7S8ZAxAe9L6nb6g8kPdia5hj32Puu5iOqBCMTMWFHXuK6dOV2IFrpedbTV8fjMFLdWNttQTBL6u2MRA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 20.10.0 + jest-util: 29.6.1 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: true + + /jest-worker@29.7.0: + resolution: {integrity: sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@types/node': 20.12.7 + jest-util: 29.7.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + dev: false + + /jiti@1.19.1: + resolution: {integrity: sha512-oVhqoRDaBXf7sjkll95LHVS6Myyyb1zaunVwk4Z0+WPSW4gjS0pl01zYKHScTuyEhQsFxV5L4DR5r+YqSyqyyg==} + hasBin: true + + /jiti@1.21.0: + resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==} + hasBin: true + + /joi@17.12.3: + resolution: {integrity: sha512-2RRziagf555owrm9IRVtdKynOBeITiDpuZqIpgwqXShPncPKNiRQoiGsl/T8SQdq+8ugRzH2LqY67irr2y/d+g==} + dependencies: + '@hapi/hoek': 9.3.0 + '@hapi/topo': 5.1.0 + '@sideway/address': 4.1.5 + '@sideway/formula': 3.0.1 + '@sideway/pinpoint': 2.0.0 + dev: false + + /js-sha3@0.8.0: + resolution: {integrity: sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==} + dev: false + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + /js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + + /jsbi@3.2.5: + resolution: {integrity: sha512-aBE4n43IPvjaddScbvWRA2YlTzKEynHzu7MqOyTipdHucf/VxS63ViCjxYRg86M8Rxwbt/GfzHl1kKERkt45fQ==} + dev: false + + /jsc-android@250231.0.0: + resolution: {integrity: sha512-rS46PvsjYmdmuz1OAWXY/1kCYG7pnf1TBqeTiOJr1iDz7s5DLxxC9n/ZMknLDxzYzNVfI7R95MH10emSSG1Wuw==} + dev: false + + /jsc-safe-url@0.2.4: + resolution: {integrity: sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q==} + dev: false + + /jscodeshift@0.14.0(@babel/preset-env@7.22.9): + resolution: {integrity: sha512-7eCC1knD7bLUPuSCwXsMZUH51O8jIcoVyKtI6P0XM0IVzlGjckPy3FIwQlorzbN0Sg79oK+RlohN32Mqf/lrYA==} + hasBin: true + peerDependencies: + '@babel/preset-env': ^7.1.6 + dependencies: + '@babel/core': 7.22.9 + '@babel/parser': 7.23.0 + '@babel/plugin-proposal-class-properties': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-proposal-nullish-coalescing-operator': 7.18.6(@babel/core@7.22.9) + '@babel/plugin-proposal-optional-chaining': 7.21.0(@babel/core@7.22.9) + '@babel/plugin-transform-modules-commonjs': 7.22.5(@babel/core@7.22.9) + '@babel/preset-env': 7.22.9(@babel/core@7.22.9) + '@babel/preset-flow': 7.22.5(@babel/core@7.22.9) + '@babel/preset-typescript': 7.22.5(@babel/core@7.22.9) + '@babel/register': 7.22.5(@babel/core@7.22.9) + babel-core: 7.0.0-bridge.0(@babel/core@7.22.9) + chalk: 4.1.2 + flow-parser: 0.212.0 + graceful-fs: 4.2.11 + micromatch: 4.0.5 + neo-async: 2.6.2 + node-dir: 0.1.17 + recast: 0.21.5 + temp: 0.8.4 + write-file-atomic: 2.4.3 + transitivePeerDependencies: + - supports-color + + /jsdom@22.1.0: + resolution: {integrity: sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==} + engines: {node: '>=16'} + peerDependencies: + canvas: ^2.5.0 + peerDependenciesMeta: + canvas: + optional: true + dependencies: + abab: 2.0.6 + cssstyle: 3.0.0 + data-urls: 4.0.0 + decimal.js: 10.4.3 + domexception: 4.0.0 + form-data: 4.0.0 + html-encoding-sniffer: 3.0.0 + http-proxy-agent: 5.0.0 + https-proxy-agent: 5.0.1 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.7 + parse5: 7.1.2 + rrweb-cssom: 0.6.0 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 4.1.3 + w3c-xmlserializer: 4.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 2.0.0 + whatwg-mimetype: 3.0.0 + whatwg-url: 12.0.1 + ws: 8.13.0 + xml-name-validator: 4.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /jsesc@0.5.0: + resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} + hasBin: true + + /jsesc@2.5.2: + resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} + engines: {node: '>=4'} + hasBin: true + + /json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + dev: false + + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + /json-rpc-engine@6.1.0: + resolution: {integrity: sha512-NEdLrtrq1jUZyfjkr9OCz9EzCNhnRyWtt1PAnvnhwy6e8XETS0Dtc+ZNCO2gvuAoKsIn2+vCSowXTYE4CkgnAQ==} + engines: {node: '>=10.0.0'} + dependencies: + '@metamask/safe-event-emitter': 2.0.0 + eth-rpc-errors: 4.0.3 + dev: false + + /json-rpc-middleware-stream@4.2.3: + resolution: {integrity: sha512-4iFb0yffm5vo3eFKDbQgke9o17XBcLQ2c3sONrXSbcOLzP8LTojqo8hRGVgtJShhm5q4ZDSNq039fAx9o65E1w==} + engines: {node: '>=14.0.0'} + dependencies: + '@metamask/safe-event-emitter': 3.0.0 + json-rpc-engine: 6.1.0 + readable-stream: 2.3.8 + dev: false + + /json-rpc-random-id@1.0.1: + resolution: {integrity: sha512-RJ9YYNCkhVDBuP4zN5BBtYAzEl03yq/jIIsyif0JY9qyJuQQZNeDK7anAPKKlyEtLSj2s8h6hNh2F8zO5q7ScA==} + dev: false + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + /jsonc-parser@3.2.0: + resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + + /jsonfile@2.4.0: + resolution: {integrity: sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==} + optionalDependencies: + graceful-fs: 4.2.11 + dev: false + + /jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + optionalDependencies: + graceful-fs: 4.2.11 + dev: false + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.0 + optionalDependencies: + graceful-fs: 4.2.11 + + /jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + dependencies: + array-includes: 3.1.6 + array.prototype.flat: 1.3.1 + object.assign: 4.1.4 + object.values: 1.1.6 + dev: true + + /keccak@3.0.4: + resolution: {integrity: sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==} + engines: {node: '>=10.0.0'} + requiresBuild: true + dependencies: + node-addon-api: 2.0.2 + node-gyp-build: 4.8.0 + readable-stream: 3.6.2 + dev: false + + /keyvaluestorage-interface@1.0.0: + resolution: {integrity: sha512-8t6Q3TclQ4uZynJY9IGr2+SsIGwK9JHcO6ootkHCGA0CrQCRy+VkouYNO2xicET6b9al7QKzpebNow+gkpCL8g==} + dev: false + + /kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + /klaw@1.3.1: + resolution: {integrity: sha512-TED5xi9gGQjGpNnvRWknrwAB1eL5GciPfVFOt3Vk1OJCVDQbzuSfrF3hkUQKlsgKrG1F+0t5W0m+Fje1jIt8rw==} + optionalDependencies: + graceful-fs: 4.2.11 + dev: false + + /kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + /lazy-universal-dotenv@4.0.0: + resolution: {integrity: sha512-aXpZJRnTkpK6gQ/z4nk+ZBLd/Qdp118cvPruLSIQzQNRhKwEcdXCOzXuF55VDqIiuAaY3UGZ10DJtvZzDcvsxg==} + engines: {node: '>=14.0.0'} + dependencies: + app-root-dir: 1.0.2 + dotenv: 16.3.1 + dotenv-expand: 10.0.0 + + /less-loader@11.1.3(less@4.2.0)(webpack@5.91.0): + resolution: {integrity: sha512-A5b7O8dH9xpxvkosNrP0dFp2i/dISOJa9WwGF3WJflfqIERE2ybxh1BFDj5CovC2+jCE4M354mk90hN6ziXlVw==} + engines: {node: '>= 14.15.0'} + peerDependencies: + less: ^3.5.0 || ^4.0.0 + webpack: ^5.0.0 + dependencies: + less: 4.2.0 + webpack: 5.91.0(esbuild@0.18.20) + dev: true + + /less@4.2.0: + resolution: {integrity: sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA==} + engines: {node: '>=6'} + hasBin: true + dependencies: + copy-anything: 2.0.6 + parse-node-version: 1.0.1 + tslib: 2.6.2 + optionalDependencies: + errno: 0.1.8 + graceful-fs: 4.2.11 + image-size: 0.5.5 + make-dir: 2.1.0 + mime: 1.6.0 + needle: 3.3.1 + source-map: 0.6.1 + dev: true + + /leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /lighthouse-logger@1.4.2: + resolution: {integrity: sha512-gPWxznF6TKmUHrOQjlVo2UbaL2EJ71mb2CCeRs/2qBpi4L/g4LUVc9+3lKQ6DTUZwJswfM7ainGrLO1+fOqa2g==} + dependencies: + debug: 2.6.9 + marky: 1.2.5 + transitivePeerDependencies: + - supports-color + dev: false + + /lilconfig@2.1.0: + resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} + engines: {node: '>=10'} + + /lilconfig@3.1.1: + resolution: {integrity: sha512-O18pf7nyvHTckunPWCV1XUNXU1piu01y2b7ATJ0ppkUkk8ocqVWBrYjJBCwHDjD/ZWcfyrA0P4gKhzWGi5EINQ==} + engines: {node: '>=14'} + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + /listhen@1.5.5: + resolution: {integrity: sha512-LXe8Xlyh3gnxdv4tSjTjscD1vpr/2PRpzq8YIaMJgyKzRG8wdISlWVWnGThJfHnlJ6hmLt2wq1yeeix0TEbuoA==} + hasBin: true + dependencies: + '@parcel/watcher': 2.3.0 + '@parcel/watcher-wasm': 2.3.0 + citty: 0.1.5 + clipboardy: 3.0.0 + consola: 3.2.3 + defu: 6.1.3 + get-port-please: 3.1.1 + h3: 1.9.0 + http-shutdown: 1.2.2 + jiti: 1.21.0 + mlly: 1.6.1 + node-forge: 1.3.1 + pathe: 1.1.2 + std-env: 3.7.0 + ufo: 1.5.3 + untun: 0.1.2 + uqr: 0.1.2 + dev: false + + /lit-element@3.3.3: + resolution: {integrity: sha512-XbeRxmTHubXENkV4h8RIPyr8lXc+Ff28rkcQzw3G6up2xg5E8Zu1IgOWIwBLEQsu3cOVFqdYwiVi0hv0SlpqUA==} + dependencies: + '@lit-labs/ssr-dom-shim': 1.1.2 + '@lit/reactive-element': 1.6.3 + lit-html: 2.8.0 + dev: false + + /lit-html@2.8.0: + resolution: {integrity: sha512-o9t+MQM3P4y7M7yNzqAyjp7z+mQGa4NS4CxiyLqFPyFWyc4O+nodLrkrxSaCTrla6M5YOLaT3RpbbqjszB5g3Q==} + dependencies: + '@types/trusted-types': 2.0.4 + dev: false + + /lit@2.8.0: + resolution: {integrity: sha512-4Sc3OFX9QHOJaHbmTMk28SYgVxLN3ePDjg7hofEft2zWlehFL3LiAuapWc4U/kYwMYJSh2hTCPZ6/LIC7ii0MA==} + dependencies: + '@lit/reactive-element': 1.6.3 + lit-element: 3.3.3 + lit-html: 2.8.0 + dev: false + + /load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /loader-runner@4.3.0: + resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} + engines: {node: '>=6.11.5'} + dev: true + + /loader-utils@2.0.4: + resolution: {integrity: sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==} + engines: {node: '>=8.9.0'} + dependencies: + big.js: 5.2.2 + emojis-list: 3.0.0 + json5: 2.2.3 + dev: true + + /local-pkg@0.4.3: + resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} + engines: {node: '>=14'} + dev: true + + /locate-path@2.0.0: + resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==} + engines: {node: '>=4'} + dependencies: + p-locate: 2.0.0 + path-exists: 3.0.0 + dev: false + + /locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + + /locate-path@7.2.0: + resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + p-locate: 6.0.0 + dev: true + + /lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: false + + /lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + + /lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: false + + /lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + dev: false + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash.sortby@4.7.0: + resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==} + dev: true + + /lodash.throttle@4.1.1: + resolution: {integrity: sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==} + dev: false + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + + /log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + + /log-symbols@5.1.0: + resolution: {integrity: sha512-l0x2DvrW294C9uDCoQe1VSU4gf529FkSZ6leBl4TiqZH/e+0R7hSfHQBNut2mNygDgHwvYHfFLn6Oxb3VWj2rA==} + engines: {node: '>=12'} + dependencies: + chalk: 5.3.0 + is-unicode-supported: 1.3.0 + dev: true + + /logkitty@0.7.1: + resolution: {integrity: sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==} + hasBin: true + dependencies: + ansi-fragments: 0.2.1 + dayjs: 1.11.10 + yargs: 15.4.1 + dev: false + + /lokijs@1.5.12: + resolution: {integrity: sha512-Q5ALD6JiS6xAUWCwX3taQmgwxyveCtIIuL08+ml0nHwT3k0S/GIFJN+Hd38b1qYIMaE5X++iqsqWVksz7SYW+Q==} + dev: true + + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + + /loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + dependencies: + get-func-name: 2.0.2 + dev: true + + /lower-case@2.0.2: + resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} + dependencies: + tslib: 2.6.2 + dev: true + + /lru-cache@10.2.0: + resolution: {integrity: sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==} + engines: {node: 14 || >=16.14} + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + + /lru_map@0.3.3: + resolution: {integrity: sha512-Pn9cox5CsMYngeDbmChANltQl+5pi6XmTrraMSzhPmMBbmgcxmqWry0U3PGapCU1yB4/LqCcom7qhHZiF/jGfQ==} + dev: false + + /lucide-react@0.284.0(react@18.2.0): + resolution: {integrity: sha512-dVSMHYAya/TeY3+vsk+VQJEKNQN2AhIo0+Dp09B2qpzvcBuu93H98YZykFcjIAfmanFiDd8nqfXFR38L757cyQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + + /magic-string@0.27.0: + resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /magic-string@0.30.1: + resolution: {integrity: sha512-mbVKXPmS0z0G4XqFDCTllmDQ6coZzn94aMlb0o/A4HEHJCKcanlDZwYJgwnkmgD3jyWhUgj9VsPrfd972yPffA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /magic-string@0.30.5: + resolution: {integrity: sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.4.15 + dev: true + + /make-dir@2.1.0: + resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} + engines: {node: '>=6'} + dependencies: + pify: 4.0.1 + semver: 5.7.2 + + /make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + dependencies: + semver: 6.3.1 + + /make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + dependencies: + semver: 7.5.4 + dev: true + + /makeerror@1.0.12: + resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + dependencies: + tmpl: 1.0.5 + + /map-or-similar@1.5.0: + resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} + + /markdown-to-jsx@7.2.1(react@18.2.0): + resolution: {integrity: sha512-9HrdzBAo0+sFz9ZYAGT5fB8ilzTW+q6lPocRxrIesMO+aB40V9MgFfbfMXxlGjf22OpRy+IXlvVaQenicdpgbg==} + engines: {node: '>= 10'} + peerDependencies: + react: '>= 0.14.0' + dependencies: + react: 18.2.0 + + /marky@1.2.5: + resolution: {integrity: sha512-q9JtQJKjpsVxCRVgQ+WapguSbKC3SQ5HEzFGPAJMStgh3QjCawp00UKv3MTTAArTmGmmPUvllHZoNbZ3gs0I+Q==} + dev: false + + /math-expression-evaluator@1.4.0: + resolution: {integrity: sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==} + dev: false + + /md5.js@1.3.5: + resolution: {integrity: sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==} + dependencies: + hash-base: 3.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + + /mdast-util-definitions@4.0.0: + resolution: {integrity: sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==} + dependencies: + unist-util-visit: 2.0.3 + dev: true + + /mdast-util-to-string@1.1.0: + resolution: {integrity: sha512-jVU0Nr2B9X3MU4tSK7JP1CMkSvOj7X5l/GboG1tKRw52lLF1x2Ju92Ms9tNetCcbfX3hzlM73zYo2NKkWSfF/A==} + dev: true + + /media-query-parser@2.0.2: + resolution: {integrity: sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==} + dependencies: + '@babel/runtime': 7.24.4 + dev: false + + /media-typer@0.3.0: + resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} + engines: {node: '>= 0.6'} + dev: true + + /memoize-one@5.2.1: + resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} + dev: false + + /memoizerific@1.11.3: + resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} + dependencies: + map-or-similar: 1.5.0 + + /memorystream@0.3.1: + resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} + engines: {node: '>= 0.10.0'} + dev: false + + /merge-descriptors@1.0.1: + resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==} + dev: true + + /merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + dependencies: + is-plain-obj: 2.1.0 + dev: false + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + /methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + dev: true + + /metro-babel-transformer@0.80.8: + resolution: {integrity: sha512-TTzNwRZb2xxyv4J/+yqgtDAP2qVqH3sahsnFu6Xv4SkLqzrivtlnyUbaeTdJ9JjtADJUEjCbgbFgUVafrXdR9Q==} + engines: {node: '>=18'} + dependencies: + '@babel/core': 7.24.4 + hermes-parser: 0.20.1 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + dev: false + + /metro-cache-key@0.80.8: + resolution: {integrity: sha512-qWKzxrLsRQK5m3oH8ePecqCc+7PEhR03cJE6Z6AxAj0idi99dHOSitTmY0dclXVB9vP2tQIAE8uTd8xkYGk8fA==} + engines: {node: '>=18'} + dev: false + + /metro-cache@0.80.8: + resolution: {integrity: sha512-5svz+89wSyLo7BxdiPDlwDTgcB9kwhNMfNhiBZPNQQs1vLFXxOkILwQiV5F2EwYT9DEr6OPZ0hnJkZfRQ8lDYQ==} + engines: {node: '>=18'} + dependencies: + metro-core: 0.80.8 + rimraf: 3.0.2 + dev: false + + /metro-config@0.80.8: + resolution: {integrity: sha512-VGQJpfJawtwRzGzGXVUoohpIkB0iPom4DmSbAppKfumdhtLA8uVeEPp2GM61kL9hRvdbMhdWA7T+hZFDlo4mJA==} + engines: {node: '>=18'} + dependencies: + connect: 3.7.0 + cosmiconfig: 5.2.1 + jest-validate: 29.7.0 + metro: 0.80.8 + metro-cache: 0.80.8 + metro-core: 0.80.8 + metro-runtime: 0.80.8 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /metro-core@0.80.8: + resolution: {integrity: sha512-g6lud55TXeISRTleW6SHuPFZHtYrpwNqbyFIVd9j9Ofrb5IReiHp9Zl8xkAfZQp8v6ZVgyXD7c130QTsCz+vBw==} + engines: {node: '>=18'} + dependencies: + lodash.throttle: 4.1.1 + metro-resolver: 0.80.8 + dev: false + + /metro-file-map@0.80.8: + resolution: {integrity: sha512-eQXMFM9ogTfDs2POq7DT2dnG7rayZcoEgRbHPXvhUWkVwiKkro2ngcBE++ck/7A36Cj5Ljo79SOkYwHaWUDYDw==} + engines: {node: '>=18'} + dependencies: + anymatch: 3.1.3 + debug: 2.6.9 + fb-watchman: 2.0.2 + graceful-fs: 4.2.11 + invariant: 2.2.4 + jest-worker: 29.7.0 + micromatch: 4.0.5 + node-abort-controller: 3.1.1 + nullthrows: 1.1.1 + walker: 1.0.8 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - supports-color + dev: false + + /metro-minify-terser@0.80.8: + resolution: {integrity: sha512-y8sUFjVvdeUIINDuW1sejnIjkZfEF+7SmQo0EIpYbWmwh+kq/WMj74yVaBWuqNjirmUp1YNfi3alT67wlbBWBQ==} + engines: {node: '>=18'} + dependencies: + terser: 5.30.3 + dev: false + + /metro-resolver@0.80.8: + resolution: {integrity: sha512-JdtoJkP27GGoZ2HJlEsxs+zO7jnDUCRrmwXJozTlIuzLHMRrxgIRRby9fTCbMhaxq+iA9c+wzm3iFb4NhPmLbQ==} + engines: {node: '>=18'} + dev: false + + /metro-runtime@0.80.8: + resolution: {integrity: sha512-2oScjfv6Yb79PelU1+p8SVrCMW9ZjgEiipxq7jMRn8mbbtWzyv3g8Mkwr+KwOoDFI/61hYPUbY8cUnu278+x1g==} + engines: {node: '>=18'} + dependencies: + '@babel/runtime': 7.24.4 + dev: false + + /metro-source-map@0.80.8: + resolution: {integrity: sha512-+OVISBkPNxjD4eEKhblRpBf463nTMk3KMEeYS8Z4xM/z3qujGJGSsWUGRtH27+c6zElaSGtZFiDMshEb8mMKQg==} + engines: {node: '>=18'} + dependencies: + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + invariant: 2.2.4 + metro-symbolicate: 0.80.8 + nullthrows: 1.1.1 + ob1: 0.80.8 + source-map: 0.5.7 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /metro-symbolicate@0.80.8: + resolution: {integrity: sha512-nwhYySk79jQhwjL9QmOUo4wS+/0Au9joEryDWw7uj4kz2yvw1uBjwmlql3BprQCBzRdB3fcqOP8kO8Es+vE31g==} + engines: {node: '>=18'} + hasBin: true + dependencies: + invariant: 2.2.4 + metro-source-map: 0.80.8 + nullthrows: 1.1.1 + source-map: 0.5.7 + through2: 2.0.5 + vlq: 1.0.1 + transitivePeerDependencies: + - supports-color + dev: false + + /metro-transform-plugins@0.80.8: + resolution: {integrity: sha512-sSu8VPL9Od7w98MftCOkQ1UDeySWbsIAS5I54rW22BVpPnI3fQ42srvqMLaJUQPjLehUanq8St6OMBCBgH/UWw==} + engines: {node: '>=18'} + dependencies: + '@babel/core': 7.24.4 + '@babel/generator': 7.24.4 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + nullthrows: 1.1.1 + transitivePeerDependencies: + - supports-color + dev: false + + /metro-transform-worker@0.80.8: + resolution: {integrity: sha512-+4FG3TQk3BTbNqGkFb2uCaxYTfsbuFOCKMMURbwu0ehCP8ZJuTUramkaNZoATS49NSAkRgUltgmBa4YaKZ5mqw==} + engines: {node: '>=18'} + dependencies: + '@babel/core': 7.24.4 + '@babel/generator': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/types': 7.24.0 + metro: 0.80.8 + metro-babel-transformer: 0.80.8 + metro-cache: 0.80.8 + metro-cache-key: 0.80.8 + metro-minify-terser: 0.80.8 + metro-source-map: 0.80.8 + metro-transform-plugins: 0.80.8 + nullthrows: 1.1.1 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /metro@0.80.8: + resolution: {integrity: sha512-in7S0W11mg+RNmcXw+2d9S3zBGmCARDxIwoXJAmLUQOQoYsRP3cpGzyJtc7WOw8+FXfpgXvceD0u+PZIHXEL7g==} + engines: {node: '>=18'} + hasBin: true + dependencies: + '@babel/code-frame': 7.24.2 + '@babel/core': 7.24.4 + '@babel/generator': 7.24.4 + '@babel/parser': 7.24.4 + '@babel/template': 7.24.0 + '@babel/traverse': 7.24.1 + '@babel/types': 7.24.0 + accepts: 1.3.8 + chalk: 4.1.2 + ci-info: 2.0.0 + connect: 3.7.0 + debug: 2.6.9 + denodeify: 1.2.1 + error-stack-parser: 2.1.4 + graceful-fs: 4.2.11 + hermes-parser: 0.20.1 + image-size: 1.1.1 + invariant: 2.2.4 + jest-worker: 29.7.0 + jsc-safe-url: 0.2.4 + lodash.throttle: 4.1.1 + metro-babel-transformer: 0.80.8 + metro-cache: 0.80.8 + metro-cache-key: 0.80.8 + metro-config: 0.80.8 + metro-core: 0.80.8 + metro-file-map: 0.80.8 + metro-resolver: 0.80.8 + metro-runtime: 0.80.8 + metro-source-map: 0.80.8 + metro-symbolicate: 0.80.8 + metro-transform-plugins: 0.80.8 + metro-transform-worker: 0.80.8 + mime-types: 2.1.35 + node-fetch: 2.7.0 + nullthrows: 1.1.1 + rimraf: 3.0.2 + serialize-error: 2.1.0 + source-map: 0.5.7 + strip-ansi: 6.0.1 + throat: 5.0.0 + ws: 7.5.9 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /micro-ftch@0.3.1: + resolution: {integrity: sha512-/0LLxhzP0tfiR5hcQebtudP56gUurs2CLkGarnCiB/OqEyUFQ6U3paQi/tgLv0hBJYt2rnr9MNpxz4fiiugstg==} + dev: false + + /micromatch@4.0.2: + resolution: {integrity: sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==} + engines: {node: '>=8'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + dev: true + + /micromatch@4.0.5: + resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.2 + picomatch: 2.3.1 + + /mime-db@1.33.0: + resolution: {integrity: sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==} + engines: {node: '>= 0.6'} + dev: true + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + /mime-types@2.1.18: + resolution: {integrity: sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.33.0 + dev: true + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + + /mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + + /mime@2.6.0: + resolution: {integrity: sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==} + engines: {node: '>=4.0.0'} + hasBin: true + + /mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + dev: false + + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + + /minimalistic-crypto-utils@1.0.1: + resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} + dev: false + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + + /minimatch@5.0.1: + resolution: {integrity: sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: false + + /minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + + /minimatch@9.0.3: + resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + + /minimatch@9.0.4: + resolution: {integrity: sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + /minipass@3.3.6: + resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} + engines: {node: '>=8'} + dependencies: + yallist: 4.0.0 + dev: true + + /minipass@5.0.0: + resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} + engines: {node: '>=8'} + + /minipass@7.0.4: + resolution: {integrity: sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==} + engines: {node: '>=16 || 14 >=14.17'} + + /minizlib@2.1.2: + resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==} + engines: {node: '>= 8'} + dependencies: + minipass: 3.3.6 + yallist: 4.0.0 + dev: true + + /mipd@0.0.5(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-gbKA784D2WKb5H/GtqEv+Ofd1S9Zj+Z/PGDIl1u1QAbswkxD28BQ5bSXQxkeBzPBABg1iDSbiwGG1XqlOxRspA==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.4.5 + viem: 1.9.5(typescript@5.4.5)(zod@3.22.4) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + dev: false + + /mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + dev: true + + /mkdirp@0.5.6: + resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} + hasBin: true + dependencies: + minimist: 1.2.8 + + /mkdirp@1.0.4: + resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} + engines: {node: '>=10'} + hasBin: true + + /mlly@1.4.2: + resolution: {integrity: sha512-i/Ykufi2t1EZ6NaPLdfnZk2AX8cs0d+mTzVKuPfqPKPatxLApaBoxJQ9x1/uckXtrS/U5oisPMDkNs0yQTaBRg==} + dependencies: + acorn: 8.11.2 + pathe: 1.1.1 + pkg-types: 1.0.3 + ufo: 1.3.2 + + /mlly@1.6.1: + resolution: {integrity: sha512-vLgaHvaeunuOXHSmEbZ9izxPx3USsk8KCQ8iC+aTlp5sKRSoZvwhHh5L9VbKSaVC6sJDqbyohIS76E2VmHIPAA==} + dependencies: + acorn: 8.11.3 + pathe: 1.1.2 + pkg-types: 1.0.3 + ufo: 1.5.3 + dev: false + + /mnemonist@0.38.5: + resolution: {integrity: sha512-bZTFT5rrPKtPJxj8KSV0WkPyNxl72vQepqqVUAW2ARUpUSF2qXMB6jZj7hW5/k7C1rtpzqbD/IIbJwLXUjCHeg==} + dependencies: + obliterator: 2.0.4 + dev: false + + /mocha@10.4.0: + resolution: {integrity: sha512-eqhGB8JKapEYcC4ytX/xrzKforgEc3j1pGlAXVy3eRwrtAy5/nIfT1SvgGzfN0XZZxeLq0aQWkOUAmqIJiv+bA==} + engines: {node: '>= 14.0.0'} + hasBin: true + dependencies: + ansi-colors: 4.1.1 + browser-stdout: 1.3.1 + chokidar: 3.5.3 + debug: 4.3.4(supports-color@8.1.1) + diff: 5.0.0 + escape-string-regexp: 4.0.0 + find-up: 5.0.0 + glob: 8.1.0 + he: 1.2.0 + js-yaml: 4.1.0 + log-symbols: 4.1.0 + minimatch: 5.0.1 + ms: 2.1.3 + serialize-javascript: 6.0.0 + strip-json-comments: 3.1.1 + supports-color: 8.1.1 + workerpool: 6.2.1 + yargs: 16.2.0 + yargs-parser: 20.2.4 + yargs-unparser: 2.0.0 + dev: false + + /modern-ahocorasick@1.0.1: + resolution: {integrity: sha512-yoe+JbhTClckZ67b2itRtistFKf8yPYelHLc7e5xAwtNAXxM6wJTUx2C7QeVSJFDzKT7bCIFyBVybPMKvmB9AA==} + dev: false + + /moo@0.5.2: + resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + + /motion@10.16.2: + resolution: {integrity: sha512-p+PurYqfUdcJZvtnmAqu5fJgV2kR0uLFQuBKtLeFVTrYEVllI99tiOTSefVNYuip9ELTEkepIIDftNdze76NAQ==} + dependencies: + '@motionone/animation': 10.16.3 + '@motionone/dom': 10.16.4 + '@motionone/svelte': 10.16.4 + '@motionone/types': 10.16.3 + '@motionone/utils': 10.16.3 + '@motionone/vue': 10.16.4 + dev: false + + /mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + /ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + + /ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + /multiformats@9.9.0: + resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} + dev: false + + /mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + dev: true + + /mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + /nanoid@3.3.6: + resolution: {integrity: sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + /nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /nanospinner@1.1.0: + resolution: {integrity: sha512-yFvNYMig4AthKYfHFl1sLj7B2nkHL4lzdig4osvl9/LdGbXwrdFRoqBS98gsEsOakr0yH+r5NZ/1Y9gdVB8trA==} + dependencies: + picocolors: 1.0.0 + dev: false + + /napi-wasm@1.1.0: + resolution: {integrity: sha512-lHwIAJbmLSjF9VDRm9GoVOy9AGp3aIvkjv+Kvz9h16QR3uSVYH78PNQUnT2U4X53mhlnV2M7wrhibQ3GHicDmg==} + dev: false + + /natural-compare-lite@1.4.0: + resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} + dev: true + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /needle@3.3.1: + resolution: {integrity: sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==} + engines: {node: '>= 4.4.x'} + hasBin: true + requiresBuild: true + dependencies: + iconv-lite: 0.6.3 + sax: 1.3.0 + dev: true + optional: true + + /negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + + /neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + + /no-case@3.0.4: + resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + dependencies: + lower-case: 2.0.2 + tslib: 2.6.2 + dev: true + + /nocache@3.0.4: + resolution: {integrity: sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==} + engines: {node: '>=12.0.0'} + dev: false + + /node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + dev: false + + /node-addon-api@2.0.2: + resolution: {integrity: sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==} + dev: false + + /node-addon-api@5.1.0: + resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} + dev: false + + /node-addon-api@7.0.0: + resolution: {integrity: sha512-vgbBJTS4m5/KkE16t5Ly0WW9hz46swAstv0hYYwMtbG7AznRhNyfLRe8HZAiWIpcHzoO7HxhLuBQj9rJ/Ho0ZA==} + dev: false + + /node-dir@0.1.17: + resolution: {integrity: sha512-tmPX422rYgofd4epzrNoOXiE8XFZYOcCq1vD7MAXCDO+O+zndlA2ztdKKMa+EeuBG5tHETpr4ml4RGgpqDCCAg==} + engines: {node: '>= 0.10.5'} + dependencies: + minimatch: 3.1.2 + + /node-fetch-native@1.2.0: + resolution: {integrity: sha512-5IAMBTl9p6PaAjYCnMv5FmqIF6GcZnawAVnzaCG0rX2aYZJ4CxEkZNtVPuTRug7fL7wyM5BQYTlAzcyMPi6oTQ==} + dev: true + + /node-fetch-native@1.4.1: + resolution: {integrity: sha512-NsXBU0UgBxo2rQLOeWNZqS3fvflWePMECr8CoSWoSTqCqGbVVsvl9vZu1HfQicYN0g5piV9Gh8RTEvo/uP752w==} + dev: false + + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + + /node-forge@1.3.1: + resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} + engines: {node: '>= 6.13.0'} + dev: false + + /node-gyp-build@4.8.0: + resolution: {integrity: sha512-u6fs2AEUljNho3EYTJNBfImO5QTo/J/1Etd+NVdCj7qWKUSN/bSLkZwhDv7I+w/MSC6qJ4cknepkAYykDdK8og==} + hasBin: true + dev: false + + /node-int64@0.4.0: + resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} + + /node-releases@2.0.13: + resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} + + /node-releases@2.0.14: + resolution: {integrity: sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==} + + /node-stream-zip@1.15.0: + resolution: {integrity: sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==} + engines: {node: '>=0.12.0'} + dev: false + + /normalize-package-data@2.5.0: + resolution: {integrity: sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==} + dependencies: + hosted-git-info: 2.8.9 + resolve: 1.22.8 + semver: 5.7.2 + validate-npm-package-license: 3.0.4 + dev: true + + /normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + /normalize-range@0.1.2: + resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==} + engines: {node: '>=0.10.0'} + dev: true + + /npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + dependencies: + path-key: 3.1.1 + + /npm-run-path@5.1.0: + resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + + /npmlog@5.0.1: + resolution: {integrity: sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==} + dependencies: + are-we-there-yet: 2.0.0 + console-control-strings: 1.1.0 + gauge: 3.0.2 + set-blocking: 2.0.0 + dev: true + + /nullthrows@1.1.1: + resolution: {integrity: sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==} + dev: false + + /nwsapi@2.2.7: + resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} + dev: true + + /ob1@0.80.8: + resolution: {integrity: sha512-QHJQk/lXMmAW8I7AIM3in1MSlwe1umR72Chhi8B7Xnq6mzjhBKkA6Fy/zAhQnGkA4S912EPCEvTij5yh+EQTAA==} + engines: {node: '>=18'} + dev: false + + /obj-multiplex@1.0.0: + resolution: {integrity: sha512-0GNJAOsHoBHeNTvl5Vt6IWnpUEcc3uSRxzBri7EDyIcMgYvnY2JL2qdeV5zTMjWQX5OHcD5amcW2HFfDh0gjIA==} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + readable-stream: 2.3.8 + dev: false + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + /object-hash@3.0.0: + resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==} + engines: {node: '>= 6'} + + /object-inspect@1.12.3: + resolution: {integrity: sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==} + + /object-inspect@1.13.1: + resolution: {integrity: sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==} + + /object-is@1.1.5: + resolution: {integrity: sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.1 + dev: true + + /object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + dev: true + + /object.assign@4.1.4: + resolution: {integrity: sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + dev: true + + /object.entries@1.1.7: + resolution: {integrity: sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + dev: true + + /object.fromentries@2.0.7: + resolution: {integrity: sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + dev: true + + /object.hasown@1.1.3: + resolution: {integrity: sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==} + dependencies: + define-properties: 1.2.0 + es-abstract: 1.22.1 + dev: true + + /object.values@1.1.6: + resolution: {integrity: sha512-FVVTkD1vENCsAcwNs9k6jea2uHC/X0+JcjG8YA60FN5CMaJmG95wT9jek/xX9nornqGRrBkKtzuAu2wuHpKqvw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + dev: true + + /obliterator@2.0.4: + resolution: {integrity: sha512-lgHwxlxV1qIg1Eap7LgIeoBWIMFibOjbrYPIPJZcI1mmGAI2m3lNYpK12Y+GBdPQ0U1hRwSord7GIaawz962qQ==} + dev: false + + /ofetch@1.3.3: + resolution: {integrity: sha512-s1ZCMmQWXy4b5K/TW9i/DtiN8Ku+xCiHcjQ6/J/nDdssirrQNOoB165Zu8EqLMA2lln1JUth9a0aW9Ap2ctrUg==} + dependencies: + destr: 2.0.2 + node-fetch-native: 1.4.1 + ufo: 1.5.3 + dev: false + + /on-exit-leak-free@0.2.0: + resolution: {integrity: sha512-dqaz3u44QbRXQooZLTUKU41ZrzYrcvLISVgbrzbyCMxpmSLJvZ3ZamIJIZ29P6OhZIkNIQKosdeM6t1LYbA9hg==} + dev: false + + /on-finished@2.3.0: + resolution: {integrity: sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: false + + /on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + + /on-headers@1.0.2: + resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==} + engines: {node: '>= 0.8'} + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + + /open@6.4.0: + resolution: {integrity: sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==} + engines: {node: '>=8'} + dependencies: + is-wsl: 1.1.0 + dev: false + + /open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + dev: false + + /open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + /opener@1.5.2: + resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} + hasBin: true + dev: true + + /optionator@0.9.3: + resolution: {integrity: sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==} + engines: {node: '>= 0.8.0'} + dependencies: + '@aashutoshrathi/word-wrap': 1.2.6 + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.0 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + + /ora@6.3.1: + resolution: {integrity: sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + chalk: 5.3.0 + cli-cursor: 4.0.0 + cli-spinners: 2.9.0 + is-interactive: 2.0.0 + is-unicode-supported: 1.3.0 + log-symbols: 5.1.0 + stdin-discarder: 0.1.0 + strip-ansi: 7.1.0 + wcwidth: 1.0.1 + dev: true + + /os-tmpdir@1.0.2: + resolution: {integrity: sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==} + engines: {node: '>=0.10.0'} + + /outdent@0.8.0: + resolution: {integrity: sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==} + dev: false + + /p-limit@1.3.0: + resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} + engines: {node: '>=4'} + dependencies: + p-try: 1.0.0 + dev: false + + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + + /p-limit@4.0.0: + resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + yocto-queue: 1.0.0 + dev: true + + /p-locate@2.0.0: + resolution: {integrity: sha512-nQja7m7gSKuewoVRen45CtVfODR3crN3goVQ0DDZ9N3yHxgpkuBhZqsaiotSQRrADUrne346peY7kT3TSACykg==} + engines: {node: '>=4'} + dependencies: + p-limit: 1.3.0 + dev: false + + /p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + dependencies: + p-limit: 2.3.0 + + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + + /p-locate@6.0.0: + resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + p-limit: 4.0.0 + dev: true + + /p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + dependencies: + aggregate-error: 3.1.0 + + /p-try@1.0.0: + resolution: {integrity: sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww==} + engines: {node: '>=4'} + dev: false + + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + /pako@0.2.9: + resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} + dev: true + + /param-case@3.0.4: + resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + + /parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + dependencies: + error-ex: 1.3.2 + json-parse-better-errors: 1.0.2 + dev: false + + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.23.4 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + /parse-node-version@1.0.1: + resolution: {integrity: sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==} + engines: {node: '>= 0.10'} + dev: true + + /parse5@7.1.2: + resolution: {integrity: sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==} + dependencies: + entities: 4.5.0 + dev: true + + /parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + /pascal-case@3.1.2: + resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /path-case@3.0.4: + resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + /path-exists@5.0.0: + resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + + /path-is-inside@1.0.2: + resolution: {integrity: sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + /path-scurry@1.10.1: + resolution: {integrity: sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 10.2.0 + minipass: 5.0.0 + + /path-scurry@1.10.2: + resolution: {integrity: sha512-7xTavNy5RQXnsjANvVvMkEjvloOinkAjv/Z6Ildz9v2RinZ4SBKTWFOVRbaF8p0vpHnyjV/UwNDdKuUv6M5qcA==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + lru-cache: 10.2.0 + minipass: 7.0.4 + + /path-to-regexp@0.1.7: + resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==} + dev: true + + /path-to-regexp@2.2.1: + resolution: {integrity: sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ==} + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + /pathe@1.1.1: + resolution: {integrity: sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==} + + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: false + + /pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + dev: true + + /pbkdf2@3.1.2: + resolution: {integrity: sha512-iuh7L6jA7JEGu2WxDwtQP1ddOpaJNC4KlDEFfdQajSGgGPNi4OyDc2R7QnbY2bR9QjBVGwgvTdNJZoE7RaxUMA==} + engines: {node: '>=0.12'} + dependencies: + create-hash: 1.2.0 + create-hmac: 1.1.7 + ripemd160: 2.0.2 + safe-buffer: 5.2.1 + sha.js: 2.4.11 + dev: false + + /peek-stream@1.1.3: + resolution: {integrity: sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==} + dependencies: + buffer-from: 1.1.2 + duplexify: 3.7.1 + through2: 2.0.5 + dev: true + + /pend@1.2.0: + resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} + dev: true + + /picocolors@1.0.0: + resolution: {integrity: sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==} + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + /pify@2.3.0: + resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} + engines: {node: '>=0.10.0'} + + /pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + dev: false + + /pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + /pify@5.0.0: + resolution: {integrity: sha512-eW/gHNMlxdSP6dmG6uJip6FXN0EQBwm2clYYd8Wul42Cwu/DK8HEftzsapcNdYe2MfLiIwZqsDk2RDEsTE79hA==} + engines: {node: '>=10'} + dev: false + + /pino-abstract-transport@0.5.0: + resolution: {integrity: sha512-+KAgmVeqXYbTtU2FScx1XS3kNyfZ5TrXY07V96QnUSFqo2gAqlvmaxH67Lj7SWazqsMabf+58ctdTcBgnOLUOQ==} + dependencies: + duplexify: 4.1.2 + split2: 4.2.0 + dev: false + + /pino-std-serializers@4.0.0: + resolution: {integrity: sha512-cK0pekc1Kjy5w9V2/n+8MkZwusa6EyyxfeQCB799CQRhRt/CqYKiWs5adeu8Shve2ZNffvfC/7J64A2PJo1W/Q==} + dev: false + + /pino@7.11.0: + resolution: {integrity: sha512-dMACeu63HtRLmCG8VKdy4cShCPKaYDR4youZqoSWLxl5Gu99HUw8bw75thbPv9Nip+H+QYX8o3ZJbTdVZZ2TVg==} + hasBin: true + dependencies: + atomic-sleep: 1.0.0 + fast-redact: 3.3.0 + on-exit-leak-free: 0.2.0 + pino-abstract-transport: 0.5.0 + pino-std-serializers: 4.0.0 + process-warning: 1.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.1.0 + safe-stable-stringify: 2.4.3 + sonic-boom: 2.8.0 + thread-stream: 0.15.2 + dev: false + + /pirates@4.0.6: + resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} + engines: {node: '>= 6'} + + /pkg-dir@3.0.0: + resolution: {integrity: sha512-/E57AYkoeQ25qkxMj5PBOVgF8Kiu/h7cYS30Z5+R7WaiCCBfLq58ZI/dSeaEKb9WVJV5n/03QwrN3IeWIFllvw==} + engines: {node: '>=6'} + dependencies: + find-up: 3.0.0 + + /pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + + /pkg-dir@5.0.0: + resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} + engines: {node: '>=10'} + dependencies: + find-up: 5.0.0 + + /pkg-types@1.0.3: + resolution: {integrity: sha512-nN7pYi0AQqJnoLPC9eHFQ8AcyaixBUOwvqc5TDnIKCMEE6I0y8P7OKA7fPexsXGCGxQDl/cmrLAp26LhcwxZ4A==} + dependencies: + jsonc-parser: 3.2.0 + mlly: 1.4.2 + pathe: 1.1.1 + + /pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + dependencies: + find-up: 3.0.0 + dev: true + + /playwright-core@1.43.0: + resolution: {integrity: sha512-iWFjyBUH97+pUFiyTqSLd8cDMMOS0r2ZYz2qEsPjH8/bX++sbIJT35MSwKnp1r/OQBAqC5XO99xFbJ9XClhf4w==} + engines: {node: '>=16'} + hasBin: true + dev: true + + /playwright@1.43.0: + resolution: {integrity: sha512-SiOKHbVjTSf6wHuGCbqrEyzlm6qvXcv7mENP+OZon1I07brfZLGdfWV0l/efAzVx7TF3Z45ov1gPEkku9q25YQ==} + engines: {node: '>=16'} + hasBin: true + dependencies: + playwright-core: 1.43.0 + optionalDependencies: + fsevents: 2.3.2 + dev: true + + /pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + dev: false + + /pofile@1.1.4: + resolution: {integrity: sha512-r6Q21sKsY1AjTVVjOuU02VYKVNQGJNQHjTIvs4dEbeuuYfxgYk/DGD2mqqq4RDaVkwdSq0VEtmQUOPe/wH8X3g==} + dev: true + + /polished@4.2.2: + resolution: {integrity: sha512-Sz2Lkdxz6F2Pgnpi9U5Ng/WdWAUZxmHrNPoVlm3aAemxoy2Qy7LGjQg4uf8qKelDAUW94F4np3iH2YPf2qefcQ==} + engines: {node: '>=10'} + dependencies: + '@babel/runtime': 7.24.4 + + /pony-cause@2.1.10: + resolution: {integrity: sha512-3IKLNXclQgkU++2fSi93sQ6BznFuxSLB11HdvZQ6JW/spahf/P1pAHBQEahr20rs0htZW0UDkM1HmA+nZkXKsw==} + engines: {node: '>=12.0.0'} + dev: false + + /portfinder@1.0.32: + resolution: {integrity: sha512-on2ZJVVDXRADWE6jnQaX0ioEylzgBpQk8r55NE4wjXW1ZxO+BgDlY6DXwj20i0V8eB4SenDQ00WEaxfiIQPcxg==} + engines: {node: '>= 0.12.0'} + dependencies: + async: 2.6.4 + debug: 3.2.7 + mkdirp: 0.5.6 + transitivePeerDependencies: + - supports-color + dev: true + + /postcss-import@15.1.0(postcss@8.4.26): + resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} + engines: {node: '>=14.0.0'} + peerDependencies: + postcss: ^8.0.0 + dependencies: + postcss: 8.4.26 + postcss-value-parser: 4.2.0 + read-cache: 1.0.0 + resolve: 1.22.8 + + /postcss-js@4.0.1(postcss@8.4.26): + resolution: {integrity: sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==} + engines: {node: ^12 || ^14 || >= 16} + peerDependencies: + postcss: ^8.4.21 + dependencies: + camelcase-css: 2.0.1 + postcss: 8.4.26 + + /postcss-load-config@4.0.2(postcss@8.4.26): + resolution: {integrity: sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==} + engines: {node: '>= 14'} + peerDependencies: + postcss: '>=8.0.9' + ts-node: '>=9.0.0' + peerDependenciesMeta: + postcss: + optional: true + ts-node: + optional: true + dependencies: + lilconfig: 3.1.1 + postcss: 8.4.26 + yaml: 2.4.1 + + /postcss-loader@7.3.3(postcss@8.4.26)(webpack@5.91.0): + resolution: {integrity: sha512-YgO/yhtevGO/vJePCQmTxiaEwER94LABZN0ZMT4A0vsak9TpO+RvKRs7EmJ8peIlB9xfXCsS7M8LjqncsUZ5HA==} + engines: {node: '>= 14.15.0'} + peerDependencies: + postcss: ^7.0.0 || ^8.0.1 + webpack: ^5.0.0 + dependencies: + cosmiconfig: 8.2.0 + jiti: 1.19.1 + postcss: 8.4.26 + semver: 7.5.4 + webpack: 5.91.0(esbuild@0.18.20) + dev: true + + /postcss-modules-extract-imports@3.0.0(postcss@8.4.31): + resolution: {integrity: sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.31 + dev: true + + /postcss-modules-local-by-default@4.0.3(postcss@8.4.31): + resolution: {integrity: sha512-2/u2zraspoACtrbFRnTijMiQtb4GW4BvatjaG/bCjYQo8kLTdevCUlwuBHx2sCnSyrI3x3qj4ZK1j5LQBgzmwA==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0(postcss@8.4.31) + postcss: 8.4.31 + postcss-selector-parser: 6.0.16 + postcss-value-parser: 4.2.0 + dev: true + + /postcss-modules-scope@3.0.0(postcss@8.4.31): + resolution: {integrity: sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + postcss: 8.4.31 + postcss-selector-parser: 6.0.16 + dev: true + + /postcss-modules-values@4.0.0(postcss@8.4.31): + resolution: {integrity: sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==} + engines: {node: ^10 || ^12 || >= 14} + peerDependencies: + postcss: ^8.1.0 + dependencies: + icss-utils: 5.1.0(postcss@8.4.31) + postcss: 8.4.31 + dev: true + + /postcss-nested@6.0.1(postcss@8.4.26): + resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + dependencies: + postcss: 8.4.26 + postcss-selector-parser: 6.0.16 + + /postcss-selector-parser@6.0.16: + resolution: {integrity: sha512-A0RVJrX+IUkVZbW3ClroRWurercFhieevHB38sr2+l9eUClMqome3LmEmnhlNy+5Mr2EYN6B2Kaw9wYdd+VHiw==} + engines: {node: '>=4'} + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + /postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + /postcss@8.4.26: + resolution: {integrity: sha512-jrXHFF8iTloAenySjM/ob3gSj7pCu0Ji49hnjqzsgSRa50hkWCKD0HQ+gMNJkW38jBI68MpAAg7ZWwHwX8NMMw==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + + /postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.6 + picocolors: 1.0.0 + source-map-js: 1.0.2 + dev: true + + /postcss@8.4.38: + resolution: {integrity: sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.0.0 + source-map-js: 1.2.0 + dev: true + + /preact@10.18.1: + resolution: {integrity: sha512-mKUD7RRkQQM6s7Rkmi7IFkoEHjuFqRQUaXamO61E6Nn7vqF/bo7EZCmSyrUnp2UWHw0O7XjZ2eeXis+m7tf4lg==} + dev: false + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier-plugin-tailwindcss@0.5.13(prettier@3.2.5): + resolution: {integrity: sha512-2tPWHCFNC+WRjAC4SIWQNSOdcL1NNkydXim8w7TDqlZi+/ulZYz2OouAI6qMtkggnPt7lGamboj6LcTMwcCvoQ==} + engines: {node: '>=14.21.3'} + peerDependencies: + '@ianvs/prettier-plugin-sort-imports': '*' + '@prettier/plugin-pug': '*' + '@shopify/prettier-plugin-liquid': '*' + '@trivago/prettier-plugin-sort-imports': '*' + '@zackad/prettier-plugin-twig-melody': '*' + prettier: ^3.0 + prettier-plugin-astro: '*' + prettier-plugin-css-order: '*' + prettier-plugin-import-sort: '*' + prettier-plugin-jsdoc: '*' + prettier-plugin-marko: '*' + prettier-plugin-organize-attributes: '*' + prettier-plugin-organize-imports: '*' + prettier-plugin-sort-imports: '*' + prettier-plugin-style-order: '*' + prettier-plugin-svelte: '*' + peerDependenciesMeta: + '@ianvs/prettier-plugin-sort-imports': + optional: true + '@prettier/plugin-pug': + optional: true + '@shopify/prettier-plugin-liquid': + optional: true + '@trivago/prettier-plugin-sort-imports': + optional: true + '@zackad/prettier-plugin-twig-melody': + optional: true + prettier-plugin-astro: + optional: true + prettier-plugin-css-order: + optional: true + prettier-plugin-import-sort: + optional: true + prettier-plugin-jsdoc: + optional: true + prettier-plugin-marko: + optional: true + prettier-plugin-organize-attributes: + optional: true + prettier-plugin-organize-imports: + optional: true + prettier-plugin-sort-imports: + optional: true + prettier-plugin-style-order: + optional: true + prettier-plugin-svelte: + optional: true + dependencies: + prettier: 3.2.5 + dev: true + + /prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + dev: true + + /prettier@3.2.5: + resolution: {integrity: sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /pretty-format@26.6.2: + resolution: {integrity: sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==} + engines: {node: '>= 10'} + dependencies: + '@jest/types': 26.6.2 + ansi-regex: 5.0.1 + ansi-styles: 4.3.0 + react-is: 17.0.2 + dev: false + + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + + /pretty-format@28.1.3: + resolution: {integrity: sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==} + engines: {node: ^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0} + dependencies: + '@jest/schemas': 28.1.3 + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 18.2.0 + dev: true + + /pretty-format@29.6.1: + resolution: {integrity: sha512-7jRj+yXO0W7e4/tSJKoR7HRIHLPPjtNaUGG2xxKQnGvPNRkgWcQ0AZX6P4KBRJN4FcTBWb3sa7DVUJmocYuoog==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + dev: true + + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.2.0 + + /pretty-hrtime@1.0.3: + resolution: {integrity: sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==} + engines: {node: '>= 0.8'} + + /process-nextick-args@1.0.7: + resolution: {integrity: sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==} + dev: false + + /process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + /process-warning@1.0.0: + resolution: {integrity: sha512-du4wfLyj4yCZq1VupnVSZmRsPJsNuxoDQFdCFHLaYiEbFBD7QE0a+I4D7hOxrVnh78QE/YipFAj9lXHiXocV+Q==} + dev: false + + /process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: true + + /progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + dev: true + + /promise@8.3.0: + resolution: {integrity: sha512-rZPNPKTOYVNEEKFaq1HqTgOwZD+4/YHS5ukLzQCypkj+OkYx7iv0mA91lJlpPPZ8vMau3IIGj5Qlwrx+8iiSmg==} + dependencies: + asap: 2.0.6 + dev: false + + /prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + /proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + dev: true + + /proxy-compare@2.5.1: + resolution: {integrity: sha512-oyfc0Tx87Cpwva5ZXezSp5V9vht1c7dZBhvuV/y3ctkgMVUmiAGDVeeB0dKhGSyT0v1ZTEQYpe/RXlBVBNuCLA==} + dev: false + + /proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + dev: true + + /prr@1.0.1: + resolution: {integrity: sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==} + requiresBuild: true + dev: true + optional: true + + /pseudolocale@2.0.0: + resolution: {integrity: sha512-g1K9tCQYY4e3UGtnW8qs3kGWAOONxt7i5wuOFvf3N1EIIRhiLVIhZ9AM/ZyGTxsp231JbFywJU/EbJ5ZoqnZdg==} + engines: {node: '>=16.0.0'} + hasBin: true + dependencies: + commander: 10.0.1 + dev: true + + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: true + + /pump@2.0.1: + resolution: {integrity: sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + dev: true + + /pump@3.0.0: + resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + + /pumpify@1.5.1: + resolution: {integrity: sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==} + dependencies: + duplexify: 3.7.1 + inherits: 2.0.4 + pump: 2.0.1 + dev: true + + /punycode@1.4.1: + resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} + dev: true + + /punycode@2.3.0: + resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==} + engines: {node: '>=6'} + dev: true + + /puppeteer-core@2.1.1: + resolution: {integrity: sha512-n13AWriBMPYxnpbb6bnaY5YoY6rGj8vPLrz6CZF3o0qJNEwlcfJVxBzYZ0NJsQ21UbdJoijPCDrM++SUVEz7+w==} + engines: {node: '>=8.16.0'} + dependencies: + '@types/mime-types': 2.1.1 + debug: 4.3.4(supports-color@8.1.1) + extract-zip: 1.7.0 + https-proxy-agent: 4.0.0 + mime: 2.6.0 + mime-types: 2.1.35 + progress: 2.0.3 + proxy-from-env: 1.1.0 + rimraf: 2.7.1 + ws: 6.2.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /qr-code-styling@1.6.0-rc.1: + resolution: {integrity: sha512-ModRIiW6oUnsP18QzrRYZSc/CFKFKIdj7pUs57AEVH20ajlglRpN3HukjHk0UbNMTlKGuaYl7Gt6/O5Gg2NU2Q==} + dependencies: + qrcode-generator: 1.4.4 + dev: false + + /qrcode-generator@1.4.4: + resolution: {integrity: sha512-HM7yY8O2ilqhmULxGMpcHSF1EhJJ9yBj8gvDEuZ6M+KGJ0YY2hKpnXvRD+hZPLrDVck3ExIGhmPtSdcjC+guuw==} + dev: false + + /qrcode-terminal-nooctal@0.12.1: + resolution: {integrity: sha512-jy/kkD0iIMDjTucB+5T6KBsnirlhegDH47vHgrj5MejchSQmi/EAMM0xMFeePgV9CJkkAapNakpVUWYgHvtdKg==} + hasBin: true + dev: false + + /qrcode@1.5.3: + resolution: {integrity: sha512-puyri6ApkEHYiVl4CFzo1tDkAZ+ATcnbJrJ6RiBM1Fhctdn/ix9MTE3hRph33omisEbC/2fcfemsseiKgBPKZg==} + engines: {node: '>=10.13.0'} + hasBin: true + dependencies: + dijkstrajs: 1.0.3 + encode-utf8: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + dev: false + + /qs@6.11.0: + resolution: {integrity: sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.4 + dev: true + + /qs@6.11.2: + resolution: {integrity: sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.4 + + /qs@6.12.0: + resolution: {integrity: sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.6 + + /query-string@7.1.3: + resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} + engines: {node: '>=6'} + dependencies: + decode-uri-component: 0.2.2 + filter-obj: 1.1.0 + split-on-first: 1.1.0 + strict-uri-encode: 2.0.0 + dev: false + + /querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + dev: true + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + /queue@6.0.2: + resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} + dependencies: + inherits: 2.0.4 + dev: false + + /quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + dev: false + + /radix3@1.1.0: + resolution: {integrity: sha512-pNsHDxbGORSvuSScqNJ+3Km6QAVqk8CfsCBIEoDgpqLrkD2f3QM4I7d1ozJJ172OmIcoUcerZaNWqtLkRXTV3A==} + dev: false + + /ramda@0.27.2: + resolution: {integrity: sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==} + dev: true + + /ramda@0.29.0: + resolution: {integrity: sha512-BBea6L67bYLtdbOqfp8f58fPMqEwx0doL+pAi8TZyp2YWz8R9G8z9x75CZI8W+ftqhFHCpEX2cRnUUXK130iKA==} + + /randombytes@2.1.0: + resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + dependencies: + safe-buffer: 5.2.1 + + /range-parser@1.2.0: + resolution: {integrity: sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==} + engines: {node: '>= 0.6'} + dev: true + + /range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + /raw-body@2.5.1: + resolution: {integrity: sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: true + + /raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: false + + /rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + dev: true + + /react-colorful@5.6.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /react-confetti@6.1.0(react@18.2.0): + resolution: {integrity: sha512-7Ypx4vz0+g8ECVxr88W9zhcQpbeujJAVqL14ZnXJ3I23mOI9/oBVTQ3dkJhUmB0D6XOtCZEM6N0Gm9PMngkORw==} + engines: {node: '>=10.18'} + peerDependencies: + react: ^16.3.0 || ^17.0.1 || ^18.0.0 + dependencies: + react: 18.2.0 + tween-functions: 1.2.0 + dev: false + + /react-devtools-core@4.28.5: + resolution: {integrity: sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==} + dependencies: + shell-quote: 1.8.1 + ws: 7.5.9 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + dev: false + + /react-docgen-typescript@2.2.2(typescript@5.4.5): + resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==} + peerDependencies: + typescript: '>= 4.3.x' + dependencies: + typescript: 5.4.5 + dev: true + + /react-docgen@6.0.4: + resolution: {integrity: sha512-gF+p+1ZwC2eO66bt763Tepmh5q9kDiFIrqW3YjUV/a+L96h0m5+/wSFQoOHL2cffyrPMZMxP03IgbggJ11QbOw==} + engines: {node: '>=14.18.0'} + dependencies: + '@babel/core': 7.22.9 + '@babel/traverse': 7.22.8 + '@babel/types': 7.23.0 + '@types/babel__core': 7.20.3 + '@types/babel__traverse': 7.20.3 + '@types/doctrine': 0.0.6 + '@types/resolve': 1.20.4 + doctrine: 3.0.0 + resolve: 1.22.2 + strip-indent: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /react-dom@18.2.0(react@18.2.0): + resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} + peerDependencies: + react: ^18.2.0 + dependencies: + loose-envify: 1.4.0 + react: 18.2.0 + scheduler: 0.23.0 + + /react-element-to-jsx-string@15.0.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==} + peerDependencies: + react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + dependencies: + '@base2/pretty-print-object': 1.0.1 + is-plain-object: 5.0.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-is: 18.1.0 + dev: true + + /react-hook-form@7.48.2(react@18.2.0): + resolution: {integrity: sha512-H0T2InFQb1hX7qKtDIZmvpU1Xfn/bdahWBN1fH19gSe4bBEqTfmlr7H3XWTaVtiK4/tpPaI1F3355GPMZYge+A==} + engines: {node: '>=12.22.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + dependencies: + react: 18.2.0 + dev: false + + /react-hot-toast@2.4.1(csstype@3.1.3)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-j8z+cQbWIM5LY37pR6uZR6D4LfseplqnuAO4co4u8917hBUvXlEqyP1ZzqVLcqoyUesZZv/ImreoCeHVDpE5pQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16' + react-dom: '>=16' + dependencies: + goober: 2.1.13(csstype@3.1.3) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + transitivePeerDependencies: + - csstype + dev: false + + /react-i18next@13.5.0(i18next@22.5.1)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0): + resolution: {integrity: sha512-CFJ5NDGJ2MUyBohEHxljOq/39NQ972rh1ajnadG9BjTk+UXbHLq4z5DKEbEQBDoIhUmmbuS/fIMJKo6VOax1HA==} + peerDependencies: + i18next: '>= 23.2.3' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.24.4 + html-parse-stringify: 3.0.1 + i18next: 22.5.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-native: 0.73.6(@babel/core@7.22.9)(@babel/preset-env@7.22.9)(react@18.2.0) + dev: false + + /react-inspector@6.0.2(react@18.2.0): + resolution: {integrity: sha512-x+b7LxhmHXjHoU/VrFAzw5iutsILRoYyDq97EDYdFpPLcvqtEzk4ZSZSQjnFPbr5T57tLXnHcqFYoN1pI6u8uQ==} + peerDependencies: + react: ^16.8.4 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + /react-is@18.1.0: + resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==} + dev: true + + /react-is@18.2.0: + resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==} + + /react-native-webview@11.26.1(react-native@0.73.6)(react@18.2.0): + resolution: {integrity: sha512-hC7BkxOpf+z0UKhxFSFTPAM4shQzYmZHoELa6/8a/MspcjEP7ukYKpuSUTLDywQditT8yI9idfcKvfZDKQExGw==} + peerDependencies: + react: '*' + react-native: '*' + dependencies: + escape-string-regexp: 2.0.0 + invariant: 2.2.4 + react: 18.2.0 + react-native: 0.73.6(@babel/core@7.22.9)(@babel/preset-env@7.22.9)(react@18.2.0) + dev: false + + /react-native@0.73.6(@babel/core@7.22.9)(@babel/preset-env@7.22.9)(react@18.2.0): + resolution: {integrity: sha512-oqmZe8D2/VolIzSPZw+oUd6j/bEmeRHwsLn1xLA5wllEYsZ5zNuMsDus235ONOnCRwexqof/J3aztyQswSmiaA==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + react: 18.2.0 + dependencies: + '@jest/create-cache-key-function': 29.7.0 + '@react-native-community/cli': 12.3.6 + '@react-native-community/cli-platform-android': 12.3.6 + '@react-native-community/cli-platform-ios': 12.3.6 + '@react-native/assets-registry': 0.73.1 + '@react-native/codegen': 0.73.3(@babel/preset-env@7.22.9) + '@react-native/community-cli-plugin': 0.73.17(@babel/core@7.22.9)(@babel/preset-env@7.22.9) + '@react-native/gradle-plugin': 0.73.4 + '@react-native/js-polyfills': 0.73.1 + '@react-native/normalize-colors': 0.73.2 + '@react-native/virtualized-lists': 0.73.4(react-native@0.73.6) + abort-controller: 3.0.0 + anser: 1.4.10 + ansi-regex: 5.0.1 + base64-js: 1.5.1 + chalk: 4.1.2 + deprecated-react-native-prop-types: 5.0.0 + event-target-shim: 5.0.1 + flow-enums-runtime: 0.0.6 + invariant: 2.2.4 + jest-environment-node: 29.7.0 + jsc-android: 250231.0.0 + memoize-one: 5.2.1 + metro-runtime: 0.80.8 + metro-source-map: 0.80.8 + mkdirp: 0.5.6 + nullthrows: 1.1.1 + pretty-format: 26.6.2 + promise: 8.3.0 + react: 18.2.0 + react-devtools-core: 4.28.5 + react-refresh: 0.14.0 + react-shallow-renderer: 16.15.0(react@18.2.0) + regenerator-runtime: 0.13.11 + scheduler: 0.24.0-canary-efb381bbf-20230505 + stacktrace-parser: 0.1.10 + whatwg-fetch: 3.6.20 + ws: 6.2.2 + yargs: 17.7.2 + transitivePeerDependencies: + - '@babel/core' + - '@babel/preset-env' + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: false + + /react-refresh@0.14.0: + resolution: {integrity: sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==} + engines: {node: '>=0.10.0'} + + /react-remove-scroll-bar@2.3.4(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.14 + react: 18.2.0 + react-style-singleton: 2.2.1(@types/react@18.2.14)(react@18.2.0) + tslib: 2.6.2 + + /react-remove-scroll@2.5.5(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.14 + react: 18.2.0 + react-remove-scroll-bar: 2.3.4(@types/react@18.2.14)(react@18.2.0) + react-style-singleton: 2.2.1(@types/react@18.2.14)(react@18.2.0) + tslib: 2.6.2 + use-callback-ref: 1.3.0(@types/react@18.2.14)(react@18.2.0) + use-sidecar: 1.1.2(@types/react@18.2.14)(react@18.2.0) + + /react-remove-scroll@2.5.7(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.14 + react: 18.2.0 + react-remove-scroll-bar: 2.3.4(@types/react@18.2.14)(react@18.2.0) + react-style-singleton: 2.2.1(@types/react@18.2.14)(react@18.2.0) + tslib: 2.6.2 + use-callback-ref: 1.3.0(@types/react@18.2.14)(react@18.2.0) + use-sidecar: 1.1.2(@types/react@18.2.14)(react@18.2.0) + dev: false + + /react-router-dom@6.14.2(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5pWX0jdKR48XFZBuJqHosX3AAHjRAzygouMTyimnBPOLdY3WjzUSKhus2FVMihUFWzeLebDgr4r8UeQFAct7Bg==} + engines: {node: '>=14'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + dependencies: + '@remix-run/router': 1.7.2 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-router: 6.14.2(react@18.2.0) + dev: false + + /react-router@6.14.2(react@18.2.0): + resolution: {integrity: sha512-09Zss2dE2z+T1D03IheqAFtK4UzQyX8nFPWx6jkwdYzGLXd5ie06A6ezS2fO6zJfEb/SpG6UocN2O1hfD+2urQ==} + engines: {node: '>=14'} + peerDependencies: + react: '>=16.8' + dependencies: + '@remix-run/router': 1.7.2 + react: 18.2.0 + dev: false + + /react-shallow-renderer@16.15.0(react@18.2.0): + resolution: {integrity: sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==} + peerDependencies: + react: ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + object-assign: 4.1.1 + react: 18.2.0 + react-is: 18.2.0 + dev: false + + /react-style-singleton@2.2.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.14 + get-nonce: 1.0.1 + invariant: 2.2.4 + react: 18.2.0 + tslib: 2.6.2 + + /react-use-measure@2.1.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + dependencies: + debounce: 1.2.1 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /react@18.2.0: + resolution: {integrity: sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + + /read-cache@1.0.0: + resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + dependencies: + pify: 2.3.0 + + /read-pkg-up@7.0.1: + resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + read-pkg: 5.2.0 + type-fest: 0.8.1 + dev: true + + /read-pkg@5.2.0: + resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} + engines: {node: '>=8'} + dependencies: + '@types/normalize-package-data': 2.4.1 + normalize-package-data: 2.5.0 + parse-json: 5.2.0 + type-fest: 0.6.0 + dev: true + + /readable-stream@2.3.3: + resolution: {integrity: sha512-m+qzzcn7KUxEmd1gMbchF+Y2eIUbieUaxkWtptyHywrX0rE8QEYqPC07Vuy4Wm32/xE16NcdBctb8S0Xe/5IeQ==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 1.0.7 + safe-buffer: 5.1.2 + string_decoder: 1.0.3 + util-deprecate: 1.0.2 + dev: false + + /readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + /readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + /readdirp@3.5.0: + resolution: {integrity: sha512-cMhu7c/8rdhkHXWsY+osBhfSy0JikwpHK/5+imo+LpeasTF8ouErHrlYkwT0++njiyuDvc7OFY5T3ukvZ8qmFQ==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + dev: true + + /readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + dependencies: + picomatch: 2.3.1 + + /readline@1.3.0: + resolution: {integrity: sha512-k2d6ACCkiNYz222Fs/iNze30rRJ1iIicW7JuX/7/cozvih6YCkFZH+J6mAFDVgv0dRBaAyr4jDqC95R2y4IADg==} + dev: false + + /real-require@0.1.0: + resolution: {integrity: sha512-r/H9MzAWtrv8aSVjPCMFpDMl5q66GqtmmRkRjpHTsp4zBAa+snZyiQNlMONiUmEJcsnaw0wCauJ2GWODr/aFkg==} + engines: {node: '>= 12.13.0'} + dev: false + + /recast@0.21.5: + resolution: {integrity: sha512-hjMmLaUXAm1hIuTqOdeYObMslq/q+Xff6QE3Y2P+uoHAg2nmVlLBps2hzh1UJDdMtDTMXOFewK6ky51JQIeECg==} + engines: {node: '>= 4'} + dependencies: + ast-types: 0.15.2 + esprima: 4.0.1 + source-map: 0.6.1 + tslib: 2.6.2 + + /recast@0.23.3: + resolution: {integrity: sha512-HbCVFh2ANP6a09nzD4lx7XthsxMOJWKX5pIcUwtLrmeEIl3I0DwjCoVXDE0Aobk+7k/mS3H50FK4iuYArpcT6Q==} + engines: {node: '>= 4'} + dependencies: + assert: 2.0.0 + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tslib: 2.6.2 + dev: true + + /redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + + /redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + dev: false + + /redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: false + + /reduce-css-calc@1.3.0: + resolution: {integrity: sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==} + dependencies: + balanced-match: 0.4.2 + math-expression-evaluator: 1.4.0 + reduce-function-call: 1.0.3 + dev: false + + /reduce-function-call@1.0.3: + resolution: {integrity: sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==} + dependencies: + balanced-match: 1.0.2 + dev: false + + /reflect.getprototypeof@1.0.4: + resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.1 + es-abstract: 1.22.1 + get-intrinsic: 1.2.1 + globalthis: 1.0.3 + which-builtin-type: 1.1.3 + dev: true + + /regenerate-unicode-properties@10.1.0: + resolution: {integrity: sha512-d1VudCLoIGitcU/hEg2QqvyGZQmdC0Lf8BqdOMXGFSvJP4bNV1+XqbPQeHHLD51Jh4QJJ225dlIFvY4Ly6MXmQ==} + engines: {node: '>=4'} + dependencies: + regenerate: 1.4.2 + + /regenerate@1.4.2: + resolution: {integrity: sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==} + + /regenerator-runtime@0.13.11: + resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + /regenerator-transform@0.15.1: + resolution: {integrity: sha512-knzmNAcuyxV+gQCufkYcvOqX/qIIfHLv0u5x79kRxuGojfYVky1f15TzZEu2Avte8QGepvUNTnLskf8E6X6Vyg==} + dependencies: + '@babel/runtime': 7.24.4 + + /regex-parser@2.2.11: + resolution: {integrity: sha512-jbD/FT0+9MBU2XAZluI7w2OBs1RBi6p9M83nkoZayQXXU9e8Robt69FcZc7wU4eJD/YFTjn1JdCk3rbMJajz8Q==} + dev: true + + /regexp.prototype.flags@1.5.0: + resolution: {integrity: sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + dev: true + + /regexpu-core@5.3.2: + resolution: {integrity: sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==} + engines: {node: '>=4'} + dependencies: + '@babel/regjsgen': 0.8.0 + regenerate: 1.4.2 + regenerate-unicode-properties: 10.1.0 + regjsparser: 0.9.1 + unicode-match-property-ecmascript: 2.0.0 + unicode-match-property-value-ecmascript: 2.1.0 + + /registry-auth-token@3.3.2: + resolution: {integrity: sha512-JL39c60XlzCVgNrO+qq68FoNb56w/m7JYvGR2jT5iR1xBrUA3Mfx5Twk5rqTThPmQKMWydGmq8oFtDlxfrmxnQ==} + dependencies: + rc: 1.2.8 + safe-buffer: 5.2.1 + dev: true + + /registry-url@3.1.0: + resolution: {integrity: sha512-ZbgR5aZEdf4UKZVBPYIgaglBmSF2Hi94s2PcIHhRGFjKYu+chjJdYfHn4rt3hB6eCKLJ8giVIIfgMa1ehDfZKA==} + engines: {node: '>=0.10.0'} + dependencies: + rc: 1.2.8 + dev: true + + /regjsparser@0.9.1: + resolution: {integrity: sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==} + hasBin: true + dependencies: + jsesc: 0.5.0 + + /remark-external-links@8.0.0: + resolution: {integrity: sha512-5vPSX0kHoSsqtdftSHhIYofVINC8qmp0nctkeU9YoJwV3YfiBRiI6cbFRJ0oI/1F9xS+bopXG0m2KS8VFscuKA==} + dependencies: + extend: 3.0.2 + is-absolute-url: 3.0.3 + mdast-util-definitions: 4.0.0 + space-separated-tokens: 1.1.5 + unist-util-visit: 2.0.3 + dev: true + + /remark-slug@6.1.0: + resolution: {integrity: sha512-oGCxDF9deA8phWvxFuyr3oSJsdyUAxMFbA0mZ7Y1Sas+emILtO+e5WutF9564gDsEN4IXaQXm5pFo6MLH+YmwQ==} + dependencies: + github-slugger: 1.5.0 + mdast-util-to-string: 1.1.0 + unist-util-visit: 2.0.3 + dev: true + + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: false + + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + /require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + dev: false + + /requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + + /resolve-from@3.0.0: + resolution: {integrity: sha512-GnlH6vxLymXJNMBo7XP1fJIzBFbdYt49CuTwmB/6N53t+kMPRMFKz783LlQ4tv28XoQfMWinAJX6WCGf2IlaIw==} + engines: {node: '>=4'} + dev: false + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + /resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + /resolve-url-loader@5.0.0: + resolution: {integrity: sha512-uZtduh8/8srhBoMx//5bwqjQ+rfYOUq8zC9NrMUGtjBiGTtFJM42s58/36+hTqeqINcnYe08Nj3LkK9lW4N8Xg==} + engines: {node: '>=12'} + dependencies: + adjust-sourcemap-loader: 4.0.0 + convert-source-map: 1.9.0 + loader-utils: 2.0.4 + postcss: 8.4.31 + source-map: 0.6.1 + dev: true + + /resolve@1.17.0: + resolution: {integrity: sha512-ic+7JYiV8Vi2yzQGFWOkiZD5Z9z7O2Zhm9XMaTxdJExKasieFCr+yXZ/WmXsckHiKl12ar0y6XiXDx3m4RHn1w==} + dependencies: + path-parse: 1.0.7 + dev: false + + /resolve@1.22.2: + resolution: {integrity: sha512-Sb+mjNHOULsBv818T40qSPeRiuWLyaGMa5ewydRLFimneixmVy2zdivRl+AF6jaYPC8ERxGDmFSiqui6SfPd+g==} + hasBin: true + dependencies: + is-core-module: 2.12.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + /resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + dependencies: + is-core-module: 2.13.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + + /restore-cursor@4.0.0: + resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + /rimraf@2.6.3: + resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} + hasBin: true + dependencies: + glob: 7.2.3 + + /rimraf@2.7.1: + resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + hasBin: true + dependencies: + glob: 7.2.3 + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + hasBin: true + dependencies: + glob: 7.2.3 + + /ripemd160@2.0.2: + resolution: {integrity: sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==} + dependencies: + hash-base: 3.1.0 + inherits: 2.0.4 + dev: false + + /rlp@2.2.7: + resolution: {integrity: sha512-d5gdPmgQ0Z+AklL2NVXr/IoSjNZFfTVvQWzL/AM2AOcSzYP2xjlb0AC8YyCLc41MSNf6P6QVtjgPdmVtzb+4lQ==} + hasBin: true + dependencies: + bn.js: 5.2.1 + dev: false + + /robust-predicates@3.0.2: + resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==} + dev: false + + /rollup-plugin-visualizer@5.12.0: + resolution: {integrity: sha512-8/NU9jXcHRs7Nnj07PF2o4gjxmm9lXIrZ8r175bT9dK8qoLlvKTwRMArRCMgpMGlq8CTLugRvEmyMeMXIU2pNQ==} + engines: {node: '>=14'} + hasBin: true + peerDependencies: + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rollup: + optional: true + dependencies: + open: 8.4.2 + picomatch: 2.3.1 + source-map: 0.7.4 + yargs: 17.7.2 + dev: false + + /rollup@3.29.4: + resolution: {integrity: sha512-oWzmBZwvYrU0iJHtDmhsm662rC15FRXmcjCk1xD771dFDx5jJ02ufAQQTn0etB2emNk4J9EZg/yWKpsn9BWGRw==} + engines: {node: '>=14.18.0', npm: '>=8.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /rrweb-cssom@0.6.0: + resolution: {integrity: sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==} + dev: true + + /run-async@2.4.1: + resolution: {integrity: sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==} + engines: {node: '>=0.12.0'} + dev: true + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + + /rxjs@6.6.7: + resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} + engines: {npm: '>=2.0.0'} + dependencies: + tslib: 1.14.1 + dev: true + + /safe-array-concat@1.0.0: + resolution: {integrity: sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==} + engines: {node: '>=0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + isarray: 2.0.5 + dev: true + + /safe-array-concat@1.0.1: + resolution: {integrity: sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==} + engines: {node: '>=0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + isarray: 2.0.5 + dev: true + + /safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + /safe-regex-test@1.0.0: + resolution: {integrity: sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + is-regex: 1.1.4 + dev: true + + /safe-stable-stringify@2.4.3: + resolution: {integrity: sha512-e2bDA2WJT0wxseVd4lsDP4+3ONX6HpMXQa1ZhFQ7SU+GjvORCmShbCMltrtIDfkYhVHrOcPtj+KhmDBdPdZD1g==} + engines: {node: '>=10'} + dev: false + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + /sass-loader@13.3.2(webpack@5.91.0): + resolution: {integrity: sha512-CQbKl57kdEv+KDLquhC+gE3pXt74LEAzm+tzywcA0/aHZuub8wTErbjAoNI57rPUWRYRNC5WUnNl8eGJNbDdwg==} + engines: {node: '>= 14.15.0'} + peerDependencies: + fibers: '>= 3.1.0' + node-sass: ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 + sass: ^1.3.0 + sass-embedded: '*' + webpack: ^5.0.0 + peerDependenciesMeta: + fibers: + optional: true + node-sass: + optional: true + sass: + optional: true + sass-embedded: + optional: true + dependencies: + neo-async: 2.6.2 + webpack: 5.91.0(esbuild@0.18.20) + dev: true + + /sax@1.3.0: + resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==} + requiresBuild: true + dev: true + optional: true + + /saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + dependencies: + xmlchars: 2.2.0 + dev: true + + /scheduler@0.23.0: + resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + dependencies: + loose-envify: 1.4.0 + + /scheduler@0.24.0-canary-efb381bbf-20230505: + resolution: {integrity: sha512-ABvovCDe/k9IluqSh4/ISoq8tIJnW8euVAWYt5j/bg6dRnqwQwiGO1F/V4AyK96NGF/FB04FhOUDuWj8IKfABA==} + dependencies: + loose-envify: 1.4.0 + dev: false + + /schema-utils@3.3.0: + resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} + engines: {node: '>= 10.13.0'} + dependencies: + '@types/json-schema': 7.0.15 + ajv: 6.12.6 + ajv-keywords: 3.5.2(ajv@6.12.6) + dev: true + + /scrypt-js@3.0.1: + resolution: {integrity: sha512-cdwTTnqPu0Hyvf5in5asVdZocVDTNRmR7XEcJuIzMjJeSHybHl7vpB66AzwTaIg6CLSbtjcxc8fqcySfnTkccA==} + dev: false + + /secp256k1@4.0.3: + resolution: {integrity: sha512-NLZVf+ROMxwtEj3Xa562qgv2BK5e2WNmXPiOdVIPLgs6lyTzMvBq0aWTYMI5XCP9jZMVKOcqZLw/Wc4vDkuxhA==} + engines: {node: '>=10.0.0'} + requiresBuild: true + dependencies: + elliptic: 6.5.5 + node-addon-api: 2.0.2 + node-gyp-build: 4.8.0 + dev: false + + /secp256k1@5.0.0: + resolution: {integrity: sha512-TKWX8xvoGHrxVdqbYeZM9w+izTF4b9z3NhSaDkdn81btvuh+ivbIMGT/zQvDtTFWhRlThpoz6LEYTr7n8A5GcA==} + engines: {node: '>=14.0.0'} + requiresBuild: true + dependencies: + elliptic: 6.5.5 + node-addon-api: 5.1.0 + node-gyp-build: 4.8.0 + dev: false + + /secure-compare@3.0.1: + resolution: {integrity: sha512-AckIIV90rPDcBcglUwXPF3kg0P0qmPsPXAj6BBEENQE1p5yA1xfmDJzfi1Tappj37Pv2mVbKpL3Z1T+Nn7k1Qw==} + dev: true + + /semver@5.7.2: + resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} + hasBin: true + + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + /semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + + /semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: false + + /send@0.18.0: + resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} + engines: {node: '>= 0.8.0'} + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + + /sentence-case@3.0.4: + resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} + dependencies: + no-case: 3.0.4 + tslib: 2.6.2 + upper-case-first: 2.0.2 + dev: true + + /serialize-error@2.1.0: + resolution: {integrity: sha512-ghgmKt5o4Tly5yEG/UJp8qTd0AN7Xalw4XBtDEKP655B699qMEtra1WlXeE6WIvdEG481JvRxULKsInq/iNysw==} + engines: {node: '>=0.10.0'} + dev: false + + /serialize-javascript@6.0.0: + resolution: {integrity: sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==} + dependencies: + randombytes: 2.1.0 + dev: false + + /serialize-javascript@6.0.2: + resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==} + dependencies: + randombytes: 2.1.0 + dev: true + + /serve-handler@6.1.5: + resolution: {integrity: sha512-ijPFle6Hwe8zfmBxJdE+5fta53fdIY0lHISJvuikXB3VYFafRjMRpOffSPvCYsbKyBA7pvy9oYr/BT1O3EArlg==} + dependencies: + bytes: 3.0.0 + content-disposition: 0.5.2 + fast-url-parser: 1.1.3 + mime-types: 2.1.18 + minimatch: 3.1.2 + path-is-inside: 1.0.2 + path-to-regexp: 2.2.1 + range-parser: 1.2.0 + dev: true + + /serve-static@1.15.0: + resolution: {integrity: sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==} + engines: {node: '>= 0.8.0'} + dependencies: + encodeurl: 1.0.2 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.18.0 + transitivePeerDependencies: + - supports-color + + /serve@14.2.1: + resolution: {integrity: sha512-48er5fzHh7GCShLnNyPBRPEjs2I6QBozeGr02gaacROiyS/8ARADlj595j39iZXAqBbJHH/ivJJyPRWY9sQWZA==} + engines: {node: '>= 14'} + hasBin: true + dependencies: + '@zeit/schemas': 2.29.0 + ajv: 8.11.0 + arg: 5.0.2 + boxen: 7.0.0 + chalk: 5.0.1 + chalk-template: 0.4.0 + clipboardy: 3.0.0 + compression: 1.7.4 + is-port-reachable: 4.0.0 + serve-handler: 6.1.5 + update-check: 1.5.4 + transitivePeerDependencies: + - supports-color + dev: true + + /set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + + /set-function-name@2.0.1: + resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.1 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.0 + dev: true + + /setimmediate@1.0.5: + resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + dev: false + + /setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + /sha.js@2.4.11: + resolution: {integrity: sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==} + hasBin: true + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + dev: false + + /shallow-clone@3.0.1: + resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} + engines: {node: '>=8'} + dependencies: + kind-of: 6.0.3 + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + /shell-quote@1.8.1: + resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + dev: false + + /side-channel@1.0.4: + resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + object-inspect: 1.12.3 + + /side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.1 + + /siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + /simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + dependencies: + semver: 7.5.4 + dev: true + + /sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + /size-limit@10.0.1: + resolution: {integrity: sha512-lANGZsG+kk5P9zv84J/ratfT6LPTbWR8IitFXNfy41OyfnxoDBHqsl5+JVcG4YSU1UYcmShbp99O/SEPlDs1ZA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + bytes-iec: 3.1.1 + chokidar: 3.5.3 + globby: 13.2.2 + lilconfig: 2.1.0 + nanospinner: 1.1.0 + picocolors: 1.0.0 + dev: false + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + /slash@4.0.0: + resolution: {integrity: sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==} + engines: {node: '>=12'} + + /slice-ansi@2.1.0: + resolution: {integrity: sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==} + engines: {node: '>=6'} + dependencies: + ansi-styles: 3.2.1 + astral-regex: 1.0.0 + is-fullwidth-code-point: 2.0.0 + dev: false + + /snake-case@3.0.4: + resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} + dependencies: + dot-case: 3.0.4 + tslib: 2.6.2 + dev: true + + /socket.io-client@4.7.4(bufferutil@4.0.8)(utf-8-validate@6.0.3): + resolution: {integrity: sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.4(supports-color@8.1.1) + engine.io-client: 6.5.3(bufferutil@4.0.8)(utf-8-validate@6.0.3) + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.0 + debug: 4.3.4(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + dev: false + + /solc@0.7.3(debug@4.3.4): + resolution: {integrity: sha512-GAsWNAjGzIDg7VxzP6mPjdurby3IkGCjQcM8GFYZT6RyaoUZKmMU6Y7YwG+tFGhv7dwZ8rmR4iwFDrrD99JwqA==} + engines: {node: '>=8.0.0'} + hasBin: true + dependencies: + command-exists: 1.2.9 + commander: 3.0.2 + follow-redirects: 1.15.6(debug@4.3.4) + fs-extra: 0.30.0 + js-sha3: 0.8.0 + memorystream: 0.3.1 + require-from-string: 2.0.2 + semver: 5.7.2 + tmp: 0.0.33 + transitivePeerDependencies: + - debug + dev: false + + /sonic-boom@2.8.0: + resolution: {integrity: sha512-kuonw1YOYYNOve5iHdSahXPOK49GqwA+LZhI6Wz/l0rP57iKyXXIHaRagOBHAPmGwJC6od2Z9zgvZ5loSgMlVg==} + dependencies: + atomic-sleep: 1.0.0 + dev: false + + /source-map-js@1.0.2: + resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==} + engines: {node: '>=0.10.0'} + + /source-map-js@1.2.0: + resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + /source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + dev: false + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + /source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + dev: false + + /source-map@0.8.0-beta.0: + resolution: {integrity: sha512-2ymg6oRBpebeZi9UUNsgQ89bhx01TcTkmNTGnNO88imTmbSgy4nfujrgVEFKWpMTEGA11EDkTt7mqObTPdigIA==} + engines: {node: '>= 8'} + dependencies: + whatwg-url: 7.1.0 + dev: true + + /space-separated-tokens@1.1.5: + resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==} + dev: true + + /spdx-correct@3.2.0: + resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} + dependencies: + spdx-expression-parse: 3.0.1 + spdx-license-ids: 3.0.13 + dev: true + + /spdx-exceptions@2.3.0: + resolution: {integrity: sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==} + dev: true + + /spdx-expression-parse@3.0.1: + resolution: {integrity: sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==} + dependencies: + spdx-exceptions: 2.3.0 + spdx-license-ids: 3.0.13 + dev: true + + /spdx-license-ids@3.0.13: + resolution: {integrity: sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==} + dev: true + + /split-on-first@1.1.0: + resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} + engines: {node: '>=6'} + dev: false + + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + /stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + dependencies: + escape-string-regexp: 2.0.0 + + /stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + + /stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + dev: false + + /stacktrace-parser@0.1.10: + resolution: {integrity: sha512-KJP1OCML99+8fhOHxwwzyWrlUuVX5GQ0ZpJTd1DFXhdkrvg1szxfHhawXUZ3g9TkXORQd4/WG68jMlQZ2p8wlg==} + engines: {node: '>=6'} + dependencies: + type-fest: 0.7.1 + dev: false + + /standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false + + /statuses@1.5.0: + resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} + engines: {node: '>= 0.6'} + dev: false + + /statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + + /std-env@3.3.3: + resolution: {integrity: sha512-Rz6yejtVyWnVjC1RFvNmYL10kgjC49EOghxWn0RFqlCHGFpQx+Xe7yW3I4ceK1SGrWIGMjD5Kbue8W/udkbMJg==} + dev: true + + /std-env@3.5.0: + resolution: {integrity: sha512-JGUEaALvL0Mf6JCfYnJOTcobY+Nc7sG/TemDRBqCA0wEr4DER7zDchaaixTlmOxAjG1uRJmX82EQcxwTQTkqVA==} + dev: true + + /std-env@3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + dev: false + + /stdin-discarder@0.1.0: + resolution: {integrity: sha512-xhV7w8S+bUwlPTb4bAOUQhv8/cSS5offJuX8GQGq32ONF0ZtDWKfkdomM3HMRA+LhX6um/FZ0COqlwsjD53LeQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + bl: 5.1.0 + dev: true + + /stop-iteration-iterator@1.0.0: + resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==} + engines: {node: '>= 0.4'} + dependencies: + internal-slot: 1.0.5 + dev: true + + /store2@2.14.2: + resolution: {integrity: sha512-siT1RiqlfQnGqgT/YzXVUNsom9S0H1OX+dpdGN1xkyYATo4I6sep5NmsRD/40s3IIOvlCq6akxkqG82urIZW1w==} + + /store2@2.14.3: + resolution: {integrity: sha512-4QcZ+yx7nzEFiV4BMLnr/pRa5HYzNITX2ri0Zh6sT9EyQHbBHacC6YigllUPU9X3D0f/22QCgfokpKs52YRrUg==} + + /storybook-addon-pseudo-states@2.1.2(@storybook/components@7.6.17)(@storybook/core-events@7.6.17)(@storybook/manager-api@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/theming@7.6.17)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-AHv6q1JiQEUnMyZE3729iV6cNmBW7bueeytc4Lga4+8W1En8YNea5VjqAdrDNJhXVU0QEEIGtxkD3EoC9aVWLw==} + peerDependencies: + '@storybook/components': ^7.4.6 + '@storybook/core-events': ^7.4.6 + '@storybook/manager-api': ^7.4.6 + '@storybook/preview-api': ^7.4.6 + '@storybook/theming': ^7.4.6 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/components': 7.6.17(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.6.17 + '@storybook/manager-api': 7.6.17(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.6.17 + '@storybook/theming': 7.6.17(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: true + + /storybook-addon-react-router-v6@2.0.8(@storybook/blocks@7.5.1)(@storybook/channels@7.6.17)(@storybook/components@7.6.17)(@storybook/core-events@7.6.17)(@storybook/manager-api@7.6.17)(@storybook/preview-api@7.6.17)(@storybook/theming@7.6.17)(react-dom@18.2.0)(react-router-dom@6.14.2)(react@18.2.0): + resolution: {integrity: sha512-Pi97ccelVq6RiLxSg8/puVeiNQnpdl5WlEcpF2wn6IxKlOIpOBOv70W1VGemUpAmJur+7EM79Mf6vMXiRK2W0g==} + peerDependencies: + '@storybook/blocks': ^7.0.0 + '@storybook/channels': ^7.0.0 + '@storybook/components': ^7.0.0 + '@storybook/core-events': ^7.0.0 + '@storybook/manager-api': ^7.0.0 + '@storybook/preview-api': ^7.0.0 + '@storybook/theming': ^7.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-router-dom: ^6.4.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/blocks': 7.5.1(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/channels': 7.6.17 + '@storybook/components': 7.6.17(@types/react-dom@18.2.6)(@types/react@18.2.14)(react-dom@18.2.0)(react@18.2.0) + '@storybook/core-events': 7.6.17 + '@storybook/manager-api': 7.6.17(react-dom@18.2.0)(react@18.2.0) + '@storybook/preview-api': 7.6.17 + '@storybook/theming': 7.6.17(react-dom@18.2.0)(react@18.2.0) + compare-versions: 6.1.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + react-inspector: 6.0.2(react@18.2.0) + react-router-dom: 6.14.2(react-dom@18.2.0)(react@18.2.0) + dev: false + + /storybook@7.5.1: + resolution: {integrity: sha512-Wg3j3z5H03PYnEcmlnhf6bls0OtjmsNPsQ93dTV8F4AweqBECwzjf94Wj++NrP3X+WbfMoCbBU6LRFuEyzCCxw==} + hasBin: true + dependencies: + '@storybook/cli': 7.5.1 + transitivePeerDependencies: + - bufferutil + - encoding + - supports-color + - utf-8-validate + dev: true + + /stream-shift@1.0.1: + resolution: {integrity: sha512-AiisoFqQ0vbGcZgQPY1cdP2I76glaVA/RauYR4G4thNFgkTqr90yXTo4LYX60Jl+sIlPNHHdGSwo01AvbKUSVQ==} + + /strict-uri-encode@2.0.0: + resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} + engines: {node: '>=4'} + dev: false + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + + /string.prototype.matchall@4.0.10: + resolution: {integrity: sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + get-intrinsic: 1.2.1 + has-symbols: 1.0.3 + internal-slot: 1.0.5 + regexp.prototype.flags: 1.5.0 + set-function-name: 2.0.1 + side-channel: 1.0.4 + dev: true + + /string.prototype.trim@1.2.7: + resolution: {integrity: sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + dev: true + + /string.prototype.trimend@1.0.6: + resolution: {integrity: sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + dev: true + + /string.prototype.trimstart@1.0.6: + resolution: {integrity: sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==} + dependencies: + call-bind: 1.0.2 + define-properties: 1.2.0 + es-abstract: 1.22.1 + dev: true + + /string_decoder@1.0.3: + resolution: {integrity: sha512-4AH6Z5fzNNBcH+6XDMfA/BTt87skxqJlO0lAh3Dker5zThcAxG6mKz+iGu308UKoPPQ8Dcqx/4JhujzltRa+hQ==} + dependencies: + safe-buffer: 5.1.2 + dev: false + + /string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + dependencies: + safe-buffer: 5.1.2 + + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + + /strip-ansi@5.2.0: + resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==} + engines: {node: '>=6'} + dependencies: + ansi-regex: 4.1.1 + dev: false + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + + /strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.0.1 + + /strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + dev: true + + /strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + + /strip-hex-prefix@1.0.0: + resolution: {integrity: sha1-DF8VX+8RUTczd96du1iNoFUA428=} + engines: {node: '>=6.5.0', npm: '>=3'} + dependencies: + is-hex-prefixed: 1.0.0 + dev: false + + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /strip-indent@4.0.0: + resolution: {integrity: sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==} + engines: {node: '>=12'} + dependencies: + min-indent: 1.0.1 + dev: true + + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + /strip-literal@1.3.0: + resolution: {integrity: sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==} + dependencies: + acorn: 8.11.2 + dev: true + + /strnum@1.0.5: + resolution: {integrity: sha512-J8bbNyKKXl5qYcR36TIO8W3mVGVHrmmxsd5PAItGkmyzwJvybiw2IVq5nqd0i4LSNSkB/sx9VHllbfFdr9k1JA==} + dev: false + + /style-loader@3.3.3(webpack@5.91.0): + resolution: {integrity: sha512-53BiGLXAcll9maCYtZi2RCQZKa8NQQai5C4horqKyRmHj9H7QmcUyucrH+4KW/gBQbXM2AsB0axoEcFZPlfPcw==} + engines: {node: '>= 12.13.0'} + peerDependencies: + webpack: ^5.0.0 + dependencies: + webpack: 5.91.0(esbuild@0.18.20) + dev: true + + /stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + dev: false + + /sucrase@3.35.0: + resolution: {integrity: sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + commander: 4.1.1 + glob: 10.3.12 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.6 + ts-interface-checker: 0.1.13 + + /sudo-prompt@9.2.1: + resolution: {integrity: sha512-Mu7R0g4ig9TUuGSxJavny5Rv0egCEtpZRNMrZaYS1vxkiIxGiGUwoezU3LazIQ+KE04hTrTfNPgxU5gzi7F5Pw==} + dev: false + + /superstruct@1.0.3: + resolution: {integrity: sha512-8iTn3oSS8nRGn+C2pgXSKPI3jmpm6FExNazNpjvqS6ZUJQCej3PUXEKM8NjHBOs54ExM+LPW/FBRhymrdcCiSg==} + engines: {node: '>=14.0.0'} + dev: false + + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + + /supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + /svg-parser@2.0.4: + resolution: {integrity: sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==} + dev: true + + /symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + dev: true + + /synchronous-promise@2.0.17: + resolution: {integrity: sha512-AsS729u2RHUfEra9xJrE39peJcc2stq2+poBXX8bcM08Y6g9j/i/PUzwNQqkaJde7Ntg1TO7bSREbR5sdosQ+g==} + + /tailwind-merge@1.14.0: + resolution: {integrity: sha512-3mFKyCo/MBcgyOTlrY8T7odzZFx+w+qKSMAmdFzRvqBfLlSigU6TZnlFHK0lkMwj9Bj8OYU+9yW9lmGuS0QEnQ==} + dev: false + + /tailwindcss-animate@1.0.6(tailwindcss@3.4.3): + resolution: {integrity: sha512-4WigSGMvbl3gCCact62ZvOngA+PRqhAn7si3TQ3/ZuPuQZcIEtVap+ENSXbzWhpojKB8CpvnIsrwBu8/RnHtuw==} + peerDependencies: + tailwindcss: '>=3.0.0 || insiders' + dependencies: + tailwindcss: 3.4.3 + dev: false + + /tailwindcss@3.4.3: + resolution: {integrity: sha512-U7sxQk/n397Bmx4JHbJx/iSOOv5G+II3f1kpLpY2QeUv5DcPdcTsYLlusZfq1NthHS1c1cZoyFmmkex1rzke0A==} + engines: {node: '>=14.0.0'} + hasBin: true + dependencies: + '@alloc/quick-lru': 5.2.0 + arg: 5.0.2 + chokidar: 3.6.0 + didyoumean: 1.2.2 + dlv: 1.1.3 + fast-glob: 3.3.2 + glob-parent: 6.0.2 + is-glob: 4.0.3 + jiti: 1.21.0 + lilconfig: 2.1.0 + micromatch: 4.0.5 + normalize-path: 3.0.0 + object-hash: 3.0.0 + picocolors: 1.0.0 + postcss: 8.4.26 + postcss-import: 15.1.0(postcss@8.4.26) + postcss-js: 4.0.1(postcss@8.4.26) + postcss-load-config: 4.0.2(postcss@8.4.26) + postcss-nested: 6.0.1(postcss@8.4.26) + postcss-selector-parser: 6.0.16 + resolve: 1.22.8 + sucrase: 3.35.0 + transitivePeerDependencies: + - ts-node + + /tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + dev: true + + /tar-fs@2.1.1: + resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.0 + tar-stream: 2.2.0 + dev: true + + /tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: true + + /tar@6.1.15: + resolution: {integrity: sha512-/zKt9UyngnxIT/EAGYuxaMYgOIJiP81ab9ZfkILq4oNLPFX50qyYmu7jRj9qeXoxmJHjGlbH0+cm2uy1WCs10A==} + engines: {node: '>=10'} + dependencies: + chownr: 2.0.0 + fs-minipass: 2.1.0 + minipass: 5.0.0 + minizlib: 2.1.2 + mkdirp: 1.0.4 + yallist: 4.0.0 + dev: true + + /telejson@7.2.0: + resolution: {integrity: sha512-1QTEcJkJEhc8OnStBx/ILRu5J2p0GjvWsBx56bmZRqnrkdBMUe+nX92jxV+p3dB4CP6PZCdJMQJwCggkNBMzkQ==} + dependencies: + memoizerific: 1.11.3 + + /temp-dir@2.0.0: + resolution: {integrity: sha512-aoBAniQmmwtcKp/7BzsH8Cxzv8OL736p7v1ihGb5e9DJ9kTwGWHrQrVB5+lfVDzfGrdRzXch+ig7LHaY1JTOrg==} + engines: {node: '>=8'} + + /temp@0.8.4: + resolution: {integrity: sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==} + engines: {node: '>=6.0.0'} + dependencies: + rimraf: 2.6.3 + + /tempy@1.0.1: + resolution: {integrity: sha512-biM9brNqxSc04Ee71hzFbryD11nX7VPhQQY32AdDmjFvodsRFz/3ufeoTZ6uYkRFfGo188tENcASNs3vTdsM0w==} + engines: {node: '>=10'} + dependencies: + del: 6.1.1 + is-stream: 2.0.1 + temp-dir: 2.0.0 + type-fest: 0.16.0 + unique-string: 2.0.0 + dev: true + + /terser-webpack-plugin@5.3.10(esbuild@0.18.20)(webpack@5.91.0): + resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@swc/core': '*' + esbuild: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@swc/core': + optional: true + esbuild: + optional: true + uglify-js: + optional: true + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + esbuild: 0.18.20 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.30.3 + webpack: 5.91.0(esbuild@0.18.20) + dev: true + + /terser@5.30.3: + resolution: {integrity: sha512-STdUgOUx8rLbMGO9IOwHLpCqolkDITFFQSMYYwKE1N2lY6MVSaeoi10z/EhWxRc6ybqoVmKSkhKYH/XUpl7vSA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + '@jridgewell/source-map': 0.3.6 + acorn: 8.11.3 + commander: 2.20.3 + source-map-support: 0.5.21 + + /test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + dev: true + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + dependencies: + thenify: 3.3.1 + + /thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + dependencies: + any-promise: 1.3.0 + + /thread-stream@0.15.2: + resolution: {integrity: sha512-UkEhKIg2pD+fjkHQKyJO3yoIvAP3N6RlNFt2dUhcS1FGvCD1cQa1M/PGknCLFIyZdtJOWQjejp7bdNqmN7zwdA==} + dependencies: + real-require: 0.1.0 + dev: false + + /throat@5.0.0: + resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + dev: false + + /through2@2.0.5: + resolution: {integrity: sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==} + dependencies: + readable-stream: 2.3.8 + xtend: 4.0.2 + + /through@2.3.8: + resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} + dev: true + + /tiny-invariant@1.3.1: + resolution: {integrity: sha512-AD5ih2NlSssTCwsMznbvwMZpJ1cbhkGd2uueNxzv2jDlEeZdU04JQfRnggJQ8DrcVBGjAsCKwFBbDlVNtEMlzw==} + + /tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + /tiny-warning@1.0.3: + resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + dev: false + + /tinybench@2.5.1: + resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} + dev: true + + /tinypool@0.6.0: + resolution: {integrity: sha512-FdswUUo5SxRizcBc6b1GSuLpLjisa8N8qMyYoP3rl+bym+QauhtJP5bvZY1ytt8krKGmMLYIRl36HBZfeAoqhQ==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@2.2.0: + resolution: {integrity: sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==} + engines: {node: '>=14.0.0'} + dev: true + + /tmp@0.0.33: + resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} + engines: {node: '>=0.6.0'} + dependencies: + os-tmpdir: 1.0.2 + + /tmpl@1.0.5: + resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + + /tocbot@4.21.3: + resolution: {integrity: sha512-UKFkjz0nRB4s5WAeVnQJ3iIKmaKBbWDxzHYlRzJPZO7Doyp6v12nkJMN6T3HiMoQFHldvO1MZLAKRJqDMzkv2A==} + + /toformat@2.0.0: + resolution: {integrity: sha512-03SWBVop6nU8bpyZCx7SodpYznbZF5R4ljwNLBcTQzKOD9xuihRo/psX58llS1BMFhhAI08H3luot5GoXJz2pQ==} + dev: false + + /toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + /tough-cookie@4.1.3: + resolution: {integrity: sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==} + engines: {node: '>=6'} + dependencies: + psl: 1.9.0 + punycode: 2.3.0 + universalify: 0.2.0 + url-parse: 1.5.10 + dev: true + + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + /tr46@1.0.1: + resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==} + dependencies: + punycode: 2.3.0 + dev: true + + /tr46@4.1.1: + resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} + engines: {node: '>=14'} + dependencies: + punycode: 2.3.0 + dev: true + + /ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + + /ts-essentials@9.4.2(typescript@5.4.5): + resolution: {integrity: sha512-mB/cDhOvD7pg3YCLk2rOtejHjjdSi9in/IBYE13S+8WA5FBSraYf4V/ws55uvs0IvQ/l0wBOlXy5yBNZ9Bl8ZQ==} + peerDependencies: + typescript: '>=4.1.0' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.4.5 + dev: true + + /ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + /tsconfck@3.0.1(typescript@5.4.5): + resolution: {integrity: sha512-7ppiBlF3UEddCLeI1JRx5m2Ryq+xk4JrZuq4EuYXykipebaq1dV0Fhgr1hb7CkmHt32QSgOZlcqVLEtHBG4/mg==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.4.5 + dev: true + + /tsconfig-paths@3.14.2: + resolution: {integrity: sha512-o/9iXgCYc5L/JxCHPe3Hvh8Q/2xm5Z+p18PESBU6Ff33695QnCHBEjcytY2q19ua7Mbl/DavtBOLq+oG0RCL+g==} + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + dev: true + + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + + /tslib@2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: false + + /tslib@2.6.0: + resolution: {integrity: sha512-7At1WUettjcSRHXCyYtTselblcHl9PJFFVKiCAy/bY97+BPZXSQ2wbq0P9s8tK2G7dFQfNnlJnPAiArVBVBsfA==} + + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} + + /tsort@0.0.1: + resolution: {integrity: sha1-4igPXoF/i/QnVlf9D5rr1E9aJ4Y=} + dev: false + + /tsutils@3.21.0(typescript@5.0.2): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 5.0.2 + dev: true + + /tween-functions@1.2.0: + resolution: {integrity: sha512-PZBtLYcCLtEcjL14Fzb1gSxPBeL7nWvGhO5ZFPGqziCcr8uvHp0NDmdjBchp6KHL+tExcg0m3NISmKxhU394dA==} + dev: false + + /tweetnacl-util@0.15.1: + resolution: {integrity: sha512-RKJBIj8lySrShN4w6i/BonWp2Z/uxwC3h4y7xsRrpP59ZboCd0GpEVsOnMDYLMmKBpYhb5TgHzZXy7wTfYFBRw==} + dev: false + + /tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + dev: false + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-detect@4.0.8: + resolution: {integrity: sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==} + engines: {node: '>=4'} + + /type-fest@0.16.0: + resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} + engines: {node: '>=10'} + dev: true + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + + /type-fest@0.21.3: + resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==} + engines: {node: '>=10'} + + /type-fest@0.6.0: + resolution: {integrity: sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==} + engines: {node: '>=8'} + dev: true + + /type-fest@0.7.1: + resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} + engines: {node: '>=8'} + dev: false + + /type-fest@0.8.1: + resolution: {integrity: sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==} + engines: {node: '>=8'} + dev: true + + /type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + /type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + dev: true + + /typed-array-buffer@1.0.0: + resolution: {integrity: sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + get-intrinsic: 1.2.1 + is-typed-array: 1.1.10 + dev: true + + /typed-array-byte-length@1.0.0: + resolution: {integrity: sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.2 + for-each: 0.3.3 + has-proto: 1.0.1 + is-typed-array: 1.1.10 + dev: true + + /typed-array-byte-offset@1.0.0: + resolution: {integrity: sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + has-proto: 1.0.1 + is-typed-array: 1.1.10 + dev: true + + /typed-array-length@1.0.4: + resolution: {integrity: sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==} + dependencies: + call-bind: 1.0.2 + for-each: 0.3.3 + is-typed-array: 1.1.10 + dev: true + + /typedarray@0.0.6: + resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} + dev: true + + /typescript@5.0.2: + resolution: {integrity: sha512-wVORMBGO/FAs/++blGNeAVdbNKtIh1rbBL2EyQ1+J9lClJ93KiiKe8PmFIVdXhHcyv44SL9oglmfeSsndo0jRw==} + engines: {node: '>=12.20'} + hasBin: true + dev: true + + /typescript@5.4.5: + resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} + engines: {node: '>=14.17'} + hasBin: true + + /ua-parser-js@1.0.37: + resolution: {integrity: sha512-bhTyI94tZofjo+Dn8SN6Zv8nBDvyXTymAdM3LDI/0IboIUwTu1rEhW7v2TfiVsoYWgkQ4kOVqnI8APUFbIQIFQ==} + dev: false + + /ufo@1.3.2: + resolution: {integrity: sha512-o+ORpgGwaYQXgqGDwd+hkS4PuZ3QnmqMMxRuajK/a38L6fTpcE5GPIfrf+L/KemFzfUpeUQc1rRS1iDBozvnFA==} + + /ufo@1.5.3: + resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} + dev: false + + /uglify-js@3.17.4: + resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} + engines: {node: '>=0.8.0'} + hasBin: true + requiresBuild: true + optional: true + + /uint8arrays@3.1.1: + resolution: {integrity: sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==} + dependencies: + multiformats: 9.9.0 + dev: false + + /unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + dependencies: + call-bind: 1.0.2 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + dev: true + + /uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + dev: false + + /undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + + /undici@5.28.4: + resolution: {integrity: sha512-72RFADWFqKmUb2hmmvNODKL3p9hcB6Gt2DOQMis1SEBaV6a4MH8soBvzg+95CYhCKPFedut2JY9bMfrDl9D23g==} + engines: {node: '>=14.0'} + dependencies: + '@fastify/busboy': 2.1.1 + dev: false + + /unenv@1.8.0: + resolution: {integrity: sha512-uIGbdCWZfhRRmyKj1UioCepQ0jpq638j/Cf0xFTn4zD1nGJ2lSdzYHLzfdXN791oo/0juUiSWW1fBklXMTsuqg==} + dependencies: + consola: 3.2.3 + defu: 6.1.3 + mime: 3.0.0 + node-fetch-native: 1.4.1 + pathe: 1.1.2 + dev: false + + /unfetch@4.2.0: + resolution: {integrity: sha512-F9p7yYCn6cIW9El1zi0HI6vqpeIvBsr3dSuRO6Xuppb1u5rXpCPmMvLSyECLhybr9isec8Ohl0hPekMVrEinDA==} + dev: false + + /unicode-canonical-property-names-ecmascript@2.0.0: + resolution: {integrity: sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==} + engines: {node: '>=4'} + + /unicode-match-property-ecmascript@2.0.0: + resolution: {integrity: sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==} + engines: {node: '>=4'} + dependencies: + unicode-canonical-property-names-ecmascript: 2.0.0 + unicode-property-aliases-ecmascript: 2.1.0 + + /unicode-match-property-value-ecmascript@2.1.0: + resolution: {integrity: sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==} + engines: {node: '>=4'} + + /unicode-property-aliases-ecmascript@2.1.0: + resolution: {integrity: sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==} + engines: {node: '>=4'} + + /union@0.5.0: + resolution: {integrity: sha512-N6uOhuW6zO95P3Mel2I2zMsbsanvvtgn6jVqJv4vbVcz/JN0OkL9suomjQGmWtxJQXOCqUJvquc1sMeNz/IwlA==} + engines: {node: '>= 0.8.0'} + dependencies: + qs: 6.11.2 + dev: true + + /unique-string@2.0.0: + resolution: {integrity: sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg==} + engines: {node: '>=8'} + dependencies: + crypto-random-string: 2.0.0 + dev: true + + /unist-util-is@4.1.0: + resolution: {integrity: sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==} + dev: true + + /unist-util-visit-parents@3.1.1: + resolution: {integrity: sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==} + dependencies: + '@types/unist': 2.0.7 + unist-util-is: 4.1.0 + dev: true + + /unist-util-visit@2.0.3: + resolution: {integrity: sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==} + dependencies: + '@types/unist': 2.0.7 + unist-util-is: 4.1.0 + unist-util-visit-parents: 3.1.1 + dev: true + + /universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + dev: false + + /universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + dev: true + + /universalify@2.0.0: + resolution: {integrity: sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==} + engines: {node: '>= 10.0.0'} + + /unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + /unplugin@1.5.0: + resolution: {integrity: sha512-9ZdRwbh/4gcm1JTOkp9lAkIDrtOyOxgHmY7cjuwI8L/2RTikMcVG25GsZwNAgRuap3iDw2jeq7eoqtAsz5rW3A==} + dependencies: + acorn: 8.11.2 + chokidar: 3.6.0 + webpack-sources: 3.2.3 + webpack-virtual-modules: 0.5.0 + dev: true + + /unraw@3.0.0: + resolution: {integrity: sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==} + + /unstorage@1.10.1(idb-keyval@6.2.1): + resolution: {integrity: sha512-rWQvLRfZNBpF+x8D3/gda5nUCQL2PgXy2jNG4U7/Rc9BGEv9+CAJd0YyGCROUBKs9v49Hg8huw3aih5Bf5TAVw==} + peerDependencies: + '@azure/app-configuration': ^1.4.1 + '@azure/cosmos': ^4.0.0 + '@azure/data-tables': ^13.2.2 + '@azure/identity': ^3.3.2 + '@azure/keyvault-secrets': ^4.7.0 + '@azure/storage-blob': ^12.16.0 + '@capacitor/preferences': ^5.0.6 + '@netlify/blobs': ^6.2.0 + '@planetscale/database': ^1.11.0 + '@upstash/redis': ^1.23.4 + '@vercel/kv': ^0.2.3 + idb-keyval: ^6.2.1 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/kv': + optional: true + idb-keyval: + optional: true + dependencies: + anymatch: 3.1.3 + chokidar: 3.6.0 + destr: 2.0.2 + h3: 1.9.0 + idb-keyval: 6.2.1 + ioredis: 5.3.2 + listhen: 1.5.5 + lru-cache: 10.2.0 + mri: 1.2.0 + node-fetch-native: 1.4.1 + ofetch: 1.3.3 + ufo: 1.5.3 + transitivePeerDependencies: + - supports-color + dev: false + + /untildify@4.0.0: + resolution: {integrity: sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==} + engines: {node: '>=8'} + dev: true + + /untun@0.1.2: + resolution: {integrity: sha512-wLAMWvxfqyTiBODA1lg3IXHQtjggYLeTK7RnSfqtOXixWJ3bAa2kK/HHmOOg19upteqO3muLvN6O/icbyQY33Q==} + hasBin: true + dependencies: + citty: 0.1.5 + consola: 3.2.3 + pathe: 1.1.2 + dev: false + + /update-browserslist-db@1.0.11(browserslist@4.21.9): + resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.21.9 + escalade: 3.1.1 + picocolors: 1.0.0 + dev: true + + /update-browserslist-db@1.0.13(browserslist@4.22.1): + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.22.1 + escalade: 3.1.1 + picocolors: 1.0.0 + + /update-browserslist-db@1.0.13(browserslist@4.23.0): + resolution: {integrity: sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.23.0 + escalade: 3.1.1 + picocolors: 1.0.0 + + /update-check@1.5.4: + resolution: {integrity: sha512-5YHsflzHP4t1G+8WGPlvKbJEbAJGCgw+Em+dGR1KmBUbr1J36SJBqlHLjR7oob7sco5hWHGQVcr9B2poIVDDTQ==} + dependencies: + registry-auth-token: 3.3.2 + registry-url: 3.1.0 + dev: true + + /upper-case-first@2.0.2: + resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + dependencies: + tslib: 2.6.2 + dev: true + + /upper-case@2.0.2: + resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} + dependencies: + tslib: 2.6.2 + dev: true + + /uqr@0.1.2: + resolution: {integrity: sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==} + dev: false + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.0 + dev: true + + /url-join@4.0.1: + resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + dev: true + + /url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + dev: true + + /use-callback-ref@1.3.0(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-3FT9PRuRdbB9HfXhEq35u4oZkvpJ5kuYbpqhCfmiZyReuRgpnhDlbr2ZEnnuS0RrJAPn6l23xjFg9kpDM+Ms7w==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.14 + react: 18.2.0 + tslib: 2.6.2 + + /use-resize-observer@9.1.0(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==} + peerDependencies: + react: 16.8.0 - 18 + react-dom: 16.8.0 - 18 + dependencies: + '@juggle/resize-observer': 3.4.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + /use-sidecar@1.1.2(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.2.14 + detect-node-es: 1.1.0 + react: 18.2.0 + tslib: 2.6.2 + + /use-sync-external-store@1.2.0(react@18.2.0): + resolution: {integrity: sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.2.0 + dev: false + + /utf-8-validate@6.0.3: + resolution: {integrity: sha512-uIuGf9TWQ/y+0Lp+KGZCMuJWc3N9BHA+l/UmHd/oUHwJJDeysyTRxNQVkbzsIWfGFbRe3OcgML/i0mvVRPOyDA==} + engines: {node: '>=6.14.2'} + requiresBuild: true + dependencies: + node-gyp-build: 4.8.0 + dev: false + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + /util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + dependencies: + inherits: 2.0.4 + is-arguments: 1.1.1 + is-generator-function: 1.0.10 + is-typed-array: 1.1.10 + which-typed-array: 1.1.10 + + /utils-merge@1.0.1: + resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=} + engines: {node: '>= 0.4.0'} + + /uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + hasBin: true + dev: false + + /uuid@9.0.0: + resolution: {integrity: sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg==} + hasBin: true + dev: true + + /v8-to-istanbul@9.1.0: + resolution: {integrity: sha512-6z3GW9x8G1gd+JIIgQQQxXuiJtCXeAjp6RaPEPLv62mH3iPHPxV6W3robxtCzNErRo6ZwTmzWhsbNvjyEBKzKA==} + engines: {node: '>=10.12.0'} + dependencies: + '@jridgewell/trace-mapping': 0.3.19 + '@types/istanbul-lib-coverage': 2.0.4 + convert-source-map: 1.9.0 + dev: true + + /validate-npm-package-license@3.0.4: + resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + dependencies: + spdx-correct: 3.2.0 + spdx-expression-parse: 3.0.1 + dev: true + + /valtio@1.11.2(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-1XfIxnUXzyswPAPXo1P3Pdx2mq/pIqZICkWN60Hby0d9Iqb+MEIpqgYVlbflvHdrp2YR/q3jyKWRPJJ100yxaw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=16.8' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.14 + proxy-compare: 2.5.1 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false + + /vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + /viem@1.9.5(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-o6gxd4hOMof4n1Lm4j58Y2VbAa95O6KrQU4tqvkQjMd0MH/s9u0UBh7pv45gFSr53+NW8036G4hYcEV35nry8g==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@adraffy/ens-normalize': 1.9.0 + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.0 + '@scure/bip32': 1.3.0 + '@scure/bip39': 1.2.0 + '@types/ws': 8.5.5 + abitype: 0.9.3(typescript@5.4.5)(zod@3.22.4) + isomorphic-ws: 5.0.0(ws@8.12.0) + typescript: 5.4.5 + ws: 8.12.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + dev: false + + /viem@2.9.21(typescript@5.4.5)(zod@3.22.4): + resolution: {integrity: sha512-8GtxPjPGpiN5cmr19zSX9mb1LX/eON3MPxxAd3QmyUFn69Rp566zlREOqE7zM35y5yX59fXwnz6O3X7e9+C9zg==} + peerDependencies: + typescript: '>=5.0.4' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@adraffy/ens-normalize': 1.10.0 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@scure/bip32': 1.3.2 + '@scure/bip39': 1.2.1 + abitype: 1.0.0(typescript@5.4.5)(zod@3.22.4) + isows: 1.0.3(ws@8.13.0) + typescript: 5.4.5 + ws: 8.13.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + - zod + + /vite-node@0.33.0(@types/node@20.10.0)(less@4.2.0): + resolution: {integrity: sha512-19FpHYbwWWxDr73ruNahC+vtEdza52kA90Qb3La98yZ0xULqV8A5JLNPUff0f5zID4984tW7l3DH2przTJUZSw==} + engines: {node: '>=v14.18.0'} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.4(supports-color@8.1.1) + mlly: 1.4.2 + pathe: 1.1.1 + picocolors: 1.0.0 + vite: 4.5.0(@types/node@20.10.0)(less@4.2.0) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /vite-plugin-svgr@4.2.0(vite@4.5.0): + resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==} + peerDependencies: + vite: ^2.6.0 || 3 || 4 || 5 + dependencies: + '@rollup/pluginutils': 5.0.5 + '@svgr/core': 8.1.0 + '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0) + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + transitivePeerDependencies: + - rollup + - supports-color + dev: true + + /vite-tsconfig-paths@4.3.1(typescript@5.4.5)(vite@4.5.0): + resolution: {integrity: sha512-cfgJwcGOsIxXOLU/nELPny2/LUD/lcf1IbfyeKTv2bsupVbTH/xpFtdQlBmIP1GEK2CjjLxYhFfB+QODFAx5aw==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + dependencies: + debug: 4.3.4(supports-color@8.1.1) + globrex: 0.1.2 + tsconfck: 3.0.1(typescript@5.4.5) + vite: 4.5.0(@types/node@20.4.10)(less@4.2.0) + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /vite@4.5.0(@types/node@20.10.0)(less@4.2.0): + resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.10.0 + esbuild: 0.18.20 + less: 4.2.0 + postcss: 8.4.38 + rollup: 3.29.4 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vite@4.5.0(@types/node@20.4.10)(less@4.2.0): + resolution: {integrity: sha512-ulr8rNLA6rkyFAlVWw2q5YJ91v098AFQ2R0PRFwPzREXOUJQPtFUG0t+/ZikhaOCDqFoDhN6/v8Sq0o4araFAw==} + engines: {node: ^14.18.0 || >=16.0.0} + hasBin: true + peerDependencies: + '@types/node': '>= 14' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.4.10 + esbuild: 0.18.20 + less: 4.2.0 + postcss: 8.4.31 + rollup: 3.29.4 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitest@0.33.0(jsdom@22.1.0)(less@4.2.0): + resolution: {integrity: sha512-1CxaugJ50xskkQ0e969R/hW47za4YXDUfWJDxip1hwbnhUjYolpfUn2AMOulqG/Dtd9WYAtkHmM/m3yKVrEejQ==} + engines: {node: '>=v14.18.0'} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@vitest/browser': '*' + '@vitest/ui': '*' + happy-dom: '*' + jsdom: '*' + playwright: '*' + safaridriver: '*' + webdriverio: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + playwright: + optional: true + safaridriver: + optional: true + webdriverio: + optional: true + dependencies: + '@types/chai': 4.3.9 + '@types/chai-subset': 1.3.4 + '@types/node': 20.10.0 + '@vitest/expect': 0.33.0 + '@vitest/runner': 0.33.0 + '@vitest/snapshot': 0.33.0 + '@vitest/spy': 0.33.0 + '@vitest/utils': 0.33.0 + acorn: 8.11.2 + acorn-walk: 8.3.0 + cac: 6.7.14 + chai: 4.3.10 + debug: 4.3.4(supports-color@8.1.1) + jsdom: 22.1.0 + local-pkg: 0.4.3 + magic-string: 0.30.5 + pathe: 1.1.1 + picocolors: 1.0.0 + std-env: 3.5.0 + strip-literal: 1.3.0 + tinybench: 2.5.1 + tinypool: 0.6.0 + vite: 4.5.0(@types/node@20.10.0)(less@4.2.0) + vite-node: 0.33.0(@types/node@20.10.0)(less@4.2.0) + why-is-node-running: 2.2.2 + transitivePeerDependencies: + - less + - lightningcss + - sass + - stylus + - sugarss + - supports-color + - terser + dev: true + + /vlq@1.0.1: + resolution: {integrity: sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==} + dev: false + + /void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + dev: false + + /w3c-xmlserializer@4.0.0: + resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} + engines: {node: '>=14'} + dependencies: + xml-name-validator: 4.0.0 + dev: true + + /wagmi@2.5.20(@tanstack/react-query@5.28.8)(@types/react@18.2.14)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4): + resolution: {integrity: sha512-K/9qk6+t/+NKFdbQyB7LtFgl3UXnGjvgyzAyfMQ+dF56uTSJipQwc94CSlN8kdQXTIOvhUSK2P7WJrdTEd15AA==} + peerDependencies: + '@tanstack/react-query': '>=5.0.0' + react: '>=18' + typescript: '>=5.0.4' + viem: 2.x + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@tanstack/react-query': 5.28.8(react@18.2.0) + '@wagmi/connectors': 4.1.26(@types/react@18.2.14)(@wagmi/core@2.6.17)(react-dom@18.2.0)(react-native@0.73.6)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4) + '@wagmi/core': 2.6.17(@types/react@18.2.14)(react@18.2.0)(typescript@5.4.5)(viem@2.9.21)(zod@3.22.4) + react: 18.2.0 + typescript: 5.4.5 + use-sync-external-store: 1.2.0(react@18.2.0) + viem: 2.9.21(typescript@5.4.5)(zod@3.22.4) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@netlify/blobs' + - '@planetscale/database' + - '@react-native-async-storage/async-storage' + - '@tanstack/query-core' + - '@types/react' + - '@upstash/redis' + - '@vercel/kv' + - bufferutil + - encoding + - immer + - react-dom + - react-native + - rollup + - supports-color + - utf-8-validate + - zod + dev: false + + /walker@1.0.8: + resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} + dependencies: + makeerror: 1.0.12 + + /watchpack@2.4.0: + resolution: {integrity: sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==} + engines: {node: '>=10.13.0'} + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + dev: true + + /watchpack@2.4.1: + resolution: {integrity: sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg==} + engines: {node: '>=10.13.0'} + dependencies: + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + dev: true + + /wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + dependencies: + defaults: 1.0.4 + + /webextension-polyfill-ts@0.25.0: + resolution: {integrity: sha512-ikQhwwHYkpBu00pFaUzIKY26I6L87DeRI+Q6jBT1daZUNuu8dSrg5U9l/ZbqdaQ1M/TTSPKeAa3kolP5liuedw==} + deprecated: This project has moved to @types/webextension-polyfill + dependencies: + webextension-polyfill: 0.7.0 + dev: false + + /webextension-polyfill@0.10.0: + resolution: {integrity: sha512-c5s35LgVa5tFaHhrZDnr3FpQpjj1BB+RXhLTYUxGqBVN460HkbM8TBtEqdXWbpTKfzwCcjAZVF7zXCYSKtcp9g==} + dev: false + + /webextension-polyfill@0.7.0: + resolution: {integrity: sha512-su48BkMLxqzTTvPSE1eWxKToPS2Tv5DLGxKexLEVpwFd6Po6N8hhSLIvG6acPAg7qERoEaDL+Y5HQJeJeml5Aw==} + dev: false + + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + /webidl-conversions@4.0.2: + resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==} + dev: true + + /webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + dev: true + + /webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + dev: true + + /webpack-virtual-modules@0.5.0: + resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + dev: true + + /webpack@5.91.0(esbuild@0.18.20): + resolution: {integrity: sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.5 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + acorn: 8.11.3 + acorn-import-assertions: 1.9.0(acorn@8.11.3) + browserslist: 4.23.0 + chrome-trace-event: 1.0.3 + enhanced-resolve: 5.16.0 + es-module-lexer: 1.5.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(esbuild@0.18.20)(webpack@5.91.0) + watchpack: 2.4.1 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + dev: true + + /whatwg-encoding@2.0.0: + resolution: {integrity: sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==} + engines: {node: '>=12'} + dependencies: + iconv-lite: 0.6.3 + dev: true + + /whatwg-fetch@3.6.20: + resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + dev: false + + /whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + dev: true + + /whatwg-url@12.0.1: + resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} + engines: {node: '>=14'} + dependencies: + tr46: 4.1.1 + webidl-conversions: 7.0.0 + dev: true + + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + /whatwg-url@7.1.0: + resolution: {integrity: sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==} + dependencies: + lodash.sortby: 4.7.0 + tr46: 1.0.1 + webidl-conversions: 4.0.2 + dev: true + + /which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + dev: true + + /which-builtin-type@1.1.3: + resolution: {integrity: sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==} + engines: {node: '>= 0.4'} + dependencies: + function.prototype.name: 1.1.5 + has-tostringtag: 1.0.0 + is-async-function: 2.0.0 + is-date-object: 1.0.5 + is-finalizationregistry: 1.0.2 + is-generator-function: 1.0.10 + is-regex: 1.1.4 + is-weakref: 1.0.2 + isarray: 2.0.5 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.1 + which-typed-array: 1.1.10 + dev: true + + /which-collection@1.0.1: + resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + dependencies: + is-map: 2.0.2 + is-set: 2.0.2 + is-weakmap: 2.0.1 + is-weakset: 2.0.2 + dev: true + + /which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + dev: false + + /which-typed-array@1.1.10: + resolution: {integrity: sha512-uxoA5vLUfRPdjCuJ1h5LlYdmTLbYfums398v3WLkM+i/Wltl2/XyZpQWKbN++ck5L64SR/grOHqtXCUKmlZPNA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.5 + call-bind: 1.0.2 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.0 + is-typed-array: 1.1.10 + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + + /why-is-node-running@2.2.2: + resolution: {integrity: sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + + /wide-align@1.1.5: + resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + dependencies: + string-width: 4.2.3 + dev: true + + /widest-line@3.1.0: + resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} + engines: {node: '>=8'} + dependencies: + string-width: 4.2.3 + dev: false + + /widest-line@4.0.1: + resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + dev: true + + /wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + + /workerpool@6.2.1: + resolution: {integrity: sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==} + dev: false + + /wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: false + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + /write-file-atomic@2.4.3: + resolution: {integrity: sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==} + dependencies: + graceful-fs: 4.2.11 + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + + /write-file-atomic@4.0.2: + resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} + engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} + dependencies: + imurmurhash: 0.1.4 + signal-exit: 3.0.7 + dev: true + + /ws@6.2.2: + resolution: {integrity: sha512-zmhltoSR8u1cnDsD43TX59mzoMZsLKqUweyYBAIvTngR3shc0W6aOZylZmq/7hqyVxPdi+5Ud2QInblgyE72fw==} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dependencies: + async-limiter: 1.0.1 + + /ws@7.5.9: + resolution: {integrity: sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + + /ws@8.11.0(bufferutil@4.0.8)(utf-8-validate@6.0.3): + resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dependencies: + bufferutil: 4.0.8 + utf-8-validate: 6.0.3 + dev: false + + /ws@8.12.0: + resolution: {integrity: sha512-kU62emKIdKVeEIOIKVegvqpXMSTAMLJozpHZaJNDYqBjzlSYXQGviYwN1osDLJ9av68qHd4a2oSjd7yD4pacig==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + + /ws@8.13.0: + resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + /ws@8.14.2: + resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /ws@8.5.0: + resolution: {integrity: sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + + /xml-name-validator@4.0.0: + resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} + engines: {node: '>=12'} + dev: true + + /xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + dev: true + + /xmlhttprequest-ssl@2.0.0: + resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==} + engines: {node: '>=0.4.0'} + dev: false + + /xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + + /y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + dev: false + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: false + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + + /yaml@2.4.1: + resolution: {integrity: sha512-pIXzoImaqmfOrL7teGUBt/T7ZDnyeGBWyXQBvOVhLkWLN37GXv8NMLK406UY6dS51JfcQHsmcW5cJ441bHg6Lg==} + engines: {node: '>= 14'} + hasBin: true + + /yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + dev: false + + /yargs-parser@20.2.4: + resolution: {integrity: sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==} + engines: {node: '>=10'} + dev: false + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: false + + /yargs-unparser@2.0.0: + resolution: {integrity: sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==} + engines: {node: '>=10'} + dependencies: + camelcase: 6.3.0 + decamelize: 4.0.0 + flat: 5.0.2 + is-plain-obj: 2.1.0 + dev: false + + /yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + dev: false + + /yargs@16.2.0: + resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} + engines: {node: '>=10'} + dependencies: + cliui: 7.0.4 + escalade: 3.1.2 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 20.2.4 + dev: false + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.1.2 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: false + + /yauzl@2.10.0: + resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==} + dependencies: + buffer-crc32: 0.2.13 + fd-slicer: 1.1.0 + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + /yocto-queue@1.0.0: + resolution: {integrity: sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==} + engines: {node: '>=12.20'} + dev: true + + /zod@3.22.4: + resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} + + /zustand@4.4.1(@types/react@18.2.14)(react@18.2.0): + resolution: {integrity: sha512-QCPfstAS4EBiTQzlaGP1gmorkh/UL1Leaj2tdj+zZCZ/9bm0WS7sI2wnfD5lpOszFqWJ1DcPnGoY8RDL61uokw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.2.14 + react: 18.2.0 + use-sync-external-store: 1.2.0(react@18.2.0) + dev: false diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..600b4bb48 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/**'