diff --git a/package-lock.json b/package-lock.json index c690f967..1ab11251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,8 @@ "dependencies": { "@morpho-org/blue-sdk": "^5.0.0", "@morpho-org/blue-sdk-viem": "^4.0.0", - "@tanstack/react-query": "^5.90.2" + "@tanstack/react-query": "^5.90.2", + "usehooks-ts": "^3.1.1" }, "devDependencies": { "@commitlint/cli": "^19.8.0", @@ -32263,6 +32264,21 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/usehooks-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz", + "integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/utf-8-validate": { "version": "5.0.10", "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", diff --git a/package.json b/package.json index d52da1aa..8e61876f 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,8 @@ "dependencies": { "@morpho-org/blue-sdk": "^5.0.0", "@morpho-org/blue-sdk-viem": "^4.0.0", - "@tanstack/react-query": "^5.90.2" + "@tanstack/react-query": "^5.90.2", + "usehooks-ts": "^3.1.1" }, "optionalDependencies": { "@rollup/rollup-darwin-arm64": "4.52.4" diff --git a/packages/babylon-core-ui/src/components/ActivityList/ActivityList.tsx b/packages/babylon-core-ui/src/components/ActivityList/ActivityList.tsx index cedb9483..19e6827c 100644 --- a/packages/babylon-core-ui/src/components/ActivityList/ActivityList.tsx +++ b/packages/babylon-core-ui/src/components/ActivityList/ActivityList.tsx @@ -17,13 +17,49 @@ import type { PropsWithChildren } from "react"; export interface ActivityListProps { onNewItem?: () => void; className?: string; + isEmpty?: boolean; + isConnected?: boolean; } export function ActivityList({ onNewItem, className, children, + isEmpty = false, + isConnected = false, }: PropsWithChildren) { + // Show empty state when connected but no activities + if (isEmpty && isConnected) { + return ( + +
+ Babylon mascot +
+

+ Supply Collateral BTC Trustlessly +

+

+ Enter the amount of BTC you want to deposit and select a provider to secure it. + Your deposit will appear here once confirmed. +

+
+ + + +
+
+ ); + } + return (
diff --git a/packages/babylon-core-ui/src/widgets/sections/ActivityCard/ActivityCard.tsx b/packages/babylon-core-ui/src/widgets/sections/ActivityCard/ActivityCard.tsx index b177f4d2..991f77d3 100644 --- a/packages/babylon-core-ui/src/widgets/sections/ActivityCard/ActivityCard.tsx +++ b/packages/babylon-core-ui/src/widgets/sections/ActivityCard/ActivityCard.tsx @@ -39,6 +39,7 @@ export interface ActivityCardData { label: string; items: ActivityListItemData[]; }[]; + warning?: React.ReactNode; primaryAction?: ActivityCardActionButton; secondaryActions?: ActivityCardActionButton[]; } @@ -72,6 +73,12 @@ export function ActivityCard({ data, className }: ActivityCardProps) { {data.secondaryActions && data.secondaryActions.length > 0 && ( )} + + {data.warning && ( +
+ {data.warning} +
+ )}
{data.primaryAction && ( diff --git a/routes/vault/src/assets/index.ts b/routes/vault/src/assets/index.ts index aea86a43..4a1dba68 100644 --- a/routes/vault/src/assets/index.ts +++ b/routes/vault/src/assets/index.ts @@ -1,5 +1,8 @@ // Asset exports for vault application +// Bitcoin icon as data URI - Orange bitcoin logo +export const bitcoinIcon = "data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%23FF7C2A' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23.638 14.904c-1.602 6.43-8.113 10.34-14.542 8.736C2.67 22.05-1.244 15.525.362 9.105 1.962 2.67 8.475-1.243 14.9.358c6.43 1.605 10.342 8.115 8.738 14.548v-.002zm-6.35-4.613c.24-1.59-.974-2.45-2.64-3.03l.54-2.153-1.315-.33-.525 2.107c-.345-.087-.705-.167-1.064-.25l.526-2.127-1.32-.33-.54 2.165c-.285-.067-.565-.132-.84-.2l-1.815-.45-.35 1.407s.975.225.955.236c.535.136.63.486.615.766l-1.477 5.92c-.075.166-.24.406-.614.314.015.02-.96-.24-.96-.24l-.66 1.51 1.71.426.93.242-.54 2.19 1.32.327.54-2.17c.36.1.705.19 1.05.273l-.51 2.154 1.32.33.545-2.19c2.24.427 3.93.257 4.64-1.774.57-1.637-.03-2.58-1.217-3.196.854-.193 1.5-.76 1.68-1.93h.01zm-3.01 4.22c-.404 1.64-3.157.75-4.05.53l.72-2.9c.896.23 3.757.67 3.33 2.37zm.41-4.24c-.37 1.49-2.662.735-3.405.55l.654-2.64c.744.18 3.137.524 2.75 2.084v.006z'/%3E%3C/svg%3E"; + // USDC icon as data URI - Simple circle design with USDC branding export const usdcIcon = 'data:image/svg+xml,%3Csvg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Ccircle cx="20" cy="20" r="20" fill="%232775CA"/%3E%3Cpath d="M24.0001 17.3999C24.0001 15.6399 22.8001 14.7999 20.4001 14.5999V12.3999H18.8001V14.5599C18.3601 14.5599 17.9201 14.5599 17.4801 14.5999V12.3999H15.8801V14.5999C15.5601 14.5999 15.2401 14.6399 14.9201 14.6399H13.2001V16.3599H14.4401C14.8801 16.3599 15.0801 16.5999 15.0801 16.9599V23.0399C15.0801 23.3999 14.8801 23.6399 14.4401 23.6399H13.2001V25.7599L14.8401 25.7999C15.1601 25.7999 15.4801 25.7999 15.8001 25.8399V28.0399H17.4001V25.8799C17.8401 25.8799 18.2801 25.9199 18.7201 25.9199V28.0799H20.3201V25.8799C23.2001 25.7199 24.8001 24.5199 24.8001 22.3599C24.8001 20.7999 24.0001 19.8799 22.5601 19.4799C23.5201 19.0799 24.0001 18.3599 24.0001 17.3999ZM18.7201 21.1999V23.5199C17.7201 23.4399 17.2001 23.3999 16.6801 23.3599V21.1999C17.2001 21.1599 17.7201 21.1199 18.7201 21.1999ZM20.3201 16.7199C21.2401 16.7999 21.7601 16.8399 22.3201 16.9199V19.0399C21.7601 18.9599 21.2401 18.9199 20.3201 18.8399V16.7199ZM20.3201 23.5599V21.1599C21.3201 21.2399 21.8401 21.2799 22.4001 21.3599C22.6001 22.3599 21.8401 23.1999 20.3201 23.5599Z" fill="white"/%3E%3C/svg%3E'; diff --git a/routes/vault/src/clients/vault-providers-api/index.ts b/routes/vault/src/clients/vault-providers-api/index.ts new file mode 100644 index 00000000..67ccc978 --- /dev/null +++ b/routes/vault/src/clients/vault-providers-api/index.ts @@ -0,0 +1,80 @@ +/** + * Vault Provider API Client + * + * This module handles fetching vault provider data. + * Currently returns mock data, but structured to support GraphQL integration. + */ + +export interface VaultProvider { + id: string; + name: string; + icon: string; + apy?: number; + tvl?: string; + description?: string; +} + +export interface VaultProvidersResponse { + providers: VaultProvider[]; +} + +/** + * Fetch vault providers from backend + * TODO: Implement GraphQL query when backend is ready + * + * @returns Promise + */ +export async function getVaultProviders(): Promise { + // TODO: Replace with actual GraphQL endpoint + // Example GraphQL query: + // query GetVaultProviders { + // vaultProviders { + // id + // name + // icon + // apy + // tvl + // description + // } + // } + + // Mock data - will be replaced by GraphQL query + const mockProviders: VaultProvider[] = [ + { + id: "ironclad", + name: "Ironclad BTC", + icon: "/icons/ironclad.svg", + apy: 8.5, + tvl: "1.2M", + description: "Secure Bitcoin custody with institutional-grade security" + }, + { + id: "atlas", + name: "Atlas Custody", + icon: "/icons/atlas.svg", + apy: 7.8, + tvl: "2.5M", + description: "Regulated custody solution for digital assets" + }, + { + id: "stonewall", + name: "Stonewall Capital", + icon: "/icons/stonewall.svg", + apy: 9.2, + tvl: "850K", + description: "High-yield Bitcoin vault provider" + }, + { + id: "redwood", + name: "Redwood BTC", + icon: "/icons/redwood.svg", + apy: 8.0, + tvl: "1.8M", + description: "Enterprise-grade Bitcoin vault infrastructure" + }, + ]; + + return { + providers: mockProviders, + }; +} \ No newline at end of file diff --git a/routes/vault/src/components/VaultDashboard/VaultActivityCard.tsx b/routes/vault/src/components/VaultDashboard/VaultActivityCard.tsx index 8153afdc..4f6fce5c 100644 --- a/routes/vault/src/components/VaultDashboard/VaultActivityCard.tsx +++ b/routes/vault/src/components/VaultDashboard/VaultActivityCard.tsx @@ -7,12 +7,14 @@ import { ActivityCard, StatusBadge, ProviderItem, + Warning, type ActivityCardData, type ActivityCardDetailItem, } from "@babylonlabs-io/core-ui"; import type { VaultActivity } from "../../mockData/vaultActivities"; import { getVaultState, getActionForState } from "../../utils/vaultState"; import { formatUSDCAmount } from "../../utils/peginTransformers"; +import { bitcoinIcon } from "../../assets"; interface VaultActivityCardProps { activity: VaultActivity; @@ -114,10 +116,17 @@ export function VaultActivityCard({ activity, onBorrow, onRepay }: VaultActivity // Transform to ActivityCardData format const cardData: ActivityCardData = { formattedAmount: `${activity.collateral.amount} ${activity.collateral.symbol}`, - icon: activity.collateral.icon, + icon: activity.collateral.icon || bitcoinIcon, iconAlt: activity.collateral.symbol, details, optionalDetails: optionalDetails.length > 0 ? optionalDetails : undefined, + // Add warning for pending peg-ins + warning: activity.isPending ? ( + + {activity.pendingMessage || + "Your peg-in is being processed. This can take up to ~5 hours while Bitcoin confirmations and provider acknowledgements complete."} + + ) : undefined, primaryAction: getActionForState(vaultState, activity, onBorrow, onRepay), }; diff --git a/routes/vault/src/components/VaultDashboard/VaultDashboard.tsx b/routes/vault/src/components/VaultDashboard/VaultDashboard.tsx index 3a427b02..38f6d9ad 100644 --- a/routes/vault/src/components/VaultDashboard/VaultDashboard.tsx +++ b/routes/vault/src/components/VaultDashboard/VaultDashboard.tsx @@ -1,15 +1,30 @@ import { ActivityList } from "@babylonlabs-io/core-ui"; +import { useCallback } from "react"; import { BorrowFlow } from "../BorrowFlow"; import { RepayFlow } from "../RepayFlow"; +import { + PeginModal, + PeginSignModal, + PeginSuccessModal, +} from "../modals"; import { useVaultPositions } from "../../hooks/useVaultPositions"; import { useBorrowFlow } from "../../hooks/useBorrowFlow"; import { useRepayFlow } from "../../hooks/useRepayFlow"; +import { usePeginFlow } from "../../hooks/usePeginFlow"; import { EmptyState } from "./EmptyState"; import { VaultActivityCard } from "./VaultActivityCard"; +import type { VaultActivity } from "../../mockData/vaultActivities"; export function VaultDashboard() { // Data fetching - const { activities, isWalletConnected, refetchActivities } = useVaultPositions(); + const { + activities, + isWalletConnected, + refetchActivities, + connectedAddress, + btcAddress, + addPendingPegin, + } = useVaultPositions(); // Borrow flow modal state const { @@ -27,12 +42,47 @@ export function VaultDashboard() { closeRepayFlow, } = useRepayFlow(); - // Handle "New Item" button click - open borrow for first activity - const handleNewBorrow = () => { - if (activities.length > 0) { - openBorrowFlow(activities[0]); + // Peg-in flow modal state + const { + isOpen: peginFlowOpen, + signModalOpen: peginSignModalOpen, + successModalOpen: peginSuccessModalOpen, + peginAmount, + selectedProviders, + btcBalanceSat, + openPeginFlow, + closePeginFlow, + handlePeginClick, + handlePeginSignSuccess: handlePeginSignSuccessBase, + handlePeginSuccessClose, + } = usePeginFlow(); + + // Handle peg-in sign success with storage integration + const handlePeginSignSuccess = useCallback(() => { + // Add to local storage when peg-in is submitted + if (connectedAddress && btcAddress) { + const peginId = `pending-${Date.now()}`; // Temporary ID until we get BTC tx hash + + addPendingPegin({ + id: peginId, + amount: peginAmount.toString(), + providers: selectedProviders, + ethAddress: connectedAddress, + btcAddress: btcAddress, + }); + + console.log('[VaultDashboard] Added pending peg-in to localStorage:', { + id: peginId, + amount: peginAmount, + providers: selectedProviders, + }); } - }; + + // Complete the peg-in flow and refetch activities + handlePeginSignSuccessBase(() => { + refetchActivities(); + }); + }, [connectedAddress, btcAddress, peginAmount, selectedProviders, addPendingPegin, handlePeginSignSuccessBase, refetchActivities]); // Show message if wallet is not connected if (!isWalletConnected) { @@ -42,8 +92,12 @@ export function VaultDashboard() { return ( <>
- - {activities.map((activity) => ( + + {activities.map((activity: VaultActivity) => (
+ {/* Peg-in Modals */} + + + {}} + onSuccess={handlePeginSignSuccess} + amount={peginAmount} + selectedProviders={selectedProviders} + /> + + + void; + onPegIn: (amount: number, providers: string[]) => void; + btcBalance?: number; // BTC balance in satoshis +} + +export function PeginModal({ open, onClose, onPegIn, btcBalance = 0 }: PeginModalProps) { + // Local state for form inputs + const [amount, setAmount] = useState(""); + const [selectedProviders, setSelectedProviders] = useState([]); + + // Fetch vault providers + const { data: vaultProvidersData, isLoading: isLoadingProviders, error: providersError } = useVaultProviders(); + const vaultProviders = vaultProvidersData?.providers || []; + + // Hardcoded BTC price - TODO: Replace with real price feed from API + const btcPrice = 95000; // $95,000 per BTC + + // Use deposit form hook for validation and business logic + const { + btcBalanceFormatted, + calculateUsdValue, + validateAmount, + validateProviders, + validateForm, + getMaxBalance, + coinName, + } = usePeginForm({ + btcBalance, + btcPrice, + coinName: 'BTC', + displayUSD: true, + }); + + // Parse amount as number + const amountNum = useMemo(() => { + const parsed = parseFloat(amount || "0"); + return isNaN(parsed) ? 0 : parsed; + }, [amount]); + + // Calculate USD equivalent + const amountUsd = useMemo(() => calculateUsdValue(amountNum), [amountNum, calculateUsdValue]); + + // Get validation states from hook + const amountValidation = useMemo(() => validateAmount(amountNum), [amountNum, validateAmount]); + const providersValidation = useMemo(() => validateProviders(selectedProviders), [selectedProviders, validateProviders]); + const isValid = useMemo(() => validateForm(amountNum, selectedProviders), [amountNum, selectedProviders, validateForm]); + + // Determine what error to show + const showAmountError = amount !== "" && !amountValidation.valid; + const showProvidersError = amount !== "" && amountNum > 0 && !providersValidation.valid; + + // Handler: Toggle provider selection + const handleToggleProvider = (providerId: string) => { + setSelectedProviders((prev) => + prev.includes(providerId) + ? prev.filter((id) => id !== providerId) + : [...prev, providerId] + ); + }; + + // Handler: Amount input change + const handleAmountChange = (e: React.ChangeEvent) => { + setAmount(e.target.value); + }; + + // Handler: Balance click to auto-fill max amount + const handleBalanceClick = () => { + const maxBalance = getMaxBalance(); + if (maxBalance > 0) { + setAmount(maxBalance.toString()); + } + }; + + // Handler: Peg-in button click + const handlePegIn = () => { + if (isValid) { + console.log("Peg-in:", { amount: amountNum, providers: selectedProviders }); + onPegIn(amountNum, selectedProviders); + } + }; + + // Handler: Prevent arrow keys from changing number input + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "ArrowUp" || e.key === "ArrowDown") { + e.preventDefault(); + } + }; + + // Handler: Reset state when modal closes + const handleClose = () => { + setAmount(""); + setSelectedProviders([]); + onClose(); + }; + + return ( + + + + + {/* Bitcoin Amount Section */} +
+ + Bitcoin + + + + + {/* Clickable Balance Display */} + + + {/* Error Messages */} + {showAmountError && amountValidation.error && ( + + {amountValidation.error} + + )} + {showProvidersError && providersValidation.error && ( + + {providersValidation.error} + + )} + +
+ + {/* Vault Provider Selection Section */} +
+
+ + Select Vault Providers + + + Choose one or more providers to secure your BTC + +
+ + {isLoadingProviders ? ( +
+ +
+ ) : providersError ? ( +
+ + Failed to load vault providers. Please try again. + +
+ ) : ( +
+ {vaultProviders.map((provider: VaultProvider) => { + const isSelected = selectedProviders.includes(provider.id); + return ( +
+
+
+ + {provider.name.charAt(0)} + +
+
+ + {provider.name} + + {provider.apy && ( + + APY: {provider.apy}% + + )} +
+
+ +
+ ); + })} +
+ )} +
+
+ + + + +
+ ); +} \ No newline at end of file diff --git a/routes/vault/src/components/modals/PeginSignModal.tsx b/routes/vault/src/components/modals/PeginSignModal.tsx new file mode 100644 index 00000000..8a822ef7 --- /dev/null +++ b/routes/vault/src/components/modals/PeginSignModal.tsx @@ -0,0 +1,116 @@ +import { + Button, + DialogBody, + DialogFooter, + DialogHeader, + Loader, + ResponsiveDialog, + Step, + Text, +} from "@babylonlabs-io/core-ui"; +import { useEffect, useState } from "react"; + +interface PeginSignModalProps { + open: boolean; + onClose: () => void; + onSuccess: () => void; + amount: number; + selectedProviders: string[]; +} + +/** + * PeginSignModal - Multi-step signing modal for deposit flow + * + * Shows 4 steps that auto-progress with 2-second delays: + * 1. Sign proof of possession + * 2. Sign & broadcast pegInRequest to Vault Controller + * 3. Validating + * 4. Payout transactions + * + * After all steps complete, triggers onSuccess callback to show success modal. + * + * Note: This is hardcoded for UI demonstration only. + * Real implementation would integrate with wallet signing. + */ +export function PeginSignModal({ + open, + onClose, + onSuccess, +}: PeginSignModalProps) { + const [currentStep, setCurrentStep] = useState(1); + const [processing, setProcessing] = useState(false); + + // Auto-progress through steps with 2-second delays (hardcoded for UI demo) + useEffect(() => { + if (!open || currentStep > 4) return; + + setProcessing(true); + + const timer = setTimeout(() => { + if (currentStep === 4) { + // Last step complete, trigger success modal + setProcessing(false); + onSuccess(); + } else { + // Move to next step + setCurrentStep((prev) => prev + 1); + } + }, 2000); // 2 second delay per step + + return () => clearTimeout(timer); + }, [open, currentStep, onSuccess]); + + // Reset state when modal closes + useEffect(() => { + if (!open) { + setCurrentStep(1); + setProcessing(false); + } + }, [open]); + + return ( + + + + + + Please sign the following messages + + +
+ + Step 1: Sign proof of possession + + + Step 2: Sign & broadcast pegInRequest to Vault Controller + + + Step 3: Validating + + + Step 4: Payout transactions + +
+
+ + + + +
+ ); +} diff --git a/routes/vault/src/components/modals/PeginSuccessModal.tsx b/routes/vault/src/components/modals/PeginSuccessModal.tsx new file mode 100644 index 00000000..5f153bc3 --- /dev/null +++ b/routes/vault/src/components/modals/PeginSuccessModal.tsx @@ -0,0 +1,60 @@ +import { + Button, + DialogBody, + DialogFooter, + Heading, + ResponsiveDialog, + Text, +} from "@babylonlabs-io/core-ui"; + +interface PeginSuccessModalProps { + open: boolean; + onClose: () => void; + amount: number; +} + +/** + * PeginSuccessModal - Success celebration modal after deposit completion + * + * Displays: + * - Mascot image (celebrating) + * - "BTC Peg-in Successful" heading + * - Confirmation message with wait time (~5 hours) + * - "Done" button to close + */ +export function PeginSuccessModal({ + open, + onClose, +}: PeginSuccessModalProps) { + return ( + + + Success mascot + + + BTC Deposit Successful + + + + Your deposit has been recorded and is now awaiting confirmation on + the Bitcoin network. This usually takes up to 5 hours. + + + + + + + + ); +} diff --git a/routes/vault/src/components/modals/index.ts b/routes/vault/src/components/modals/index.ts index 3bba0bd4..1c5c0ec0 100644 --- a/routes/vault/src/components/modals/index.ts +++ b/routes/vault/src/components/modals/index.ts @@ -2,3 +2,6 @@ export { BorrowModal } from "./BorrowModal"; export { BorrowSignModal } from "./BorrowSignModal"; export { BorrowSuccessModal } from "./BorrowSuccessModal"; +export { PeginModal } from "./PeginModal"; +export { PeginSignModal } from "./PeginSignModal"; +export { PeginSuccessModal } from "./PeginSuccessModal"; diff --git a/routes/vault/src/hooks/index.ts b/routes/vault/src/hooks/index.ts index 90fa68cb..08106f1f 100644 --- a/routes/vault/src/hooks/index.ts +++ b/routes/vault/src/hooks/index.ts @@ -3,4 +3,11 @@ */ export { usePeginRequests } from './usePeginRequests'; -export type { UsePeginRequestsResult } from './usePeginRequests'; \ No newline at end of file +export type { UsePeginRequestsResult } from './usePeginRequests'; +export { useVaultProviders } from './useVaultProviders'; +export { usePeginForm } from './usePeginForm'; +export { usePeginStorage } from './usePeginStorage'; +export { useVaultPositions } from './useVaultPositions'; +export { useBorrowFlow } from './useBorrowFlow'; +export { useRepayFlow } from './useRepayFlow'; +export { usePeginFlow } from './usePeginFlow'; \ No newline at end of file diff --git a/routes/vault/src/hooks/usePeginFlow.ts b/routes/vault/src/hooks/usePeginFlow.ts new file mode 100644 index 00000000..622c99f2 --- /dev/null +++ b/routes/vault/src/hooks/usePeginFlow.ts @@ -0,0 +1,70 @@ +import { useState, useCallback } from 'react'; + +/** + * Hook to manage peg-in flow modal state + */ +export function usePeginFlow() { + // Hardcoded BTC balance (in satoshis) - TODO: Replace with real wallet balance + const btcBalanceSat = 500000000; // 5 BTC + + // Modal states + const [isOpen, setIsOpen] = useState(false); + const [signModalOpen, setSignModalOpen] = useState(false); + const [successModalOpen, setSuccessModalOpen] = useState(false); + + // Peg-in flow data + const [peginAmount, setPeginAmount] = useState(0); + const [selectedProviders, setSelectedProviders] = useState([]); + + const openPeginFlow = useCallback(() => { + setIsOpen(true); + }, []); + + const closePeginFlow = useCallback(() => { + setIsOpen(false); + }, []); + + // Handle peg-in click from PeginModal + const handlePeginClick = useCallback((amount: number, providers: string[]) => { + console.log("Peg-in clicked:", { amount, providers }); + setPeginAmount(amount); + setSelectedProviders(providers); + setIsOpen(false); + setSignModalOpen(true); + }, []); + + // Handle signing success - accepts callback for parent to handle storage + const handlePeginSignSuccess = useCallback((onSuccess?: () => void) => { + setSignModalOpen(false); + setSuccessModalOpen(true); + + // Call parent callback if provided + if (onSuccess) { + onSuccess(); + } + }, []); + + // Handle success modal close + const handlePeginSuccessClose = useCallback(() => { + setSuccessModalOpen(false); + setPeginAmount(0); + setSelectedProviders([]); + }, []); + + return { + // Modal states + isOpen, + signModalOpen, + successModalOpen, + // Peg-in data + peginAmount, + selectedProviders, + btcBalanceSat, + // Actions + openPeginFlow, + closePeginFlow, + handlePeginClick, + handlePeginSignSuccess, + handlePeginSuccessClose, + }; +} diff --git a/routes/vault/src/hooks/usePeginForm.ts b/routes/vault/src/hooks/usePeginForm.ts new file mode 100644 index 00000000..65d41031 --- /dev/null +++ b/routes/vault/src/hooks/usePeginForm.ts @@ -0,0 +1,135 @@ +import { useMemo, useCallback } from 'react'; +import { object, number, array, string } from 'yup'; + +// Helper function to convert satoshis to BTC +const satoshiToBtc = (satoshi: number): number => { + return satoshi / 100000000; +}; + +// Helper function to format number +const formatNumber = (value: any): number => { + if (typeof value === 'number') return value; + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; +}; + +// Helper function to validate decimal points +const validateDecimalPoints = (value: any): boolean => { + if (!value) return true; + const str = String(value); + const decimalIndex = str.indexOf('.'); + if (decimalIndex === -1) return true; + const decimals = str.slice(decimalIndex + 1).length; + return decimals <= 8; +}; + +interface UsePeginFormParams { + btcBalance: number; // in satoshis + btcPrice: number; + coinName?: string; + displayUSD?: boolean; +} + +export function usePeginForm({ + btcBalance, + btcPrice, + coinName = 'BTC', + displayUSD = true, +}: UsePeginFormParams) { + // Convert balance from satoshis to BTC + const btcBalanceFormatted = useMemo( + () => satoshiToBtc(btcBalance), + [btcBalance] + ); + + // Validation schema (following simple-staking pattern) + const validationSchema = useMemo( + () => + object().shape({ + amount: number() + .transform(formatNumber) + .typeError('Peg-in amount must be a valid number.') + .required('Peg-in amount is required.') + .moreThan(0, 'Peg-in amount must be greater than 0.') + .max( + btcBalanceFormatted, + `Peg-in amount exceeds your balance (${satoshiToBtc(btcBalance).toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 8 })} ${coinName})!` + ) + .test( + 'decimal-points', + 'Peg-in amount must have no more than 8 decimal points.', + validateDecimalPoints + ), + + selectedProviders: array() + .of(string().required()) + .required('Please select at least one vault provider.') + .min(1, 'Please select at least one vault provider.'), + }), + [btcBalance, btcBalanceFormatted, coinName] + ); + + // Calculate USD equivalent + const calculateUsdValue = useCallback( + (amount: number): string => { + if (!displayUSD || !btcPrice || amount === 0) return ''; + const usdValue = amount * btcPrice; + return `$${usdValue.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + }, + [btcPrice, displayUSD] + ); + + // Validate amount + const validateAmount = useCallback( + (amount: number): { valid: boolean; error?: string } => { + if (amount <= 0) { + return { valid: false, error: 'Peg-in amount must be greater than 0.' }; + } + if (amount > btcBalanceFormatted) { + return { valid: false, error: 'Amount exceeds available balance' }; + } + if (!validateDecimalPoints(amount)) { + return { valid: false, error: 'Peg-in amount must have no more than 8 decimal points.' }; + } + return { valid: true }; + }, + [btcBalanceFormatted] + ); + + // Validate providers + const validateProviders = useCallback( + (providers: string[]): { valid: boolean; error?: string } => { + if (providers.length === 0) { + return { valid: false, error: 'Please select at least one vault provider' }; + } + return { valid: true }; + }, + [] + ); + + // Validate entire form + const validateForm = useCallback( + (amount: number, providers: string[]): boolean => { + const amountValidation = validateAmount(amount); + const providersValidation = validateProviders(providers); + return amountValidation.valid && providersValidation.valid; + }, + [validateAmount, validateProviders] + ); + + // Handler: Auto-fill max balance + const getMaxBalance = useCallback((): number => { + return btcBalanceFormatted; + }, [btcBalanceFormatted]); + + return { + validationSchema, + btcBalanceFormatted, + calculateUsdValue, + validateAmount, + validateProviders, + validateForm, + getMaxBalance, + coinName, + }; +} diff --git a/routes/vault/src/hooks/usePeginStorage.ts b/routes/vault/src/hooks/usePeginStorage.ts new file mode 100644 index 00000000..4f456c89 --- /dev/null +++ b/routes/vault/src/hooks/usePeginStorage.ts @@ -0,0 +1,129 @@ +/** + * Hook for managing peg-in local storage + * + * Similar to simple-staking's useDelegationStorage pattern: + * - Merges pending peg-ins from localStorage with confirmed peg-ins from API + * - Automatically removes confirmed peg-ins from localStorage + * - Cleans up old pending peg-ins + */ + +import { useCallback, useEffect, useMemo } from 'react'; +import { useLocalStorage } from 'usehooks-ts'; +import type { VaultActivity } from '../mockData/vaultActivities'; +import { + type PendingPeginRequest, + filterPendingPegins, +} from '../storage/peginStorage'; +import { bitcoinIcon } from '../assets'; + +interface UsePeginStorageParams { + ethAddress: string; + confirmedPegins: VaultActivity[]; // Peg-ins from API/blockchain +} + +export function usePeginStorage({ + ethAddress, + confirmedPegins, +}: UsePeginStorageParams) { + const storageKey = `vault-pending-pegins-${ethAddress}`; + + // Store pending peg-ins in localStorage + const [pendingPegins = [], setPendingPegins] = useLocalStorage< + PendingPeginRequest[] + >(storageKey, []); + + // Create a map of confirmed peg-in IDs for quick lookup + const confirmedPeginMap = useMemo(() => { + return confirmedPegins.reduce( + (acc, pegin) => ({ + ...acc, + [pegin.id]: pegin, + }), + {} as Record, + ); + }, [confirmedPegins]); + + // Sync: Remove pending peg-ins that are now confirmed + useEffect(() => { + if (!ethAddress) return; + + const confirmedIds = Object.keys(confirmedPeginMap); + const filteredPegins = filterPendingPegins(pendingPegins, confirmedIds); + + // Only update if something changed + if (filteredPegins.length !== pendingPegins.length) { + setPendingPegins(filteredPegins); + } + }, [ethAddress, confirmedPeginMap, pendingPegins, setPendingPegins]); + + // Convert pending peg-ins to VaultActivity format + const pendingActivities: VaultActivity[] = useMemo(() => { + return pendingPegins + .filter((pegin: PendingPeginRequest) => !confirmedPeginMap[pegin.id]) // Don't show if already confirmed + .map((pegin: PendingPeginRequest) => ({ + id: pegin.id, + collateral: { + amount: pegin.amount, + symbol: 'BTC', + icon: bitcoinIcon, + }, + status: { + label: 'Pending', + variant: 'pending' as const, + }, + providers: pegin.providers.map((providerId: string) => ({ + id: providerId, + name: providerId, // TODO: Map to actual provider names + icon: undefined, + })), + action: { + label: 'Borrow USDC', + onClick: () => console.log('Borrow from pending peg-in:', pegin.id), + }, + isPending: true, // Flag to show callout message + })); + }, [pendingPegins, confirmedPeginMap]); + + // Merge pending and confirmed activities + const allActivities: VaultActivity[] = useMemo(() => { + return [...pendingActivities, ...confirmedPegins]; + }, [pendingActivities, confirmedPegins]); + + // Add a new pending peg-in + const addPendingPegin = useCallback( + (pegin: Omit) => { + if (!ethAddress) return; + + const newPegin: PendingPeginRequest = { + ...pegin, + timestamp: Date.now(), + status: 'pending', + }; + + setPendingPegins((prev: PendingPeginRequest[]) => [...prev, newPegin]); + }, + [ethAddress, setPendingPegins], + ); + + // Remove a pending peg-in manually + const removePendingPegin = useCallback( + (peginId: string) => { + setPendingPegins((prev: PendingPeginRequest[]) => prev.filter((p: PendingPeginRequest) => p.id !== peginId)); + }, + [setPendingPegins], + ); + + // Clear all pending peg-ins + const clearPendingPegins = useCallback(() => { + setPendingPegins([]); + }, [setPendingPegins]); + + return { + allActivities, + pendingPegins, + pendingActivities, + addPendingPegin, + removePendingPegin, + clearPendingPegins, + }; +} diff --git a/routes/vault/src/hooks/useVaultPositions.ts b/routes/vault/src/hooks/useVaultPositions.ts index 93a0eac5..ad04fb6c 100644 --- a/routes/vault/src/hooks/useVaultPositions.ts +++ b/routes/vault/src/hooks/useVaultPositions.ts @@ -2,6 +2,7 @@ import { useMemo } from "react"; import { useChainConnector } from "@babylonlabs-io/wallet-connector"; import type { Hex } from "viem"; import { usePeginRequests } from "./usePeginRequests"; +import { usePeginStorage } from "./usePeginStorage"; /** * Hook to manage vault positions data fetching and wallet connection @@ -9,6 +10,13 @@ import { usePeginRequests } from "./usePeginRequests"; */ export function useVaultPositions() { const ethConnector = useChainConnector('ETH'); + const btcConnector = useChainConnector('BTC'); + + // Get BTC address from connector + const btcAddress = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (btcConnector as any)?.connectedWallet?.account?.address as string | undefined; + }, [btcConnector]); const connectedAddress = useMemo(() => { const address = ( @@ -21,15 +29,26 @@ export function useVaultPositions() { }, [ethConnector]); // Fetch pegin requests from blockchain - // Note: We pass a no-op function since we don't need borrow callback here anymore - const { activities, refetch } = usePeginRequests( + const { activities: confirmedActivities, refetch } = usePeginRequests( connectedAddress, () => {} // no-op callback ); + // Integrate local storage for pending peg-ins + const { + allActivities, + addPendingPegin, + } = usePeginStorage({ + ethAddress: connectedAddress || '', + confirmedPegins: confirmedActivities, + }); + return { - activities, + activities: allActivities, isWalletConnected: !!connectedAddress, refetchActivities: refetch, + connectedAddress, + btcAddress, + addPendingPegin, }; } diff --git a/routes/vault/src/hooks/useVaultProviders.ts b/routes/vault/src/hooks/useVaultProviders.ts new file mode 100644 index 00000000..b6324233 --- /dev/null +++ b/routes/vault/src/hooks/useVaultProviders.ts @@ -0,0 +1,17 @@ +import { useQuery } from '@tanstack/react-query'; +import { getVaultProviders } from '../clients/vault-providers-api'; + +const FIVE_MINUTES = 5 * 60 * 1000; + +export const VAULT_PROVIDERS_KEY = 'VAULT_PROVIDERS'; + +/** + * Hook to fetch vault providers + */ +export const useVaultProviders = () => { + return useQuery({ + queryKey: [VAULT_PROVIDERS_KEY], + queryFn: getVaultProviders, + staleTime: FIVE_MINUTES, + }); +}; diff --git a/routes/vault/src/mockData/vaultActivities.ts b/routes/vault/src/mockData/vaultActivities.ts index 7258166a..d3c33fea 100644 --- a/routes/vault/src/mockData/vaultActivities.ts +++ b/routes/vault/src/mockData/vaultActivities.ts @@ -58,6 +58,9 @@ export interface VaultActivity { borrowAmount: bigint; active: boolean; }; + // Pending peg-in flags + isPending?: boolean; // Flag to show pending callout message + pendingMessage?: string; // Custom pending message } const bitcoinIconDataUri = "data:image/svg+xml,%3Csvg viewBox='0 0 24 24' fill='%23FF7C2A' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M23.638 14.904c-1.602 6.43-8.113 10.34-14.542 8.736C2.67 22.05-1.244 15.525.362 9.105 1.962 2.67 8.475-1.243 14.9.358c6.43 1.605 10.342 8.115 8.738 14.548v-.002zm-6.35-4.613c.24-1.59-.974-2.45-2.64-3.03l.54-2.153-1.315-.33-.525 2.107c-.345-.087-.705-.167-1.064-.25l.526-2.127-1.32-.33-.54 2.165c-.285-.067-.565-.132-.84-.2l-1.815-.45-.35 1.407s.975.225.955.236c.535.136.63.486.615.766l-1.477 5.92c-.075.166-.24.406-.614.314.015.02-.96-.24-.96-.24l-.66 1.51 1.71.426.93.242-.54 2.19 1.32.327.54-2.17c.36.1.705.19 1.05.273l-.51 2.154 1.32.33.545-2.19c2.24.427 3.93.257 4.64-1.774.57-1.637-.03-2.58-1.217-3.196.854-.193 1.5-.76 1.68-1.93h.01zm-3.01 4.22c-.404 1.64-3.157.75-4.05.53l.72-2.9c.896.23 3.757.67 3.33 2.37zm.41-4.24c-.37 1.49-2.662.735-3.405.55l.654-2.64c.744.18 3.137.524 2.75 2.084v.006z'/%3E%3C/svg%3E"; diff --git a/routes/vault/src/storage/index.ts b/routes/vault/src/storage/index.ts index 4ed79fcb..93a63224 100644 --- a/routes/vault/src/storage/index.ts +++ b/routes/vault/src/storage/index.ts @@ -1 +1,5 @@ -// Local storage utilities for persisting intermediate state (e.g., pending transactions, user preferences) +/** + * Storage utilities + */ + +export * from './peginStorage'; \ No newline at end of file diff --git a/routes/vault/src/storage/peginStorage.ts b/routes/vault/src/storage/peginStorage.ts new file mode 100644 index 00000000..82eb9bcf --- /dev/null +++ b/routes/vault/src/storage/peginStorage.ts @@ -0,0 +1,151 @@ +/** + * Local Storage utilities for pending peg-in transactions + * + * Similar to simple-staking's delegation storage pattern: + * - Store pending peg-ins in localStorage + * - Merge with API data when available + * - Remove from localStorage when confirmed on-chain + */ + +export interface PendingPeginRequest { + id: string; // Unique identifier (BTC tx hash or temporary ID) + btcTxHash?: string; // BTC transaction hash (once available) + amount: string; // BTC amount as string to avoid BigInt serialization issues + providers: string[]; // Selected vault provider IDs + ethAddress: string; // ETH address that initiated the peg-in + btcAddress: string; // BTC address used + timestamp: number; // When the peg-in was initiated + status: 'pending' | 'confirming' | 'confirmed'; +} + +const STORAGE_KEY_PREFIX = 'vault-pending-pegins'; +const MAX_PENDING_DURATION = 24 * 60 * 60 * 1000; // 24 hours + +/** + * Get storage key for a specific address + */ +function getStorageKey(ethAddress: string): string { + return `${STORAGE_KEY_PREFIX}-${ethAddress}`; +} + +/** + * Get all pending peg-ins from localStorage for an address + */ +export function getPendingPegins(ethAddress: string): PendingPeginRequest[] { + if (!ethAddress) return []; + + try { + const key = getStorageKey(ethAddress); + const stored = localStorage.getItem(key); + if (!stored) return []; + + const parsed: PendingPeginRequest[] = JSON.parse(stored); + return parsed; + } catch (error) { + console.error('Error reading pending peg-ins from localStorage:', error); + return []; + } +} + +/** + * Save pending peg-ins to localStorage + */ +export function savePendingPegins( + ethAddress: string, + pegins: PendingPeginRequest[], +): void { + if (!ethAddress) return; + + try { + const key = getStorageKey(ethAddress); + localStorage.setItem(key, JSON.stringify(pegins)); + } catch (error) { + console.error('Error saving pending peg-ins to localStorage:', error); + } +} + +/** + * Add a new pending peg-in to localStorage + */ +export function addPendingPegin( + ethAddress: string, + pegin: Omit, +): void { + const existingPegins = getPendingPegins(ethAddress); + + const newPegin: PendingPeginRequest = { + ...pegin, + timestamp: Date.now(), + status: 'pending', + }; + + const updatedPegins = [...existingPegins, newPegin]; + savePendingPegins(ethAddress, updatedPegins); +} + +/** + * Remove a pending peg-in from localStorage by ID + */ +export function removePendingPegin(ethAddress: string, peginId: string): void { + const existingPegins = getPendingPegins(ethAddress); + const updatedPegins = existingPegins.filter((p) => p.id !== peginId); + savePendingPegins(ethAddress, updatedPegins); +} + +/** + * Update status of a pending peg-in + */ +export function updatePeginStatus( + ethAddress: string, + peginId: string, + status: PendingPeginRequest['status'], + btcTxHash?: string, +): void { + const existingPegins = getPendingPegins(ethAddress); + const updatedPegins = existingPegins.map((p) => + p.id === peginId + ? { ...p, status, ...(btcTxHash && { btcTxHash }) } + : p, + ); + savePendingPegins(ethAddress, updatedPegins); +} + +/** + * Filter and clean up old pending peg-ins + * Removes peg-ins that have exceeded the max duration + */ +export function filterPendingPegins( + pendingPegins: PendingPeginRequest[], + confirmedPeginIds: string[], +): PendingPeginRequest[] { + const now = Date.now(); + + return pendingPegins.filter((pegin) => { + // Remove if already confirmed + if (confirmedPeginIds.includes(pegin.id)) { + return false; + } + + // Remove if exceeded max duration + const age = now - pegin.timestamp; + if (age > MAX_PENDING_DURATION) { + return false; + } + + return true; + }); +} + +/** + * Clear all pending peg-ins for an address + */ +export function clearPendingPegins(ethAddress: string): void { + if (!ethAddress) return; + + try { + const key = getStorageKey(ethAddress); + localStorage.removeItem(key); + } catch (error) { + console.error('Error clearing pending peg-ins from localStorage:', error); + } +} diff --git a/services/simple-staking/public/mascot-smile-expression-full-body.png b/services/simple-staking/public/mascot-smile-expression-full-body.png new file mode 100644 index 00000000..68a89f82 Binary files /dev/null and b/services/simple-staking/public/mascot-smile-expression-full-body.png differ diff --git a/services/simple-staking/public/mascot-smile-expression.png b/services/simple-staking/public/mascot-smile-expression.png new file mode 100644 index 00000000..70370b40 Binary files /dev/null and b/services/simple-staking/public/mascot-smile-expression.png differ