From 25bae1af8a607155a02f03359a5ebebdf626584b Mon Sep 17 00:00:00 2001 From: yaugourt Date: Fri, 15 May 2026 16:34:49 -0400 Subject: [PATCH 1/3] feat(widget): add usdh-to-usdc migration widget Add USDHMigration, a HyperCore-only exit widget that converts a USDH balance back to USDC ahead of the USDH sunset. It mirrors USDHSwap in reverse: sell side, no bridging, no source-chain toggle. - new packages/widget/src/usdh-migration.tsx + USDHMigrationProps - generalize PayCard/ReceiveCard/ResultPanel/ActionButton with optional ticker props (defaults keep USDHSwap rendering identical) - demo registry entry + preview for the migration widget - usdh-migration.test.tsx (8 tests) --- .changeset/widget-usdh-migration.md | 5 + apps/demo/src/components/ComponentPreview.tsx | 6 + .../components/registry/previews/widget.tsx | 46 +- apps/demo/src/lib/component-registry.ts | 50 ++- .../widget/src/components/action-button.tsx | 7 +- packages/widget/src/components/pay-card.tsx | 26 +- .../widget/src/components/receive-card.tsx | 11 +- .../widget/src/components/result-panel.tsx | 11 +- packages/widget/src/index.ts | 2 + packages/widget/src/usdh-migration.tsx | 400 ++++++++++++++++++ packages/widget/test/bundle-size.test.ts | 10 +- packages/widget/test/usdh-migration.test.tsx | 369 ++++++++++++++++ 12 files changed, 917 insertions(+), 26 deletions(-) create mode 100644 .changeset/widget-usdh-migration.md create mode 100644 packages/widget/src/usdh-migration.tsx create mode 100644 packages/widget/test/usdh-migration.test.tsx diff --git a/.changeset/widget-usdh-migration.md b/.changeset/widget-usdh-migration.md new file mode 100644 index 0000000..5cbd418 --- /dev/null +++ b/.changeset/widget-usdh-migration.md @@ -0,0 +1,5 @@ +--- +'@usdh-kit/widget': minor +--- + +Add `USDHMigration`, an exit widget that converts a HyperCore USDH balance back to USDC as USDH is sunset on Hyperliquid. diff --git a/apps/demo/src/components/ComponentPreview.tsx b/apps/demo/src/components/ComponentPreview.tsx index 569611d..8c1ee71 100644 --- a/apps/demo/src/components/ComponentPreview.tsx +++ b/apps/demo/src/components/ComponentPreview.tsx @@ -34,6 +34,10 @@ const UsdhWidgetPreview = dynamic( () => import('./registry/previews/widget').then((module) => module.UsdhWidgetPreview), { loading: PreviewLoading }, ) +const UsdhMigrationPreview = dynamic( + () => import('./registry/previews/widget').then((module) => module.UsdhMigrationPreview), + { loading: PreviewLoading }, +) const MarketBoardPreview = dynamic( () => import('./registry/previews/market-board').then((module) => module.MarketBoardPreview), { loading: PreviewLoading }, @@ -70,6 +74,8 @@ export function ComponentPreview({ switch (slug) { case 'usdh-widget': return + case 'usdh-migration': + return case 'market-board': return ( + + +
+
+
+ + Migration module +
+

+ Packaged USDHMigration: convert a HyperCore USDH balance back to USDC. +

+
+ + exit path + +
+
+
+ +
+
+
+
+ + ) +} + function PreWalletContext({ snapshot, compact, diff --git a/apps/demo/src/lib/component-registry.ts b/apps/demo/src/lib/component-registry.ts index 1b45042..fa0b807 100644 --- a/apps/demo/src/lib/component-registry.ts +++ b/apps/demo/src/lib/component-registry.ts @@ -1,4 +1,5 @@ import { + ArrowLeftRight, BookOpen, Boxes, Braces, @@ -16,6 +17,7 @@ export type RegistryDataMode = 'sample' | 'live' export type ComponentSlug = | 'usdh-widget' + | 'usdh-migration' | 'market-board' | 'quote-readiness' | 'outcome-reads' @@ -152,6 +154,52 @@ export function SwapCard({ context }) { installCommand: 'pnpm add @usdh-kit/widget wagmi viem', composition: 'Use USDHSwap as the write boundary and place read-only quote context around it.', }, + { + slug: 'usdh-migration', + title: 'USDH Migration', + shortTitle: 'Migration', + eyebrow: 'Drop-in exit', + category: 'Widget', + description: + 'The packaged exit component for USDH to USDC: convert a HyperCore USDH balance back to USDC as USDH is sunset.', + icon: ArrowLeftRight, + tags: ['widget', 'migration', 'usdh', 'usdc', 'exit'], + liveCapable: true, + visible: true, + snippets: [ + { + title: 'Render migration widget', + language: 'tsx', + code: `'use client' + +import { USDHMigration } from '@usdh-kit/widget' +import '@usdh-kit/widget/styles.css' + +export function UsdhMigrationEntry() { + return +}`, + }, + ], + useCase: { + usedFor: 'Wallet exit flows, USDH wind-down banners, and balance migration prompts.', + reads: 'HyperCore USDH balance, USDH/USDC pair, and the sell-side receive estimate.', + doesNot: + 'Bridge to HyperEVM, change the widget API, or submit swaps before wallet/session state.', + }, + usage: { + title: 'USDH exit entry', + language: 'tsx', + code: `export function MigrationEntry() { + return ( +
+ +
+ ) +}`, + }, + installCommand: 'pnpm add @usdh-kit/widget wagmi viem', + composition: 'Use USDHMigration as the write boundary for the USDH wind-down exit path.', + }, { slug: 'market-board', title: 'Quote Summary', @@ -1067,7 +1115,7 @@ export const visibleComponentEntries = componentEntries.filter((entry) => entry. export const componentSections: ComponentSection[] = [ { title: 'USDH', - items: ['usdh-widget', 'market-board', 'quote-readiness'], + items: ['usdh-widget', 'usdh-migration', 'market-board', 'quote-readiness'], }, { title: 'HIP-4', diff --git a/packages/widget/src/components/action-button.tsx b/packages/widget/src/components/action-button.tsx index 84665b8..2a3dafd 100644 --- a/packages/widget/src/components/action-button.tsx +++ b/packages/widget/src/components/action-button.tsx @@ -13,6 +13,8 @@ export function ActionButton(props: { needsTradingSession: boolean disabled: boolean onClick: () => void + /** Pay-side token ticker shown in balance/minimum labels. Defaults to `'USDC'`. */ + payTicker?: 'USDC' | 'USDH' }) { const { phase, @@ -24,6 +26,7 @@ export function ActionButton(props: { needsTradingSession, disabled, onClick, + payTicker = 'USDC', } = props return ( ) diff --git a/packages/widget/src/components/receive-card.tsx b/packages/widget/src/components/receive-card.tsx index 7d66640..d4efc2d 100644 --- a/packages/widget/src/components/receive-card.tsx +++ b/packages/widget/src/components/receive-card.tsx @@ -10,6 +10,8 @@ export function ReceiveCard(props: { receiveTicker?: 'USDC' | 'USDH' }) { const { receiveDisplay, receiveUsdValue, isQuoting, hasQuote, receiveTicker = 'USDH' } = props + const displayClass = + receiveDisplay.length > 12 ? 'text-lg font-medium' : 'text-3xl font-light tracking-tight' return (
@@ -17,7 +19,7 @@ export function ReceiveCard(props: { on HyperCore
- + {isQuoting && !hasQuote ? : receiveDisplay} void /** Received-token ticker shown in the receipt. Defaults to `'USDH'` for USDHSwap. */ receiveTicker?: 'USDC' | 'USDH' + resetLabel?: string }) { - const { result, onReset, receiveTicker = 'USDH' } = props + const { result, onReset, receiveTicker = 'USDH', resetLabel = 'Swap again' } = props return (
@@ -19,7 +24,7 @@ export function ResultPanel(props: {

Received

- {trimReceive(result.receivedUsdh, USDC_DECIMALS)} {receiveTicker} + {trimReceive(result.receivedAmount, USDC_DECIMALS)} {receiveTicker}

diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 50fcd85..07a73ee 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -6,5 +6,10 @@ export { USDHSwap } from './usdh-swap.js' export type { USDHSwapProps } from './usdh-swap.js' export { USDHMigration } from './usdh-migration.js' export type { USDHMigrationProps } from './usdh-migration.js' -export type { HyperNetwork, SwapResultPayload, WidgetTheme } from './types.js' +export type { + HyperNetwork, + SwapResultPayload, + USDHMigrationResultPayload, + WidgetTheme, +} from './types.js' export { useEffectiveTheme } from './use-theme.js' diff --git a/packages/widget/src/styles.css b/packages/widget/src/styles.css index ed4744b..19b595f 100644 --- a/packages/widget/src/styles.css +++ b/packages/widget/src/styles.css @@ -5,7 +5,7 @@ * * The widget defaults to light mode tokens on `.usdh-widget`; when the * effective theme is dark (computed from the `theme` prop or the user's - * system preference) USDHSwap adds a `dark` class to the root which + * system preference) the widget adds a `dark` class to the root which * overrides each token. Tokens are RGB triples (no `rgb()` wrapper) so * the Tailwind `` substitution can apply opacity (e.g. * `bg-usdh-surface/40`). diff --git a/packages/widget/src/types.ts b/packages/widget/src/types.ts index d347edd..d8fa618 100644 --- a/packages/widget/src/types.ts +++ b/packages/widget/src/types.ts @@ -16,3 +16,11 @@ export interface SwapResultPayload { receivedUsdh: bigint txHash?: `0x${string}` } + +export interface USDHMigrationResultPayload { + orderId: string + spentUsdh: bigint + receivedUsdc: bigint + price: bigint + slippageBps: number +} diff --git a/packages/widget/src/usdh-migration.tsx b/packages/widget/src/usdh-migration.tsx index 019d84f..611c1b2 100644 --- a/packages/widget/src/usdh-migration.tsx +++ b/packages/widget/src/usdh-migration.tsx @@ -16,7 +16,7 @@ import { SlippageRow } from './components/slippage-row.js' import { formatUsd, scaleAmount, trimReceive } from './format-display.js' import { formatUnits, parseUnits } from './format.js' import { friendlyError } from './friendly-error.js' -import type { HyperNetwork, SwapResultPayload, WidgetTheme } from './types.js' +import type { HyperNetwork, USDHMigrationResultPayload, WidgetTheme } from './types.js' import { useAgentWalletKit } from './use-agent-wallet-kit.js' import { useUsdcBalances } from './use-balances.js' import { useCountdown } from './use-countdown.js' @@ -52,12 +52,12 @@ export type USDHMigrationProps = { /** Pre-fill the pay amount as a decimal string. */ defaultAmount?: string /** Called when a migration fills successfully. */ - onMigrationComplete?: (result: SwapResultPayload) => void + onMigrationComplete?: (result: USDHMigrationResultPayload) => void } /** * Exit widget: convert a USDH HyperCore balance back to USDC. This is the - * reverse of `USDHSwap` — USDH is being sunset on Hyperliquid in favour of + * reverse of `USDHSwap`: USDH is being sunset on Hyperliquid in favour of * USDC, and this tool helps users migrate out. HyperCore sell side only: * no bridging, no HyperEVM source, no source-chain toggle. */ @@ -87,8 +87,9 @@ export function USDHMigration(props: USDHMigrationProps) { const [route, setRoute] = useState(null) const [isQuoting, setIsQuoting] = useState(false) const [readOnlyEstimate, setReadOnlyEstimate] = useState(null) + const [readOnlyQuoteUnavailable, setReadOnlyQuoteUnavailable] = useState(false) const [isReadOnlyQuoting, setIsReadOnlyQuoting] = useState(false) - const [result, setResult] = useState(null) + const [result, setResult] = useState(null) const [error, setError] = useState(null) const quoteExpirySeconds = useCountdown(quote?.validUntil ?? null) @@ -159,6 +160,7 @@ export function USDHMigration(props: USDHMigrationProps) { useEffect(() => { setReadOnlyEstimate(null) + setReadOnlyQuoteUnavailable(false) if (isConnected || parsedAmount === null || parsedAmount <= 0n) { setIsReadOnlyQuoting(false) return @@ -170,20 +172,35 @@ export function USDHMigration(props: USDHMigrationProps) { try { const info = createInfoClient({ network, timeoutMs: READ_ONLY_QUOTE_TIMEOUT_MS }) const pairs = listUsdhSpotPairs(await info.spotMeta()) - const pair = - pairs.find((candidate) => candidate.base === 'USDH' && candidate.quote === 'USDC') ?? - pairs[0] - if (!pair) return + const pair = pairs.find( + (candidate) => candidate.base === 'USDH' && candidate.quote === 'USDC', + ) + if (!pair) { + if (!cancelled) setReadOnlyQuoteUnavailable(true) + return + } const book = await info.l2Book(pair.name) - // Selling USDH lifts the best bid — multiply size by the bid price. + // Selling USDH lifts the best bid: multiply size by the bid price. const bid = book.levels[0]?.[0]?.px - if (!bid) return + if (!bid) { + if (!cancelled) setReadOnlyQuoteUnavailable(true) + return + } const bidPrice18 = parseUnits(bid, PRICE_DECIMALS) - if (bidPrice18 <= 0n) return + if (bidPrice18 <= 0n) { + if (!cancelled) setReadOnlyQuoteUnavailable(true) + return + } const nextEstimate = (parsedAmount * bidPrice18) / 10n ** BigInt(PRICE_DECIMALS) - if (!cancelled) setReadOnlyEstimate(nextEstimate) + if (!cancelled) { + setReadOnlyEstimate(nextEstimate) + setReadOnlyQuoteUnavailable(false) + } } catch { - if (!cancelled) setReadOnlyEstimate(null) + if (!cancelled) { + setReadOnlyEstimate(null) + setReadOnlyQuoteUnavailable(true) + } } finally { if (!cancelled) setIsReadOnlyQuoting(false) } @@ -252,9 +269,12 @@ export function USDHMigration(props: USDHMigrationProps) { amount: parsedAmount, slippageBps, }) - const payload: SwapResultPayload = { + const payload: USDHMigrationResultPayload = { orderId: next.orderId, - receivedUsdh: next.received, + spentUsdh: next.spent, + receivedUsdc: next.received, + price: next.price, + slippageBps: next.slippageBps, } setResult(payload) setPhase('done') @@ -282,15 +302,12 @@ export function USDHMigration(props: USDHMigrationProps) { routeLoaded && hcCovers - // Display the receive amount: while no quote is in, mirror the input - // (USDH and USDC are USD-pegged stables so 1:1 is the honest user-facing - // default). When a quote arrives, show the rounded estimate. const receiveDisplay = quote ? trimReceive(quote.estimatedReceived, USDH_DECIMALS) : readOnlyEstimate !== null ? trimReceive(readOnlyEstimate, USDH_DECIMALS) - : parsedAmount && parsedAmount > 0n - ? trimReceive(parsedAmount, USDH_DECIMALS) + : readOnlyQuoteUnavailable + ? 'Quote unavailable' : '0' const payUsdValue = parsedAmount ? formatUsd(parsedAmount, USDH_DECIMALS) : null @@ -298,9 +315,7 @@ export function USDHMigration(props: USDHMigrationProps) { ? quote.estimatedReceived : readOnlyEstimate !== null ? readOnlyEstimate - : parsedAmount && parsedAmount > 0n - ? parsedAmount - : null + : null const receiveUsdValue = receiveBigint ? formatUsd(receiveBigint, USDH_DECIMALS) : null function setMaxAmount() { @@ -384,11 +399,21 @@ export function USDHMigration(props: USDHMigrationProps) { disabled={!canSwap} onClick={executeMigration} payTicker="USDH" + actionLabel="Migrate" + connectLabel="Connect wallet to migrate" + workingLabel="Migrating" /> )} {error && } - {result && } + {result && ( + + )} {!hideAttribution && (

diff --git a/packages/widget/src/usdh-swap.tsx b/packages/widget/src/usdh-swap.tsx index cc7bdd9..fd47348 100644 --- a/packages/widget/src/usdh-swap.tsx +++ b/packages/widget/src/usdh-swap.tsx @@ -478,7 +478,16 @@ export function USDHSwap(props: USDHSwapProps) { )} {error && } - {result && } + {result && ( + + )} {!hideAttribution && (
diff --git a/packages/widget/tailwind.config.cjs b/packages/widget/tailwind.config.cjs index 8e56a08..4b1435a 100644 --- a/packages/widget/tailwind.config.cjs +++ b/packages/widget/tailwind.config.cjs @@ -10,7 +10,7 @@ module.exports = { colors: { // Semantic tokens — resolved from CSS variables defined in // src/styles.css. Light mode is the default; the `dark` class - // (added on the widget root by USDHSwap when the effective theme + // (added on the widget root when the effective theme // is dark) overrides each token. Using rgb(var() / ) // means consumers can still write `bg-usdh-surface/40`, etc. 'usdh-bg': 'rgb(var(--usdh-bg) / )', diff --git a/packages/widget/test/usdh-migration.test.tsx b/packages/widget/test/usdh-migration.test.tsx index f34bdc2..c3b70e6 100644 --- a/packages/widget/test/usdh-migration.test.tsx +++ b/packages/widget/test/usdh-migration.test.tsx @@ -71,6 +71,17 @@ vi.mock('@tanstack/react-query', () => ({ const mockPreflightSwap = vi.fn() const mockSwap = vi.fn() const mockApproveAgent = vi.fn() +const mockL2Book = vi.fn() +const mockListUsdhSpotPairs = vi.fn() + +const usdhUsdcPair = { + name: '@230', + label: 'USDH/USDC', + index: 230, + base: 'USDH', + quote: 'USDC', + usdhRole: 'base', +} function makeQuote(estimatedReceived = 11_000_000n) { return { @@ -127,22 +138,10 @@ vi.mock('@usdh-kit/sdk', () => ({ }), createInfoClient: () => ({ spotMeta: vi.fn(async () => ({})), - l2Book: vi.fn(async () => ({ - coin: '@230', - levels: [[{ px: '0.9999', sz: '10', n: 1 }], [{ px: '1.0001', sz: '10', n: 1 }]], - })), + l2Book: (...args: unknown[]) => mockL2Book(...args), spotClearinghouseState: vi.fn(), }), - listUsdhSpotPairs: () => [ - { - name: '@230', - label: 'USDH/USDC', - index: 230, - base: 'USDH', - quote: 'USDC', - usdhRole: 'base', - }, - ], + listUsdhSpotPairs: (...args: unknown[]) => mockListUsdhSpotPairs(...args), getHyperEvmNativeUsdcAddress: () => '0xb88339cb7199b77e23db6e890353e22632ba630f', BridgeAndSwapError: class extends Error {}, isBridgeAndSwapError: () => false, @@ -220,6 +219,11 @@ describe('USDHMigration', () => { mockHcQueryData.mockReturnValue(undefined) mockPreflightSwap.mockResolvedValue(makeRoute()) mockSwap.mockResolvedValue(makeSwapResult()) + mockL2Book.mockResolvedValue({ + coin: '@230', + levels: [[{ px: '0.9999', sz: '10', n: 1 }], [{ px: '1.0001', sz: '10', n: 1 }]], + }) + mockListUsdhSpotPairs.mockReturnValue([usdhUsdcPair]) mockUseReadContract.mockReturnValue({ data: undefined, isLoading: false, refetch: vi.fn() }) }) @@ -235,7 +239,47 @@ describe('USDHMigration', () => { expect(screen.getByText('You pay')).toBeInTheDocument() expect(screen.getByText('You receive')).toBeInTheDocument() expect(screen.getByLabelText('Amount in USDH')).toBeInTheDocument() - expect(screen.getByRole('button', { name: 'Connect wallet to swap' })).toBeDisabled() + expect(screen.getByRole('button', { name: 'Connect wallet to migrate' })).toBeDisabled() + }) + + it('shows a strict read-only USDH/USDC quote before wallet connect', async () => { + mockUseAccount.mockReturnValue({ isConnected: false }) + mockUseWalletClient.mockReturnValue({ data: undefined }) + mockUseChainId.mockReturnValue(0) + + render() + + await waitFor(() => { + expect(mockL2Book).toHaveBeenCalledWith('@230') + }) + expect(screen.getByText('10.9989')).toBeInTheDocument() + }) + + it('shows quote unavailable when the USDH/USDC pair is missing before connect', async () => { + mockUseAccount.mockReturnValue({ isConnected: false }) + mockUseWalletClient.mockReturnValue({ data: undefined }) + mockUseChainId.mockReturnValue(0) + mockListUsdhSpotPairs.mockReturnValue([{ ...usdhUsdcPair, base: 'HYPE', quote: 'USDH' }]) + + render() + + await waitFor(() => { + expect(screen.getByText('Quote unavailable')).toBeInTheDocument() + }) + expect(mockL2Book).not.toHaveBeenCalled() + }) + + it('shows quote unavailable when the USDH/USDC book has no bid before connect', async () => { + mockUseAccount.mockReturnValue({ isConnected: false }) + mockUseWalletClient.mockReturnValue({ data: undefined }) + mockUseChainId.mockReturnValue(0) + mockL2Book.mockResolvedValue({ coin: '@230', levels: [[], []] }) + + render() + + await waitFor(() => { + expect(screen.getByText('Quote unavailable')).toBeInTheDocument() + }) }) it('auto-fetches a quote (debounced) and renders the rounded USDC receive estimate', async () => { @@ -333,9 +377,9 @@ describe('USDHMigration', () => { render() await waitFor(() => { - expect(screen.getByRole('button', { name: 'Swap' })).not.toBeDisabled() + expect(screen.getByRole('button', { name: 'Migrate' })).not.toBeDisabled() }) - fireEvent.click(screen.getByRole('button', { name: 'Swap' })) + fireEvent.click(screen.getByRole('button', { name: 'Migrate' })) await waitFor(() => { expect(screen.getByText('Filled')).toBeInTheDocument() @@ -351,7 +395,10 @@ describe('USDHMigration', () => { ) expect(onMigrationComplete).toHaveBeenCalledWith({ orderId: 'order-7', - receivedUsdh: 10_998_000n, + spentUsdh: 11_000_000n, + receivedUsdc: 10_998_000n, + price: 1_000_000_000_000_000_000n, + slippageBps: 0, }) }) From fd7c539a829cafc9047d176789c1e6827817e8ba Mon Sep 17 00:00:00 2001 From: sumfxn Date: Sat, 16 May 2026 04:43:27 +0200 Subject: [PATCH 3/3] fix(widget): harden migration quote safety --- .../widget/src/components/result-panel.tsx | 39 +++++- packages/widget/src/usdh-migration.tsx | 116 ++++++++++++++++-- packages/widget/test/bundle-size.test.ts | 11 +- packages/widget/test/usdh-migration.test.tsx | 105 +++++++++++++++- scripts/demo-browser-smoke.mjs | 1 + 5 files changed, 247 insertions(+), 25 deletions(-) diff --git a/packages/widget/src/components/result-panel.tsx b/packages/widget/src/components/result-panel.tsx index 9080fa9..5b4fc94 100644 --- a/packages/widget/src/components/result-panel.tsx +++ b/packages/widget/src/components/result-panel.tsx @@ -4,6 +4,8 @@ const USDC_DECIMALS = 6 export interface ResultPanelPayload { orderId: string receivedAmount: bigint + spentAmount?: bigint + requestedAmount?: bigint txHash?: `0x${string}` } @@ -12,20 +14,40 @@ export function ResultPanel(props: { onReset: () => void /** Received-token ticker shown in the receipt. Defaults to `'USDH'` for USDHSwap. */ receiveTicker?: 'USDC' | 'USDH' + spentTicker?: 'USDC' | 'USDH' resetLabel?: string }) { - const { result, onReset, receiveTicker = 'USDH', resetLabel = 'Swap again' } = props + const { + result, + onReset, + receiveTicker = 'USDH', + spentTicker = 'USDC', + resetLabel = 'Swap again', + } = props + const partialFill = + result.spentAmount !== undefined && + result.requestedAmount !== undefined && + result.spentAmount < result.requestedAmount return (

- Filled + {partialFill ? 'Partially filled' : 'Filled'}

Received

{trimReceive(result.receivedAmount, USDC_DECIMALS)} {receiveTicker}

+ {result.spentAmount !== undefined && ( +

+ Spent{' '} + + {trimReceive(result.spentAmount, USDC_DECIMALS)} + {' '} + {spentTicker} +

+ )}
) } diff --git a/packages/widget/src/usdh-migration.tsx b/packages/widget/src/usdh-migration.tsx index 611c1b2..f22e100 100644 --- a/packages/widget/src/usdh-migration.tsx +++ b/packages/widget/src/usdh-migration.tsx @@ -13,7 +13,7 @@ import { PayCard } from './components/pay-card.js' import { ReceiveCard } from './components/receive-card.js' import { ResultPanel } from './components/result-panel.js' import { SlippageRow } from './components/slippage-row.js' -import { formatUsd, scaleAmount, trimReceive } from './format-display.js' +import { formatBalance, formatUsd, scaleAmount, trimReceive } from './format-display.js' import { formatUnits, parseUnits } from './format.js' import { friendlyError } from './friendly-error.js' import type { HyperNetwork, USDHMigrationResultPayload, WidgetTheme } from './types.js' @@ -29,6 +29,13 @@ const MIN_SWAP_DISPLAY = '11' const QUOTE_DEBOUNCE_MS = 400 const READ_ONLY_QUOTE_TIMEOUT_MS = 1_500 const PRICE_DECIMALS = 18 +const TEN_18 = 10n ** BigInt(PRICE_DECIMALS) + +type BidDepthEstimate = { + receivedUsdc: bigint + spentUsdh: bigint + fullyCovered: boolean +} // USDH -> USDC is HyperCore-only and never bridges, so the migration widget // has a strictly simpler lifecycle than USDHSwap (no `bridging` phase). @@ -89,6 +96,8 @@ export function USDHMigration(props: USDHMigrationProps) { const [readOnlyEstimate, setReadOnlyEstimate] = useState(null) const [readOnlyQuoteUnavailable, setReadOnlyQuoteUnavailable] = useState(false) const [isReadOnlyQuoting, setIsReadOnlyQuoting] = useState(false) + const [depthWarning, setDepthWarning] = useState(null) + const [knownDepthLimited, setKnownDepthLimited] = useState(false) const [result, setResult] = useState(null) const [error, setError] = useState(null) @@ -124,6 +133,8 @@ export function USDHMigration(props: USDHMigrationProps) { const requestId = ++quoteRequestId.current setQuote(null) setRoute(null) + setDepthWarning(null) + setKnownDepthLimited(false) if (!kit) { setIsQuoting(false) return @@ -146,8 +157,30 @@ export function USDHMigration(props: USDHMigrationProps) { sourceChain: 'hypercore', }) if (requestId !== quoteRequestId.current) return - setRoute(nextRoute) - setQuote(nextRoute.quote) + let nextQuote = nextRoute.quote + try { + const book = await kit.getBook(nextRoute.quote.pair) + if (requestId !== quoteRequestId.current) return + const depth = estimateUsdcFromBidDepth(book.levels[0], parsedAmount) + if (depth === null || depth.spentUsdh === 0n) { + setKnownDepthLimited(true) + setDepthWarning('No visible USDH/USDC bid depth for this amount.') + } else if (!depth.fullyCovered) { + setKnownDepthLimited(true) + setDepthWarning( + `Visible bid depth covers ${trimReceive(depth.spentUsdh, USDH_DECIMALS)} of ${trimReceive(parsedAmount, USDH_DECIMALS)} USDH. Reduce the amount or refresh before migrating.`, + ) + } else { + setKnownDepthLimited(false) + setDepthWarning(null) + nextQuote = { ...nextRoute.quote, estimatedReceived: depth.receivedUsdc } + } + } catch { + setKnownDepthLimited(true) + setDepthWarning('Unable to verify visible USDH/USDC bid depth. Refresh before migrating.') + } + setRoute({ ...nextRoute, quote: nextQuote }) + setQuote(nextQuote) } catch (err) { if (requestId !== quoteRequestId.current) return setError(friendlyError(err)) @@ -161,6 +194,8 @@ export function USDHMigration(props: USDHMigrationProps) { useEffect(() => { setReadOnlyEstimate(null) setReadOnlyQuoteUnavailable(false) + setDepthWarning(null) + setKnownDepthLimited(false) if (isConnected || parsedAmount === null || parsedAmount <= 0n) { setIsReadOnlyQuoting(false) return @@ -180,21 +215,24 @@ export function USDHMigration(props: USDHMigrationProps) { return } const book = await info.l2Book(pair.name) - // Selling USDH lifts the best bid: multiply size by the bid price. - const bid = book.levels[0]?.[0]?.px - if (!bid) { + const depth = estimateUsdcFromBidDepth(book.levels[0], parsedAmount) + if (depth === null || depth.spentUsdh === 0n) { if (!cancelled) setReadOnlyQuoteUnavailable(true) return } - const bidPrice18 = parseUnits(bid, PRICE_DECIMALS) - if (bidPrice18 <= 0n) { - if (!cancelled) setReadOnlyQuoteUnavailable(true) + if (!depth.fullyCovered) { + if (!cancelled) { + setReadOnlyQuoteUnavailable(true) + setDepthWarning( + `Visible bid depth covers ${trimReceive(depth.spentUsdh, USDH_DECIMALS)} of ${trimReceive(parsedAmount, USDH_DECIMALS)} USDH.`, + ) + } return } - const nextEstimate = (parsedAmount * bidPrice18) / 10n ** BigInt(PRICE_DECIMALS) if (!cancelled) { - setReadOnlyEstimate(nextEstimate) + setReadOnlyEstimate(depth.receivedUsdc) setReadOnlyQuoteUnavailable(false) + setDepthWarning(null) } } catch { if (!cancelled) { @@ -300,7 +338,8 @@ export function USDHMigration(props: USDHMigrationProps) { parsedAmount > 0n && !belowMinOrderValue && routeLoaded && - hcCovers + hcCovers && + !knownDepthLimited const receiveDisplay = quote ? trimReceive(quote.estimatedReceived, USDH_DECIMALS) @@ -344,6 +383,17 @@ export function USDHMigration(props: USDHMigrationProps) {

USDH is being sunset on Hyperliquid. This converts your HyperCore USDH balance back to USDC.

+ {isConnected && ( +
+ HyperCore USDH balance + + + {formatBalance(balances.hcUsdh, balances.hcUsdhDecimals)} + {' '} + USDH + +
+ )}
+ {depthWarning && ( +

{depthWarning}

+ )} {insufficientForRoute && (

Exceeds your HyperCore USDH balance. @@ -408,9 +461,15 @@ export function USDHMigration(props: USDHMigrationProps) { {error && } {result && ( )} @@ -423,3 +482,34 @@ export function USDHMigration(props: USDHMigrationProps) {

) } + +function estimateUsdcFromBidDepth( + bids: Array<{ px: string; sz: string }>, + desiredUsdh: bigint, +): BidDepthEstimate | null { + if (desiredUsdh <= 0n) return null + let remainingUsdh = desiredUsdh + let spentUsdh = 0n + let receivedUsdc = 0n + + try { + for (const level of bids) { + if (remainingUsdh <= 0n) break + const levelSizeUsdh = parseUnits(level.sz, USDH_DECIMALS) + const bidPrice18 = parseUnits(level.px, PRICE_DECIMALS) + if (levelSizeUsdh <= 0n || bidPrice18 <= 0n) continue + const fillUsdh = levelSizeUsdh < remainingUsdh ? levelSizeUsdh : remainingUsdh + spentUsdh += fillUsdh + receivedUsdc += (fillUsdh * bidPrice18) / TEN_18 + remainingUsdh -= fillUsdh + } + } catch { + return null + } + + return { + receivedUsdc, + spentUsdh, + fullyCovered: remainingUsdh === 0n, + } +} diff --git a/packages/widget/test/bundle-size.test.ts b/packages/widget/test/bundle-size.test.ts index 9520c25..666b864 100644 --- a/packages/widget/test/bundle-size.test.ts +++ b/packages/widget/test/bundle-size.test.ts @@ -10,12 +10,13 @@ import { describe, expect, it } from 'vitest' * over-the-wire size to end users is gzipped and roughly 30 to 35 % of * this number. * - * Current actual: ~67.9 KB ESM after the browser agent-session flow, native - * USDC bridge support, dual-token balance display, and the USDHMigration - * exit widget. Budget leaves a small cushion while still catching dependency - * creep; viem/accounts remains external and is not bundled into the widget. + * Current actual: ~73.6 KB ESM after the browser agent-session flow, native + * USDC bridge support, dual-token balance display, the USDHMigration exit + * widget, depth-aware migration quotes, and partial-fill receipts. Budget + * leaves a small cushion while still catching dependency creep; viem/accounts + * remains external and is not bundled into the widget. */ -const BUDGET_KB = 70 +const BUDGET_KB = 75 describe('widget bundle size', () => { it('ESM bundle stays under budget', () => { diff --git a/packages/widget/test/usdh-migration.test.tsx b/packages/widget/test/usdh-migration.test.tsx index c3b70e6..39c9d8e 100644 --- a/packages/widget/test/usdh-migration.test.tsx +++ b/packages/widget/test/usdh-migration.test.tsx @@ -71,6 +71,7 @@ vi.mock('@tanstack/react-query', () => ({ const mockPreflightSwap = vi.fn() const mockSwap = vi.fn() const mockApproveAgent = vi.fn() +const mockKitGetBook = vi.fn() const mockL2Book = vi.fn() const mockListUsdhSpotPairs = vi.fn() @@ -120,11 +121,13 @@ function makeRoute( } } -function makeSwapResult(overrides: Partial<{ orderId: string; received: bigint }> = {}) { +function makeSwapResult( + overrides: Partial<{ orderId: string; received: bigint; spent: bigint }> = {}, +) { return { orderId: overrides.orderId ?? 'order-42', received: overrides.received ?? 11_000_000n, - spent: 11_000_000n, + spent: overrides.spent ?? 11_000_000n, price: 1_000_000_000_000_000_000n, slippageBps: 0, } @@ -135,6 +138,7 @@ vi.mock('@usdh-kit/sdk', () => ({ createUsdhKit: () => ({ preflightSwap: mockPreflightSwap, swap: mockSwap, + getBook: mockKitGetBook, }), createInfoClient: () => ({ spotMeta: vi.fn(async () => ({})), @@ -219,9 +223,13 @@ describe('USDHMigration', () => { mockHcQueryData.mockReturnValue(undefined) mockPreflightSwap.mockResolvedValue(makeRoute()) mockSwap.mockResolvedValue(makeSwapResult()) + mockKitGetBook.mockResolvedValue({ + coin: '@230', + levels: [[{ px: '0.9999', sz: '20', n: 1 }], [{ px: '1.0001', sz: '20', n: 1 }]], + }) mockL2Book.mockResolvedValue({ coin: '@230', - levels: [[{ px: '0.9999', sz: '10', n: 1 }], [{ px: '1.0001', sz: '10', n: 1 }]], + levels: [[{ px: '0.9999', sz: '20', n: 1 }], [{ px: '1.0001', sz: '20', n: 1 }]], }) mockListUsdhSpotPairs.mockReturnValue([usdhUsdcPair]) mockUseReadContract.mockReturnValue({ data: undefined, isLoading: false, refetch: vi.fn() }) @@ -282,7 +290,24 @@ describe('USDHMigration', () => { }) }) - it('auto-fetches a quote (debounced) and renders the rounded USDC receive estimate', async () => { + it('shows quote unavailable when visible bid depth is below the amount before connect', async () => { + mockUseAccount.mockReturnValue({ isConnected: false }) + mockUseWalletClient.mockReturnValue({ data: undefined }) + mockUseChainId.mockReturnValue(0) + mockL2Book.mockResolvedValue({ + coin: '@230', + levels: [[{ px: '0.9999', sz: '5', n: 1 }], [{ px: '1.0001', sz: '20', n: 1 }]], + }) + + render() + + await waitFor(() => { + expect(screen.getByText('Quote unavailable')).toBeInTheDocument() + }) + expect(screen.getByText(/Visible bid depth covers 5 of 11 USDH/)).toBeInTheDocument() + }) + + it('auto-fetches a quote and renders the depth-aware USDC receive estimate', async () => { setConnected() mockPreflightSwap.mockResolvedValue(makeRoute({ estimatedReceived: 10_999_800n })) @@ -302,10 +327,58 @@ describe('USDHMigration', () => { { timeout: 2_000 }, ) await waitFor(() => { - expect(screen.getByText('10.9998')).toBeInTheDocument() + expect(mockKitGetBook).toHaveBeenCalledWith('USDH/USDC') + expect(screen.getByText('10.9989')).toBeInTheDocument() }) }) + it('renders the connected HyperCore USDH balance', async () => { + setConnected() + mockPreflightSwap.mockImplementation(() => new Promise(() => {})) + + render() + + expect(screen.getByText('HyperCore USDH balance')).toBeInTheDocument() + expect(screen.getByText('20')).toBeInTheDocument() + }) + + it('disables migration when connected visible bid depth is below the amount', async () => { + setConnected() + mockKitGetBook.mockResolvedValue({ + coin: '@230', + levels: [[{ px: '0.9999', sz: '5', n: 1 }], [{ px: '1.0001', sz: '20', n: 1 }]], + }) + + render() + + await waitFor( + () => { + expect(screen.getByText(/Visible bid depth covers 5 of 11 USDH/)).toBeInTheDocument() + }, + { timeout: 2_000 }, + ) + expect(screen.getByRole('button', { name: 'Migrate' })).toBeDisabled() + }) + + it('disables migration when connected bid depth cannot be verified', async () => { + setConnected() + mockKitGetBook.mockRejectedValue(new Error('book unavailable')) + + render() + + await waitFor( + () => { + expect( + screen.getByText( + 'Unable to verify visible USDH/USDC bid depth. Refresh before migrating.', + ), + ).toBeInTheDocument() + }, + { timeout: 2_000 }, + ) + expect(screen.getByRole('button', { name: 'Migrate' })).toBeDisabled() + }) + it('surfaces a friendly error when the auto-quote rejects', async () => { setConnected() mockPreflightSwap.mockRejectedValue(new Error('upstream down')) @@ -402,6 +475,28 @@ describe('USDHMigration', () => { }) }) + it('shows a partial-fill receipt when IOC liquidity only fills part of the amount', async () => { + setConnected() + mockSwap.mockResolvedValue( + makeSwapResult({ orderId: 'order-partial', spent: 6_000_000n, received: 5_998_000n }), + ) + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Migrate' })).not.toBeDisabled() + }) + fireEvent.click(screen.getByRole('button', { name: 'Migrate' })) + + await waitFor(() => { + expect(screen.getByText('Partially filled')).toBeInTheDocument() + }) + expect(screen.getByText(/5\.998 USDC/)).toBeInTheDocument() + expect(screen.getByText(/Migrated/)).toHaveTextContent( + 'Migrated 6 of 11 USDH. The unfilled balance remains on HyperCore.', + ) + }) + it('renders the watermark by default and hides it when hideAttribution is true', () => { mockUseAccount.mockReturnValue({ isConnected: false }) mockUseWalletClient.mockReturnValue({ data: undefined }) diff --git a/scripts/demo-browser-smoke.mjs b/scripts/demo-browser-smoke.mjs index 810a3e4..30b8847 100644 --- a/scripts/demo-browser-smoke.mjs +++ b/scripts/demo-browser-smoke.mjs @@ -9,6 +9,7 @@ const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const demoDir = join(repoRoot, 'apps', 'demo') const routes = [ '/components', + '/components/usdh-migration', '/components/usdh-widget', '/components/market-board', '/components/outcome-reads',