Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/components/EarnIntentWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ export function EarnIntentWidget(props: EarnIntentWidgetProps) {
content: (
<MarketPickerPage
rows={picker.rows}
vaults={picker.vaults}
vaultsLoading={picker.vaultsLoading}
vaultsError={picker.vaultsError}
selectedId={earnSelectedMarket?.id}
isLoading={picker.isLoading}
error={picker.error}
Expand All @@ -221,6 +224,10 @@ export function EarnIntentWidget(props: EarnIntentWidgetProps) {
setEarnSelectedMarket(m);
setView("main");
}}
onSelectVault={(m) => {
setEarnSelectedMarket(m);
setView("main");
}}
/>
),
};
Expand Down
48 changes: 39 additions & 9 deletions src/components/MarketPickerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import type {
} from "@epoch-protocol/epoch-flows-sdk";
import type {
EpochEarnMarket,
EpochEarnVault,
OneDeltaConfig,
OneDeltaMarketRow,
} from "../types";
import { MarketRowCard } from "./earn/MarketRowCard";
import { VaultMarketRowCard } from "./earn/VaultMarketRowCard";
import { ArrowDownUpIcon } from "./Icons";
import { FilterDropdown, type FilterOption } from "./ui/FilterDropdown";
import { SearchInput } from "./ui/SearchInput";
Expand All @@ -29,6 +31,9 @@ interface Props {
* re-sort, re-filter (except the page-local text search), or slice.
*/
rows: EarnMarketRow[];
vaults?: EpochEarnVault[];
vaultsLoading?: boolean;
vaultsError?: Error | null;
selectedId?: string;
isLoading: boolean;
error: Error | null;
Expand All @@ -37,6 +42,7 @@ interface Props {
row: OneDeltaMarketRow,
config: OneDeltaConfig,
) => void;
onSelectVault?: (market: EpochEarnMarket) => void;

/** Chain filter — forwarded to the API as `chainId` (or all). */
chainFilter: number | "all";
Expand Down Expand Up @@ -172,10 +178,14 @@ function rowMatches(item: EarnMarketRow, query: string): boolean {

export function MarketPickerPage({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/no-high-complexity-react-function (warning)

MarketPickerPage has cyclomatic complexity 18, cognitive complexity 23, and maximum nesting depth 4, so its React logic is hard to understand and change. Extract independent branches into components or hooks.

Fix → Extract independent render branches and state logic into focused components or hooks until the control flow is easy to follow.

Docs

rows,
vaults = [],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

React Doctor · react-doctor/rerender-memo-with-default-value (warning)

This reruns hooks that list it in their dependency array because default prop value [] makes a brand new array every render, so move it to a constant at the top of the file

Fix → Move it to the top of the file: const EMPTY_ITEMS: Item[] = [], then use that as the default value

Docs

vaultsLoading = false,
vaultsError = null,
selectedId,
isLoading,
error,
onSelect,
onSelectVault,
chainFilter,
onChainChange,
lenderFilter,
Expand Down Expand Up @@ -241,17 +251,14 @@ export function MarketPickerPage({
() => rows.filter((item) => rowMatches(item, query)),
[rows, query],
);

if (error) {
return (
<p className="m-0 text-[13px] text-error">
Failed to load markets: {error.message}
</p>
);
}
const visibleVaults = useMemo(() => {
const q = query.trim().toLowerCase();
return vaults.filter((v) => !q || `${v.share.symbol} ${v.share.address} ${v.underlying.symbol} ${v.provider} ${v.providerName}`.toLowerCase().includes(q));
}, [vaults, query]);

return (
<div className="flex flex-col gap-4">
{error && <p className="m-0 text-[13px] text-error">Failed to load lending markets: {error.message}</p>}
<SearchInput
value={query}
onChange={setQuery}
Expand Down Expand Up @@ -294,7 +301,7 @@ export function MarketPickerPage({
/>
</div>

{isLoading ? (
{error ? null : isLoading ? (
<div className="flex flex-col gap-2">
<Skeleton width="100%" height={72} radius="var(--epoch-radius-sm)" />
<Skeleton width="100%" height={72} radius="var(--epoch-radius-sm)" />
Expand Down Expand Up @@ -349,6 +356,29 @@ export function MarketPickerPage({
</div>
</div>
)}

<div className="flex items-center justify-between gap-2 border-t border-line pt-3">
<span className={SECTION_LABEL}>Synchronous vaults</span>
</div>
{vaultsError && <p className="m-0 text-[12px] text-fg-muted">Vault listings unavailable: {vaultsError.message}</p>}
{vaultsLoading ? (
<div className="flex flex-col gap-2"><Skeleton width="100%" height={72} radius="var(--epoch-radius-sm)" /><Skeleton width="100%" height={72} radius="var(--epoch-radius-sm)" /></div>
) : visibleVaults.length === 0 ? (
<p className="my-2 text-[13px] text-fg-muted">No synchronous vaults found on this page.</p>
) : (
<div className="flex flex-col overflow-x-hidden">
{visibleVaults.map((vault) => (
<VaultMarketRowCard key={vault.id} vault={vault} selected={selectedId === vault.id} onClick={() => onSelectVault?.({
id: vault.id, displayName: vault.share.symbol, chainLabel: getEpochChainById(vault.chainId)?.name ?? `Chain ${vault.chainId}`,
aprDecimal: vault.apyPercent / 100, vaultAddress: vault.vaultAddress, underlyingAddress: vault.underlying.address,
productKind: 'vault', providerKey: vault.provider,
token: { address: vault.underlying.address, symbol: vault.underlying.symbol, decimals: vault.underlying.decimals, logoURI: vault.underlying.logoURI },
destinationChainName: getEpochChainById(vault.chainId)?.name ?? `Chain ${vault.chainId}`, chainId: vault.chainId,
lenderName: vault.providerName,
})} />
))}
</div>
)}
</div>
);
}
7 changes: 2 additions & 5 deletions src/components/WithdrawDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,9 @@ export function WithdrawDetailPanel({
approxUsd={approxUsd}
/>

<SmartWithdrawToggle
enabled={smartWithdraw}
onChange={onSmartWithdrawChange}
/>
{market.productKind !== 'vault' && <SmartWithdrawToggle enabled={smartWithdraw} onChange={onSmartWithdrawChange} />}

{smartWithdraw && (
{market.productKind !== 'vault' && smartWithdraw && (
<SmartWithdrawDestination
smartDestChainId={smartDestChainId}
smartDestTokenAddress={smartDestTokenAddress}
Expand Down
1 change: 1 addition & 0 deletions src/components/earn/EarnMainView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export function EarnMainView({
</>
) : (
<>
{positionsState.partial && <p role="status" className="m-0 text-[12px] text-fg-muted">Some position sources did not respond. Showing available positions.</p>}
<GaslessSection
allowed={effectiveAllowGasless}
wallet={gaslessWallet}
Expand Down
19 changes: 19 additions & 0 deletions src/components/earn/VaultMarketRowCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { EpochEarnVault } from '../../types';
import { getEpochChainById } from '../../epoch-config';
import { cn } from '../../lib/cn';
import { TrendingUpIcon } from '../Icons';
import { TokenAvatar } from '../ui/TokenAvatar';

export function VaultMarketRowCard({ vault, selected, onClick }: { vault: EpochEarnVault; selected?: boolean; onClick: () => void }) {
const chain = getEpochChainById(vault.chainId);
const provider = vault.provider === 'euler-earn' ? 'Euler Earn' : vault.provider[0].toUpperCase() + vault.provider.slice(1);
return <button type="button" onClick={onClick} aria-label={`Select ${vault.share.symbol} vault from ${provider}`}
className={cn('group relative box-border flex w-full cursor-pointer items-center gap-3.5 rounded-sm border-0 border-b border-line bg-transparent px-2 py-3 pl-3 text-left transition-colors hover:bg-surface-muted', selected && 'bg-accent-soft')}>
<TokenAvatar src={vault.underlying.logoURI} symbol={vault.underlying.symbol} size={40} chainSrc={chain?.logoURI} chainAlt={chain?.name} />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-[15px] font-semibold text-fg">{vault.share.symbol}</span>
<span className="truncate text-[12px] text-fg-muted">{chain?.name ?? `Chain ${vault.chainId}`} · {provider} · {vault.underlying.symbol}</span>
</div>
<span className="inline-flex shrink-0 items-center gap-1 text-[14px] font-bold tabular-nums text-success"><TrendingUpIcon />{vault.apyPercent.toFixed(vault.apyPercent >= 10 ? 1 : 2)}%</span>
</button>;
}
76 changes: 67 additions & 9 deletions src/earn/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {
fetchLendingPoolsPage,
fetchLendingPoolsPageMulti,
fetchUserPositions,
fetchVaultPositions,
fetchVaultsPage,
flattenConfigsToMarkets,
HARDCODED_ONEDELTA_CONFIGS,
} from '@epoch-protocol/epoch-flows-sdk';
Expand All @@ -12,10 +14,13 @@ import type {
PoolSortBy,
PoolSortDir,
} from '@epoch-protocol/epoch-flows-sdk';
import { getEpochChainById } from '../epoch-config';
import type {
ApiConfig,
EpochEarnMarket,
EpochEarnPosition,
EpochEarnVault,
EpochEarnVaultPosition,
EpochEarnPositionsSummary,
OneDeltaConfig,
} from '../types';
Expand Down Expand Up @@ -58,6 +63,43 @@ export interface PositionsState {
summary: EpochEarnPositionsSummary | null;
isLoading: boolean;
error: Error | null;
partial?: boolean;
}

export interface VaultsPageState {
vaults: EpochEarnVault[];
hasMore: boolean;
isLoading: boolean;
error: Error | null;
}

export function useVaultsPage(opts: {
api: ApiConfig; enabled?: boolean; chainIds: number[]; start: number; limit: number;
}): VaultsPageState {
const positionsBaseUrl = opts.api.positionsBaseUrl?.replace(/\/$/, '');
const chainIdsKey = opts.chainIds.join(',');
const [state, setState] = useState<VaultsPageState>({ vaults: [], hasMore: false, isLoading: false, error: null });
const requestId = useRef(0);
useEffect(() => {
if (!opts.enabled || !positionsBaseUrl || !chainIdsKey) {
setState({ vaults: [], hasMore: false, isLoading: false, error: null });
return;
}
const id = ++requestId.current;
const controller = new AbortController();
setState((old) => ({ ...old, isLoading: true, error: null }));
Promise.allSettled(chainIdsKey.split(',').map((chain) => fetchVaultsPage({
positionsBaseUrl, chainId: Number(chain), start: opts.start, limit: opts.limit, signal: controller.signal,
}))).then((results) => {
if (id !== requestId.current) return;
const successful = results.flatMap((r) => r.status === 'fulfilled' ? [r.value] : []);
const failures = results.filter((r) => r.status === 'rejected');
setState({ vaults: successful.flatMap((r) => r.items), hasMore: successful.some((r) => r.hasMore), isLoading: false,
error: successful.length ? null : failures.length ? new Error(String((failures[0] as PromiseRejectedResult).reason)) : null });
});
return () => { controller.abort(); };
}, [positionsBaseUrl, opts.enabled, chainIdsKey, opts.start, opts.limit]);
return state;
}

/**
Expand Down Expand Up @@ -434,17 +476,33 @@ export function useUserPositions(opts: {

const run = async () => {
try {
const { positions, summary } = await fetchUserPositions({
address,
network,
positionsBaseUrl,
configs,
chainsOverride,
lendersOverride,
signal: controller.signal,
const lendingPromise = fetchUserPositions({
address, network, positionsBaseUrl, configs, chainsOverride, lendersOverride, signal: controller.signal,
});
const chainIds = (chainsOverride ?? '').split(',').map(Number).filter((n) => Number.isSafeInteger(n) && n > 0);
const vaultPromise = positionsBaseUrl && network === 'mainnet'
? fetchVaultPositions({ positionsBaseUrl, account: address, chainIds, signal: controller.signal })
: Promise.resolve({ items: [] as EpochEarnVaultPosition[], partial: false, failedChains: [] as number[] });
const [lendingResult, vaultResult] = await Promise.allSettled([lendingPromise, vaultPromise]);
if (cancelled || id !== reqIdRef.current) return;
setState({ positions, summary, isLoading: false, error: null });
const lending = lendingResult.status === 'fulfilled' ? lendingResult.value : { positions: [], summary: null };
const vaults = vaultResult.status === 'fulfilled' ? vaultResult.value : { items: [], partial: true, failedChains: chainIds };
const vaultPositions = vaults.items.filter((p) => BigInt(p.sharesRaw) > 0n).map((p) => {
const chainName = getEpochChainById(p.chainId)?.name ?? `Chain ${p.chainId}`;
const providerName = p.provider === 'euler-earn' ? 'Euler Earn' : p.provider[0].toUpperCase() + p.provider.slice(1);
const market: EpochEarnMarket = {
id: `vault:${p.chainId}:${p.vaultAddress.toLowerCase()}`, displayName: `${p.shareSymbol} · ${providerName}`,
chainLabel: chainName, aprDecimal: 0, vaultAddress: p.vaultAddress, underlyingAddress: p.underlyingAddress,
productKind: 'vault', providerKey: p.provider,
token: { address: p.underlyingAddress, symbol: p.underlyingSymbol, decimals: p.underlyingDecimals },
destinationChainName: chainName, chainId: p.chainId, lenderName: providerName,
};
return { id: market.id, market, shareBalanceRaw: p.sharesRaw, underlyingBalanceRaw: p.assetsRaw,
withdrawableRaw: p.assetsRaw, underlyingUsdValue: p.usdValue } as EpochEarnPosition;
});
const lendingFailed = lendingResult.status === 'rejected';
setState({ positions: [...lending.positions, ...vaultPositions], summary: lending.summary, isLoading: false,
error: null, partial: lendingFailed || vaults.partial || vaultResult.status === 'rejected' });
} catch (err) {
if (cancelled || id !== reqIdRef.current) return;
if (err instanceof DOMException && err.name === 'AbortError') return;
Expand Down
46 changes: 24 additions & 22 deletions src/earn/use-earn-intent-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ const swLog = (stage: string, data?: Record<string, unknown>) => {
function isSmartWithdrawInput(input: EarnQuoteInput): boolean {
return (
input.tab === 'withdraw' &&
(input.market ?? input.position?.market)?.productKind !== 'vault' &&
input.smartWithdraw === true &&
input.smartDestChainId != null &&
(!!input.smartDestTokenAddress || !!input.midenDest)
Expand Down Expand Up @@ -346,8 +347,9 @@ export function useEarnIntentFlow({
(input: EarnQuoteInput) => {
const market = input.market ?? input.position?.market;
if (!market) throw new Error('Market missing');
const isVault = market.productKind === 'vault';
const marketUid = market.oneDeltaMarketUid;
if (!marketUid) throw new Error('Market is missing the 1delta marketUid');
if (!isVault && !marketUid) throw new Error('Market is missing the 1delta marketUid');

const sourceToken = input.sourceToken;
if (!sourceToken) throw new Error('Source token missing');
Expand All @@ -359,7 +361,7 @@ export function useEarnIntentFlow({
// tuple and settle against another. Fail fast at intent-build time with
// a message that points at the offending field — easier than chasing a
// revert in the solver.
const muidParts = marketUid.split(':');
const muidParts = marketUid?.split(':') ?? [];
const muidChain = muidParts[1];
const muidUnderlying = muidParts[2];
if (
Expand All @@ -384,11 +386,15 @@ export function useEarnIntentFlow({

const isWithdraw = input.tab === 'withdraw';
const isMidenDeposit = !isWithdraw && !!input.midenSource;
if (isVault && input.midenSource) throw new Error('Synchronous vault deposits require an EVM source token');

const protocolHashIdentifier = keccak256(toBytes(earnProtocolName(marketUid)));
const underlyingAddress = market.token.address as string;
const actionChainId =
market.chainId != null ? String(market.chainId) : muidChain ?? '1';
const protocolHashIdentifier = keccak256(toBytes(isVault ? ONEDELTA_PROTOCOL_NAME : earnProtocolName(marketUid!)));
const underlyingAddress = (isVault ? market.underlyingAddress : market.token.address) as string;
if (isVault && (!underlyingAddress || !/^0x[\da-f]{40}$/i.test(underlyingAddress) || !/^0x[\da-f]{40}$/i.test(market.vaultAddress))) {
throw new Error('Vault selection is missing valid vault or underlying addresses');
}
const actionChainId = market.chainId != null ? String(market.chainId) : muidChain ?? '1';
if (isVault && market.chainId == null) throw new Error('Vault selection is missing its chain ID');

// Smart Withdraw is handled by `submitSmartWithdraw` (a 2-leg batch:
// withdraw → cross-chain swap), so buildParams only ever builds the plain
Expand All @@ -407,18 +413,16 @@ export function useEarnIntentFlow({
const baseTypestring = isWithdraw
? WITHDRAW_EXTRADATA_TYPESTRING
: DEPOSIT_EXTRADATA_TYPESTRING;
const extraDataTypestring = isMidenDeposit
? `${baseTypestring},${EARN_MIDEN_EXTRA_FIELDS.join(',')}`
: baseTypestring;

const extraData: Record<string, string | boolean> = {
marketUid,
action: input.tab,
payAsset,
// Declared by the canonical typestring, so it must be present. Pinned
// false: a full-exit withdraw resolves its size at execution, and
// deposits pass a real amount either way.
isAll: false,
const vaultTypestring = 'string productKind,string action,uint256 chainId,address vault,address underlying,address payAsset,bool isAll';
const extraDataTypestring = isVault
? vaultTypestring
: isMidenDeposit ? `${baseTypestring},${EARN_MIDEN_EXTRA_FIELDS.join(',')}` : baseTypestring;

const extraData: Record<string, string | boolean | number> = isVault ? {
productKind: 'vault', action: input.tab, chainId: Number(actionChainId),
vault: market.vaultAddress, underlying: underlyingAddress, payAsset, isAll: false,
} : {
marketUid: marketUid!, action: input.tab, payAsset, isAll: false,
};
if (isMidenDeposit && input.midenSource) {
extraData.midenSourceAccount = normalizeMidenId(input.midenSource.accountId);
Expand All @@ -427,17 +431,15 @@ export function useEarnIntentFlow({
extraData.midenNoteId = '';
}

const tokenInDecimals = isMidenDeposit
? input.midenSource!.decimals
: sourceToken.decimals;
const tokenInDecimals = isMidenDeposit ? input.midenSource!.decimals : isWithdraw ? market.token.decimals : sourceToken.decimals;
const tokenInAmount = parseUnits(
input.amount.trim().replace(/,/g, ''),
tokenInDecimals,
).toString();

const tokenInAddress = isMidenDeposit
? (EVM_ZERO_ADDRESS as `0x${string}`)
: (sourceToken.address as `0x${string}`);
: (isWithdraw ? underlyingAddress : sourceToken.address) as `0x${string}`;

return {
tokenInAmount,
Expand Down
Loading
Loading