From e55038a35bf303dc4909bc4f9f93ad2f8467dfee Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Thu, 16 Jul 2026 07:55:39 +0530 Subject: [PATCH 01/13] refactor: fix react-doctor findings and split pay/swap widget Extract source/destination/CTA/engine hooks out of PaySwapIntentWidget (918 -> 220 lines), share latest-ref/token-pick/ActionButton across flows, and remove dead code. react-doctor: 111 -> 10 issues. --- demo/src/app/App.tsx | 2 +- demo/src/components/Detail.tsx | 8 - demo/src/components/MultiSelectDropdown.tsx | 34 +- demo/src/earn/useEarnMidenAdapter.ts | 9 +- demo/src/lib/format.ts | 5 - demo/src/lib/isLocalAllocatorHost.ts | 10 - demo/src/miden/MidenBridgePanel.tsx | 126 +- .../miden/components/MidenBridgeFields.tsx | 186 +++ demo/src/miden/constants/miden-tokens.ts | 3 - demo/src/surfaces/EarnSurface.tsx | 5 +- demo/src/surfaces/SwapSurface.tsx | 1 - package.json | 5 +- src/EpochIntentWidget.tsx | 10 +- src/components/ChainSelector.tsx | 1 + src/components/Dropdown.tsx | 35 +- src/components/EarnIntentWidget.tsx | 1129 +++++------------ src/components/GaslessSection.tsx | 33 + src/components/GaslessToggle.tsx | 19 +- src/components/Icons.tsx | 2 + src/components/IntentProgress.tsx | 55 + src/components/MarketPickerPage.tsx | 6 +- src/components/NetworkToggle.tsx | 16 +- src/components/PaySwapIntentWidget.tsx | 1021 +++------------ src/components/ProgressStepper.tsx | 2 +- src/components/SwapIntentSummary.tsx | 23 +- src/components/SwapIntentWidget.tsx | 11 +- src/components/earn/EarnCtaButton.tsx | 55 + src/components/earn/MidenAssetPicker.tsx | 31 + src/components/pay/PaySwapMainView.tsx | 96 ++ src/components/pay/TokenPickerModal.tsx | 37 + src/components/ui/ActionButton.tsx | 56 + src/components/ui/Card.tsx | 17 + src/earn/api.ts | 14 +- src/earn/earn-chains.ts | 31 + src/earn/earn-cta.ts | 182 +++ src/earn/use-earn-intent-flow.ts | 496 ++------ src/earn/use-earn-market-picker.ts | 267 ++++ src/earn/use-earn-miden.ts | 240 ++++ src/earn/use-earn-quote-target.ts | 177 +++ src/hooks/use-latest-ref.ts | 25 + src/hooks/use-on-open.ts | 19 + src/hooks/use-token-pick.ts | 57 + src/pay/format-usd.ts | 25 + src/pay/pay-swap-cta.ts | 140 ++ src/pay/pay-swap-props.ts | 49 + src/pay/pay-swap-variants.tsx | 119 ++ src/pay/resolve-default-source.ts | 45 + src/pay/use-destination-selection.ts | 162 +++ src/pay/use-pay-swap-callbacks.ts | 104 ++ src/pay/use-pay-swap-engine.ts | 329 +++++ src/pay/use-quote-auto-fetch.ts | 61 + src/pay/use-source-selection.ts | 113 ++ src/use-intent-flow.ts | 42 +- src/use-token-balance.ts | 19 +- test/pay-swap-cta.test.ts | 168 +++ 55 files changed, 3626 insertions(+), 2307 deletions(-) delete mode 100644 demo/src/components/Detail.tsx delete mode 100644 demo/src/lib/format.ts delete mode 100644 demo/src/lib/isLocalAllocatorHost.ts create mode 100644 demo/src/miden/components/MidenBridgeFields.tsx create mode 100644 src/components/GaslessSection.tsx create mode 100644 src/components/IntentProgress.tsx create mode 100644 src/components/earn/EarnCtaButton.tsx create mode 100644 src/components/earn/MidenAssetPicker.tsx create mode 100644 src/components/pay/PaySwapMainView.tsx create mode 100644 src/components/pay/TokenPickerModal.tsx create mode 100644 src/components/ui/ActionButton.tsx create mode 100644 src/earn/earn-chains.ts create mode 100644 src/earn/earn-cta.ts create mode 100644 src/earn/use-earn-market-picker.ts create mode 100644 src/earn/use-earn-miden.ts create mode 100644 src/earn/use-earn-quote-target.ts create mode 100644 src/hooks/use-latest-ref.ts create mode 100644 src/hooks/use-on-open.ts create mode 100644 src/hooks/use-token-pick.ts create mode 100644 src/pay/format-usd.ts create mode 100644 src/pay/pay-swap-cta.ts create mode 100644 src/pay/pay-swap-props.ts create mode 100644 src/pay/pay-swap-variants.tsx create mode 100644 src/pay/resolve-default-source.ts create mode 100644 src/pay/use-destination-selection.ts create mode 100644 src/pay/use-pay-swap-callbacks.ts create mode 100644 src/pay/use-pay-swap-engine.ts create mode 100644 src/pay/use-quote-auto-fetch.ts create mode 100644 src/pay/use-source-selection.ts create mode 100644 test/pay-swap-cta.test.ts diff --git a/demo/src/app/App.tsx b/demo/src/app/App.tsx index 4433fb9..88d9340 100644 --- a/demo/src/app/App.tsx +++ b/demo/src/app/App.tsx @@ -90,7 +90,7 @@ export default function App() { /> )} {surface === 'swap' && ( - + )} {surface === 'earn' && ( diff --git a/demo/src/components/Detail.tsx b/demo/src/components/Detail.tsx deleted file mode 100644 index ce64ca8..0000000 --- a/demo/src/components/Detail.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export function Detail({ label, value }: { label: string; value: string }) { - return ( -
-
{label}
-
{value}
-
- ); -} diff --git a/demo/src/components/MultiSelectDropdown.tsx b/demo/src/components/MultiSelectDropdown.tsx index 7175e62..aa51c3e 100644 --- a/demo/src/components/MultiSelectDropdown.tsx +++ b/demo/src/components/MultiSelectDropdown.tsx @@ -1,4 +1,4 @@ -import { useEffect, useId, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; export interface MultiSelectOption { value: string; @@ -28,15 +28,23 @@ export function MultiSelectDropdown({ const [query, setQuery] = useState(''); + // Closing always clears the search so the next open is clean. Both setters + // fire together here rather than letting an effect react to `open`, so React + // batches them into a single render. + const close = useCallback(() => { + setOpen(false); + setQuery(''); + }, []); + useEffect(() => { if (!open) return; const onDocClick = (e: MouseEvent) => { - if (!wrapRef.current?.contains(e.target as Node)) setOpen(false); + if (!wrapRef.current?.contains(e.target as Node)) close(); }; const onEsc = (e: KeyboardEvent) => { if (e.key === 'Escape') { if (query) setQuery(''); - else setOpen(false); + else close(); } }; document.addEventListener('mousedown', onDocClick); @@ -45,12 +53,7 @@ export function MultiSelectDropdown({ document.removeEventListener('mousedown', onDocClick); document.removeEventListener('keydown', onEsc); }; - }, [open, query]); - - // Reset search when the dropdown closes so the next open is clean. - useEffect(() => { - if (!open) setQuery(''); - }, [open]); + }, [open, query, close]); const filteredOptions = query.trim().length === 0 @@ -63,8 +66,12 @@ export function MultiSelectDropdown({ ); }); + // Membership is tested once per option while rendering the list, so a Set + // keeps each check O(1) instead of rescanning `selected` every row. + const selectedSet = useMemo(() => new Set(selected), [selected]); + const toggle = (value: string) => { - const next = selected.includes(value) + const next = selectedSet.has(value) ? selected.filter((v) => v !== value) : [...selected, value]; onChange(next); @@ -77,8 +84,7 @@ export function MultiSelectDropdown({ ? `All (${selected.length})` : selected.length <= 2 ? options - .filter((o) => selected.includes(o.value)) - .map((o) => o.label) + .flatMap((o) => (selectedSet.has(o.value) ? [o.label] : [])) .join(', ') : `${selected.length} selected`; @@ -94,7 +100,7 @@ export function MultiSelectDropdown({ - )} - + epoch.clearQuote()} + /> {epoch.pendingQuote && (
diff --git a/demo/src/miden/components/MidenBridgeFields.tsx b/demo/src/miden/components/MidenBridgeFields.tsx new file mode 100644 index 0000000..d37e7be --- /dev/null +++ b/demo/src/miden/components/MidenBridgeFields.tsx @@ -0,0 +1,186 @@ +import { useId } from "react"; +import type { MidenWalletAsset } from "../hooks/useMidenWalletAdapter"; + +interface OutputToken { + symbol: string; + address: string; + decimals: number; +} + +interface MidenBridgeFieldsProps { + assets: MidenWalletAsset[]; + isLoadingAssets: boolean; + outputTokens: ReadonlyArray; + selectedAssetId: string; + setSelectedAssetId: (v: string) => void; + outputToken: string; + setOutputToken: (v: string) => void; + minTokenOut: string; + setMinTokenOut: (v: string) => void; + chainId: string; + setChainId: (v: string) => void; + evmAddress: string; + setEvmAddress: (v: string) => void; + /** The connected EVM wallet, offered as a one-click recipient. */ + connectedAddress?: string; + selectedAssetBalance?: bigint; + /** Any edit invalidates an outstanding quote. */ + onDirty: () => void; +} + +/** + * The bridge form: source asset, output token, minimum output, destination. + * + * Every field calls `onDirty` so a pending quote can't survive an edit that + * would change it — the panel drops the quote rather than letting the user + * confirm terms they can no longer see. + */ +export function MidenBridgeFields({ + assets, + isLoadingAssets, + outputTokens, + selectedAssetId, + setSelectedAssetId, + outputToken, + setOutputToken, + minTokenOut, + setMinTokenOut, + chainId, + setChainId, + evmAddress, + setEvmAddress, + connectedAddress, + selectedAssetBalance, + onDirty, +}: MidenBridgeFieldsProps) { + const sourceAssetId = useId(); + const outputTokenId = useId(); + const minOutputId = useId(); + const destChainId = useId(); + const destAddressId = useId(); + + return ( + <> +
+ + +
+ Balance: {selectedAssetBalance?.toString() ?? "—"} +
+
+ +
+
+ + +
+
+ + { + setMinTokenOut(e.target.value); + onDirty(); + }} + /> +
+
+ +
+ + { + setChainId(e.target.value); + onDirty(); + }} + /> +
+ +
+ + { + setEvmAddress(e.target.value); + onDirty(); + }} + placeholder={connectedAddress ?? "0x…"} + /> + {connectedAddress && ( + + )} +
+ + ); +} diff --git a/demo/src/miden/constants/miden-tokens.ts b/demo/src/miden/constants/miden-tokens.ts index 1720a27..770bd6d 100644 --- a/demo/src/miden/constants/miden-tokens.ts +++ b/demo/src/miden/constants/miden-tokens.ts @@ -22,9 +22,6 @@ const MIDEN_FAUCET_DECIMALS: Record = { "2458e5446128e6b150b75b8ebd9ce1": 6, // MIDEN }; -/** @deprecated Prefer `DEFAULT_MIDEN_FAUCET` from `@epoch-protocol/epoch-intent-widget`. */ -export const MIDEN_USDC_FAUCET_ID = "0x2458e5446128e6b150b75b8ebd9ce1"; - function toMapKey(faucetId: string): string { const hex = normalizeMidenIdToHex(faucetId); const lower = hex.trim().toLowerCase(); diff --git a/demo/src/surfaces/EarnSurface.tsx b/demo/src/surfaces/EarnSurface.tsx index e5cb625..aa9beb8 100644 --- a/demo/src/surfaces/EarnSurface.tsx +++ b/demo/src/surfaces/EarnSurface.tsx @@ -94,7 +94,10 @@ export function EarnSurface({ onOpenWidget }: Props) { const [lenders, setLenders] = useState([]); const earnChainIds = chains.length - ? chains.map((c) => Number(c)).filter(Number.isFinite) + ? chains.flatMap((c) => { + const id = Number(c); + return Number.isFinite(id) ? [id] : []; + }) : undefined; const earnLenderFilter = lenders.length ? lenders.join(',') : undefined; diff --git a/demo/src/surfaces/SwapSurface.tsx b/demo/src/surfaces/SwapSurface.tsx index 5d21efa..58e8bec 100644 --- a/demo/src/surfaces/SwapSurface.tsx +++ b/demo/src/surfaces/SwapSurface.tsx @@ -7,7 +7,6 @@ import { Row } from '../components/Row'; import type { DemoNetwork } from '../app/AppShell'; interface Props { - apiBaseUrl: string; onOpenWidget: (props: ScenarioProps) => void; network: DemoNetwork; } diff --git a/package.json b/package.json index ca6dfb0..e8c44b3 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,10 @@ "test:gasless": "node --import tsx --test test/GaslessToggle.test.tsx", "prepublishOnly": "npm run build", "predemo": "npm run build:css", - "demo": "npm-run-all -p dev:css \"cd demo && npm run dev\"" + "demo": "npm-run-all -p dev:css \"cd demo && npm run dev\"", + "doctor": "npx react-doctor@latest", + "test:swap": "node --import tsx --test test/pay-swap-cta.test.ts", + "test": "node --import tsx --test test/*.test.ts test/*.test.tsx" }, "dependencies": { "@epoch-protocol/epoch-commons-sdk": "^0.1.17", diff --git a/src/EpochIntentWidget.tsx b/src/EpochIntentWidget.tsx index 795a782..a6b8f36 100644 --- a/src/EpochIntentWidget.tsx +++ b/src/EpochIntentWidget.tsx @@ -1,5 +1,5 @@ -import { useEffect, useRef } from 'react'; import { useAccount } from 'wagmi'; +import { useOnOpen } from './hooks/use-on-open'; import { Modal } from './components/Modal'; import { WalletConnectorPanel } from './components/wallet/WalletConnectorPanel'; import { EarnIntentWidget } from './components/EarnIntentWidget'; @@ -68,13 +68,7 @@ export function EpochIntentWidget(props: EpochIntentWidgetProps) { rawMode === 'pay' || rawMode === 'swap' || rawMode === 'earn' ? rawMode : 'pay'; const { isConnected } = useAccount(); - const onOpenRef = useRef(onOpen); - onOpenRef.current = onOpen; - const wasOpenRef = useRef(false); - useEffect(() => { - if (isOpen && !wasOpenRef.current) onOpenRef.current?.(); - wasOpenRef.current = isOpen; - }, [isOpen]); + useOnOpen(isOpen, onOpen); if (!isOpen) { return null; diff --git a/src/components/ChainSelector.tsx b/src/components/ChainSelector.tsx index f4bcfb9..c4f74e8 100644 --- a/src/components/ChainSelector.tsx +++ b/src/components/ChainSelector.tsx @@ -33,6 +33,7 @@ export function ChainSelector({ chains, selectedChainId, onSelect, onBack }: Cha - {inlineError && ( -
- - {inlineError} -
- )} -
+ ); // Busy/quoting states win first (smart-aware progress copy); otherwise defer @@ -1261,41 +756,33 @@ export function EarnIntentWidget({ ? detailFooter : undefined; + // Chrome every view shares. Spread rather than retyped per branch, so a modal + // prop can't end up wired on three views and forgotten on the fourth. + const modalChrome = { + isOpen, + onClose, + theme, + classNames: cn, + renderInline, + }; + const backToMain = () => setView("main"); + if (view === "selectToken") { const isMidenPicker = fundingSource === "miden" && midenEnabled; return ( setView("main")} - renderInline={renderInline} + onBack={backToMain} > {isMidenPicker ? ( -
    - {(midenAssets.length > 0 - ? midenAssets - : [DEFAULT_MIDEN_FAUCET] - ).map((asset) => ( -
  • - -
  • - ))} -
+ 0 ? miden.assets : [DEFAULT_MIDEN_FAUCET]} + onSelect={(faucetId) => { + setSelectedMidenFaucetId(faucetId); + setView("main"); + }} + /> ) : ( { setSelectedChainId(cid); - setSelectedTokenAddress(addr); + setTokenAddressPick(addr); setView("main"); }} - onBack={() => setView("main")} + onBack={backToMain} /> )}
@@ -1316,33 +803,24 @@ export function EarnIntentWidget({ if (view === "withdrawDetail" && selectedPosition) { return ( { setView("main"); - setSelectedPosition(null); + selectPosition(null); setWithdrawAmount(""); - setWithdrawIsAll(false); setSmartWithdraw(false); - setSmartDestChainId(null); - setSmartDestTokenAddress(""); }} - renderInline={renderInline} > { setWithdrawAmount(v); - setWithdrawIsAll(false); }} - onPickFraction={(human, isMax) => { + onPickFraction={(human) => { setWithdrawAmount(human); - setWithdrawIsAll(isMax); }} onPickAnotherPosition={() => { // Open the picker without discarding the current selection — if @@ -1351,7 +829,7 @@ export function EarnIntentWidget({ setView("main"); }} smartWithdraw={smartWithdraw} - onSmartWithdrawChange={setSmartWithdraw} + onSmartWithdrawChange={handleSmartWithdrawChange} smartDestChainId={smartDestChainId} smartDestTokenAddress={smartDestTokenAddress} onPickDestChain={(id) => { @@ -1360,7 +838,7 @@ export function EarnIntentWidget({ // surface a stale token from another network. Miden → default faucet. if (id === MIDEN_VIRTUAL_CHAIN_ID) { setSmartDestTokenAddress( - midenDestFaucets[0]?.faucetId ?? DEFAULT_MIDEN_FAUCET.faucetId, + miden.destFaucets[0]?.faucetId ?? DEFAULT_MIDEN_FAUCET.faucetId, ); } else { const firstTok = getEpochTokensByChainEnv(id, isTestnet)[0]; @@ -1369,9 +847,9 @@ export function EarnIntentWidget({ }} onPickDestToken={setSmartDestTokenAddress} isTestnet={isTestnet} - midenDestEnabled={midenDestEnabled} + midenDestEnabled={miden.destEnabled} midenRecipientAccount={earnMiden?.accountId} - midenFaucets={midenDestFaucets} + midenFaucets={miden.destFaucets} buildError={withdrawBuildError} quoteError={earnFlow.quoteError} isQuoting={earnFlow.isQuoting} @@ -1418,13 +896,9 @@ export function EarnIntentWidget({ if (view === "selectMarket") { return ( setView("main")} - renderInline={renderInline} + onBack={backToMain} headerAction={headerAction} > { - setPickerChainId(c); - setPickerPage(0); - }} - lenderFilter={pickerLenderKey} - onLenderChange={(l) => { - setPickerLenderKey(l); - setPickerPage(0); - }} - sortBy={poolSortBy} - sortDir={poolSortDir} - onSortChange={(by, dir) => { - setPoolSortBy(by); - setPoolSortDir(dir); - setPickerPage(0); - }} - page={pickerPage} + chainFilter={picker.chainFilter} + onChainChange={picker.setChainFilter} + lenderFilter={picker.lenderFilter} + onLenderChange={picker.setLenderFilter} + sortBy={picker.sortBy} + sortDir={picker.sortDir} + onSortChange={picker.setSort} + page={picker.page} hasMore={picker.hasMore} - onPrev={() => setPickerPage((p) => Math.max(0, p - 1))} - onNext={() => setPickerPage((p) => p + 1)} - availableChainIds={ - sanitizedEarnChainIds ?? [ - ...(isTestnet ? EARN_TESTNET_CHAIN_IDS : EARN_MAINNET_CHAIN_IDS), - ] - } - availableLenders={availableLenders} + onPrev={picker.prevPage} + onNext={picker.nextPage} + availableChainIds={picker.availableChainIds} + availableLenders={picker.availableLenders} onSelect={(m) => { setEarnSelectedMarket(m); setView("main"); @@ -1470,14 +930,10 @@ export function EarnIntentWidget({ return ( {!earnHideTabs && ( @@ -1543,14 +999,14 @@ export function EarnIntentWidget({ onSelectSourceToken={() => setView("selectToken")} walletBalance={ fundingSource === "miden" - ? midenBalance + ? miden.balance : isConnected ? balance : null } sourceTokenDecimals={ fundingSource === "miden" - ? (selectedMidenAsset?.decimals ?? 18) + ? (miden.selectedAsset?.decimals ?? 18) : (selectedToken?.decimals ?? 18) } balanceLoading={ @@ -1566,7 +1022,24 @@ export function EarnIntentWidget({ /> ) : ( - + {effectiveAllowGasless ? ( + + gaslessWallet.switchToEpochSmartAccount() + } + setupBusy={gaslessWallet.setupBusy} + setupError={gaslessWallet.setupError} + checking={gaslessWallet.checking} + onEnable={() => setGasless(true)} + onDisable={() => setGasless(false)} + className="mb-1" + /> + ) : null} + { - setSelectedPosition(p); + selectPosition(p); setWithdrawAmount(""); - setWithdrawIsAll(false); setSmartWithdraw(false); setView("withdrawDetail"); }} chainFilter={positionsChainId} onChainFilterChange={(v) => { setPositionsChainId(v); - setSelectedPosition(null); + selectPosition(null); setWithdrawAmount(""); - setWithdrawIsAll(false); }} lenderFilter={positionsLenderKey} onLenderFilterChange={(v) => { setPositionsLenderKey(v); - setSelectedPosition(null); + selectPosition(null); setWithdrawAmount(""); - setWithdrawIsAll(false); }} - /> + /> + )} {(earnFlow.status === "submitting" || diff --git a/src/components/GaslessSection.tsx b/src/components/GaslessSection.tsx new file mode 100644 index 0000000..06bfaec --- /dev/null +++ b/src/components/GaslessSection.tsx @@ -0,0 +1,33 @@ +import type { UseGaslessWalletResult } from '../hooks/use-gasless-wallet-check'; +import { GaslessEnableButton } from './GaslessEnableButton'; + +interface GaslessSectionProps { + /** Render nothing when the wallet can't do gasless at all. */ + allowed: boolean; + wallet: UseGaslessWalletResult; + gasless: boolean; + onChange: (next: boolean) => void; +} + +/** Wires `useGaslessWallet` to its button. Shared by every flow's footer area. */ +export function GaslessSection({ + allowed, + wallet, + gasless, + onChange, +}: GaslessSectionProps) { + if (!allowed) return null; + return ( + wallet.switchToEpochSmartAccount()} + setupBusy={wallet.setupBusy} + setupError={wallet.setupError} + checking={wallet.checking} + onEnable={() => onChange(true)} + onDisable={() => onChange(false)} + /> + ); +} diff --git a/src/components/GaslessToggle.tsx b/src/components/GaslessToggle.tsx index 82bfd20..91f13c5 100644 --- a/src/components/GaslessToggle.tsx +++ b/src/components/GaslessToggle.tsx @@ -14,21 +14,22 @@ const SEGMENT_BASE = /** * Segmented control for gasless vs standard (user-paid) Compact deposits. */ +const segmentClasses = (active: boolean, segmentDisabled?: boolean) => + cn( + SEGMENT_BASE, + segmentDisabled && 'opacity-50 cursor-not-allowed', + active + ? 'cursor-default bg-primary text-white shadow-[0_1px_2px_rgba(15,23,42,0.12)]' + : !segmentDisabled && 'cursor-pointer bg-transparent text-fg-muted', + !active && segmentDisabled && 'cursor-not-allowed', + ); + export function GaslessToggle({ gasless, onChange, gaslessDisabled = false, gaslessDisabledReason, }: GaslessToggleProps) { - const segmentClasses = (active: boolean, segmentDisabled?: boolean) => - cn( - SEGMENT_BASE, - segmentDisabled && 'opacity-50 cursor-not-allowed', - active - ? 'cursor-default bg-primary text-white shadow-[0_1px_2px_rgba(15,23,42,0.12)]' - : !segmentDisabled && 'cursor-pointer bg-transparent text-fg-muted', - !active && segmentDisabled && 'cursor-not-allowed', - ); return (
+ + {complete && ( +
+ +
+ + {successMessage} +
+
+
+ )} + + ); +} diff --git a/src/components/MarketPickerPage.tsx b/src/components/MarketPickerPage.tsx index 4ad2251..4c77c42 100644 --- a/src/components/MarketPickerPage.tsx +++ b/src/components/MarketPickerPage.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { chainDotColor } from "../chain-colors"; +import { earnChainIdsFor } from "../earn/earn-chains"; import { getEpochChainById } from "../epoch-config"; import { SECTION_LABEL } from "../lib/styles"; import type { @@ -71,8 +72,9 @@ interface Props { } // Default mainnet earn universe — used when the parent doesn't narrow the -// chain set. Kept in sync with EarnIntentWidget.EARN_MAINNET_CHAIN_IDS. -const DEFAULT_CHAIN_IDS = [1, 8453, 42161, 10, 137]; +// chain set. Single-sourced from `earn-chains`; this used to be a hand-copied +// literal that silently drifted from the widget's own list. +const DEFAULT_CHAIN_IDS = earnChainIdsFor(false); // Known lender families with pretty labels. Used as a fallback when the // consumer doesn't supply `availableLenders` and as the display lookup for diff --git a/src/components/NetworkToggle.tsx b/src/components/NetworkToggle.tsx index 1dc7c75..33377a9 100644 --- a/src/components/NetworkToggle.tsx +++ b/src/components/NetworkToggle.tsx @@ -14,15 +14,15 @@ const SEGMENT_BASE = * colour while the idle segment stays muted, following standard settings-pill * conventions. */ -export function NetworkToggle({ isTestnet, onChange }: NetworkToggleProps) { - const segmentClasses = (active: boolean) => - cn( - SEGMENT_BASE, - active - ? 'cursor-default bg-primary text-white shadow-[0_1px_2px_rgba(15,23,42,0.12)]' - : 'cursor-pointer bg-transparent text-fg-muted', - ); +const segmentClasses = (active: boolean) => + cn( + SEGMENT_BASE, + active + ? 'cursor-default bg-primary text-white shadow-[0_1px_2px_rgba(15,23,42,0.12)]' + : 'cursor-pointer bg-transparent text-fg-muted', + ); +export function NetworkToggle({ isTestnet, onChange }: NetworkToggleProps) { return (
& { - /** `pay` vs `swap` — same SDK path; affects copy and `onStart` / internal `mode`. */ - variant: "pay" | "swap"; +const CTA_TONE_CLASSES: Record = { + primary: "bg-primary hover:bg-primary-hover", + warning: "bg-warning hover:bg-warning", + success: "bg-success hover:bg-success", }; -export function PaySwapIntentWidget({ - variant, - isOpen, - onClose, - intent: intentProp, - api, - network = "mainnet", - allowNetworkToggle = false, - allowGasless = true, - gasless: gaslessProp = false, - classNames: cn, - theme, - onIntentSent, - onIntentComplete, - onError, - onStart, - onSign, - onSuccess, - onStatus, - title: titleProp, - submitButtonText: submitButtonTextProp, - renderInline = false, - toAddress, - toAmount, - toChainId, - toToken, - toTokenDecimals, - toTokenSymbol, - sourceChainIds, - sourceTokenFilter, - defaultSourceChainId, - defaultSourceTokenAddress, - lockDestinationToken: lockDestinationTokenProp = true, - ctaLabels, - usdPriceFor, - onSourceTokenChange, - onQuote, - routingAndLiquidityOptions, -}: PaySwapIntentWidgetProps) { - // `lockDestinationToken` is a Pay-only concept — Swap UX always lets the - // user pick what they receive. Force-disable for Swap regardless of the - // incoming prop so an integrator passing `lockDestinationToken: true` to a - // Swap widget gets the expected always-clickable Buy pill. - const lockDestinationToken = - variant === "swap" ? false : lockDestinationTokenProp; - - const flatPayBuild = useMemo(() => { - if (intentProp) return null; - if (!toAddress && !toAmount && !toChainId && !toToken) return null; - return buildPayIntentFromFlatProps({ - toAddress, - toAmount, - toChainId, - toToken, - toTokenDecimals, - toTokenSymbol, - }); - }, [ - intentProp, - toAddress, - toAmount, - toChainId, - toToken, - toTokenDecimals, - toTokenSymbol, - ]); - - const payIntent: IntentProps | null = - intentProp ?? (flatPayBuild?.ok ? flatPayBuild.intent : null); - - const sessionId = useSessionId(isOpen); - - const [isTestnet, setIsTestnet] = useState(network === "testnet"); - const [gasless, setGasless] = useState(gaslessProp); - - const { data: walletClient } = useWalletClient(); - const { address, isConnected, connector } = useAccount(); - const chainId = useChainId(); - const { switchChain } = useSwitchChain(); - - const effectiveAllowGasless = useMemo( - () => allowGasless && walletClient != null && detectWalletAccountType(walletClient as never) === "local", - [allowGasless, walletClient], - ); - - const resolvedIntent = useMemo(() => { - return payIntent ?? PLACEHOLDER_INTENT; - }, [payIntent]); - - const { - requiredToken, - requiredAmount, - config: intentConfig, - destinationChainName, - positionLabel, - receiver, - } = resolvedIntent; - - const verb = variant === "swap" ? "Swap" : "Pay"; - - const modalTitle = - titleProp ?? (positionLabel ? `${verb} ${positionLabel}` : verb); - - const modalSubmitText = - submitButtonTextProp ?? (positionLabel ? `${verb} ${positionLabel}` : verb); - - const resolvedAllowNetworkToggle = allowNetworkToggle; - - const networkEnv: "mainnet" | "testnet" = isTestnet ? "testnet" : "mainnet"; - const resolvedApi = useMemo( - () => resolveApiForNetwork(api, networkEnv), - [api, networkEnv], - ); - const { baseUrl: apiBaseUrl, rpcUrls } = resolvedApi; - - const [selectedChainId, setSelectedChainId] = useState(null); - const [selectedTokenAddress, setSelectedTokenAddress] = useState(""); - // Destination overrides — only used when `lockDestinationToken === false`. - // null/'' means "use whatever the intent props pinned". - const [destChainIdOverride, setDestChainIdOverride] = useState( - null, - ); - const [destTokenAddressOverride, setDestTokenAddressOverride] = useState(""); +/** + * Shared engine for the Pay and Swap flows. `usePaySwapEngine` owns the state + * and the SDK wiring; this file decides what the user sees. + */ +export function PaySwapIntentWidget(props: PaySwapIntentWidgetProps) { + const { classNames: cn, theme, renderInline, isOpen, onClose, ctaLabels } = props; const [view, setView] = useState("main"); + const engine = usePaySwapEngine(props); + const { spec, source, destination, intentFlow, resolvedIntent } = engine; - useEffect(() => { - setIsTestnet(network === "testnet"); - }, [network]); - - const sourceChainIdsKey = sourceChainIds ? sourceChainIds.join(",") : ""; - const availableChains = useMemo(() => { - const all = getEpochChains(isTestnet); - if (!sourceChainIds || sourceChainIds.length === 0) return all; - const allow = new Set(sourceChainIds); - return all.filter((c) => allow.has(c.id)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isTestnet, sourceChainIdsKey]); - - const allTokens = useMemo((): TokenWithChain[] => { - const flat = availableChains.flatMap((chain) => - getEpochTokensByChainEnv(chain.id, isTestnet).map((tok) => ({ - ...tok, - chain, - })), - ); - return sourceTokenFilter ? flat.filter(sourceTokenFilter) : flat; - }, [availableChains, isTestnet, sourceTokenFilter]); - - // Tokens available on the currently-selected chain, derived from the - // already-filtered `allTokens` so `sourceTokenFilter` is applied uniformly. - const availableTokens = useMemo( - () => allTokens.filter((tok) => tok.chain.id === selectedChainId), - [allTokens, selectedChainId], - ); - - const selectedToken = useMemo( - () => - availableTokens.find((tok) => tok.address === selectedTokenAddress) ?? - null, - [availableTokens, selectedTokenAddress], - ); - - const gaslessWallet = useGaslessWallet({ - allowGasless: effectiveAllowGasless, - apiBaseUrl, - gasless, - setGasless, - walletClient, - address, - chainIdForCheck: - selectedChainId ?? - (isTestnet ? (availableChains[0]?.id ?? 84532) : walletClient?.chain?.id ?? null), - switchChain, - }); - - const selectedChain = availableChains.find((c) => c.id === selectedChainId); - - const pillToken = selectedToken ?? allTokens[0] ?? null; - const pillChain = selectedChain ?? availableChains[0] ?? null; - - // Initial selection: honor integrator-supplied defaults when present and - // still part of the filtered token set; otherwise fall back to the first - // available token. Runs only when nothing is selected yet so we don't fight - // user changes mid-session. - useEffect(() => { - if (!isOpen) return; - if (selectedChainId !== null) return; - if (defaultSourceChainId && defaultSourceTokenAddress) { - const wanted = allTokens.find( - (t) => - t.chain.id === defaultSourceChainId && - t.address.toLowerCase() === defaultSourceTokenAddress.toLowerCase(), - ); - if (wanted) { - setSelectedChainId(wanted.chain.id); - setSelectedTokenAddress(wanted.address); - return; - } - } - if (defaultSourceChainId) { - const first = allTokens.find((t) => t.chain.id === defaultSourceChainId); - if (first) { - setSelectedChainId(first.chain.id); - setSelectedTokenAddress(first.address); - return; - } - } - const first = allTokens[0]; - if (!first) return; - setSelectedChainId(first.chain.id); - setSelectedTokenAddress(first.address); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, allTokens, defaultSourceChainId, defaultSourceTokenAddress]); - - useEffect(() => { - if (availableTokens.length > 0) { - setSelectedTokenAddress((prev) => - availableTokens.some((tok) => tok.address === prev) - ? prev - : availableTokens[0].address, - ); - } else { - setSelectedTokenAddress(""); - } - }, [availableTokens]); - - const onSourceTokenChangeRef = useRef(onSourceTokenChange); - onSourceTokenChangeRef.current = onSourceTokenChange; - useEffect(() => { - if (!onSourceTokenChangeRef.current) return; - if (!selectedChainId || !selectedTokenAddress) return; - onSourceTokenChangeRef.current({ - chainId: selectedChainId, - tokenAddress: selectedTokenAddress as `0x${string}`, - }); - }, [selectedChainId, selectedTokenAddress]); - - const { balance, isLoading: isBalanceLoading } = useTokenBalance( - selectedChainId, - selectedTokenAddress, - address, - rpcUrls, - ); - - const isWrongNetwork = - selectedChainId !== null && chainId !== selectedChainId; - const insufficientBalance = balance !== null && balance === 0n; - - // --------------------------------------------------------------------------- - // Effective destination (token + chain). When `lockDestinationToken === true` - // (default), pinned from the intent props. When `false`, the user-picked - // destination overrides the pinned values everywhere downstream — pill, - // quote inputs, intent submission. - // --------------------------------------------------------------------------- - const pinnedDestChainId = - (isTestnet - ? intentConfig.destinationTestnetChainId - : intentConfig.destinationChainId) ?? (isTestnet ? 84532 : 8453); - - const allDestTokens = useMemo( - (): TokenWithChain[] => - getEpochChains(isTestnet).flatMap((chain) => - getEpochTokensByChainEnv(chain.id, isTestnet).map((tok) => ({ - ...tok, - chain, - })), - ), - [isTestnet], - ); - - const effectiveDestChainId = lockDestinationToken - ? pinnedDestChainId - : (destChainIdOverride ?? pinnedDestChainId); - - const effectiveDestTokenAddress = lockDestinationToken - ? requiredToken.address - : destTokenAddressOverride || requiredToken.address; - - const effectiveDestMeta = useMemo( - () => - allDestTokens.find( - (t) => - t.chain.id === effectiveDestChainId && - t.address.toLowerCase() === effectiveDestTokenAddress.toLowerCase(), - ) ?? null, - [allDestTokens, effectiveDestChainId, effectiveDestTokenAddress], - ); - - const effectiveRequiredToken = useMemo( - () => - effectiveDestMeta - ? { - address: effectiveDestMeta.address, - symbol: effectiveDestMeta.symbol, - decimals: effectiveDestMeta.decimals, - } - : requiredToken, - [effectiveDestMeta, requiredToken], - ); - - const effectiveIntentConfig = useMemo(() => { - if (lockDestinationToken) return intentConfig; - return { - ...intentConfig, - ...(isTestnet - ? { destinationTestnetChainId: effectiveDestChainId } - : { destinationChainId: effectiveDestChainId }), - }; - }, [intentConfig, isTestnet, effectiveDestChainId, lockDestinationToken]); - - const intentFlow = useIntentFlow({ - apiBaseUrl, - walletClient, - address, - requiredToken: effectiveRequiredToken, - requiredAmount, - intentConfig: effectiveIntentConfig, - isTestnet, - sessionId, - mode: variant, - receiver, - routingAndLiquidityOptions, - gasless: effectiveAllowGasless && gasless, - onIntentSent, - onIntentComplete, - onRequestClose: onClose, - onStart, - onSign, - onSuccess, - onErrorCtx: onError, - }); - - const onStatusRef = useRef(onStatus); - onStatusRef.current = onStatus; - useEffect(() => { - if (!onStatusRef.current) return; - onStatusRef.current({ - sessionId, - status: intentFlow.status, - progress: intentFlow.statusProgress, - activeStep: intentFlow.activeStep, - }); - }, [ - sessionId, - intentFlow.status, - intentFlow.statusProgress, - intentFlow.activeStep, - ]); - - useEffect(() => { - if (!isOpen) { - intentFlow.reset(); - setSelectedChainId(null); - setSelectedTokenAddress(""); - setDestChainIdOverride(null); - setDestTokenAddressOverride(""); - setIsTestnet(network === "testnet"); - setView("main"); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, network]); - - // When the user flips mainnet/testnet, the previous destination override - // (an address from the other env) is no longer valid — drop it so the pill - // falls back to the intent prop's pinned destination, which the integrator - // is expected to provide via `destinationChainId` + `destinationTestnetChainId`. - useEffect(() => { - setDestChainIdOverride(null); - setDestTokenAddressOverride(""); - }, [isTestnet]); - - const fetchQuoteRef = useRef(intentFlow.fetchQuote); - fetchQuoteRef.current = intentFlow.fetchQuote; - - useEffect(() => { - if ( - intentConfig.fixedOutput && - selectedChainId && - selectedToken && - walletClient && - address && - !isWrongNetwork - ) { - fetchQuoteRef.current({ - sourceChainId: selectedChainId, - sourceToken: selectedToken, - }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - selectedChainId, - selectedTokenAddress, - intentConfig.fixedOutput, - address, - chainId, - isWrongNetwork, - !!walletClient, - // Re-quote when the user picks a different destination — the PaySession - // already recreates on requiredToken/intentConfig changes, so this just - // makes sure the auto-fetch fires once after that recreation. - effectiveDestChainId, - effectiveDestTokenAddress, - ]); - - // Emit quote results once per settle. Watches `isQuoting` falling edge so - // we don't fire on every render while quote is in flight. - const onQuoteRef = useRef(onQuote); - onQuoteRef.current = onQuote; - const prevQuotingRef = useRef(false); - useEffect(() => { - const wasQuoting = prevQuotingRef.current; - prevQuotingRef.current = intentFlow.isQuoting; - if (!onQuoteRef.current) return; - if (!wasQuoting || intentFlow.isQuoting) return; - if (!selectedChainId || !selectedToken) return; - const raw = intentFlow.quotedPayRaw; - let bigintRaw: bigint | null = null; - if (raw) { - try { - bigintRaw = BigInt(raw); - } catch { - bigintRaw = null; - } - } - onQuoteRef.current({ - sourceChainId: selectedChainId, - sourceTokenAddress: selectedTokenAddress as `0x${string}`, - paySymbol: selectedToken.symbol, - payAmount: intentFlow.quotedPayAmount ?? null, - payAmountRaw: bigintRaw, - error: intentFlow.quoteError ?? undefined, - }); - }, [ - intentFlow.isQuoting, - intentFlow.quotedPayAmount, - intentFlow.quotedPayRaw, - intentFlow.quoteError, - selectedChainId, - selectedTokenAddress, - selectedToken, - ]); - - const hasResolvableIntent = !!payIntent; - - const showIntentSummary = !!payIntent; - - const isBusy = - intentFlow.status === "submitting" || intentFlow.status === "polling"; - - const canSubmit = - hasResolvableIntent && - !!walletClient && - !!address && - !!selectedChainId && - !!selectedTokenAddress && - !!selectedToken && - !isWrongNetwork && - !insufficientBalance && - !isBusy && - !(intentConfig.fixedOutput && intentFlow.isQuoting); - - const requiredAmountStr = formatAmount( - requiredAmount, - requiredToken.decimals, + const receiveAmountStr = formatAmount( + resolvedIntent.requiredAmount, + resolvedIntent.requiredToken.decimals, ); + // Fixed-output is the only mode that must wait on a quote to know what the + // user pays; otherwise the required amount is the pay amount. const payAmountStr = (() => { - if (!selectedToken) return "—"; - if (!intentConfig.fixedOutput) return requiredAmountStr; + if (!source.token) return "—"; + if (!resolvedIntent.config.fixedOutput) return receiveAmountStr; if (intentFlow.isQuoting) return ""; - if (intentFlow.quotedPayAmount) return intentFlow.quotedPayAmount; - if (intentFlow.quoteError) return "—"; - return "—"; + return intentFlow.quotedPayAmount ?? "—"; })(); - const flatPayError = - flatPayBuild && !flatPayBuild.ok ? flatPayBuild.error : null; - - const cta = { - submit: ctaLabels?.submit ?? modalSubmitText, - switchNetwork: - ctaLabels?.switchNetwork ?? ((chain: string) => `Switch to ${chain}`), - quoting: ctaLabels?.quoting ?? "Fetching quote…", - preparing: ctaLabels?.preparing ?? "Preparing…", - signing: ctaLabels?.signing ?? "Signing…", - submitting: ctaLabels?.submitting ?? "Submitting…", - polling: ctaLabels?.polling ?? "Waiting for execution…", - complete: ctaLabels?.complete ?? "Completed ✓", - insufficientBalance: - ctaLabels?.insufficientBalance ?? - ((sym: string) => `Insufficient ${sym} balance`), - configureRequired: - ctaLabels?.configureRequired ?? - (variant === "swap" ? "Configure swap" : "Configure payment"), - }; + const ctaState = resolvePaySwapCta({ + labels: resolvePaySwapCtaLabels(ctaLabels, { + submit: engine.modalSubmitText, + configureRequired: spec.configureLabel, + }), + hasIntent: engine.hasIntent, + buildError: engine.flatPayError, + fixedOutput: !!resolvedIntent.config.fixedOutput, + flow: intentFlow, + isWrongNetwork: engine.isWrongNetwork, + selectedChain: source.chain, + insufficientBalance: engine.insufficientBalance, + selectedToken: source.token, + }); - type CtaAction = "switch" | "submit" | "disabled"; - type CtaTone = "primary" | "warning" | "success"; - const ctaState: { action: CtaAction; label: string; tone?: CtaTone } = - (() => { - if (!payIntent) { - return { - action: "disabled", - label: flatPayError ?? cta.configureRequired, - }; - } - if (intentConfig.fixedOutput && intentFlow.isQuoting) - return { action: "disabled", label: cta.quoting }; - if (intentFlow.status === "submitting") { - if (intentFlow.activeStep === 1) - return { action: "disabled", label: cta.preparing }; - if (intentFlow.activeStep === 2) - return { action: "disabled", label: cta.signing }; - if (intentFlow.activeStep === 3) - return { action: "disabled", label: cta.submitting }; - } - if (intentFlow.status === "polling") - return { action: "disabled", label: cta.polling }; - if (intentFlow.status === "complete") - return { action: "disabled", label: cta.complete, tone: "success" }; - if (isWrongNetwork && selectedChain) { - return { - action: "switch", - label: cta.switchNetwork(selectedChain.name), - tone: "warning", - }; - } - if (insufficientBalance && selectedToken) { - return { - action: "disabled", - label: cta.insufficientBalance(selectedToken.symbol), - }; - } - return { action: "submit", label: cta.submit }; - })(); - const ctaEnabled = - ctaState.action === "submit" || ctaState.action === "switch"; - const CTA_TONE_CLASSES: Record = { - primary: "bg-primary hover:bg-primary-hover", - warning: "bg-warning hover:bg-warning", - success: "bg-success hover:bg-success", + const handleCtaClick = () => { + if (ctaState.action === "switch" && source.chain) { + engine.switchChain?.({ chainId: source.chain.id }); + return; + } + if (ctaState.action !== "submit") return; + if (!source.chainId || !source.token) return; + intentFlow.submit({ + sourceChainId: source.chainId, + sourceToken: source.token, + }); }; - const ctaToneClasses = CTA_TONE_CLASSES[ctaState.tone ?? "primary"]; - - const balanceStr = (() => { - if (!selectedToken || balance === null) return undefined; - return `Balance: ${formatAmount(balance, selectedToken.decimals)} ${selectedToken.symbol}`; - })(); - // USD equivalent for the pay/sell amount — driven entirely by integrator - // resolver. When not provided, hook returns null and we render no "≈ $…" - // line. Cached per (chain, address) so token-flipping is instant. const { priceUsd } = useTokenUsdPrice({ - chainId: selectedChainId, - tokenAddress: selectedTokenAddress, - tokenSymbol: selectedToken?.symbol ?? "", - resolver: usdPriceFor, + chainId: source.chainId, + tokenAddress: source.tokenAddress, + tokenSymbol: source.token?.symbol ?? "", + resolver: engine.usdPriceResolver, }); - const usdEquivalentStr = useMemo(() => { - if (priceUsd == null) return null; - if (!payAmountStr || payAmountStr === "—" || payAmountStr === "") - return null; - const n = Number(payAmountStr.replace(/,/g, "")); - if (!Number.isFinite(n) || n <= 0) return null; - const usd = n * priceUsd; - const formatted = - usd >= 1000 - ? usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) - : usd >= 1 - ? usd.toFixed(2) - : usd.toFixed(4); - return `≈ $${formatted}`; - }, [priceUsd, payAmountStr]); - - const destinationChain = useMemo( - () => getEpochChainById(effectiveDestChainId), - [effectiveDestChainId], - ); - // Prefer the live SDK token lookup so logo + decimals are correct, but fall - // back to the integrator-supplied `requiredToken` when the address isn't in - // our bundled token registry (custom tokens). - const destinationTokenMeta = useMemo( - () => - effectiveDestMeta ?? - getEpochTokensByChainEnv(effectiveDestChainId, isTestnet).find( - (tok) => - tok.address.toLowerCase() === - effectiveRequiredToken.address.toLowerCase(), - ), - [ - effectiveDestMeta, - effectiveDestChainId, - isTestnet, - effectiveRequiredToken.address, - ], - ); - - const destinationPill = ( - setView("selectDestToken") - } - ariaLabel={lockDestinationToken ? undefined : "Change destination token"} - /> - ); + // Spread, so a modal prop can't be wired on two views and missed on the third. + const chrome = { isOpen, onClose, theme, classNames: cn, renderInline }; + const backToMain = () => setView("main"); if (view === "selectToken") { return ( - setView("main")} - renderInline={renderInline} - > - { - setSelectedChainId(cid); - setSelectedTokenAddress(addr); - setView("main"); - }} - onBack={() => setView("main")} - /> - + tokens={source.allTokens} + selectedTokenAddress={source.tokenAddress} + selectedChainId={source.chainId} + onSelect={(cid, addr) => { + source.select(cid, addr); + backToMain(); + }} + onBack={backToMain} + /> ); } if (view === "selectDestToken") { return ( - setView("main")} - renderInline={renderInline} - > - { - setDestChainIdOverride(cid); - setDestTokenAddressOverride(addr); - setView("main"); - }} - onBack={() => setView("main")} - /> - + tokens={destination.allTokens} + selectedTokenAddress={destination.tokenAddress} + selectedChainId={destination.chainId} + onSelect={(cid, addr) => { + destination.select(cid, addr); + backToMain(); + }} + onBack={backToMain} + /> ); } - const floatingPill = - showIntentSummary && isConnected && pillToken && pillChain ? ( - setView("selectToken")} - ariaLabel="Change source token" - /> - ) : undefined; - const inlineError = intentFlow.quoteError ? `Quote failed: ${intentFlow.quoteError}` : intentFlow.status === "error" && intentFlow.error ? intentFlow.error : null; - const handleCtaClick = () => { - if (ctaState.action === "switch" && selectedChain) { - switchChain?.({ chainId: selectedChain.id }); - return; - } - if (ctaState.action !== "submit") return; - if (!selectedChainId || !selectedToken) return; - intentFlow.submit({ - sourceChainId: selectedChainId, - sourceToken: selectedToken, - }); - }; - - const ctaDisabled = - !ctaEnabled || (ctaState.action === "submit" && !canSubmit); - const footer = (
{inlineError && ( @@ -800,134 +140,81 @@ export function PaySwapIntentWidget({ {inlineError}
)} - + className={cn?.button} + />
); - const headerAction = resolvedAllowNetworkToggle ? ( - { - setIsTestnet(checked); - setSelectedChainId(null); - setSelectedTokenAddress(""); - }} + const sourcePill = + engine.hasIntent && engine.isConnected ? ( + setView("selectToken")} + ariaLabel="Change source token" + /> + ) : undefined; + + const { lockDestinationToken } = engine; + const destinationPill = ( + setView("selectDestToken") + } + ariaLabel={lockDestinationToken ? undefined : "Change destination token"} /> - ) : undefined; + ); return ( + ) : undefined + } > - {!payIntent && flatPayError && ( - - {flatPayError} - - )} - - {effectiveAllowGasless ? ( - gaslessWallet.switchToEpochSmartAccount()} - setupBusy={gaslessWallet.setupBusy} - setupError={gaslessWallet.setupError} - checking={gaslessWallet.checking} - onEnable={() => setGasless(true)} - onDisable={() => setGasless(false)} - /> - ) : null} - - {showIntentSummary && variant === "swap" && ( - - )} - {showIntentSummary && variant === "pay" && ( - - )} - - {(intentFlow.status === "submitting" || - intentFlow.status === "polling" || - intentFlow.status === "complete") && ( - - )} - - {intentFlow.status === "complete" && ( -
- -
- - - {variant === "swap" - ? "Swap completed successfully." - : "Intent executed successfully."} - -
-
-
- )} +
); } diff --git a/src/components/ProgressStepper.tsx b/src/components/ProgressStepper.tsx index 0a7f880..96c621d 100644 --- a/src/components/ProgressStepper.tsx +++ b/src/components/ProgressStepper.tsx @@ -54,7 +54,7 @@ export function ProgressStepper({ activeStep, statusProgress, className }: Progr ); return ( -
+
{iconContent} diff --git a/src/components/SwapIntentSummary.tsx b/src/components/SwapIntentSummary.tsx index 95bbab9..2eef7ec 100644 --- a/src/components/SwapIntentSummary.tsx +++ b/src/components/SwapIntentSummary.tsx @@ -5,7 +5,7 @@ import { ArrowDownIcon, ChevronRightIcon, WalletIcon } from './Icons'; import { Avatar } from './Avatar'; import { Shimmer } from './Shimmer'; import { TokenAmountCard } from './ui/TokenAmountCard'; -import { truncateAddress, formatBalancePortionForInput } from '../utils'; +import { truncateAddress } from '../utils'; interface SwapIntentSummaryProps { sellAmount: string; @@ -22,17 +22,11 @@ interface SwapIntentSummaryProps { balanceStr?: string; balanceError?: boolean; isBalanceLoading?: boolean; - sellBalanceRaw?: bigint | null; - sellDecimals?: number; - onAmountChange?: ((amount: string) => void) | null; /** Optional USD equivalent for the sell amount (e.g. "≈ $1.23"). */ usdEquivalent?: string | null; classNames?: EpochClassNames; } -const PCT_BTN = - 'cursor-pointer rounded-full border border-line bg-surface px-2.5 py-1 text-[11px] font-semibold text-fg-secondary transition-colors duration-150 hover:border-line-strong hover:text-fg'; - /** * Swap-flavoured intent summary — two stacked cards (Sell / Buy) with a small * down-arrow chip between them. Buy card has a muted background so the two @@ -52,17 +46,9 @@ export function SwapIntentSummary({ balanceStr, balanceError, isBalanceLoading, - sellBalanceRaw, - sellDecimals = 18, - onAmountChange, usdEquivalent, classNames: cs, }: SwapIntentSummaryProps) { - const applyPortion = (num: number, den: number) => { - if (!onAmountChange || !sellBalanceRaw || sellBalanceRaw === 0n) return; - onAmountChange(formatBalancePortionForInput(sellBalanceRaw, num, den, sellDecimals)); - }; - const walletBadge = walletAddress ? (
)} - {onAmountChange && sellBalanceRaw ? ( - - - - - - ) : null} ); diff --git a/src/components/SwapIntentWidget.tsx b/src/components/SwapIntentWidget.tsx index e40bfb4..e563a05 100644 --- a/src/components/SwapIntentWidget.tsx +++ b/src/components/SwapIntentWidget.tsx @@ -3,12 +3,11 @@ import { PaySwapIntentWidget, type PaySwapIntentWidgetProps } from './PaySwapInt export type SwapIntentWidgetProps = Omit; /** - * Swap flow — cross-chain swap between two tokens. Visual cues: - * - Teal accent on the destination card with a top-edge accent stripe - * - "From" / "To" copy - * - Swap-flip connector glyph (↕) between source + destination cards - * - Rendered through `SwapIntentSummary` (a separate file you can fork in - * isolation without affecting Pay or Earn). + * Swap flow — cross-chain swap between two tokens. + * + * Swap always lets the user pick what they receive, so it ignores + * `lockDestinationToken`. Rendered through `SwapIntentSummary`: stacked + * "You pay" / "You receive" cards with a down-arrow chip between them. * * Logic is shared with Pay via `PaySwapIntentWidget`. Edit * `SwapIntentSummary.tsx` for Swap-only visual tweaks; edit this file for diff --git a/src/components/earn/EarnCtaButton.tsx b/src/components/earn/EarnCtaButton.tsx new file mode 100644 index 0000000..8e5acb1 --- /dev/null +++ b/src/components/earn/EarnCtaButton.tsx @@ -0,0 +1,55 @@ +import { RetryIcon } from '../Icons'; +import { ActionButton } from '../ui/ActionButton'; + +interface EarnCtaButtonProps { + label: string; + /** Tailwind classes for the tone (primary / warning / …). */ + toneClasses: string; + enabled: boolean; + busy: boolean; + /** Show the retry glyph — the previous quote failed. */ + showRetryIcon?: boolean; + onClick: () => void; + /** Consumer override for the button element. */ + buttonClassName?: string; + /** Rendered under the button when the flow has something to say. */ + error?: string | null; +} + +/** The Earn footer: the shared CTA plus the inline error that belongs to it. */ +export function EarnCtaButton({ + label, + toneClasses, + enabled, + busy, + showRetryIcon = false, + onClick, + buttonClassName, + error, +}: EarnCtaButtonProps) { + return ( +
+ : undefined} + onClick={onClick} + className={buttonClassName} + /> + {error && ( +
+ + {error} +
+ )} +
+ ); +} diff --git a/src/components/earn/MidenAssetPicker.tsx b/src/components/earn/MidenAssetPicker.tsx new file mode 100644 index 0000000..99573cd --- /dev/null +++ b/src/components/earn/MidenAssetPicker.tsx @@ -0,0 +1,31 @@ +import type { MidenAsset } from '../../earn/use-earn-miden'; + +interface MidenAssetPickerProps { + assets: MidenAsset[]; + onSelect: (faucetId: string) => void; +} + +/** + * Source-asset list for a Miden-funded deposit. + * + * Deliberately not the EVM `TokenSelector`: Miden assets are keyed by faucet id + * rather than a contract address, and carry no chain to filter by. + */ +export function MidenAssetPicker({ assets, onSelect }: MidenAssetPickerProps) { + return ( +
    + {assets.map((asset) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/src/components/pay/PaySwapMainView.tsx b/src/components/pay/PaySwapMainView.tsx new file mode 100644 index 0000000..a5e8e4b --- /dev/null +++ b/src/components/pay/PaySwapMainView.tsx @@ -0,0 +1,96 @@ +import type { ReactNode } from 'react'; +import type { PaySwapEngine } from '../../pay/use-pay-swap-engine'; +import type { EpochClassNames } from '../../types'; +import { Banner } from '../Banner'; +import { GaslessSection } from '../GaslessSection'; +import { IntentProgress } from '../IntentProgress'; + +/** Statuses Pay/Swap shows the step tracker for. Earn tracks a different set. */ +const PROGRESS_STATUSES = ['submitting', 'polling', 'complete']; + +interface PaySwapMainViewProps { + engine: PaySwapEngine; + payAmount: string; + receiveAmount: string; + payTokenPill: ReactNode; + receiveTokenPill: ReactNode; + balanceStr?: string; + usdEquivalent: string | null; + classNames?: EpochClassNames; +} + +export function PaySwapMainView({ + engine, + payAmount, + receiveAmount, + payTokenPill, + receiveTokenPill, + balanceStr, + usdEquivalent, + classNames: cn, +}: PaySwapMainViewProps) { + const { + spec, + source, + destination, + intentFlow, + resolvedIntent, + hasIntent, + flatPayError, + isConnected, + address, + walletIcon, + insufficientBalance, + isBalanceLoading, + } = engine; + + return ( + <> + {!hasIntent && flatPayError && ( + + {flatPayError} + + )} + + + + {hasIntent && + spec.renderSummary({ + payAmount, + paySymbol: source.token?.symbol ?? '', + payTokenPill, + receiveAmount, + receiveSymbol: resolvedIntent.requiredToken.symbol, + receiveTokenPill, + destinationChainName: spec.fallsBackToResolvedChainName + ? (resolvedIntent.destinationChainName ?? destination.chain?.name) + : resolvedIntent.destinationChainName, + positionLabel: resolvedIntent.positionLabel, + recipientAddress: engine.recipientAddress, + walletAddress: isConnected ? address : undefined, + walletIcon: isConnected ? walletIcon : undefined, + walletConnected: isConnected, + isQuoting: intentFlow.isQuoting, + balanceStr: isConnected ? balanceStr : undefined, + balanceError: insufficientBalance, + isBalanceLoading: isConnected && !!source.token && isBalanceLoading, + usdEquivalent, + classNames: cn, + })} + + + + ); +} diff --git a/src/components/pay/TokenPickerModal.tsx b/src/components/pay/TokenPickerModal.tsx new file mode 100644 index 0000000..2a342ef --- /dev/null +++ b/src/components/pay/TokenPickerModal.tsx @@ -0,0 +1,37 @@ +import type { ComponentProps } from 'react'; +import { Modal } from '../Modal'; +import { TokenSelector, type TokenWithChain } from '../TokenSelector'; + +interface TokenPickerModalProps { + /** Modal chrome shared with every other view of the widget. */ + chrome: Omit, 'title' | 'children'>; + title: string; + tokens: TokenWithChain[]; + selectedTokenAddress: string; + selectedChainId: number | null; + onSelect: (chainId: number, tokenAddress: string) => void; + onBack: () => void; +} + +/** Source and destination pickers differ only in title and list. */ +export function TokenPickerModal({ + chrome, + title, + tokens, + selectedTokenAddress, + selectedChainId, + onSelect, + onBack, +}: TokenPickerModalProps) { + return ( + + onSelect(cid, addr)} + onBack={onBack} + /> + + ); +} diff --git a/src/components/ui/ActionButton.tsx b/src/components/ui/ActionButton.tsx new file mode 100644 index 0000000..74c0306 --- /dev/null +++ b/src/components/ui/ActionButton.tsx @@ -0,0 +1,56 @@ +import type { ReactNode } from 'react'; +import { cn as twcn } from '../../lib/cn'; + +interface ActionButtonProps { + label: string; + /** Tone utilities for the filled background (primary / warning / success). */ + toneClasses: string; + /** Blocks the click on its own — a busy button is never clickable. */ + busy: boolean; + /** Blocks the click for non-busy reasons (nothing selected, wrong network…). */ + disabled?: boolean; + /** Shown before the label when not busy; the spinner takes over when busy. */ + leadingIcon?: ReactNode; + onClick: () => void; + /** Consumer override (`classNames.button`). */ + className?: string; +} + +/** + * The full-width filled CTA shared by every flow's footer. + * + * Exists because Pay, Swap, and Earn each had a byte-identical copy of this + * markup; a tweak to the padding or the spinner had to be made in three places + * and inevitably wasn't. + */ +export function ActionButton({ + label, + toneClasses, + busy, + disabled = false, + leadingIcon, + onClick, + className, +}: ActionButtonProps) { + const blocked = busy || disabled; + return ( + + ); +} diff --git a/src/components/ui/Card.tsx b/src/components/ui/Card.tsx index af0ece4..0bcf0d3 100644 --- a/src/components/ui/Card.tsx +++ b/src/components/ui/Card.tsx @@ -36,6 +36,11 @@ export function Card({ ...(radius ? { borderRadius: radius } : null), ...style, }; + // A clickable card is a real interaction target, so it needs the keyboard + // affordances a native button would give for free: focusable, and activated + // by Enter/Space. It stays a plain div when there is nothing to click, and + // cannot become a -
-
- + void midenWallet.connect()} + />

Intent details

@@ -401,35 +235,15 @@ export function MidenBridgePanel() { /> {epoch.pendingQuote && ( -
-
- Quote - -
-
-
- Required Miden deposit -
-
- {(() => { - const qr = epoch.pendingQuote.quoteResult as Record< - string, - unknown - >; - const tokenInRaw = qr.tokenIn as string | undefined; - if (!tokenInRaw) return "calculated at execution"; - if (midenFaucetDecimals === undefined) return tokenInRaw; - return `${formatQuoteTokenIn(tokenInRaw, midenFaucetDecimals)} ${selectedAsset?.symbol ?? "tokens"}`; - })()} -
-
-
+ ) + .tokenIn as string | undefined + } + faucetDecimals={midenFaucetDecimals} + assetSymbol={selectedAsset?.symbol} + onClear={() => epoch.clearQuote()} + /> )} {!epoch.pendingQuote ? ( @@ -441,7 +255,7 @@ export function MidenBridgePanel() { : "cursor-pointer bg-primary" }`} disabled={epoch.isFetchingQuote || !canFetch} - onClick={handleGetQuote} + onClick={intent.getQuote} > {epoch.isFetchingQuote ? "Fetching quote…" : "Get quote"} @@ -454,7 +268,7 @@ export function MidenBridgePanel() { : "cursor-pointer bg-primary" }`} disabled={epoch.isLoading} - onClick={handleConfirm} + onClick={intent.confirm} > {epoch.isLoading ? "Processing…" : "Confirm & sign"} @@ -468,15 +282,15 @@ export function MidenBridgePanel() { {epoch.error && (

{epoch.error}

)} - {confirmStatus && ( + {intent.confirmStatus && (

- {confirmStatus} + {intent.confirmStatus}

)} - {localMidenNoteId && ( + {intent.localMidenNoteId && (
Miden note id @@ -489,10 +303,10 @@ export function MidenBridgePanel() { rel="noreferrer" className="break-all font-mono text-xs text-fg" > - {localMidenNoteId} + {intent.localMidenNoteId} ) : ( - {localMidenNoteId} + {intent.localMidenNoteId} )}
@@ -527,84 +341,13 @@ export function MidenBridgePanel() {
- {(result || epoch.error) && ( -
-

- Execution status -

- {epoch.error && !result && ( -

{epoch.error}

- )} - {result && ( -
- {depositTxHash && ( - - )} - {midenTxId && ( - - )} - {midenNoteIdForStatus && ( - - )} -
- )} -
- )} -
- ); -} - -function StatusRow({ - label: lb, - value, - href, -}: { - label: string; - value: string; - href: string | null; -}) { - return ( -
-
- {lb} -
-
- {href ? ( - - {value} - - ) : ( - {value} - )} -
+
); } -/** RainbowKit lives in the app header — remind users to connect there. */ -function ConnectButtonPlaceholder() { - return ( -

- Use Connect in the page header (RainbowKit). This wallet - pays gas and receives status polls for{" "} - getIntentStatus. -

- ); -} diff --git a/demo/src/miden/components/MidenExecutionStatus.tsx b/demo/src/miden/components/MidenExecutionStatus.tsx new file mode 100644 index 0000000..9c4f8cd --- /dev/null +++ b/demo/src/miden/components/MidenExecutionStatus.tsx @@ -0,0 +1,100 @@ +import type { IntentResult } from "../types/miden"; +import { + explorerTxUrl, + midenscanNoteUrl, + truncateHash, + MIDEN_CHAIN_ID, +} from "../lib/explorers"; + +interface MidenExecutionStatusProps { + result: IntentResult | null; + error: string | null; + /** Live flow status from the poller, if one is running. */ + flow?: { midenNoteId?: string; midenTxId?: string; evmChainId?: number }; + /** Fallback when the flow hasn't reported a note id yet. */ + fallbackNoteId?: string; +} + +function StatusRow({ + label, + value, + href, +}: { + label: string; + value: string; + href: string | null; +}) { + return ( +
+ + {label} + + {href ? ( + + {value} + + ) : ( + {value} + )} +
+ ); +} + +/** Transaction receipts for a submitted bridge intent, once there are any. */ +export function MidenExecutionStatus({ + result, + error, + flow, + fallbackNoteId, +}: MidenExecutionStatusProps) { + if (!result && !error) return null; + + const depositTxHash = result?.solveResult?.depositResult?.transactionHash; + const depositChainId = + (result as { depositChainId?: number } | null)?.depositChainId ?? + flow?.evmChainId; + const depositTxUrl = + depositChainId != null && depositTxHash + ? explorerTxUrl(Number(depositChainId), depositTxHash) + : null; + + const midenTxId = flow?.midenTxId; + const noteId = flow?.midenNoteId ?? fallbackNoteId; + + return ( +
+

Execution status

+ {error && !result &&

{error}

} + {result && ( +
+ {depositTxHash && ( + + )} + {midenTxId && ( + + )} + {noteId && ( + + )} +
+ )} +
+ ); +} diff --git a/demo/src/miden/components/MidenQuoteCard.tsx b/demo/src/miden/components/MidenQuoteCard.tsx new file mode 100644 index 0000000..5846d3b --- /dev/null +++ b/demo/src/miden/components/MidenQuoteCard.tsx @@ -0,0 +1,48 @@ +import { formatQuoteTokenIn } from "../services/epoch-bridge"; + +interface MidenQuoteCardProps { + /** Raw `tokenIn` from the solver quote. */ + tokenInRaw: string | undefined; + /** Undefined when the faucet isn't in the bundled decimals map. */ + faucetDecimals: number | undefined; + assetSymbol: string | undefined; + onClear: () => void; +} + +/** What the user must deposit on Miden for the quoted intent. */ +export function MidenQuoteCard({ + tokenInRaw, + faucetDecimals, + assetSymbol, + onClear, +}: MidenQuoteCardProps) { + // A reverse quote leaves tokenIn empty — the solver settles it at execution. + const required = !tokenInRaw + ? "calculated at execution" + : faucetDecimals === undefined + ? tokenInRaw + : `${formatQuoteTokenIn(tokenInRaw, faucetDecimals)} ${assetSymbol ?? "tokens"}`; + + return ( +
+
+ Quote + +
+
+
+ Required Miden deposit +
+
+ {required} +
+
+
+ ); +} diff --git a/demo/src/miden/components/MidenWalletsSection.tsx b/demo/src/miden/components/MidenWalletsSection.tsx new file mode 100644 index 0000000..a62228a --- /dev/null +++ b/demo/src/miden/components/MidenWalletsSection.tsx @@ -0,0 +1,56 @@ +interface MidenWalletsSectionProps { + midenConnected: boolean; + midenAccountIdHex: string | undefined; + onConnectMiden: () => void; +} + +const LABEL = + "mb-1.5 block text-[0.6875rem] font-bold uppercase tracking-wide text-fg-muted"; +const CARD = "rounded-lg border border-line bg-canvas p-3"; + +/** The EVM wallet is owned by the page header (RainbowKit), not this panel. */ +function ConnectButtonPlaceholder() { + return ( +

+ Use Connect in the page header (RainbowKit). This wallet + pays gas and receives status polls for{" "} + getIntentStatus. +

+ ); +} + +export function MidenWalletsSection({ + midenConnected, + midenAccountIdHex, + onConnectMiden, +}: MidenWalletsSectionProps) { + return ( +
+
+ Wallets +
+
+
+
EVM (gas + recipient)
+ +
+
+
Miden
+
+ {midenConnected + ? (midenAccountIdHex ?? "connected") + : "Not connected"} +
+ +
+
+
+ ); +} diff --git a/demo/src/miden/constants/chains.ts b/demo/src/miden/constants/chains.ts index f6d9c56..269e914 100644 --- a/demo/src/miden/constants/chains.ts +++ b/demo/src/miden/constants/chains.ts @@ -1,9 +1,2 @@ /** Sepolia — default for Cross-chain deposit `destinationChainId` and withdraw `sourceChainId`. */ export const DEFAULT_SEPOLIA_CHAIN_ID_STR = '11155111'; - -/** - * Virtual chain id for Miden as intent *output* in EVM→Miden (`gettokenout` with Miden extraData). - * Allocator SIO uses this for `tokenOut.chainId` with `getTokenDataFromMidenFaucetId`; epoch-sio treats 0 as Miden - * (`MIDEN_CHAIN_ID` in epoch-sio/src/services/web3/safe.ts). Do not set to an EVM chain id for this flow. - */ -export const MIDEN_DESTINATION_CHAIN_ID = 999999999; diff --git a/demo/src/miden/hooks/useIntentTransactionStatus.ts b/demo/src/miden/hooks/useIntentTransactionStatus.ts index 8e335d1..f7821bb 100644 --- a/demo/src/miden/hooks/useIntentTransactionStatus.ts +++ b/demo/src/miden/hooks/useIntentTransactionStatus.ts @@ -107,8 +107,15 @@ export function useIntentTransactionStatus(userAddress?: string, intentNonce?: s pollCountRef.current = 0; console.log(`${LOG} starting polling loop @ ${POLL_INTERVAL}ms`); void poll(); - intervalRef.current = setInterval(poll, POLL_INTERVAL); - return () => stopPolling(); + // Cleanup clears the id this run created rather than whatever the ref holds + // — `poll` can call `stopPolling` on a terminal status and swap it out. + const id = setInterval(poll, POLL_INTERVAL); + intervalRef.current = id; + return () => { + clearInterval(id); + if (intervalRef.current === id) intervalRef.current = null; + setIsPolling(false); + }; }, [sdk, userAddress, intentNonce, poll, stopPolling]); return { statuses, isPolling, error, refetch: poll }; diff --git a/demo/src/miden/hooks/useMidenBridgeIntent.ts b/demo/src/miden/hooks/useMidenBridgeIntent.ts new file mode 100644 index 0000000..90a3a97 --- /dev/null +++ b/demo/src/miden/hooks/useMidenBridgeIntent.ts @@ -0,0 +1,179 @@ +import { useState } from "react"; +import { toast } from "sonner"; +import { SendTransaction } from "@miden-sdk/miden-wallet-adapter-base"; +import { useMidenFiWallet } from "@miden-sdk/miden-wallet-adapter-react"; +import type { SolveIntentParams } from "@epoch-protocol/epoch-intents-sdk/dist/types"; +import type { CrossChainIntentParams } from "../types/miden"; + +interface UseMidenBridgeIntentOptions { + epoch: { + pendingQuote: unknown; + fetchQuote: (params: CrossChainIntentParams) => Promise; + confirmIntent: ( + createNote: SolveIntentParams["createMidenP2IDNote"], + ) => Promise; + }; + midenAccountIdHex: string | undefined; + /** Throws when the form isn't complete enough to build an intent. */ + buildParams: () => CrossChainIntentParams; + outputToken: string; + resolvedEvmRecipient: string; +} + +interface MidenBridgeIntent { + confirmStatus: string; + /** Note id from the P2IDE send, surfaced before the intent settles. */ + localMidenNoteId: string | undefined; + localIntentNonce: string | undefined; + localIntentUserAddress: string | undefined; + getQuote: () => void; + confirm: () => void; +} + +const isNonceLike = (v: unknown) => + typeof v === "string" || typeof v === "number" || typeof v === "bigint"; + +/** Pull the intent nonce out of whatever shape the solver returned. */ +function readNonce(r: Record): string | undefined { + const solveNonce = (r.solveResult as Record | undefined) + ?.nonce; + const raw = isNonceLike(r.nonce) + ? r.nonce + : isNonceLike(r.intentNonce) + ? r.intentNonce + : isNonceLike(solveNonce) + ? solveNonce + : undefined; + return raw != null ? String(raw) : undefined; +} + +/** + * Quote + confirm for the Miden → EVM bridge. + * + * `confirm` hands the SDK a callback that mints the P2IDE note on Miden; the SDK + * calls it mid-solve, so the note id only becomes known from inside that + * callback — hence the local state rather than a return value. + */ +export function useMidenBridgeIntent({ + epoch, + midenAccountIdHex, + buildParams, + outputToken, + resolvedEvmRecipient, +}: UseMidenBridgeIntentOptions): MidenBridgeIntent { + const { requestSend, waitForTransaction } = useMidenFiWallet(); + const [confirmStatus, setConfirmStatus] = useState(""); + const [localMidenNoteId, setLocalMidenNoteId] = useState(); + const [localIntentNonce, setLocalIntentNonce] = useState(); + const [localIntentUserAddress, setLocalIntentUserAddress] = + useState(); + + const getQuote = () => { + if ( + !outputToken || + outputToken === "0x0000000000000000000000000000000000000000" + ) { + toast.error("Select a valid output token"); + return; + } + void toast.promise(epoch.fetchQuote(buildParams()), { + loading: "Fetching quote…", + success: "Quote ready — review and confirm", + error: (err) => (err instanceof Error ? err.message : "Quote failed"), + }); + }; + + const createMidenP2IDNote: SolveIntentParams["createMidenP2IDNote"] = async ( + faucetIdParam, + amountParam, + allocatorId, + ) => { + setConfirmStatus("Creating P2IDE note on Miden…"); + try { + if (!midenAccountIdHex) throw new Error("Missing Miden account id"); + if (!requestSend) throw new Error("Miden wallet adapter not available"); + // Checked before the send, not after: requestSend broadcasts a real + // transaction, so bailing out afterwards would move funds and still + // throw, leaving the note unreadable. + if (!waitForTransaction) + throw new Error("waitForTransaction not available in adapter"); + + const normalizedAmount = BigInt(amountParam); + if (normalizedAmount > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("Amount too large for wallet adapter send"); + } + + const payload = new SendTransaction( + midenAccountIdHex, + allocatorId, + faucetIdParam, + "public", + Number(normalizedAmount), + ); + const txId = await requestSend(payload); + const finalized = await waitForTransaction(txId, 120_000); + const first = finalized.outputNotes?.[0]; + const noteId = first ? first.id().toString() : ""; + if (!noteId) + throw new Error(`Could not read output note id for tx ${txId}`); + setLocalMidenNoteId(noteId); + return { success: true, noteId }; + } catch (err) { + return { + success: false, + error: err instanceof Error ? err.message : String(err), + }; + } + }; + + const confirm = () => { + if (!epoch.pendingQuote) return; + void toast.promise( + (async () => { + setConfirmStatus("Submitting intent…"); + const result = await epoch.confirmIntent(createMidenP2IDNote); + if ( + result && + typeof result === "object" && + "error" in result && + (result as { error?: string }).error + ) { + throw new Error((result as { error: string }).error); + } + if (result && typeof result === "object") { + const r = result as unknown as Record; + const nonce = readNonce(r); + const intentData = r.intentData as { recipient?: string } | undefined; + const recipient = + typeof intentData?.recipient === "string" + ? intentData.recipient + : undefined; + if (nonce) setLocalIntentNonce(nonce); + setLocalIntentUserAddress( + (recipient ?? resolvedEvmRecipient)?.trim() || undefined, + ); + } + setConfirmStatus("Intent submitted successfully."); + return "Cross-chain intent submitted"; + })(), + { + loading: "Confirming intent…", + success: (msg) => msg, + error: (err) => { + const msg = err instanceof Error ? err.message : "Unknown error"; + setConfirmStatus(`Error: ${msg}. Quote is still saved — try again.`); + return `Error: ${msg}`; + }, + }, + ); + }; + + return { + confirmStatus, + localMidenNoteId, + localIntentNonce, + localIntentUserAddress, + getQuote, + confirm, + }; +} diff --git a/demo/src/miden/services/epoch-bridge.ts b/demo/src/miden/services/epoch-bridge.ts index e699fd5..7e7a942 100644 --- a/demo/src/miden/services/epoch-bridge.ts +++ b/demo/src/miden/services/epoch-bridge.ts @@ -1,18 +1,10 @@ import { parseUnits, formatUnits } from "viem"; -import type { - CrossChainIntentParams, - EVMToMidenIntentParams, - IntentResult, -} from "../types/miden"; -import { MIDEN_DESTINATION_CHAIN_ID } from "../constants/chains"; +import type { CrossChainIntentParams, IntentResult } from "../types/miden"; import type { EpochIntentSDK, IntentQuoteResult, } from "@epoch-protocol/epoch-intents-sdk"; -import { - MIDEN_TO_EVM_EXTRA_TYPESTRING, - EVM_TO_MIDEN_EXTRA_TYPESTRING, -} from "@epoch-protocol/epoch-intents-sdk"; +import { MIDEN_TO_EVM_EXTRA_TYPESTRING } from "@epoch-protocol/epoch-intents-sdk"; import type { CollateralType, GetTaskDataParams, @@ -28,14 +20,6 @@ export interface CrossChainQuote { params: CrossChainIntentParams; } -/** Pre-fetched EVM→Miden quote (reverse `tokenInAmount: "0"` + Miden `minTokenOut`). */ -export interface EVMToMidenQuote { - taskTypeString: string; - intentData: unknown; - quoteResult: IntentQuoteResult; - params: EVMToMidenIntentParams; -} - /** Format base-unit token amount for display. */ export function formatQuoteTokenIn( raw: string | undefined, @@ -171,160 +155,6 @@ export function buildEpochTaskDataParams( return taskDataParams; } -export function buildEVMToMidenTaskDataParams(params: EVMToMidenIntentParams) { - const midenRecipientHex = normalizeMidenIdToHex(params.midenRecipientId); - const midenFaucetHex = normalizeMidenIdToHex(params.midenFaucetId); - const evmDecimals = params.evmTokenDecimals ?? 18; - - const rawEvm = params.evmAmount?.trim() ?? ""; - const hasFixedEvmIn = rawEvm !== "" && rawEvm !== "0"; - - const minHuman = (params.minTokenOut ?? "").trim(); - // Do not scale using frontend-provided decimals. Treat minTokenOut as already - // being in base units, and let backend derive/validate decimals from faucet id. - const scaledMinMidenOut = minHuman ? minHuman : "0"; - - const amountInWei = hasFixedEvmIn - ? parseUnits(rawEvm, evmDecimals).toString() - : "0"; - - if (!hasFixedEvmIn && scaledMinMidenOut === "0") { - throw new Error( - "EVM→Miden: set minTokenOut (minimum Miden tokens to receive) for quote path, or provide evmAmount for a fixed EVM spend.", - ); - } - - const destinationChainId = - params.destinationChainId ?? MIDEN_DESTINATION_CHAIN_ID; - if (destinationChainId !== MIDEN_DESTINATION_CHAIN_ID) { - throw new Error( - `EVM→Miden: destinationChainId must be ${MIDEN_DESTINATION_CHAIN_ID} (Miden output). Got ${destinationChainId}.`, - ); - } - - console.log("[EpochBridge] Building EVM→Miden task data params from:", { - sourceChainId: params.sourceChainId, - destinationChainId, - evmSourceAddress: params.evmSourceAddress, - evmTokenAddress: params.evmTokenAddress, - route: hasFixedEvmIn ? "forward" : "reverse-quote", - evmAmount: hasFixedEvmIn ? rawEvm : "0", - midenRecipientId: midenRecipientHex, - midenFaucetId: midenFaucetHex.slice(0, 16) + "...", - minTokenOutHuman: minHuman || "0", - amountInWei, - scaledMinMidenOut, - }); - - const taskDataParams = { - taskType: "gettokenout" as TaskType, - intentData: { - isNative: false, - depositTokenAddress: params.evmTokenAddress, - tokenInAmount: amountInWei, - outputTokenAddress: ZERO_ADDRESS, - minTokenOut: scaledMinMidenOut, // Miden-side minimum out (base units) - destinationChainId: String(destinationChainId), - protocolHashIdentifier: ZERO_HASH, - recipient: params.evmSourceAddress, - }, - // EVM→Miden carries the recipient on Miden — no source note. Canonical - // EVM→Miden suffix from the SDK (midenRecipientAccount + midenFaucetId). - extraDataTypestring: EVM_TO_MIDEN_EXTRA_TYPESTRING, - extraData: { - midenRecipientAccount: midenRecipientHex, - midenFaucetId: midenFaucetHex, - }, - }; - - console.log( - "[EpochBridge] EVM→Miden task data params built:", - taskDataParams, - ); - return taskDataParams; -} - -/** Step 1: reverse-quote EVM→Miden (required Miden `minTokenOut` in base units, `tokenInAmount: "0"`). */ -export async function getEVMToMidenQuote( - sdk: EpochIntentSDK, - params: EVMToMidenIntentParams, - sponsorAddress: string, -): Promise { - const quoteParams: EVMToMidenIntentParams = { - ...params, - evmAmount: undefined, - }; - const taskDataParams = buildEVMToMidenTaskDataParams(quoteParams); - const { taskTypeString, intentData } = await sdk.getTaskData(taskDataParams); - console.log("[EpochBridge] getEVMToMidenQuote getTaskData:", { - taskTypeString, - intentData, - }); - - const quoteResult = await sdk.getIntentQuote({ - sponsorAddress: sponsorAddress as `0x${string}`, - taskTypeString, - intentData, - isNative: false, - }); - console.log("[EpochBridge] getEVMToMidenQuote quoteResult:", quoteResult); - - if (!quoteResult.success) { - throw new Error(quoteResult.error ?? "Quote failed"); - } - - return { taskTypeString, intentData, quoteResult, params: quoteParams }; -} - -export async function buildEVMToMidenIntent( - sdk: EpochIntentSDK, - params: EVMToMidenIntentParams & { preFetchedQuote?: EVMToMidenQuote }, -): Promise { - let taskTypeString: string; - let intentData: unknown; - let quoteResult: IntentQuoteResult | undefined; - - if (params.preFetchedQuote) { - ({ taskTypeString, intentData, quoteResult } = params.preFetchedQuote); - console.log( - "[EpochBridge] EVM→Miden using pre-fetched quote, skipping getTaskData", - ); - } else { - const taskDataParams = buildEVMToMidenTaskDataParams(params); - ({ taskTypeString, intentData } = await sdk.getTaskData(taskDataParams)); - console.log("[EpochBridge] SDK.getTaskData() response:", { - taskTypeString, - intentData, - }); - } - - try { - const solveResult = await sdk.solveIntent({ - isNative: false, - sponsorAddress: params.evmSourceAddress as `0x${string}`, - taskTypeString, - intentData, - quoteResult, - collateralType: "evm" as CollateralType, - }); - - console.log("[EpochBridge] SDK.solveIntent() response:", solveResult); - return { - taskTypeString, - intentData: intentData as Record, - solveResult, - }; - } catch (err) { - console.error("[EpochBridge] EVM→Miden solveIntent failed:", err); - return { - taskTypeString, - intentData: intentData as Record, - error: - err instanceof Error ? err.message : "Failed to solve EVM→Miden intent", - }; - } -} - /** Step 1 of the minTokenOut route: get a reverse quote without executing. */ export async function getCrossChainQuote( sdk: EpochIntentSDK, diff --git a/demo/src/miden/types/miden.ts b/demo/src/miden/types/miden.ts index 54c7ab1..a4f64a2 100644 --- a/demo/src/miden/types/miden.ts +++ b/demo/src/miden/types/miden.ts @@ -31,34 +31,6 @@ export interface CrossChainIntentParams { minTokenOut: string; } -export interface EVMToMidenIntentParams { - /** EVM chain where `evmTokenAddress` is deployed (align with wallet; mirrors deposit tab chain id). */ - sourceChainId: number; - /** - * Intent output chain: must be `MIDEN_DESTINATION_CHAIN_ID` (currently `999999999`) for Miden credit in this stack. - * Maps to mandate `destinationChainId` in task data (SIO `tokenOut.chainId`); not the EVM `sourceChainId`. - */ - destinationChainId: number; - evmSourceAddress: string; - evmTokenAddress: string; - /** Human-readable EVM input amount. Omit, empty, or "0" to use reverse-quote path (EVM spend comes from quote). */ - evmAmount?: string; - evmTokenDecimals?: number; - midenRecipientId: string; - midenFaucetId: string; - /** - * Withdraw flow no longer depends on frontend faucet-decimals. - * Backend should derive decimals from `midenFaucetId` when needed. - */ - midenDecimals?: number; - /** - * Minimum Miden-side output you want. - * Reverse-quote path: paired with `tokenInAmount: "0"` so SIO derives required EVM `tokenIn`. - * Forward path (when `evmAmount` is set): optional slippage floor on Miden output. - */ - minTokenOut: string; -} - export interface IntentResult { taskTypeString: string; intentData: Record; diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 8f79238..9312c7e 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -27,8 +27,11 @@ interface ModalProps { renderInline?: boolean; } +// The itself is the overlay, so `classNames.overlay` keeps applying. +// The leading resets undo the UA's dialog defaults (auto margins, fit-content +// sizing, border, its own ::backdrop) — we paint the scrim ourselves. const OVERLAY_CLASSES = - 'fixed inset-0 z-[9999] flex items-center justify-center p-4 bg-overlay backdrop-blur-md animate-overlay-in'; + 'm-0 max-w-none max-h-none w-full h-full border-0 p-4 fixed inset-0 z-[9999] flex items-center justify-center bg-overlay backdrop-blur-md animate-overlay-in [&::backdrop]:bg-transparent'; const CONTAINER_CLASSES = 'flex w-full max-w-[480px] max-h-[90vh] flex-col overflow-hidden rounded-lg border border-line bg-canvas font-sans text-sm text-fg shadow-lg animate-modal-in'; @@ -64,16 +67,18 @@ export function Modal({ onBack, renderInline = false, }: ModalProps) { - const containerRef = useRef(null); + const dialogRef = useRef(null); + // showModal() is what buys the focus trap, Escape, and the top layer — none of + // which a plain div gets. Opening happens on mount because the component + // returns null while closed. useEffect(() => { - if (!isOpen || renderInline) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') onClose(); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [isOpen, onClose, renderInline]); + if (renderInline) return; + const dialog = dialogRef.current; + if (!dialog || dialog.open) return; + dialog.showModal(); + return () => dialog.close(); + }, [renderInline]); useEffect(() => { if (!isOpen || renderInline) return; @@ -84,10 +89,6 @@ export function Modal({ }; }, [isOpen, renderInline]); - useEffect(() => { - if (isOpen && !renderInline) containerRef.current?.focus(); - }, [isOpen, renderInline]); - if (!isOpen) return null; const cssVars = themeToCssVars(theme); @@ -151,25 +152,34 @@ export function Modal({ } const modal = ( -
{ + // Escape: let the widget own closing so state unwinds the same way it + // does for the close button. + e.preventDefault(); + onClose(); + }} > - +
); return createPortal(modal, document.body); From 833ba008d7fcd14c6dd59686b2f91d7dc13c73d1 Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Thu, 16 Jul 2026 09:13:59 +0530 Subject: [PATCH 03/13] refactor: split EarnIntentWidget into engine + views Move Earn state and SDK wiring into useEarnEngine (1091 -> 245 lines). Network-scoped choices become one self-evicting value, so a network flip invalidates them together instead of a 6-setter reset. Share TokenPickerModal/IntentProgress/GaslessSection across flows. react-doctor: 2 -> 0. --- src/components/EarnIntentWidget.tsx | 1001 ++--------------- src/components/GaslessSection.tsx | 3 + src/components/IntentProgress.tsx | 5 + src/components/PaySwapIntentWidget.tsx | 2 +- src/components/{pay => }/TokenPickerModal.tsx | 6 +- src/components/earn/EarnMainView.tsx | 193 ++++ .../earn/EarnWithdrawDetailView.tsx | 106 ++ src/components/pay/PaySwapMainView.tsx | 10 +- src/earn/api.ts | 2 +- src/earn/earn-props.ts | 82 ++ src/earn/use-earn-engine.ts | 769 +++++++++++++ src/earn/use-earn-intent-flow.ts | 6 +- 12 files changed, 1250 insertions(+), 935 deletions(-) rename src/components/{pay => }/TokenPickerModal.tsx (83%) create mode 100644 src/components/earn/EarnMainView.tsx create mode 100644 src/components/earn/EarnWithdrawDetailView.tsx create mode 100644 src/earn/earn-props.ts create mode 100644 src/earn/use-earn-engine.ts diff --git a/src/components/EarnIntentWidget.tsx b/src/components/EarnIntentWidget.tsx index 6d09e52..7089eb7 100644 --- a/src/components/EarnIntentWidget.tsx +++ b/src/components/EarnIntentWidget.tsx @@ -1,697 +1,68 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useAccount, useChainId, useSwitchChain, useWalletClient } from "wagmi"; -import { detectWalletAccountType } from "@epoch-protocol/epoch-intents-sdk"; -import { getEpochChains, getEpochTokensByChainEnv } from "../epoch-config"; -import { useTokenBalance } from "../use-token-balance"; -import { useSessionId } from "../session"; -import type { - ApiConfig, - EarnDepositIntentDefaults, - EarnMidenAdapter, - EarnWithdrawIntentDefaults, - EpochClassNames, - EpochEarnMarket, - EpochEarnPosition, - EpochTheme, - IntentCompletePayload, - IntentSentPayload, - OnErrorCtx, - OnSignCtx, - OnStartCtx, - OnStatusCtx, - OnSuccessCtx, - RoutingAndLiquidityOptions, -} from "../types"; -import { useUserPositions } from "../earn/api"; -import { useEarnMarketPicker } from "../earn/use-earn-market-picker"; -import { useEarnMiden } from "../earn/use-earn-miden"; -import { useEarnQuoteTarget } from "../earn/use-earn-quote-target"; -import { resolveEarnCta, isEarnCtaEnabled } from "../earn/earn-cta"; -import { EARN_TESTNET_SOURCE_EVM_CHAIN_IDS } from "../earn/earn-chains"; -import { MIDEN_VIRTUAL_CHAIN_ID, DEFAULT_MIDEN_FAUCET } from "../earn/miden"; -import { - DUMMY_LENDING_SUPPORTED_ADDRESSES, - DEPRECATED_DUMMY_LENDING_USDC_ADDRESS, -} from "../earn/dummy-lending-markets"; -import { resolveApiForNetwork } from "../resolve-api-config"; -import { useEarnIntentFlow } from "../earn/use-earn-intent-flow"; -import { useGaslessWallet } from "../hooks/use-gasless-wallet-check"; -import { useLatestRef } from "../hooks/use-latest-ref"; -import { useOnOpen } from "../hooks/use-on-open"; -import { useTokenPick } from "../hooks/use-token-pick"; -import type { OneDeltaConfig } from "../types"; -import { ArrowDownIcon, CheckIcon } from "./Icons"; -import { SegmentedTabs } from "./ui/SegmentedTabs"; -import { Banner } from "./Banner"; -import { EarnFlowPanel } from "./EarnFlowPanel"; +import { useEarnEngine } from "../earn/use-earn-engine"; +import { EarnMainView } from "./earn/EarnMainView"; +import { TokenPickerModal } from "./TokenPickerModal"; +import { EarnWithdrawDetailView } from "./earn/EarnWithdrawDetailView"; +import { DEFAULT_MIDEN_FAUCET } from "../earn/miden"; import { MarketPickerPage } from "./MarketPickerPage"; import { Modal } from "./Modal"; import { NetworkToggle } from "./NetworkToggle"; -import { GaslessEnableButton } from "./GaslessEnableButton"; -import { ProgressStepper } from "./ProgressStepper"; -import { TokenSelector, type TokenWithChain } from "./TokenSelector"; -import { WithdrawPanel } from "./WithdrawPanel"; import { MidenAssetPicker } from "./earn/MidenAssetPicker"; import { EarnCtaButton } from "./earn/EarnCtaButton"; import { - WithdrawDetailPanel, WithdrawFundsButton, } from "./WithdrawDetailPanel"; -type EarnView = "main" | "selectToken" | "selectMarket" | "withdrawDetail"; - -interface EarnIntentWidgetProps { - isOpen: boolean; - onClose: () => void; - api: ApiConfig; - network?: "mainnet" | "testnet"; - allowNetworkToggle?: boolean; - allowGasless?: boolean; - gasless?: boolean; - classNames?: EpochClassNames; - theme?: "light" | "dark" | EpochTheme; - renderInline?: boolean; - title?: string; - submitButtonText?: string; - earnMarkets?: EpochEarnMarket[]; - earnMarketsSource?: OneDeltaConfig[]; - earnDefaultTab?: "deposit" | "withdraw"; - earnHideTabs?: boolean; - earnDepositDefaults?: EarnDepositIntentDefaults; - earnWithdrawDefaults?: EarnWithdrawIntentDefaults; - /** Override the 1delta-solver base URL (`POST /earn/quote`). Defaults to `api.baseUrl`. */ - earnSolverUrl?: string; - /** Optional Miden wallet adapter for testnet earn deposits funded from Miden. */ - earnMiden?: EarnMidenAdapter; - /** - * Chain IDs to fan /pools fetches over. Forwarded as one `chainId=` per - * request. Default: [1, 8453, 42161, 10, 137]. Set to a single chain to - * scope the picker. - */ - earnChainIds?: number[]; - /** - * Restrict /pools to specific lender keys. Passed verbatim as the `lender` - * query param — 1delta accepts CSV (e.g. `AAVE_V3,COMPOUND_V3_USDC`) and - * matches the granular `lenderKey` (per-market for Morpho/Fluid). Omit to - * include every lender on each chain. - */ - earnLenderFilter?: string; - /** Max rows per chain on /pools (1delta `count`). Default 100. */ - earnPoolsPerChain?: number; - /** /pools sort field. Default `totalDepositsUsd`. */ - earnPoolsSortBy?: - | "depositRate" - | "variableBorrowRate" - | "totalDepositsUsd" - | "totalLiquidityUsd" - | "utilization"; - /** /pools sort direction. Default `DESC`. */ - earnPoolsSortDir?: "ASC" | "DESC"; - /** @deprecated no-op — markets always come from `earnMarketsSource`. */ - earnUseMockData?: boolean; - onIntentSent?: (data: IntentSentPayload) => void; - onIntentComplete?: (data: IntentCompletePayload) => void; - onError?: (ctx: OnErrorCtx) => void; - onOpen?: () => void; - onStart?: (ctx: OnStartCtx) => void; - onSign?: (ctx: OnSignCtx) => void; - onSuccess?: (ctx: OnSuccessCtx) => void; - onStatus?: (ctx: OnStatusCtx) => void; - routingAndLiquidityOptions?: RoutingAndLiquidityOptions; -} - -export function EarnIntentWidget({ - isOpen, - onClose, - api, - network: networkProp = "mainnet", - allowNetworkToggle = true, - allowGasless = true, - gasless: gaslessProp = false, - classNames: cn, - theme, - renderInline = false, - title, - submitButtonText, - earnMarkets: earnMarketsProp, - earnMarketsSource, - earnDefaultTab = "deposit", - earnHideTabs = false, - earnDepositDefaults, - earnWithdrawDefaults, - earnSolverUrl, - earnMiden, - earnChainIds, - earnLenderFilter, - earnPoolsPerChain, - earnPoolsSortBy, - earnPoolsSortDir, - onIntentSent, - onIntentComplete, - onError, - onOpen, - onStart, - onSign, - onSuccess, - onStatus, - routingAndLiquidityOptions, -}: EarnIntentWidgetProps) { - const sessionId = useSessionId(isOpen); - const { address, isConnected, connector } = useAccount(); - const chainId = useChainId(); - const { switchChain } = useSwitchChain(); - const { data: walletClient } = useWalletClient(); - - const effectiveAllowGasless = useMemo( - () => - allowGasless && - walletClient != null && - detectWalletAccountType(walletClient as never) === "local", - [allowGasless, walletClient], - ); - - const [earnTab, setEarnTab] = useState<"deposit" | "withdraw">( - earnDefaultTab, - ); - const [earnSelectedMarket, setEarnSelectedMarket] = - useState(null); - const [earnAmount, setEarnAmount] = useState(""); - const [selectedPosition, setSelectedPosition] = - useState(null); - const [withdrawAmount, setWithdrawAmount] = useState(""); - // Smart Withdraw = let Epoch's intent network bridge/swap the withdrawn - // underlying to a different chain or token. OFF = receive the underlying on - // its native chain (current default behaviour). When ON, the - // `smartDest*` state captures the user's chosen destination — wired into - // the UI but not yet plumbed into the intent payload (today's flow still - // pins source = underlying, lands on the position's chain). - const [smartWithdraw, setSmartWithdraw] = useState(false); - const [smartDestChainId, setSmartDestChainId] = useState(null); - const [smartDestTokenAddress, setSmartDestTokenAddress] = useState(""); - // Positions-API filters. Defaults: all chains (empty → derived CSV) + all - // lenders. User can narrow via the dropdowns in WithdrawPanel. - const [positionsChainId, setPositionsChainId] = useState( - networkProp === "testnet" ? "84532" : "", - ); - const [positionsLenderKey, setPositionsLenderKey] = useState(""); - const [selectedChainId, setSelectedChainId] = useState(null); - const [fundingSource, setFundingSource] = useState<"evm" | "miden">("evm"); - const [selectedMidenFaucetId, setSelectedMidenFaucetId] = useState( - DEFAULT_MIDEN_FAUCET.faucetId, - ); - const [view, setView] = useState("main"); - const [isTestnet, setIsTestnet] = useState(networkProp === "testnet"); - const [gasless, setGasless] = useState(gaslessProp); - const networkEnv: "mainnet" | "testnet" = isTestnet ? "testnet" : "mainnet"; - const midenEnabled = - networkEnv === "testnet" && - earnMiden != null && - earnMiden.enabled !== false; - const resolvedApi = useMemo( - () => resolveApiForNetwork(api, networkEnv), - [api, networkEnv], - ); - - // Source-funding chains. Testnet is narrowed to the chains dummy-lending can - // actually be funded from; mainnet offers the full Epoch chain list. - const availableChains = useMemo(() => { - const chains = getEpochChains(isTestnet); - if (!isTestnet) return chains; - return chains.filter((c) => EARN_TESTNET_SOURCE_EVM_CHAIN_IDS.has(c.id)); - }, [isTestnet]); - - const allTokens = useMemo(() => { - const allowed = new Set(DUMMY_LENDING_SUPPORTED_ADDRESSES); - const deprecated = DEPRECATED_DUMMY_LENDING_USDC_ADDRESS.toLowerCase(); - return availableChains.flatMap((chain) => - getEpochTokensByChainEnv(chain.id, isTestnet).flatMap((tok) => { - if (isTestnet) { - const addr = tok.address.toLowerCase(); - if (!allowed.has(addr) || addr === deprecated) return []; - } - return [{ ...tok, chain }]; - }), - ); - }, [availableChains, isTestnet]); +export type { EarnIntentWidgetProps } from "../earn/earn-props"; +import type { EarnIntentWidgetProps } from "../earn/earn-props"; - const availableTokens = useMemo( - () => - selectedChainId - ? getEpochTokensByChainEnv(selectedChainId, isTestnet) - : [], - [selectedChainId, isTestnet], - ); +/** + * Earn flow. `useEarnEngine` owns the state and SDK wiring; this file decides + * what the user sees. + */ +export function EarnIntentWidget(props: EarnIntentWidgetProps) { + const engine = useEarnEngine(props); const { - address: selectedTokenAddress, - token: selectedToken, - setPick: setTokenAddressPick, - } = useTokenPick(availableTokens); - - useOnOpen(isOpen, onOpen); - - useEffect(() => { - if (!isOpen) return; - setEarnTab(earnDefaultTab); - }, [isOpen, earnDefaultTab]); - - // Default destination = the position's underlying chain + token. - const applySmartDestDefaults = useCallback( - (position: EpochEarnPosition | null) => { - if (!position) { - setSmartDestChainId(null); - setSmartDestTokenAddress(""); - return; - } - if (position.market.chainId != null) { - setSmartDestChainId(position.market.chainId); - } - setSmartDestTokenAddress(position.market.token.address); - }, - [], - ); - - // Every position change reseeds the destination in the same render, rather - // than letting an effect watch `selectedPosition` and set it a render later. - const selectPosition = useCallback( - (position: EpochEarnPosition | null) => { - setSelectedPosition(position); - applySmartDestDefaults(position); - }, - [applySmartDestDefaults], - ); - - // Smart Withdraw OFF restores the position's own chain + underlying so - // nothing stale survives if the user toggles it back on. - const handleSmartWithdrawChange = useCallback( - (next: boolean) => { - setSmartWithdraw(next); - if (!next) applySmartDestDefaults(selectedPosition); - }, - [applySmartDestDefaults, selectedPosition], - ); - - // Switching network invalidates every network-scoped selection. Both entry - // points — the `network` prop and the header toggle — funnel through here so - // the resets land in the same render as the `isTestnet` flip, instead of - // cascading through an effect that watches `isTestnet` and repaints twice. - const applyNetwork = useCallback( - (nextIsTestnet: boolean) => { - setIsTestnet(nextIsTestnet); - setSelectedChainId(null); - setTokenAddressPick(""); - setEarnSelectedMarket(null); - selectPosition(null); - setEarnAmount(""); - setWithdrawAmount(""); - setPositionsChainId(nextIsTestnet ? "84532" : ""); - if (!nextIsTestnet) setFundingSource("evm"); - }, - [selectPosition, setTokenAddressPick], - ); - - useEffect(() => { - applyNetwork(networkProp === "testnet"); - }, [networkProp, applyNetwork]); - - const picker = useEarnMarketPicker({ - api: resolvedApi, - enabled: isOpen && view === "selectMarket", - configsEnabled: isOpen, - isTestnet, - networkEnv, - earnChainIds, - earnLenderFilter, - earnMarketsSource, - defaultSortBy: earnPoolsSortBy, - defaultSortDir: earnPoolsSortDir, - }); - - // No "reset on close" effect here by design. `EpochIntentWidget` — the only - // thing that renders this — returns null while closed, so the whole component - // unmounts and every value below reverts to its useState initializer on the - // next open. A reset effect would be dead code that silently rots as new - // state is added. - - // legacy: callers passing `earnMarkets` directly still see them — we render - // the configs picker but the deprecated prop is accepted for back-compat. - void earnMarketsProp; - void earnPoolsPerChain; - - const positionsState = useUserPositions({ - address, - network: networkEnv, - api: resolvedApi, - // Only fetch positions while the Withdraw tab is active AND the user is - // on the main list view — skips the request on deposit usage and - // prevents a refetch when the user enters the withdraw detail view. - enabled: isOpen && isConnected && earnTab === "withdraw" && view === "main", - // Scope to the earn chains directly — no longer derived from a full pool - // config set (the picker is now server-paginated). Empty user filter ⇒ - // fall back to the all-chains CSV. - chainsOverride: positionsChainId || picker.earnChainsCsv, - lendersOverride: positionsLenderKey, - }); - - // Auto-pick: when the user lands on the Withdraw tab and positions arrive, - // jump straight into the detail/amount view with positions[0] selected. The - // user opens the picker explicitly via the From card chevron — so we only - // do this once per (open × tab-entry) and never re-trigger it as long as a - // selection is preserved. - const didAutoPickRef = useRef(false); - useEffect(() => { - if (!isOpen) { - didAutoPickRef.current = false; - return; - } - if (earnTab !== "withdraw") { - didAutoPickRef.current = false; - return; - } - if (didAutoPickRef.current) return; - if (selectedPosition) return; - const first = positionsState.positions[0]; - if (!first) return; - didAutoPickRef.current = true; - selectPosition(first); - setView("withdrawDetail"); - }, [ - isOpen, + allTokens, + ctaEnabled, + ctaState, + earnFlow, + earnSelectedMarket, earnTab, - positionsState.positions, - selectedPosition, - selectPosition, - ]); - - const gaslessWallet = useGaslessWallet({ - allowGasless: - effectiveAllowGasless && - (earnTab === "withdraw" || fundingSource === "evm"), - apiBaseUrl: resolvedApi.baseUrl, - gasless, - setGasless, - walletClient, - address, - // A withdraw executes on the POSITION's chain, not the deposit funding - // chain — probing the wrong one would gate the toggle on 7702 support the - // withdraw never uses. - chainIdForCheck: - earnTab === "withdraw" - ? (selectedPosition?.market.chainId ?? null) - : fundingSource === "miden" - ? null - : (selectedChainId ?? - (isTestnet ? 84532 : (walletClient?.chain?.id ?? null))), - switchChain, - }); - - const selectedChain = availableChains.find((c) => c.id === selectedChainId); - const miden = useEarnMiden({ - earnMiden, + fundingSource, + handleCtaClick, + isBusy, isTestnet, + miden, midenEnabled, - fundingSource, - selectedMidenFaucetId, - earnTab, - smartWithdraw, - smartDestChainId, - smartDestTokenAddress, - }); - - const pillToken = - fundingSource === "miden" && miden.sourceToken - ? miden.sourceToken - : (selectedToken ?? allTokens[0] ?? null); - const pillChain = - fundingSource === "miden" - ? miden.chain - : (selectedChain ?? availableChains[0] ?? null); - - useEffect(() => { - if (!isOpen) return; - if (fundingSource === "miden") return; - if (selectedChainId !== null) return; - const first = allTokens[0]; - if (!first) return; - setSelectedChainId(first.chain.id); - setTokenAddressPick(first.address); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isOpen, allTokens]); - - const { balance, isLoading: isBalanceLoading } = useTokenBalance( + modalTitle, + picker, + selectPosition, selectedChainId, - selectedTokenAddress, - address, - api.rpcUrls, - ); - - const { - activeAmount, - activeMarket, - activeBuildOk, - depositBuildError, - withdrawBuildError, - effectiveSourceChainId, - effectiveSourceToken, - isSmartWithdrawDegenerate, - } = useEarnQuoteTarget({ - earnTab, - fundingSource, - earnSelectedMarket, - earnAmount, selectedPosition, - withdrawAmount, - earnDepositDefaults, - earnWithdrawDefaults, - selectedChainId, - selectedToken, - midenSourceToken: miden.sourceToken, - smartWithdraw, - smartDestChainId, - smartDestTokenAddress, - onPinSourceChain: setSelectedChainId, - }); - - const earnFlow = useEarnIntentFlow({ - apiBaseUrl: resolvedApi.baseUrl, - earnSolverUrl, - walletClient, - address, - sessionId, - routingAndLiquidityOptions, - gasless: effectiveAllowGasless && gasless, - onIntentSent, - onIntentComplete, - onError, - onStart, - onSign, - onSuccess, - onRequestClose: onClose, - }); - - const onStatusRef = useLatestRef(onStatus); - useEffect(() => { - const callbackStatus = - earnFlow.status === "quoting" ? "idle" : earnFlow.status; - if (callbackStatus === "polling") return; - onStatusRef.current?.({ - sessionId, - status: callbackStatus, - progress: earnFlow.statusProgress, - activeStep: earnFlow.activeStep, - }); - }, [ - sessionId, - earnFlow.status, - earnFlow.statusProgress, - earnFlow.activeStep, - onStatusRef, - ]); - - // Single point of entry for kicking off a quote — used both by the auto-fire - // effect (debounced as inputs change) and by the manual "Retry quote" CTA - // shown when the previous attempt failed. - const triggerQuote = useCallback(() => { - if ( - !activeBuildOk || - effectiveSourceChainId == null || - !effectiveSourceToken || - !activeMarket || - !address - ) - return; - if ( - fundingSource === "miden" && - (!earnMiden?.connected || !miden.quoteSource) - ) - return; - // Destination not yet moved off the position's own chain/token → no route - // to quote. Skip until the user picks a real destination. - if (isSmartWithdrawDegenerate) return; - // Miden destination chosen but no Miden account connected → no recipient. - if (miden.smartDestNotReady) return; - earnFlow.fetchQuote({ - tab: earnTab, - amount: activeAmount, - market: activeMarket, - position: selectedPosition, - sourceChainId: effectiveSourceChainId, - sourceToken: effectiveSourceToken, - network: networkEnv, - midenSource: miden.quoteSource, - smartWithdraw: earnTab === "withdraw" ? smartWithdraw : undefined, - smartDestChainId: earnTab === "withdraw" ? smartDestChainId : undefined, - smartDestTokenAddress: - earnTab === "withdraw" ? smartDestTokenAddress : undefined, - midenDest: earnTab === "withdraw" ? miden.smartDest : undefined, - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - activeBuildOk, - activeAmount, - activeMarket, - effectiveSourceChainId, - effectiveSourceToken?.address, - address, - earnTab, + selectedTokenAddress, + setEarnSelectedMarket, + setSelectedChainId, + setSelectedMidenFaucetId, + setSmartWithdraw, + setTokenAddressPick, + setWithdrawAmount, smartWithdraw, - smartDestChainId, - smartDestTokenAddress, - selectedPosition, - networkEnv, - fundingSource, - miden.quoteSource, - earnMiden?.connected, - isSmartWithdrawDegenerate, - miden.smartDest, - miden.smartDestNotReady, - ]); - - useEffect(() => { - const timer = window.setTimeout(triggerQuote, 250); - return () => window.clearTimeout(timer); - }, [triggerQuote]); - - const isWrongNetwork = - fundingSource === "evm" && - effectiveSourceChainId !== null && - chainId !== effectiveSourceChainId; - const insufficientBalance = - earnTab === "deposit" && - fundingSource === "evm" && - balance !== null && - balance === 0n; - const insufficientMidenBalance = - earnTab === "deposit" && - fundingSource === "miden" && - miden.balance !== null && - miden.balance === 0n; - const isBusy = earnFlow.isBusy; - - // Cross-chain = the market lives on a different chain than the source the - // user is funding from. SIO will insert a bridge/swap step before the - // 1delta deposit, so the CTA hints at that. - const isCrossChain = - !!activeMarket && - earnTab === "deposit" && - (fundingSource === "miden" || - (selectedChainId !== null && - activeMarket.chainId != null && - selectedChainId !== activeMarket.chainId)); - - const ctaState = resolveEarnCta({ - earnTab, - fundingSource, - flow: { - isQuoting: earnFlow.isQuoting, - status: earnFlow.status, - quoteError: earnFlow.quoteError, - }, - isConnected, - midenConnected: !!earnMiden?.connected, - hasSelectedMarket: !!earnSelectedMarket, - depositAmount: earnAmount, - hasSelectedPosition: !!selectedPosition, - withdrawAmount, - availableChains, - selectedChain, - effectiveSourceChainId, - effectiveSourceToken, - isWrongNetwork, - insufficientBalance, - selectedToken, - insufficientMidenBalance, - midenAssetSymbol: miden.selectedAsset?.symbol, - buildOk: activeBuildOk, - isSmartWithdrawDegenerate, - midenSmartDestNotReady: miden.smartDestNotReady, - isCrossChain, - submitButtonText, - }); - - const ctaEnabled = isEarnCtaEnabled(ctaState.action); - - const detailTokenSymbol = selectedPosition?.market.token.symbol; - const modalTitle = - view === "withdrawDetail" && detailTokenSymbol - ? `Withdraw ${detailTokenSymbol}` - : (title ?? (earnTab === "deposit" ? "Earn" : "Withdraw")); + view, + setView, + applyNetwork, + allowNetworkToggle, + cn, + theme, + renderInline, + isOpen, + onClose, + } = engine; const headerAction = allowNetworkToggle ? ( - applyNetwork(checked)} - /> + ) : null; - const handleConnectMiden = useCallback(() => { - void Promise.resolve(earnMiden?.connect?.()).catch(() => { - // useEarnMidenAdapter surfaces a toast; swallow to avoid unhandled rejection. - }); - }, [earnMiden]); - - const handleCtaClick = () => { - if (ctaState.action === "connectMiden") { - handleConnectMiden(); - return; - } - if (ctaState.action === "switch") { - const target = - earnTab === "withdraw" - ? (availableChains.find((c) => c.id === effectiveSourceChainId) ?? - selectedChain) - : selectedChain; - if (target) switchChain?.({ chainId: target.id }); - return; - } - if (ctaState.action === "retry") { - triggerQuote(); - return; - } - if (ctaState.action !== "submit") return; - if ( - effectiveSourceChainId == null || - !effectiveSourceToken || - !activeMarket - ) - return; - earnFlow.submit({ - tab: earnTab, - amount: activeAmount, - market: activeMarket, - position: selectedPosition, - sourceChainId: effectiveSourceChainId, - sourceToken: effectiveSourceToken, - network: networkEnv, - quote: earnFlow.quote, - midenSource: miden.quoteSource, - smartWithdraw: earnTab === "withdraw" ? smartWithdraw : undefined, - smartDestChainId: earnTab === "withdraw" ? smartDestChainId : undefined, - smartDestTokenAddress: - earnTab === "withdraw" ? smartDestTokenAddress : undefined, - midenDest: earnTab === "withdraw" ? miden.smartDest : undefined, - }); - }; const inlineError = earnFlow.error ?? @@ -768,35 +139,37 @@ export function EarnIntentWidget({ const backToMain = () => setView("main"); if (view === "selectToken") { - const isMidenPicker = fundingSource === "miden" && midenEnabled; - return ( - - {isMidenPicker ? ( + // Miden assets are keyed by faucet id, not a contract address, so they get + // their own picker rather than the shared EVM one. + if (fundingSource === "miden" && midenEnabled) { + return ( + 0 ? miden.assets : [DEFAULT_MIDEN_FAUCET]} + assets={ + miden.assets.length > 0 ? miden.assets : [DEFAULT_MIDEN_FAUCET] + } onSelect={(faucetId) => { setSelectedMidenFaucetId(faucetId); - setView("main"); - }} - /> - ) : ( - { - setSelectedChainId(cid); - setTokenAddressPick(addr); - setView("main"); + backToMain(); }} - onBack={backToMain} /> - )} - + + ); + } + return ( + { + setSelectedChainId(cid); + setTokenAddressPick(addr); + backToMain(); + }} + onBack={backToMain} + /> ); } @@ -813,82 +186,11 @@ export function EarnIntentWidget({ setSmartWithdraw(false); }} > - { - setWithdrawAmount(v); - }} - onPickFraction={(human) => { - setWithdrawAmount(human); - }} - onPickAnotherPosition={() => { - // Open the picker without discarding the current selection — if - // the user backs out, they return to the same amount + position. - // The picker swap (onPickPosition) is what resets the amount. - setView("main"); - }} - smartWithdraw={smartWithdraw} - onSmartWithdrawChange={handleSmartWithdrawChange} - smartDestChainId={smartDestChainId} - smartDestTokenAddress={smartDestTokenAddress} - onPickDestChain={(id) => { - setSmartDestChainId(id); - // Reset receive token to first option on the new chain so we never - // surface a stale token from another network. Miden → default faucet. - if (id === MIDEN_VIRTUAL_CHAIN_ID) { - setSmartDestTokenAddress( - miden.destFaucets[0]?.faucetId ?? DEFAULT_MIDEN_FAUCET.faucetId, - ); - } else { - const firstTok = getEpochTokensByChainEnv(id, isTestnet)[0]; - setSmartDestTokenAddress(firstTok?.address ?? ""); - } - }} - onPickDestToken={setSmartDestTokenAddress} - isTestnet={isTestnet} - midenDestEnabled={miden.destEnabled} - midenRecipientAccount={earnMiden?.accountId} - midenFaucets={miden.destFaucets} - buildError={withdrawBuildError} - quoteError={earnFlow.quoteError} - isQuoting={earnFlow.isQuoting} - approxUsd={(() => { - const usd = selectedPosition.underlyingUsdValue; - const bal = (() => { - try { - return ( - Number(selectedPosition.underlyingBalanceRaw) / - 10 ** selectedPosition.market.token.decimals - ); - } catch { - return 0; - } - })(); - const n = Number(withdrawAmount); - if (!Number.isFinite(n) || n <= 0 || !usd || bal <= 0) return null; - return (n / bal) * usd; - })()} + onPickAnotherPosition={backToMain} /> - {(earnFlow.status === "submitting" || - earnFlow.status === "sent" || - earnFlow.status === "complete") && ( - - )} - {earnFlow.status === "complete" && ( - -
- - Withdraw completed successfully. -
-
- )} ); } @@ -935,157 +237,12 @@ export function EarnIntentWidget({ footer={footer} headerAction={headerAction} > - {!earnHideTabs && ( - - tabs={[ - { - value: "deposit", - label: "Deposit", - icon: , - }, - { - value: "withdraw", - label: "Withdraw", - icon: ( - - - - ), - }, - ]} - value={earnTab} - onChange={setEarnTab} - size="md" - style={{ marginBottom: "4px" }} - /> - )} - - {earnTab === "deposit" ? ( - <> - {effectiveAllowGasless && fundingSource === "evm" ? ( - - gaslessWallet.switchToEpochSmartAccount() - } - setupBusy={gaslessWallet.setupBusy} - setupError={gaslessWallet.setupError} - checking={gaslessWallet.checking} - onEnable={() => setGasless(true)} - onDisable={() => setGasless(false)} - className="mb-1" - /> - ) : null} - setView("selectMarket")} - amount={earnAmount} - onAmountChange={setEarnAmount} - buildError={depositBuildError} - walletConnected={isConnected} - walletAddress={isConnected ? address : undefined} - walletIcon={isConnected ? connector?.icon : undefined} - sourceTokenSymbol={pillToken?.symbol ?? "-"} - sourceChainName={pillChain?.name ?? ""} - sourceTokenLogoURI={pillToken?.logoURI} - sourceChainLogoURI={pillChain?.logoURI} - onSelectSourceToken={() => setView("selectToken")} - walletBalance={ - fundingSource === "miden" - ? miden.balance - : isConnected - ? balance - : null - } - sourceTokenDecimals={ - fundingSource === "miden" - ? (miden.selectedAsset?.decimals ?? 18) - : (selectedToken?.decimals ?? 18) - } - balanceLoading={ - fundingSource === "miden" - ? false - : isConnected && !!selectedToken && isBalanceLoading - } - midenEnabled={midenEnabled} - fundingSource={fundingSource} - onFundingSourceChange={setFundingSource} - midenConnected={!!earnMiden?.connected} - onConnectMiden={handleConnectMiden} - /> - - ) : ( - <> - {effectiveAllowGasless ? ( - - gaslessWallet.switchToEpochSmartAccount() - } - setupBusy={gaslessWallet.setupBusy} - setupError={gaslessWallet.setupError} - checking={gaslessWallet.checking} - onEnable={() => setGasless(true)} - onDisable={() => setGasless(false)} - className="mb-1" - /> - ) : null} - { - selectPosition(p); - setWithdrawAmount(""); - setSmartWithdraw(false); - setView("withdrawDetail"); - }} - chainFilter={positionsChainId} - onChainFilterChange={(v) => { - setPositionsChainId(v); - selectPosition(null); - setWithdrawAmount(""); - }} - lenderFilter={positionsLenderKey} - onLenderFilterChange={(v) => { - setPositionsLenderKey(v); - selectPosition(null); - setWithdrawAmount(""); - }} - /> - - )} - - {(earnFlow.status === "submitting" || - earnFlow.status === "sent" || - earnFlow.status === "complete") && ( - - )} - - {earnFlow.status === "complete" && ( - -
- - Earn action completed successfully. -
-
- )} + setView("selectToken")} + onPickMarket={() => setView("selectMarket")} + onOpenPosition={() => setView("withdrawDetail")} + /> ); -} +} \ No newline at end of file diff --git a/src/components/GaslessSection.tsx b/src/components/GaslessSection.tsx index 06bfaec..2a2920e 100644 --- a/src/components/GaslessSection.tsx +++ b/src/components/GaslessSection.tsx @@ -7,6 +7,7 @@ interface GaslessSectionProps { wallet: UseGaslessWalletResult; gasless: boolean; onChange: (next: boolean) => void; + className?: string; } /** Wires `useGaslessWallet` to its button. Shared by every flow's footer area. */ @@ -15,6 +16,7 @@ export function GaslessSection({ wallet, gasless, onChange, + className, }: GaslessSectionProps) { if (!allowed) return null; return ( @@ -28,6 +30,7 @@ export function GaslessSection({ checking={wallet.checking} onEnable={() => onChange(true)} onDisable={() => onChange(false)} + className={className} /> ); } diff --git a/src/components/IntentProgress.tsx b/src/components/IntentProgress.tsx index 50c0c86..7167130 100644 --- a/src/components/IntentProgress.tsx +++ b/src/components/IntentProgress.tsx @@ -3,6 +3,11 @@ import { Banner } from './Banner'; import { CheckIcon } from './Icons'; import { ProgressStepper } from './ProgressStepper'; +/** Flows disagree on which statuses show the tracker — Earn reports `sent` + * where Pay/Swap reports `polling`. */ +export const EARN_PROGRESS_STATUSES = ['submitting', 'sent', 'complete']; +export const PAY_SWAP_PROGRESS_STATUSES = ['submitting', 'polling', 'complete']; + interface IntentProgressProps { status: string; /** diff --git a/src/components/PaySwapIntentWidget.tsx b/src/components/PaySwapIntentWidget.tsx index 8fc2a6b..da712d2 100644 --- a/src/components/PaySwapIntentWidget.tsx +++ b/src/components/PaySwapIntentWidget.tsx @@ -14,7 +14,7 @@ import { NetworkToggle } from "./NetworkToggle"; import { TokenChainPill } from "./TokenChainPill"; import { ActionButton } from "./ui/ActionButton"; import { PaySwapMainView } from "./pay/PaySwapMainView"; -import { TokenPickerModal } from "./pay/TokenPickerModal"; +import { TokenPickerModal } from "./TokenPickerModal"; export type { PaySwapIntentWidgetProps } from "../pay/pay-swap-props"; import type { PaySwapIntentWidgetProps } from "../pay/pay-swap-props"; diff --git a/src/components/pay/TokenPickerModal.tsx b/src/components/TokenPickerModal.tsx similarity index 83% rename from src/components/pay/TokenPickerModal.tsx rename to src/components/TokenPickerModal.tsx index 2a342ef..36da718 100644 --- a/src/components/pay/TokenPickerModal.tsx +++ b/src/components/TokenPickerModal.tsx @@ -1,6 +1,6 @@ import type { ComponentProps } from 'react'; -import { Modal } from '../Modal'; -import { TokenSelector, type TokenWithChain } from '../TokenSelector'; +import { Modal } from './Modal'; +import { TokenSelector, type TokenWithChain } from './TokenSelector'; interface TokenPickerModalProps { /** Modal chrome shared with every other view of the widget. */ @@ -13,7 +13,7 @@ interface TokenPickerModalProps { onBack: () => void; } -/** Source and destination pickers differ only in title and list. */ +/** Every flow's token picker differs only in title and list. */ export function TokenPickerModal({ chrome, title, diff --git a/src/components/earn/EarnMainView.tsx b/src/components/earn/EarnMainView.tsx new file mode 100644 index 0000000..57c6639 --- /dev/null +++ b/src/components/earn/EarnMainView.tsx @@ -0,0 +1,193 @@ +import type { EarnEngine } from "../../earn/use-earn-engine"; +import { ArrowDownIcon } from "../Icons"; +import { SegmentedTabs } from "../ui/SegmentedTabs"; +import { EarnFlowPanel } from "../EarnFlowPanel"; +import { GaslessSection } from "../GaslessSection"; +import { + IntentProgress, + EARN_PROGRESS_STATUSES, +} from "../IntentProgress"; +import { WithdrawPanel } from "../WithdrawPanel"; + +interface EarnMainViewProps { + engine: EarnEngine; + onPickToken: () => void; + onPickMarket: () => void; + onOpenPosition: () => void; +} + +/** Deposit / Withdraw tabs — the widget's landing view. */ +export function EarnMainView({ + engine, + onPickToken, + onPickMarket, + onOpenPosition, +}: EarnMainViewProps) { + const { + address, + walletIcon, + balance, + cn, + depositBuildError, + earnAmount, + earnFlow, + earnHideTabs, + earnMiden, + earnSelectedMarket, + earnTab, + effectiveAllowGasless, + fundingSource, + gasless, + gaslessWallet, + handleConnectMiden, + isBalanceLoading, + isConnected, + isTestnet, + miden, + midenEnabled, + pillChain, + pillToken, + positionsChainId, + positionsLenderKey, + positionsState, + selectPosition, + selectedPosition, + selectedToken, + setEarnAmount, + setEarnTab, + setFundingSource, + setGasless, + setPositionsChainId, + setPositionsLenderKey, + setSmartWithdraw, + setWithdrawAmount, + } = engine; + + return ( + <> + {!earnHideTabs && ( + + tabs={[ + { + value: "deposit", + label: "Deposit", + icon: , + }, + { + value: "withdraw", + label: "Withdraw", + icon: ( + + + + ), + }, + ]} + value={earnTab} + onChange={setEarnTab} + size="md" + style={{ marginBottom: "4px" }} + /> + )} + + {earnTab === "deposit" ? ( + <> + + + + ) : ( + <> + + { + selectPosition(p); + setWithdrawAmount(""); + setSmartWithdraw(false); + onOpenPosition(); + }} + chainFilter={positionsChainId} + onChainFilterChange={(v) => { + setPositionsChainId(v); + selectPosition(null); + setWithdrawAmount(""); + }} + lenderFilter={positionsLenderKey} + onLenderFilterChange={(v) => { + setPositionsLenderKey(v); + selectPosition(null); + setWithdrawAmount(""); + }} + /> + + )} + + + + ); +} diff --git a/src/components/earn/EarnWithdrawDetailView.tsx b/src/components/earn/EarnWithdrawDetailView.tsx new file mode 100644 index 0000000..da839fc --- /dev/null +++ b/src/components/earn/EarnWithdrawDetailView.tsx @@ -0,0 +1,106 @@ +import type { EarnEngine } from "../../earn/use-earn-engine"; +import { MIDEN_VIRTUAL_CHAIN_ID, DEFAULT_MIDEN_FAUCET } from "../../earn/miden"; +import { getEpochTokensByChainEnv } from "../../epoch-config"; +import { + IntentProgress, + EARN_PROGRESS_STATUSES, +} from "../IntentProgress"; +import { WithdrawDetailPanel } from "../WithdrawDetailPanel"; +import type { EpochEarnPosition } from "../../types"; + +interface EarnWithdrawDetailViewProps { + engine: EarnEngine; + position: EpochEarnPosition; + onPickAnotherPosition: () => void; +} + +/** Amount + Smart Withdraw destination for one position. */ +export function EarnWithdrawDetailView({ + engine, + position, + onPickAnotherPosition, +}: EarnWithdrawDetailViewProps) { + const { + cn, + earnFlow, + earnMiden, + handleSmartWithdrawChange, + isTestnet, + miden, + setSmartDestChainId, + setSmartDestTokenAddress, + setWithdrawAmount, + smartDestChainId, + smartDestTokenAddress, + smartWithdraw, + withdrawAmount, + withdrawBuildError, + } = engine; + + return ( + <> + { + setWithdrawAmount(v); + }} + onPickFraction={(human) => { + setWithdrawAmount(human); + }} + // Opens the picker without discarding the current selection — backing + // out returns to the same amount + position. + onPickAnotherPosition={onPickAnotherPosition} + smartWithdraw={smartWithdraw} + onSmartWithdrawChange={handleSmartWithdrawChange} + smartDestChainId={smartDestChainId} + smartDestTokenAddress={smartDestTokenAddress} + onPickDestChain={(id) => { + setSmartDestChainId(id); + // Reset receive token to first option on the new chain so we never + // surface a stale token from another network. Miden → default faucet. + if (id === MIDEN_VIRTUAL_CHAIN_ID) { + setSmartDestTokenAddress( + miden.destFaucets[0]?.faucetId ?? DEFAULT_MIDEN_FAUCET.faucetId, + ); + } else { + const firstTok = getEpochTokensByChainEnv(id, isTestnet)[0]; + setSmartDestTokenAddress(firstTok?.address ?? ""); + } + }} + onPickDestToken={setSmartDestTokenAddress} + isTestnet={isTestnet} + midenDestEnabled={miden.destEnabled} + midenRecipientAccount={earnMiden?.accountId} + midenFaucets={miden.destFaucets} + buildError={withdrawBuildError} + quoteError={earnFlow.quoteError} + isQuoting={earnFlow.isQuoting} + approxUsd={(() => { + const usd = position.underlyingUsdValue; + const bal = (() => { + try { + return ( + Number(position.underlyingBalanceRaw) / + 10 ** position.market.token.decimals + ); + } catch { + return 0; + } + })(); + const n = Number(withdrawAmount); + if (!Number.isFinite(n) || n <= 0 || !usd || bal <= 0) return null; + return (n / bal) * usd; + })()} + /> + + + ); +} diff --git a/src/components/pay/PaySwapMainView.tsx b/src/components/pay/PaySwapMainView.tsx index a5e8e4b..d6590eb 100644 --- a/src/components/pay/PaySwapMainView.tsx +++ b/src/components/pay/PaySwapMainView.tsx @@ -3,10 +3,10 @@ import type { PaySwapEngine } from '../../pay/use-pay-swap-engine'; import type { EpochClassNames } from '../../types'; import { Banner } from '../Banner'; import { GaslessSection } from '../GaslessSection'; -import { IntentProgress } from '../IntentProgress'; - -/** Statuses Pay/Swap shows the step tracker for. Earn tracks a different set. */ -const PROGRESS_STATUSES = ['submitting', 'polling', 'complete']; +import { + IntentProgress, + PAY_SWAP_PROGRESS_STATUSES, +} from '../IntentProgress'; interface PaySwapMainViewProps { engine: PaySwapEngine; @@ -85,7 +85,7 @@ export function PaySwapMainView({ void; + api: ApiConfig; + network?: "mainnet" | "testnet"; + allowNetworkToggle?: boolean; + allowGasless?: boolean; + gasless?: boolean; + classNames?: EpochClassNames; + theme?: "light" | "dark" | EpochTheme; + renderInline?: boolean; + title?: string; + submitButtonText?: string; + earnMarkets?: EpochEarnMarket[]; + earnMarketsSource?: OneDeltaConfig[]; + earnDefaultTab?: "deposit" | "withdraw"; + earnHideTabs?: boolean; + earnDepositDefaults?: EarnDepositIntentDefaults; + earnWithdrawDefaults?: EarnWithdrawIntentDefaults; + /** Override the 1delta-solver base URL (`POST /earn/quote`). Defaults to `api.baseUrl`. */ + earnSolverUrl?: string; + /** Optional Miden wallet adapter for testnet earn deposits funded from Miden. */ + earnMiden?: EarnMidenAdapter; + /** + * Chain IDs to fan /pools fetches over. Forwarded as one `chainId=` per + * request. Default: [1, 8453, 42161, 10, 137]. Set to a single chain to + * scope the picker. + */ + earnChainIds?: number[]; + /** + * Restrict /pools to specific lender keys. Passed verbatim as the `lender` + * query param — 1delta accepts CSV (e.g. `AAVE_V3,COMPOUND_V3_USDC`) and + * matches the granular `lenderKey` (per-market for Morpho/Fluid). Omit to + * include every lender on each chain. + */ + earnLenderFilter?: string; + /** Max rows per chain on /pools (1delta `count`). Default 100. */ + earnPoolsPerChain?: number; + /** /pools sort field. Default `totalDepositsUsd`. */ + earnPoolsSortBy?: + | "depositRate" + | "variableBorrowRate" + | "totalDepositsUsd" + | "totalLiquidityUsd" + | "utilization"; + /** /pools sort direction. Default `DESC`. */ + earnPoolsSortDir?: "ASC" | "DESC"; + /** @deprecated no-op — markets always come from `earnMarketsSource`. */ + earnUseMockData?: boolean; + onIntentSent?: (data: IntentSentPayload) => void; + onIntentComplete?: (data: IntentCompletePayload) => void; + onError?: (ctx: OnErrorCtx) => void; + onOpen?: () => void; + onStart?: (ctx: OnStartCtx) => void; + onSign?: (ctx: OnSignCtx) => void; + onSuccess?: (ctx: OnSuccessCtx) => void; + onStatus?: (ctx: OnStatusCtx) => void; + routingAndLiquidityOptions?: RoutingAndLiquidityOptions; +} diff --git a/src/earn/use-earn-engine.ts b/src/earn/use-earn-engine.ts new file mode 100644 index 0000000..1b87ff7 --- /dev/null +++ b/src/earn/use-earn-engine.ts @@ -0,0 +1,769 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useAccount, useChainId, useSwitchChain, useWalletClient } from "wagmi"; +import { detectWalletAccountType } from "@epoch-protocol/epoch-intents-sdk"; +import { getEpochChains, getEpochTokensByChainEnv } from "../epoch-config"; +import { useTokenBalance } from "../use-token-balance"; +import { useSessionId } from "../session"; +import type { EpochEarnMarket, EpochEarnPosition } from "../types"; +import { useUserPositions } from "./api"; +import { useEarnMarketPicker } from "./use-earn-market-picker"; +import { useEarnMiden } from "./use-earn-miden"; +import { useEarnQuoteTarget } from "./use-earn-quote-target"; +import { resolveEarnCta, isEarnCtaEnabled } from "./earn-cta"; +import { EARN_TESTNET_SOURCE_EVM_CHAIN_IDS } from "./earn-chains"; +import { DEFAULT_MIDEN_FAUCET } from "./miden"; +import { + DUMMY_LENDING_SUPPORTED_ADDRESSES, + DEPRECATED_DUMMY_LENDING_USDC_ADDRESS, +} from "./dummy-lending-markets"; +import { resolveApiForNetwork } from "../resolve-api-config"; +import { useEarnIntentFlow } from "./use-earn-intent-flow"; +import { useGaslessWallet } from "../hooks/use-gasless-wallet-check"; +import { useLatestRef } from "../hooks/use-latest-ref"; +import { useOnOpen } from "../hooks/use-on-open"; +import { useTokenPick } from "../hooks/use-token-pick"; +import type { TokenWithChain } from "../components/TokenSelector"; +import type { EarnIntentWidgetProps } from "./earn-props"; + +export type EarnView = "main" | "selectToken" | "selectMarket" | "withdrawDetail"; + +/** + * Choices that only make sense for one network. Tagged with `forTestnet` so a + * network flip invalidates them together rather than one reset per field. + */ +interface EarnSelection { + forTestnet: boolean; + market: EpochEarnMarket | null; + depositAmount: string; + position: EpochEarnPosition | null; + withdrawAmount: string; + chainId: number | null; + fundingSource: "evm" | "miden"; + /** Positions-API chain filter. Testnet pins Base Sepolia; mainnet = all. */ + positionsChainId: string; + /** + * Smart Withdraw routes the withdrawn underlying to another chain/token via + * the intent network. OFF = receive it on the position's own chain. + */ + smartWithdraw: boolean; + smartDestChainId: number | null; + smartDestTokenAddress: string; +} + +const defaultSelection = (isTestnet: boolean): EarnSelection => ({ + forTestnet: isTestnet, + market: null, + depositAmount: "", + position: null, + withdrawAmount: "", + chainId: null, + fundingSource: "evm", + positionsChainId: isTestnet ? "84532" : "", + smartWithdraw: false, + smartDestChainId: null, + smartDestTokenAddress: "", +}); + +/** + * Everything the Earn widget knows, minus how it looks. + * + * Takes the widget's props whole so the component isn't a forty-line + * destructure before it can render anything. + */ +export function useEarnEngine(props: EarnIntentWidgetProps) { + const { + isOpen, + onClose, + api, + network: networkProp = "mainnet", + allowNetworkToggle = true, + allowGasless = true, + gasless: gaslessProp = false, + classNames: cn, + theme, + renderInline = false, + title, + submitButtonText, + earnMarkets: earnMarketsProp, + earnMarketsSource, + earnDefaultTab = "deposit", + earnHideTabs = false, + earnDepositDefaults, + earnWithdrawDefaults, + earnSolverUrl, + earnMiden, + earnChainIds, + earnLenderFilter, + earnPoolsPerChain, + earnPoolsSortBy, + earnPoolsSortDir, + onIntentSent, + onIntentComplete, + onError, + onOpen, + onStart, + onSign, + onSuccess, + onStatus, + routingAndLiquidityOptions, + } = props; + + const sessionId = useSessionId(isOpen); + const { address, isConnected, connector } = useAccount(); + const chainId = useChainId(); + const { switchChain } = useSwitchChain(); + const { data: walletClient } = useWalletClient(); + + const effectiveAllowGasless = useMemo( + () => + allowGasless && + walletClient != null && + detectWalletAccountType(walletClient as never) === "local", + [allowGasless, walletClient], + ); + + const [tabOverride, setTabOverride] = useState<{ + forDefault: "deposit" | "withdraw"; + tab: "deposit" | "withdraw"; + } | null>(null); + const earnTab = + tabOverride?.forDefault === earnDefaultTab ? tabOverride.tab : earnDefaultTab; + const setEarnTab = useCallback( + (tab: "deposit" | "withdraw") => + setTabOverride({ forDefault: earnDefaultTab, tab }), + [earnDefaultTab], + ); + // The header toggle overrides the `network` prop, but only for the prop value + // it was set against: a new `network` from the integrator evicts the override + // instead of being silently ignored. Keyed this way, nothing has to reset it. + const [networkOverride, setNetworkOverride] = useState<{ + forNetwork: string; + isTestnet: boolean; + } | null>(null); + const isTestnet = + networkOverride?.forNetwork === networkProp + ? networkOverride.isTestnet + : networkProp === "testnet"; + + const [positionsLenderKey, setPositionsLenderKey] = useState(""); + + // Every network-scoped choice, stored as one value tagged with the network it + // was made on. A flip makes the whole set stale at once, so `selection` falls + // back to defaults and nothing has to remember to reset it. + const [storedSelection, setStoredSelection] = useState(() => + defaultSelection(networkProp === "testnet"), + ); + const selection = + storedSelection.forTestnet === isTestnet + ? storedSelection + : defaultSelection(isTestnet); + const patchSelection = useCallback( + (patch: Partial) => + setStoredSelection((prev) => ({ + ...(prev.forTestnet === isTestnet + ? prev + : defaultSelection(isTestnet)), + ...patch, + forTestnet: isTestnet, + })), + [isTestnet], + ); + + const { + market: earnSelectedMarket, + depositAmount: earnAmount, + position: selectedPosition, + withdrawAmount, + chainId: selectedChainId, + positionsChainId, + smartWithdraw, + smartDestChainId, + smartDestTokenAddress, + } = selection; + // Miden funding only exists on testnet, so it can't survive a flip to mainnet. + const fundingSource = isTestnet ? selection.fundingSource : "evm"; + + const setEarnSelectedMarket = useCallback( + (market: EpochEarnMarket | null) => patchSelection({ market }), + [patchSelection], + ); + const setEarnAmount = useCallback( + (depositAmount: string) => patchSelection({ depositAmount }), + [patchSelection], + ); + const setSelectedPosition = useCallback( + (position: EpochEarnPosition | null) => patchSelection({ position }), + [patchSelection], + ); + const setWithdrawAmount = useCallback( + (v: string) => patchSelection({ withdrawAmount: v }), + [patchSelection], + ); + const setSelectedChainId = useCallback( + (chainId: number | null) => patchSelection({ chainId }), + [patchSelection], + ); + const setPositionsChainId = useCallback( + (v: string) => patchSelection({ positionsChainId: v }), + [patchSelection], + ); + const setSmartWithdraw = useCallback( + (v: boolean) => patchSelection({ smartWithdraw: v }), + [patchSelection], + ); + const setSmartDestChainId = useCallback( + (v: number | null) => patchSelection({ smartDestChainId: v }), + [patchSelection], + ); + const setSmartDestTokenAddress = useCallback( + (v: string) => patchSelection({ smartDestTokenAddress: v }), + [patchSelection], + ); + const setFundingSource = useCallback( + (v: "evm" | "miden") => patchSelection({ fundingSource: v }), + [patchSelection], + ); + const [selectedMidenFaucetId, setSelectedMidenFaucetId] = useState( + DEFAULT_MIDEN_FAUCET.faucetId, + ); + const [view, setView] = useState("main"); + const [gasless, setGasless] = useState(gaslessProp); + const networkEnv: "mainnet" | "testnet" = isTestnet ? "testnet" : "mainnet"; + const midenEnabled = + networkEnv === "testnet" && + earnMiden != null && + earnMiden.enabled !== false; + const resolvedApi = useMemo( + () => resolveApiForNetwork(api, networkEnv), + [api, networkEnv], + ); + + // Source-funding chains. Testnet is narrowed to the chains dummy-lending can + // actually be funded from; mainnet offers the full Epoch chain list. + const availableChains = useMemo(() => { + const chains = getEpochChains(isTestnet); + if (!isTestnet) return chains; + return chains.filter((c) => EARN_TESTNET_SOURCE_EVM_CHAIN_IDS.has(c.id)); + }, [isTestnet]); + + const allTokens = useMemo(() => { + const allowed = new Set(DUMMY_LENDING_SUPPORTED_ADDRESSES); + const deprecated = DEPRECATED_DUMMY_LENDING_USDC_ADDRESS.toLowerCase(); + return availableChains.flatMap((chain) => + getEpochTokensByChainEnv(chain.id, isTestnet).flatMap((tok) => { + if (isTestnet) { + const addr = tok.address.toLowerCase(); + if (!allowed.has(addr) || addr === deprecated) return []; + } + return [{ ...tok, chain }]; + }), + ); + }, [availableChains, isTestnet]); + + const availableTokens = useMemo( + () => + selectedChainId + ? getEpochTokensByChainEnv(selectedChainId, isTestnet) + : [], + [selectedChainId, isTestnet], + ); + + const { + address: selectedTokenAddress, + token: selectedToken, + setPick: setTokenAddressPick, + } = useTokenPick(availableTokens); + + useOnOpen(isOpen, onOpen); + + // Default destination = the position's underlying chain + token. + const applySmartDestDefaults = useCallback( + (position: EpochEarnPosition | null) => { + if (!position) { + setSmartDestChainId(null); + setSmartDestTokenAddress(""); + return; + } + if (position.market.chainId != null) { + setSmartDestChainId(position.market.chainId); + } + setSmartDestTokenAddress(position.market.token.address); + }, + [setSmartDestChainId, setSmartDestTokenAddress], + ); + + // Every position change reseeds the destination in the same render, rather + // than letting an effect watch `selectedPosition` and set it a render later. + const selectPosition = useCallback( + (position: EpochEarnPosition | null) => { + setSelectedPosition(position); + applySmartDestDefaults(position); + }, + [applySmartDestDefaults, setSelectedPosition], + ); + + // Smart Withdraw OFF restores the position's own chain + underlying so + // nothing stale survives if the user toggles it back on. + const handleSmartWithdrawChange = useCallback( + (next: boolean) => { + setSmartWithdraw(next); + if (!next) applySmartDestDefaults(selectedPosition); + }, + [applySmartDestDefaults, selectedPosition, setSmartWithdraw], + ); + + // Switching network invalidates every network-scoped selection. Both entry + // points — the `network` prop and the header toggle — funnel through here so + // the resets land in the same render as the `isTestnet` flip, instead of + // cascading through an effect that watches `isTestnet` and repaints twice. + // Only flips the toggle. Everything network-scoped below is keyed on + // `isTestnet` and evicts itself, so there is nothing left to reset. + const applyNetwork = useCallback( + (nextIsTestnet: boolean) => + setNetworkOverride({ forNetwork: networkProp, isTestnet: nextIsTestnet }), + [networkProp], + ); + + const picker = useEarnMarketPicker({ + api: resolvedApi, + enabled: isOpen && view === "selectMarket", + configsEnabled: isOpen, + isTestnet, + networkEnv, + earnChainIds, + earnLenderFilter, + earnMarketsSource, + defaultSortBy: earnPoolsSortBy, + defaultSortDir: earnPoolsSortDir, + }); + + // No "reset on close" effect here by design. `EpochIntentWidget` — the only + // thing that renders this — returns null while closed, so the whole component + // unmounts and every value below reverts to its useState initializer on the + // next open. A reset effect would be dead code that silently rots as new + // state is added. + + // legacy: callers passing `earnMarkets` directly still see them — we render + // the configs picker but the deprecated prop is accepted for back-compat. + void earnMarketsProp; + void earnPoolsPerChain; + + const positionsState = useUserPositions({ + address, + network: networkEnv, + api: resolvedApi, + // Only fetch positions while the Withdraw tab is active AND the user is + // on the main list view — skips the request on deposit usage and + // prevents a refetch when the user enters the withdraw detail view. + enabled: isOpen && isConnected && earnTab === "withdraw" && view === "main", + // Scope to the earn chains directly — no longer derived from a full pool + // config set (the picker is now server-paginated). Empty user filter ⇒ + // fall back to the all-chains CSV. + chainsOverride: positionsChainId || picker.earnChainsCsv, + lendersOverride: positionsLenderKey, + }); + + // Auto-pick: when the user lands on the Withdraw tab and positions arrive, + // jump straight into the detail/amount view with positions[0] selected. The + // user opens the picker explicitly via the From card chevron — so we only + // do this once per (open × tab-entry) and never re-trigger it as long as a + // selection is preserved. + const didAutoPickRef = useRef(false); + useEffect(() => { + if (!isOpen) { + didAutoPickRef.current = false; + return; + } + if (earnTab !== "withdraw") { + didAutoPickRef.current = false; + return; + } + if (didAutoPickRef.current) return; + if (selectedPosition) return; + const first = positionsState.positions[0]; + if (!first) return; + didAutoPickRef.current = true; + selectPosition(first); + setView("withdrawDetail"); + }, [ + isOpen, + earnTab, + positionsState.positions, + selectedPosition, + selectPosition, + ]); + + const gaslessWallet = useGaslessWallet({ + allowGasless: + effectiveAllowGasless && + (earnTab === "withdraw" || fundingSource === "evm"), + apiBaseUrl: resolvedApi.baseUrl, + gasless, + setGasless, + walletClient, + address, + // A withdraw executes on the POSITION's chain, not the deposit funding + // chain — probing the wrong one would gate the toggle on 7702 support the + // withdraw never uses. + chainIdForCheck: + earnTab === "withdraw" + ? (selectedPosition?.market.chainId ?? null) + : fundingSource === "miden" + ? null + : (selectedChainId ?? + (isTestnet ? 84532 : (walletClient?.chain?.id ?? null))), + switchChain, + }); + + const selectedChain = availableChains.find((c) => c.id === selectedChainId); + const miden = useEarnMiden({ + earnMiden, + isTestnet, + midenEnabled, + fundingSource, + selectedMidenFaucetId, + earnTab, + smartWithdraw, + smartDestChainId, + smartDestTokenAddress, + }); + + const pillToken = + fundingSource === "miden" && miden.sourceToken + ? miden.sourceToken + : (selectedToken ?? allTokens[0] ?? null); + const pillChain = + fundingSource === "miden" + ? miden.chain + : (selectedChain ?? availableChains[0] ?? null); + + useEffect(() => { + if (!isOpen) return; + if (fundingSource === "miden") return; + if (selectedChainId !== null) return; + const first = allTokens[0]; + if (!first) return; + setSelectedChainId(first.chain.id); + setTokenAddressPick(first.address); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen, allTokens]); + + const { balance, isLoading: isBalanceLoading } = useTokenBalance( + selectedChainId, + selectedTokenAddress, + address, + api.rpcUrls, + ); + + const { + activeAmount, + activeMarket, + activeBuildOk, + depositBuildError, + withdrawBuildError, + effectiveSourceChainId, + effectiveSourceToken, + isSmartWithdrawDegenerate, + } = useEarnQuoteTarget({ + earnTab, + fundingSource, + earnSelectedMarket, + earnAmount, + selectedPosition, + withdrawAmount, + earnDepositDefaults, + earnWithdrawDefaults, + selectedChainId, + selectedToken, + midenSourceToken: miden.sourceToken, + smartWithdraw, + smartDestChainId, + smartDestTokenAddress, + onPinSourceChain: setSelectedChainId, + }); + + const earnFlow = useEarnIntentFlow({ + apiBaseUrl: resolvedApi.baseUrl, + earnSolverUrl, + walletClient, + address, + sessionId, + routingAndLiquidityOptions, + gasless: effectiveAllowGasless && gasless, + onIntentSent, + onIntentComplete, + onError, + onStart, + onSign, + onSuccess, + onRequestClose: onClose, + }); + + const onStatusRef = useLatestRef(onStatus); + useEffect(() => { + const callbackStatus = + earnFlow.status === "quoting" ? "idle" : earnFlow.status; + if (callbackStatus === "polling") return; + onStatusRef.current?.({ + sessionId, + status: callbackStatus, + progress: earnFlow.statusProgress, + activeStep: earnFlow.activeStep, + }); + }, [ + sessionId, + earnFlow.status, + earnFlow.statusProgress, + earnFlow.activeStep, + onStatusRef, + ]); + + // Single point of entry for kicking off a quote — used both by the auto-fire + // effect (debounced as inputs change) and by the manual "Retry quote" CTA + // shown when the previous attempt failed. + const triggerQuote = useCallback(() => { + if ( + !activeBuildOk || + effectiveSourceChainId == null || + !effectiveSourceToken || + !activeMarket || + !address + ) + return; + if ( + fundingSource === "miden" && + (!earnMiden?.connected || !miden.quoteSource) + ) + return; + // Destination not yet moved off the position's own chain/token → no route + // to quote. Skip until the user picks a real destination. + if (isSmartWithdrawDegenerate) return; + // Miden destination chosen but no Miden account connected → no recipient. + if (miden.smartDestNotReady) return; + earnFlow.fetchQuote({ + tab: earnTab, + amount: activeAmount, + market: activeMarket, + position: selectedPosition, + sourceChainId: effectiveSourceChainId, + sourceToken: effectiveSourceToken, + network: networkEnv, + midenSource: miden.quoteSource, + smartWithdraw: earnTab === "withdraw" ? smartWithdraw : undefined, + smartDestChainId: earnTab === "withdraw" ? smartDestChainId : undefined, + smartDestTokenAddress: + earnTab === "withdraw" ? smartDestTokenAddress : undefined, + midenDest: earnTab === "withdraw" ? miden.smartDest : undefined, + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + activeBuildOk, + activeAmount, + activeMarket, + effectiveSourceChainId, + effectiveSourceToken?.address, + address, + earnTab, + smartWithdraw, + smartDestChainId, + smartDestTokenAddress, + selectedPosition, + networkEnv, + fundingSource, + miden.quoteSource, + earnMiden?.connected, + isSmartWithdrawDegenerate, + miden.smartDest, + miden.smartDestNotReady, + ]); + + useEffect(() => { + const timer = window.setTimeout(triggerQuote, 250); + return () => window.clearTimeout(timer); + }, [triggerQuote]); + + const isWrongNetwork = + fundingSource === "evm" && + effectiveSourceChainId !== null && + chainId !== effectiveSourceChainId; + const insufficientBalance = + earnTab === "deposit" && + fundingSource === "evm" && + balance !== null && + balance === 0n; + const insufficientMidenBalance = + earnTab === "deposit" && + fundingSource === "miden" && + miden.balance !== null && + miden.balance === 0n; + const isBusy = earnFlow.isBusy; + + // Cross-chain = the market lives on a different chain than the source the + // user is funding from. SIO will insert a bridge/swap step before the + // 1delta deposit, so the CTA hints at that. + const isCrossChain = + !!activeMarket && + earnTab === "deposit" && + (fundingSource === "miden" || + (selectedChainId !== null && + activeMarket.chainId != null && + selectedChainId !== activeMarket.chainId)); + + const ctaState = resolveEarnCta({ + earnTab, + fundingSource, + flow: { + isQuoting: earnFlow.isQuoting, + status: earnFlow.status, + quoteError: earnFlow.quoteError, + }, + isConnected, + midenConnected: !!earnMiden?.connected, + hasSelectedMarket: !!earnSelectedMarket, + depositAmount: earnAmount, + hasSelectedPosition: !!selectedPosition, + withdrawAmount, + availableChains, + selectedChain, + effectiveSourceChainId, + effectiveSourceToken, + isWrongNetwork, + insufficientBalance, + selectedToken, + insufficientMidenBalance, + midenAssetSymbol: miden.selectedAsset?.symbol, + buildOk: activeBuildOk, + isSmartWithdrawDegenerate, + midenSmartDestNotReady: miden.smartDestNotReady, + isCrossChain, + submitButtonText, + }); + + const ctaEnabled = isEarnCtaEnabled(ctaState.action); + + const detailTokenSymbol = selectedPosition?.market.token.symbol; + const modalTitle = + view === "withdrawDetail" && detailTokenSymbol + ? `Withdraw ${detailTokenSymbol}` + : (title ?? (earnTab === "deposit" ? "Earn" : "Withdraw")); + + const handleConnectMiden = useCallback(() => { + void Promise.resolve(earnMiden?.connect?.()).catch(() => { + // useEarnMidenAdapter surfaces a toast; swallow to avoid unhandled rejection. + }); + }, [earnMiden]); + + const handleCtaClick = () => { + if (ctaState.action === "connectMiden") { + handleConnectMiden(); + return; + } + if (ctaState.action === "switch") { + const target = + earnTab === "withdraw" + ? (availableChains.find((c) => c.id === effectiveSourceChainId) ?? + selectedChain) + : selectedChain; + if (target) switchChain?.({ chainId: target.id }); + return; + } + if (ctaState.action === "retry") { + triggerQuote(); + return; + } + if (ctaState.action !== "submit") return; + if ( + effectiveSourceChainId == null || + !effectiveSourceToken || + !activeMarket + ) + return; + earnFlow.submit({ + tab: earnTab, + amount: activeAmount, + market: activeMarket, + position: selectedPosition, + sourceChainId: effectiveSourceChainId, + sourceToken: effectiveSourceToken, + network: networkEnv, + quote: earnFlow.quote, + midenSource: miden.quoteSource, + smartWithdraw: earnTab === "withdraw" ? smartWithdraw : undefined, + smartDestChainId: earnTab === "withdraw" ? smartDestChainId : undefined, + smartDestTokenAddress: + earnTab === "withdraw" ? smartDestTokenAddress : undefined, + midenDest: earnTab === "withdraw" ? miden.smartDest : undefined, + }); + }; + return { + address, + allTokens, + balance, + connector, + walletIcon: connector?.icon, + ctaEnabled, + ctaState, + depositBuildError, + earnAmount, + earnFlow, + earnSelectedMarket, + earnTab, + effectiveAllowGasless, + fundingSource, + gasless, + gaslessWallet, + handleConnectMiden, + handleCtaClick, + handleSmartWithdrawChange, + isBalanceLoading, + isBusy, + isConnected, + isTestnet, + miden, + midenEnabled, + modalTitle, + picker, + pillChain, + pillToken, + positionsChainId, + positionsLenderKey, + positionsState, + selectPosition, + selectedChainId, + selectedPosition, + selectedToken, + selectedTokenAddress, + setEarnAmount, + setEarnSelectedMarket, + setEarnTab, + setFundingSource, + setGasless, + setPositionsChainId, + setPositionsLenderKey, + setSelectedChainId, + setSelectedMidenFaucetId, + setSmartDestChainId, + setSmartDestTokenAddress, + setSmartWithdraw, + setTokenAddressPick, + setWithdrawAmount, + smartDestChainId, + smartDestTokenAddress, + smartWithdraw, + view, + setView, + withdrawAmount, + withdrawBuildError, + applyNetwork, + allowNetworkToggle, + cn, + theme, + renderInline, + isOpen, + onClose, + earnHideTabs, + earnMiden, + }; +} + +export type EarnEngine = ReturnType; diff --git a/src/earn/use-earn-intent-flow.ts b/src/earn/use-earn-intent-flow.ts index 2f7fd98..088f3fb 100644 --- a/src/earn/use-earn-intent-flow.ts +++ b/src/earn/use-earn-intent-flow.ts @@ -46,7 +46,7 @@ interface ExecutionTx { callData: string; } -interface EarnQuote { +export interface EarnQuote { tokenIn?: string; tokenOut?: string; asset?: string; @@ -55,7 +55,7 @@ interface EarnQuote { raw?: unknown; } -interface EarnQuoteInput { +export interface EarnQuoteInput { tab: 'deposit' | 'withdraw'; amount: string; market?: EpochEarnMarket | null; @@ -88,7 +88,7 @@ interface EarnQuoteInput { }; } -interface EarnSubmitInput extends EarnQuoteInput { +export interface EarnSubmitInput extends EarnQuoteInput { quote: EarnQuote | null; } From b451ed7da0e0de645a117c6eedf924cfb68fb393 Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Thu, 16 Jul 2026 17:50:31 +0530 Subject: [PATCH 04/13] refactor: split WithdrawDetailPanel, trim noisy comments Break WithdrawDetailPanel (452 -> 157) into withdraw/ subcomponents: FromCard, AmountCard, SmartWithdrawToggle, SmartWithdrawDestination. Drop a stale comment and pure-restate comments across the pay/earn files. --- src/components/WithdrawDetailPanel.tsx | 371 ++---------------- src/components/earn/EarnMainView.tsx | 1 - .../earn/EarnWithdrawDetailView.tsx | 1 - .../withdraw/SmartWithdrawDestination.tsx | 128 ++++++ .../withdraw/SmartWithdrawToggle.tsx | 50 +++ .../withdraw/WithdrawAmountCard.tsx | 129 ++++++ src/components/withdraw/WithdrawFromCard.tsx | 82 ++++ src/earn/use-earn-engine.ts | 19 +- src/pay/pay-swap-cta.ts | 1 - src/pay/pay-swap-variants.tsx | 1 - src/pay/resolve-default-source.ts | 1 - 11 files changed, 432 insertions(+), 352 deletions(-) create mode 100644 src/components/withdraw/SmartWithdrawDestination.tsx create mode 100644 src/components/withdraw/SmartWithdrawToggle.tsx create mode 100644 src/components/withdraw/WithdrawAmountCard.tsx create mode 100644 src/components/withdraw/WithdrawFromCard.tsx diff --git a/src/components/WithdrawDetailPanel.tsx b/src/components/WithdrawDetailPanel.tsx index 9c6b9de..4541678 100644 --- a/src/components/WithdrawDetailPanel.tsx +++ b/src/components/WithdrawDetailPanel.tsx @@ -1,67 +1,36 @@ -import { useMemo, type ReactNode } from 'react'; import { cn } from '../lib/cn'; -import { SECTION_LABEL } from '../lib/styles'; -import { formatAmount, formatBalancePortionForInput } from '../utils'; -import { getEpochChains, getEpochTokensByChainEnv } from '../epoch-config'; -import { MIDEN_VIRTUAL_CHAIN_ID } from '../earn/miden'; import type { EpochEarnPosition } from '../types'; -import { Avatar } from './Avatar'; -import { Dropdown, type DropdownOption } from './Dropdown'; -import { ChevronRightIcon, SparklesIcon, TrendingUpIcon } from './Icons'; -import { Pill } from './ui/Pill'; +import { SmartWithdrawDestination } from './withdraw/SmartWithdrawDestination'; +import { SmartWithdrawToggle } from './withdraw/SmartWithdrawToggle'; +import { WithdrawAmountCard } from './withdraw/WithdrawAmountCard'; +import { WithdrawFromCard } from './withdraw/WithdrawFromCard'; interface Props { position: EpochEarnPosition; amount: string; onAmountChange: (v: string) => void; - /** 20% / 50% / Max. `isMax=true` only when the user picked the full balance. */ - onPickFraction: (humanAmount: string, isMax: boolean) => void; - /** Smart Withdraw = route the withdrawn underlying through Epoch's intent - * network to a different chain or token. */ + onPickFraction: (humanAmount: string) => void; smartWithdraw: boolean; onSmartWithdrawChange: (next: boolean) => void; smartDestChainId: number | null; smartDestTokenAddress: string; onPickDestChain: (chainId: number) => void; onPickDestToken: (address: string) => void; - /** Testnet vs mainnet — selects the chain/token sets for the destination - * dropdowns. MUST match the active network or SIO rejects the destination - * with CHAIN_NOT_SUPPORTED (mainnet chains aren't in the testnet graph). */ + /** Must match the active network or SIO rejects the destination with + * CHAIN_NOT_SUPPORTED — mainnet chains aren't in the testnet graph. */ isTestnet: boolean; buildError: string | null; quoteError: string | null; isQuoting: boolean; approxUsd?: number | null; - /** Tap on the From card → return to the position list. */ onPickAnotherPosition?: () => void; - /** When true, "Miden" is offered as a Smart Withdraw destination (EVM→Miden - * delivery). Requires a connected Miden account (`midenRecipientAccount`). */ + /** Offer Miden as a Smart Withdraw destination (needs a connected account). */ midenDestEnabled?: boolean; - /** The connected Miden account funds are delivered to when the destination is - * Miden. Shown read-only so the user can confirm where proceeds land. */ midenRecipientAccount?: string | null; - /** Miden faucet assets surfaced as "Receive Token" options when the - * destination chain is Miden. `value` is the faucet id. */ + /** `value` is the faucet id. */ midenFaucets?: { faucetId: string; symbol: string; logoURI?: string }[]; } -const FRACTIONS: { label: string; num: number; den: number; isMax: boolean }[] = [ - { label: '20%', num: 20, den: 100, isMax: false }, - { label: '50%', num: 50, den: 100, isMax: false }, - { label: 'Max', num: 1, den: 1, isMax: true }, -]; - -const FRACTION_CHIP_CLASSES = - 'cursor-pointer rounded-full border border-line bg-surface-muted px-2.5 py-1 text-[11px] font-semibold text-fg-secondary transition-[background-color,border-color,color,transform] duration-100 hover:border-primary hover:text-primary active:scale-[0.96]'; - -function formatUsd(v: number | null | undefined): string { - if (v == null || !Number.isFinite(v)) return '—'; - if (v >= 1) return `$${v.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; - if (v >= 0.01) return `$${v.toFixed(4)}`; - if (v > 0) return '<$0.01'; - return '$0.00'; -} - export function WithdrawDetailPanel({ position, amount, @@ -84,7 +53,6 @@ export function WithdrawDetailPanel({ midenFaucets, }: Props) { const { market } = position; - const decimals = market.token.decimals; const balanceRaw = (() => { try { return BigInt(position.withdrawableRaw ?? position.underlyingBalanceRaw); @@ -92,60 +60,18 @@ export function WithdrawDetailPanel({ return 0n; } })(); - const balanceHuman = formatAmount(balanceRaw, decimals); - - const applyFraction = (num: number, den: number, isMax: boolean) => { - if (balanceRaw === 0n) return; - onPickFraction(formatBalancePortionForInput(balanceRaw, num, den, decimals), isMax); - }; - - const isMidenDest = smartDestChainId === MIDEN_VIRTUAL_CHAIN_ID; - const chainOptions: DropdownOption[] = useMemo(() => { - const opts = getEpochChains(isTestnet).map((c) => ({ - value: String(c.id), - label: c.name, - leading: , - })); - if (midenDestEnabled) { - opts.push({ - value: String(MIDEN_VIRTUAL_CHAIN_ID), - label: 'Miden', - leading: , - }); - } - return opts; - }, [isTestnet, midenDestEnabled]); - const tokenOptions: DropdownOption[] = useMemo(() => { - if (smartDestChainId == null) return []; - // Miden destination → the faucet assets (only USDC surfaced today). The - // dropdown `value` is the faucet id, which the flow reads back as - // `midenDest.faucetId`. - if (smartDestChainId === MIDEN_VIRTUAL_CHAIN_ID) { - return (midenFaucets ?? []).map((f) => ({ - value: f.faucetId, - label: f.symbol, - sublabel: 'Miden', - leading: , - })); - } - return getEpochTokensByChainEnv(smartDestChainId, isTestnet).map((tok) => ({ - value: tok.address, - label: tok.symbol, - sublabel: tok.name, - leading: , - })); - }, [smartDestChainId, isTestnet, midenFaucets]); const inlineError = buildError ?? quoteError; - const aprPct = Number.isFinite(market.aprDecimal) ? market.aprDecimal * 100 : null; + const aprPct = Number.isFinite(market.aprDecimal) + ? market.aprDecimal * 100 + : null; const sublabel = [market.lenderName ?? market.lenderKey, market.chainLabel] .filter(Boolean) .join(' · '); return (
- {/* ---- From card --------------------------------------------------- */} - - {/* ---- Amount card ------------------------------------------------- */} -
-
- Amount - -
- - onAmountChange(e.target.value)} - aria-label={`Withdraw amount in ${market.token.symbol}`} - className="block w-full border-0 bg-transparent p-0 text-[34px] font-bold leading-none -tracking-[0.03em] tabular-nums text-fg outline-none placeholder:text-fg-muted" - /> - -
-
- ≈ {formatUsd(approxUsd)} - Balance: {balanceHuman} -
-
- {FRACTIONS.map((f) => ( - - ))} -
-
-
- Balance: {balanceHuman} -
-
+ - {/* ---- Smart Withdraw --------------------------------------------- */} - + {smartWithdraw && ( -
- - onPickDestChain(Number(v))} - ariaLabel="Destination chain" - placeholder="Select chain" - searchable={chainOptions.length > 6} - /> - - - 6} - /> - -
- )} - - {smartWithdraw && isMidenDest && ( -
- Recipient (Miden) - {midenRecipientAccount ? ( -
- {midenRecipientAccount} -
- ) : ( -
- Connect a Miden account to deliver the withdrawal here. -
- )} -
+ )} {isQuoting && ( @@ -263,164 +126,6 @@ export function WithdrawDetailPanel({ ); } -function FromCard({ - tokenSymbol, - tokenLogoURI, - sublabel, - aprPct, - onClick, -}: { - tokenSymbol: string; - tokenLogoURI?: string; - sublabel: string; - aprPct: number | null; - onClick?: () => void; -}) { - const isInteractive = !!onClick; - const inner = ( - <> -
- From -
-
- -
-
- {tokenSymbol} position -
- {sublabel && ( -
{sublabel}
- )} -
-
- {aprPct != null && ( - } - > - {aprPct.toFixed(aprPct >= 10 ? 1 : 2)}% APR - - )} - {isInteractive && ( - - - - )} -
-
- - ); - - const baseClass = - 'group block w-full rounded-md border border-line bg-surface px-4 py-3 text-left transition-[border-color,box-shadow] duration-150'; - - if (isInteractive) { - return ( - - ); - } - return
{inner}
; -} - -function TokenChainBadge({ - tokenSymbol, - tokenLogoURI, - chainName, -}: { - tokenSymbol: string; - tokenLogoURI?: string; - chainName: string; -}) { - return ( -
- - - {tokenSymbol} - - {chainName} - - -
- ); -} - -function SmartWithdrawToggle({ - enabled, - onChange, -}: { - enabled: boolean; - onChange: (next: boolean) => void; -}) { - return ( - - ); -} - -function LabeledPicker({ label, children }: { label: string; children: ReactNode }) { - return ( -
- {label} - {children} -
- ); -} - -/** - * Solid primary CTA — matches the deposit flow's submit button so the app's - * color identity stays consistent across modes. - */ export function WithdrawFundsButton({ disabled, isBusy, diff --git a/src/components/earn/EarnMainView.tsx b/src/components/earn/EarnMainView.tsx index 57c6639..8c47931 100644 --- a/src/components/earn/EarnMainView.tsx +++ b/src/components/earn/EarnMainView.tsx @@ -16,7 +16,6 @@ interface EarnMainViewProps { onOpenPosition: () => void; } -/** Deposit / Withdraw tabs — the widget's landing view. */ export function EarnMainView({ engine, onPickToken, diff --git a/src/components/earn/EarnWithdrawDetailView.tsx b/src/components/earn/EarnWithdrawDetailView.tsx index da839fc..27d88e7 100644 --- a/src/components/earn/EarnWithdrawDetailView.tsx +++ b/src/components/earn/EarnWithdrawDetailView.tsx @@ -14,7 +14,6 @@ interface EarnWithdrawDetailViewProps { onPickAnotherPosition: () => void; } -/** Amount + Smart Withdraw destination for one position. */ export function EarnWithdrawDetailView({ engine, position, diff --git a/src/components/withdraw/SmartWithdrawDestination.tsx b/src/components/withdraw/SmartWithdrawDestination.tsx new file mode 100644 index 0000000..7f2763a --- /dev/null +++ b/src/components/withdraw/SmartWithdrawDestination.tsx @@ -0,0 +1,128 @@ +import { useMemo, type ReactNode } from 'react'; +import { SECTION_LABEL } from '../../lib/styles'; +import { getEpochChains, getEpochTokensByChainEnv } from '../../epoch-config'; +import { MIDEN_VIRTUAL_CHAIN_ID } from '../../earn/miden'; +import { Avatar } from '../Avatar'; +import { Dropdown, type DropdownOption } from '../Dropdown'; + +interface SmartWithdrawDestinationProps { + smartDestChainId: number | null; + smartDestTokenAddress: string; + onPickDestChain: (chainId: number) => void; + onPickDestToken: (address: string) => void; + isTestnet: boolean; + midenDestEnabled: boolean; + midenRecipientAccount?: string | null; + midenFaucets?: { faucetId: string; symbol: string; logoURI?: string }[]; +} + +function LabeledPicker({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+ {label} + {children} +
+ ); +} + +export function SmartWithdrawDestination({ + smartDestChainId, + smartDestTokenAddress, + onPickDestChain, + onPickDestToken, + isTestnet, + midenDestEnabled, + midenRecipientAccount, + midenFaucets, +}: SmartWithdrawDestinationProps) { + const isMidenDest = smartDestChainId === MIDEN_VIRTUAL_CHAIN_ID; + + const chainOptions: DropdownOption[] = useMemo(() => { + const opts = getEpochChains(isTestnet).map((c) => ({ + value: String(c.id), + label: c.name, + leading: , + })); + if (midenDestEnabled) { + opts.push({ + value: String(MIDEN_VIRTUAL_CHAIN_ID), + label: 'Miden', + leading: , + }); + } + return opts; + }, [isTestnet, midenDestEnabled]); + + const tokenOptions: DropdownOption[] = useMemo(() => { + if (smartDestChainId == null) return []; + // Miden faucet ids are the dropdown value; the flow reads them back as + // `midenDest.faucetId`. + if (smartDestChainId === MIDEN_VIRTUAL_CHAIN_ID) { + return (midenFaucets ?? []).map((f) => ({ + value: f.faucetId, + label: f.symbol, + sublabel: 'Miden', + leading: , + })); + } + return getEpochTokensByChainEnv(smartDestChainId, isTestnet).map((tok) => ({ + value: tok.address, + label: tok.symbol, + sublabel: tok.name, + leading: , + })); + }, [smartDestChainId, isTestnet, midenFaucets]); + + return ( + <> +
+ + onPickDestChain(Number(v))} + ariaLabel="Destination chain" + placeholder="Select chain" + searchable={chainOptions.length > 6} + /> + + + 6} + /> + +
+ + {isMidenDest && ( +
+ Recipient (Miden) + {midenRecipientAccount ? ( +
+ {midenRecipientAccount} +
+ ) : ( +
+ Connect a Miden account to deliver the withdrawal here. +
+ )} +
+ )} + + ); +} diff --git a/src/components/withdraw/SmartWithdrawToggle.tsx b/src/components/withdraw/SmartWithdrawToggle.tsx new file mode 100644 index 0000000..e486af9 --- /dev/null +++ b/src/components/withdraw/SmartWithdrawToggle.tsx @@ -0,0 +1,50 @@ +import { cn } from '../../lib/cn'; +import { SparklesIcon } from '../Icons'; + +interface SmartWithdrawToggleProps { + enabled: boolean; + onChange: (next: boolean) => void; +} + +export function SmartWithdrawToggle({ + enabled, + onChange, +}: SmartWithdrawToggleProps) { + return ( + + ); +} diff --git a/src/components/withdraw/WithdrawAmountCard.tsx b/src/components/withdraw/WithdrawAmountCard.tsx new file mode 100644 index 0000000..81f5149 --- /dev/null +++ b/src/components/withdraw/WithdrawAmountCard.tsx @@ -0,0 +1,129 @@ +import { cn } from '../../lib/cn'; +import { SECTION_LABEL } from '../../lib/styles'; +import { formatAmount, formatBalancePortionForInput } from '../../utils'; +import { Avatar } from '../Avatar'; + +const FRACTIONS: { label: string; num: number; den: number }[] = [ + { label: '20%', num: 20, den: 100 }, + { label: '50%', num: 50, den: 100 }, + { label: 'Max', num: 1, den: 1 }, +]; + +const FRACTION_CHIP = + 'cursor-pointer rounded-full border border-line bg-surface-muted px-2.5 py-1 text-[11px] font-semibold text-fg-secondary transition-[background-color,border-color,color,transform] duration-100 hover:border-primary hover:text-primary active:scale-[0.96]'; + +function formatUsd(v: number | null | undefined): string { + if (v == null || !Number.isFinite(v)) return '—'; + if (v >= 1) + return `$${v.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + if (v >= 0.01) return `$${v.toFixed(4)}`; + if (v > 0) return '<$0.01'; + return '$0.00'; +} + +function TokenChainBadge({ + tokenSymbol, + tokenLogoURI, + chainName, +}: { + tokenSymbol: string; + tokenLogoURI?: string; + chainName: string; +}) { + return ( +
+ + + {tokenSymbol} + + {chainName} + + +
+ ); +} + +interface WithdrawAmountCardProps { + tokenSymbol: string; + tokenLogoURI?: string; + chainName: string; + decimals: number; + amount: string; + onAmountChange: (v: string) => void; + balanceRaw: bigint; + onPickFraction: (humanAmount: string) => void; + approxUsd?: number | null; +} + +export function WithdrawAmountCard({ + tokenSymbol, + tokenLogoURI, + chainName, + decimals, + amount, + onAmountChange, + balanceRaw, + onPickFraction, + approxUsd, +}: WithdrawAmountCardProps) { + const balanceHuman = formatAmount(balanceRaw, decimals); + + const applyFraction = (num: number, den: number) => { + if (balanceRaw === 0n) return; + onPickFraction(formatBalancePortionForInput(balanceRaw, num, den, decimals)); + }; + + return ( +
+
+ Amount + +
+ + onAmountChange(e.target.value)} + aria-label={`Withdraw amount in ${tokenSymbol}`} + className="block w-full border-0 bg-transparent p-0 text-[34px] font-bold leading-none -tracking-[0.03em] tabular-nums text-fg outline-none placeholder:text-fg-muted" + /> + +
+
+ ≈ {formatUsd(approxUsd)} + Balance: {balanceHuman} +
+
+ {FRACTIONS.map((f) => ( + + ))} +
+
+
+ Balance: {balanceHuman} +
+
+ ); +} diff --git a/src/components/withdraw/WithdrawFromCard.tsx b/src/components/withdraw/WithdrawFromCard.tsx new file mode 100644 index 0000000..c74413b --- /dev/null +++ b/src/components/withdraw/WithdrawFromCard.tsx @@ -0,0 +1,82 @@ +import { cn } from '../../lib/cn'; +import { SECTION_LABEL } from '../../lib/styles'; +import { Avatar } from '../Avatar'; +import { ChevronRightIcon, TrendingUpIcon } from '../Icons'; +import { Pill } from '../ui/Pill'; + +interface WithdrawFromCardProps { + tokenSymbol: string; + tokenLogoURI?: string; + sublabel: string; + aprPct: number | null; + /** When set, the card becomes a button that returns to the position list. */ + onClick?: () => void; +} + +export function WithdrawFromCard({ + tokenSymbol, + tokenLogoURI, + sublabel, + aprPct, + onClick, +}: WithdrawFromCardProps) { + const inner = ( + <> +
+ From +
+
+ +
+
+ {tokenSymbol} position +
+ {sublabel && ( +
+ {sublabel} +
+ )} +
+
+ {aprPct != null && ( + } + > + {aprPct.toFixed(aprPct >= 10 ? 1 : 2)}% APR + + )} + {onClick && ( + + + + )} +
+
+ + ); + + const baseClass = + 'group block w-full rounded-md border border-line bg-surface px-4 py-3 text-left transition-[border-color,box-shadow] duration-150'; + + if (onClick) { + return ( + + ); + } + return
{inner}
; +} diff --git a/src/earn/use-earn-engine.ts b/src/earn/use-earn-engine.ts index 1b87ff7..2c34b40 100644 --- a/src/earn/use-earn-engine.ts +++ b/src/earn/use-earn-engine.ts @@ -276,7 +276,6 @@ export function useEarnEngine(props: EarnIntentWidgetProps) { useOnOpen(isOpen, onOpen); - // Default destination = the position's underlying chain + token. const applySmartDestDefaults = useCallback( (position: EpochEarnPosition | null) => { if (!position) { @@ -312,12 +311,8 @@ export function useEarnEngine(props: EarnIntentWidgetProps) { [applySmartDestDefaults, selectedPosition, setSmartWithdraw], ); - // Switching network invalidates every network-scoped selection. Both entry - // points — the `network` prop and the header toggle — funnel through here so - // the resets land in the same render as the `isTestnet` flip, instead of - // cascading through an effect that watches `isTestnet` and repaints twice. - // Only flips the toggle. Everything network-scoped below is keyed on - // `isTestnet` and evicts itself, so there is nothing left to reset. + // Only flips the toggle — everything network-scoped is keyed on `isTestnet` + // and evicts itself, so there is nothing to reset. const applyNetwork = useCallback( (nextIsTestnet: boolean) => setNetworkOverride({ forNetwork: networkProp, isTestnet: nextIsTestnet }), @@ -337,14 +332,10 @@ export function useEarnEngine(props: EarnIntentWidgetProps) { defaultSortDir: earnPoolsSortDir, }); - // No "reset on close" effect here by design. `EpochIntentWidget` — the only - // thing that renders this — returns null while closed, so the whole component - // unmounts and every value below reverts to its useState initializer on the - // next open. A reset effect would be dead code that silently rots as new - // state is added. + // No reset-on-close effect: `EpochIntentWidget` unmounts this while closed, + // so state reverts to its initializers on the next open. - // legacy: callers passing `earnMarkets` directly still see them — we render - // the configs picker but the deprecated prop is accepted for back-compat. + // Deprecated `earnMarkets` prop, still accepted for back-compat. void earnMarketsProp; void earnPoolsPerChain; diff --git a/src/pay/pay-swap-cta.ts b/src/pay/pay-swap-cta.ts index 7822f47..2991013 100644 --- a/src/pay/pay-swap-cta.ts +++ b/src/pay/pay-swap-cta.ts @@ -134,7 +134,6 @@ export function resolvePaySwapCta({ return { action: 'submit', label: labels.submit }; } -/** Actions the user can actually click. */ export function isPaySwapCtaEnabled(action: PaySwapCtaAction): boolean { return action === 'submit' || action === 'switch'; } diff --git a/src/pay/pay-swap-variants.tsx b/src/pay/pay-swap-variants.tsx index 763e90c..8bdc3f9 100644 --- a/src/pay/pay-swap-variants.tsx +++ b/src/pay/pay-swap-variants.tsx @@ -12,7 +12,6 @@ export interface SummaryContext { paySymbol: string; /** Source token/chain pill — doubles as the "change source" trigger. */ payTokenPill?: ReactNode; - /** What the user ends up with. */ receiveAmount: string; receiveSymbol: string; receiveTokenPill?: ReactNode; diff --git a/src/pay/resolve-default-source.ts b/src/pay/resolve-default-source.ts index c124d99..09c6b27 100644 --- a/src/pay/resolve-default-source.ts +++ b/src/pay/resolve-default-source.ts @@ -1,6 +1,5 @@ import type { EpochChain, EpochToken } from '../types'; -/** A token paired with the chain it lives on. */ interface TokenOnChain extends EpochToken { chain: EpochChain; } From 7974d8442ed8a0a32a1a6f10e0a4d7cbc4dce5ab Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Thu, 16 Jul 2026 19:30:01 +0530 Subject: [PATCH 05/13] fix(earn): source deposit/withdraw typestrings from SDK constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adopt DEPOSIT_EXTRADATA_TYPESTRING / WITHDRAW_EXTRADATA_TYPESTRING (added on smallocator dev) instead of hand-building the fields. Adds the canonical `isAll` and drops the stale `simulate` — no solver reads it. --- src/earn/use-earn-intent-flow.ts | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/earn/use-earn-intent-flow.ts b/src/earn/use-earn-intent-flow.ts index 088f3fb..8382339 100644 --- a/src/earn/use-earn-intent-flow.ts +++ b/src/earn/use-earn-intent-flow.ts @@ -6,8 +6,10 @@ import { TaskType } from '@epoch-protocol/epoch-commons-sdk'; import { ActionType, CollateralType, + DEPOSIT_EXTRADATA_TYPESTRING, EpochIntentSDK, EVM_TO_MIDEN_EXTRA_TYPESTRING, + WITHDRAW_EXTRADATA_TYPESTRING, ZERO_BYTES32, isUserWalletRejection, } from '@epoch-protocol/epoch-intents-sdk'; @@ -399,23 +401,25 @@ export function useEarnIntentFlow({ : input.sourceChainId === Number(destinationChainId); const payAsset = underlyingAddress; - const extraDataFields = ['string marketUid', 'string action', 'string payAsset']; - if (isWithdraw) extraDataFields.push('bool isAll', 'bool simulate'); - if (isMidenDeposit) extraDataFields.push(...EARN_MIDEN_EXTRA_FIELDS); - const extraDataTypestring = extraDataFields.join(','); + // Canonical earn extradata typestrings, single-sourced from the SDK so the + // fields stay in lockstep with the solver. Miden deposits append the + // Miden→EVM witness suffix. + const baseTypestring = isWithdraw + ? WITHDRAW_EXTRADATA_TYPESTRING + : DEPOSIT_EXTRADATA_TYPESTRING; + const extraDataTypestring = isMidenDeposit + ? `${baseTypestring},${EARN_MIDEN_EXTRA_FIELDS.join(',')}` + : baseTypestring; const extraData: Record = { 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, }; - if (isWithdraw) { - // Pinned false to match the SDK's withdraw action: a full-exit withdraw - // resolves its size at execution time, which a swap leg can't be quoted - // against. The field stays in the typestring — the solver decodes it. - extraData.isAll = false; - extraData.simulate = true; - } if (isMidenDeposit && input.midenSource) { extraData.midenSourceAccount = normalizeMidenId(input.midenSource.accountId); extraData.midenFaucetId = normalizeMidenId(input.midenSource.faucetId); From 554ad58b6a7af82e87e8f617b4f575feecff93a2 Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Thu, 16 Jul 2026 19:40:06 +0530 Subject: [PATCH 06/13] refactor: dedupe engines, consolidate USD formatting, drop dead code Extract usePropOverride (network/tab self-eviction) and useEffectiveGasless, shared by both engines. Consolidate the duplicated formatUsd into lib/format-usd. Remove unused SourcePicker. --- src/components/PaySwapIntentWidget.tsx | 2 +- src/components/PositionRow.tsx | 11 +- src/components/SourcePicker.tsx | 103 ------------------ .../withdraw/WithdrawAmountCard.tsx | 12 +- src/earn/use-earn-engine.ts | 47 ++------ src/hooks/use-effective-gasless.ts | 19 ++++ src/hooks/use-prop-override.ts | 25 +++++ src/lib/format-usd.ts | 35 ++++++ src/pay/format-usd.ts | 25 ----- src/pay/use-pay-swap-engine.ts | 31 ++---- 10 files changed, 101 insertions(+), 209 deletions(-) delete mode 100644 src/components/SourcePicker.tsx create mode 100644 src/hooks/use-effective-gasless.ts create mode 100644 src/hooks/use-prop-override.ts create mode 100644 src/lib/format-usd.ts delete mode 100644 src/pay/format-usd.ts diff --git a/src/components/PaySwapIntentWidget.tsx b/src/components/PaySwapIntentWidget.tsx index da712d2..ccc3348 100644 --- a/src/components/PaySwapIntentWidget.tsx +++ b/src/components/PaySwapIntentWidget.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTokenUsdPrice } from "../hooks/use-token-usd-price"; import { formatAmount } from "../utils"; -import { formatUsdEquivalent } from "../pay/format-usd"; +import { formatUsdEquivalent } from "../lib/format-usd"; import { isPaySwapCtaEnabled, resolvePaySwapCta, diff --git a/src/components/PositionRow.tsx b/src/components/PositionRow.tsx index c0e81da..6554d67 100644 --- a/src/components/PositionRow.tsx +++ b/src/components/PositionRow.tsx @@ -1,4 +1,5 @@ import { cn } from '../lib/cn'; +import { formatUsdPrice } from '../lib/format-usd'; import { formatAmount } from '../utils'; import type { EpochEarnPosition } from '../types'; @@ -10,14 +11,6 @@ interface Props { entryDelayMs?: number; } -function formatUsd(v: number | undefined): string | null { - if (v === undefined || !Number.isFinite(v)) return null; - if (v >= 1) return `$${v.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; - if (v >= 0.01) return `$${v.toFixed(4)}`; - if (v > 0) return `<$0.01`; - return `$0.00`; -} - const LOGO_CLASSES = 'h-9 w-9 shrink-0 rounded-full bg-surface object-cover'; const ROW_CLASSES = @@ -35,7 +28,7 @@ export function PositionRow({ position, expanded, onWithdrawClick, entryDelayMs } catch { /* keep dash */ } - const usd = formatUsd(position.underlyingUsdValue); + const usd = formatUsdPrice(position.underlyingUsdValue); const lender = market.lenderName ?? market.lenderKey ?? 'Lender'; return ( diff --git a/src/components/SourcePicker.tsx b/src/components/SourcePicker.tsx deleted file mode 100644 index dbe8f4f..0000000 --- a/src/components/SourcePicker.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { cn } from '../lib/cn'; -import type { EpochChain, EpochToken, EpochClassNames } from '../types'; -import { Dropdown, type DropdownOption } from './Dropdown'; -import { Avatar } from './Avatar'; - -interface SourcePickerProps { - chains: EpochChain[]; - tokens: EpochToken[]; - selectedChainId: number | null; - selectedTokenAddress: string; - onChainChange: (chainId: number | null) => void; - onTokenChange: (address: string) => void; - balance: bigint | null; - isBalanceLoading: boolean; - insufficientBalance: boolean; - formatAmount: (amount: bigint, decimals: number) => string; - classNames?: EpochClassNames; -} - -/** - * Chain + token picker with live balance display. - * Renders inside the "Pay with" card area. - */ -export function SourcePicker({ - chains, - tokens, - selectedChainId, - selectedTokenAddress, - onChainChange, - onTokenChange, - balance, - isBalanceLoading, - insufficientBalance, - formatAmount, -}: SourcePickerProps) { - const selectedToken = tokens.find((tok) => tok.address === selectedTokenAddress); - - const chainOptions: DropdownOption[] = chains.map((chain) => ({ - value: String(chain.id), - label: chain.name, - sublabel: `Chain ID ${chain.id}`, - leading: , - })); - - const tokenOptions: DropdownOption[] = tokens.map((tok) => ({ - value: tok.address, - label: tok.symbol, - sublabel: tok.name, - leading: , - })); - - return ( -
-
- - From - - {selectedChainId !== null && selectedTokenAddress && selectedToken && ( - - {isBalanceLoading - ? 'Loading…' - : balance !== null - ? `Balance: ${formatAmount(balance, selectedToken.decimals)} ${selectedToken.symbol}` - : 'Balance unavailable'} - - )} -
- -
- onChainChange(v ? Number(v) : null)} - placeholder="Select chain" - ariaLabel="Source chain" - searchable={chainOptions.length > 6} - emptyLabel="No chains available" - /> - - 6} - emptyLabel="No tokens on this chain" - /> -
-
- ); -} diff --git a/src/components/withdraw/WithdrawAmountCard.tsx b/src/components/withdraw/WithdrawAmountCard.tsx index 81f5149..46a2d00 100644 --- a/src/components/withdraw/WithdrawAmountCard.tsx +++ b/src/components/withdraw/WithdrawAmountCard.tsx @@ -1,6 +1,7 @@ import { cn } from '../../lib/cn'; import { SECTION_LABEL } from '../../lib/styles'; import { formatAmount, formatBalancePortionForInput } from '../../utils'; +import { formatUsdPrice } from '../../lib/format-usd'; import { Avatar } from '../Avatar'; const FRACTIONS: { label: string; num: number; den: number }[] = [ @@ -12,15 +13,6 @@ const FRACTIONS: { label: string; num: number; den: number }[] = [ const FRACTION_CHIP = 'cursor-pointer rounded-full border border-line bg-surface-muted px-2.5 py-1 text-[11px] font-semibold text-fg-secondary transition-[background-color,border-color,color,transform] duration-100 hover:border-primary hover:text-primary active:scale-[0.96]'; -function formatUsd(v: number | null | undefined): string { - if (v == null || !Number.isFinite(v)) return '—'; - if (v >= 1) - return `$${v.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; - if (v >= 0.01) return `$${v.toFixed(4)}`; - if (v > 0) return '<$0.01'; - return '$0.00'; -} - function TokenChainBadge({ tokenSymbol, tokenLogoURI, @@ -105,7 +97,7 @@ export function WithdrawAmountCard({
- ≈ {formatUsd(approxUsd)} + ≈ {formatUsdPrice(approxUsd) ?? '—'} Balance: {balanceHuman}
diff --git a/src/earn/use-earn-engine.ts b/src/earn/use-earn-engine.ts index 2c34b40..da562aa 100644 --- a/src/earn/use-earn-engine.ts +++ b/src/earn/use-earn-engine.ts @@ -1,6 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAccount, useChainId, useSwitchChain, useWalletClient } from "wagmi"; -import { detectWalletAccountType } from "@epoch-protocol/epoch-intents-sdk"; import { getEpochChains, getEpochTokensByChainEnv } from "../epoch-config"; import { useTokenBalance } from "../use-token-balance"; import { useSessionId } from "../session"; @@ -18,7 +17,9 @@ import { } from "./dummy-lending-markets"; import { resolveApiForNetwork } from "../resolve-api-config"; import { useEarnIntentFlow } from "./use-earn-intent-flow"; +import { useEffectiveGasless } from "../hooks/use-effective-gasless"; import { useGaslessWallet } from "../hooks/use-gasless-wallet-check"; +import { usePropOverride } from "../hooks/use-prop-override"; import { useLatestRef } from "../hooks/use-latest-ref"; import { useOnOpen } from "../hooks/use-on-open"; import { useTokenPick } from "../hooks/use-token-pick"; @@ -114,36 +115,16 @@ export function useEarnEngine(props: EarnIntentWidgetProps) { const { switchChain } = useSwitchChain(); const { data: walletClient } = useWalletClient(); - const effectiveAllowGasless = useMemo( - () => - allowGasless && - walletClient != null && - detectWalletAccountType(walletClient as never) === "local", - [allowGasless, walletClient], - ); + const effectiveAllowGasless = useEffectiveGasless(allowGasless, walletClient); - const [tabOverride, setTabOverride] = useState<{ - forDefault: "deposit" | "withdraw"; - tab: "deposit" | "withdraw"; - } | null>(null); - const earnTab = - tabOverride?.forDefault === earnDefaultTab ? tabOverride.tab : earnDefaultTab; - const setEarnTab = useCallback( - (tab: "deposit" | "withdraw") => - setTabOverride({ forDefault: earnDefaultTab, tab }), - [earnDefaultTab], + const [earnTab, setEarnTab] = usePropOverride( + earnDefaultTab, + (t) => t, + ); + const [isTestnet, applyNetwork] = usePropOverride( + networkProp, + (n) => n === "testnet", ); - // The header toggle overrides the `network` prop, but only for the prop value - // it was set against: a new `network` from the integrator evicts the override - // instead of being silently ignored. Keyed this way, nothing has to reset it. - const [networkOverride, setNetworkOverride] = useState<{ - forNetwork: string; - isTestnet: boolean; - } | null>(null); - const isTestnet = - networkOverride?.forNetwork === networkProp - ? networkOverride.isTestnet - : networkProp === "testnet"; const [positionsLenderKey, setPositionsLenderKey] = useState(""); @@ -311,14 +292,6 @@ export function useEarnEngine(props: EarnIntentWidgetProps) { [applySmartDestDefaults, selectedPosition, setSmartWithdraw], ); - // Only flips the toggle — everything network-scoped is keyed on `isTestnet` - // and evicts itself, so there is nothing to reset. - const applyNetwork = useCallback( - (nextIsTestnet: boolean) => - setNetworkOverride({ forNetwork: networkProp, isTestnet: nextIsTestnet }), - [networkProp], - ); - const picker = useEarnMarketPicker({ api: resolvedApi, enabled: isOpen && view === "selectMarket", diff --git a/src/hooks/use-effective-gasless.ts b/src/hooks/use-effective-gasless.ts new file mode 100644 index 0000000..6ea46af --- /dev/null +++ b/src/hooks/use-effective-gasless.ts @@ -0,0 +1,19 @@ +import { useMemo } from 'react'; +import { detectWalletAccountType } from '@epoch-protocol/epoch-intents-sdk'; + +/** + * Gasless is only offered for local (EOA) signers the SDK can 7702-relay for; + * smart-account / injected wallets fall back to user-paid. + */ +export function useEffectiveGasless( + allowGasless: boolean, + walletClient: unknown, +): boolean { + return useMemo( + () => + allowGasless && + walletClient != null && + detectWalletAccountType(walletClient as never) === 'local', + [allowGasless, walletClient], + ); +} diff --git a/src/hooks/use-prop-override.ts b/src/hooks/use-prop-override.ts new file mode 100644 index 0000000..6464010 --- /dev/null +++ b/src/hooks/use-prop-override.ts @@ -0,0 +1,25 @@ +import { useCallback, useState } from 'react'; + +/** + * A value that follows `derive(prop)` until the user overrides it, then + * re-follows the prop the moment `prop` changes. + * + * The override is tagged with the prop value it was set against, so a new prop + * evicts it automatically — no reset effect, and the user's choice never + * silently ignores a fresh prop from the integrator. + */ +export function usePropOverride( + prop: P, + derive: (prop: P) => V, +): [V, (value: V) => void] { + const [override, setOverride] = useState<{ forProp: P; value: V } | null>( + null, + ); + const value = + override && override.forProp === prop ? override.value : derive(prop); + const set = useCallback( + (next: V) => setOverride({ forProp: prop, value: next }), + [prop], + ); + return [value, set]; +} diff --git a/src/lib/format-usd.ts b/src/lib/format-usd.ts new file mode 100644 index 0000000..6262fdb --- /dev/null +++ b/src/lib/format-usd.ts @@ -0,0 +1,35 @@ +/** + * Format a USD amount for display, precision scaling down as the value grows. + * Returns null for missing/invalid input so callers can render their own dash. + */ +export function formatUsdPrice(v: number | null | undefined): string | null { + if (v == null || !Number.isFinite(v)) return null; + if (v >= 1) + return `$${v.toLocaleString(undefined, { maximumFractionDigits: 2 })}`; + if (v >= 0.01) return `$${v.toFixed(4)}`; + if (v > 0) return '<$0.01'; + return '$0.00'; +} + +/** + * Render a token amount as its USD equivalent (`≈ $x`), or null when there's + * nothing meaningful to show. + */ +export function formatUsdEquivalent( + amount: string, + priceUsd: number | null | undefined, +): string | null { + if (priceUsd == null) return null; + if (!amount || amount === '—') return null; + const n = Number(amount.replace(/,/g, '')); + if (!Number.isFinite(n) || n <= 0) return null; + + const usd = n * priceUsd; + const formatted = + usd >= 1000 + ? usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) + : usd >= 1 + ? usd.toFixed(2) + : usd.toFixed(4); + return `≈ $${formatted}`; +} diff --git a/src/pay/format-usd.ts b/src/pay/format-usd.ts deleted file mode 100644 index 0a165af..0000000 --- a/src/pay/format-usd.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Render a token amount as its USD equivalent, or null when there's nothing - * meaningful to show. - * - * Precision scales down as the number grows: cents matter on $1.23, not on - * $4,182. - */ -export function formatUsdEquivalent( - amount: string, - priceUsd: number | null | undefined, -): string | null { - if (priceUsd == null) return null; - if (!amount || amount === '—') return null; - const n = Number(amount.replace(/,/g, '')); - if (!Number.isFinite(n) || n <= 0) return null; - - const usd = n * priceUsd; - const formatted = - usd >= 1000 - ? usd.toLocaleString(undefined, { maximumFractionDigits: 0 }) - : usd >= 1 - ? usd.toFixed(2) - : usd.toFixed(4); - return `≈ $${formatted}`; -} diff --git a/src/pay/use-pay-swap-engine.ts b/src/pay/use-pay-swap-engine.ts index 23b8f53..08cac2c 100644 --- a/src/pay/use-pay-swap-engine.ts +++ b/src/pay/use-pay-swap-engine.ts @@ -1,7 +1,8 @@ -import { useCallback, useMemo, useState } from 'react'; +import { useMemo, useState } from 'react'; import { useAccount, useChainId, useSwitchChain, useWalletClient } from 'wagmi'; -import { detectWalletAccountType } from '@epoch-protocol/epoch-intents-sdk'; +import { useEffectiveGasless } from '../hooks/use-effective-gasless'; import { useGaslessWallet } from '../hooks/use-gasless-wallet-check'; +import { usePropOverride } from '../hooks/use-prop-override'; import type { UseGaslessWalletResult } from '../hooks/use-gasless-wallet-check'; import { resolveApiForNetwork } from '../resolve-api-config'; import { useSessionId } from '../session'; @@ -150,21 +151,9 @@ export function usePaySwapEngine(props: PaySwapIntentWidgetProps): PaySwapEngine const sessionId = useSessionId(isOpen); const [gasless, setGasless] = useState(gaslessProp); - // The header toggle overrides the `network` prop, but only for the prop value - // it was set against: a new `network` from the integrator evicts the override - // instead of being silently ignored. Keyed this way, nothing has to reset it. - const [networkOverride, setNetworkOverride] = useState<{ - forNetwork: string; - isTestnet: boolean; - } | null>(null); - const isTestnet = - networkOverride?.forNetwork === network - ? networkOverride.isTestnet - : network === 'testnet'; - const applyNetwork = useCallback( - (nextIsTestnet: boolean) => - setNetworkOverride({ forNetwork: network, isTestnet: nextIsTestnet }), - [network], + const [isTestnet, applyNetwork] = usePropOverride( + network, + (n) => n === 'testnet', ); const { data: walletClient } = useWalletClient(); @@ -172,13 +161,7 @@ export function usePaySwapEngine(props: PaySwapIntentWidgetProps): PaySwapEngine const chainId = useChainId(); const { switchChain } = useSwitchChain(); - const effectiveAllowGasless = useMemo( - () => - allowGasless && - walletClient != null && - detectWalletAccountType(walletClient as never) === 'local', - [allowGasless, walletClient], - ); + const effectiveAllowGasless = useEffectiveGasless(allowGasless, walletClient); const networkEnv: 'mainnet' | 'testnet' = isTestnet ? 'testnet' : 'mainnet'; const resolvedApi = useMemo( From d65955e12c3e2fd367a38f7e6d05e268cf7d7630 Mon Sep 17 00:00:00 2001 From: jasspreetbawa13 Date: Thu, 16 Jul 2026 22:58:59 +0530 Subject: [PATCH 07/13] fix(modal): dropdown menus hidden behind the dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit showModal() puts the modal in the browser top layer, above any menu portalled to document.body — so the Smart Withdraw token/chain dropdowns rendered behind it. Portal dropdown menus into the containing instead, and move the blur scrim off the dialog so it doesn't become a containing block that offsets their fixed positioning. --- src/components/Dropdown.tsx | 14 +++++++++++++- src/components/Modal.tsx | 18 ++++++++++-------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index bfcc1c7..3f03ba3 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -207,6 +207,17 @@ export function Dropdown({ return out as CSSProperties; })(); + // Portal into the containing when there is one: a modal opened with + // showModal() sits in the browser top layer, above any body-level portal + // regardless of z-index, so a menu portalled to document.body would render + // behind it. The dialog fills the viewport (inset-0), so the menu's fixed + // positioning still resolves to the same coordinates. Falls back to body for + // inline (non-modal) rendering. + const portalTarget = + (typeof document !== 'undefined' && + containerRef.current?.closest('dialog')) || + (typeof document !== 'undefined' ? document.body : null); + const menuPositionStyle: CSSProperties = { top: menuRect?.top ?? 0, left: menuRect?.left ?? 0, @@ -261,6 +272,7 @@ export function Dropdown({ {open && menuRect && + portalTarget && createPortal(
, - document.body, + portalTarget, )}
); diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index 9312c7e..88af178 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -27,11 +27,13 @@ interface ModalProps { renderInline?: boolean; } -// The itself is the overlay, so `classNames.overlay` keeps applying. -// The leading resets undo the UA's dialog defaults (auto margins, fit-content -// sizing, border, its own ::backdrop) — we paint the scrim ourselves. +// The is the overlay, so `classNames.overlay` keeps applying. The +// resets undo the UA dialog defaults (auto margins, fit-content sizing, border, +// its own ::backdrop). Deliberately NO backdrop-filter here: that would make the +// dialog a containing block for fixed-positioned descendants, offsetting the +// portalled dropdown menus by the padding. The scrim paints the blur instead. const OVERLAY_CLASSES = - 'm-0 max-w-none max-h-none w-full h-full border-0 p-4 fixed inset-0 z-[9999] flex items-center justify-center bg-overlay backdrop-blur-md animate-overlay-in [&::backdrop]:bg-transparent'; + 'fixed inset-0 z-[9999] m-0 max-w-none max-h-none w-full h-full border-0 p-4 flex items-center justify-center bg-transparent animate-overlay-in [&::backdrop]:bg-transparent'; const CONTAINER_CLASSES = 'flex w-full max-w-[480px] max-h-[90vh] flex-col overflow-hidden rounded-lg border border-line bg-canvas font-sans text-sm text-fg shadow-lg animate-modal-in'; @@ -164,15 +166,15 @@ export function Modal({ onClose(); }} > - {/* Pointer-only convenience: a real close button lives in the header and - Escape is handled natively, so this is hidden from assistive tech - rather than duplicated as a second tab stop. */} + {/* Scrim: paints the blur (kept off the dialog to avoid a containing + block) and closes on click. Pointer-only — the header has a real close + button and Escape is native — so it's hidden from assistive tech. */}