From fd6876fe99e160767f9e9e147be0085bc82f4542 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 15:26:45 +0800 Subject: [PATCH 01/11] chore: default usePublicAllocator to true, uncomment settings toggle - Everyone benefits from liquidity sourcing by default - Settings toggle available under Experimental for opt-out - Renamed section to 'Liquidity Sourcing' --- .../monarch-settings/panels/ExperimentalPanel.tsx | 10 ++++------ src/stores/useAppSettings.ts | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx index 32fefad8..e01cfffc 100644 --- a/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx +++ b/src/modals/settings/monarch-settings/panels/ExperimentalPanel.tsx @@ -12,7 +12,7 @@ type ExperimentalPanelProps = { export function ExperimentalPanel({ onNavigateToDetail }: ExperimentalPanelProps) { const { trendingConfig, setTrendingEnabled } = useMarketPreferences(); - const { showDeveloperOptions, setShowDeveloperOptions } = useAppSettings(); + const { showDeveloperOptions, setShowDeveloperOptions, usePublicAllocator, setUsePublicAllocator } = useAppSettings(); return (
@@ -34,18 +34,16 @@ export function ExperimentalPanel({ onNavigateToDetail }: ExperimentalPanelProps />
- {/* TODO: Uncomment when public allocator integration with withdraw/borrow is ready
-

Experimental

+

Liquidity Sourcing

- */}

Developer

diff --git a/src/stores/useAppSettings.ts b/src/stores/useAppSettings.ts index f63e5611..90398aad 100644 --- a/src/stores/useAppSettings.ts +++ b/src/stores/useAppSettings.ts @@ -57,7 +57,7 @@ export const useAppSettings = create()( isAprDisplay: false, trustedVaultsWarningDismissed: false, showDeveloperOptions: false, - usePublicAllocator: false, + usePublicAllocator: true, // Actions setUsePermit2: (use) => set({ usePermit2: use }), From c439b5c00ac124ac82a40ef52cf63da62e1054ba Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 15:35:14 +0800 Subject: [PATCH 02/11] feat: integrate Public Allocator with withdraw and borrow flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract shared PA utils (getVaultPullableAmount, autoAllocateWithdrawals, resolveWithdrawals, buildBundlerReallocateCalldata) from pull-liquidity-modal into src/utils/public-allocator.ts - Create useMarketLiquiditySourcing hook that pre-fetches PA vault data at the market detail page level for instant modal calculations. Provides computeReallocation() to build reallocation plans on demand. - Borrow integration (single tx): prepend reallocateTo calldata to bundler multicall when borrowAmount exceeds market liquidity. Fee added to tx value. Shows '+PA' indicator on available liquidity and sourcing message. - Withdraw integration (2-step tx): when withdrawAmount exceeds market liquidity, execute reallocateTo on PA contract first (step 1), then morpho.withdraw() (step 2). Shows phase progress and retry button if step 2 fails. - Update modal prop chain: market-view → BorrowModal → AddCollateralAndBorrow → useBorrowTransaction, and market-view → SupplyModalV2 → WithdrawModalContent. Add liquiditySourcing to modal store supply props. - Pull-liquidity-modal now imports from shared utils (no logic duplication). --- .../components/pull-liquidity-modal.tsx | 190 +------------ src/features/market-detail/market-view.tsx | 7 +- src/hooks/useBorrowTransaction.ts | 19 +- src/hooks/useMarketLiquiditySourcing.ts | 175 ++++++++++++ src/modals/borrow/borrow-modal.tsx | 5 +- .../components/add-collateral-and-borrow.tsx | 21 +- src/modals/supply/supply-modal.tsx | 4 + src/modals/supply/withdraw-modal-content.tsx | 198 +++++++++++-- src/stores/useModalStore.ts | 2 + src/utils/public-allocator.ts | 265 ++++++++++++++++++ 10 files changed, 670 insertions(+), 216 deletions(-) create mode 100644 src/hooks/useMarketLiquiditySourcing.ts create mode 100644 src/utils/public-allocator.ts diff --git a/src/features/market-detail/components/pull-liquidity-modal.tsx b/src/features/market-detail/components/pull-liquidity-modal.tsx index 31417258..9449a9e8 100644 --- a/src/features/market-detail/components/pull-liquidity-modal.tsx +++ b/src/features/market-detail/components/pull-liquidity-modal.tsx @@ -9,9 +9,15 @@ import { Spinner } from '@/components/ui/spinner'; import { TokenIcon } from '@/components/shared/token-icon'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; import { usePublicAllocator } from '@/hooks/usePublicAllocator'; -import { usePublicAllocatorVaults, type ProcessedPublicAllocatorVault } from '@/hooks/usePublicAllocatorVaults'; -import { usePublicAllocatorLiveData, type LiveMarketData } from '@/hooks/usePublicAllocatorLiveData'; +import { usePublicAllocatorVaults } from '@/hooks/usePublicAllocatorVaults'; +import { usePublicAllocatorLiveData } from '@/hooks/usePublicAllocatorLiveData'; import { PUBLIC_ALLOCATOR_ADDRESSES } from '@/constants/public-allocator'; +import { + getVaultPullableAmount, + getVaultPullableAmountLive, + autoAllocateWithdrawals, + autoAllocateWithdrawalsLive, +} from '@/utils/public-allocator'; import type { Market } from '@/utils/types'; import type { SupportedNetworks } from '@/utils/networks'; @@ -24,186 +30,6 @@ type PullLiquidityModalProps = { onSuccess?: () => void; }; -/** - * Calculate max pullable liquidity from a vault into the target market (API data only). - * - * Bounded by: - * 1. Per-source: min(flowCap.maxOut, vaultSupply, marketLiquidity) - * 2. Target market: flowCap.maxIn - * 3. Target market: supplyCap - currentSupply (remaining vault capacity) - */ -function getVaultPullableAmount(vault: ProcessedPublicAllocatorVault, targetMarketKey: string): bigint { - let total = 0n; - - for (const alloc of vault.state.allocation) { - if (alloc.market.uniqueKey === targetMarketKey) continue; - - const cap = vault.flowCapsByMarket.get(alloc.market.uniqueKey); - if (!cap || cap.maxOut === 0n) continue; - - const vaultSupply = BigInt(alloc.supplyAssets); - const liquidity = BigInt(alloc.market.state.liquidityAssets); - - let pullable = cap.maxOut; - if (vaultSupply < pullable) pullable = vaultSupply; - if (liquidity < pullable) pullable = liquidity; - if (pullable > 0n) total += pullable; - } - - // Cap by target market's maxIn flow cap - const targetCap = vault.flowCapsByMarket.get(targetMarketKey); - if (!targetCap) { - return 0n; - } - if (total > targetCap.maxIn) { - total = targetCap.maxIn; - } - - // Cap by remaining supply cap for the target market in this vault - const targetAlloc = vault.state.allocation.find((a) => a.market.uniqueKey === targetMarketKey); - if (targetAlloc) { - const supplyCap = BigInt(targetAlloc.supplyCap); - const currentSupply = BigInt(targetAlloc.supplyAssets); - const remainingCap = supplyCap > currentSupply ? supplyCap - currentSupply : 0n; - if (total > remainingCap) total = remainingCap; - } - - return total; -} - -/** - * Calculate max pullable liquidity using live on-chain data for flow caps, - * vault supply, and market liquidity. - * - * Falls back to API values for supply cap constraints (which are less volatile). - */ -function getVaultPullableAmountLive( - vault: ProcessedPublicAllocatorVault, - targetMarketKey: string, - liveData: Map, -): bigint { - let total = 0n; - - for (const alloc of vault.state.allocation) { - if (alloc.market.uniqueKey === targetMarketKey) continue; - - const live = liveData.get(alloc.market.uniqueKey); - if (!live || live.maxOut === 0n) continue; - - let pullable = live.maxOut; - if (live.vaultSupplyAssets < pullable) pullable = live.vaultSupplyAssets; - if (live.marketLiquidity < pullable) pullable = live.marketLiquidity; - if (pullable > 0n) total += pullable; - } - - // Cap by target market's live maxIn flow cap - const targetLive = liveData.get(targetMarketKey); - if (!targetLive) { - return 0n; - } - if (total > targetLive.maxIn) { - total = targetLive.maxIn; - } - - // Cap by remaining supply cap (uses API data — supply caps rarely change) - const targetAlloc = vault.state.allocation.find((a) => a.market.uniqueKey === targetMarketKey); - if (targetAlloc) { - const supplyCap = BigInt(targetAlloc.supplyCap); - // Use live supply if available, fall back to API - const currentSupply = targetLive.vaultSupplyAssets; - const remainingCap = supplyCap > currentSupply ? supplyCap - currentSupply : 0n; - if (total > remainingCap) total = remainingCap; - } - - return total; -} - -/** - * Auto-allocate a pull amount across source markets greedily (API data). - * Pulls from the most liquid source first. - */ -function autoAllocateWithdrawals( - vault: ProcessedPublicAllocatorVault, - targetMarketKey: string, - requestedAmount: bigint, -): { marketKey: string; amount: bigint }[] { - const sources: { marketKey: string; maxPullable: bigint }[] = []; - - for (const alloc of vault.state.allocation) { - if (alloc.market.uniqueKey === targetMarketKey) continue; - - const cap = vault.flowCapsByMarket.get(alloc.market.uniqueKey); - if (!cap || cap.maxOut === 0n) continue; - - const vaultSupply = BigInt(alloc.supplyAssets); - const liquidity = BigInt(alloc.market.state.liquidityAssets); - - let maxPullable = cap.maxOut; - if (vaultSupply < maxPullable) maxPullable = vaultSupply; - if (liquidity < maxPullable) maxPullable = liquidity; - - if (maxPullable > 0n) { - sources.push({ marketKey: alloc.market.uniqueKey, maxPullable }); - } - } - - sources.sort((a, b) => (b.maxPullable > a.maxPullable ? 1 : b.maxPullable < a.maxPullable ? -1 : 0)); - - const withdrawals: { marketKey: string; amount: bigint }[] = []; - let remaining = requestedAmount; - - for (const source of sources) { - if (remaining <= 0n) break; - const pullAmount = remaining < source.maxPullable ? remaining : source.maxPullable; - withdrawals.push({ marketKey: source.marketKey, amount: pullAmount }); - remaining -= pullAmount; - } - - return withdrawals; -} - -/** - * Auto-allocate a pull amount across source markets using live on-chain data. - * Same greedy algorithm but uses RPC-verified flow caps, supply, and liquidity. - */ -function autoAllocateWithdrawalsLive( - vault: ProcessedPublicAllocatorVault, - targetMarketKey: string, - requestedAmount: bigint, - liveData: Map, -): { marketKey: string; amount: bigint }[] { - const sources: { marketKey: string; maxPullable: bigint }[] = []; - - for (const alloc of vault.state.allocation) { - if (alloc.market.uniqueKey === targetMarketKey) continue; - - const live = liveData.get(alloc.market.uniqueKey); - if (!live || live.maxOut === 0n) continue; - - let maxPullable = live.maxOut; - if (live.vaultSupplyAssets < maxPullable) maxPullable = live.vaultSupplyAssets; - if (live.marketLiquidity < maxPullable) maxPullable = live.marketLiquidity; - - if (maxPullable > 0n) { - sources.push({ marketKey: alloc.market.uniqueKey, maxPullable }); - } - } - - sources.sort((a, b) => (b.maxPullable > a.maxPullable ? 1 : b.maxPullable < a.maxPullable ? -1 : 0)); - - const withdrawals: { marketKey: string; amount: bigint }[] = []; - let remaining = requestedAmount; - - for (const source of sources) { - if (remaining <= 0n) break; - const pullAmount = remaining < source.maxPullable ? remaining : source.maxPullable; - withdrawals.push({ marketKey: source.marketKey, amount: pullAmount }); - remaining -= pullAmount; - } - - return withdrawals; -} - /** * Modal for pulling liquidity into a market via the Public Allocator. * diff --git a/src/features/market-detail/market-view.tsx b/src/features/market-detail/market-view.tsx index 341bf454..da0e548c 100644 --- a/src/features/market-detail/market-view.tsx +++ b/src/features/market-detail/market-view.tsx @@ -28,6 +28,7 @@ import { SuppliersTable } from '@/features/market-detail/components/suppliers-ta import SupplierFiltersModal from '@/features/market-detail/components/filters/supplier-filters-modal'; import TransactionFiltersModal from '@/features/market-detail/components/filters/transaction-filters-modal'; import { useMarketWarnings } from '@/hooks/useMarketWarnings'; +import { useMarketLiquiditySourcing } from '@/hooks/useMarketLiquiditySourcing'; import { useAllMarketBorrowers, useAllMarketSuppliers } from '@/hooks/useAllMarketPositions'; import { MarketHeader } from './components/market-header'; import { PullLiquidityModal } from './components/pull-liquidity-modal'; @@ -85,6 +86,9 @@ function MarketContent() { // Get all warnings for this market (hook handles undefined market) const allWarnings = useMarketWarnings(market); + // Pre-fetch Public Allocator data eagerly so modals have instant access + const liquiditySourcing = useMarketLiquiditySourcing(market ?? undefined, network); + // Get dynamic chart colors const chartColors = useChartColors(); @@ -260,7 +264,7 @@ function MarketContent() { // Handlers for supply/borrow actions const handleSupplyClick = () => { - openModal('supply', { market, position: userPosition, isMarketPage: true, refetch: handleRefreshAllSync }); + openModal('supply', { market, position: userPosition, isMarketPage: true, refetch: handleRefreshAllSync, liquiditySourcing }); }; const handleBorrowClick = () => { @@ -317,6 +321,7 @@ function MarketContent() { refetch={handleRefreshAllSync} isRefreshing={isRefreshing} position={userPosition} + liquiditySourcing={liquiditySourcing} /> )} diff --git a/src/hooks/useBorrowTransaction.ts b/src/hooks/useBorrowTransaction.ts index a63e2edd..d46b6668 100644 --- a/src/hooks/useBorrowTransaction.ts +++ b/src/hooks/useBorrowTransaction.ts @@ -13,12 +13,14 @@ import { useStyledToast } from './useStyledToast'; import { useTransactionWithToast } from './useTransactionWithToast'; import { useUserMarketsCache } from '@/stores/useUserMarketsCache'; import { useTransactionTracking } from '@/hooks/useTransactionTracking'; +import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; type UseBorrowTransactionProps = { market: Market; collateralAmount: bigint; borrowAmount: bigint; onSuccess?: () => void; + liquiditySourcing?: LiquiditySourcingResult; }; // Define step types similar to useRebalance @@ -30,7 +32,7 @@ export type BorrowStepType = | 'approve_token' // For standard flow: Step 2 (if needed) | 'execute'; // Common final step -export function useBorrowTransaction({ market, collateralAmount, borrowAmount, onSuccess }: UseBorrowTransactionProps) { +export function useBorrowTransaction({ market, collateralAmount, borrowAmount, onSuccess, liquiditySourcing }: UseBorrowTransactionProps) { const { usePermit2: usePermit2Setting } = useAppSettings(); const [useEth, setUseEth] = useState(false); @@ -133,6 +135,18 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o try { const transactions: `0x${string}`[] = []; + let reallocationFee = 0n; + + // --- Public Allocator: prepend reallocateTo if borrow exceeds market liquidity --- + const marketLiquidity = BigInt(market.state.liquidityAssets); + if (borrowAmount > 0n && borrowAmount > marketLiquidity && liquiditySourcing?.canSourceLiquidity) { + const extraNeeded = borrowAmount - marketLiquidity; + const reallocation = liquiditySourcing.computeReallocation(extraNeeded); + if (reallocation) { + transactions.push(reallocation.bundlerCalldata); + reallocationFee = reallocation.fee; + } + } // --- ETH Flow: Skip permit2/ERC20 approval, native ETH can't be permit-signed --- if (useEth) { @@ -278,7 +292,7 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o functionName: 'multicall', args: [transactions], }) + MONARCH_TX_IDENTIFIER) as `0x${string}`, - value: useEth ? collateralAmount : 0n, + value: (useEth ? collateralAmount : 0n) + reallocationFee, }); batchAddUserMarkets([ @@ -316,6 +330,7 @@ export function useBorrowTransaction({ market, collateralAmount, borrowAmount, o bundlerAddress, toast, tracking, + liquiditySourcing, ]); // Combined approval and borrow flow diff --git a/src/hooks/useMarketLiquiditySourcing.ts b/src/hooks/useMarketLiquiditySourcing.ts new file mode 100644 index 00000000..fd2ce1b0 --- /dev/null +++ b/src/hooks/useMarketLiquiditySourcing.ts @@ -0,0 +1,175 @@ +import { useMemo, useCallback } from 'react'; +import type { Address } from 'viem'; +import { usePublicAllocatorVaults, type ProcessedPublicAllocatorVault } from '@/hooks/usePublicAllocatorVaults'; +import { PUBLIC_ALLOCATOR_ADDRESSES } from '@/constants/public-allocator'; +import { + getVaultPullableAmount, + autoAllocateWithdrawals, + resolveWithdrawals, + buildBundlerReallocateCalldata, + type PAMarketParams, +} from '@/utils/public-allocator'; +import type { Market } from '@/utils/types'; +import type { SupportedNetworks } from '@/utils/networks'; + +// ── Types ── + +export type ReallocationPlan = { + /** The vault to reallocate from */ + vaultAddress: Address; + /** Vault name for display */ + vaultName: string; + /** Fee in wei to pay for the reallocation */ + fee: bigint; + /** Bundler-compatible calldata (for borrow multicall) */ + bundlerCalldata: `0x${string}`; + /** Resolved withdrawals with marketParams, amounts, and sort keys */ + withdrawals: { marketParams: PAMarketParams; amount: bigint; sortKey: string }[]; + /** Target market params */ + targetMarketParams: PAMarketParams; +}; + +export type LiquiditySourcingResult = { + /** Total extra liquidity available across all PA vaults */ + totalAvailableExtraLiquidity: bigint; + /** Whether any PA vaults can source liquidity for this market */ + canSourceLiquidity: boolean; + /** Loading state */ + isLoading: boolean; + /** + * For a given amount of extra liquidity needed, compute the reallocation plan. + * Returns null if the amount can't be sourced. + */ + computeReallocation: (extraAmountNeeded: bigint) => ReallocationPlan | null; + /** Refetch PA data */ + refetch: () => void; +}; + +/** + * Pre-fetches Public Allocator vault data for a market at the page level. + * + * This hook is the "brain" of liquidity sourcing. It eagerly loads PA-enabled + * vaults and pre-computes max pullable amounts so that modal UI calculations + * are instant — no lazy loading when user opens a modal. + * + * Used by both borrow and withdraw flows: + * - **Borrow**: prepend `reallocateTo` calldata to bundler multicall (single tx) + * - **Withdraw**: execute reallocateTo as step 1, then withdraw as step 2 + * + * @param market - The current market + * @param network - The network to query + */ +export function useMarketLiquiditySourcing(market: Market | undefined, network: SupportedNetworks): LiquiditySourcingResult { + const supplyingVaults = market?.supplyingVaults ?? []; + const supplyingVaultAddresses = useMemo(() => supplyingVaults.map((v) => v.address), [supplyingVaults]); + const allocatorAddress = PUBLIC_ALLOCATOR_ADDRESSES[network]; + const isNetworkSupported = !!allocatorAddress; + const marketKey = market?.uniqueKey ?? ''; + + // Batch-fetch all PA-enabled vaults upfront (API data) + const { vaults: paVaults, isLoading, refetch } = usePublicAllocatorVaults(supplyingVaultAddresses, network); + + // Pre-compute pullable amounts for each vault, sorted by most pullable + const vaultsWithPullable = useMemo(() => { + if (!isNetworkSupported || !marketKey) return []; + + return paVaults + .map((vault) => ({ + vault, + pullable: getVaultPullableAmount(vault, marketKey), + })) + .filter(({ pullable }) => pullable > 0n) + .sort((a, b) => (b.pullable > a.pullable ? 1 : b.pullable < a.pullable ? -1 : 0)); + }, [paVaults, marketKey, isNetworkSupported]); + + // Total available extra liquidity across all PA vaults + const totalAvailableExtraLiquidity = useMemo( + () => vaultsWithPullable.reduce((sum, { pullable }) => sum + pullable, 0n), + [vaultsWithPullable], + ); + + const canSourceLiquidity = totalAvailableExtraLiquidity > 0n; + + /** + * Compute a reallocation plan for a given amount of extra liquidity needed. + * + * Auto-selects the best vault (most pullable) that can cover the requested amount, + * uses greedy allocation across source markets, sorts by market ID ascending, + * and builds bundler-compatible calldata. + */ + const computeReallocation = useCallback( + (extraAmountNeeded: bigint): ReallocationPlan | null => { + if (!market || !allocatorAddress || extraAmountNeeded <= 0n || vaultsWithPullable.length === 0) { + return null; + } + + // Find the best vault that can cover the full amount + // (first in sorted order that has enough pullable) + let selectedEntry: { vault: ProcessedPublicAllocatorVault; pullable: bigint } | null = null; + + for (const entry of vaultsWithPullable) { + if (entry.pullable >= extraAmountNeeded) { + selectedEntry = entry; + break; + } + } + + // If no single vault can cover it, use the vault with the most pullable + // (partial coverage is still useful) + if (!selectedEntry) { + selectedEntry = vaultsWithPullable[0]; + } + + const { vault } = selectedEntry; + + // Auto-allocate withdrawals using the greedy algorithm + const allocated = autoAllocateWithdrawals(vault, marketKey, extraAmountNeeded); + if (allocated.length === 0) return null; + + // Resolve to full withdrawal structs with market params + const resolvedWithdrawals = resolveWithdrawals(vault, allocated); + if (resolvedWithdrawals.length === 0) return null; + + // Build target market params + const targetMarketParams: PAMarketParams = { + loanToken: market.loanAsset.address as Address, + collateralToken: market.collateralAsset.address as Address, + oracle: market.oracleAddress as Address, + irm: market.irmAddress as Address, + lltv: BigInt(market.lltv), + }; + + // Build bundler calldata + const withdrawalsForCalldata = resolvedWithdrawals.map(({ marketParams, amount }) => ({ + marketParams, + amount, + })); + + const bundlerCalldata = buildBundlerReallocateCalldata( + allocatorAddress, + vault.address as Address, + vault.feeBigInt, + withdrawalsForCalldata, + targetMarketParams, + ); + + return { + vaultAddress: vault.address as Address, + vaultName: vault.name, + fee: vault.feeBigInt, + bundlerCalldata, + withdrawals: resolvedWithdrawals, + targetMarketParams, + }; + }, + [market, allocatorAddress, marketKey, vaultsWithPullable], + ); + + return { + totalAvailableExtraLiquidity, + canSourceLiquidity, + isLoading, + computeReallocation, + refetch, + }; +} diff --git a/src/modals/borrow/borrow-modal.tsx b/src/modals/borrow/borrow-modal.tsx index cfa7d566..b00dc4de 100644 --- a/src/modals/borrow/borrow-modal.tsx +++ b/src/modals/borrow/borrow-modal.tsx @@ -5,6 +5,7 @@ import { erc20Abi } from 'viem'; import { Button } from '@/components/ui/button'; import { Modal, ModalHeader, ModalBody } from '@/components/common/Modal'; import type { Market, MarketPosition } from '@/utils/types'; +import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; import { AddCollateralAndBorrow } from './components/add-collateral-and-borrow'; import { WithdrawCollateralAndRepay } from './components/withdraw-collateral-and-repay'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -16,9 +17,10 @@ type BorrowModalProps = { refetch?: () => void; isRefreshing?: boolean; position: MarketPosition | null; + liquiditySourcing?: LiquiditySourcingResult; }; -export function BorrowModal({ market, onOpenChange, oraclePrice, refetch, isRefreshing = false, position }: BorrowModalProps): JSX.Element { +export function BorrowModal({ market, onOpenChange, oraclePrice, refetch, isRefreshing = false, position, liquiditySourcing }: BorrowModalProps): JSX.Element { const [mode, setMode] = useState<'borrow' | 'repay'>('borrow'); const { address: account } = useConnection(); @@ -113,6 +115,7 @@ export function BorrowModal({ market, onOpenChange, oraclePrice, refetch, isRefr oraclePrice={oraclePrice} onSuccess={refetch} isRefreshing={isRefreshing} + liquiditySourcing={liquiditySourcing} /> ) : ( void; isRefreshing?: boolean; + liquiditySourcing?: LiquiditySourcingResult; }; export function AddCollateralAndBorrow({ @@ -32,6 +34,7 @@ export function AddCollateralAndBorrow({ oraclePrice, onSuccess, isRefreshing = false, + liquiditySourcing, }: BorrowLogicProps): JSX.Element { // State for collateral and borrow amounts const [collateralAmount, setCollateralAmount] = useState(BigInt(0)); @@ -47,6 +50,11 @@ export function AddCollateralAndBorrow({ const [currentLTV, setCurrentLTV] = useState(BigInt(0)); const [newLTV, setNewLTV] = useState(BigInt(0)); + // Compute effective available liquidity (market + PA extra) + const extraLiquidity = liquiditySourcing?.totalAvailableExtraLiquidity ?? 0n; + const marketLiquidity = BigInt(market.state.liquidityAssets); + const effectiveAvailableLiquidity = marketLiquidity + extraLiquidity; + // Use the new hook for borrow transaction logic const { transaction, @@ -63,6 +71,7 @@ export function AddCollateralAndBorrow({ collateralAmount, borrowAmount, onSuccess, + liquiditySourcing, }); const handleBorrow = useCallback(() => { @@ -272,8 +281,11 @@ export function AddCollateralAndBorrow({

Borrow

- Available: {formatReadable(formatBalance(market.state.liquidityAssets, market.loanAsset.decimals))}{' '} + Available: {formatReadable(formatBalance(effectiveAvailableLiquidity, market.loanAsset.decimals))}{' '} {market.loanAsset.symbol} + {extraLiquidity > 0n && ( + +PA + )}

@@ -285,9 +297,14 @@ export function AddCollateralAndBorrow({ setError={setBorrowInputError} exceedMaxErrMessage="Exceeds available liquidity" value={borrowAmount} - max={BigInt(market.state.liquidityAssets)} + max={effectiveAvailableLiquidity} /> {borrowInputError &&

{borrowInputError}

} + {borrowAmount > marketLiquidity && borrowAmount <= effectiveAvailableLiquidity && liquiditySourcing?.canSourceLiquidity && ( +

+ ⚡ Sourcing extra liquidity via Public Allocator +

+ )}
diff --git a/src/modals/supply/supply-modal.tsx b/src/modals/supply/supply-modal.tsx index 07b42b06..42880ed9 100644 --- a/src/modals/supply/supply-modal.tsx +++ b/src/modals/supply/supply-modal.tsx @@ -3,6 +3,7 @@ import { LuArrowRightLeft } from 'react-icons/lu'; import { Modal, ModalBody, ModalHeader } from '@/components/common/Modal'; import { useFreshMarketsState } from '@/hooks/useFreshMarketsState'; import type { Market, MarketPosition } from '@/utils/types'; +import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; import { MarketDetailsBlock } from '@/features/markets/components/market-details-block'; import { SupplyModalContent } from './supply-modal-content'; import { TokenIcon } from '@/components/shared/token-icon'; @@ -14,6 +15,7 @@ type SupplyModalV2Props = { refetch?: () => void; isMarketPage?: boolean; defaultMode?: 'supply' | 'withdraw'; + liquiditySourcing?: LiquiditySourcingResult; }; export function SupplyModalV2({ @@ -23,6 +25,7 @@ export function SupplyModalV2({ refetch, isMarketPage, defaultMode = 'supply', + liquiditySourcing, }: SupplyModalV2Props): JSX.Element { const [mode, setMode] = useState<'supply' | 'withdraw'>(defaultMode); const [supplyPreviewAmount, setSupplyPreviewAmount] = useState(); @@ -116,6 +119,7 @@ export function SupplyModalV2({ onClose={() => onOpenChange(false)} refetch={refetch ?? (() => {})} onAmountChange={setWithdrawPreviewAmount} + liquiditySourcing={liquiditySourcing} /> )} diff --git a/src/modals/supply/withdraw-modal-content.tsx b/src/modals/supply/withdraw-modal-content.tsx index bb0b5132..d404ce7a 100644 --- a/src/modals/supply/withdraw-modal-content.tsx +++ b/src/modals/supply/withdraw-modal-content.tsx @@ -1,29 +1,36 @@ // Import the necessary hooks -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { type Address, encodeFunctionData } from 'viem'; -import { useConnection } from 'wagmi'; +import { useConnection, useSwitchChain } from 'wagmi'; import morphoAbi from '@/abis/morpho'; +import { publicAllocatorAbi } from '@/abis/public-allocator'; import Input from '@/components/Input/Input'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; import { formatBalance, formatReadable, min } from '@/utils/balance'; import { getMorphoAddress } from '@/utils/morpho'; +import { PUBLIC_ALLOCATOR_ADDRESSES } from '@/constants/public-allocator'; import type { SupportedNetworks } from '@/utils/networks'; import type { Market, MarketPosition } from '@/utils/types'; +import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +type WithdrawPhase = 'idle' | 'sourcing' | 'withdrawing'; + type WithdrawModalContentProps = { position?: MarketPosition | null; market?: Market; onClose: () => void; refetch: () => void; onAmountChange?: (amount: bigint) => void; + liquiditySourcing?: LiquiditySourcingResult; }; -export function WithdrawModalContent({ position, market, onClose, refetch, onAmountChange }: WithdrawModalContentProps): JSX.Element { +export function WithdrawModalContent({ position, market, onClose, refetch, onAmountChange, liquiditySourcing }: WithdrawModalContentProps): JSX.Element { const toast = useStyledToast(); const [inputError, setInputError] = useState(null); const [withdrawAmount, setWithdrawAmount] = useState(BigInt(0)); + const [withdrawPhase, setWithdrawPhase] = useState('idle'); // Notify parent component when withdraw amount changes const handleWithdrawAmountChange = useCallback( @@ -34,11 +41,38 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo [onAmountChange], ); const { address: account, chainId } = useConnection(); + const { mutateAsync: switchChainAsync } = useSwitchChain(); // Prefer the market prop (which has fresh state) over position.market const activeMarket = market ?? position?.market; - const { isConfirming, sendTransaction } = useTransactionWithToast({ + // Compute effective max amount with PA extra liquidity + const marketLiquidity = BigInt(activeMarket?.state.liquidityAssets ?? 0); + const extraLiquidity = liquiditySourcing?.totalAvailableExtraLiquidity ?? 0n; + const effectiveLiquidity = marketLiquidity + extraLiquidity; + const supplyAssets = BigInt(position?.state.supplyAssets ?? 0); + const effectiveMax = position ? min(supplyAssets, effectiveLiquidity) : 0n; + + // Whether this withdraw needs PA sourcing + const needsSourcing = withdrawAmount > marketLiquidity && withdrawAmount > 0n && liquiditySourcing?.canSourceLiquidity; + + // ── Transaction hook for sourcing step (Step 1) ── + const { isConfirming: isSourceConfirming, sendTransactionAsync: sendSourceTxAsync } = useTransactionWithToast({ + toastId: 'source-liquidity-withdraw', + pendingText: 'Sourcing Liquidity', + successText: 'Liquidity Sourced', + errorText: 'Failed to source liquidity', + chainId, + pendingDescription: 'Moving liquidity via the Public Allocator...', + successDescription: 'Liquidity is now available. Proceeding to withdraw...', + onSuccess: () => { + // After sourcing succeeds, automatically trigger withdraw + setWithdrawPhase('withdrawing'); + }, + }); + + // ── Transaction hook for withdraw step (Step 2 or direct) ── + const { isConfirming: isWithdrawConfirming, sendTransaction: sendWithdrawTx } = useTransactionWithToast({ toastId: 'withdraw', pendingText: activeMarket ? `Withdrawing ${formatBalance(withdrawAmount, activeMarket.loanAsset.decimals)} ${activeMarket.loanAsset.symbol}` @@ -49,23 +83,16 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo pendingDescription: activeMarket ? `Withdrawing from market ${activeMarket.uniqueKey.slice(2, 8)}...` : '', successDescription: activeMarket ? `Successfully withdrawn from market ${activeMarket.uniqueKey.slice(2, 8)}` : '', onSuccess: () => { + setWithdrawPhase('idle'); refetch(); onClose(); }, }); - const withdraw = useCallback(async () => { - if (!activeMarket) { - toast.error('No market', 'Market data not available'); - return; - } - - if (!account) { - toast.info('No account connected', 'Please connect your wallet to continue.'); - return; - } + // ── Execute the withdraw transaction (shared between direct and 2-step flows) ── + const executeWithdraw = useCallback(() => { + if (!activeMarket || !account) return; - // Calculate withdraw parameters - use asset-based withdrawal if no position detected let assetsToWithdraw: string; let sharesToWithdraw: string; @@ -74,12 +101,11 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo assetsToWithdraw = isMax ? '0' : withdrawAmount.toString(); sharesToWithdraw = isMax ? position.state.supplyShares : '0'; } else { - // No position detected - use asset-based withdrawal assetsToWithdraw = withdrawAmount.toString(); sharesToWithdraw = '0'; } - sendTransaction({ + sendWithdrawTx({ account, to: getMorphoAddress(activeMarket.morphoBlue.chain.id as SupportedNetworks), data: encodeFunctionData({ @@ -101,11 +127,93 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo }), chainId: activeMarket.morphoBlue.chain.id, }); - }, [account, activeMarket, position, withdrawAmount, sendTransaction, toast]); + }, [account, activeMarket, position, withdrawAmount, sendWithdrawTx]); - const handleWithdraw = useCallback(() => { - void withdraw(); - }, [withdraw]); + // ── Main withdraw handler ── + const handleWithdraw = useCallback(async () => { + if (!activeMarket) { + toast.error('No market', 'Market data not available'); + return; + } + + if (!account) { + toast.info('No account connected', 'Please connect your wallet to continue.'); + return; + } + + if (needsSourcing && liquiditySourcing) { + // 2-step flow: source liquidity first + const extraNeeded = withdrawAmount - marketLiquidity; + const reallocation = liquiditySourcing.computeReallocation(extraNeeded); + + if (!reallocation) { + toast.error('Cannot source liquidity', 'Unable to find a vault with enough available liquidity.'); + return; + } + + const allocatorAddress = PUBLIC_ALLOCATOR_ADDRESSES[activeMarket.morphoBlue.chain.id as SupportedNetworks]; + if (!allocatorAddress) { + toast.error('Not supported', 'Public Allocator is not available on this network.'); + return; + } + + setWithdrawPhase('sourcing'); + + try { + await switchChainAsync({ chainId: activeMarket.morphoBlue.chain.id }); + + // Step 1: Call reallocateTo on the public allocator contract directly + const sortedWithdrawals = reallocation.withdrawals.map(({ marketParams, amount }) => ({ + marketParams, + amount, + })); + + await sendSourceTxAsync({ + to: allocatorAddress, + data: encodeFunctionData({ + abi: publicAllocatorAbi, + functionName: 'reallocateTo', + args: [reallocation.vaultAddress, sortedWithdrawals, reallocation.targetMarketParams], + }), + value: reallocation.fee, + chainId: activeMarket.morphoBlue.chain.id, + }); + + // onSuccess of the source tx hook will set phase to 'withdrawing' + // and we trigger step 2 from the effect below + } catch (error) { + setWithdrawPhase('idle'); + console.error('Error during liquidity sourcing:', error); + if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { + toast.error('Sourcing Failed', 'Failed to source liquidity from the Public Allocator.'); + } + } + } else { + // Direct withdraw (no sourcing needed) + executeWithdraw(); + } + }, [account, activeMarket, needsSourcing, liquiditySourcing, withdrawAmount, marketLiquidity, executeWithdraw, sendSourceTxAsync, switchChainAsync, toast]); + + // Auto-trigger withdraw after sourcing succeeds + // (withdrawPhase is set to 'withdrawing' in the source tx onSuccess callback) + const handleWithdrawClick = useCallback(() => { + void handleWithdraw(); + }, [handleWithdraw]); + + // When phase transitions to 'withdrawing', execute the withdraw + // We use a separate callback to avoid re-renders triggering double withdraws + const handleRetryWithdraw = useCallback(() => { + executeWithdraw(); + }, [executeWithdraw]); + + // Compute the reallocation plan for display (memoized) + const reallocationPlan = useMemo(() => { + if (!needsSourcing || !liquiditySourcing) return null; + const extraNeeded = withdrawAmount - marketLiquidity; + return liquiditySourcing.computeReallocation(extraNeeded); + }, [needsSourcing, liquiditySourcing, withdrawAmount, marketLiquidity]); + + const isLoading = isSourceConfirming || isWithdrawConfirming; if (!activeMarket) { return ( @@ -115,6 +223,14 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo ); } + // Phase-based button text + const getButtonText = () => { + if (withdrawPhase === 'sourcing') return 'Step 1/2: Sourcing...'; + if (withdrawPhase === 'withdrawing') return 'Step 2/2: Withdrawing...'; + if (needsSourcing) return 'Source & Withdraw'; + return 'Withdraw'; + }; + return (
{/* Withdraw Input Section */} @@ -134,24 +250,50 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo
0n ? 'Exceeds available liquidity (incl. PA)' : 'Insufficient Liquidity'} + allowExceedMax={true} error={inputError} /> + + {/* Sourcing indicator */} + {needsSourcing && reallocationPlan && ( +

+ ⚡ Will source extra liquidity from {reallocationPlan.vaultName} + {reallocationPlan.fee > 0n && ( + + (fee: {formatBalance(reallocationPlan.fee, 18)} ETH) + + )} +

+ )} + + {/* Phase progress for 2-step flow */} + {withdrawPhase === 'withdrawing' && !isWithdrawConfirming && ( +
+

✓ Liquidity sourced.

+ +
+ )}
- Withdraw + {getButtonText()}
diff --git a/src/stores/useModalStore.ts b/src/stores/useModalStore.ts index 1b83cfed..92b3e4e1 100644 --- a/src/stores/useModalStore.ts +++ b/src/stores/useModalStore.ts @@ -3,6 +3,7 @@ import { create } from 'zustand'; import type { Market, MarketPosition, GroupedPosition } from '@/utils/types'; import type { SwapToken } from '@/features/swap/types'; import type { SupportedNetworks } from '@/utils/networks'; +import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; /** * Registry of Zustand-managed modals (Pattern 2). @@ -22,6 +23,7 @@ export type ModalProps = { defaultMode?: 'supply' | 'withdraw'; isMarketPage?: boolean; refetch?: () => void; + liquiditySourcing?: LiquiditySourcingResult; }; // Rebalance diff --git a/src/utils/public-allocator.ts b/src/utils/public-allocator.ts new file mode 100644 index 00000000..62002686 --- /dev/null +++ b/src/utils/public-allocator.ts @@ -0,0 +1,265 @@ +import { type Address, encodeFunctionData } from 'viem'; +import morphoBundlerAbi from '@/abis/bundlerV2'; +import type { ProcessedPublicAllocatorVault } from '@/hooks/usePublicAllocatorVaults'; +import type { LiveMarketData } from '@/hooks/usePublicAllocatorLiveData'; + +// ── Types ── + +export type PAMarketParams = { + loanToken: Address; + collateralToken: Address; + oracle: Address; + irm: Address; + lltv: bigint; +}; + +export type SortedWithdrawal = { + marketParams: PAMarketParams; + amount: bigint; +}; + +// ── Bundler Calldata ── + +/** + * Build bundler-compatible reallocateTo calldata. + * Can be prepended to a multicall batch (borrow) or called standalone. + */ +export function buildBundlerReallocateCalldata( + publicAllocatorAddress: Address, + vaultAddress: Address, + fee: bigint, + withdrawals: SortedWithdrawal[], + supplyMarketParams: PAMarketParams, +): `0x${string}` { + return encodeFunctionData({ + abi: morphoBundlerAbi, + functionName: 'reallocateTo', + args: [publicAllocatorAddress, vaultAddress, fee, withdrawals, supplyMarketParams], + }); +} + +// ── Pullable Amount Calculations ── + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000' as Address; + +/** + * Calculate max pullable liquidity from a vault into the target market (API data only). + * + * Bounded by: + * 1. Per-source: min(flowCap.maxOut, vaultSupply, marketLiquidity) + * 2. Target market: flowCap.maxIn + * 3. Target market: supplyCap - currentSupply (remaining vault capacity) + */ +export function getVaultPullableAmount(vault: ProcessedPublicAllocatorVault, targetMarketKey: string): bigint { + let total = 0n; + + for (const alloc of vault.state.allocation) { + if (alloc.market.uniqueKey === targetMarketKey) continue; + + const cap = vault.flowCapsByMarket.get(alloc.market.uniqueKey); + if (!cap || cap.maxOut === 0n) continue; + + const vaultSupply = BigInt(alloc.supplyAssets); + const liquidity = BigInt(alloc.market.state.liquidityAssets); + + let pullable = cap.maxOut; + if (vaultSupply < pullable) pullable = vaultSupply; + if (liquidity < pullable) pullable = liquidity; + if (pullable > 0n) total += pullable; + } + + // Cap by target market's maxIn flow cap + const targetCap = vault.flowCapsByMarket.get(targetMarketKey); + if (!targetCap) { + return 0n; + } + if (total > targetCap.maxIn) { + total = targetCap.maxIn; + } + + // Cap by remaining supply cap for the target market in this vault + const targetAlloc = vault.state.allocation.find((a) => a.market.uniqueKey === targetMarketKey); + if (targetAlloc) { + const supplyCap = BigInt(targetAlloc.supplyCap); + const currentSupply = BigInt(targetAlloc.supplyAssets); + const remainingCap = supplyCap > currentSupply ? supplyCap - currentSupply : 0n; + if (total > remainingCap) total = remainingCap; + } + + return total; +} + +/** + * Calculate max pullable liquidity using live on-chain data for flow caps, + * vault supply, and market liquidity. + * + * Falls back to API values for supply cap constraints (which are less volatile). + */ +export function getVaultPullableAmountLive( + vault: ProcessedPublicAllocatorVault, + targetMarketKey: string, + liveData: Map, +): bigint { + let total = 0n; + + for (const alloc of vault.state.allocation) { + if (alloc.market.uniqueKey === targetMarketKey) continue; + + const live = liveData.get(alloc.market.uniqueKey); + if (!live || live.maxOut === 0n) continue; + + let pullable = live.maxOut; + if (live.vaultSupplyAssets < pullable) pullable = live.vaultSupplyAssets; + if (live.marketLiquidity < pullable) pullable = live.marketLiquidity; + if (pullable > 0n) total += pullable; + } + + // Cap by target market's live maxIn flow cap + const targetLive = liveData.get(targetMarketKey); + if (!targetLive) { + return 0n; + } + if (total > targetLive.maxIn) { + total = targetLive.maxIn; + } + + // Cap by remaining supply cap (uses API data — supply caps rarely change) + const targetAlloc = vault.state.allocation.find((a) => a.market.uniqueKey === targetMarketKey); + if (targetAlloc) { + const supplyCap = BigInt(targetAlloc.supplyCap); + // Use live supply if available, fall back to API + const currentSupply = targetLive.vaultSupplyAssets; + const remainingCap = supplyCap > currentSupply ? supplyCap - currentSupply : 0n; + if (total > remainingCap) total = remainingCap; + } + + return total; +} + +// ── Auto-Allocation Algorithms ── + +/** + * Auto-allocate a pull amount across source markets greedily (API data). + * Pulls from the most liquid source first. + */ +export function autoAllocateWithdrawals( + vault: ProcessedPublicAllocatorVault, + targetMarketKey: string, + requestedAmount: bigint, +): { marketKey: string; amount: bigint }[] { + const sources: { marketKey: string; maxPullable: bigint }[] = []; + + for (const alloc of vault.state.allocation) { + if (alloc.market.uniqueKey === targetMarketKey) continue; + + const cap = vault.flowCapsByMarket.get(alloc.market.uniqueKey); + if (!cap || cap.maxOut === 0n) continue; + + const vaultSupply = BigInt(alloc.supplyAssets); + const liquidity = BigInt(alloc.market.state.liquidityAssets); + + let maxPullable = cap.maxOut; + if (vaultSupply < maxPullable) maxPullable = vaultSupply; + if (liquidity < maxPullable) maxPullable = liquidity; + + if (maxPullable > 0n) { + sources.push({ marketKey: alloc.market.uniqueKey, maxPullable }); + } + } + + sources.sort((a, b) => (b.maxPullable > a.maxPullable ? 1 : b.maxPullable < a.maxPullable ? -1 : 0)); + + const withdrawals: { marketKey: string; amount: bigint }[] = []; + let remaining = requestedAmount; + + for (const source of sources) { + if (remaining <= 0n) break; + const pullAmount = remaining < source.maxPullable ? remaining : source.maxPullable; + withdrawals.push({ marketKey: source.marketKey, amount: pullAmount }); + remaining -= pullAmount; + } + + return withdrawals; +} + +/** + * Auto-allocate a pull amount across source markets using live on-chain data. + * Same greedy algorithm but uses RPC-verified flow caps, supply, and liquidity. + */ +export function autoAllocateWithdrawalsLive( + vault: ProcessedPublicAllocatorVault, + targetMarketKey: string, + requestedAmount: bigint, + liveData: Map, +): { marketKey: string; amount: bigint }[] { + const sources: { marketKey: string; maxPullable: bigint }[] = []; + + for (const alloc of vault.state.allocation) { + if (alloc.market.uniqueKey === targetMarketKey) continue; + + const live = liveData.get(alloc.market.uniqueKey); + if (!live || live.maxOut === 0n) continue; + + let maxPullable = live.maxOut; + if (live.vaultSupplyAssets < maxPullable) maxPullable = live.vaultSupplyAssets; + if (live.marketLiquidity < maxPullable) maxPullable = live.marketLiquidity; + + if (maxPullable > 0n) { + sources.push({ marketKey: alloc.market.uniqueKey, maxPullable }); + } + } + + sources.sort((a, b) => (b.maxPullable > a.maxPullable ? 1 : b.maxPullable < a.maxPullable ? -1 : 0)); + + const withdrawals: { marketKey: string; amount: bigint }[] = []; + let remaining = requestedAmount; + + for (const source of sources) { + if (remaining <= 0n) break; + const pullAmount = remaining < source.maxPullable ? remaining : source.maxPullable; + withdrawals.push({ marketKey: source.marketKey, amount: pullAmount }); + remaining -= pullAmount; + } + + return withdrawals; +} + +// ── Withdrawal Resolution ── + +/** + * Resolve auto-allocated withdrawals (marketKey + amount) into full withdrawal + * structs with marketParams and sort keys for the contract. + * + * Returns sorted by market ID ascending (contract requirement). + */ +export function resolveWithdrawals( + vault: ProcessedPublicAllocatorVault, + allocatedWithdrawals: { marketKey: string; amount: bigint }[], +): { marketParams: PAMarketParams; amount: bigint; sortKey: string }[] { + const allocationMap = new Map(vault.state.allocation.map((a) => [a.market.uniqueKey, a])); + + const sources = allocatedWithdrawals + .map(({ marketKey, amount }) => { + const alloc = allocationMap.get(marketKey); + if (!alloc) return null; + return { + marketParams: { + loanToken: alloc.market.loanAsset.address as Address, + collateralToken: (alloc.market.collateralAsset?.address ?? ZERO_ADDRESS) as Address, + oracle: (alloc.market.oracle?.address ?? ZERO_ADDRESS) as Address, + irm: alloc.market.irmAddress as Address, + lltv: BigInt(alloc.market.lltv), + }, + amount, + sortKey: marketKey, + }; + }) + .filter((s): s is NonNullable => s !== null); + + // Sort by market ID ascending (contract requirement) + sources.sort((a, b) => + a.sortKey.toLowerCase() < b.sortKey.toLowerCase() ? -1 : a.sortKey.toLowerCase() > b.sortKey.toLowerCase() ? 1 : 0, + ); + + return sources; +} From 240b24de3c526cd2e58ecce0675649c44475a035 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 15:37:02 +0800 Subject: [PATCH 03/11] fix: auto-trigger withdraw step 2 after sourcing succeeds - Added useEffect to auto-fire executeWithdraw() when phase becomes 'withdrawing' (after source tx confirms on-chain) - Ref guard prevents double-execution from re-renders - Retry button still works if auto-trigger fails --- src/modals/supply/withdraw-modal-content.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/modals/supply/withdraw-modal-content.tsx b/src/modals/supply/withdraw-modal-content.tsx index d404ce7a..b8a34323 100644 --- a/src/modals/supply/withdraw-modal-content.tsx +++ b/src/modals/supply/withdraw-modal-content.tsx @@ -1,5 +1,5 @@ // Import the necessary hooks -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { type Address, encodeFunctionData } from 'viem'; import { useConnection, useSwitchChain } from 'wagmi'; import morphoAbi from '@/abis/morpho'; @@ -194,15 +194,25 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo } }, [account, activeMarket, needsSourcing, liquiditySourcing, withdrawAmount, marketLiquidity, executeWithdraw, sendSourceTxAsync, switchChainAsync, toast]); - // Auto-trigger withdraw after sourcing succeeds - // (withdrawPhase is set to 'withdrawing' in the source tx onSuccess callback) const handleWithdrawClick = useCallback(() => { void handleWithdraw(); }, [handleWithdraw]); - // When phase transitions to 'withdrawing', execute the withdraw - // We use a separate callback to avoid re-renders triggering double withdraws + // Auto-trigger step 2 when sourcing completes + const hasAutoTriggeredRef = useRef(false); + useEffect(() => { + if (withdrawPhase === 'withdrawing' && !isWithdrawConfirming && !hasAutoTriggeredRef.current) { + hasAutoTriggeredRef.current = true; + executeWithdraw(); + } + if (withdrawPhase === 'idle') { + hasAutoTriggeredRef.current = false; + } + }, [withdrawPhase, isWithdrawConfirming, executeWithdraw]); + + // Manual retry if step 2 fails after sourcing succeeded const handleRetryWithdraw = useCallback(() => { + hasAutoTriggeredRef.current = true; executeWithdraw(); }, [executeWithdraw]); From 40620d3ee42333354f77b064fca4aa5644868da2 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 17:13:45 +0800 Subject: [PATCH 04/11] fix: integrate ProcessModal into withdraw flow for PA sourcing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The withdraw modal was only using useTransactionWithToast (toasts) but never integrated with useTransactionTracking / useTransactionProcessStore. This meant the ProcessModal never appeared during the 2-step source+withdraw flow. Changes: - Add useTransactionTracking('withdraw') to WithdrawModalContent - Define step constants for 2-step (sourcing → withdrawing) and direct flows - Call tracking.start() when beginning either flow - tracking.update('withdrawing') when sourcing completes - tracking.complete() when withdraw tx is submitted - tracking.fail() on errors - Switch sendWithdrawTx to sendWithdrawTxAsync for proper async error handling --- src/modals/supply/withdraw-modal-content.tsx | 127 ++++++++++++++----- 1 file changed, 94 insertions(+), 33 deletions(-) diff --git a/src/modals/supply/withdraw-modal-content.tsx b/src/modals/supply/withdraw-modal-content.tsx index b8a34323..fa37cc0f 100644 --- a/src/modals/supply/withdraw-modal-content.tsx +++ b/src/modals/supply/withdraw-modal-content.tsx @@ -7,6 +7,7 @@ import { publicAllocatorAbi } from '@/abis/public-allocator'; import Input from '@/components/Input/Input'; import { useStyledToast } from '@/hooks/useStyledToast'; import { useTransactionWithToast } from '@/hooks/useTransactionWithToast'; +import { useTransactionTracking } from '@/hooks/useTransactionTracking'; import { formatBalance, formatReadable, min } from '@/utils/balance'; import { getMorphoAddress } from '@/utils/morpho'; import { PUBLIC_ALLOCATOR_ADDRESSES } from '@/constants/public-allocator'; @@ -15,6 +16,15 @@ import type { Market, MarketPosition } from '@/utils/types'; import type { LiquiditySourcingResult } from '@/hooks/useMarketLiquiditySourcing'; import { ExecuteTransactionButton } from '@/components/ui/ExecuteTransactionButton'; +export type WithdrawStepType = 'sourcing' | 'withdrawing'; + +const WITHDRAW_STEPS_WITH_SOURCING = [ + { id: 'sourcing', title: 'Source Liquidity', description: 'Moving liquidity via the Public Allocator' }, + { id: 'withdrawing', title: 'Withdraw', description: 'Withdrawing assets from the market' }, +]; + +const WITHDRAW_STEPS_DIRECT = [{ id: 'withdrawing', title: 'Withdraw', description: 'Withdrawing assets from the market' }]; + type WithdrawPhase = 'idle' | 'sourcing' | 'withdrawing'; type WithdrawModalContentProps = { @@ -26,12 +36,22 @@ type WithdrawModalContentProps = { liquiditySourcing?: LiquiditySourcingResult; }; -export function WithdrawModalContent({ position, market, onClose, refetch, onAmountChange, liquiditySourcing }: WithdrawModalContentProps): JSX.Element { +export function WithdrawModalContent({ + position, + market, + onClose, + refetch, + onAmountChange, + liquiditySourcing, +}: WithdrawModalContentProps): JSX.Element { const toast = useStyledToast(); const [inputError, setInputError] = useState(null); const [withdrawAmount, setWithdrawAmount] = useState(BigInt(0)); const [withdrawPhase, setWithdrawPhase] = useState('idle'); + // Transaction tracking for ProcessModal + const tracking = useTransactionTracking('withdraw'); + // Notify parent component when withdraw amount changes const handleWithdrawAmountChange = useCallback( (amount: bigint) => { @@ -67,12 +87,13 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo successDescription: 'Liquidity is now available. Proceeding to withdraw...', onSuccess: () => { // After sourcing succeeds, automatically trigger withdraw + tracking.update('withdrawing'); setWithdrawPhase('withdrawing'); }, }); // ── Transaction hook for withdraw step (Step 2 or direct) ── - const { isConfirming: isWithdrawConfirming, sendTransaction: sendWithdrawTx } = useTransactionWithToast({ + const { isConfirming: isWithdrawConfirming, sendTransactionAsync: sendWithdrawTxAsync } = useTransactionWithToast({ toastId: 'withdraw', pendingText: activeMarket ? `Withdrawing ${formatBalance(withdrawAmount, activeMarket.loanAsset.decimals)} ${activeMarket.loanAsset.symbol}` @@ -90,7 +111,7 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo }); // ── Execute the withdraw transaction (shared between direct and 2-step flows) ── - const executeWithdraw = useCallback(() => { + const executeWithdraw = useCallback(async () => { if (!activeMarket || !account) return; let assetsToWithdraw: string; @@ -105,29 +126,36 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo sharesToWithdraw = '0'; } - sendWithdrawTx({ - account, - to: getMorphoAddress(activeMarket.morphoBlue.chain.id as SupportedNetworks), - data: encodeFunctionData({ - abi: morphoAbi, - functionName: 'withdraw', - args: [ - { - loanToken: activeMarket.loanAsset.address as Address, - collateralToken: activeMarket.collateralAsset.address as Address, - oracle: activeMarket.oracleAddress as Address, - irm: activeMarket.irmAddress as Address, - lltv: BigInt(activeMarket.lltv), - }, - BigInt(assetsToWithdraw), - BigInt(sharesToWithdraw), - account, // onBehalf - account, // receiver - ], - }), - chainId: activeMarket.morphoBlue.chain.id, - }); - }, [account, activeMarket, position, withdrawAmount, sendWithdrawTx]); + try { + await sendWithdrawTxAsync({ + account, + to: getMorphoAddress(activeMarket.morphoBlue.chain.id as SupportedNetworks), + data: encodeFunctionData({ + abi: morphoAbi, + functionName: 'withdraw', + args: [ + { + loanToken: activeMarket.loanAsset.address as Address, + collateralToken: activeMarket.collateralAsset.address as Address, + oracle: activeMarket.oracleAddress as Address, + irm: activeMarket.irmAddress as Address, + lltv: BigInt(activeMarket.lltv), + }, + BigInt(assetsToWithdraw), + BigInt(sharesToWithdraw), + account, // onBehalf + account, // receiver + ], + }), + chainId: activeMarket.morphoBlue.chain.id, + }); + // TX submitted — ProcessModal can close, toast tracks confirmation + tracking.complete(); + } catch (error) { + tracking.fail(); + console.error('Error during withdraw:', error); + } + }, [account, activeMarket, position, withdrawAmount, sendWithdrawTxAsync, tracking]); // ── Main withdraw handler ── const handleWithdraw = useCallback(async () => { @@ -141,6 +169,8 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo return; } + const tokenSymbol = activeMarket.loanAsset.symbol; + if (needsSourcing && liquiditySourcing) { // 2-step flow: source liquidity first const extraNeeded = withdrawAmount - marketLiquidity; @@ -157,6 +187,17 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo return; } + // Start tracking the 2-step process modal + tracking.start( + WITHDRAW_STEPS_WITH_SOURCING, + { + title: `Withdraw ${tokenSymbol}`, + description: 'Source liquidity, then withdraw', + tokenSymbol, + }, + 'sourcing', + ); + setWithdrawPhase('sourcing'); try { @@ -182,6 +223,7 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo // onSuccess of the source tx hook will set phase to 'withdrawing' // and we trigger step 2 from the effect below } catch (error) { + tracking.fail(); setWithdrawPhase('idle'); console.error('Error during liquidity sourcing:', error); if (error instanceof Error && !error.message.toLowerCase().includes('rejected')) { @@ -190,9 +232,30 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo } } else { // Direct withdraw (no sourcing needed) - executeWithdraw(); + tracking.start( + WITHDRAW_STEPS_DIRECT, + { + title: `Withdraw ${tokenSymbol}`, + tokenSymbol, + }, + 'withdrawing', + ); + + await executeWithdraw(); } - }, [account, activeMarket, needsSourcing, liquiditySourcing, withdrawAmount, marketLiquidity, executeWithdraw, sendSourceTxAsync, switchChainAsync, toast]); + }, [ + account, + activeMarket, + needsSourcing, + liquiditySourcing, + withdrawAmount, + marketLiquidity, + executeWithdraw, + sendSourceTxAsync, + switchChainAsync, + toast, + tracking, + ]); const handleWithdrawClick = useCallback(() => { void handleWithdraw(); @@ -203,7 +266,7 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo useEffect(() => { if (withdrawPhase === 'withdrawing' && !isWithdrawConfirming && !hasAutoTriggeredRef.current) { hasAutoTriggeredRef.current = true; - executeWithdraw(); + void executeWithdraw(); } if (withdrawPhase === 'idle') { hasAutoTriggeredRef.current = false; @@ -213,7 +276,7 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo // Manual retry if step 2 fails after sourcing succeeded const handleRetryWithdraw = useCallback(() => { hasAutoTriggeredRef.current = true; - executeWithdraw(); + void executeWithdraw(); }, [executeWithdraw]); // Compute the reallocation plan for display (memoized) @@ -273,9 +336,7 @@ export function WithdrawModalContent({ position, market, onClose, refetch, onAmo

⚡ Will source extra liquidity from {reallocationPlan.vaultName} {reallocationPlan.fee > 0n && ( - - (fee: {formatBalance(reallocationPlan.fee, 18)} ETH) - + (fee: {formatBalance(reallocationPlan.fee, 18)} ETH) )}

)} From 4800ae329b1d4d0170a701c79e1dde411949f292 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 17:15:51 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix:=20seamless=202-step=20withdraw=20flo?= =?UTF-8?q?w=20=E2=80=94=20single=20click,=20auto-proceed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove phase-based button text (Step 1/2, Step 2/2) - Remove manual retry UI (Execute Withdraw link) - Button always shows 'Withdraw', ProcessModal handles step tracking - Auto-trigger step 2 (withdraw) after sourcing tx confirms - User clicks once, wallet prompts for each tx signature automatically --- src/modals/supply/withdraw-modal-content.tsx | 34 ++------------------ 1 file changed, 3 insertions(+), 31 deletions(-) diff --git a/src/modals/supply/withdraw-modal-content.tsx b/src/modals/supply/withdraw-modal-content.tsx index fa37cc0f..f33a6256 100644 --- a/src/modals/supply/withdraw-modal-content.tsx +++ b/src/modals/supply/withdraw-modal-content.tsx @@ -273,12 +273,6 @@ export function WithdrawModalContent({ } }, [withdrawPhase, isWithdrawConfirming, executeWithdraw]); - // Manual retry if step 2 fails after sourcing succeeded - const handleRetryWithdraw = useCallback(() => { - hasAutoTriggeredRef.current = true; - void executeWithdraw(); - }, [executeWithdraw]); - // Compute the reallocation plan for display (memoized) const reallocationPlan = useMemo(() => { if (!needsSourcing || !liquiditySourcing) return null; @@ -296,14 +290,6 @@ export function WithdrawModalContent({ ); } - // Phase-based button text - const getButtonText = () => { - if (withdrawPhase === 'sourcing') return 'Step 1/2: Sourcing...'; - if (withdrawPhase === 'withdrawing') return 'Step 2/2: Withdrawing...'; - if (needsSourcing) return 'Source & Withdraw'; - return 'Withdraw'; - }; - return (
{/* Withdraw Input Section */} @@ -340,31 +326,17 @@ export function WithdrawModalContent({ )}

)} - - {/* Phase progress for 2-step flow */} - {withdrawPhase === 'withdrawing' && !isWithdrawConfirming && ( -
-

✓ Liquidity sourced.

- -
- )}
- {getButtonText()} + Withdraw From e23344925cdf7b9653f7040ca688c1fb205bf0c8 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 17:19:01 +0800 Subject: [PATCH 06/11] feat: +PA indicator with liquidity icon and hoverable tooltip Replace plain title attr with Radix Tooltip + LuDroplets icon for the Public Allocator extra liquidity badge in the borrow modal. --- .../components/add-collateral-and-borrow.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/modals/borrow/components/add-collateral-and-borrow.tsx b/src/modals/borrow/components/add-collateral-and-borrow.tsx index 6938096d..8a6723e7 100644 --- a/src/modals/borrow/components/add-collateral-and-borrow.tsx +++ b/src/modals/borrow/components/add-collateral-and-borrow.tsx @@ -1,6 +1,8 @@ import { useState, useEffect, useCallback } from 'react'; +import { LuDroplets } from 'react-icons/lu'; import { IconSwitch } from '@/components/ui/icon-switch'; import { RefetchIcon } from '@/components/ui/refetch-icon'; +import { Tooltip } from '@/components/ui/tooltip'; import { LTVWarning } from '@/components/shared/ltv-warning'; import { MarketDetailsBlock } from '@/features/markets/components/market-details-block'; import Input from '@/components/Input/Input'; @@ -284,7 +286,12 @@ export function AddCollateralAndBorrow({ Available: {formatReadable(formatBalance(effectiveAvailableLiquidity, market.loanAsset.decimals))}{' '} {market.loanAsset.symbol} {extraLiquidity > 0n && ( - +PA + + + + +PA + + )}

@@ -300,11 +307,11 @@ export function AddCollateralAndBorrow({ max={effectiveAvailableLiquidity} /> {borrowInputError &&

{borrowInputError}

} - {borrowAmount > marketLiquidity && borrowAmount <= effectiveAvailableLiquidity && liquiditySourcing?.canSourceLiquidity && ( -

- ⚡ Sourcing extra liquidity via Public Allocator -

- )} + {borrowAmount > marketLiquidity && + borrowAmount <= effectiveAvailableLiquidity && + liquiditySourcing?.canSourceLiquidity && ( +

⚡ Sourcing extra liquidity via Public Allocator

+ )} From 0cf7de8ab19c3b6308b843064159eec05d8a60e2 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 17:21:19 +0800 Subject: [PATCH 07/11] feat: show +PA liquidity indicator in MarketDetailsBlock - Add optional extraLiquidity prop to MarketDetailsBlock - Show LuDroplets icon + '+PA' badge next to Liquidity value with tooltip showing exact extra amount available from PA vaults - Pass extraLiquidity from supply modal (withdraw mode) and borrow modal --- .../components/market-details-block.tsx | 41 +++++++++++++------ .../components/add-collateral-and-borrow.tsx | 1 + src/modals/supply/supply-modal.tsx | 1 + 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/features/markets/components/market-details-block.tsx b/src/features/markets/components/market-details-block.tsx index ccefd60e..9e52dcba 100644 --- a/src/features/markets/components/market-details-block.tsx +++ b/src/features/markets/components/market-details-block.tsx @@ -1,10 +1,12 @@ import { useState, useMemo } from 'react'; import { ChevronDownIcon, ChevronUpIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; +import { LuDroplets } from 'react-icons/lu'; import { motion, AnimatePresence } from 'framer-motion'; import { formatUnits } from 'viem'; import { useMarketCampaigns } from '@/hooks/useMarketCampaigns'; import { useAppSettings } from '@/stores/useAppSettings'; import { useRateLabel } from '@/hooks/useRateLabel'; +import { Tooltip } from '@/components/ui/tooltip'; import { formatBalance, formatReadable } from '@/utils/balance'; import { getIRMTitle, previewMarketState } from '@/utils/morpho'; import { getTruncatedAssetName } from '@/utils/oracle'; @@ -22,6 +24,8 @@ type MarketDetailsBlockProps = { disableExpansion?: boolean; supplyDelta?: bigint; borrowDelta?: bigint; + /** Extra liquidity available from Public Allocator vaults (in raw token units) */ + extraLiquidity?: bigint; }; export function MarketDetailsBlock({ @@ -33,6 +37,7 @@ export function MarketDetailsBlock({ disableExpansion = false, supplyDelta, borrowDelta, + extraLiquidity, }: MarketDetailsBlockProps): JSX.Element { const [isExpanded, setIsExpanded] = useState(!defaultCollapsed && !disableExpansion); @@ -240,19 +245,31 @@ export function MarketDetailsBlock({

Liquidity:

- {previewState !== null ? ( -

- +

+ {previewState !== null ? ( +

+ + {formatReadable(formatBalance(market.state.liquidityAssets, market.loanAsset.decimals))} + + {' → '} + {formatReadable(formatBalance(previewState.liquidityAssets.toString(), market.loanAsset.decimals))} +

+ ) : ( +

{formatReadable(formatBalance(market.state.liquidityAssets, market.loanAsset.decimals))} - - {' → '} - {formatReadable(formatBalance(previewState.liquidityAssets.toString(), market.loanAsset.decimals))} -

- ) : ( -

- {formatReadable(formatBalance(market.state.liquidityAssets, market.loanAsset.decimals))} -

- )} +

+ )} + {extraLiquidity != null && extraLiquidity > 0n && ( + + + + +PA + + + )} +

Utilization:

diff --git a/src/modals/borrow/components/add-collateral-and-borrow.tsx b/src/modals/borrow/components/add-collateral-and-borrow.tsx index 8a6723e7..825e3d06 100644 --- a/src/modals/borrow/components/add-collateral-and-borrow.tsx +++ b/src/modals/borrow/components/add-collateral-and-borrow.tsx @@ -230,6 +230,7 @@ export function AddCollateralAndBorrow({ defaultCollapsed showRewards borrowDelta={borrowAmount} + extraLiquidity={extraLiquidity} />
diff --git a/src/modals/supply/supply-modal.tsx b/src/modals/supply/supply-modal.tsx index 42880ed9..89362381 100644 --- a/src/modals/supply/supply-modal.tsx +++ b/src/modals/supply/supply-modal.tsx @@ -103,6 +103,7 @@ export function SupplyModalV2({ mode="supply" showRewards supplyDelta={supplyDelta} + extraLiquidity={mode === 'withdraw' ? liquiditySourcing?.totalAvailableExtraLiquidity : undefined} /> {mode === 'supply' ? ( From 5cc5435c0f2df36199fe976a23b65910571d9015 Mon Sep 17 00:00:00 2001 From: antoncoding Date: Wed, 28 Jan 2026 17:30:11 +0800 Subject: [PATCH 08/11] misc: fix icon --- .../markets/components/market-details-block.tsx | 6 ++---- .../borrow/components/add-collateral-and-borrow.tsx | 11 +++++++---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/features/markets/components/market-details-block.tsx b/src/features/markets/components/market-details-block.tsx index 9e52dcba..2c8fae5d 100644 --- a/src/features/markets/components/market-details-block.tsx +++ b/src/features/markets/components/market-details-block.tsx @@ -261,12 +261,10 @@ export function MarketDetailsBlock({ )} {extraLiquidity != null && extraLiquidity > 0n && ( - - - +PA - + )} diff --git a/src/modals/borrow/components/add-collateral-and-borrow.tsx b/src/modals/borrow/components/add-collateral-and-borrow.tsx index 825e3d06..23b27c2c 100644 --- a/src/modals/borrow/components/add-collateral-and-borrow.tsx +++ b/src/modals/borrow/components/add-collateral-and-borrow.tsx @@ -287,10 +287,13 @@ export function AddCollateralAndBorrow({ Available: {formatReadable(formatBalance(effectiveAvailableLiquidity, market.loanAsset.decimals))}{' '} {market.loanAsset.symbol} {extraLiquidity > 0n && ( - - - - +PA + + + + )} From 846e3c428868c8893fe35f324e1a9585b0e0da7d Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 17:43:37 +0800 Subject: [PATCH 09/11] fix: guard against undefined collateralAsset in PA sourcing Idle markets have no collateralAsset, which would cause a runtime error when building targetMarketParams. Add early return in computeReallocation when market.collateralAsset is undefined. --- src/hooks/useMarketLiquiditySourcing.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/useMarketLiquiditySourcing.ts b/src/hooks/useMarketLiquiditySourcing.ts index fd2ce1b0..7239fe7d 100644 --- a/src/hooks/useMarketLiquiditySourcing.ts +++ b/src/hooks/useMarketLiquiditySourcing.ts @@ -99,7 +99,7 @@ export function useMarketLiquiditySourcing(market: Market | undefined, network: */ const computeReallocation = useCallback( (extraAmountNeeded: bigint): ReallocationPlan | null => { - if (!market || !allocatorAddress || extraAmountNeeded <= 0n || vaultsWithPullable.length === 0) { + if (!market || !market.collateralAsset || !allocatorAddress || extraAmountNeeded <= 0n || vaultsWithPullable.length === 0) { return null; } From c0393f758dbc213764b73e115f435bac414888f2 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 17:45:24 +0800 Subject: [PATCH 10/11] fix: use zeroAddress fallback for idle market collateralAsset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Idle markets have no collateralAsset — use zeroAddress instead of skipping, so PA sourcing still works for these markets. --- src/hooks/useMarketLiquiditySourcing.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/hooks/useMarketLiquiditySourcing.ts b/src/hooks/useMarketLiquiditySourcing.ts index 7239fe7d..51db36cc 100644 --- a/src/hooks/useMarketLiquiditySourcing.ts +++ b/src/hooks/useMarketLiquiditySourcing.ts @@ -1,5 +1,5 @@ import { useMemo, useCallback } from 'react'; -import type { Address } from 'viem'; +import { type Address, zeroAddress } from 'viem'; import { usePublicAllocatorVaults, type ProcessedPublicAllocatorVault } from '@/hooks/usePublicAllocatorVaults'; import { PUBLIC_ALLOCATOR_ADDRESSES } from '@/constants/public-allocator'; import { @@ -99,7 +99,7 @@ export function useMarketLiquiditySourcing(market: Market | undefined, network: */ const computeReallocation = useCallback( (extraAmountNeeded: bigint): ReallocationPlan | null => { - if (!market || !market.collateralAsset || !allocatorAddress || extraAmountNeeded <= 0n || vaultsWithPullable.length === 0) { + if (!market || !allocatorAddress || extraAmountNeeded <= 0n || vaultsWithPullable.length === 0) { return null; } @@ -133,7 +133,7 @@ export function useMarketLiquiditySourcing(market: Market | undefined, network: // Build target market params const targetMarketParams: PAMarketParams = { loanToken: market.loanAsset.address as Address, - collateralToken: market.collateralAsset.address as Address, + collateralToken: (market.collateralAsset?.address ?? zeroAddress) as Address, oracle: market.oracleAddress as Address, irm: market.irmAddress as Address, lltv: BigInt(market.lltv), From 2d1ae8f1c7a9204a749c52afe7f92a9315b1a276 Mon Sep 17 00:00:00 2001 From: anton Date: Wed, 28 Jan 2026 17:45:43 +0800 Subject: [PATCH 11/11] fix: use z-[2000] instead of z-2000 for Tailwind v4 compat --- src/features/markets/components/market-details-block.tsx | 2 +- src/modals/borrow/components/add-collateral-and-borrow.tsx | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/features/markets/components/market-details-block.tsx b/src/features/markets/components/market-details-block.tsx index 2c8fae5d..99387c46 100644 --- a/src/features/markets/components/market-details-block.tsx +++ b/src/features/markets/components/market-details-block.tsx @@ -261,7 +261,7 @@ export function MarketDetailsBlock({ )} {extraLiquidity != null && extraLiquidity > 0n && ( diff --git a/src/modals/borrow/components/add-collateral-and-borrow.tsx b/src/modals/borrow/components/add-collateral-and-borrow.tsx index 23b27c2c..73770e98 100644 --- a/src/modals/borrow/components/add-collateral-and-borrow.tsx +++ b/src/modals/borrow/components/add-collateral-and-borrow.tsx @@ -287,12 +287,11 @@ export function AddCollateralAndBorrow({ Available: {formatReadable(formatBalance(effectiveAvailableLiquidity, market.loanAsset.decimals))}{' '} {market.loanAsset.symbol} {extraLiquidity > 0n && ( - - - +