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 && (
-
-
-
+