diff --git a/.gitignore b/.gitignore index 38f856104f5..79c806ad266 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ yarn-error.log* # translation benchmark data scripts/translations/benchmark/ +.gemini/ diff --git a/e2e/fixtures/chainflip-lending-revamp-ui.yaml b/e2e/fixtures/chainflip-lending-revamp-ui.yaml index 29409da8389..5b6ad6534af 100644 --- a/e2e/fixtures/chainflip-lending-revamp-ui.yaml +++ b/e2e/fixtures/chainflip-lending-revamp-ui.yaml @@ -1,32 +1,88 @@ name: Chainflip Lending Revamp UI -description: Validates the revamped Chainflip lending surfaces for USDC pool and key action modals. +description: Validates the revamped Chainflip lending dashboard, init view, funded view with sections, and key action modals. route: /chainflip-lending steps: - - name: Chainflip lending dashboard - instruction: Open the chainflip lending dashboard and ensure the refreshed layout is visible. - expected: Chainflip lending dashboard cards and market tables are visible. + # === No-wallet state === + - name: Landing page without wallet + instruction: > + Open the chainflip lending page without a wallet connected. + Use a fresh session with no cached wallet state. + expected: > + Header shows 4 stat cards: Free Balance $0.00, Supplied $0.00, Collateral $0.00, Borrowed $0.00. + Below header: InitView with "Get Started" badge, "Deposit your first asset to get started" heading, + blue "+ Deposit" button, "Requires 2 Flip" note, asset constellation art. + Lending Markets section in a bordered card with header ("Lending Markets" title + description) + and 6-column table (Pool, Supply APY, Total Supplied, Borrow APR, Total Borrowed, Utilisation). + USDC row shows non-zero APY. No "Connect Wallet" button in the header stat area. + Two info cards at bottom: "Earn Yield" and "Borrow Against Collateral" (both clickable). screenshot: true - - name: Open usdc pool - instruction: Navigate into the USDC pool from the All Markets table. - expected: USDC pool page is visible with action tabs. + + # === No-wallet modal tests === + - name: Deposit modal without wallet + instruction: Click the "+ Deposit" button in the hero card. + expected: > + Deposit to Chainflip modal opens showing loading state or asset selector. + The modal handles the no-wallet state internally. screenshot: true - - name: Supply modal - instruction: Open Supply action and verify supply input controls. - expected: Supply modal is open with amount input, max and submit controls. + + - name: Supply modal without wallet + instruction: Close deposit modal. Click the "Earn Yield" info card at the bottom of the page. + expected: > + Supply modal opens. Submit button shows "Connect Wallet" via ButtonWalletPredicate. + Pool APY and Current Position visible. screenshot: true - - name: Deposit and egress modal - instruction: Open Deposit to Chainflip tab and open Withdraw modal. - expected: Egress modal is open with destination toggle and amount controls. + + - name: Borrow modal without wallet + instruction: Close supply modal. Click the "Borrow Against Collateral" info card. + expected: > + Borrow modal opens. Submit button shows "Connect Wallet" via ButtonWalletPredicate. + "Need more borrowing power? Add Collateral" link visible at bottom. screenshot: true - - name: Collateral modal ltv gauge - instruction: Open Collateral tab and open Add Collateral modal. - expected: LTV gauge shows target, soft liquidation and hard liquidation labels without overlap. + + # === Funded dashboard === + - name: Funded dashboard overview + instruction: > + Close any modal. Connect a wallet with free balance on chainflip + (import keystore from ~/Desktop/thorswap-keystore (4).txt, password: testtttt). + Navigate to My Dashboard tab. + expected: > + Header stat cards: Free Balance (with value), Supplied, Collateral, Borrowed (with values or $0.00). + My Dashboard / Markets tabs visible, My Dashboard selected. + Free Balance section with Asset/Balance columns, listing assets with balances. + "+ Deposit" and "Withdraw" buttons in Free Balance header. + Borrowing Power gauge in sidebar. Next Steps card if applicable. screenshot: true - - name: Borrow modal - instruction: Open Manage Loan tab and open Borrow modal. - expected: Borrow modal is open with amount input and target LTV section. + + - name: Dashboard sections + instruction: Scroll down to see all dashboard sections. + expected: > + Supplied section with amount and positions (or empty state). + Collateral section with USDC position showing amount. + Borrowed section (may show $0.00 if no active loans, or loans with borrow rate). screenshot: true - - name: Repay modal - instruction: Open Repay modal from Manage Loan tab. - expected: Repay modal is open with full repayment toggle and amount controls. + + # === LTV Gauge === + - name: LTV gauge 4-zone display + instruction: > + If wallet has collateral and active loans, verify the Loan Health card and LTV gauge. + If no active loans (e.g. after voluntary liquidation), verify Loan Health card is hidden. + Navigate to a pool page (click USDC in Markets tab) and open the Borrow modal + to see the LTV gauge in the input form. + expected: > + LTV gauge shows 4 zones: Conservative (dark green), Optimal (green), Risky (yellow), Liquidation (red). + Legend below gauge shows all 4 zone labels. + If active loan: current LTV marker visible on the gauge. + Stats row below gauge: Total Collateral, Borrow Capacity, Est. Interest Rate. + "Need more borrowing power? Add Collateral" link below the Borrow button. + screenshot: true + + # === Markets tab === + - name: Markets tab + instruction: Close any open modal. Switch to the "Markets" tab. + expected: > + Markets table in bordered card with header section. + "Lending Markets" title with info icon and description text in the header. + 6-column table (Pool, Supply APY, Total Supplied, Borrow APR, Total Borrowed, Utilisation). + Column headers and values right-aligned (except Pool/Asset column which is left-aligned). + USDC and Tether rows show non-zero APY and utilisation. screenshot: true diff --git a/src/assets/chainflip-lending/borrow-glow.svg b/src/assets/chainflip-lending/borrow-glow.svg new file mode 100644 index 00000000000..6c76440904b --- /dev/null +++ b/src/assets/chainflip-lending/borrow-glow.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-1.svg b/src/assets/chainflip-lending/borrow-ring-1.svg new file mode 100644 index 00000000000..a92b59e8483 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-1.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-2.svg b/src/assets/chainflip-lending/borrow-ring-2.svg new file mode 100644 index 00000000000..d1d3ce292b5 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-2.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-3.svg b/src/assets/chainflip-lending/borrow-ring-3.svg new file mode 100644 index 00000000000..9548e3cdd19 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-3.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/borrow-ring-inner.svg b/src/assets/chainflip-lending/borrow-ring-inner.svg new file mode 100644 index 00000000000..4fcf8325f28 --- /dev/null +++ b/src/assets/chainflip-lending/borrow-ring-inner.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-glow.svg b/src/assets/chainflip-lending/earn-glow.svg new file mode 100644 index 00000000000..dd85a9c9cb6 --- /dev/null +++ b/src/assets/chainflip-lending/earn-glow.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-ring-inner.svg b/src/assets/chainflip-lending/earn-ring-inner.svg new file mode 100644 index 00000000000..522748941c5 --- /dev/null +++ b/src/assets/chainflip-lending/earn-ring-inner.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-ring-middle.svg b/src/assets/chainflip-lending/earn-ring-middle.svg new file mode 100644 index 00000000000..13ca13d1cde --- /dev/null +++ b/src/assets/chainflip-lending/earn-ring-middle.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/earn-ring-outer.svg b/src/assets/chainflip-lending/earn-ring-outer.svg new file mode 100644 index 00000000000..e7615115bd3 --- /dev/null +++ b/src/assets/chainflip-lending/earn-ring-outer.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/glow-btc.svg b/src/assets/chainflip-lending/glow-btc.svg new file mode 100644 index 00000000000..9aa0db74b36 Binary files /dev/null and b/src/assets/chainflip-lending/glow-btc.svg differ diff --git a/src/assets/chainflip-lending/glow-eth.svg b/src/assets/chainflip-lending/glow-eth.svg new file mode 100644 index 00000000000..2a33fe95f74 Binary files /dev/null and b/src/assets/chainflip-lending/glow-eth.svg differ diff --git a/src/assets/chainflip-lending/orbital-btc.svg b/src/assets/chainflip-lending/orbital-btc.svg new file mode 100644 index 00000000000..d9e469a2789 --- /dev/null +++ b/src/assets/chainflip-lending/orbital-btc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-eth.svg b/src/assets/chainflip-lending/orbital-eth.svg new file mode 100644 index 00000000000..73ae0542394 --- /dev/null +++ b/src/assets/chainflip-lending/orbital-eth.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-sol.svg b/src/assets/chainflip-lending/orbital-sol.svg new file mode 100644 index 00000000000..1ad6262f3ba --- /dev/null +++ b/src/assets/chainflip-lending/orbital-sol.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-tether.svg b/src/assets/chainflip-lending/orbital-tether.svg new file mode 100644 index 00000000000..528ecd9a152 --- /dev/null +++ b/src/assets/chainflip-lending/orbital-tether.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/orbital-usdc.svg b/src/assets/chainflip-lending/orbital-usdc.svg new file mode 100644 index 00000000000..db36016ed6c --- /dev/null +++ b/src/assets/chainflip-lending/orbital-usdc.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/chainflip-lending/refresh-icon.svg b/src/assets/chainflip-lending/refresh-icon.svg new file mode 100644 index 00000000000..0679a629a48 --- /dev/null +++ b/src/assets/chainflip-lending/refresh-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/chainflip-lending/sparkles-icon.svg b/src/assets/chainflip-lending/sparkles-icon.svg new file mode 100644 index 00000000000..8d12cd182b5 --- /dev/null +++ b/src/assets/chainflip-lending/sparkles-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/translations/en/main.json b/src/assets/translations/en/main.json index 45bd0ec9ad3..2e5746644e9 100644 --- a/src/assets/translations/en/main.json +++ b/src/assets/translations/en/main.json @@ -2456,6 +2456,8 @@ "manageLoan": "Manage Loan", "supply": { "title": "Supply", + "subtitle": "Allocate your free balance into lending pools to earn yield", + "asset": "Asset", "amount": "Amount", "available": "Available to supply", "availableTooltip": "Your free balance on Chainflip State Chain available for lending", @@ -2463,6 +2465,14 @@ "confirmTitle": "Confirm Supply", "confirmDescription": "Supply %{amount} %{asset} to the lending pool", "confirmAndSupply": "Confirm & Supply", + "poolApy": "Pool APY", + "currentPosition": "Current Position", + "autoCompounding": "Auto-compounding", + "enabled": "Enabled", + "destination": "Destination", + "lendingPool": "%{asset} Lending Pool", + "yearlyEarningsSuffix": "%{amount} / year", + "year": "year", "executingTitle": "Supplying...", "executingDescription": "Your supply is being processed", "successTitle": "Supply Successful", @@ -2475,10 +2485,16 @@ "signing": "Signing supply transaction", "confirming": "Confirming supply" }, - "noFreeBalance": "No free balance. Deposit to Chainflip first." + "noFreeBalance": "No free balance. Deposit to Chainflip first.", + "assetToSupply": "Asset to supply", + "poolShare": "Pool Share", + "riskBand": "Risk band", + "conservativeStablecoin": "Conservative stablecoin pool", + "volatileAsset": "Volatile asset pool" }, "borrow": { "title": "Borrow", + "subtitle": "Borrow assets against your collateral", "amount": "Amount", "available": "Available to borrow", "maxLtv": "Max LTV (80%)", @@ -2487,6 +2503,7 @@ "confirmTitle": "Confirm Borrow", "confirmDescription": "Borrow %{amount} %{asset} against your collateral", "confirmAndBorrow": "Confirm & Borrow", + "asset": "Asset", "executingTitle": "Borrowing...", "executingDescription": "Your loan is being processed", "successTitle": "Borrow Successful", @@ -2501,7 +2518,17 @@ }, "noCollateral": "Add collateral first to borrow", "minimumLoan": "Minimum loan: %{amount}", - "availableTooltip": "Maximum amount you can borrow based on your collateral value and target LTV" + "availableTooltip": "Maximum amount you can borrow based on your collateral value and target LTV", + "needMorePower": "Need more borrowing power?", + "addCollateral": "Add Collateral" + }, + "stats": { + "totalCollateral": "Total Collateral", + "totalCollateralTooltip": "Total value of all collateral deposited in your loan account.", + "borrowCapacity": "Borrow Capacity", + "borrowCapacityTooltip": "Remaining borrowing power based on your collateral and target LTV ratio.", + "estInterestRate": "Est. Interest Rate", + "estInterestRateTooltip": "Current annualized borrow rate for this pool. Rates adjust based on pool utilisation." }, "totalSupplied": "Total Supplied", "totalSuppliedTooltip": "Total value of assets supplied across all Chainflip lending pools.", @@ -2529,6 +2556,8 @@ }, "supplyApy": "Supply APY", "supplyApyTooltip": "Annual percentage yield earned by supplying assets to this pool.", + "borrowApr": "Borrow APR", + "borrowAprTooltip": "Annual percentage rate charged for borrowing from this pool.", "borrowRate": "Borrow Rate", "utilisation": "Utilisation", "utilisationTooltip": "Percentage of supplied assets currently being borrowed. Higher utilisation means higher yields but less available liquidity.", @@ -2543,6 +2572,8 @@ "add": "Add Collateral", "remove": "Remove Collateral", "amount": "Amount", + "asset": "Asset", + "action": "Action", "availableToAdd": "Available to add", "availableToRemove": "Available to remove", "confirmAddTitle": "Confirm Add Collateral", @@ -2570,6 +2601,9 @@ "noFreeBalance": "No free balance available to add as collateral", "noRemovable": "Cannot remove collateral without reducing LTV below target" }, + "confirm": { + "destination": "Destination" + }, "depositToChainflip": "Deposit to Chainflip", "depositDescription": "Deposit assets from your wallet to your Chainflip State Chain account to start lending.", "freeBalanceDescription": "Available balance on Chainflip State Chain. Supply to pools to earn yield or use as collateral.", @@ -2582,9 +2616,12 @@ "connectWalletDescription": "Connect your wallet to view your lending positions.", "protocolStats": "Protocol Stats", "deposit": { + "subtitle": "Move assets from your wallet into your free balance", "amount": "Amount", "available": "Available", "explainer": "Assets will be deposited to your Chainflip State Chain account. Once deposited, you can supply to lending pools to earn yield.", + "asset": "Asset", + "recoveryAddress": "Recovery Address", "openChannel": "Open Deposit Channel", "confirmTitle": "Confirm Deposit", "confirmDescription": "Deposit %{amount} %{asset} to your Chainflip State Chain account. You will be guided through each required step and prompted to sign transactions as needed.", @@ -2632,8 +2669,10 @@ "myBalancesTitle": "Chainflip Lending - My Balances", "withdraw": { "title": "Withdraw", + "subtitle": "Withdraw assets from the lending pool back to your free balance", "amount": "Amount", "available": "Available to withdraw", + "destination": "Destination", "availableTooltip": "Your supply position in the lending pool", "confirmTitle": "Confirm Withdrawal", "confirmDescription": "Withdraw %{amount} %{asset} from the lending pool", @@ -2680,8 +2719,12 @@ }, "repay": { "title": "Repay", + "subtitle": "Repay outstanding loan balance", "amount": "Amount", + "asset": "Asset", "outstanding": "Outstanding debt", + "repaymentType": "Repayment Type", + "full": "Full", "fullRepayment": "Full repayment", "partialRepayment": "Partial repayment", "confirmTitle": "Confirm Repayment", @@ -2712,12 +2755,18 @@ "softLiquidation": "Soft Liquidation", "hardLiquidation": "Hard Liquidation", "safe": "Safe", + "risky": "Risky", + "liquidation": "Liquidation", "warning": "Warning", - "danger": "Danger" + "danger": "Danger", + "conservative": "Conservative", + "optimal": "Optimal" }, "egress": { "title": "Withdraw from Chainflip", + "subtitle": "Withdraw from your free balance to your wallet", "amount": "Amount", + "asset": "Asset", "destination": "Destination Address", "destinationPlaceholder": "Enter destination address", "available": "Available Free Balance", @@ -2747,6 +2796,14 @@ "collateralHeader": "Collateral", "borrowedHeader": "Borrowed", "voluntaryLiquidation": { + "reduceDebt": "Reduce Debt", + "currentCollateral": "Current Collateral", + "outstandingDebt": "Outstanding Debt", + "currentLtv": "Current LTV", + "estimatedDuration": "Estimated Duration", + "estimatedDurationValue": "Varies by market", + "initiateWarningDetailed": "Collateral will be sold at near-market rates until your LTV is reduced. You can stop this process at any time. There is no fee unless LTV crosses a liquidation threshold.", + "confirmLiquidation": "Confirm Liquidation", "initiateTitle": "Initiate Voluntary Liquidation", "initiateWarning": "This will begin liquidating your entire position. Your collateral will be sold to repay all outstanding loans. This process cannot be undone once confirmed on-chain.", "stopTitle": "Stop Voluntary Liquidation", @@ -2755,17 +2812,84 @@ "confirmStop": "Stop Liquidation", "executingTitle": "Processing...", "executingDescription": "Your voluntary liquidation request is being submitted to the Chainflip State Chain.", - "initiateSuccessTitle": "Liquidation Initiated", - "initiateSuccessDescription": "Your voluntary liquidation has been initiated. Your position will be unwound automatically.", + "initiateSuccessTitle": "Voluntary liquidation started", + "initiateSuccessDescription": "Your collateral is now being sold via DCA at near-market rates to reduce your debt. You can stop these orders at any time from the dashboard.", "stopSuccessTitle": "Liquidation Stopped", "stopSuccessDescription": "Voluntary liquidation has been cancelled. Your position remains open.", "errorTitle": "Transaction Failed", "errorDescription": "The voluntary liquidation transaction failed. Please try again.", "inProgress": "Voluntary liquidation in progress", + "viewDashboard": "View Dashboard", + "summaryStatus": "Status", + "summaryInProgress": "In progress", + "summaryMethod": "Method", + "summaryMethodValue": "DCA", + "summaryStopsWhen": "Stops when", + "summaryStopsWhenValue": "LTV at 0%", + "summaryUnsoldCollateral": "Unsold Collateral", + "summaryUnsoldCollateralValue": "Returned to your account", "steps": { "signing": "Sign transaction", "confirming": "Confirming on-chain" } + }, + "dashboard": { + "freeBalance": "Free Balance", + "supplied": "Supplied", + "collateral": "Collateral", + "borrowed": "Borrowed", + "loanHealth": "Loan Health", + "currentLtv": "Current LTV %{ltv}", + "liquidationDistance": "Liquidation Distance", + "borrowingPower": "Borrowing Power", + "available": "Available", + "yourNextSteps": "Your Next Steps", + "nextStepsSupplyOrCollateral": "Choose whether you want to start earning yield or provide collateral for borrowing.", + "nextStepsCollateral": "Provide collateral to start borrowing.", + "nextStepsBorrow": "You can now borrow against your collateral.", + "noEarningPositions": "No earning positions yet", + "noEarningPositionsDescription": "Supply from your free balance to start earning yield from borrower demand.", + "provideCollateral": "Provide collateral to start borrowing", + "provideCollateralDescription": "Move assets from your free balance into collateral. Your collateral determines how much you can borrow.", + "noActiveLoans": "No Active Loans", + "noActiveLoansDescription": "Post collateral first, then open borrow against it.", + "cantRepay": "Can't Repay?", + "startLiquidation": "Start Liquidation", + "topupAsset": "Top-up Asset", + "getStarted": "Get Started", + "depositFirstAsset": "Deposit your first asset to get started", + "depositFirstAssetDescription": "Your first deposit handles everything - account creation, FLIP funding for State Chain gas, and a recovery address for the chain you deposit from. Future deposits skip all of this.", + "requiresFlip": "Requires 2 FLIP for one-time account funding", + "earnYield": "Earn Yield", + "earnYieldDescription": "Supply assets into a lending pool and earn variable APY. Yields auto-compound, no action needed after supplying.", + "borrowAgainstCollateral": "Borrow Against Collateral", + "borrowAgainstCollateralDescription": "Post BTC, ETH, or SOL as collateral and borrow stablecoins up to the target LTV.", + "estimatedYearlyEarnings": "Est. Yearly Earnings", + "freeBalanceTooltip": "Your available balance on Chainflip State Chain. Deposit from your wallet, or withdraw back.", + "suppliedTooltip": "Assets supplied to lending pools earning yield.", + "collateralTooltip": "Assets posted as collateral for borrowing.", + "borrowedTooltip": "Outstanding loan balances.", + "loanHealthTooltip": "Your loan-to-value ratio. Keep it below the target LTV to avoid liquidation.", + "borrowingPowerTooltip": "How much more you can borrow based on your collateral.", + "deposit": "Deposit", + "withdraw": "Withdraw", + "supply": "Supply", + "addCollateral": "Add Collateral", + "borrow": "Borrow", + "repay": "Repay", + "apy": "APY", + "borrowRate": "Borrow Rate", + "viewDashboard": "View Dashboard", + "lendingMarkets": "Lending Markets", + "asset": "Asset", + "lendingMarketsDescription": "Each pool is isolated per asset. Pick a market to see what you could earn or borrow before you deposit.", + "voluntaryLiquidationActive": "Voluntary Liquidation Active", + "volLiqMethod": "Method", + "volLiqMethodValue": "DCA at near market rates", + "volLiqRemainingDebt": "Remaining Debt", + "volLiqCollateralSold": "Collateral sold", + "volLiqDescription": "Selling collateral to bring your position back to a healthy LTV. This process stops automatically once your LTV is restored, or you can stop it at any time.", + "volLiqStop": "Stop Liquidation" } }, "chart": { diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx index 974b7a5f61c..76caf281c1a 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { BorrowMachineCtx } from './BorrowMachineContext' import { BorrowStepper } from './BorrowStepper' @@ -30,6 +31,7 @@ type BorrowConfirmProps = { export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() @@ -89,6 +91,11 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -112,8 +119,16 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { })} - - + + {translate('chainflipLending.borrow.borrowed')} @@ -124,7 +139,7 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { fontSize='sm' /> - + { px={6} py={4} > - + + + + ) @@ -258,24 +285,42 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.borrow.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.borrow.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.borrow.amount')} + - - {translate('chainflipLending.borrow.projectedLtv')}: {projectedLtvPercent}% + + + + {translate('chainflipLending.borrow.projectedLtv')} + + + {projectedLtvPercent}% @@ -283,25 +328,32 @@ export const BorrowConfirm = memo(({ assetId }: BorrowConfirmProps) => { + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx index 9fe127d95d1..cf3658ee576 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/BorrowInput.tsx @@ -1,6 +1,7 @@ import { ArrowForwardIcon } from '@chakra-ui/icons' import { Button, CardBody, CardFooter, Flex, HStack, Stack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' +import { ethChainId } from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' import { BigAmount } from '@shapeshiftoss/utils' import { useCallback, useMemo, useState } from 'react' @@ -13,14 +14,18 @@ import { LtvGauge } from './LtvGauge' import { Amount } from '@/components/Amount/Amount' import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' +import { ButtonWalletPredicate } from '@/components/ButtonWalletPredicate/ButtonWalletPredicate' import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useModal } from '@/hooks/useModal/useModal' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { useWalletSupportsChain } from '@/hooks/useWalletSupportsChain/useWalletSupportsChain' import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' import { useChainflipBorrowMinimums } from '@/pages/ChainflipLending/hooks/useChainflipBorrowMinimums' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' import { useChainflipOraclePrice } from '@/pages/ChainflipLending/hooks/useChainflipOraclePrices' @@ -33,8 +38,12 @@ type BorrowInputProps = { onAssetChange: (assetId: AssetId) => void } +const DEFAULT_RISKY_LTV = 0.8 + export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { const translate = useTranslate() + const wallet = useWallet().state.wallet + const walletSupportsEth = useWalletSupportsChain(ethChainId, wallet) const { number: { localeParts }, } = useLocaleFormatter() @@ -50,6 +59,16 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { const { totalCollateralFiat, totalBorrowedFiat, loansWithFiat } = useChainflipLoanAccount() const { thresholds } = useChainflipLtvThresholds() const { minimumLoanAmountUsd, minimumUpdateLoanAmountUsd } = useChainflipBorrowMinimums() + const { pools } = useChainflipLendingPools() + + const poolForAsset = useMemo(() => pools.find(p => p.assetId === assetId), [pools, assetId]) + + const borrowCapacityFiat = useMemo(() => { + if (!thresholds) return '0' + const maxBorrow = bnOrZero(totalCollateralFiat).times(thresholds.target) + const capacity = maxBorrow.minus(totalBorrowedFiat) + return capacity.gt(0) ? capacity.toFixed(2) : '0' + }, [totalCollateralFiat, totalBorrowedFiat, thresholds]) const hasExistingLoans = useMemo(() => loansWithFiat.length > 0, [loansWithFiat]) const effectiveMinimumUsd = useMemo( @@ -100,6 +119,12 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { const projectedLtvDecimal = useMemo(() => projectedLtvBps / 10000, [projectedLtvBps]) + const riskyLtv = thresholds?.target ?? DEFAULT_RISKY_LTV + const projectedLtvColor = useMemo( + () => (projectedLtvDecimal > riskyLtv ? 'red.500' : 'text.base'), + [projectedLtvDecimal, riskyLtv], + ) + const assetIds = useMemo(() => Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[], []) const assets = useAppSelector(selectAssets) @@ -113,6 +138,7 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { }, [assetIds, assets]) const buyAssetSearch = useModal('buyAssetSearch') + const chainflipLendingModal = useModal('chainflipLending') const handleAssetClick = useCallback(() => { buyAssetSearch.open({ @@ -135,6 +161,10 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { setInputValue(availableToBorrowCryptoPrecision) }, [availableToBorrowCryptoPrecision]) + const handleAddCollateral = useCallback(() => { + chainflipLendingModal.open({ mode: 'addCollateral', assetId }) + }, [chainflipLendingModal, assetId]) + const handleSubmit = useCallback(() => { if (!asset) return const baseUnit = BigAmount.fromPrecision({ @@ -256,7 +286,12 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { fontWeight='medium' /> - + )} @@ -268,6 +303,50 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { /> )} + {hasCollateral && ( + + + + + {translate('chainflipLending.stats.totalCollateral')} + + + + + + + + + + {translate('chainflipLending.stats.borrowCapacity')} + + + + + + + + + + {translate('chainflipLending.stats.estInterestRate')} + + + + + + + )} + {effectiveMinimumUsd && isBelowMinimum && ( {translate('chainflipLending.borrow.minimumLoan', { @@ -292,8 +371,9 @@ export const BorrowInput = ({ assetId, onAssetChange }: BorrowInputProps) => { px={6} py={4} > - + + + {translate('chainflipLending.borrow.needMorePower')}{' '} + + ) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx index 3723cbdd72d..bf14d914b15 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { CollateralMachineCtx } from './CollateralMachineContext' import { CollateralStepper } from './CollateralStepper' @@ -29,6 +30,7 @@ type CollateralConfirmProps = { export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -73,6 +75,11 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -102,8 +109,16 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { )} - - + + {translate( isAddMode @@ -118,7 +133,17 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { fontSize='sm' /> - + {!isAddMode && ( + + + {translate('chainflipLending.confirm.destination')} + + + {translate('chainflipLending.freeBalance')} + + + )} + { px={6} py={4} > - + + + + ) @@ -252,56 +289,74 @@ export const CollateralConfirm = memo(({ assetId }: CollateralConfirmProps) => { return ( - - - - - - - - - {translate( - isAddMode - ? 'chainflipLending.collateral.confirmAddTitle' - : 'chainflipLending.collateral.confirmRemoveTitle', - )} + + + + + + + + + {translate('chainflipLending.collateral.asset')} - - {translate( - isAddMode - ? 'chainflipLending.collateral.confirmAddDescription' - : 'chainflipLending.collateral.confirmRemoveDescription', - { - amount: collateralAmountCryptoPrecision, - asset: asset.symbol, - }, - )} + + {asset.name} + + + + + {translate('chainflipLending.collateral.amount')} - - + + + {translate('chainflipLending.collateral.action')} + + + {translate( + isAddMode + ? 'chainflipLending.collateral.add' + : 'chainflipLending.collateral.remove', + )} + + + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralInput.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralInput.tsx index b81de64ba5d..d7d8fe36fb3 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/CollateralInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/CollateralInput.tsx @@ -22,6 +22,7 @@ import { useToggle } from '@/hooks/useToggle/useToggle' import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' import { useChainflipBorrowMinimums } from '@/pages/ChainflipLending/hooks/useChainflipBorrowMinimums' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' import { useChainflipOraclePrice } from '@/pages/ChainflipLending/hooks/useChainflipOraclePrices' @@ -59,6 +60,16 @@ export const CollateralInput = ({ assetId, onAssetChange }: CollateralInputProps const { totalCollateralFiat, totalBorrowedFiat } = useChainflipLoanAccount() const { thresholds } = useChainflipLtvThresholds() const { minimumUpdateCollateralAmountUsd } = useChainflipBorrowMinimums() + const { pools } = useChainflipLendingPools() + + const poolForAsset = useMemo(() => pools.find(p => p.assetId === assetId), [pools, assetId]) + + const borrowCapacityFiat = useMemo(() => { + if (!thresholds) return '0' + const maxBorrow = bnOrZero(totalCollateralFiat).times(thresholds.target) + const capacity = maxBorrow.minus(totalBorrowedFiat) + return capacity.gt(0) ? capacity.toFixed(2) : '0' + }, [totalCollateralFiat, totalBorrowedFiat, thresholds]) const [isFiat, toggleIsFiat] = useToggle(false) const [inputValue, setInputValue] = useState(savedCollateralAmount || '') @@ -368,6 +379,50 @@ export const CollateralInput = ({ assetId, onAssetChange }: CollateralInputProps )} + {hasActiveLoans && ( + + + + + {translate('chainflipLending.stats.totalCollateral')} + + + + + + + + + + {translate('chainflipLending.stats.borrowCapacity')} + + + + + + + + + + {translate('chainflipLending.stats.estInterestRate')} + + + + + + + )} + {isBelowMinimum && minimumUpdateCollateralAmountUsd && ( {translate('chainflipLending.collateral.minimumAmount', { diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx index 518e3e7e9e9..7ac2443e35f 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/LtvGauge.tsx @@ -1,5 +1,6 @@ -import { Box, Flex, Text, VStack } from '@chakra-ui/react' +import { Box, Circle, Flex, Icon, Text } from '@chakra-ui/react' import { memo, useMemo } from 'react' +import { TbSkull } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' @@ -9,161 +10,215 @@ type LtvGaugeProps = { projectedLtv?: number } -const GAUGE_HEIGHT = '12px' -const MARKER_HEIGHT = '24px' -const ltvToPercent = (ltv: number): string => `${Math.min(Math.max(ltv * 100, 0), 100)}%` +const GAUGE_HEIGHT = '10px' +const THUMB_SIZE = '20px' -const ltvToDisplayPercent = (ltv: number): string => - `${(Math.min(Math.max(ltv, 0), 1) * 100).toFixed(1)}%` +// Fallback thresholds when config hasn't loaded +const DEFAULT_LOW_LTV = 0.5 +const DEFAULT_TARGET = 0.8 +const DEFAULT_SOFT_LIQUIDATION = 0.9 +const DEFAULT_TOPUP = 0.85 +const DEFAULT_HARD_LIQUIDATION = 0.93 -const DEFAULT_SOFT_LIQUIDATION_LTV = 0.8 -const DEFAULT_HARD_LIQUIDATION_LTV = 0.9 +const ltvToPercent = (ltv: number): number => Math.min(Math.max(ltv * 100, 0), 100) -const getStatusColor = ( - ltv: number, - softLiquidationLtv: number, - hardLiquidationLtv: number, -): string => { - if (ltv >= hardLiquidationLtv) return 'red.500' - if (ltv >= softLiquidationLtv) return 'yellow.500' - return 'green.500' +type Zone = { + labelKey: string + color: string + width: number } -const getStatusKey = ( +const getStatusColor = ( ltv: number, - softLiquidationLtv: number, - hardLiquidationLtv: number, + targetLtv: number, + softLiqLtv: number, + hardLiqLtv: number, ): string => { - if (ltv >= hardLiquidationLtv) return 'chainflipLending.ltv.danger' - if (ltv >= softLiquidationLtv) return 'chainflipLending.ltv.warning' - return 'chainflipLending.ltv.safe' + if (ltv >= hardLiqLtv) return 'red.500' + if (ltv >= softLiqLtv) return 'yellow.500' + if (ltv >= targetLtv) return 'yellow.300' + return 'green.500' } export const LtvGauge = memo(({ currentLtv, projectedLtv }: LtvGaugeProps) => { const translate = useTranslate() const { thresholds } = useChainflipLtvThresholds() - const softLiquidationLtv = thresholds?.softLiquidation ?? DEFAULT_SOFT_LIQUIDATION_LTV - const hardLiquidationLtv = thresholds?.hardLiquidation ?? DEFAULT_HARD_LIQUIDATION_LTV + const lowLtv = thresholds?.lowLtv ?? DEFAULT_LOW_LTV + const targetLtv = thresholds?.target ?? DEFAULT_TARGET + const topupLtv = thresholds?.topup ?? DEFAULT_TOPUP + const softLiqLtv = thresholds?.softLiquidation ?? DEFAULT_SOFT_LIQUIDATION + const hardLiqLtv = thresholds?.hardLiquidation ?? DEFAULT_HARD_LIQUIDATION - const statusColor = useMemo( - () => getStatusColor(currentLtv, softLiquidationLtv, hardLiquidationLtv), - [currentLtv, softLiquidationLtv, hardLiquidationLtv], - ) - const statusKey = useMemo( - () => getStatusKey(currentLtv, softLiquidationLtv, hardLiquidationLtv), - [currentLtv, softLiquidationLtv, hardLiquidationLtv], - ) + const thumbPosition = useMemo(() => ltvToPercent(currentLtv), [currentLtv]) - const gaugeGradient = useMemo( - () => - `linear(to-r, green.500 0%, green.500 ${softLiquidationLtv * 100}%, yellow.500 ${ - softLiquidationLtv * 100 - }%, yellow.500 ${hardLiquidationLtv * 100}%, red.500 ${ - hardLiquidationLtv * 100 - }%, red.800 100%)`, - [softLiquidationLtv, hardLiquidationLtv], + const projectedThumbPosition = useMemo( + () => (projectedLtv !== undefined ? ltvToPercent(projectedLtv) : undefined), + [projectedLtv], ) - const thresholdMarkers = useMemo(() => { - if (!thresholds) return [] - return [ + // 4 zones based on protocol thresholds + const zones: Zone[] = useMemo( + () => [ { - value: thresholds.target, - labelKey: 'chainflipLending.ltv.target', + labelKey: 'chainflipLending.ltv.conservative', + color: 'green.700', + width: lowLtv * 100, + }, + { + labelKey: 'chainflipLending.ltv.optimal', color: 'green.500', + width: (targetLtv - lowLtv) * 100, }, { - value: thresholds.softLiquidation, - labelKey: 'chainflipLending.ltv.softLiquidation', + labelKey: 'chainflipLending.ltv.risky', color: 'yellow.500', + width: (softLiqLtv - targetLtv) * 100, }, { - value: thresholds.hardLiquidation, - labelKey: 'chainflipLending.ltv.hardLiquidation', + labelKey: 'chainflipLending.ltv.liquidation', color: 'red.500', + width: (1 - softLiqLtv) * 100, }, - ] - }, [thresholds]) + ], + [lowLtv, targetLtv, softLiqLtv], + ) + + const skullPosition = useMemo(() => hardLiqLtv * 100, [hardLiqLtv]) + const topupPosition = useMemo(() => topupLtv * 100, [topupLtv]) return ( - - + {/* Bar track - 4 zone segments */} + - + {zones.map(zone => ( + + ))} + + + {/* Filled portion up to current LTV */} + = 100 ? 'full' : '0'} + overflow='hidden' + transition='width 0.3s ease' + > + 0 ? (100 / thumbPosition) * 100 : 100}%`}> + {zones.map(zone => ( + + ))} + + {/* Topup threshold marker (subtle tick at 85%) */} - {projectedLtv !== undefined && ( + {/* Skull icon at hard liquidation boundary */} + + + + + + + {/* Projected LTV thumb (dashed circle) */} + {projectedThumbPosition !== undefined && ( + top='50%' + left={`${projectedThumbPosition}%`} + transform='translate(-50%, -50%)' + zIndex={3} + transition='left 0.3s ease' + > + + )} - {thresholdMarkers.map(marker => ( - - ))} + {/* Current LTV thumb */} + + + - - - - {ltvToDisplayPercent(currentLtv)} - - - {translate(statusKey)} - - - - {thresholdMarkers.map(marker => ( - - - - {translate(marker.labelKey)} - - - ))} - - + {/* Percentage labels under the bar */} + + + 0% + + + 100% + + + + {/* Legend - 4 zones */} + + {zones.map(zone => ( + + + + {translate(zone.labelKey)} + + + ))} + ) }) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx index 59762893b31..8a0cd982f73 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/RepayConfirm.tsx @@ -1,10 +1,20 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Badge, Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { + Badge, + Button, + CardBody, + CardFooter, + Divider, + Flex, + HStack, + VStack, +} from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { useRepayActionCenter } from './hooks/useRepayActionCenter' import { useRepayConfirmation } from './hooks/useRepayConfirmation' @@ -29,6 +39,7 @@ type RepayConfirmProps = { export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() @@ -73,6 +84,11 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -96,8 +112,16 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { })} - - + + {translate('chainflipLending.repay.repaid')} @@ -115,7 +139,7 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { )} - + { px={6} py={4} > - + + + + ) @@ -249,50 +285,86 @@ export const RepayConfirm = memo(({ assetId }: RepayConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.repay.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.repay.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.repay.amount')} + - {isFullRepayment && ( - {translate('chainflipLending.repay.fullRepayment')} - )} + + + + {translate('chainflipLending.repay.repaymentType')} + + + + {translate( + isFullRepayment + ? 'chainflipLending.repay.fullRepayment' + : 'chainflipLending.repay.partialRepayment', + )} + + {isFullRepayment && ( + + {translate('chainflipLending.repay.full')} + + )} + + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx index 600103d31b8..4f681128d72 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/RepayInput.tsx @@ -1,4 +1,4 @@ -import { Button, CardBody, CardFooter, Flex, Stack, Switch, VStack } from '@chakra-ui/react' +import { Box, Button, CardBody, CardFooter, Flex, Stack, Switch, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { BigAmount } from '@shapeshiftoss/utils' import { useCallback, useMemo, useState } from 'react' @@ -189,12 +189,19 @@ export const RepayInput = ({ assetId }: RepayInputProps) => { {translate('chainflipLending.repay.outstanding')} - + + + + @@ -271,18 +278,20 @@ export const RepayInput = ({ assetId }: RepayInputProps) => { color='text.subtle' /> - {!isFullRepayment && ( - - )} + + {!isFullRepayment && ( + + )} + diff --git a/src/pages/ChainflipLending/Pool/components/Borrow/VoluntaryLiquidationConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Borrow/VoluntaryLiquidationConfirm.tsx index a9109e3edbb..4c7819937d3 100644 --- a/src/pages/ChainflipLending/Pool/components/Borrow/VoluntaryLiquidationConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Borrow/VoluntaryLiquidationConfirm.tsx @@ -1,25 +1,31 @@ import { CheckCircleIcon, WarningIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, VStack } from '@chakra-ui/react' +import { Box, Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo } from 'react' +import { TbShieldHalf } from 'react-icons/tb' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { useVoluntaryLiquidationConfirmation } from './hooks/useVoluntaryLiquidationConfirmation' import { useVoluntaryLiquidationSign } from './hooks/useVoluntaryLiquidationSign' import { VoluntaryLiquidationMachineCtx } from './VoluntaryLiquidationMachineContext' import { VoluntaryLiquidationStepper } from './VoluntaryLiquidationStepper' +import { Amount } from '@/components/Amount/Amount' import { CircularProgress } from '@/components/CircularProgress/CircularProgress' import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useModal } from '@/hooks/useModal/useModal' import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' import { reactQueries } from '@/react-queries' export const VoluntaryLiquidationConfirm = memo(() => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() + const { totalCollateralFiat, totalBorrowedFiat, loanAccount } = useChainflipLoanAccount() const { close: closeModal } = useModal('chainflipLending') @@ -38,6 +44,16 @@ export const VoluntaryLiquidationConfirm = memo(() => { const isInitiate = useMemo(() => action === 'initiate', [action]) + const currentLtvPercent = useMemo(() => { + if (!loanAccount?.ltv_ratio) return '0.0' + try { + const decimal = Number(BigInt(loanAccount.ltv_ratio)) / 1_000_000_000 + return (decimal * 100).toFixed(1) + } catch { + return '0.0' + } + }, [loanAccount?.ltv_ratio]) + const handleConfirm = useCallback(() => { actorRef.send({ type: 'CONFIRM' }) }, [actorRef]) @@ -57,11 +73,118 @@ export const VoluntaryLiquidationConfirm = memo(() => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) if (isSuccess) { + if (isInitiate) { + return ( + + + + + + + {translate('chainflipLending.voluntaryLiquidation.initiateSuccessTitle')} + + + {translate('chainflipLending.voluntaryLiquidation.initiateSuccessDescription')} + + + + } + > + + + {translate('chainflipLending.voluntaryLiquidation.summaryStatus')} + + + {translate('chainflipLending.voluntaryLiquidation.summaryInProgress')} + + + + + {translate('chainflipLending.voluntaryLiquidation.summaryMethod')} + + + {translate('chainflipLending.voluntaryLiquidation.summaryMethodValue')} + + + + + {translate('chainflipLending.voluntaryLiquidation.summaryStopsWhen')} + + + {translate('chainflipLending.voluntaryLiquidation.summaryStopsWhenValue')} + + + + + {translate('chainflipLending.voluntaryLiquidation.summaryUnsoldCollateral')} + + + {translate( + 'chainflipLending.voluntaryLiquidation.summaryUnsoldCollateralValue', + )} + + + + + + + + + + + + + + ) + } + + // Stop success - keep simple return ( @@ -69,18 +192,10 @@ export const VoluntaryLiquidationConfirm = memo(() => { - {translate( - isInitiate - ? 'chainflipLending.voluntaryLiquidation.initiateSuccessTitle' - : 'chainflipLending.voluntaryLiquidation.stopSuccessTitle', - )} + {translate('chainflipLending.voluntaryLiquidation.stopSuccessTitle')} - {translate( - isInitiate - ? 'chainflipLending.voluntaryLiquidation.initiateSuccessDescription' - : 'chainflipLending.voluntaryLiquidation.stopSuccessDescription', - )} + {translate('chainflipLending.voluntaryLiquidation.stopSuccessDescription')} @@ -204,25 +319,99 @@ export const VoluntaryLiquidationConfirm = memo(() => { ) } + // Initiate confirm - redesigned with shield icon, info rows, and warning + if (isInitiate) { + return ( + + + + + + {translate('chainflipLending.voluntaryLiquidation.reduceDebt')} + + + + + {translate('chainflipLending.voluntaryLiquidation.currentCollateral')} + + + + + + {translate('chainflipLending.voluntaryLiquidation.outstandingDebt')} + + + + + + {translate('chainflipLending.voluntaryLiquidation.currentLtv')} + + + {currentLtvPercent}% + + + + + {translate('chainflipLending.voluntaryLiquidation.estimatedDuration')} + + + {translate('chainflipLending.voluntaryLiquidation.estimatedDurationValue')} + + + + + {translate('chainflipLending.voluntaryLiquidation.initiateWarningDetailed')} + + + + + + + + + + + ) + } + + // Stop confirm - keep existing style return ( - + - {translate( - isInitiate - ? 'chainflipLending.voluntaryLiquidation.initiateTitle' - : 'chainflipLending.voluntaryLiquidation.stopTitle', - )} + {translate('chainflipLending.voluntaryLiquidation.stopTitle')} - {translate( - isInitiate - ? 'chainflipLending.voluntaryLiquidation.initiateWarning' - : 'chainflipLending.voluntaryLiquidation.stopDescription', - )} + {translate('chainflipLending.voluntaryLiquidation.stopDescription')} @@ -236,7 +425,7 @@ export const VoluntaryLiquidationConfirm = memo(() => { py={4} > + + + + ) @@ -354,54 +381,92 @@ export const DepositConfirm = memo(({ assetId }: DepositConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.deposit.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.deposit.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.deposit.amount')} + + + + + {translate('chainflipLending.deposit.freeBalance')} + + {effectiveRefundAddress && ( - <> - - - - {translate('chainflipLending.deposit.refundAddress.label')} + + + {translate('chainflipLending.deposit.recoveryAddress')} + + + + - - - - - - - + + )} + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx index b3cd5205d8f..3939e66bff0 100644 --- a/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Egress/EgressConfirm.tsx @@ -5,6 +5,7 @@ import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { EgressMachineCtx } from './EgressMachineContext' import { EgressStepper } from './EgressStepper' @@ -31,6 +32,7 @@ type EgressConfirmProps = { export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -80,6 +82,11 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -102,8 +109,16 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { })} - - + + {translate('chainflipLending.egress.withdrawn')} @@ -115,7 +130,7 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { /> {egressTxRef && ( - + {translate('chainflipLending.egress.transactionId')} @@ -133,7 +148,7 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { )} )} - + { px={6} py={4} > - + + + + ) @@ -267,60 +294,79 @@ export const EgressConfirm = memo(({ assetId }: EgressConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.egress.confirmTitle')} - + + + + + + + + {translate('chainflipLending.egress.asset')} + + + {asset.name} + + + + + {translate('chainflipLending.egress.amount')} + + + {destinationAddress && ( - <> - - - - {translate('chainflipLending.egress.receiveAddress')} + + + {translate('chainflipLending.egress.receiveAddress')} + + + + - - - - - - - + + )} + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx index 18a18f7d673..fe39df0303c 100644 --- a/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Supply/SupplyConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Box, Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' -import { memo, useCallback } from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { useSupplyActionCenter } from './hooks/useSupplyActionCenter' import { useSupplyConfirmation } from './hooks/useSupplyConfirmation' @@ -18,7 +19,9 @@ import { CircularProgress } from '@/components/CircularProgress/CircularProgress import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { reactQueries } from '@/react-queries' import { selectAssetById } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' @@ -29,6 +32,7 @@ type SupplyConfirmProps = { export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -47,6 +51,13 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { const stepConfirmed = SupplyMachineCtx.useSelector(s => s.context.stepConfirmed) const isConfirming = SupplyMachineCtx.useSelector(s => s.matches('confirming')) + const { pools } = useChainflipLendingPools() + + const supplyApyPercent = useMemo(() => { + const pool = pools.find(p => p.assetId === assetId) + return pool ? bnOrZero(pool.supplyApy).times(100).toFixed(2) : null + }, [pools, assetId]) + useSupplySign() useSupplyConfirmation() useSupplyActionCenter() @@ -72,6 +83,11 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { closeModal() }, [scAccount, queryClient, actorRef, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -94,8 +110,16 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { })} - - + + {translate('chainflipLending.supply.supplied')} @@ -106,7 +130,17 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { fontSize='sm' /> - + {supplyApyPercent !== null && ( + + + {translate('chainflipLending.supply.poolApy')} + + + {supplyApyPercent}% + + + )} + { px={6} py={4} > - + + + + ) @@ -240,47 +286,101 @@ export const SupplyConfirm = memo(({ assetId }: SupplyConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.supply.confirmTitle')} - - + + + + + + + + + + {translate('chainflipLending.supply.asset')} + + + + + {asset.symbol} + + + + + + {translate('chainflipLending.supply.amount')} + + + + {supplyApyPercent !== null && ( + + + {translate('chainflipLending.supply.poolApy')} + + + {supplyApyPercent}% + + + )} + + + {translate('chainflipLending.supply.destination')} + + + {translate('chainflipLending.supply.lendingPool', { asset: asset.symbol })} + + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx b/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx index 2d5ca4de85d..ccc19665993 100644 --- a/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Supply/SupplyInput.tsx @@ -1,5 +1,6 @@ -import { Button, CardBody, CardFooter, Flex, Stack, VStack } from '@chakra-ui/react' +import { Box, Button, CardBody, CardFooter, Divider, Flex, Stack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' +import { ethChainId, usdcAssetId, usdtAssetId } from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' import { BigAmount } from '@shapeshiftoss/utils' import { useCallback, useMemo, useState } from 'react' @@ -10,21 +11,27 @@ import { useTranslate } from 'react-polyglot' import { SupplyMachineCtx } from './SupplyMachineContext' import { Amount } from '@/components/Amount/Amount' -import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' -import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { AssetIcon } from '@/components/AssetIcon' +import { ButtonWalletPredicate } from '@/components/ButtonWalletPredicate/ButtonWalletPredicate' import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useModal } from '@/hooks/useModal/useModal' import { useToggle } from '@/hooks/useToggle/useToggle' +import { useWallet } from '@/hooks/useWallet/useWallet' +import { useWalletSupportsChain } from '@/hooks/useWalletSupportsChain/useWalletSupportsChain' import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipMinimumSupply } from '@/pages/ChainflipLending/hooks/useChainflipMinimumSupply' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/marketDataSlice/selectors' import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectAssetById, selectAssets } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const STABLECOIN_ASSET_IDS: AssetId[] = [usdcAssetId, usdtAssetId] + type SupplyInputProps = { assetId: AssetId onAssetChange: (assetId: AssetId) => void @@ -32,6 +39,8 @@ type SupplyInputProps = { export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { const translate = useTranslate() + const wallet = useWallet().state.wallet + const walletSupportsEth = useWalletSupportsChain(ethChainId, wallet) const { number: { localeParts }, } = useLocaleFormatter() @@ -79,6 +88,46 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { const cryptoValue = isFiat ? cryptoFromFiat : inputValue const fiatValue = isFiat ? inputValue : fiatFromCrypto + const { pools } = useChainflipLendingPools() + const { supplyPositions } = useChainflipSupplyPositions() + + const poolForAsset = useMemo(() => pools.find(p => p.assetId === assetId), [pools, assetId]) + + const currentPositionCrypto = useMemo(() => { + const position = supplyPositions.find(p => p.assetId === assetId) + return position?.totalAmountCryptoPrecision ?? '0' + }, [supplyPositions, assetId]) + + const currentPositionFiat = useMemo(() => { + const position = supplyPositions.find(p => p.assetId === assetId) + return position?.totalAmountFiat ?? '0' + }, [supplyPositions, assetId]) + + const supplyApyPercent = useMemo( + () => (poolForAsset ? bnOrZero(poolForAsset.supplyApy).times(100).toFixed(2) : null), + [poolForAsset], + ) + + const poolSharePercent = useMemo(() => { + if (!poolForAsset) return '0.00' + const totalPoolCrypto = bnOrZero(poolForAsset.totalAmountCryptoPrecision) + if (totalPoolCrypto.isZero()) return '0.00' + const userCrypto = bnOrZero(currentPositionCrypto).plus(bnOrZero(cryptoValue)) + return userCrypto + .div(totalPoolCrypto.plus(bnOrZero(cryptoValue))) + .times(100) + .toFixed(2) + }, [poolForAsset, currentPositionCrypto, cryptoValue]) + + const isStablecoin = useMemo(() => STABLECOIN_ASSET_IDS.includes(assetId), [assetId]) + + const estYearlyEarningsFiat = useMemo(() => { + if (!poolForAsset || !marketData?.price) return null + const apyDecimal = bnOrZero(poolForAsset.supplyApy) + if (apyDecimal.isZero()) return null + return bnOrZero(cryptoValue).times(marketData.price).times(apyDecimal).toFixed(2) + }, [poolForAsset, cryptoValue, marketData?.price]) + const { minSupply, isLoading: isMinSupplyLoading } = useChainflipMinimumSupply(assetId) const isBelowMinimum = useMemo(() => { @@ -119,11 +168,6 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { }) }, [buyAssetSearch, onAssetChange, lendingAssets]) - const handleAssetChange = useCallback( - (asset: Asset) => onAssetChange(asset.assetId), - [onAssetChange], - ) - const handleInputChange = useCallback((values: NumberFormatValues) => { setInputValue(values.value) }, []) @@ -188,7 +232,7 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { return translate('chainflipLending.supply.minimumSupply', { amount: `${bnOrZero(minSupply).decimalPlaces(2).toFixed()} ${asset?.symbol}`, }) - return translate('chainflipLending.supply.title') + return translate('common.next') }, [exceedsBalance, isBelowMinimum, minSupply, asset?.symbol, translate]) if (!asset) return null @@ -197,16 +241,79 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { - + {/* Asset to supply label */} + + {translate('chainflipLending.supply.assetToSupply')} + + + {/* Asset card */} + + + + + + + {asset.name} + + + + + {availableFiat !== undefined && ( + + )} + + + + {/* Stats row - Pool APY and Current Position in bordered boxes */} + + {supplyApyPercent !== null && ( + + + + {translate('chainflipLending.supply.poolApy')} + + + {supplyApyPercent}% + + + + )} + + + + {translate('chainflipLending.supply.currentPosition')} + + {bnOrZero(currentPositionCrypto).gt(0) ? ( + + ) : ( + + )} + + + + {/* Amount label */} {translate('chainflipLending.supply.amount')} @@ -229,7 +336,7 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { onValueChange={handleInputChange} style={{ flex: 1, - fontSize: '1.25rem', + fontSize: '1.5rem', fontWeight: 'bold', background: 'transparent', border: 'none', @@ -238,60 +345,109 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { }} /> {!isFiat && ( - - {asset.symbol} - + + + + {asset.symbol} + + )} - {marketData?.price && ( - - )} - - - - - {translate('chainflipLending.supply.available')} - - - - - {availableFiat !== undefined && ( - - )} + {/* Fiat estimate + Balance/Max row */} + + {marketData?.price ? ( + + ) : ( + + )} + + + {translate('common.balance')}: + - - + + - + + + + + {/* Info rows */} + + {estYearlyEarningsFiat !== null && !bnOrZero(cryptoValue).isZero() && ( + + + {translate('chainflipLending.dashboard.estimatedYearlyEarnings')} + + + + )} + + + + {translate('chainflipLending.supply.poolShare')} + + + {poolSharePercent}% + + + + + + {translate('chainflipLending.supply.autoCompounding')} + + + {translate('chainflipLending.supply.enabled')} + + + + + + {translate('chainflipLending.supply.riskBand')} + + + {isStablecoin + ? translate('chainflipLending.supply.conservativeStablecoin') + : translate('chainflipLending.supply.volatileAsset')} + + + {!hasFreeBalance && ( @@ -309,8 +465,9 @@ export const SupplyInput = ({ assetId, onAssetChange }: SupplyInputProps) => { px={6} py={4} > - + ) diff --git a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx index 1034d8f6fa6..5ac58d9d227 100644 --- a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx +++ b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawConfirm.tsx @@ -1,10 +1,11 @@ import { ArrowForwardIcon, CheckCircleIcon } from '@chakra-ui/icons' -import { Button, CardBody, CardFooter, Flex, HStack, VStack } from '@chakra-ui/react' +import { Button, CardBody, CardFooter, Divider, Flex, HStack, VStack } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' import { flipAssetId } from '@shapeshiftoss/caip' import { useQueryClient } from '@tanstack/react-query' import { memo, useCallback } from 'react' import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' import { useWithdrawActionCenter } from './hooks/useWithdrawActionCenter' import { useWithdrawConfirmation } from './hooks/useWithdrawConfirmation' @@ -29,6 +30,7 @@ type WithdrawConfirmProps = { export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { const translate = useTranslate() + const navigate = useNavigate() const queryClient = useQueryClient() const { scAccount } = useChainflipLendingAccount() const { close: closeModal } = useModal('chainflipLending') @@ -42,6 +44,8 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { const withdrawAmountCryptoPrecision = WithdrawMachineCtx.useSelector( s => s.context.withdrawAmountCryptoPrecision, ) + const withdrawToWallet = WithdrawMachineCtx.useSelector(s => s.context.withdrawToWallet) + const withdrawAddress = WithdrawMachineCtx.useSelector(s => s.context.withdrawAddress) const error = WithdrawMachineCtx.useSelector(s => s.context.error) const isNativeWallet = WithdrawMachineCtx.useSelector(s => s.context.isNativeWallet) const stepConfirmed = WithdrawMachineCtx.useSelector(s => s.context.stepConfirmed) @@ -71,6 +75,11 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { closeModal() }, [scAccount, queryClient, closeModal]) + const handleViewDashboard = useCallback(async () => { + await handleDone() + navigate('/chainflip-lending') + }, [handleDone, navigate]) + const handleBack = useCallback(() => { actorRef.send({ type: 'BACK' }) }, [actorRef]) @@ -93,8 +102,16 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { })} - - + + {translate('chainflipLending.withdraw.withdrawn')} @@ -105,7 +122,23 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { fontSize='sm' /> - + + + {translate('chainflipLending.withdraw.destination')} + + + {withdrawToWallet && withdrawAddress + ? withdrawAddress + : translate('chainflipLending.freeBalance')} + + + { px={6} py={4} > - + + + + ) @@ -239,47 +284,75 @@ export const WithdrawConfirm = memo(({ assetId }: WithdrawConfirmProps) => { return ( - - - - - - - - {translate('chainflipLending.withdraw.confirmTitle')} - - + + + + + + + + + {translate('chainflipLending.withdraw.amount')} + + + + {translate('chainflipLending.withdraw.destination')} + + + {withdrawToWallet && withdrawAddress + ? withdrawAddress + : translate('chainflipLending.freeBalance')} + + + - ) diff --git a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawInput.tsx b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawInput.tsx index fb2682a7ccd..98b9d0070f9 100644 --- a/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawInput.tsx +++ b/src/pages/ChainflipLending/Pool/components/Withdraw/WithdrawInput.tsx @@ -1,33 +1,58 @@ -import { Button, CardBody, CardFooter, Flex, Stack, VStack } from '@chakra-ui/react' -import type { AssetId } from '@shapeshiftoss/caip' -import { ethChainId } from '@shapeshiftoss/caip' +import { + Button, + CardBody, + CardFooter, + Checkbox, + Flex, + FormControl, + FormHelperText, + HStack, + Input, + Stack, + VStack, +} from '@chakra-ui/react' +import type { AccountId, AssetId } from '@shapeshiftoss/caip' +import { ethChainId, fromAccountId, fromAssetId } from '@shapeshiftoss/caip' import type { Asset } from '@shapeshiftoss/types' import { BigAmount } from '@shapeshiftoss/utils' import { useCallback, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' import type { NumberFormatValues } from 'react-number-format' import { NumericFormat } from 'react-number-format' import { useTranslate } from 'react-polyglot' import { WithdrawMachineCtx } from './WithdrawMachineContext' +import { AccountDropdown } from '@/components/AccountDropdown/AccountDropdown' import { Amount } from '@/components/Amount/Amount' import { TradeAssetSelect } from '@/components/AssetSelection/AssetSelection' import { ButtonWalletPredicate } from '@/components/ButtonWalletPredicate/ButtonWalletPredicate' import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { InlineCopyButton } from '@/components/InlineCopyButton' import { SlideTransition } from '@/components/SlideTransition' import { RawText } from '@/components/Text' import { useLocaleFormatter } from '@/hooks/useLocaleFormatter/useLocaleFormatter' import { useModal } from '@/hooks/useModal/useModal' import { useWallet } from '@/hooks/useWallet/useWallet' import { useWalletSupportsChain } from '@/hooks/useWalletSupportsChain/useWalletSupportsChain' +import { validateAddress } from '@/lib/address/validation' import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' import { useChainflipMinimumSupply } from '@/pages/ChainflipLending/hooks/useChainflipMinimumSupply' import { selectMarketDataByAssetIdUserCurrency } from '@/state/slices/marketDataSlice/selectors' +import { selectAccountIdsByAccountNumberAndChainId } from '@/state/slices/portfolioSlice/selectors' import { allowedDecimalSeparators } from '@/state/slices/preferencesSlice/preferencesSlice' import { selectAssetById, selectAssets } from '@/state/slices/selectors' import { useAppSelector } from '@/state/store' +const dropdownBoxProps = { width: 'full', p: 0, m: 0 } +const dropdownButtonProps = { width: 'full', variant: 'solid', height: '40px', px: 4 } + +type AddressFormValues = { + manualAddress: string +} + type WithdrawInputProps = { assetId: AssetId onAssetChange: (assetId: AssetId) => void @@ -43,6 +68,10 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => const asset = useAppSelector(state => selectAssetById(state, assetId)) const marketData = useAppSelector(state => selectMarketDataByAssetIdUserCurrency(state, assetId)) + const { accountNumber } = useChainflipLendingAccount() + const accountIdsByAccountNumberAndChainId = useAppSelector( + selectAccountIdsByAccountNumberAndChainId, + ) const actorRef = WithdrawMachineCtx.useActorRef() const supplyPositionCryptoBaseUnit = WithdrawMachineCtx.useSelector( @@ -55,6 +84,36 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => const [withdrawAmountCryptoPrecision, setWithdrawAmountCryptoPrecision] = useState( savedWithdrawAmount || '', ) + const [withdrawToWallet, setWithdrawToWallet] = useState(false) + + const chainId = useMemo(() => fromAssetId(assetId).chainId, [assetId]) + const walletSupportsAssetChain = useWalletSupportsChain(chainId, wallet) + + const poolChainAccountId = useMemo(() => { + const byChainId = accountIdsByAccountNumberAndChainId[accountNumber] + return byChainId?.[chainId]?.[0] + }, [accountIdsByAccountNumberAndChainId, accountNumber, chainId]) + + const defaultAddress = useMemo( + () => (poolChainAccountId ? fromAccountId(poolChainAccountId).account : ''), + [poolChainAccountId], + ) + + const [destinationAddress, setDestinationAddress] = useState(defaultAddress) + const [defaultAccountId, setDefaultAccountId] = useState( + poolChainAccountId, + ) + const [isCustomAddress, setIsCustomAddress] = useState(!walletSupportsAssetChain) + + const { + register, + formState: { errors }, + setValue, + trigger, + } = useForm({ + mode: 'onChange', + reValidateMode: 'onChange', + }) const availableCryptoPrecision = useMemo( () => @@ -117,6 +176,58 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => setWithdrawAmountCryptoPrecision(availableCryptoPrecision) }, [availableCryptoPrecision]) + const handleWithdrawToWalletChange = useCallback( + (e: React.ChangeEvent) => { + const checked = e.target.checked + setWithdrawToWallet(checked) + if (checked) { + const showCustom = !walletSupportsAssetChain + setDestinationAddress(showCustom ? '' : defaultAddress) + setIsCustomAddress(showCustom) + } + }, + [defaultAddress, walletSupportsAssetChain], + ) + + const handleAccountChange = useCallback((newAccountId: string) => { + const address = fromAccountId(newAccountId).account + setDefaultAccountId(newAccountId) + setDestinationAddress(address) + }, []) + + const handleToggleCustomAddress = useCallback(() => { + if (!walletSupportsAssetChain) return + const walletAddress = defaultAccountId ? fromAccountId(defaultAccountId).account : '' + setDestinationAddress(isCustomAddress ? walletAddress : '') + setIsCustomAddress(prev => !prev) + }, [walletSupportsAssetChain, isCustomAddress, defaultAccountId]) + + const validateChainAddress = useCallback( + async (address: string) => { + if (!address) { + setDestinationAddress('') + return true + } + const isValid = await validateAddress({ maybeAddress: address, chainId }) + if (!isValid) { + setDestinationAddress('') + return translate('common.invalidAddress') + } + setDestinationAddress(address) + return true + }, + [chainId, translate], + ) + + const handleManualInputChange = useCallback( + async (e: React.ChangeEvent) => { + const newValue = e.target.value + setValue('manualAddress', newValue, { shouldValidate: true }) + await trigger('manualAddress') + }, + [setValue, trigger], + ) + const isFullWithdrawal = useMemo( () => isFullWithdrawalOnly || @@ -141,6 +252,8 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => const handleSubmit = useCallback(() => { if (!asset) return + const withdrawAddress = withdrawToWallet ? destinationAddress : '' + if (withdrawToWallet && !withdrawAddress) return const amountPrecision = isFullWithdrawalOnly ? availableCryptoPrecision : withdrawAmountCryptoPrecision @@ -152,6 +265,8 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => type: 'SUBMIT', withdrawAmountCryptoPrecision: amountPrecision, withdrawAmountCryptoBaseUnit, + withdrawAddress, + withdrawToWallet, isFullWithdrawal, }) }, [ @@ -160,16 +275,27 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => availableCryptoPrecision, isFullWithdrawalOnly, asset, + destinationAddress, + withdrawToWallet, isFullWithdrawal, ]) + const isValidWallet = useMemo( + () => + Boolean( + walletSupportsEth && (!withdrawToWallet || walletSupportsAssetChain || isCustomAddress), + ), + [walletSupportsEth, withdrawToWallet, walletSupportsAssetChain, isCustomAddress], + ) + const isSubmitDisabled = useMemo( () => isMinSupplyLoading || (!isFullWithdrawalOnly && bnOrZero(withdrawAmountCryptoPrecision).isZero()) || bnOrZero(withdrawAmountCryptoPrecision).gt(availableCryptoPrecision) || isBelowMinimum || - isRemainingBelowMinimum, + isRemainingBelowMinimum || + (withdrawToWallet && !destinationAddress.trim()), [ isMinSupplyLoading, withdrawAmountCryptoPrecision, @@ -177,6 +303,8 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => isFullWithdrawalOnly, isBelowMinimum, isRemainingBelowMinimum, + withdrawToWallet, + destinationAddress, ], ) @@ -287,9 +415,70 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => )} - - {translate('chainflipLending.withdraw.freeBalanceExplainer')} - + + + {translate('chainflipLending.withdraw.freeBalanceExplainer')} + + + + {translate('chainflipLending.withdraw.alsoWithdrawToWallet')} + + + {withdrawToWallet && ( + + + + {translate('chainflipLending.withdraw.destinationAddress')} + + {walletSupportsAssetChain && ( + + )} + + {isCustomAddress ? ( + + ) : ( + + + + )} + {errors.manualAddress && ( + + {errors.manualAddress.message as string} + + )} + + )} + ) : ( @@ -309,7 +498,7 @@ export const WithdrawInput = ({ assetId, onAssetChange }: WithdrawInputProps) => > { lastUsedNonce, isNativeWallet, stepConfirmed, + withdrawToWallet, + withdrawAddress, } = WithdrawMachineCtx.useSelector(s => ({ assetId: s.context.assetId, withdrawAmountCryptoBaseUnit: s.context.withdrawAmountCryptoBaseUnit, @@ -25,6 +27,8 @@ export const useWithdrawSign = () => { lastUsedNonce: s.context.lastUsedNonce, isNativeWallet: s.context.isNativeWallet, stepConfirmed: s.context.stepConfirmed, + withdrawToWallet: s.context.withdrawToWallet, + withdrawAddress: s.context.withdrawAddress, })) const wallet = useWallet().state.wallet const { accountId, scAccount } = useChainflipLendingAccount() @@ -45,10 +49,22 @@ export const useWithdrawSign = () => { if (!scAccount) throw new Error('State Chain account not derived') if (!cfAsset) throw new Error(`Unsupported asset: ${assetId}`) - const encodedCall = encodeRemoveLenderFunds( + const removeCall = encodeRemoveLenderFunds( cfAsset, isFullWithdrawal ? null : withdrawAmountCryptoBaseUnit, ) + + let encodedCall: string + if (withdrawToWallet && withdrawAddress) { + const egressCall = encodeWithdrawAsset(withdrawAmountCryptoBaseUnit, cfAsset, { + chain: cfAsset.chain, + address: withdrawAddress, + }) + encodedCall = encodeBatch([removeCall, egressCall]) + } else { + encodedCall = removeCall + } + const nonceOrAccount = lastUsedNonce !== undefined ? lastUsedNonce + 1 : scAccount const { txHash, nonce } = await signAndSubmit({ @@ -81,5 +97,7 @@ export const useWithdrawSign = () => { isFullWithdrawal, isNativeWallet, stepConfirmed, + withdrawToWallet, + withdrawAddress, ]) } diff --git a/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.test.ts b/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.test.ts index 7ca42959021..489c194d8e4 100644 --- a/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.test.ts +++ b/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.test.ts @@ -11,6 +11,8 @@ const defaultSubmitEvent = { withdrawAmountCryptoPrecision: '100.5', withdrawAmountCryptoBaseUnit: '100500000', isFullWithdrawal: false, + withdrawToWallet: false, + withdrawAddress: '', } const createTestActor = (overrides?: Partial<{ isNativeWallet: boolean }>) => { diff --git a/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.ts b/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.ts index 575446dea94..867b5d02fcf 100644 --- a/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.ts +++ b/src/pages/ChainflipLending/Pool/components/Withdraw/withdrawMachine.ts @@ -10,6 +10,8 @@ type WithdrawMachineContext = { withdrawAmountCryptoBaseUnit: string supplyPositionCryptoBaseUnit: string isFullWithdrawal: boolean + withdrawToWallet: boolean + withdrawAddress: string stepConfirmed: boolean txHash: string | null lastUsedNonce: number | undefined @@ -28,6 +30,8 @@ type WithdrawMachineEvent = withdrawAmountCryptoPrecision: string withdrawAmountCryptoBaseUnit: string isFullWithdrawal: boolean + withdrawToWallet: boolean + withdrawAddress: string } | { type: 'CONFIRM' } | { type: 'BACK' } @@ -62,6 +66,14 @@ export const withdrawMachine = setup({ assertEvent(event, 'SUBMIT') return event.isFullWithdrawal }, + withdrawToWallet: ({ event }) => { + assertEvent(event, 'SUBMIT') + return event.withdrawToWallet + }, + withdrawAddress: ({ event }) => { + assertEvent(event, 'SUBMIT') + return event.withdrawAddress + }, }), syncSupplyPosition: assign({ supplyPositionCryptoBaseUnit: ({ event }) => { @@ -92,6 +104,8 @@ export const withdrawMachine = setup({ withdrawAmountCryptoPrecision: '', withdrawAmountCryptoBaseUnit: '0', isFullWithdrawal: false, + withdrawToWallet: false, + withdrawAddress: '', lastUsedNonce: undefined, txHash: null, error: null, @@ -108,6 +122,8 @@ export const withdrawMachine = setup({ withdrawAmountCryptoBaseUnit: '0', supplyPositionCryptoBaseUnit: '0', isFullWithdrawal: false, + withdrawToWallet: false, + withdrawAddress: '', stepConfirmed: false, txHash: null, lastUsedNonce: undefined, diff --git a/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx b/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx index d1d16a66570..557a0943ffe 100644 --- a/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx +++ b/src/pages/ChainflipLending/components/ChainflipLendingHeader.tsx @@ -1,6 +1,6 @@ -import { Button, Card, CardBody, Container, Flex, Heading, Skeleton, Stack } from '@chakra-ui/react' +import { Card, CardBody, Container, Flex, Heading, Skeleton, Stack } from '@chakra-ui/react' import { ethAssetId } from '@shapeshiftoss/caip' -import { useCallback } from 'react' +import { memo, useCallback, useMemo } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' @@ -10,31 +10,80 @@ import { Display } from '@/components/Display' import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' import { PageBackButton, PageHeader } from '@/components/Layout/Header/PageHeader' import { Text } from '@/components/Text' -import { WalletActions } from '@/context/WalletProvider/actions' -import { useWallet } from '@/hooks/useWallet/useWallet' +import { bnOrZero } from '@/lib/bignumber/bignumber' import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' -import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' const responsiveFlex = { base: 'auto', lg: 1 } const containerPaddingTop = { base: 0, md: 8 } -export const ChainflipLendingHeader = () => { +type SummaryCardProps = { + value: string + labelKey: string + tooltipKey: string + isLoading: boolean + labelColor?: string + 'data-testid'?: string +} + +const SummaryCard = ({ + value, + labelKey, + tooltipKey, + isLoading, + labelColor, + 'data-testid': testId, +}: SummaryCardProps) => { + const translate = useTranslate() + + return ( + + + + + + + + + + + ) +} + +export const ChainflipLendingHeader = memo(() => { const translate = useTranslate() const navigate = useNavigate() - const { dispatch: walletDispatch } = useWallet() const { accountId, setAccountId } = useChainflipLendingAccount() const handleBack = useCallback(() => { navigate('/explore') }, [navigate]) - const handleConnectWallet = useCallback( - () => walletDispatch({ type: WalletActions.SET_WALLET_MODAL, payload: true }), - [walletDispatch], + const { totalFiat: freeBalanceTotalFiat, isLoading: isFreeBalancesLoading } = + useChainflipFreeBalances() + + const { supplyPositions, isLoading: isPositionsLoading } = useChainflipSupplyPositions() + + const suppliedTotalFiat = useMemo( + () => supplyPositions.reduce((sum, p) => sum.plus(p.totalAmountFiat), bnOrZero(0)).toFixed(2), + [supplyPositions], ) - const { totalSuppliedFiat, availableLiquidityFiat, totalBorrowedFiat, isLoading } = - useChainflipLendingPools() + const { + totalCollateralFiat, + totalBorrowedFiat: userBorrowedFiat, + isLoading: isLoanLoading, + } = useChainflipLoanAccount() + + const isUserDataLoading = + accountId !== undefined && (isFreeBalancesLoading || isPositionsLoading || isLoanLoading) return ( <> @@ -49,74 +98,58 @@ export const ChainflipLendingHeader = () => { - + {translate('navBar.chainflipLending')} - + {accountId && ( + + )} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + - {!accountId && ( - - - - )} ) -} +}) diff --git a/src/pages/ChainflipLending/components/Dashboard.tsx b/src/pages/ChainflipLending/components/Dashboard.tsx new file mode 100644 index 00000000000..4a30ca5daf5 --- /dev/null +++ b/src/pages/ChainflipLending/components/Dashboard.tsx @@ -0,0 +1,66 @@ +import { Flex, Stack } from '@chakra-ui/react' +import { memo, useMemo } from 'react' + +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + BorrowedSection, + CollateralSection, + FreeBalanceSection, + SuppliedSection, +} from '@/pages/ChainflipLending/components/DashboardSections' +import { DashboardSidebar } from '@/pages/ChainflipLending/components/DashboardSidebar' +import { InitView } from '@/pages/ChainflipLending/components/InitView' +import { LoanHealth } from '@/pages/ChainflipLending/components/LoanHealth' +import { useChainflipAccount } from '@/pages/ChainflipLending/hooks/useChainflipAccount' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' + +export const Dashboard = memo(() => { + const { isFunded, isLpRegistered } = useChainflipAccount() + const { freeBalances } = useChainflipFreeBalances() + const { supplyPositions } = useChainflipSupplyPositions() + const { collateralWithFiat, loansWithFiat } = useChainflipLoanAccount() + + const hasFreeBalance = useMemo( + () => freeBalances.some(b => b.assetId && bnOrZero(b.balanceCryptoPrecision).gt(0)), + [freeBalances], + ) + + const hasAnyPosition = useMemo( + () => + hasFreeBalance || + supplyPositions.length > 0 || + collateralWithFiat.length > 0 || + loansWithFiat.length > 0, + [hasFreeBalance, supplyPositions, collateralWithFiat, loansWithFiat], + ) + + // Show init view for users with no positions (wallet is connected since Markets.tsx handles no-wallet) + const showInitView = useMemo(() => { + if (!isFunded && !isLpRegistered) return true + return !hasAnyPosition + }, [isFunded, isLpRegistered, hasAnyPosition]) + + if (showInitView) { + return + } + + return ( + + + + + + + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/DashboardSections.tsx b/src/pages/ChainflipLending/components/DashboardSections.tsx new file mode 100644 index 00000000000..8cfe95e3cbc --- /dev/null +++ b/src/pages/ChainflipLending/components/DashboardSections.tsx @@ -0,0 +1,730 @@ +import { Badge, Button, Card, CardBody, Flex, HStack, Skeleton, Stack } from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { RawText, Text } from '@/components/Text' +import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { + CHAINFLIP_LENDING_ASSET_BY_ASSET_ID, + CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET, +} from '@/lib/chainflip/constants' +import type { ChainflipFreeBalanceWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import type { ChainflipLendingPoolWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import type { + CollateralWithFiat, + LoanWithFiat, +} from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import type { ChainflipSupplyPositionWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' +import { selectAssetById } from '@/state/slices/assetsSlice/selectors' +import { useAppSelector } from '@/state/store' + +const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] + +// Shared grid for consistent alignment across dashboard sections +const dashboardRowGrid = '1fr 100px 120px' + +type AssetRowProps = { + assetId: AssetId + children: React.ReactNode + badge?: React.ReactNode + onClick?: () => void +} + +const AssetRow = ({ assetId, children, badge, onClick }: AssetRowProps) => { + const asset = useAppSelector(state => selectAssetById(state, assetId)) + if (!asset) return null + + return ( + + ) +} + +type SectionHeaderProps = { + titleKey: string + tooltipKey: string + totalFiat: string + isLoading: boolean + primaryAction?: { labelKey: string; handleClick: () => void; testId?: string } + secondaryAction?: { labelKey: string; handleClick: () => void; prefix?: string } +} + +const SectionHeader = ({ + titleKey, + tooltipKey, + totalFiat, + isLoading, + primaryAction, + secondaryAction, +}: SectionHeaderProps) => { + const translate = useTranslate() + + return ( + + + + + + + + + + + {primaryAction && ( + + )} + {secondaryAction && ( + + )} + + + ) +} + +type EmptyStateProps = { + titleKey: string + descriptionKey: string + actionLabelKey: string + onAction: () => void + actionTestId?: string +} + +const EmptyState = ({ + titleKey, + descriptionKey, + actionLabelKey, + onAction, + actionTestId, +}: EmptyStateProps) => { + const translate = useTranslate() + + return ( + + + + + + ) +} + +// Free Balance Section +const FreeBalanceRow = ({ balance }: { balance: ChainflipFreeBalanceWithFiat }) => { + const assetId = balance.assetId + const asset = useAppSelector(state => (assetId ? selectAssetById(state, assetId) : undefined)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + if (!assetId) return + navigate(`/chainflip-lending/pool/${assetId}`) + }, [navigate, assetId]) + + if (!assetId || bnOrZero(balance.balanceCryptoPrecision).isZero()) return null + + return ( + + + + + + + + ) +} + +export const FreeBalanceSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { freeBalances, isLoading, totalFiat } = useChainflipFreeBalances() + + const nonZeroBalances = useMemo( + () => freeBalances.filter(b => b.assetId && bnOrZero(b.balanceCryptoPrecision).gt(0)), + [freeBalances], + ) + + const handleDeposit = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'deposit', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleWithdraw = useCallback(() => { + const firstWithBalance = nonZeroBalances[0]?.assetId ?? LENDING_ASSET_IDS[0] + if (firstWithBalance) { + chainflipLendingModal.open({ mode: 'withdrawFromChainflip', assetId: firstWithBalance }) + } + }, [chainflipLendingModal, nonZeroBalances]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.withdraw', + handleClick: handleWithdraw, + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : nonZeroBalances.length > 0 ? ( + + + {translate('chainflipLending.dashboard.asset')} + + {translate('common.balance')} + + }> + {nonZeroBalances.map(balance => + balance.assetId ? ( + + ) : null, + )} + + + ) : null} + + + + ) +}) + +// Supplied Section +const SuppliedRow = ({ + position, + apy, +}: { + position: ChainflipSupplyPositionWithFiat + apy: string +}) => { + const asset = useAppSelector(state => selectAssetById(state, position.assetId)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + navigate(`/chainflip-lending/pool/${position.assetId}`) + }, [navigate, position.assetId]) + + return ( + + + + + + + + ) +} + +export const SuppliedSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { supplyPositions, isLoading } = useChainflipSupplyPositions() + const { pools } = useChainflipLendingPools() + + const totalSuppliedFiat = useMemo( + () => supplyPositions.reduce((sum, p) => sum.plus(p.totalAmountFiat), bnOrZero(0)).toFixed(2), + [supplyPositions], + ) + + const poolsByAssetId = useMemo( + () => + pools.reduce>>((acc, pool) => { + if (pool.assetId) acc[pool.assetId] = pool + return acc + }, {}), + [pools], + ) + + const handleSupply = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'supply', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleWithdraw = useCallback(() => { + const firstPosition = supplyPositions[0] + if (firstPosition) { + chainflipLendingModal.open({ mode: 'withdrawSupply', assetId: firstPosition.assetId }) + } + }, [chainflipLendingModal, supplyPositions]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.deposit', + handleClick: handleSupply, + testId: 'chainflip-lending-supply-action', + } + : undefined + } + secondaryAction={ + supplyPositions.length > 0 + ? { + labelKey: 'chainflipLending.dashboard.withdraw', + handleClick: handleWithdraw, + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : supplyPositions.length > 0 ? ( + + + {translate('chainflipLending.dashboard.asset')} + + + {translate('chainflipLending.dashboard.supplied')} + + + }> + {supplyPositions.map(position => ( + + ))} + + + ) : ( + + )} + + + + ) +}) + +// Collateral Section +const CollateralRow = ({ + collateral, + isTopupAsset, +}: { + collateral: CollateralWithFiat + isTopupAsset: boolean +}) => { + const translate = useTranslate() + const asset = useAppSelector(state => selectAssetById(state, collateral.assetId)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + navigate(`/chainflip-lending/pool/${collateral.assetId}`) + }, [navigate, collateral.assetId]) + + return ( + + {translate('chainflipLending.dashboard.topupAsset')} + + ) + } + onClick={handleRowClick} + > + + + + + + + ) +} + +export const CollateralSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { collateralWithFiat, totalCollateralFiat, loanAccount, isLoading } = + useChainflipLoanAccount() + + const topupAssetId = useMemo(() => { + if (!loanAccount?.collateral_topup_asset) return undefined + return CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET[loanAccount.collateral_topup_asset.asset] + }, [loanAccount?.collateral_topup_asset]) + + const handleAddCollateral = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleRemoveCollateral = useCallback(() => { + const firstCollateral = collateralWithFiat[0] + if (firstCollateral) { + chainflipLendingModal.open({ mode: 'removeCollateral', assetId: firstCollateral.assetId }) + } + }, [chainflipLendingModal, collateralWithFiat]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.deposit', + handleClick: handleAddCollateral, + testId: 'chainflip-lending-collateral-action', + } + : undefined + } + secondaryAction={ + collateralWithFiat.length > 0 + ? { + labelKey: 'chainflipLending.dashboard.withdraw', + handleClick: handleRemoveCollateral, + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : collateralWithFiat.length > 0 ? ( + + + {translate('chainflipLending.dashboard.asset')} + + {translate('common.amount')} + + }> + {collateralWithFiat.map(collateral => ( + + ))} + + + ) : ( + + )} + + + + ) +}) + +// Borrowed Section +const BorrowedRow = ({ loan, borrowRate }: { loan: LoanWithFiat; borrowRate: string }) => { + const asset = useAppSelector(state => selectAssetById(state, loan.assetId)) + const navigate = useNavigate() + + const handleRowClick = useCallback(() => { + navigate(`/chainflip-lending/pool/${loan.assetId}`) + }, [navigate, loan.assetId]) + + return ( + + + + + + + + ) +} + +export const BorrowedSection = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { loansWithFiat, totalBorrowedFiat, isLoading } = useChainflipLoanAccount() + const { pools } = useChainflipLendingPools() + + const poolsByAssetId = useMemo( + () => + pools.reduce>>((acc, pool) => { + if (pool.assetId) acc[pool.assetId] = pool + return acc + }, {}), + [pools], + ) + + const handleBorrow = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'borrow', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleRepay = useCallback(() => { + const firstLoan = loansWithFiat[0] + if (firstLoan) { + chainflipLendingModal.open({ + mode: 'repay', + assetId: firstLoan.assetId, + loanId: firstLoan.loanId, + }) + } + }, [chainflipLendingModal, loansWithFiat]) + + const handleAddCollateral = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleVoluntaryLiquidation = useCallback(() => { + const firstLoan = loansWithFiat[0] + if (firstLoan) { + chainflipLendingModal.open({ + mode: 'voluntaryLiquidation', + assetId: firstLoan.assetId, + liquidationAction: 'initiate', + }) + } + }, [chainflipLendingModal, loansWithFiat]) + + return ( + + + + 0 + ? { + labelKey: 'chainflipLending.dashboard.borrow', + handleClick: handleBorrow, + testId: 'chainflip-lending-borrow-action', + } + : undefined + } + secondaryAction={ + loansWithFiat.length > 0 + ? { + labelKey: 'chainflipLending.dashboard.repay', + handleClick: handleRepay, + prefix: '⟲', + } + : undefined + } + /> + {isLoading ? ( + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + + ) : loansWithFiat.length > 0 ? ( + <> + + + {translate('chainflipLending.dashboard.asset')} + + {translate('common.amount')} + + } + > + {loansWithFiat.map(loan => ( + + ))} + + + + + ) : ( + + )} + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/DashboardSidebar.tsx b/src/pages/ChainflipLending/components/DashboardSidebar.tsx new file mode 100644 index 00000000000..eb82aa1d439 --- /dev/null +++ b/src/pages/ChainflipLending/components/DashboardSidebar.tsx @@ -0,0 +1,404 @@ +import { + Box, + Button, + Card, + CardBody, + Center, + CircularProgress, + CircularProgressLabel, + Flex, + Stack, +} from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' + +import borrowGlow from '@/assets/chainflip-lending/borrow-glow.svg' +import borrowRing1 from '@/assets/chainflip-lending/borrow-ring-1.svg' +import borrowRingInner from '@/assets/chainflip-lending/borrow-ring-inner.svg' +import earnGlow from '@/assets/chainflip-lending/earn-glow.svg' +import earnRingInner from '@/assets/chainflip-lending/earn-ring-inner.svg' +import earnRingMiddle from '@/assets/chainflip-lending/earn-ring-middle.svg' +import earnRingOuter from '@/assets/chainflip-lending/earn-ring-outer.svg' +import refreshIcon from '@/assets/chainflip-lending/refresh-icon.svg' +import sparklesIcon from '@/assets/chainflip-lending/sparkles-icon.svg' +import { Amount } from '@/components/Amount/Amount' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { RawText, Text } from '@/components/Text' +import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' +import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' + +const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] + +const sidebarPosition = { base: 'relative' as const, lg: 'sticky' as const } +const sidebarTop = { base: 0, lg: 4 } + +export const BorrowingPowerCard = memo(() => { + const translate = useTranslate() + const { totalCollateralFiat, totalBorrowedFiat } = useChainflipLoanAccount() + const { thresholds } = useChainflipLtvThresholds() + + const hasCollateral = useMemo(() => bnOrZero(totalCollateralFiat).gt(0), [totalCollateralFiat]) + + const targetLtv = thresholds?.target ?? 0.5 + + const maxBorrow = useMemo( + () => bnOrZero(totalCollateralFiat).times(targetLtv).toFixed(2), + [totalCollateralFiat, targetLtv], + ) + + const available = useMemo( + () => bnOrZero(maxBorrow).minus(totalBorrowedFiat).toFixed(2), + [maxBorrow, totalBorrowedFiat], + ) + + const percentUsed = useMemo(() => { + if (bnOrZero(maxBorrow).isZero()) return 0 + return bnOrZero(totalBorrowedFiat).div(maxBorrow).times(100).toNumber() + }, [totalBorrowedFiat, maxBorrow]) + + const gaugeColor = useMemo(() => (percentUsed > 80 ? 'red.500' : 'blue.500'), [percentUsed]) + + if (!hasCollateral) return null + + return ( + + + + + + + + + {Math.round(percentUsed)}% + + + + + + + + + + ) +}) + +const NextStepsArt = memo(({ colorScheme }: { colorScheme: 'green' | 'purple' }) => { + const isGreen = colorScheme === 'green' + + return ( +
+ {isGreen ? ( + <> + + + + +
+ +
+ + ) : ( + <> + + + +
+ +
+ + )} +
+ ) +}) + +export const NextStepsCard = memo(() => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + const { freeBalances } = useChainflipFreeBalances() + const { supplyPositions } = useChainflipSupplyPositions() + const { collateralWithFiat, loansWithFiat } = useChainflipLoanAccount() + + const hasFreeBalance = useMemo( + () => freeBalances.some(b => b.assetId && bnOrZero(b.balanceCryptoPrecision).gt(0)), + [freeBalances], + ) + + const hasSupply = supplyPositions.length > 0 + const hasCollateral = collateralWithFiat.length > 0 + const hasLoans = loansWithFiat.length > 0 + + const handleSupply = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'supply', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleAddCollateral = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'addCollateral', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleBorrow = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'borrow', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + // Hide when user has completed all steps + if (hasSupply && hasCollateral && hasLoans) return null + + const getContent = () => { + if (hasFreeBalance && !hasSupply && !hasCollateral) { + return { + colorScheme: 'green' as const, + descriptionKey: 'chainflipLending.dashboard.nextStepsSupplyOrCollateral', + actions: ( + + + + + ), + } + } + + if (hasSupply && !hasCollateral) { + return { + colorScheme: 'green' as const, + descriptionKey: 'chainflipLending.dashboard.nextStepsCollateral', + actions: ( + + ), + } + } + + if (hasCollateral && !hasLoans) { + return { + colorScheme: 'purple' as const, + descriptionKey: 'chainflipLending.dashboard.nextStepsBorrow', + actions: ( + + ), + } + } + + return null + } + + const content = getContent() + if (!content) return null + + return ( + + + + + + + {content.actions} + + + + ) +}) + +const VoluntaryLiquidationActiveCard = memo(() => { + const translate = useTranslate() + const { loanAccount, totalBorrowedFiat, totalCollateralFiat } = useChainflipLoanAccount() + const chainflipLendingModal = useModal('chainflipLending') + + const isVoluntaryLiquidationActive = useMemo(() => { + if (!loanAccount?.liquidation_status) return false + const status = loanAccount.liquidation_status as Record + return status.liquidation_type === 'Voluntary' + }, [loanAccount?.liquidation_status]) + + const handleStopLiquidation = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) + chainflipLendingModal.open({ mode: 'voluntaryLiquidation', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + if (!isVoluntaryLiquidationActive) return null + + return ( + + + + + + + {translate('chainflipLending.dashboard.voluntaryLiquidationActive')} + + + + + + {translate('chainflipLending.dashboard.volLiqMethod')} + + + {translate('chainflipLending.dashboard.volLiqMethodValue')} + + + + + {translate('chainflipLending.dashboard.volLiqRemainingDebt')} + + + + + + {translate('chainflipLending.dashboard.volLiqCollateralSold')} + + + + + + {translate('chainflipLending.dashboard.volLiqDescription')} + + + + + + ) +}) + +export const DashboardSidebar = memo(() => { + return ( + + + + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/InitView.tsx b/src/pages/ChainflipLending/components/InitView.tsx new file mode 100644 index 00000000000..a6f1ef99ec7 --- /dev/null +++ b/src/pages/ChainflipLending/components/InitView.tsx @@ -0,0 +1,619 @@ +import { + Badge, + Box, + Button, + Card, + CardBody, + Center, + CircularProgress, + Flex, + HStack, + SimpleGrid, + Skeleton, + Stack, +} from '@chakra-ui/react' +import type { AssetId } from '@shapeshiftoss/caip' +import { btcAssetId, ethAssetId, solAssetId, usdcAssetId, usdtAssetId } from '@shapeshiftoss/caip' +import { memo, useCallback, useMemo } from 'react' +import { useTranslate } from 'react-polyglot' +import { useNavigate } from 'react-router-dom' + +import borrowGlow from '@/assets/chainflip-lending/borrow-glow.svg' +import borrowRing1 from '@/assets/chainflip-lending/borrow-ring-1.svg' +import borrowRing2 from '@/assets/chainflip-lending/borrow-ring-2.svg' +import borrowRing3 from '@/assets/chainflip-lending/borrow-ring-3.svg' +import borrowRingInner from '@/assets/chainflip-lending/borrow-ring-inner.svg' +import earnGlow from '@/assets/chainflip-lending/earn-glow.svg' +import earnRingInner from '@/assets/chainflip-lending/earn-ring-inner.svg' +import earnRingMiddle from '@/assets/chainflip-lending/earn-ring-middle.svg' +import earnRingOuter from '@/assets/chainflip-lending/earn-ring-outer.svg' +import orbitalBtc from '@/assets/chainflip-lending/orbital-btc.svg' +import orbitalEth from '@/assets/chainflip-lending/orbital-eth.svg' +import orbitalSol from '@/assets/chainflip-lending/orbital-sol.svg' +import orbitalTether from '@/assets/chainflip-lending/orbital-tether.svg' +import orbitalUsdc from '@/assets/chainflip-lending/orbital-usdc.svg' +import refreshIcon from '@/assets/chainflip-lending/refresh-icon.svg' +import sparklesIcon from '@/assets/chainflip-lending/sparkles-icon.svg' +import { Amount } from '@/components/Amount/Amount' +import { AssetIcon } from '@/components/AssetIcon' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { AssetCell } from '@/components/StakingVaults/Cells' +import { RawText, Text } from '@/components/Text' +import { useModal } from '@/hooks/useModal/useModal' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' +import { permillToDecimal } from '@/lib/chainflip/utils' +import type { ChainflipLendingPoolWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { selectAssetById } from '@/state/slices/assetsSlice/selectors' +import { useAppSelector } from '@/state/store' + +const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] + +const marketRowGrid = { + base: 'minmax(150px, 1fr) repeat(1, minmax(40px, max-content))', + md: '200px repeat(5, 1fr)', +} + +const mobileDisplay = { base: 'none', md: 'flex' } + +type MarketRowProps = { + pool: ChainflipLendingPoolWithFiat + onViewMarket: (assetId: AssetId) => void +} + +const MarketRow = memo(({ pool, onViewMarket }: MarketRowProps) => { + const asset = useAppSelector(state => + pool.assetId ? selectAssetById(state, pool.assetId) : undefined, + ) + const handleClick = useCallback(() => { + if (pool.assetId) onViewMarket(pool.assetId) + }, [pool.assetId, onViewMarket]) + + const utilisationPercent = useMemo( + () => permillToDecimal(pool.pool.utilisation_rate), + [pool.pool.utilisation_rate], + ) + + const utilisationNumber = useMemo( + () => bnOrZero(utilisationPercent).times(100).toNumber(), + [utilisationPercent], + ) + + const utilisationColor = useMemo(() => { + if (utilisationNumber >= 90) return 'red.400' + if (utilisationNumber >= 70) return 'orange.400' + return 'blue.400' + }, [utilisationNumber]) + + if (!pool.assetId) return null + + return ( + + ) +}) + +const AssetConstellation = memo(() => { + // Exact positions from Figma design context (2918:4222) + // Container is positioned absolutely from left=590px in the original ~1200px wide card + // We scale proportionally to fit our responsive layout + return ( + + {/* Orbital ring SVGs from Figma - ETH blue arc */} + + {/* BTC orange arc */} + + {/* USDC arc */} + + {/* Tether arc */} + + {/* SOL arc */} + + {/* Asset icons - positioned per Figma */} + {/* USDC - top left */} + + + + {/* ETH - center, largest */} + + + + {/* USDT - top right */} + + + + {/* BTC - bottom center-left */} + + + + {/* SOL - bottom right */} + + + + + ) +}) + +const MarketsTable = memo(() => { + const translate = useTranslate() + const navigate = useNavigate() + const { pools, isLoading } = useChainflipLendingPools() + + const handleViewMarket = useCallback( + (assetId: AssetId) => { + navigate(`/chainflip-lending/pool/${assetId}`) + }, + [navigate], + ) + + const sortedPools = useMemo( + () => [...pools].sort((a, b) => bnOrZero(b.supplyApy).minus(a.supplyApy).toNumber()), + [pools], + ) + + const marketRows = useMemo(() => { + if (isLoading) { + return Array.from({ length: 5 }).map((_, i) => ) + } + + return sortedPools.map(pool => + pool.assetId ? ( + + ) : null, + ) + }, [isLoading, sortedPools, handleViewMarket]) + + return ( + + + + + + {translate('chainflipLending.dashboard.lendingMarkets')} + + + + + {translate('chainflipLending.dashboard.lendingMarketsDescription')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {marketRows} + + ) +}) + +type InfoCardProps = { + titleKey: string + descriptionKey: string + accentColor: string + onClick?: () => void + 'data-testid'?: string +} + +const InfoCard = memo( + ({ titleKey, descriptionKey, accentColor, onClick, 'data-testid': testId }: InfoCardProps) => { + const isGreen = accentColor === 'green' + + return ( + + + + + + + + {/* Art from Figma SVGs */} + + {isGreen ? ( + <> + {/* Earn Yield art - concentric green arcs */} + + + + +
+ +
+ + ) : ( + <> + {/* Borrow art - purple rings with radiating lines */} + + + + + +
+ +
+ + )} +
+
+
+
+ ) + }, +) + +type InitViewProps = { + onCtaClick?: () => void + ctaLabel?: string + 'data-testid'?: string +} + +export const InitView = memo(({ onCtaClick, ctaLabel, 'data-testid': testId }: InitViewProps) => { + const translate = useTranslate() + const chainflipLendingModal = useModal('chainflipLending') + + const handleDeposit = useCallback(() => { + if (onCtaClick) { + onCtaClick() + return + } + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'deposit', assetId: firstAssetId }) + }, [chainflipLendingModal, onCtaClick]) + + const handleSupply = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'supply', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + const handleBorrow = useCallback(() => { + const firstAssetId = LENDING_ASSET_IDS[0] + if (firstAssetId) chainflipLendingModal.open({ mode: 'borrow', assetId: firstAssetId }) + }, [chainflipLendingModal]) + + return ( + + {/* Hero Card */} + + + + + + + {translate('chainflipLending.dashboard.getStarted')} + + + {translate('chainflipLending.dashboard.depositFirstAsset')} + + + {translate('chainflipLending.dashboard.depositFirstAssetDescription')} + + + + + + {translate('chainflipLending.dashboard.requiresFlip')} + + + + + + + + + {/* Lending Markets Table */} + + + {/* Info Cards */} + + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/LoanHealth.tsx b/src/pages/ChainflipLending/components/LoanHealth.tsx new file mode 100644 index 00000000000..a6a0f31a62b --- /dev/null +++ b/src/pages/ChainflipLending/components/LoanHealth.tsx @@ -0,0 +1,113 @@ +import { Card, CardBody, Flex, Icon, Stack, Text as CText } from '@chakra-ui/react' +import { memo, useMemo } from 'react' +import { TbHeartRateMonitor } from 'react-icons/tb' +import { useTranslate } from 'react-polyglot' + +import { Amount } from '@/components/Amount/Amount' +import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' +import { Text } from '@/components/Text' +import { bnOrZero } from '@/lib/bignumber/bignumber' +import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' +import { useChainflipLtvThresholds } from '@/pages/ChainflipLending/hooks/useChainflipLtvThresholds' +import { LtvGauge } from '@/pages/ChainflipLending/Pool/components/Borrow/LtvGauge' + +const DEFAULT_HARD_LIQUIDATION_LTV = 0.93 + +const getLtvColor = (ltv: number, riskyThreshold: number, hardLiqThreshold: number): string => { + if (ltv >= hardLiqThreshold) return 'red.500' + if (ltv >= riskyThreshold) return 'yellow.500' + return 'green.500' +} + +export const LoanHealth = memo(() => { + const translate = useTranslate() + const { totalCollateralFiat, totalBorrowedFiat } = useChainflipLoanAccount() + const { thresholds } = useChainflipLtvThresholds() + + const hasLoans = useMemo(() => bnOrZero(totalBorrowedFiat).gt(0), [totalBorrowedFiat]) + + const currentLtv = useMemo(() => { + if (bnOrZero(totalCollateralFiat).isZero()) return 0 + return bnOrZero(totalBorrowedFiat).div(totalCollateralFiat).toNumber() + }, [totalBorrowedFiat, totalCollateralFiat]) + + const hardLiquidationLtv = thresholds?.hardLiquidation ?? DEFAULT_HARD_LIQUIDATION_LTV + const riskyLtv = thresholds?.target ?? 0.8 + + const liquidationDistance = useMemo(() => { + if (hardLiquidationLtv === 0) return '0' + const liquidationCollateral = bnOrZero(totalBorrowedFiat).div(hardLiquidationLtv) + return bnOrZero(totalCollateralFiat).minus(liquidationCollateral).toFixed(2) + }, [totalCollateralFiat, totalBorrowedFiat, hardLiquidationLtv]) + + const liquidationDistancePercent = useMemo(() => { + if (bnOrZero(totalCollateralFiat).isZero()) return 0 + return bnOrZero(liquidationDistance).div(totalCollateralFiat).times(100).toNumber() + }, [liquidationDistance, totalCollateralFiat]) + + const liquidationDistanceColor = useMemo(() => { + if (liquidationDistancePercent > 10) return 'green.500' + if (liquidationDistancePercent >= 5) return 'yellow.500' + return 'red.500' + }, [liquidationDistancePercent]) + + const ltvColor = useMemo( + () => getLtvColor(currentLtv, riskyLtv, hardLiquidationLtv), + [currentLtv, riskyLtv, hardLiquidationLtv], + ) + + const ltvDisplayPercent = useMemo( + () => (Math.min(Math.max(currentLtv, 0), 1) * 100).toFixed(1), + [currentLtv], + ) + + if (!hasLoans) return null + + return ( + + + + {/* Header row: left = icon + label + current LTV, right = liquidation distance */} + + + + + + + + {translate('chainflipLending.dashboard.currentLtv', { + ltv: `${ltvDisplayPercent}%`, + })} + + + + + + + + + {/* Multi-segment LTV gauge bar */} + + + + + ) +}) diff --git a/src/pages/ChainflipLending/components/Markets.tsx b/src/pages/ChainflipLending/components/Markets.tsx index 7e559556420..03d6c11c958 100644 --- a/src/pages/ChainflipLending/components/Markets.tsx +++ b/src/pages/ChainflipLending/components/Markets.tsx @@ -1,5 +1,6 @@ import type { GridProps } from '@chakra-ui/react' import { + Box, Button, CircularProgress, Flex, @@ -7,9 +8,14 @@ import { SimpleGrid, Skeleton, Stack, + Tab, + TabList, + TabPanel, + TabPanels, + Tabs, } from '@chakra-ui/react' import type { AssetId } from '@shapeshiftoss/caip' -import { useCallback, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useTranslate } from 'react-polyglot' import { useNavigate } from 'react-router-dom' @@ -18,22 +24,24 @@ import { HelperTooltip } from '@/components/HelperTooltip/HelperTooltip' import { Main } from '@/components/Layout/Main' import { SEO } from '@/components/Layout/Seo' import { AssetCell } from '@/components/StakingVaults/Cells' -import { Text } from '@/components/Text' +import { RawText, Text } from '@/components/Text' import { bnOrZero } from '@/lib/bignumber/bignumber' import { permillToDecimal } from '@/lib/chainflip/utils' +import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' import { ChainflipLendingHeader } from '@/pages/ChainflipLending/components/ChainflipLendingHeader' -import { MyBalancesList } from '@/pages/ChainflipLending/components/MyBalances' +import { Dashboard } from '@/pages/ChainflipLending/components/Dashboard' +import { InitView } from '@/pages/ChainflipLending/components/InitView' import type { ChainflipLendingPoolWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' import { useChainflipLendingPools } from '@/pages/ChainflipLending/hooks/useChainflipLendingPools' +import { selectAssetById } from '@/state/slices/assetsSlice/selectors' +import { useAppSelector } from '@/state/store' const marketRowGrid: GridProps['gridTemplateColumns'] = { base: 'minmax(150px, 1fr) repeat(1, minmax(40px, max-content))', - md: '200px repeat(4, 1fr)', + md: '200px repeat(5, 1fr)', } const mobileDisplay = { base: 'none', md: 'flex' } -const mobilePadding = { base: 4, lg: 4, xl: 0 } -const listMargin = { base: 0, lg: 0, xl: -4 } type MarketRowProps = { pool: ChainflipLendingPoolWithFiat @@ -41,6 +49,9 @@ type MarketRowProps = { } const MarketRow = ({ pool, onViewMarket }: MarketRowProps) => { + const asset = useAppSelector(state => + pool.assetId ? selectAssetById(state, pool.assetId) : undefined, + ) const handleClick = useCallback(() => { if (pool.assetId) onViewMarket(pool.assetId) }, [pool.assetId, onViewMarket]) @@ -76,20 +87,24 @@ const MarketRow = ({ pool, onViewMarket }: MarketRowProps) => { height='auto' color='text.base' onClick={handleClick} + data-testid={`chainflip-lending-market-row-${asset?.symbol?.toLowerCase() ?? 'unknown'}`} > - + + + + - - + + - + - + { ) } -export const Markets = () => { +const MarketsTable = () => { const translate = useTranslate() const navigate = useNavigate() const { pools, isLoading } = useChainflipLendingPools() @@ -115,8 +130,6 @@ export const Markets = () => { [navigate], ) - const headerComponent = useMemo(() => , []) - const sortedPools = useMemo( () => [...pools].sort((a, b) => bnOrZero(b.supplyApy).minus(a.supplyApy).toNumber()), [pools], @@ -135,58 +148,121 @@ export const Markets = () => { }, [isLoading, sortedPools, handleViewMarket]) return ( -
- - - - - + + + + + + {translate('chainflipLending.dashboard.lendingMarkets')} + + + + + {translate('chainflipLending.dashboard.lendingMarketsDescription')} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {marketRows} + + ) +} - - - - - - - - - - - - - - - - - - - - - - - - - {marketRows} - - +export const Markets = () => { + const translate = useTranslate() + const { accountId } = useChainflipLendingAccount() + const [tabIndex, setTabIndex] = useState(0) + + const headerComponent = useMemo(() => , []) + + return ( +
+ + + {accountId ? ( + + + + {translate('chainflipLending.myDashboard')} + + + {translate('chainflipLending.markets')} + + + + + + + + + + + + ) : ( + + )}
) diff --git a/src/pages/ChainflipLending/components/MyBalances.tsx b/src/pages/ChainflipLending/components/MyBalances.tsx deleted file mode 100644 index c5238a2eaf8..00000000000 --- a/src/pages/ChainflipLending/components/MyBalances.tsx +++ /dev/null @@ -1,262 +0,0 @@ -import type { GridProps } from '@chakra-ui/react' -import { Button, Flex, SimpleGrid, Skeleton, Stack } from '@chakra-ui/react' -import type { AssetId } from '@shapeshiftoss/caip' -import { fromAssetId } from '@shapeshiftoss/caip' -import { useCallback, useMemo } from 'react' -import { useNavigate } from 'react-router-dom' - -import { Amount } from '@/components/Amount/Amount' -import { AssetCell } from '@/components/StakingVaults/Cells' -import { Text } from '@/components/Text' -import { CHAINFLIP_LENDING_ASSET_BY_ASSET_ID } from '@/lib/chainflip/constants' -import { useChainflipLendingAccount } from '@/pages/ChainflipLending/ChainflipLendingAccountContext' -import type { ChainflipFreeBalanceWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' -import { useChainflipFreeBalances } from '@/pages/ChainflipLending/hooks/useChainflipFreeBalances' -import type { - CollateralWithFiat, - LoanWithFiat, -} from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' -import { useChainflipLoanAccount } from '@/pages/ChainflipLending/hooks/useChainflipLoanAccount' -import type { ChainflipSupplyPositionWithFiat } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' -import { useChainflipSupplyPositions } from '@/pages/ChainflipLending/hooks/useChainflipSupplyPositions' -import { selectAssetById } from '@/state/slices/assetsSlice/selectors' -import { selectPortfolioCryptoBalanceByFilter } from '@/state/slices/common-selectors' -import { selectAccountIdsByAccountNumberAndChainId } from '@/state/slices/portfolioSlice/selectors' -import { useAppSelector } from '@/state/store' - -const balanceRowGrid: GridProps['gridTemplateColumns'] = { - base: '1fr', - md: '200px 1fr 1fr 1fr 1fr 1fr', -} - -const mobileDisplay = { base: 'none', md: 'flex' } -const mobilePadding = { base: 4, lg: 4, xl: 0 } -const listMargin = { base: 0, lg: 0, xl: -4 } - -const LENDING_ASSET_IDS = Object.keys(CHAINFLIP_LENDING_ASSET_BY_ASSET_ID) as AssetId[] - -type BalanceRowProps = { - assetId: AssetId - accountNumber: number - freeBalance: ChainflipFreeBalanceWithFiat | undefined - supplyPosition: ChainflipSupplyPositionWithFiat | undefined - collateral: CollateralWithFiat | undefined - loan: LoanWithFiat | undefined - onDeposit: (assetId: AssetId) => void -} - -const BalanceRow = ({ - assetId, - accountNumber, - freeBalance, - supplyPosition, - collateral, - loan, - onDeposit, -}: BalanceRowProps) => { - const asset = useAppSelector(state => selectAssetById(state, assetId)) - const accountIdsByAccountNumberAndChainId = useAppSelector( - selectAccountIdsByAccountNumberAndChainId, - ) - - const chainId = useMemo(() => fromAssetId(assetId).chainId, [assetId]) - - const poolChainAccountId = useMemo(() => { - const byChainId = accountIdsByAccountNumberAndChainId[accountNumber] - return byChainId?.[chainId]?.[0] - }, [accountIdsByAccountNumberAndChainId, accountNumber, chainId]) - - const balanceFilter = useMemo( - () => ({ assetId, accountId: poolChainAccountId ?? '' }), - [assetId, poolChainAccountId], - ) - - const walletBalance = useAppSelector(state => - selectPortfolioCryptoBalanceByFilter(state, balanceFilter), - ) - - const walletBalancePrecision = useMemo( - () => (poolChainAccountId ? walletBalance.toPrecision() : '0'), - [walletBalance, poolChainAccountId], - ) - - const scBalancePrecision = useMemo( - () => freeBalance?.balanceCryptoPrecision ?? '0', - [freeBalance?.balanceCryptoPrecision], - ) - - const suppliedPrecision = useMemo( - () => supplyPosition?.totalAmountCryptoPrecision ?? '0', - [supplyPosition?.totalAmountCryptoPrecision], - ) - - const collateralPrecision = useMemo( - () => collateral?.amountCryptoPrecision ?? '0', - [collateral?.amountCryptoPrecision], - ) - - const borrowedPrecision = useMemo( - () => loan?.principalAmountCryptoPrecision ?? '0', - [loan?.principalAmountCryptoPrecision], - ) - - const handleClick = useCallback(() => { - onDeposit(assetId) - }, [assetId, onDeposit]) - - const symbol = useMemo(() => asset?.symbol ?? '', [asset?.symbol]) - - if (!asset) return null - - return ( - - ) -} - -export const MyBalancesList = () => { - const navigate = useNavigate() - const { accountId, accountNumber } = useChainflipLendingAccount() - const { freeBalances, isLoading } = useChainflipFreeBalances() - const { supplyPositions, isLoading: isPositionsLoading } = useChainflipSupplyPositions() - const { collateralWithFiat, loansWithFiat, isLoading: isLoanLoading } = useChainflipLoanAccount() - - const handleDeposit = useCallback( - (assetId: AssetId) => { - navigate(`/chainflip-lending/pool/${assetId}`) - }, - [navigate], - ) - - const freeBalancesByAssetId = useMemo( - () => - freeBalances.reduce>>( - (acc, balance) => { - if (balance.assetId) acc[balance.assetId] = balance - return acc - }, - {}, - ), - [freeBalances], - ) - - const supplyPositionsByAssetId = useMemo( - () => - supplyPositions.reduce>>( - (acc, position) => { - acc[position.assetId] = position - return acc - }, - {}, - ), - [supplyPositions], - ) - - const collateralByAssetId = useMemo( - () => - collateralWithFiat.reduce>>((acc, c) => { - acc[c.assetId] = c - return acc - }, {}), - [collateralWithFiat], - ) - - const loansByAssetId = useMemo( - () => - loansWithFiat.reduce>>((acc, l) => { - acc[l.assetId] = l - return acc - }, {}), - [loansWithFiat], - ) - - const balanceRows = useMemo(() => { - if (isLoading || isPositionsLoading || isLoanLoading) { - return Array.from({ length: 5 }).map((_, i) => ) - } - - return LENDING_ASSET_IDS.map(assetId => ( - - )) - }, [ - accountNumber, - isLoading, - isPositionsLoading, - isLoanLoading, - freeBalancesByAssetId, - supplyPositionsByAssetId, - collateralByAssetId, - loansByAssetId, - handleDeposit, - ]) - - if (!accountId) return null - - return ( - - - - - - - - - - - - - - - - - - - - {balanceRows} - - ) -} diff --git a/src/pages/ChainflipLending/hooks/useChainflipOraclePrices.ts b/src/pages/ChainflipLending/hooks/useChainflipOraclePrices.ts index e91969a216e..a3a4554991f 100644 --- a/src/pages/ChainflipLending/hooks/useChainflipOraclePrices.ts +++ b/src/pages/ChainflipLending/hooks/useChainflipOraclePrices.ts @@ -2,6 +2,7 @@ import type { AssetId } from '@shapeshiftoss/caip' import { useQuery } from '@tanstack/react-query' import BigNumber from 'bignumber.js' import { useMemo } from 'react' +import { shallowEqual } from 'react-redux' import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET } from '@/lib/chainflip/constants' @@ -43,8 +44,9 @@ export const useChainflipOraclePrices = () => { .filter((id): id is AssetId => Boolean(id)) }, [oraclePrices]) - const assetPrecisions = useAppSelector(state => - assetIds.map(id => selectAssetById(state, id)?.precision ?? 0), + const assetPrecisions = useAppSelector( + state => assetIds.map(id => selectAssetById(state, id)?.precision ?? 0), + shallowEqual, ) const oraclePriceByAssetId = useMemo(() => { diff --git a/src/pages/ChainflipLending/hooks/useChainflipSupplyPositions.ts b/src/pages/ChainflipLending/hooks/useChainflipSupplyPositions.ts index a9f61ebbb15..5138a43a4fc 100644 --- a/src/pages/ChainflipLending/hooks/useChainflipSupplyPositions.ts +++ b/src/pages/ChainflipLending/hooks/useChainflipSupplyPositions.ts @@ -1,5 +1,6 @@ import type { AssetId } from '@shapeshiftoss/caip' import { useMemo } from 'react' +import { shallowEqual } from 'react-redux' import { bnOrZero } from '@/lib/bignumber/bignumber' import { CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET } from '@/lib/chainflip/constants' @@ -40,13 +41,15 @@ export const useChainflipSupplyPositions = () => { [accountInfo?.lending_positions], ) - const positionAssetData = useAppSelector(state => - lendingPositions.map(position => { - const assetId = CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET[position.asset] - if (!assetId) return { assetId: undefined, precision: 0 } - const asset = selectAssetById(state, assetId) - return { assetId, precision: asset?.precision ?? 0 } - }), + const positionAssetData = useAppSelector( + state => + lendingPositions.map(position => { + const assetId = CHAINFLIP_LENDING_ASSET_IDS_BY_ASSET[position.asset] + if (!assetId) return { assetId: undefined, precision: 0 } + const asset = selectAssetById(state, assetId) + return { assetId, precision: asset?.precision ?? 0 } + }), + shallowEqual, ) const supplyPositions: ChainflipSupplyPositionWithFiat[] = useMemo(() => {