diff --git a/README.md b/README.md index 8f0f65f..f7bcead 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,49 @@ [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. +## Transaction History + +BridgeWise UI SDK includes a multi-chain transaction history system for Stellar and EVM bridge flows. + +Usage example: + +```tsx +import { useTransactionHistory, BridgeHistory } from '@bridgewise/ui-components'; + +function HistoryPanel({ account }: { account: string }) { + const transactions = useTransactionHistory(account, { + filter: { status: 'confirmed' }, + sortOrder: 'desc', + }).transactions; + + return ( + <> + +
Total transactions: {transactions.length}
+ + ); +} +``` + +You can configure local-only storage (default) or optional backend tracking via `TransactionProvider`. + +## Multi-Bridge Liquidity Monitoring + +BridgeWise UI SDK includes liquidity monitoring across bridges for route viability checks. + +```tsx +import { useBridgeLiquidity } from '@bridgewise/ui-components'; + +const { liquidity, refreshLiquidity } = useBridgeLiquidity({ + token: 'USDC', + sourceChain: 'Ethereum', + destinationChain: 'Stellar', + refreshIntervalMs: 30000, +}); +``` + +`BridgeCompare` uses this data to prioritize high-liquidity routes and warn on low-liquidity paths. + ## Project setup ```bash diff --git a/libs/ui-components/README.md b/libs/ui-components/README.md new file mode 100644 index 0000000..711621a --- /dev/null +++ b/libs/ui-components/README.md @@ -0,0 +1,111 @@ +# @bridgewise/ui-components + +BridgeWise UI SDK components and hooks for cross-chain UX. + +## Transaction History + +The transaction history module provides a unified view across Stellar and EVM bridge executions. + +### Data model + +```ts +interface BridgeTransaction { + txHash: string; + bridgeName: string; + sourceChain: string; + destinationChain: string; + sourceToken: string; + destinationToken: string; + amount: number; + fee: number; + slippagePercent: number; + status: 'pending' | 'confirmed' | 'failed'; + timestamp: Date; + account: string; +} +``` + +### Hook usage + +```tsx +import { useTransactionHistory } from '@bridgewise/ui-components'; + +const transactions = useTransactionHistory(account).transactions; +``` + +### Filtering and sorting + +```tsx +const { transactions } = useTransactionHistory(account, { + filter: { + chain: 'ethereum', + bridgeName: 'layerzero', + status: 'confirmed', + startDate: new Date('2026-01-01'), + endDate: new Date('2026-12-31'), + }, + sortOrder: 'desc', + includeBackend: true, +}); +``` + +### Demo component + +```tsx +import { BridgeHistory } from '@bridgewise/ui-components'; + +; +``` + +### Storage configuration + +By default, history is persisted in browser local storage. + +For server-side tracking, configure an optional backend in `TransactionProvider`: + +```tsx +import { + TransactionProvider, + createHttpTransactionHistoryBackend, +} from '@bridgewise/ui-components'; + +const historyBackend = createHttpTransactionHistoryBackend({ + baseUrl: 'https://api.bridgewise.example.com', +}); + + { + console.log('Tracked transaction', tx.txHash); + }} +> + {children} +; +``` + +## Multi-Bridge Liquidity Monitoring + +Use `useBridgeLiquidity()` to fetch live liquidity per bridge, token, and chain pair. + +### Hook usage + +```tsx +import { useBridgeLiquidity } from '@bridgewise/ui-components'; + +const { liquidity, refreshLiquidity } = useBridgeLiquidity({ + token: 'USDC', + sourceChain: 'Ethereum', + destinationChain: 'Stellar', +}); +``` + +### Integration examples + +- `BridgeCompare` prioritizes higher-liquidity routes and warns/disables low-liquidity options. +- `BridgeStatus` (`TransactionHeartbeat`) can show liquidity alerts via `state.liquidityAlert`. + +### Fallback and errors + +- If provider APIs fail, the monitor returns last-known cached liquidity (when available). +- Structured provider errors are returned as `{ bridgeName, message }[]`. +- Manual refresh is supported through `refreshLiquidity()` and optional polling via `refreshIntervalMs`. diff --git a/libs/ui-components/jest.config.js b/libs/ui-components/jest.config.js new file mode 100644 index 0000000..37544f5 --- /dev/null +++ b/libs/ui-components/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/__tests__/**/*.spec.ts', '**/?(*.)+(spec|test).ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'json'], + testPathIgnorePatterns: ['/dist/', '/node_modules/'], + transform: { + '^.+\\.(t|j)sx?$': [ + 'ts-jest', + { + tsconfig: 'tsconfig.json', + }, + ], + }, +}; diff --git a/libs/ui-components/package.json b/libs/ui-components/package.json index 0bd8bad..ebefc07 100644 --- a/libs/ui-components/package.json +++ b/libs/ui-components/package.json @@ -1,6 +1,9 @@ { "name": "@bridgewise/ui-components", "version": "0.1.0", + "scripts": { + "test": "jest --config jest.config.js" + }, "main": "src/index.ts", "exports": { ".": "./src/index.ts", diff --git a/libs/ui-components/src/components/BridgeCompare/BridgeCompare.tsx b/libs/ui-components/src/components/BridgeCompare/BridgeCompare.tsx index 74e8e09..019d551 100644 --- a/libs/ui-components/src/components/BridgeCompare/BridgeCompare.tsx +++ b/libs/ui-components/src/components/BridgeCompare/BridgeCompare.tsx @@ -1,6 +1,8 @@ import React from 'react'; import { useFeeSlippageBenchmark } from '../../hooks/useFeeSlippageBenchmark'; +import { useBridgeLiquidity } from '../../hooks/useBridgeLiquidity'; +import { prioritizeRoutesByLiquidity } from '../../liquidity/monitor'; import type { BridgeRoute, ChainId } from '../../../../bridge-core/src/types'; interface BridgeCompareProps { @@ -9,6 +11,7 @@ interface BridgeCompareProps { sourceChain: string; destinationChain: string; showBenchmarkComparison?: boolean; + minLiquidityThreshold?: number; onRouteSelect?: (route: BridgeRoute) => void; } @@ -24,6 +27,7 @@ const BridgeCompare: React.FC = ({ sourceChain, destinationChain, showBenchmarkComparison = true, + minLiquidityThreshold = 0, onRouteSelect }: BridgeCompareProps) => { // Get benchmark data for comparison @@ -38,6 +42,24 @@ const BridgeCompare: React.FC = ({ destinationChain: destinationChain as ChainId, }); + const { + liquidity, + loading: liquidityLoading, + errors: liquidityErrors, + usedFallback, + refreshLiquidity, + } = useBridgeLiquidity({ + token, + sourceChain, + destinationChain, + refreshIntervalMs: 30000, + }); + + const orderedRoutes = prioritizeRoutesByLiquidity(routes, liquidity); + + const getLiquidityForProvider = (provider: string) => + liquidity.find((item) => item.bridgeName.toLowerCase() === provider.toLowerCase()); + // Helper to get benchmark for a specific bridge const getBenchmarkForBridge = (provider: string) => { return benchmarks.find(b => b.bridgeName === provider); @@ -82,11 +104,16 @@ const BridgeCompare: React.FC = ({ {/* Routes List */}
- {routes.map((route, index) => { + {orderedRoutes.map((route, index) => { const benchmark = getBenchmarkForBridge(route.provider); const feeDiff = benchmark ? getFeeDifference(route.feePercentage, benchmark.avgFee) : null; + const routeLiquidity = getLiquidityForProvider(route.provider); + const requiredAmount = parseFloat(route.inputAmount); + const threshold = requiredAmount + minLiquidityThreshold; + const hasInsufficientLiquidity = + !!routeLiquidity && routeLiquidity.availableAmount < threshold; return (
= ({
+
+ {routeLiquidity ? ( +

+ Liquidity: {routeLiquidity.availableAmount.toLocaleString()} {token} + {hasInsufficientLiquidity ? ' (insufficient for this route)' : ''} +

+ ) : ( +

Liquidity unavailable for this route

+ )} +
+ {onRouteSelect && (
-
@@ -163,6 +208,17 @@ const BridgeCompare: React.FC = ({ })} +
+ + {usedFallback && Using cached liquidity data} +
+ {/* Loading/Error States */} {benchmarkLoading && showBenchmarkComparison && (
@@ -176,6 +232,16 @@ const BridgeCompare: React.FC = ({
)} + {liquidityLoading && ( +
Loading liquidity data...
+ )} + + {liquidityErrors.length > 0 && ( +
+ Liquidity providers unavailable: {liquidityErrors.map((error) => error.bridgeName).join(', ')} +
+ )} + {routes.length === 0 && (
No routes available for comparison diff --git a/libs/ui-components/src/components/BridgeCompare/index.ts b/libs/ui-components/src/components/BridgeCompare/index.ts new file mode 100644 index 0000000..f871d35 --- /dev/null +++ b/libs/ui-components/src/components/BridgeCompare/index.ts @@ -0,0 +1 @@ +export { default as BridgeCompare } from './BridgeCompare'; diff --git a/libs/ui-components/src/components/BridgeHistory/BridgeHistory.tsx b/libs/ui-components/src/components/BridgeHistory/BridgeHistory.tsx new file mode 100644 index 0000000..5e76f73 --- /dev/null +++ b/libs/ui-components/src/components/BridgeHistory/BridgeHistory.tsx @@ -0,0 +1,80 @@ +'use client'; + +import React from 'react'; +import { useTransactionHistory } from '../../hooks/useTransactionHistory'; +import type { + BridgeTransactionStatus, + TransactionHistoryConfig, + TransactionHistoryFilter, +} from '../../transaction-history/types'; + +export interface BridgeHistoryProps { + account: string; + chain?: string; + bridgeName?: string; + status?: BridgeTransactionStatus; + startDate?: Date; + endDate?: Date; + sortOrder?: 'asc' | 'desc'; + includeBackend?: boolean; + historyConfig?: TransactionHistoryConfig; + emptyStateMessage?: string; +} + +export const BridgeHistory: React.FC = ({ + account, + chain, + bridgeName, + status, + startDate, + endDate, + sortOrder = 'desc', + includeBackend = false, + historyConfig, + emptyStateMessage = 'No transactions found for this account.', +}) => { + const filter: TransactionHistoryFilter = { + chain, + bridgeName, + status, + startDate, + endDate, + }; + + const { transactions, loading } = useTransactionHistory( + account, + { + filter, + sortOrder, + includeBackend, + }, + historyConfig, + ); + + if (!account) { + return

Connect a wallet to view transaction history.

; + } + + if (loading) { + return

Loading transaction history...

; + } + + if (transactions.length === 0) { + return

{emptyStateMessage}

; + } + + return ( +
+

Bridge History

+
    + {transactions.map((transaction) => ( +
  • + {transaction.bridgeName} • {transaction.sourceChain} →{' '} + {transaction.destinationChain} • {transaction.amount} {transaction.sourceToken} •{' '} + {transaction.status} • {transaction.timestamp.toLocaleString()} +
  • + ))} +
+
+ ); +}; diff --git a/libs/ui-components/src/components/BridgeHistory/index.ts b/libs/ui-components/src/components/BridgeHistory/index.ts new file mode 100644 index 0000000..39bedb1 --- /dev/null +++ b/libs/ui-components/src/components/BridgeHistory/index.ts @@ -0,0 +1,2 @@ +export { BridgeHistory } from './BridgeHistory'; +export type { BridgeHistoryProps } from './BridgeHistory'; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx index c5fe48f..0741e46 100644 --- a/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionContext.tsx @@ -6,6 +6,12 @@ 'use client'; import React, { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'; +import { TransactionHistoryStorage } from '../../transaction-history/storage'; +import type { + BridgeTransaction, + BridgeTransactionStatus, + TransactionHistoryConfig, +} from '../../transaction-history/types'; export interface TransactionState { id: string; @@ -13,6 +19,16 @@ export interface TransactionState { progress: number; step: string; txHash?: string; + bridgeName?: string; + sourceChain?: string; + destinationChain?: string; + sourceToken?: string; + destinationToken?: string; + amount?: number; + fee?: number; + slippagePercent?: number; + account?: string; + liquidityAlert?: string; timestamp: number; } @@ -20,14 +36,60 @@ interface TransactionContextType { state: TransactionState; updateState: (updates: Partial) => void; clearState: () => void; - startTransaction: (id: string) => void; + startTransaction: (id: string, initialState?: Partial) => void; + recordBridgeTransaction: (transaction: Partial) => Promise; } const TransactionContext = createContext(undefined); const STORAGE_KEY = 'bridgewise_tx_state'; -export const TransactionProvider = ({ children }: { children: ReactNode }) => { +const mapStatusToHistory = ( + status: TransactionState['status'], +): BridgeTransactionStatus | null => { + if (status === 'pending') return 'pending'; + if (status === 'success') return 'confirmed'; + if (status === 'failed') return 'failed'; + return null; +}; + +const normalizeNumber = (value?: number): number => + typeof value === 'number' && Number.isFinite(value) ? value : 0; + +const normalizeBridgeTransaction = ( + payload: Partial, +): BridgeTransaction => { + const now = new Date(); + return { + txHash: payload.txHash ?? `unknown-${now.getTime()}`, + bridgeName: payload.bridgeName ?? 'unknown', + sourceChain: payload.sourceChain ?? 'unknown', + destinationChain: payload.destinationChain ?? 'unknown', + sourceToken: payload.sourceToken ?? 'unknown', + destinationToken: payload.destinationToken ?? 'unknown', + amount: normalizeNumber(payload.amount), + fee: normalizeNumber(payload.fee), + slippagePercent: normalizeNumber(payload.slippagePercent), + status: + payload.status === 'pending' || payload.status === 'confirmed' || payload.status === 'failed' + ? payload.status + : 'pending', + timestamp: payload.timestamp ?? now, + account: payload.account ?? 'unknown', + }; +}; + +export interface TransactionProviderProps { + children: ReactNode; + historyConfig?: TransactionHistoryConfig; + onTransactionTracked?: (transaction: BridgeTransaction) => void; +} + +export const TransactionProvider = ({ + children, + historyConfig, + onTransactionTracked, +}: TransactionProviderProps) => { const [state, setState] = useState({ id: '', status: 'idle', @@ -35,6 +97,7 @@ export const TransactionProvider = ({ children }: { children: ReactNode }) => { step: '', timestamp: 0, }); + const [historyStorage] = useState(() => new TransactionHistoryStorage(historyConfig)); // Load from storage on mount useEffect(() => { @@ -63,6 +126,31 @@ export const TransactionProvider = ({ children }: { children: ReactNode }) => { } }, [state]); + useEffect(() => { + const historyStatus = mapStatusToHistory(state.status); + if (!historyStatus) { + return; + } + + const tracked = normalizeBridgeTransaction({ + txHash: state.txHash ?? (state.id ? `pending-${state.id}` : undefined), + bridgeName: state.bridgeName, + sourceChain: state.sourceChain, + destinationChain: state.destinationChain, + sourceToken: state.sourceToken, + destinationToken: state.destinationToken, + amount: state.amount, + fee: state.fee, + slippagePercent: state.slippagePercent, + status: historyStatus, + timestamp: state.timestamp ? new Date(state.timestamp) : new Date(), + account: state.account, + }); + + void historyStorage.upsertTransaction(tracked); + onTransactionTracked?.(tracked); + }, [historyStorage, onTransactionTracked, state]); + const updateState = useCallback((updates: Partial) => { setState((prev) => ({ ...prev, ...updates, timestamp: Date.now() })); }, []); @@ -75,21 +163,35 @@ export const TransactionProvider = ({ children }: { children: ReactNode }) => { step: '', timestamp: 0, }); - localStorage.removeItem(STORAGE_KEY); + if (typeof window !== 'undefined') { + localStorage.removeItem(STORAGE_KEY); + } }, []); - const startTransaction = useCallback((id: string) => { + const startTransaction = useCallback((id: string, initialState?: Partial) => { setState({ id, status: 'pending', progress: 0, step: 'Initializing...', - timestamp: Date.now() + timestamp: Date.now(), + ...initialState, }); }, []); + const recordBridgeTransaction = useCallback( + async (transaction: Partial) => { + const normalized = normalizeBridgeTransaction(transaction); + await historyStorage.upsertTransaction(normalized); + onTransactionTracked?.(normalized); + }, + [historyStorage, onTransactionTracked], + ); + return ( - + {children} ); diff --git a/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx index b646661..0702a57 100644 --- a/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx +++ b/libs/ui-components/src/components/TransactionHeartbeat/TransactionHeartbeat.headless.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { useTransaction } from './TransactionContext'; import type { TransactionState } from './TransactionContext'; +import type { BridgeTransaction } from '../../transaction-history/types'; export interface TransactionHeartbeatRenderProps { /** Full transaction state object */ @@ -18,7 +19,9 @@ export interface TransactionHeartbeatRenderProps { /** Update transaction state with partial updates */ updateState: (updates: Partial) => void; /** Start a new transaction */ - startTransaction: (id: string) => void; + startTransaction: (id: string, initialState?: Partial) => void; + /** Record a normalized transaction directly into history */ + recordBridgeTransaction: (transaction: Partial) => Promise; /** Convenience boolean: true if status is 'success' */ isSuccess: boolean; /** Convenience boolean: true if status is 'failed' */ @@ -55,7 +58,7 @@ export interface TransactionHeartbeatHeadlessProps { export const TransactionHeartbeatHeadless: React.FC = ({ children, }) => { - const { state, clearState, updateState, startTransaction } = useTransaction(); + const { state, clearState, updateState, startTransaction, recordBridgeTransaction } = useTransaction(); // Don't render if transaction is idle if (state.status === 'idle') { @@ -74,6 +77,7 @@ export const TransactionHeartbeatHeadless: React.FC { {state.step}

+ {state.liquidityAlert && ( +

+ {state.liquidityAlert} +

+ )} + {/* Transaction hash link */} {state.txHash && (
@@ -210,3 +222,5 @@ export const TransactionHeartbeat: React.FC = () => { ); }; + +export const BridgeStatus = TransactionHeartbeat; diff --git a/libs/ui-components/src/components/TransactionHeartbeat/index.ts b/libs/ui-components/src/components/TransactionHeartbeat/index.ts index 3f1196d..240eb29 100644 --- a/libs/ui-components/src/components/TransactionHeartbeat/index.ts +++ b/libs/ui-components/src/components/TransactionHeartbeat/index.ts @@ -3,6 +3,7 @@ */ export { TransactionHeartbeat } from './TransactionHeartbeat'; +export { BridgeStatus } from './TransactionHeartbeat'; export { TransactionHeartbeatHeadless } from './TransactionHeartbeat.headless'; export { TransactionProvider, useTransaction } from './TransactionContext'; export type { TransactionState } from './TransactionContext'; diff --git a/libs/ui-components/src/hooks/__tests__/useBridgeLiquidity.spec.ts b/libs/ui-components/src/hooks/__tests__/useBridgeLiquidity.spec.ts new file mode 100644 index 0000000..708f030 --- /dev/null +++ b/libs/ui-components/src/hooks/__tests__/useBridgeLiquidity.spec.ts @@ -0,0 +1,32 @@ +import { BridgeLiquidityMonitor } from '../../liquidity/monitor'; +import { fetchBridgeLiquiditySnapshot } from '../useBridgeLiquidity'; + +describe('useBridgeLiquidity helpers', () => { + it('fetches liquidity snapshot with normalized response shape', async () => { + const monitor = new BridgeLiquidityMonitor({ + providers: [ + { + name: 'hop', + fetchLiquidity: async () => ({ + bridgeName: 'hop', + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + availableAmount: 333, + timestamp: new Date('2026-01-01T00:00:00.000Z'), + }), + }, + ], + }); + + const result = await fetchBridgeLiquiditySnapshot(monitor, { + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + }); + + expect(result.usedFallback).toBe(false); + expect(result.errors).toHaveLength(0); + expect(result.liquidity[0].availableAmount).toBe(333); + }); +}); diff --git a/libs/ui-components/src/hooks/useBridgeLiquidity.ts b/libs/ui-components/src/hooks/useBridgeLiquidity.ts new file mode 100644 index 0000000..75d10bb --- /dev/null +++ b/libs/ui-components/src/hooks/useBridgeLiquidity.ts @@ -0,0 +1,94 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { BridgeLiquidityMonitor } from '../liquidity/monitor'; +import type { + BridgeLiquidity, + BridgeLiquidityMonitorConfig, + BridgeLiquidityQuery, + LiquidityProviderError, +} from '../liquidity/types'; + +export interface UseBridgeLiquidityOptions extends BridgeLiquidityQuery { + refreshIntervalMs?: number; + config?: BridgeLiquidityMonitorConfig; +} + +export interface UseBridgeLiquidityResult { + liquidity: BridgeLiquidity[]; + loading: boolean; + errors: LiquidityProviderError[]; + usedFallback: boolean; + refreshLiquidity: () => Promise; +} + +export async function fetchBridgeLiquiditySnapshot( + monitor: BridgeLiquidityMonitor, + query: BridgeLiquidityQuery, +): Promise<{ + liquidity: BridgeLiquidity[]; + errors: LiquidityProviderError[]; + usedFallback: boolean; +}> { + return monitor.getLiquidity(query); +} + +export function useBridgeLiquidity(options: UseBridgeLiquidityOptions): UseBridgeLiquidityResult { + const [liquidity, setLiquidity] = useState([]); + const [errors, setErrors] = useState([]); + const [loading, setLoading] = useState(false); + const [usedFallback, setUsedFallback] = useState(false); + + const monitor = useMemo(() => new BridgeLiquidityMonitor(options.config), [options.config]); + + const refreshLiquidity = useCallback(async () => { + if (typeof window === 'undefined') { + setLiquidity([]); + setErrors([]); + setUsedFallback(false); + return; + } + + setLoading(true); + try { + const result = await fetchBridgeLiquiditySnapshot(monitor, { + token: options.token, + sourceChain: options.sourceChain, + destinationChain: options.destinationChain, + bridgeName: options.bridgeName, + }); + + setLiquidity(result.liquidity); + setErrors(result.errors); + setUsedFallback(result.usedFallback); + } finally { + setLoading(false); + } + }, [monitor, options.bridgeName, options.destinationChain, options.sourceChain, options.token]); + + useEffect(() => { + void refreshLiquidity(); + }, [refreshLiquidity]); + + useEffect(() => { + if (!options.refreshIntervalMs || options.refreshIntervalMs <= 0) { + return; + } + + const interval = window.setInterval(() => { + void refreshLiquidity(); + }, options.refreshIntervalMs); + + return () => { + window.clearInterval(interval); + }; + }, [options.refreshIntervalMs, refreshLiquidity]); + + return { + liquidity, + loading, + errors, + usedFallback, + refreshLiquidity, + }; +} diff --git a/libs/ui-components/src/hooks/useTransactionHistory.ts b/libs/ui-components/src/hooks/useTransactionHistory.ts new file mode 100644 index 0000000..65cd551 --- /dev/null +++ b/libs/ui-components/src/hooks/useTransactionHistory.ts @@ -0,0 +1,59 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { filterTransactions, sortTransactions } from '../transaction-history/filter'; +import { TransactionHistoryStorage } from '../transaction-history/storage'; +import type { + BridgeTransaction, + TransactionHistoryConfig, + UseTransactionHistoryOptions, +} from '../transaction-history/types'; + +export interface UseTransactionHistoryHookResult { + transactions: BridgeTransaction[]; + loading: boolean; + refresh: () => Promise; +} + +export function useTransactionHistory( + account: string, + options?: UseTransactionHistoryOptions, + config?: TransactionHistoryConfig, +): UseTransactionHistoryHookResult { + const [allTransactions, setAllTransactions] = useState([]); + const [loading, setLoading] = useState(false); + + const storage = useMemo(() => new TransactionHistoryStorage(config), [config]); + + const refresh = useCallback(async () => { + if (!account || typeof window === 'undefined') { + setAllTransactions([]); + return; + } + + setLoading(true); + try { + const data = await storage.getTransactionsByAccount(account, { + includeBackend: options?.includeBackend, + }); + setAllTransactions(data); + } finally { + setLoading(false); + } + }, [account, options?.includeBackend, storage]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const transactions = useMemo(() => { + const filtered = filterTransactions(allTransactions, options?.filter); + return sortTransactions(filtered, options?.sortOrder ?? 'desc'); + }, [allTransactions, options?.filter, options?.sortOrder]); + + return { + transactions, + loading, + refresh, + }; +} diff --git a/libs/ui-components/src/index.ts b/libs/ui-components/src/index.ts index e65af4e..7c84a8e 100644 --- a/libs/ui-components/src/index.ts +++ b/libs/ui-components/src/index.ts @@ -33,12 +33,40 @@ export type { // Components export { TransactionHeartbeat, + BridgeStatus, TransactionProvider, useTransaction, } from './components/TransactionHeartbeat'; +export { BridgeHistory } from './components/BridgeHistory'; +export { BridgeCompare } from './components/BridgeCompare'; export type { TransactionState } from './components/TransactionHeartbeat'; +export type { BridgeHistoryProps } from './components/BridgeHistory'; // Hooks export { useFeeSlippageBenchmark } from './hooks/useFeeSlippageBenchmark'; +export { useTransactionHistory } from './hooks/useTransactionHistory'; +export { useBridgeLiquidity } from './hooks/useBridgeLiquidity'; export type { FeeSlippageBenchmarkHookProps, FeeSlippageBenchmarkHookReturn } from './hooks/useFeeSlippageBenchmark'; + +// Transaction history +export { createHttpTransactionHistoryBackend } from './transaction-history/storage'; +export type { + BridgeTransaction, + BridgeTransactionStatus, + TransactionHistoryBackend, + TransactionHistoryConfig, + TransactionHistoryFilter, + UseTransactionHistoryOptions, +} from './transaction-history/types'; + +// Liquidity +export { BridgeLiquidityMonitor, prioritizeRoutesByLiquidity } from './liquidity/monitor'; +export type { + BridgeLiquidity, + BridgeLiquidityProvider, + BridgeLiquidityQuery, + LiquidityProviderError, + BridgeLiquidityMonitorConfig, +} from './liquidity/types'; +export type { UseBridgeLiquidityOptions, UseBridgeLiquidityResult } from './hooks/useBridgeLiquidity'; diff --git a/libs/ui-components/src/liquidity/__tests__/monitor.spec.ts b/libs/ui-components/src/liquidity/__tests__/monitor.spec.ts new file mode 100644 index 0000000..178ddce --- /dev/null +++ b/libs/ui-components/src/liquidity/__tests__/monitor.spec.ts @@ -0,0 +1,132 @@ +import { BridgeLiquidityMonitor, prioritizeRoutesByLiquidity } from '../monitor'; +import type { BridgeLiquidityProvider } from '../types'; +import type { BridgeRoute } from '../../../../bridge-core/src/types'; + +const makeStorage = () => { + const store = new Map(); + return { + getItem: jest.fn((key: string) => store.get(key) ?? null), + setItem: jest.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: jest.fn((key: string) => { + store.delete(key); + }), + }; +}; + +describe('BridgeLiquidityMonitor', () => { + beforeEach(() => { + const localStorage = makeStorage(); + Object.defineProperty(global, 'window', { + configurable: true, + writable: true, + value: { localStorage }, + }); + }); + + afterEach(() => { + delete (global as { window?: unknown }).window; + }); + + it('returns provider liquidity data when available', async () => { + const providers: BridgeLiquidityProvider[] = [ + { + name: 'hop', + fetchLiquidity: async () => ({ + bridgeName: 'hop', + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + availableAmount: 1200, + timestamp: new Date('2026-01-01T00:00:00.000Z'), + }), + }, + ]; + + const monitor = new BridgeLiquidityMonitor({ providers, storageKey: 'liq-test' }); + const result = await monitor.getLiquidity({ + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + }); + + expect(result.liquidity).toHaveLength(1); + expect(result.errors).toHaveLength(0); + expect(result.usedFallback).toBe(false); + }); + + it('falls back to cached liquidity when providers fail', async () => { + const healthyProviders: BridgeLiquidityProvider[] = [ + { + name: 'hop', + fetchLiquidity: async () => ({ + bridgeName: 'hop', + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + availableAmount: 1200, + timestamp: new Date('2026-01-01T00:00:00.000Z'), + }), + }, + ]; + + const failingProviders: BridgeLiquidityProvider[] = [ + { + name: 'hop', + fetchLiquidity: async () => { + throw new Error('unavailable'); + }, + }, + ]; + + const seed = new BridgeLiquidityMonitor({ providers: healthyProviders, storageKey: 'liq-test' }); + await seed.getLiquidity({ + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + }); + + const monitor = new BridgeLiquidityMonitor({ providers: failingProviders, storageKey: 'liq-test' }); + const result = await monitor.getLiquidity({ + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + }); + + expect(result.usedFallback).toBe(true); + expect(result.liquidity).toHaveLength(1); + expect(result.errors).toHaveLength(1); + }); +}); + +describe('prioritizeRoutesByLiquidity', () => { + it('orders routes by available liquidity descending', () => { + const routes = [ + { provider: 'hop', id: '1' }, + { provider: 'layerzero', id: '2' }, + ] as BridgeRoute[]; + + const ordered = prioritizeRoutesByLiquidity(routes, [ + { + bridgeName: 'hop', + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + availableAmount: 100, + timestamp: new Date(), + }, + { + bridgeName: 'layerzero', + token: 'USDC', + sourceChain: 'ethereum', + destinationChain: 'stellar', + availableAmount: 1000, + timestamp: new Date(), + }, + ]); + + expect(ordered[0].provider).toBe('layerzero'); + expect(ordered[1].provider).toBe('hop'); + }); +}); diff --git a/libs/ui-components/src/liquidity/index.ts b/libs/ui-components/src/liquidity/index.ts new file mode 100644 index 0000000..42e65c1 --- /dev/null +++ b/libs/ui-components/src/liquidity/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './monitor'; diff --git a/libs/ui-components/src/liquidity/monitor.ts b/libs/ui-components/src/liquidity/monitor.ts new file mode 100644 index 0000000..7ef4106 --- /dev/null +++ b/libs/ui-components/src/liquidity/monitor.ts @@ -0,0 +1,202 @@ +import type { BridgeRoute } from '../../../bridge-core/src/types'; +import type { + BridgeLiquidity, + BridgeLiquidityMonitorConfig, + BridgeLiquidityProvider, + BridgeLiquidityQuery, + LiquidityProviderError, +} from './types'; + +const DEFAULT_STORAGE_KEY = 'bridgewise_liquidity_cache_v1'; + +type StoredLiquidity = Omit & { timestamp: string }; + +const defaultProviders: BridgeLiquidityProvider[] = [ + { + name: 'hop', + fetchLiquidity: async (query) => ({ + bridgeName: 'hop', + token: query.token, + sourceChain: query.sourceChain, + destinationChain: query.destinationChain, + availableAmount: 75000, + timestamp: new Date(), + }), + }, + { + name: 'layerzero', + fetchLiquidity: async (query) => ({ + bridgeName: 'layerzero', + token: query.token, + sourceChain: query.sourceChain, + destinationChain: query.destinationChain, + availableAmount: 100000, + timestamp: new Date(), + }), + }, + { + name: 'stellar', + fetchLiquidity: async (query) => ({ + bridgeName: 'stellar', + token: query.token, + sourceChain: query.sourceChain, + destinationChain: query.destinationChain, + availableAmount: 50000, + timestamp: new Date(), + }), + }, +]; + +interface StorageLike { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; +} + +function getStorage(): StorageLike | null { + const globalWithWindow = globalThis as { + window?: { localStorage?: StorageLike }; + localStorage?: StorageLike; + }; + + try { + return globalWithWindow.window?.localStorage ?? globalWithWindow.localStorage ?? null; + } catch { + return null; + } +} + +function toStored(item: BridgeLiquidity): StoredLiquidity { + return { + ...item, + timestamp: item.timestamp.toISOString(), + }; +} + +function fromStored(item: StoredLiquidity): BridgeLiquidity { + const timestamp = new Date(item.timestamp); + return { + ...item, + timestamp: Number.isNaN(timestamp.getTime()) ? new Date() : timestamp, + }; +} + +function normalize(item: Partial, query: BridgeLiquidityQuery, bridgeName: string): BridgeLiquidity { + return { + bridgeName: item.bridgeName ?? bridgeName, + token: item.token ?? query.token, + sourceChain: item.sourceChain ?? query.sourceChain, + destinationChain: item.destinationChain ?? query.destinationChain, + availableAmount: + typeof item.availableAmount === 'number' && Number.isFinite(item.availableAmount) + ? item.availableAmount + : 0, + timestamp: item.timestamp ?? new Date(), + }; +} + +export class BridgeLiquidityMonitor { + private readonly providers: BridgeLiquidityProvider[]; + + private readonly storageKey: string; + + constructor(config?: BridgeLiquidityMonitorConfig) { + this.providers = config?.providers?.length ? config.providers : defaultProviders; + this.storageKey = config?.storageKey ?? DEFAULT_STORAGE_KEY; + } + + async getLiquidity(query: BridgeLiquidityQuery): Promise<{ + liquidity: BridgeLiquidity[]; + errors: LiquidityProviderError[]; + usedFallback: boolean; + }> { + const providers = query.bridgeName + ? this.providers.filter((provider) => provider.name.toLowerCase() === query.bridgeName?.toLowerCase()) + : this.providers; + + const liquidity: BridgeLiquidity[] = []; + const errors: LiquidityProviderError[] = []; + + for (const provider of providers) { + try { + const value = await provider.fetchLiquidity(query); + liquidity.push(normalize(value, query, provider.name)); + } catch (error) { + errors.push({ + bridgeName: provider.name, + message: error instanceof Error ? error.message : 'Failed to fetch liquidity', + }); + } + } + + if (liquidity.length > 0) { + this.saveToCache(query, liquidity); + return { liquidity, errors, usedFallback: false }; + } + + const fallback = this.getFromCache(query); + return { + liquidity: fallback, + errors, + usedFallback: fallback.length > 0, + }; + } + + private getFromCache(query: BridgeLiquidityQuery): BridgeLiquidity[] { + const storage = getStorage(); + if (!storage) { + return []; + } + + try { + const raw = storage.getItem(this.storageKey); + if (!raw) { + return []; + } + + const all = JSON.parse(raw) as Record; + const key = this.buildKey(query); + const entries = all[key] ?? []; + return entries.map((item) => fromStored(item)); + } catch { + return []; + } + } + + private saveToCache(query: BridgeLiquidityQuery, liquidity: BridgeLiquidity[]): void { + const storage = getStorage(); + if (!storage) { + return; + } + + try { + const raw = storage.getItem(this.storageKey); + const all = raw ? (JSON.parse(raw) as Record) : {}; + all[this.buildKey(query)] = liquidity.map((item) => toStored(item)); + storage.setItem(this.storageKey, JSON.stringify(all)); + } catch { + // no-op + } + } + + private buildKey(query: BridgeLiquidityQuery): string { + return [query.token, query.sourceChain, query.destinationChain, query.bridgeName ?? '*'] + .map((value) => value.toLowerCase()) + .join(':'); + } +} + +export function prioritizeRoutesByLiquidity( + routes: BridgeRoute[], + liquidity: BridgeLiquidity[], +): BridgeRoute[] { + const byBridge = new Map(); + for (const item of liquidity) { + byBridge.set(item.bridgeName.toLowerCase(), item.availableAmount); + } + + return [...routes].sort((left, right) => { + const leftLiquidity = byBridge.get(left.provider.toLowerCase()) ?? 0; + const rightLiquidity = byBridge.get(right.provider.toLowerCase()) ?? 0; + return rightLiquidity - leftLiquidity; + }); +} diff --git a/libs/ui-components/src/liquidity/types.ts b/libs/ui-components/src/liquidity/types.ts new file mode 100644 index 0000000..62601b2 --- /dev/null +++ b/libs/ui-components/src/liquidity/types.ts @@ -0,0 +1,31 @@ +export interface BridgeLiquidity { + bridgeName: string; + token: string; + sourceChain: string; + destinationChain: string; + availableAmount: number; + timestamp: Date; +} + +export interface BridgeLiquidityQuery { + token: string; + sourceChain: string; + destinationChain: string; + bridgeName?: string; +} + +export interface LiquidityProviderError { + bridgeName: string; + message: string; +} + +export interface BridgeLiquidityProvider { + name: string; + fetchLiquidity: (query: BridgeLiquidityQuery) => Promise; +} + +export interface BridgeLiquidityMonitorConfig { + providers?: BridgeLiquidityProvider[]; + storageKey?: string; + minRefreshMs?: number; +} diff --git a/libs/ui-components/src/transaction-history/__tests__/filter.spec.ts b/libs/ui-components/src/transaction-history/__tests__/filter.spec.ts new file mode 100644 index 0000000..9b6601f --- /dev/null +++ b/libs/ui-components/src/transaction-history/__tests__/filter.spec.ts @@ -0,0 +1,64 @@ +import { filterTransactions, sortTransactions } from '../filter'; +import type { BridgeTransaction } from '../types'; + +const transactions: BridgeTransaction[] = [ + { + txHash: '0x1', + bridgeName: 'layerzero', + sourceChain: 'ethereum', + destinationChain: 'stellar', + sourceToken: 'USDC', + destinationToken: 'USDC', + amount: 100, + fee: 1, + slippagePercent: 0.5, + status: 'confirmed', + timestamp: new Date('2026-01-02T00:00:00.000Z'), + account: '0xuser', + }, + { + txHash: '0x2', + bridgeName: 'hop', + sourceChain: 'polygon', + destinationChain: 'arbitrum', + sourceToken: 'USDT', + destinationToken: 'USDT', + amount: 42, + fee: 0.2, + slippagePercent: 0.1, + status: 'failed', + timestamp: new Date('2026-01-01T00:00:00.000Z'), + account: '0xuser', + }, +]; + +describe('transaction history filtering', () => { + it('filters by chain and status', () => { + const result = filterTransactions(transactions, { + chain: 'ethereum', + status: 'confirmed', + }); + + expect(result).toHaveLength(1); + expect(result[0].txHash).toBe('0x1'); + }); + + it('filters by bridge name', () => { + const result = filterTransactions(transactions, { + bridgeName: 'hop', + }); + + expect(result).toHaveLength(1); + expect(result[0].txHash).toBe('0x2'); + }); + + it('sorts by timestamp descending by default', () => { + const result = sortTransactions(transactions); + expect(result[0].txHash).toBe('0x1'); + }); + + it('sorts by timestamp ascending', () => { + const result = sortTransactions(transactions, 'asc'); + expect(result[0].txHash).toBe('0x2'); + }); +}); diff --git a/libs/ui-components/src/transaction-history/__tests__/storage.spec.ts b/libs/ui-components/src/transaction-history/__tests__/storage.spec.ts new file mode 100644 index 0000000..84366cc --- /dev/null +++ b/libs/ui-components/src/transaction-history/__tests__/storage.spec.ts @@ -0,0 +1,97 @@ +import { TransactionHistoryStorage } from '../storage'; +import type { BridgeTransaction } from '../types'; + +const createTransaction = (overrides?: Partial): BridgeTransaction => ({ + txHash: '0xabc', + bridgeName: 'layerzero', + sourceChain: 'ethereum', + destinationChain: 'stellar', + sourceToken: 'USDC', + destinationToken: 'USDC', + amount: 100, + fee: 1, + slippagePercent: 0.5, + status: 'confirmed', + timestamp: new Date('2026-01-01T00:00:00.000Z'), + account: '0xuser-1', + ...overrides, +}); + +describe('TransactionHistoryStorage', () => { + const makeLocalStorage = () => { + const store = new Map(); + + return { + getItem: jest.fn((key: string) => store.get(key) ?? null), + setItem: jest.fn((key: string, value: string) => { + store.set(key, value); + }), + removeItem: jest.fn((key: string) => { + store.delete(key); + }), + clear: jest.fn(() => { + store.clear(); + }), + }; + }; + + beforeEach(() => { + const localStorage = makeLocalStorage(); + Object.defineProperty(global, 'window', { + configurable: true, + writable: true, + value: { localStorage }, + }); + }); + + afterEach(() => { + delete (global as { window?: unknown }).window; + }); + + it('stores and retrieves transactions by account', async () => { + const storage = new TransactionHistoryStorage({ storageKey: 'test-history' }); + + await storage.upsertTransaction(createTransaction({ txHash: '0x1', account: '0xalice' })); + await storage.upsertTransaction(createTransaction({ txHash: '0x2', account: '0xbob' })); + await storage.upsertTransaction(createTransaction({ txHash: '0x3', account: '0xalice' })); + + const aliceTransactions = await storage.getTransactionsByAccount('0xalice'); + + expect(aliceTransactions).toHaveLength(2); + expect(aliceTransactions.every((transaction) => transaction.account === '0xalice')).toBe(true); + }); + + it('updates existing transaction when tx hash already exists', async () => { + const storage = new TransactionHistoryStorage({ storageKey: 'test-history' }); + + await storage.upsertTransaction(createTransaction({ txHash: '0x1', status: 'pending' })); + await storage.upsertTransaction(createTransaction({ txHash: '0x1', status: 'confirmed' })); + + const transactions = await storage.getTransactionsByAccount('0xuser-1'); + + expect(transactions).toHaveLength(1); + expect(transactions[0].status).toBe('confirmed'); + }); + + it('falls back to local data when backend fails', async () => { + const backend = { + saveTransaction: jest.fn(async () => undefined), + getTransactionsByAccount: jest.fn(async () => { + throw new Error('backend unavailable'); + }), + }; + + const storage = new TransactionHistoryStorage({ + storageKey: 'test-history', + backend, + }); + + await storage.upsertTransaction(createTransaction({ txHash: '0xlocal' })); + const transactions = await storage.getTransactionsByAccount('0xuser-1', { + includeBackend: true, + }); + + expect(transactions).toHaveLength(1); + expect(transactions[0].txHash).toBe('0xlocal'); + }); +}); diff --git a/libs/ui-components/src/transaction-history/filter.ts b/libs/ui-components/src/transaction-history/filter.ts new file mode 100644 index 0000000..0bdbac9 --- /dev/null +++ b/libs/ui-components/src/transaction-history/filter.ts @@ -0,0 +1,63 @@ +import type { BridgeTransaction, TransactionHistoryFilter } from './types'; + +export function filterTransactions( + transactions: BridgeTransaction[], + filter?: TransactionHistoryFilter, +): BridgeTransaction[] { + if (!filter) { + return transactions; + } + + return transactions.filter((transaction) => { + if (filter.chain) { + const normalizedChain = filter.chain.toLowerCase(); + const sourceMatches = transaction.sourceChain.toLowerCase() === normalizedChain; + const destinationMatches = transaction.destinationChain.toLowerCase() === normalizedChain; + + if (!sourceMatches && !destinationMatches) { + return false; + } + } + + if ( + filter.bridgeName && + transaction.bridgeName.toLowerCase() !== filter.bridgeName.toLowerCase() + ) { + return false; + } + + if (filter.status && transaction.status !== filter.status) { + return false; + } + + if (filter.startDate && transaction.timestamp < filter.startDate) { + return false; + } + + if (filter.endDate && transaction.timestamp > filter.endDate) { + return false; + } + + return true; + }); +} + +export function sortTransactions( + transactions: BridgeTransaction[], + sortOrder: 'asc' | 'desc' = 'desc', +): BridgeTransaction[] { + const copy = [...transactions]; + + copy.sort((a, b) => { + const left = a.timestamp.getTime(); + const right = b.timestamp.getTime(); + + if (sortOrder === 'asc') { + return left - right; + } + + return right - left; + }); + + return copy; +} diff --git a/libs/ui-components/src/transaction-history/index.ts b/libs/ui-components/src/transaction-history/index.ts new file mode 100644 index 0000000..1aeb918 --- /dev/null +++ b/libs/ui-components/src/transaction-history/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './storage'; +export * from './filter'; diff --git a/libs/ui-components/src/transaction-history/storage.ts b/libs/ui-components/src/transaction-history/storage.ts new file mode 100644 index 0000000..8be4b71 --- /dev/null +++ b/libs/ui-components/src/transaction-history/storage.ts @@ -0,0 +1,236 @@ +import type { + BridgeTransaction, + TransactionHistoryBackend, + TransactionHistoryConfig, +} from './types'; + +const DEFAULT_STORAGE_KEY = 'bridgewise_transaction_history_v1'; +const DEFAULT_MAX_TRANSACTIONS_PER_ACCOUNT = 200; + +interface StorageLike { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; +} + +type StoredBridgeTransaction = Omit & { timestamp: string }; + +function getStorage(): StorageLike | null { + const globalWithWindow = globalThis as { + window?: { localStorage?: StorageLike }; + localStorage?: StorageLike; + }; + + try { + return globalWithWindow.window?.localStorage ?? globalWithWindow.localStorage ?? null; + } catch { + return null; + } +} + +function toStoredTransaction(transaction: BridgeTransaction): StoredBridgeTransaction { + return { + ...transaction, + timestamp: transaction.timestamp.toISOString(), + }; +} + +function fromStoredTransaction( + transaction: StoredBridgeTransaction | Partial, +): BridgeTransaction { + const now = new Date(); + const parsedTimestamp = transaction.timestamp ? new Date(transaction.timestamp) : now; + + return { + txHash: transaction.txHash ?? `unknown-${now.getTime()}`, + bridgeName: transaction.bridgeName ?? 'unknown', + sourceChain: transaction.sourceChain ?? 'unknown', + destinationChain: transaction.destinationChain ?? 'unknown', + sourceToken: transaction.sourceToken ?? 'unknown', + destinationToken: transaction.destinationToken ?? 'unknown', + amount: typeof transaction.amount === 'number' && Number.isFinite(transaction.amount) + ? transaction.amount + : 0, + fee: typeof transaction.fee === 'number' && Number.isFinite(transaction.fee) ? transaction.fee : 0, + slippagePercent: + typeof transaction.slippagePercent === 'number' && Number.isFinite(transaction.slippagePercent) + ? transaction.slippagePercent + : 0, + status: + transaction.status === 'pending' || transaction.status === 'confirmed' || transaction.status === 'failed' + ? transaction.status + : 'pending', + timestamp: Number.isNaN(parsedTimestamp.getTime()) ? now : parsedTimestamp, + account: transaction.account ?? 'unknown', + }; +} + +export class TransactionHistoryStorage { + private backend?: TransactionHistoryBackend; + + private storageKey: string; + + private maxTransactionsPerAccount: number; + + constructor(config?: TransactionHistoryConfig) { + this.backend = config?.backend; + this.storageKey = config?.storageKey ?? DEFAULT_STORAGE_KEY; + this.maxTransactionsPerAccount = + config?.maxTransactionsPerAccount ?? DEFAULT_MAX_TRANSACTIONS_PER_ACCOUNT; + } + + async upsertTransaction(transaction: BridgeTransaction): Promise { + try { + const transactions = this.getLocalTransactions(); + const next = this.upsert(transactions, transaction); + this.saveLocalTransactions(next); + } catch { + // No-op fallback: local history unavailable + } + + if (this.backend) { + try { + await this.backend.saveTransaction(transaction); + } catch { + // No-op fallback: backend history unavailable + } + } + } + + async getTransactionsByAccount( + account: string, + options?: { includeBackend?: boolean }, + ): Promise { + const local = this.getLocalTransactions().filter((tx) => tx.account === account); + + if (!options?.includeBackend || !this.backend) { + return local; + } + + try { + const backendTransactions = await this.backend.getTransactionsByAccount(account); + return this.merge(local, backendTransactions); + } catch { + return local; + } + } + + private merge( + current: BridgeTransaction[], + incoming: BridgeTransaction[], + ): BridgeTransaction[] { + let merged = [...current]; + for (const transaction of incoming) { + merged = this.upsert(merged, transaction); + } + return merged; + } + + private upsert( + transactions: BridgeTransaction[], + transaction: BridgeTransaction, + ): BridgeTransaction[] { + const key = `${transaction.account}:${transaction.txHash}`; + const mapped = new Map(); + + for (const item of transactions) { + mapped.set(`${item.account}:${item.txHash}`, item); + } + + mapped.set(key, transaction); + + const perAccount = new Map(); + for (const value of mapped.values()) { + const list = perAccount.get(value.account) ?? []; + list.push(value); + perAccount.set(value.account, list); + } + + const flattened: BridgeTransaction[] = []; + for (const list of perAccount.values()) { + list.sort((left, right) => right.timestamp.getTime() - left.timestamp.getTime()); + flattened.push(...list.slice(0, this.maxTransactionsPerAccount)); + } + + return flattened; + } + + private getLocalTransactions(): BridgeTransaction[] { + const storage = getStorage(); + if (!storage) { + return []; + } + + try { + const raw = storage.getItem(this.storageKey); + if (!raw) { + return []; + } + + const parsed = JSON.parse(raw) as Array>; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.map((transaction) => fromStoredTransaction(transaction)); + } catch { + return []; + } + } + + private saveLocalTransactions(transactions: BridgeTransaction[]): void { + const storage = getStorage(); + if (!storage) { + return; + } + + const serialized = JSON.stringify(transactions.map((tx) => toStoredTransaction(tx))); + storage.setItem(this.storageKey, serialized); + } +} + +export interface HttpTransactionHistoryBackendConfig { + baseUrl: string; + fetcher?: typeof fetch; + headers?: Record; +} + +export function createHttpTransactionHistoryBackend( + config: HttpTransactionHistoryBackendConfig, +): TransactionHistoryBackend { + const fetcher = config.fetcher ?? fetch; + + return { + saveTransaction: async (transaction) => { + await fetcher(`${config.baseUrl}/transactions/history`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...config.headers, + }, + body: JSON.stringify(transaction), + }); + }, + getTransactionsByAccount: async (account) => { + const response = await fetcher( + `${config.baseUrl}/transactions/history?account=${encodeURIComponent(account)}`, + { + method: 'GET', + headers: { + ...config.headers, + }, + }, + ); + + if (!response.ok) { + throw new Error('Failed to load transaction history'); + } + + const payload = (await response.json()) as Array>; + if (!Array.isArray(payload)) { + return []; + } + + return payload.map((transaction) => fromStoredTransaction(transaction)); + }, + }; +} diff --git a/libs/ui-components/src/transaction-history/types.ts b/libs/ui-components/src/transaction-history/types.ts new file mode 100644 index 0000000..6b5e666 --- /dev/null +++ b/libs/ui-components/src/transaction-history/types.ts @@ -0,0 +1,42 @@ +export type BridgeTransactionStatus = 'pending' | 'confirmed' | 'failed'; + +export interface BridgeTransaction { + txHash: string; + bridgeName: string; + sourceChain: string; + destinationChain: string; + sourceToken: string; + destinationToken: string; + amount: number; + fee: number; + slippagePercent: number; + status: BridgeTransactionStatus; + timestamp: Date; + account: string; +} + +export interface TransactionHistoryBackend { + saveTransaction: (transaction: BridgeTransaction) => Promise; + getTransactionsByAccount: (account: string) => Promise; +} + +export interface TransactionHistoryFilter { + chain?: string; + bridgeName?: string; + status?: BridgeTransactionStatus; + startDate?: Date; + endDate?: Date; +} + +export interface UseTransactionHistoryOptions { + filter?: TransactionHistoryFilter; + sortBy?: 'timestamp'; + sortOrder?: 'asc' | 'desc'; + includeBackend?: boolean; +} + +export interface TransactionHistoryConfig { + backend?: TransactionHistoryBackend; + storageKey?: string; + maxTransactionsPerAccount?: number; +} diff --git a/package-lock.json b/package-lock.json index 26c9a81..c0bd0fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@nestjs/event-emitter": "^3.0.1", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", - "@nestjs/swagger": "^7.1.17", + "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", "@stellar/freighter-api": "^6.0.1", @@ -240,6 +240,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -750,7 +751,7 @@ "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "0.3.9" @@ -763,7 +764,7 @@ "version": "0.3.9", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", @@ -2025,7 +2026,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -2046,7 +2047,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -2070,9 +2071,9 @@ } }, "node_modules/@microsoft/tsdoc": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.15.1.tgz", - "integrity": "sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { @@ -2149,6 +2150,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.12.tgz", "integrity": "sha512-v6U3O01YohHO+IE3EIFXuRuu3VJILWzyMmSYZXpyBbnp0hk0mFyHxK2w3dF4I5WnbwiRbWlEXdeXFvPQ7qaZzw==", "license": "MIT", + "peer": true, "dependencies": { "file-type": "21.3.0", "iterare": "1.2.1", @@ -2196,6 +2198,7 @@ "integrity": "sha512-97DzTYMf5RtGAVvX1cjwpKRiCUpkeQ9CCzSAenqkAhOmNVVFaApbhuw+xrDt13rsCa2hHVOYPrV4dBgOYMJjsA==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "@nuxt/opencollective": "0.4.1", "fast-safe-stringify": "2.1.1", @@ -2269,6 +2272,7 @@ "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.12.tgz", "integrity": "sha512-GYK/vHI0SGz5m8mxr7v3Urx8b9t78Cf/dj5aJMZlGd9/1D9OI1hAl00BaphjEXINUJ/BQLxIlF2zUjrYsd6enQ==", "license": "MIT", + "peer": true, "dependencies": { "cors": "2.8.5", "express": "5.2.1", @@ -2384,22 +2388,22 @@ } }, "node_modules/@nestjs/swagger": { - "version": "7.4.2", - "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-7.4.2.tgz", - "integrity": "sha512-Mu6TEn1M/owIvAx2B4DUQObQXqo2028R2s9rSZ/hJEgBK95+doTwS0DjmVA2wTeZTyVtXOoN7CsoM5pONBzvKQ==", + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz", + "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==", "license": "MIT", "dependencies": { - "@microsoft/tsdoc": "^0.15.0", - "@nestjs/mapped-types": "2.0.5", - "js-yaml": "4.1.0", - "lodash": "4.17.21", - "path-to-regexp": "3.3.0", - "swagger-ui-dist": "5.17.14" + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.0", + "js-yaml": "4.1.1", + "lodash": "4.17.23", + "path-to-regexp": "8.3.0", + "swagger-ui-dist": "5.31.0" }, "peerDependencies": { - "@fastify/static": "^6.0.0 || ^7.0.0", - "@nestjs/common": "^9.0.0 || ^10.0.0", - "@nestjs/core": "^9.0.0 || ^10.0.0", + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", "class-transformer": "*", "class-validator": "*", "reflect-metadata": "^0.1.12 || ^0.2.0" @@ -2416,42 +2420,10 @@ } } }, - "node_modules/@nestjs/swagger/node_modules/@nestjs/mapped-types": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.0.5.tgz", - "integrity": "sha512-bSJv4pd6EY99NX9CjBIyn4TVDoSit82DUZlL4I3bqNfy5Gt+gXTa86i3I/i0iIV9P4hntcGM5GyO+FhZAhxtyg==", - "license": "MIT", - "peerDependencies": { - "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", - "class-transformer": "^0.4.0 || ^0.5.0", - "class-validator": "^0.13.0 || ^0.14.0", - "reflect-metadata": "^0.1.12 || ^0.2.0" - }, - "peerDependenciesMeta": { - "class-transformer": { - "optional": true - }, - "class-validator": { - "optional": true - } - } - }, - "node_modules/@nestjs/swagger/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "license": "MIT", - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@nestjs/swagger/node_modules/path-to-regexp": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-3.3.0.tgz", - "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "node_modules/@nestjs/swagger/node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/@nestjs/testing": { @@ -2568,6 +2540,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -2664,28 +2643,28 @@ "version": "1.0.12", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node12": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node14": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tsconfig/node16": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@tybys/wasm-util": { @@ -2778,6 +2757,7 @@ "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -2889,8 +2869,9 @@ "version": "22.19.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz", "integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2915,6 +2896,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -3049,6 +3031,7 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3754,8 +3737,9 @@ "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3790,7 +3774,7 @@ "version": "8.3.4", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "acorn": "^8.11.0" @@ -3805,6 +3789,7 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -3973,7 +3958,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/argparse": { @@ -4022,6 +4007,7 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.4.tgz", "integrity": "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==", "license": "MIT", + "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -4330,6 +4316,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4545,6 +4532,7 @@ "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readdirp": "^4.0.1" }, @@ -4592,13 +4580,15 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/class-validator": { "version": "0.14.3", "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.3.tgz", "integrity": "sha512-rXXekcjofVN1LTOSw+u4u9WXVEUvNBVjORW154q/IdmYWy1nMbOU9aNtZB0t8m+FJQ9q91jlr2f9CwwUFdFMRA==", "license": "MIT", + "peer": true, "dependencies": { "@types/validator": "^13.15.3", "libphonenumber-js": "^1.11.1", @@ -4917,7 +4907,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/cross-spawn": { @@ -5068,7 +5058,7 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -5272,6 +5262,7 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5332,6 +5323,7 @@ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -5605,6 +5597,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -6707,6 +6700,7 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7468,7 +7462,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7724,7 +7717,7 @@ "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/makeerror": { @@ -8364,6 +8357,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.17.2.tgz", "integrity": "sha512-vjbKdiBJRqzcYw1fNU5KuHyYvdJ1qpcQg1CeBrHFqV1pWgHeVR6j/+kX0E1AAXfyuLUGY1ICrN2ELKA/z2HWzw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.10.1", "pg-pool": "^3.11.0", @@ -8621,6 +8615,7 @@ "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -8771,6 +8766,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8826,7 +8822,8 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/require-addon": { "version": "1.2.0", @@ -8935,6 +8932,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -9524,10 +9522,13 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.17.14", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", - "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==", - "license": "Apache-2.0" + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } }, "node_modules/swagger-ui-express": { "version": "5.0.1", @@ -9644,6 +9645,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10002,8 +10004,9 @@ "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10170,6 +10173,7 @@ "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.28.tgz", "integrity": "sha512-6GH7wXhtfq2D33ZuRXYwIsl/qM5685WZcODZb7noOOcRMteM9KF2x2ap3H0EBjnSV0VO4gNAfJT5Ukp0PkOlvg==", "license": "MIT", + "peer": true, "dependencies": { "@sqltools/formatter": "^1.2.5", "ansis": "^4.2.0", @@ -10362,8 +10366,9 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10438,7 +10443,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/universalify": { @@ -10565,7 +10570,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/v8-to-istanbul": { @@ -10641,6 +10646,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -10710,6 +10716,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -10988,7 +10995,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index 1e0458f..d1dce8f 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "@nestjs/event-emitter": "^3.0.1", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", - "@nestjs/swagger": "^7.1.17", + "@nestjs/swagger": "^11.2.6", "@nestjs/throttler": "^6.5.0", "@nestjs/typeorm": "^11.0.0", "@stellar/freighter-api": "^6.0.1",