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/README.md b/README.md index e67f85e..89b832c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ +USDH sunset migration kit for Hyperliquid. -TypeScript SDK for USDH on Hyperliquid. - -USDH is the native stablecoin on Hyperliquid, issued by Bridge and designed by Native Markets, with 50% of reserve revenue routed to the Hyperliquid Assistance Fund. `@usdh-kit/sdk` ships the retail-side plumbing (pair resolution, signing, transport) so apps and bots can convert into USDH without writing the Hyperliquid action layer themselves. `@usdh-kit/widget` is an embeddable React component on top of the SDK so dapps can drop in a swap form in a few lines. +USDH is being sunset in favour of USDC. The final purpose of `usdh-kit` is to +help users migrate remaining HyperCore USDH balances back to USDC, while keeping +the original USDH integration work available as legacy reference code. - **Source:** [github.com/sumfxn/usdh-kit](https://github.com/sumfxn/usdh-kit) - **Issues:** [github.com/sumfxn/usdh-kit/issues](https://github.com/sumfxn/usdh-kit/issues) - **USDH:** [usdh.com](https://usdh.com) (issued by [Bridge](https://bridge.xyz), designed by [Native Markets](https://www.nativemarkets.com)) -- **Hyperliquid:** [hyperliquid.xyz](https://hyperliquid.xyz) · [docs](https://hyperliquid.gitbook.io/hyperliquid-docs) +- **Hyperliquid:** [hyperliquid.xyz](https://hyperliquid.xyz) / [docs](https://hyperliquid.gitbook.io/hyperliquid-docs) ## Status Pre-release. Public API is unstable until `1.0.0`. -What works today: +This repo is in sunset/migration mode: -- `getQuote()` and `swap()` for `USDC → USDH` and `USDH → USDC` end to end -- `bridgeToCore()` for moving USDC from HyperEVM to HyperCore, with credit polling -- `bridgeFromCore()` for moving linked USDC/USDH spot assets from HyperCore to HyperEVM -- `getHypercoreBalance()` for spendable HyperCore balances (`total - hold`) -- `getRoute()` / `preflightSwap()` to choose direct HyperCore swap vs HyperEVM bridge -- `bridgeAndSwap()` for the common route → bridge → swap retail flow -- Hyperliquid agent-wallet support for browser-safe L1 order signing -- React widget with built-in source-chain selection (HyperEVM bridge or direct HyperCore swap), short-lived trading sessions, friendly errors, and full theming via CSS variables +- `@usdh-kit/widget` exports `USDHMigration`, a wallet-gated `USDH -> USDC` exit widget. +- `@usdh-kit/sdk` keeps the USDH quote, swap, bridge, and signing helpers needed for migration. +- `USDHSwap` and `bridgeAndSwap()` remain available only as legacy compatibility surfaces. +- HIP-4 and outcome helpers remain historical/read-only reference work, not the future product surface. +- Future USDC or HIP-4 tooling should live in a separate repo/package with a clean name and API. +- No release, merge, or npm publish is implied without explicit approval. -Deferred to follow-up PRs: +## Migration Widget -- USDT pricing and swap (USDT/USDC/USDH double-hop) -- Multi-chain source via LiFi/Squid (Ethereum, Arbitrum, Base) +```tsx +// app/layout.tsx +import '@usdh-kit/widget/styles.css' -## Install +// app/page.tsx +import { USDHMigration } from '@usdh-kit/widget' -```sh -pnpm add @usdh-kit/sdk +export default function Page() { + return +} ``` -For the React widget: +The migration widget: -```sh -pnpm add @usdh-kit/widget wagmi viem @tanstack/react-query react react-dom -``` +- reads the connected wallet through wagmi; +- shows the user's HyperCore USDH balance; +- reads the live `USDH/USDC` book before wallet writes; +- never fakes a `1:1` receive estimate when the quote is unavailable; +- asks for a short-lived Hyperliquid trading session before submitting the `USDH -> USDC` order; +- does not bridge the resulting USDC out to HyperEVM. -The widget depends on `@usdh-kit/sdk` internally. Install the SDK separately -only when your app imports SDK APIs directly. - -## SDK quickstart +## SDK Migration Flow ```ts import { approveAgent, createUsdhKit } from '@usdh-kit/sdk' -// Browser apps should use an approved Hyperliquid agent wallet for L1 orders. await approveAgent({ network: 'mainnet', signer: masterWalletSigner, @@ -83,110 +84,63 @@ const kit = createUsdhKit({ slippageBps: 30, }) -// quote -const amount = 11_000_000n // 11 USDC; Hyperliquid spot orders must be >10 USDC - -const quote = await kit.getQuote({ from: 'USDC', amount }) -console.log(`would receive ~${quote.estimatedReceived} USDH`) +const amount = 11_000_000n // 11 USDH; Hyperliquid spot orders must be >10 USDH -// move USDC from HyperEVM to HyperCore (skip if already on HC) -const bridge = await kit.bridgeToCore({ asset: 'USDC', amount }) +const quote = await kit.getQuote({ from: 'USDH', to: 'USDC', amount }) +console.log(`would receive ~${quote.estimatedReceived} USDC`) -// swap on HyperCore via IOC limit at mid + slippageBps -const result = await kit.swap({ from: 'USDC', amount }) -console.log(`got ${result.received} USDH for ${result.spent} USDC`) -console.log(`realised slippage: ${result.slippageBps}bps`) - -// reverse direction on HyperCore -const reverse = await kit.swap({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) -console.log(`got ${reverse.received} USDC`) - -// or let the SDK route, bridge if needed, then swap -const routed = await kit.bridgeAndSwap({ - from: 'USDC', - amount, - onProgress: (event) => console.log(event.phase), -}) -console.log(`route: ${routed.route.sourceChain}`) -console.log(`order: ${routed.swap.orderId}`) +const result = await kit.swap({ from: 'USDH', to: 'USDC', amount }) +console.log(`got ${result.received} USDC for ${result.spent} USDH`) ``` -`swap()` submits an IOC limit order priced from the current mid: `USDC -> USDH` buys up to `mid + slippageBps`, while `USDH -> USDC` sells down to `mid - slippageBps`. The returned `result.slippageBps` is the realised slippage versus mid. - -## Widget quickstart +`swap()` submits an IOC limit order priced from the current mid. The sunset path +sells `USDH -> USDC` down to `mid - slippageBps`. Legacy `USDC -> USDH` +acquisition remains in the package for existing integrators, but it is no longer +the migration path. -The widget reads the connected wallet from wagmi. Wrap your tree in `WagmiProvider` and `QueryClientProvider` (e.g. via ConnectKit or RainbowKit), import the stylesheet once at your app root, then drop the component in. +## Packages -```tsx -// app/layout.tsx (Next.js) -import '@usdh-kit/widget/styles.css' - -// app/page.tsx -import { USDHSwap } from '@usdh-kit/widget' - -export default function Page() { - return -} +```sh +pnpm add @usdh-kit/sdk +pnpm add @usdh-kit/widget wagmi viem @tanstack/react-query react react-dom ``` -The widget defaults to `theme="auto"` (follows the user's system). Force a palette with `` or ``. Override any colour token from your own stylesheet — see [docs/theming.md](./docs/theming.md). - -On first use, the widget asks the connected wallet to approve a short-lived Hyperliquid trading session. That agent signs only the HyperCore USDH order. If the route starts on HyperEVM, the connected wallet still submits the USDC approval/deposit transactions required for the Circle bridge. - -USDH swap widget dark mode - -## Use cases +## Archive Contents -A few real flows the SDK is shaped for today. Runnable examples are still on the roadmap; until they land, treat these as the integration targets to copy into your own app. +The repo also preserves the original USDH work: -- **End-to-end CLI** — bridge + quote + swap from a private key on the command line. Smallest possible integration. -- **Discord bot** — slash command that quotes `USDC → USDH`, confirms the route, then calls `bridgeAndSwap()` with progress updates. -- **Subscription billing** — collect or rebalance USDC into USDH from a merchant wallet after a payment webhook. -- **Treasury rebalance** — scheduled job that converts a fraction of HyperCore USDC above a floor into USDH. Designed for cron. +- HyperCore/HyperEVM balance and bridge helpers; +- USDH spot pair discovery; +- Hyperliquid agent-wallet signing; +- legacy `USDC -> USDH` swap and bridge flows; +- experimental read-only HIP-4/outcome helpers; +- a demo registry for migration and historical component references. -## Features (V1) - -- `USDC → USDH` quote and swap via the canonical HL spot pair -- `USDH → USDC` reverse swap on HyperCore -- HyperEVM → HyperCore bridge with credit polling (`bridgeToCore`) -- HyperCore → HyperEVM bridge-out for linked USDC/USDH spot assets (`bridgeFromCore`) -- HyperCore balance, route/preflight helpers plus `bridgeAndSwap()` orchestration -- Experimental read-only outcome market metadata, books, and mids -- USDH-only spot order helpers for placing, cancelling, and reading USDH-pair orders -- Wallet-agnostic `Signer` interface (works with viem, ethers, Privy, Turnkey, raw private key) -- Approved Hyperliquid agent wallet flow for browser apps (`approveAgent`, `accountAddress`) -- Read-only `InfoClient` (spotMeta, outcomeMeta, spot clearinghouse state, L2 book, allMids) for consumers building custom UIs -- Typed error hierarchy rooted at `UsdhKitError`, including `BridgeAndSwapError` phase/cause context and `isBridgeAndSwapError()` for orchestration failures -- `friendlyError()` helper to map SDK errors to short, copy-safe strings -- React widget (`@usdh-kit/widget`) with light, dark and auto theming (WCAG AA defaults, CSS variables for integrator overrides) -- npm provenance on every release -- Mainnet and testnet support, no signing on read paths +Those surfaces are kept for compatibility and reference. They are not a roadmap +for turning `usdh-kit` into a USDC, generic spot, or HIP-4 SDK. ## Docs -- [docs/architecture.md](./docs/architecture.md) — what the SDK does under the hood, in the order it does it (msgpack, signing, bridge polling, error model). -- [docs/agent-wallets.md](./docs/agent-wallets.md) — secure signing patterns for builders: backend agent, browser trading session, externally managed signer. -- [docs/bridge-and-swap.md](./docs/bridge-and-swap.md) — the full USDC HyperEVM → USDH HyperCore flow, including wallet prompts and recovery. -- [docs/glossary.md](./docs/glossary.md) — Hyperliquid terms used across the SDK and widget (HyperEVM vs HyperCore, IOC, system address, weiDecimals, …). -- [docs/theming.md](./docs/theming.md) — widget CSS variable list, override patterns, SSR-flash mitigation, Tailwind setup. -- [docs/troubleshooting.md](./docs/troubleshooting.md) — common errors with concrete fixes (`MissingEvmWalletError`, `BridgeTimeoutError`, "borders render bright white", …). +- [docs/README.md](./docs/README.md) - docs index +- [docs/architecture.md](./docs/architecture.md) - SDK internals and legacy architecture +- [docs/agent-wallets.md](./docs/agent-wallets.md) - secure signing patterns +- [docs/bridge-and-swap.md](./docs/bridge-and-swap.md) - legacy acquisition flow and migration notes +- [docs/glossary.md](./docs/glossary.md) - Hyperliquid terms used in the repo +- [docs/roadmap.md](./docs/roadmap.md) - sunset roadmap and non-goals +- [docs/theming.md](./docs/theming.md) - widget CSS variables and theming +- [docs/troubleshooting.md](./docs/troubleshooting.md) - common errors and recovery notes -## Runtime support +## Runtime Support -- Node.js >= 18.18 (native `fetch`, `AbortController`, `bigint`) +- Node.js >= 18.18 - Bun >= 1.1 -- Modern evergreen browsers (Chrome >= 107, Safari >= 16, Firefox >= 104) -- Edge runtimes (Cloudflare Workers, Vercel Edge) - -Consumers targeting older environments must downlevel via their bundler. - -## Why USDH - -Hyperliquid's primary stable was USDC, bridged from Arbitrum. USDH is native, fully reserved (cash plus US Treasuries), and routes 50% of reserve revenue to the Assistance Fund instead of an issuer. Apps that hold or pay out stables on Hyperliquid have a reason to prefer USDH; this SDK removes the friction. +- Modern evergreen browsers +- Edge runtimes with `fetch`, `AbortController`, and `bigint` ## Contributing -See [`CONTRIBUTING.md`](./CONTRIBUTING.md). Security disclosures: [`SECURITY.md`](./SECURITY.md). +See [`CONTRIBUTING.md`](./CONTRIBUTING.md). Security disclosures: +[`SECURITY.md`](./SECURITY.md). ## License diff --git a/apps/demo/next-env.d.ts b/apps/demo/next-env.d.ts index 1b3be08..830fb59 100644 --- a/apps/demo/next-env.d.ts +++ b/apps/demo/next-env.d.ts @@ -1,5 +1,6 @@ /// /// +/// // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/demo/src/app/layout.tsx b/apps/demo/src/app/layout.tsx index a9bab4d..6102f3c 100644 --- a/apps/demo/src/app/layout.tsx +++ b/apps/demo/src/app/layout.tsx @@ -9,7 +9,7 @@ import '../../../../packages/widget/src/styles.css' export const metadata: Metadata = { title: 'usdh-kit demo', - description: 'Swap stables into USDH on Hyperliquid, end-to-end demo', + description: 'Migrate remaining USDH back to USDC on Hyperliquid.', } export const viewport: Viewport = { diff --git a/apps/demo/src/app/page.tsx b/apps/demo/src/app/page.tsx index cb78b94..35a4db4 100644 --- a/apps/demo/src/app/page.tsx +++ b/apps/demo/src/app/page.tsx @@ -11,7 +11,7 @@ export default function Home() {

- Swap stables into USDH on Hyperliquid. + Migrate remaining USDH back to USDC on Hyperliquid.

diff --git a/apps/demo/src/components/ComponentDocsShell.tsx b/apps/demo/src/components/ComponentDocsShell.tsx index cb69892..faf0af7 100644 --- a/apps/demo/src/components/ComponentDocsShell.tsx +++ b/apps/demo/src/components/ComponentDocsShell.tsx @@ -48,7 +48,7 @@ export function ComponentDocsShell({ activeSlug, children }: ComponentDocsShellP Components - USDH surfaces for builder apps. + USDH sunset migration surfaces.
@@ -103,7 +103,7 @@ export function ComponentDocsShell({ activeSlug, children }: ComponentDocsShellP Registry

- Copyable USDH product patterns. + USDH sunset migration and legacy references.

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 (

- USDH components for app builders. + USDH sunset migration kit.

- Copyable USDH and HIP-4 patterns with preview, code, contract, and examples. + A migration widget for remaining USDH balances, with the legacy swap kept only for + compatibility.

diff --git a/apps/demo/src/components/SwapSection.tsx b/apps/demo/src/components/SwapSection.tsx index 0d69c39..5c29540 100644 --- a/apps/demo/src/components/SwapSection.tsx +++ b/apps/demo/src/components/SwapSection.tsx @@ -1,11 +1,11 @@ 'use client' -import { USDHSwap } from '@usdh-kit/widget' +import { USDHMigration } from '@usdh-kit/widget' export function SwapSection() { return (
- +
) } diff --git a/apps/demo/src/components/registry/previews/market-board.tsx b/apps/demo/src/components/registry/previews/market-board.tsx index 385dd92..15c7d55 100644 --- a/apps/demo/src/components/registry/previews/market-board.tsx +++ b/apps/demo/src/components/registry/previews/market-board.tsx @@ -108,7 +108,7 @@ function QuoteSummary({
Quote summary

- Read-only context for a USDC to USDH quote. + Read-only context for a USDH to USDC migration quote.

diff --git a/apps/demo/src/components/registry/previews/widget.tsx b/apps/demo/src/components/registry/previews/widget.tsx index f589036..6fa9f80 100644 --- a/apps/demo/src/components/registry/previews/widget.tsx +++ b/apps/demo/src/components/registry/previews/widget.tsx @@ -1,8 +1,8 @@ 'use client' import { type L2Book, createQuoteSummaryData } from '@usdh-kit/sdk' -import { USDHSwap } from '@usdh-kit/widget' -import { CheckCircle2, LockKeyhole, WalletCards } from 'lucide-react' +import { USDHMigration, USDHSwap } from '@usdh-kit/widget' +import { ArrowLeftRight, CheckCircle2, LockKeyhole, WalletCards } from 'lucide-react' import { CardContent } from '@/components/ui/card' import { cn } from '@/lib/utils' @@ -48,7 +48,7 @@ export function UsdhWidgetPreview({ Swap module

- Packaged USDHSwap with read-only quote before connect. + Legacy USDHSwap preserved for historical integrations.

@@ -80,6 +80,48 @@ export function UsdhWidgetPreview({ ) } +export function UsdhMigrationPreview({ size }: { size: PreviewSize }) { + 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-recipes.ts b/apps/demo/src/lib/component-recipes.ts index 2a769de..23ee681 100644 --- a/apps/demo/src/lib/component-recipes.ts +++ b/apps/demo/src/lib/component-recipes.ts @@ -16,7 +16,7 @@ export function getComponentRecipes(slug: ComponentSlug): ComponentRecipe[] { title: 'Drop in the swap entry', description: 'Use the packaged widget as the only write boundary.', steps: [ - 'Mount USDHSwap inside your wallet route.', + 'Mount USDHMigration inside your wallet route.', 'Keep quote context around it read-only.', 'Handle completion and analytics in the parent app.', ], diff --git a/apps/demo/src/lib/component-registry.ts b/apps/demo/src/lib/component-registry.ts index 1b45042..fcc3858 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' @@ -85,12 +87,12 @@ const sdkInstall = 'pnpm add @usdh-kit/sdk' export const componentEntries: ComponentEntry[] = [ { slug: 'usdh-widget', - title: 'USDH Widget', + title: 'USDH Swap Widget', shortTitle: 'Widget', - eyebrow: 'Drop-in swap', + eyebrow: 'Legacy swap', category: 'Widget', description: - 'The packaged swap component for USDC to USDH flows, composed with pre-wallet quote context.', + 'The packaged legacy swap component for historical USDC to USDH flows, kept only for compatibility.', icon: WalletCards, tags: ['widget', 'swap', 'usdc', 'usdh', 'wallet'], liveCapable: true, @@ -133,16 +135,17 @@ export function SwapCard({ context }) { }, ], useCase: { - usedFor: 'Wallet swap entry, app dashboard modules, and onboarding flows.', + usedFor: 'Legacy USDH acquisition flows and historical integration reference.', reads: 'Pair, best ask, spread, receive estimate, and quote readiness before connect.', - doesNot: 'Change the widget API, submit swaps, or unlock writes before wallet/session state.', + doesNot: + 'Represent the migration path, change the widget API, or unlock writes before wallet/session state.', }, usage: { title: 'Pre-wallet swap entry', language: 'tsx', code: `export function SwapEntry({ quoteContext }) { return ( -
+
@@ -150,7 +153,54 @@ 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.', + composition: + 'Keep USDHSwap as a legacy write boundary; prefer USDHMigration for the sunset path.', + }, + { + slug: 'usdh-migration', + title: 'USDH Migration', + shortTitle: 'Migration', + eyebrow: 'Drop-in exit', + category: 'Widget', + description: + 'The primary sunset component: convert a HyperCore USDH balance back to USDC with wallet-gated writes.', + 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, fake receive estimates, 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', @@ -1062,26 +1112,18 @@ if (walletConnected && writesEnabled && draft.placeOrderInput) { }, ] -export const visibleComponentEntries = componentEntries.filter((entry) => entry.visible) +const visibleEntryOrder = new Map( + ['usdh-migration', 'usdh-widget'].map((slug, index) => [slug as ComponentSlug, index]), +) + +export const visibleComponentEntries = componentEntries + .filter((entry) => entry.visible && visibleEntryOrder.has(entry.slug)) + .sort((a, b) => (visibleEntryOrder.get(a.slug) ?? 999) - (visibleEntryOrder.get(b.slug) ?? 999)) export const componentSections: ComponentSection[] = [ { - title: 'USDH', - items: ['usdh-widget', 'market-board', 'quote-readiness'], - }, - { - title: 'HIP-4', - items: [ - 'outcome-reads', - 'outcome-market-row', - 'outcome-odds-selector', - 'outcome-order-book', - 'outcome-position-row', - ], - }, - { - title: 'Trading', - items: ['order-ticket-mock'], + title: 'Migration', + items: ['usdh-migration', 'usdh-widget'], }, ] diff --git a/apps/demo/src/lib/component-sdk-reads.ts b/apps/demo/src/lib/component-sdk-reads.ts index 0ea66e1..974b0fe 100644 --- a/apps/demo/src/lib/component-sdk-reads.ts +++ b/apps/demo/src/lib/component-sdk-reads.ts @@ -13,7 +13,7 @@ export function getComponentSdkReads(slug: ComponentSlug): ComponentSdkRead[] { return [ { rawRead: - 'USDHSwap owns its packaged quote flow; companion context can read spotMeta + l2Book.', + 'USDHSwap owns its legacy quote flow; companion context can read spotMeta + l2Book.', adapter: 'createQuoteSummaryData from @usdh-kit/sdk for companion read context.', produces: 'pair, best ask, spread, receive estimate', parentOwns: 'wallet provider, page placement, analytics, and surrounding cache', diff --git a/apps/demo/src/lib/wagmi.ts b/apps/demo/src/lib/wagmi.ts index f532f8a..21d512f 100644 --- a/apps/demo/src/lib/wagmi.ts +++ b/apps/demo/src/lib/wagmi.ts @@ -15,7 +15,7 @@ export const wagmiConfig = createConfig( }, walletConnectProjectId: WALLETCONNECT_PROJECT_ID, appName: 'usdh-kit demo', - appDescription: 'Swap stables into USDH on Hyperliquid', + appDescription: 'Migrate remaining USDH back to USDC on Hyperliquid', appUrl: 'https://github.com/sumfxn/usdh-kit', }), ) diff --git a/docs/README.md b/docs/README.md index 292a9e1..9bf41b8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,22 +1,23 @@ # usdh-kit -TypeScript SDK and React widget for USDH on Hyperliquid. +USDH sunset migration kit for Hyperliquid. -`usdh-kit` helps apps work with USDH without reimplementing Hyperliquid spot discovery, EIP-712 order signing, HyperEVM bridge transactions, or bridge-credit polling. +`usdh-kit` helps apps migrate remaining HyperCore USDH balances back to USDC. +The original SDK, widget, and HIP-4 work stays in the repo as legacy reference +code, not as an active product roadmap. ## Packages | Package | Purpose | |---|---| -| `@usdh-kit/sdk` | Quote, route, bridge, and swap USDH-focused flows. | -| `@usdh-kit/widget` | Embeddable React swap widget built on the SDK. | +| `@usdh-kit/sdk` | USDH quote, route, bridge, signing, and migration helpers kept for sunset support. | +| `@usdh-kit/widget` | Embeddable React migration widget plus the legacy swap widget. | ## What works today -* Quote and swap `USDC -> USDH` and `USDH -> USDC` on the canonical Hyperliquid spot pair. -* Route from existing HyperCore USDC when available. -* Bridge USDC from HyperEVM to HyperCore, wait for credit, then swap. -* Bridge linked USDC/USDH spot assets from HyperCore back to HyperEVM. +* Migrate `USDH -> USDC` on the canonical Hyperliquid spot pair. +* Keep legacy `USDC -> USDH` quote and swap support for existing integrations. +* Preserve bridge, discovery, and HIP-4 read-only helpers as archive/reference surfaces. * Use approved Hyperliquid agent wallets so browser apps do not ask Rabby or other injected wallets to sign L1 order payloads directly. * Display HyperEVM and HyperCore balances for USDC and USDH in the widget. @@ -38,11 +39,11 @@ only when your app imports SDK APIs directly. ## Minimal widget ```tsx -import { USDHSwap } from '@usdh-kit/widget' +import { USDHMigration } from '@usdh-kit/widget' import '@usdh-kit/widget/styles.css' export default function Page() { - return + return } ``` @@ -67,17 +68,17 @@ const kit = createUsdhKit({ slippageBps: 30, }) -const result = await kit.bridgeAndSwap({ - from: 'USDC', +const result = await kit.swap({ + from: 'USDH', + to: 'USDC', amount: 11_000_000n, - onProgress: (event) => console.log(event.phase), }) -console.log(result.swap.orderId) +console.log(result.orderId) ``` ## Read next -* [Bridge and swap flow](bridge-and-swap.md) explains the full user journey and Rabby prompts. +* [Bridge and swap flow](bridge-and-swap.md) explains the legacy acquisition journey and Rabby prompts. * [Agent wallets](agent-wallets.md) explains secure signing patterns for builders. * [Architecture](architecture.md) documents the SDK internals. diff --git a/docs/agent-wallets.md b/docs/agent-wallets.md index d5d9186..a851c77 100644 --- a/docs/agent-wallets.md +++ b/docs/agent-wallets.md @@ -9,7 +9,7 @@ Some browser wallets reject or mis-handle that direct order-signing flow. The pr | Role | What it does | |---|---| | Master wallet | Owns funds, signs agent approval, sends HyperEVM bridge transactions. | -| Agent wallet | Signs Hyperliquid L1 orders such as `USDC -> USDH`. | +| Agent wallet | Signs Hyperliquid L1 orders such as the sunset `USDH -> USDC` migration swap. | | `accountAddress` | Tells the SDK which master account to read for balances, routes, and bridge ownership. | The SDK never treats the agent as the funded account when `accountAddress` is set. diff --git a/docs/architecture.md b/docs/architecture.md index d35a3e6..b934533 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,6 +2,10 @@ What `@usdh-kit/sdk` actually does under the hood, in the order it does it. +Status: this architecture is preserved for USDH sunset support and historical +reference. The primary current path is `USDH -> USDC` migration; `USDC -> USDH` +flows remain documented as legacy acquisition support. + ## Layout ``` diff --git a/docs/bridge-and-swap.md b/docs/bridge-and-swap.md index eb1d9b5..30cb8a8 100644 --- a/docs/bridge-and-swap.md +++ b/docs/bridge-and-swap.md @@ -1,8 +1,8 @@ # Bridge and swap flow -The main retail path is `USDC HyperEVM -> USDH HyperCore`. +This is the legacy acquisition flow for historical `USDC HyperEVM -> USDH HyperCore` integrations. The current sunset path is `USDH -> USDC` migration, usually through `USDHMigration` or `kit.swap({ from: 'USDH', to: 'USDC' })`. -`bridgeAndSwap()` handles the full flow: +`bridgeAndSwap()` still handles the full legacy flow: 1. quote the `USDH/USDC` spot pair 2. inspect spendable HyperCore USDC diff --git a/docs/glossary.md b/docs/glossary.md index 1962a3b..99e6f55 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -28,9 +28,9 @@ Hyperliquid-specific terms used across `@usdh-kit/sdk` and `@usdh-kit/widget`. ## Stablecoins -**USDH** — Hyperliquid's native stablecoin, issued by Bridge and designed by Native Markets. Backed by cash + US Treasuries. 50% of reserve revenue routes to the Hyperliquid Assistance Fund. +**USDH** — Hyperliquid stablecoin issued by Bridge and designed by Native Markets. USDH is being sunset in favour of USDC, so current `usdh-kit` work is focused on migration and historical reference. -**USDC (HyperEVM)** — Circle's USDC bridged onto HyperEVM. The default source asset for `@usdh-kit/sdk`. +**USDC (HyperEVM)** — Circle's USDC bridged onto HyperEVM. It was the default source asset for the legacy `USDC -> USDH` acquisition flow. **USDT** — Tether's USDT. Pricing and swap support are deferred (USDT/USDC/USDH double-hop). diff --git a/docs/roadmap.md b/docs/roadmap.md index 628c0e9..0e895d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,306 +1,70 @@ # Roadmap -> Status: living plan. Tracks 1-4 have landed. Track 5 HyperEVM direct swap is -> paused as a later spike until liquidity, routing, allowance, and failure modes -> are validated. Release remains intentionally gated until live testnet/IRL -> validation and generated release output are reviewed. -> Direction: keep `usdh-kit` centered on USDH, but expand from "obtain USDH via -> USDC" to "interact cleanly with USDH surfaces on Hyperliquid". +> Status: sunset plan. `usdh-kit` stays focused on USDH migration and +> maintenance. It should not become a generic USDC, spot, or HIP-4 SDK. +> Release remains gated on review, CI, and explicit approval. ## TL;DR -`usdh-kit` should not become a generic Hyperliquid SDK. It should expose the -small set of primitives that make USDH useful: +The repo now has two jobs: -1. discover USDH markets and USDH-denominated surfaces -2. work with USDH outcomes first, if markets are natively `/USDH` -3. trade USDH markets through a focused order API -4. keep the current USDC -> USDH acquisition flow simple -5. treat HyperEVM direct swaps as a separate spike until liquidity/routing is - validated +1. Help users migrate remaining HyperCore USDH back to USDC. +2. Preserve the SDK/widget/demo work as open-source legacy reference material. -## Current SDK baseline +Future HIP-4 SDK or UI tooling should live in a separate repo/package with a +clean name and API. `usdh-kit` should not absorb that future product surface. -What already works: - -- `USDC -> USDH` quote and swap on HyperCore -- HyperEVM -> HyperCore bridge for USDC -- HyperCore balance reads and route/preflight helpers -- `bridgeAndSwap()` for route -> optional bridge -> swap -- USDH spot discovery with `listPairs`, `getPair`, `getBook`, and `getMids` -- experimental read-only outcome discovery with `listOutcomeMarkets`, - `getOutcomeMarket`, `getOutcomeBook`, and `getOutcomeMids` -- USDH-only spot orders with `placeOrder`, `cancelOrder`, `getOpenOrders`, and - `getOrderStatus` -- `InfoClient` reads for `spotMeta`, `outcomeMeta`, - `spotClearinghouseState`, `l2Book`, `allMids`, `frontendOpenOrders`, and - `orderStatus` -- typed lifecycle errors, including `BridgeAndSwapError` and - `isBridgeAndSwapError()` -- React widget on top of the SDK - -- `USDH -> USDC` HyperCore reverse swaps -- `bridgeFromCore()` for linked USDC/USDH spot assets moving from HyperCore to - HyperEVM - -This remains the core retail path. New roadmap items should preserve that simple -path instead of forcing integrators into a broader trading abstraction. - -## Track 1 - Discovery USDH - -Owner: @Yaugourt - -Status: landed for spot market discovery in PR #49. HIP-3 remains watchlist, -and outcomes continue as the separate Track 2 surface. - -Expose the markets and surfaces related to USDH. Start with spot markets, keep -outcomes clearly in scope, and keep HIP-3 as experimental/watchlist until the -shape is validated. - -### Goals - -- Replace one-off `USDH/USDC` pair lookup with USDH-aware discovery. -- Let consumers list USDH spot markets without hand-parsing Hyperliquid metadata. -- Preserve explicit orientation: USDH can be base or quote. -- Return enough metadata for UI, quoting, and later order placement. -- Avoid promising generic Hyperliquid market discovery. - -### Proposed API - -```ts -kit.listPairs({ quote?: 'USDH', kind?: 'spot' }): Promise -kit.getPair({ base, quote, kind?: 'spot' }): Promise -kit.getBook(pair: string, opts?: { nSigFigs?: NSigFigs }): Promise -kit.getMids(opts?: { quote?: 'USDH' }): Promise> -``` - -Types should make orientation explicit: - -```ts -type UsdhPair = { - kind: 'spot' - name: string - base: string - quote: string - usdhRole: 'base' | 'quote' - index: number - tokens: [number, number] -} -``` - -### Scope - -- Shipped: - - spot pairs where USDH is base or quote - - book/mid helpers for those pairs - - caching by pair name or token tuple - - testnet/mainnet token-index handling behind existing network config -- Watchlist: - - HIP-3 USDH-denominated markets - - outcome write support, only after denomination/settlement behavior is - verified -- Out of scope: - - generic pair discovery for all assets - - generic perps SDK - - routing across arbitrary token graphs - -## Track 2 - Outcomes USDH - -Owner: @sumfxn - -Status: landed as PR #51. The API is read-only and experimental: metadata, side -encoding helpers, books, and mids only. It does not add outcome orders, -cancellations, or settlement/denomination claims. - -Prioritize this if outcomes are natively denominated in USDH. This is a stronger -USDH use case than generic perps because it creates direct demand for USDH as the -settlement/quote asset. - -### Goals - -- Discover USDH-denominated outcome markets. -- Read outcome books and mids with the same ergonomics as spot. -- Make outcome support clearly experimental until tested against live/testnet - markets. -- Keep the outcome API narrow and product-shaped. - -### Proposed API - -```ts -kit.listOutcomeMarkets(): Promise -kit.getOutcomeMarket({ outcome }): Promise -kit.getOutcomeBook({ outcome, side, nSigFigs? }): Promise -kit.getOutcomeMids(): Promise> -``` - -Possible later write path: - -```ts -kit.placeOutcomeOrder({ - market, - side, - price, - size, - tif, -}): Promise -``` - -### Spike findings - -- `outcomeMeta` exposes a separate outcome namespace from spot pairs. -- Side coins use encoded `#...` names derived from outcome id and binary side. -- Live read-only probes validate `outcomeMeta`, `l2Book`, and outcome mids on - mainnet and testnet. -- USDH settlement/denomination remains unclaimed until verified separately. -- Write support remains out of scope. +## Current Baseline -## Track 3 - Targeted USDH trading - -Owner: @Yaugourt - -Status: landed as PR #54. The final API stays USDH-scoped while accepting both -live Hyperliquid pair names such as `@230` and ergonomic token aliases such as -`USDH/USDC`. - -Build only the trading primitives needed for USDH markets: - -```ts -kit.placeOrder({ pair, side, size, price?, tif?, slippageBps? }) -kit.cancelOrder({ pair, oid }) -kit.getOpenOrders({ pair? }) -kit.getOrderStatus({ pair, oid }) -``` - -This should be a focused USDH-market order layer, not a full Hyperliquid SDK. -`pair` accepts the live `listPairs()` name such as `@230` and ergonomic token -aliases such as `USDH/USDC` or `HYPE/USDH`; reads remain filtered to USDH-bearing -spot pairs. - -### Scope - -- In scope: - - `placeOrder` - - `cancelOrder` - - `getOpenOrders` - - `getOrderStatus` - - shared order formatting/signing reused by `swap()` - - typed order errors and `friendlyError()` mappings -- Later: - - modify order - - batch helpers - - vault/subaccount support - - agent wallets - - TWAP/dead-man switch - -`swap()` should remain a high-level convenience wrapper, not be replaced by a -lower-level order API in docs. - -## Track 4 - Useful USDH flows - -Status: landed in PR #56. Reverse swap and bridge-out are implemented in the SDK -with conservative constraints. No live write-path bridge/swap test has been run -yet, so release remains gated on IRL validation. - -Keep the UX simple: - -- `USDC -> USDH` remains the core path -- add `USDH -> USDC` -- add `bridgeFromCore` -- avoid arbitrary multi-hop routing for now - -### Proposed additions - -```ts -kit.swap({ from: 'USDH', to: 'USDC', amount, ... }) -kit.bridgeFromCore({ asset: 'USDC' | 'USDH', amount }) -``` - -Implementation notes from PR #56: - -- `USDH -> USDC` is HyperCore-only. HyperEVM direct swap stays a separate - Track 5 spike. -- `bridgeFromCore()` uses Hyperliquid `sendAsset` to the token system address. -- The HyperEVM recipient is the Core action sender, so v1 does not expose an - arbitrary `recipient`. -- Approved agent wallets cannot bridge funds out for a master account; - `signer.address` must match `accountAddress`. -- Live validation for #56 is read-only only: mainnet/testnet `spotMeta`, - `l2Book`, SDK `getQuote()`, and SDK `getRoute()`. - -Multi-hop via arbitrary intermediate assets should stay out of scope until there -is a clear product need and enough tests to make route selection safe. - -## Track 5 - HyperEVM direct swap - -Treat as a separate spike. - -Before promising this in public API, validate: - -- USDH liquidity on HyperEVM -- which DEX/router to integrate first -- quote accuracy -- slippage and `minOut` behavior -- allowance flow -- gas and failure modes +What already works: -Possible future shape: +- `USDH -> USDC` HyperCore migration swaps. +- `USDHMigration`, a wallet-gated React migration widget. +- Legacy `USDC -> USDH` quote/swap and `bridgeAndSwap()` flows for historical integrations. +- HyperEVM -> HyperCore bridge for USDC. +- HyperCore -> HyperEVM bridge-out for linked USDC/USDH spot assets. +- USDH spot discovery with `listPairs`, `getPair`, `getBook`, and `getMids`. +- Experimental read-only outcome discovery with `listOutcomeMarkets`, + `getOutcomeMarket`, `getOutcomeBook`, and `getOutcomeMids`. +- USDH-scoped spot orders with `placeOrder`, `cancelOrder`, `getOpenOrders`, and + `getOrderStatus`. +- Demo registry patterns for migration UX and archived HIP-4 read-only references. -```ts -kit.evmQuote({ from, to, amount }): Promise -kit.evmSwap({ from, to, amount, minOut?, recipient?, deadline? }): Promise -``` +## Release Gate -Do not block shipped tracks on this. +A final release, if approved, should be explicitly framed as a sunset/migration +release: -## Landed Split +- Promote `USDHMigration` as the primary widget. +- Keep `USDHSwap` available but documented as legacy/historical. +- Do not publish a generic spot SDK. +- Do not publish React HIP-4 components from this repo. +- Do not add new USDH acquisition roadmap items. +- Keep changesets intentional and review package output before publish. -The initial SDK expansion landed as four focused PRs: +## HIP-4 Direction -1. @Yaugourt: Track 1, Discovery USDH - - spot USDH market discovery first - - book/mid helpers - - API and tests only for confirmed metadata shape - - leave hooks/types clean enough for outcomes, but do not implement outcomes - in the same PR +The HIP-4 work in this repo is useful as prior art and reference material: -2. @sumfxn: Track 2, Outcomes USDH - - inspect real outcome metadata/API shape - - land a read-only experimental API if the shape is stable enough - - document any unknowns before write support +- outcome market reads; +- side coin decoding; +- book summaries; +- builder-oriented UI patterns in `apps/demo`. -3. @Yaugourt: Track 3, Targeted USDH trading - - spot order placement and cancellation for USDH-bearing pairs - - USDH-filtered open orders and order status reads - - live pair names plus token-pair aliases - - no generic Hyperliquid account/order surface +It is not the final HIP-4 product surface. If the team proceeds with HIP-4 +tooling, start a new repo/package and decide the public boundaries there: -4. @sumfxn: Track 4, Useful USDH flows - - `USDH -> USDC` reverse swap on HyperCore - - `bridgeFromCore()` for linked USDC/USDH spot assets - - no arbitrary bridge-out recipient - - no HyperEVM direct swap or arbitrary multi-hop routing +- headless SDK helpers; +- optional hooks package; +- no bundled visual design system unless explicitly scoped; +- clear write/read boundaries; +- live market fixtures and docs from day one. ## Non-goals -- Becoming a generic Hyperliquid SDK -- Generic perps support -- Arbitrary routing/multi-hop -- HyperEVM direct swap before liquidity and router validation -- Broad agent/vault support before the USDH-specific API is settled - -## Decisions And Open Questions - -Resolved decisions: - -1. `listPairs()` is spot-only and USDH-scoped. -2. Outcomes use separate `listOutcomeMarkets()` style APIs. -3. Track 3 supports only USDH-bearing spot pairs, not generic Hyperliquid - trading. -4. `bridgeFromCore()` v1 is sender-owned only; no arbitrary recipient. - -Open questions: - -1. Which API should expose HIP-3 USDH markets, if any? Proposed: experimental - watchlist after spot/outcomes. -2. Which examples should become first-class maintained demos before the next - release? +- Becoming a generic Hyperliquid SDK. +- Becoming a generic USDC spot SDK. +- Expanding `@usdh-kit/widget` into a broad widget suite. +- Publishing registry/demo UI components as package API. +- Adding new USDH acquisition features while USDH is sunset. +- Merging the USDC-canonical spike as-is. diff --git a/docs/theming.md b/docs/theming.md index 233b00c..4cb8989 100644 --- a/docs/theming.md +++ b/docs/theming.md @@ -5,9 +5,9 @@ ## The `theme` prop ```tsx - // default — follows prefers-color-scheme - // force dark - // force light + // default - follows prefers-color-scheme + // force dark + // force light ``` `auto` listens to the system preference via `matchMedia('(prefers-color-scheme: dark)')` and re-renders when it changes. SSR renders dark by default to match the most common DeFi expectation; the client effect corrects to light if the system says so. This causes a one-frame flash for light-mode auto users; the standard fix is to read a server-side cookie and pass it as `theme` from the parent (see Next.js example below). @@ -62,19 +62,19 @@ Override any token in your own stylesheet, loaded **after** the widget's stylesh } ``` -You don't need to override every token — unset ones inherit from the defaults. +You don't need to override every token - unset ones inherit from the defaults. ## Loading the stylesheet The widget's compiled stylesheet is exposed at `@usdh-kit/widget/styles.css`. Import it once at your application root: ```ts -// Next.js — app/layout.tsx +// Next.js - app/layout.tsx import '@usdh-kit/widget/styles.css' ``` ```ts -// Vite — main.tsx +// Vite - main.tsx import '@usdh-kit/widget/styles.css' ``` @@ -91,7 +91,7 @@ import { cookies } from 'next/headers' const themeCookie = cookies().get('usdh-theme')?.value as 'dark' | 'light' | undefined const theme = themeCookie ?? 'auto' -return +return ``` Then on the client, write the cookie when the resolved theme is known: @@ -108,7 +108,7 @@ function ThemeCookie({ theme }: { theme: 'dark' | 'light' | 'auto' }) { } ``` -`useEffectiveTheme(theme)` is the same hook the widget uses internally — you can also call it standalone if you're building custom UI with the SDK and want auto-detection without rendering the widget. +`useEffectiveTheme(theme)` is the same hook the widget uses internally - you can also call it standalone if you're building custom UI with the SDK and want auto-detection without rendering the widget. ## Tailwind setup diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 19ea012..9c98ed3 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -153,9 +153,9 @@ Without this, every `var(--usdh-*)` falls back to `currentColor`. See [theming]( Default `theme="auto"` follows `prefers-color-scheme`. If your app's page background is hardcoded dark while your OS is in light mode, you'll see a light widget on a dark page. Either: - Make your page background follow `prefers-color-scheme` too (recommended), or -- Force the widget with `` +- Force the widget with `` -### SSR flash light → dark on first paint +### SSR flash light -> dark on first paint Standard `prefers-color-scheme` tradeoff. See the cookie-based fix in [theming](./theming.md#avoiding-the-ssr-flash). @@ -163,7 +163,7 @@ Standard `prefers-color-scheme` tradeoff. See the cookie-based fix in [theming]( ### Quote refreshes feel slow -`getQuote` debounces by 400ms in the widget. If you're calling the SDK directly, you control the cadence — `getQuote` is a single `/info` round-trip with no signing. +`getQuote` debounces by 400ms in the widget. If you're calling the SDK directly, you control the cadence - `getQuote` is a single `/info` round-trip with no signing. ### Bundle size is too large diff --git a/packages/sdk/README.md b/packages/sdk/README.md index b60144a..5da49ff 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -6,29 +6,35 @@ **Current SDK version:** `0.3.0` -TypeScript SDK for USDH on Hyperliquid. +TypeScript SDK for USDH sunset support on Hyperliquid. -USDH is the native stablecoin on Hyperliquid, issued by Bridge and designed by Native Markets, with 50% of reserve revenue routed to the Hyperliquid Assistance Fund. `@usdh-kit/sdk` ships the retail-side plumbing (pair resolution, signing, transport) so apps and bots can convert into USDH without writing the Hyperliquid action layer themselves. +`@usdh-kit/sdk` now serves migration and maintenance use cases: pair resolution, signing, transport, and order helpers for moving remaining USDH balances back to USDC. The original USDH and HIP-4 work remains available as legacy reference code. -Contributors: [Yaugourt](https://x.com/Yaugourt) · [Sumfxn](https://x.com/sumfxn) +Contributors: [Yaugourt](https://x.com/Yaugourt) / [Sumfxn](https://x.com/sumfxn) ## Status Pre-release. Public API is unstable until `1.0.0`. +USDH sunset status: this package is in maintenance/migration mode. It keeps the +USDH read/write primitives needed for users to migrate back to USDC and preserves +the HIP-4 read-only work as reference code. Future HIP-4 tooling should live in a +separate repo/package rather than turning `usdh-kit` into a generic spot SDK. + What works today: -* `getQuote()` and `swap()` for `USDC → USDH` and `USDH → USDC` end to end (signing + msgpack + IOC limit submission) +* `getQuote()` and `swap()` for the sunset `USDH -> USDC` migration path +* Legacy `USDC -> USDH` quote and swap support for historical integrations * `bridgeToCore()` for moving USDC from HyperEVM to HyperCore, with credit polling * `bridgeFromCore()` for moving linked USDC/USDH spot assets from HyperCore to HyperEVM * `getHypercoreBalance()` for spendable HyperCore balances (`total - hold`) * `getRoute()` / `preflightSwap()` for HyperCore-vs-HyperEVM source selection -* `bridgeAndSwap()` for route → optional bridge → swap orchestration +* Legacy `bridgeAndSwap()` for route -> optional bridge -> swap orchestration * USDH spot market discovery (`listPairs`, `getPair`, `getBook`, `getMids`) * Experimental read-only outcome market metadata, books, and mids * Read-only `InfoClient` (spotMeta, outcomeMeta, spotClearinghouseState, L2 book, allMids) -Deferred to follow-up PRs: USDT pricing/swap and multi-chain source. +No new USDH acquisition roadmap is planned while USDH is sunset. ## Install @@ -39,28 +45,13 @@ pnpm add @usdh-kit/sdk ## Quickstart ```ts -import { BridgeTimeoutError, createUsdhKit, isBridgeAndSwapError } from '@usdh-kit/sdk' +import { createUsdhKit } from '@usdh-kit/sdk' const kit = createUsdhKit({ network: 'mainnet', signer, evmWallet, slippageBps: 30 }) -const amount = 11_000_000n // 11 USDC; Hyperliquid spot orders must be >10 USDC - -try { - const result = await kit.bridgeAndSwap({ - from: 'USDC', - amount, - onProgress: (event) => console.log(event.phase), - }) - - console.log(`got ${result.swap.received} USDH for ${result.swap.spent} USDC`) -} catch (err) { - if (isBridgeAndSwapError(err)) { - console.error(`${err.phase} failed`, err.cause) - if (err.cause instanceof BridgeTimeoutError) { - console.log(`bridge tx ${err.cause.txHash} is still pending HyperCore credit`) - } - } - throw err -} +const amount = 11_000_000n // 11 USDH; Hyperliquid spot orders must be >10 USDH + +const result = await kit.swap({ from: 'USDH', to: 'USDC', amount }) +console.log(`got ${result.received} USDC for ${result.spent} USDH`) ``` `swap()` submits an IOC limit order priced from the book mid plus/minus @@ -103,22 +94,24 @@ Backend and bot builders can provide an already-approved agent signer from a pri `getQuote()` returns a mid-price snapshot with a 30-second validity window. ```ts -const quote = await kit.getQuote({ from: 'USDC', amount: 11_000_000n }) +const quote = await kit.getQuote({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) if (Date.now() < quote.validUntil) { console.log(`mid-price on ${quote.pair}: ${quote.midPrice}`) - console.log(`would receive ~${quote.estimatedReceived} USDH`) + console.log(`would receive ~${quote.estimatedReceived} USDC`) } -const reverse = await kit.getQuote({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) -console.log(`would receive ~${reverse.estimatedReceived} USDC`) +// Legacy acquisition remains supported for existing integrators. +const legacy = await kit.getQuote({ from: 'USDC', amount: 11_000_000n }) +console.log(`would receive ~${legacy.estimatedReceived} USDH`) ``` ## Route and preflight `getHypercoreBalance()` returns total, held, and spendable HyperCore balance for a source stable. `getRoute()` decides whether `USDC -> USDH` can swap directly -from HyperCore or needs to bridge from HyperEVM first. `USDH -> USDC` is -HyperCore-only: bridge USDH to HyperCore first, swap, then call +from HyperCore or needs to bridge from HyperEVM first. This is legacy +acquisition support. The sunset `USDH -> USDC` path is HyperCore-only: bridge +USDH to HyperCore first, swap, then call `bridgeFromCore()` if you need the resulting USDC on HyperEVM. ```ts @@ -201,14 +194,17 @@ const outcomeMids = await kit.getOutcomeMids() console.log(market.name, yesBook.coin, outcomeMids[market.sides[0].coin]) ``` -## Experimental HIP-4 builder helpers +## Archived HIP-4 builder helpers -The SDK also exports public, experimental, headless helpers for app builders. +The SDK also exports public, experimental, headless helpers that were built for +the HIP-4 registry work. They do not render React components; they turn raw SDK reads into UI-ready data contracts for HIP-4 event cards, market rows, side selectors, order books, positions, plus USDH quote guards and order drafts. -These helpers are additive and read-only/draft-only. Until `1.0.0`, treat the +These helpers are additive and read-only/draft-only. They are preserved as +reference material in `usdh-kit`, not as the future HIP-4 product surface. +Until `1.0.0`, treat the exact return shapes as pre-release API, but prefer them over app-local parsing: they centralize side-coin encoding, quote health checks, decimal-safe position math, and signer-ready order draft validation. @@ -325,14 +321,13 @@ Package boundaries: | Layer | Import today | Owns | | --- | --- | --- | -| `@usdh-kit/sdk` | Read clients, USDH spot discovery, HIP-4 metadata, order methods, and builder data helpers. | Typed reads, normalization, checks, and signer-ready input shapes. | -| `@usdh-kit/widget` | The drop-in USDH swap widget. | A packaged swap UI with wallet-gated writes. | -| `apps/demo` registry | Copy/paste React patterns only. | Example component composition, docs, and visual states. | +| `@usdh-kit/sdk` | Migration reads/writes, legacy USDH spot helpers, HIP-4 reference helpers. | Typed reads, normalization, checks, and signer-ready input shapes. | +| `@usdh-kit/widget` | The drop-in USDH migration widget plus legacy swap widget. | Packaged wallet-gated migration UI. | +| `apps/demo` registry | Migration and archived reference patterns only. | Example component composition, docs, and visual states. | | Your app | Your product shell. | Routing, cache policy, wallet/session state, balances, PnL, settlement, and final writes. | -No React hooks or HIP-4 UI package is published in this release. A future -`@usdh-kit/react` package, if added, should stay hooks-only with optional cache -adapters and no bundled visual design system. +No React hooks or HIP-4 UI package is published from this repo. Future HIP-4 +tooling should be designed in a separate repo/package with a clean name and API. ## Trade USDH spot pairs diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 9692380..34937d6 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,7 +1,7 @@ { "name": "@usdh-kit/sdk", "version": "0.3.0", - "description": "TypeScript SDK for USDH on Hyperliquid", + "description": "TypeScript SDK for USDH sunset support on Hyperliquid", "license": "MIT", "author": "sumfxn", "homepage": "https://github.com/sumfxn/usdh-kit#readme", diff --git a/packages/widget/README.md b/packages/widget/README.md index 1fa6478..214269f 100644 --- a/packages/widget/README.md +++ b/packages/widget/README.md @@ -1,6 +1,6 @@ # @usdh-kit/widget -Embeddable React widget for swapping stablecoins into USDH on Hyperliquid. +Embeddable React widgets for USDH sunset migration on Hyperliquid. ## Install @@ -18,17 +18,19 @@ The widget reads the connected wallet from wagmi. Wrap your tree in `WagmiProvid The root widget entry is ESM-only because the React wallet stack it composes is ESM-first. CommonJS projects can still load `@usdh-kit/widget/styles.css` and `@usdh-kit/widget/tailwind-content`, but should import the widget from an ESM module or through their app bundler. ```tsx -import { USDHSwap } from '@usdh-kit/widget' +import { USDHMigration } from '@usdh-kit/widget' import '@usdh-kit/widget/styles.css' export default function Page() { - return + return } ``` -The full widget manages a short-lived Hyperliquid agent wallet session before swapping. For custom UIs, prefer the SDK primitives (`approveAgent`, `accountAddress`, and `createUsdhKit`) so reads use the master wallet while L1 orders are signed by an approved agent. +`USDHMigration` is the primary sunset widget. It converts HyperCore USDH back to USDC with wallet-gated writes and never displays a fake receive estimate when the live quote is unavailable. `USDHSwap` remains exported only as a legacy `USDC -> USDH` widget for historical integrations. -For HyperEVM-funded swaps, users should expect: +The migration widget manages a short-lived Hyperliquid agent wallet session before submitting an order. For custom UIs, prefer the SDK primitives (`approveAgent`, `accountAddress`, and `createUsdhKit`) so reads use the master wallet while L1 orders are signed by an approved agent. + +For legacy HyperEVM-funded `USDHSwap` flows, users should expect: 1. one wallet signature to enable the trading session on first use 2. one USDC approval transaction if allowance is not already sufficient @@ -67,6 +69,26 @@ import '@usdh-kit/widget/styles.css' ## Props +```ts +type USDHMigrationProps = { + network: 'mainnet' | 'testnet' + hideNetworkToggle?: boolean + hideAttribution?: boolean + theme?: 'dark' | 'light' | 'auto' + defaultSlippageBps?: number + defaultAmount?: string + onMigrationComplete?: (result: { + orderId: string + spentUsdh: bigint + receivedUsdc: bigint + price: bigint + slippageBps: number + }) => void +} +``` + +Legacy swap widget: + ```ts type USDHSwapProps = { network: 'mainnet' | 'testnet' @@ -83,7 +105,7 @@ type USDHSwapProps = { } ``` -`network` is required. Pass `'mainnet'` for production swaps and `'testnet'` for the Hyperliquid testnet. +`network` is required. Pass `'mainnet'` for production migration flows and `'testnet'` for the Hyperliquid testnet. ## License diff --git a/packages/widget/package.json b/packages/widget/package.json index 5cbea13..6ec277a 100644 --- a/packages/widget/package.json +++ b/packages/widget/package.json @@ -1,7 +1,7 @@ { "name": "@usdh-kit/widget", "version": "0.1.1", - "description": "Embeddable React widget for swapping stablecoins into USDH on Hyperliquid", + "description": "Embeddable React widgets for USDH sunset migration on Hyperliquid", "license": "MIT", "author": "sumfxn", "homepage": "https://github.com/sumfxn/usdh-kit#readme", diff --git a/packages/widget/src/components/action-button.tsx b/packages/widget/src/components/action-button.tsx index 84665b8..d58dbd7 100644 --- a/packages/widget/src/components/action-button.tsx +++ b/packages/widget/src/components/action-button.tsx @@ -13,6 +13,12 @@ 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' + actionLabel?: string + bridgeLabel?: string + connectLabel?: string + workingLabel?: string }) { const { phase, @@ -24,6 +30,11 @@ export function ActionButton(props: { needsTradingSession, disabled, onClick, + payTicker = 'USDC', + actionLabel = 'Swap', + bridgeLabel = 'Bridge and swap', + connectLabel = 'Connect wallet to swap', + workingLabel = 'Swapping', } = props return ( ) diff --git a/packages/widget/src/components/pay-card.tsx b/packages/widget/src/components/pay-card.tsx index 278e557..59c585e 100644 --- a/packages/widget/src/components/pay-card.tsx +++ b/packages/widget/src/components/pay-card.tsx @@ -1,4 +1,4 @@ -import { UsdcIcon } from '../icons.js' +import { UsdcIcon, UsdhIcon } from '../icons.js' import { type SourceChain, SourceChainPill } from './source-chain-pill.js' import { TokenChip } from './token-chip.js' @@ -6,11 +6,14 @@ export function PayCard(props: { amountStr: string onAmountChange: (next: string) => void inputDisabled: boolean - sourceChain: SourceChain - onSourceToggle: () => void + /** Source-chain pill controls. Omit to hide the pill (e.g. HyperCore-only flows). */ + sourceChain?: SourceChain + onSourceToggle?: () => void payUsdValue: string | null hasMaxBalance: boolean onMax: () => void + /** Pay-side token ticker. Defaults to `'USDC'` for USDHSwap. */ + payTicker?: 'USDC' | 'USDH' }) { const { amountStr, @@ -21,17 +24,20 @@ export function PayCard(props: { payUsdValue, hasMaxBalance, onMax, + payTicker = 'USDC', } = props return (
You pay - + {sourceChain !== undefined && onSourceToggle !== undefined && ( + + )}
onAmountChange(e.target.value)} onFocus={(e) => e.currentTarget.select()} disabled={inputDisabled} - aria-label="Amount in USDC" + aria-label={`Amount in ${payTicker}`} placeholder="0" className="min-w-0 flex-1 bg-transparent font-sans text-3xl font-light tracking-tight text-usdh-text outline-none placeholder:text-usdh-placeholder disabled:opacity-60" /> - } ticker="USDC" /> + : } ticker={payTicker} />
diff --git a/packages/widget/src/components/receive-card.tsx b/packages/widget/src/components/receive-card.tsx index f19f0e0..d4efc2d 100644 --- a/packages/widget/src/components/receive-card.tsx +++ b/packages/widget/src/components/receive-card.tsx @@ -1,4 +1,4 @@ -import { Spinner, UsdhIcon } from '../icons.js' +import { Spinner, UsdcIcon, UsdhIcon } from '../icons.js' import { TokenChip } from './token-chip.js' export function ReceiveCard(props: { @@ -6,8 +6,12 @@ export function ReceiveCard(props: { receiveUsdValue: string | null isQuoting: boolean hasQuote: boolean + /** Receive-side token ticker. Defaults to `'USDH'` for USDHSwap. */ + receiveTicker?: 'USDC' | 'USDH' }) { - const { receiveDisplay, receiveUsdValue, isQuoting, hasQuote } = props + const { receiveDisplay, receiveUsdValue, isQuoting, hasQuote, receiveTicker = 'USDH' } = props + const displayClass = + receiveDisplay.length > 12 ? 'text-lg font-medium' : 'text-3xl font-light tracking-tight' return (
@@ -15,10 +19,13 @@ export function ReceiveCard(props: { on HyperCore
- + {isQuoting && !hasQuote ? : receiveDisplay} - } ticker="USDH" /> + : } + ticker={receiveTicker} + />

{receiveUsdValue ?? ' '}

diff --git a/packages/widget/src/components/result-panel.tsx b/packages/widget/src/components/result-panel.tsx index bb5df95..5b4fc94 100644 --- a/packages/widget/src/components/result-panel.tsx +++ b/packages/widget/src/components/result-panel.tsx @@ -1,28 +1,60 @@ import { trimReceive } from '../format-display.js' -import type { SwapResultPayload } from '../types.js' - const USDC_DECIMALS = 6 -export function ResultPanel(props: { result: SwapResultPayload; onReset: () => void }) { - const { result, onReset } = props +export interface ResultPanelPayload { + orderId: string + receivedAmount: bigint + spentAmount?: bigint + requestedAmount?: bigint + txHash?: `0x${string}` +} + +export function ResultPanel(props: { + result: ResultPanelPayload + 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', + 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.receivedUsdh, USDC_DECIMALS)} USDH + {trimReceive(result.receivedAmount, USDC_DECIMALS)} {receiveTicker}

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

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

+ )}

@@ -36,6 +68,19 @@ export function ResultPanel(props: { result: SwapResultPayload; onReset: () => v )}

+ {partialFill && result.spentAmount !== undefined && result.requestedAmount !== undefined ? ( +

+ Migrated{' '} + + {trimReceive(result.spentAmount, USDC_DECIMALS)} + {' '} + of{' '} + + {trimReceive(result.requestedAmount, USDC_DECIMALS)} + {' '} + {spentTicker}. The unfilled balance remains on HyperCore. +

+ ) : null}
) } diff --git a/packages/widget/src/index.ts b/packages/widget/src/index.ts index 13349e7..07a73ee 100644 --- a/packages/widget/src/index.ts +++ b/packages/widget/src/index.ts @@ -4,5 +4,12 @@ export type { UsdcBalances } from './use-balances.js' export { friendlyError } from './friendly-error.js' export { USDHSwap } from './usdh-swap.js' export type { USDHSwapProps } from './usdh-swap.js' -export type { HyperNetwork, SwapResultPayload, WidgetTheme } from './types.js' +export { USDHMigration } from './usdh-migration.js' +export type { USDHMigrationProps } from './usdh-migration.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 new file mode 100644 index 0000000..f22e100 --- /dev/null +++ b/packages/widget/src/usdh-migration.tsx @@ -0,0 +1,515 @@ +'use client' + +import { createInfoClient, listUsdhSpotPairs } from '@usdh-kit/sdk' +import type { Quote, SwapRoute } from '@usdh-kit/sdk' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useAccount } from 'wagmi' + +import { ActionButton } from './components/action-button.js' +import { ArrowDivider } from './components/arrow-divider.js' +import { ErrorAlert } from './components/error-alert.js' +import { NetworkToggle } from './components/network-toggle.js' +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 { 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' +import { useAgentWalletKit } from './use-agent-wallet-kit.js' +import { useUsdcBalances } from './use-balances.js' +import { useCountdown } from './use-countdown.js' +import { useEffectiveTheme } from './use-theme.js' +import { Watermark } from './watermark.js' + +const USDH_DECIMALS = 6 +const MIN_SWAP_AMOUNT = 10_000_001n +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). +type Phase = 'idle' | 'approving' | 'swapping' | 'done' + +export type USDHMigrationProps = { + /** HyperEVM network the migration targets. */ + network: HyperNetwork + /** Hide the in-widget testnet/mainnet toggle. Defaults to false. */ + hideNetworkToggle?: boolean + /** Hide the "Powered by usdh-kit" footer. Defaults to false. */ + hideAttribution?: boolean + /** + * Theme palette. Defaults to `'auto'` (follows the user's system + * preference via `prefers-color-scheme`). Set to `'dark'` or `'light'` + * to force a specific palette. + */ + theme?: WidgetTheme + /** Default slippage in basis points (10 = 0.10%). Defaults to 30. */ + defaultSlippageBps?: number + /** Pre-fill the pay amount as a decimal string. */ + defaultAmount?: string + /** Called when a migration fills successfully. */ + 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 + * USDC, and this tool helps users migrate out. HyperCore sell side only: + * no bridging, no HyperEVM source, no source-chain toggle. + */ +export function USDHMigration(props: USDHMigrationProps) { + const { + network: initialNetwork, + hideNetworkToggle = false, + hideAttribution = false, + theme = 'auto', + defaultSlippageBps = 30, + defaultAmount = '11', + onMigrationComplete, + } = props + + const effectiveTheme = useEffectiveTheme(theme) + const [network, setNetwork] = useState(initialNetwork) + const { address, isConnected } = useAccount() + const { kit, sessionReady, isApprovingSession, enableTradingSession } = useAgentWalletKit(network) + const balances = useUsdcBalances(network, address) + + const [amountStr, setAmountStr] = useState(defaultAmount) + const [slippageBps, setSlippageBps] = useState(defaultSlippageBps) + const [customSlippageStr, setCustomSlippageStr] = useState('') + const [showCustomSlippage, setShowCustomSlippage] = useState(false) + const [phase, setPhase] = useState('idle') + const [quote, setQuote] = useState(null) + 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 [depthWarning, setDepthWarning] = useState(null) + const [knownDepthLimited, setKnownDepthLimited] = useState(false) + const [result, setResult] = useState(null) + const [error, setError] = useState(null) + + const quoteExpirySeconds = useCountdown(quote?.validUntil ?? null) + + const busy = phase === 'approving' || phase === 'swapping' + const showingResult = phase === 'done' && result !== null + const networkToggleLocked = busy + + useEffect(() => { + if (!networkToggleLocked) setNetwork(initialNetwork) + }, [initialNetwork, networkToggleLocked]) + + useEffect(() => { + if (quote && quoteExpirySeconds === 0) setQuote(null) + }, [quote, quoteExpirySeconds]) + + const parsedAmount = useMemo(() => { + try { + return parseUnits(amountStr || '0', USDH_DECIMALS) + } catch { + return null + } + }, [amountStr]) + const balanceRefreshKey = balances.hcUsdh?.toString() ?? '' + + const quoteRequestId = useRef(0) + + useEffect(() => { + // A USDH deposit crediting after mount can flip the route from blocked to + // coverable, so re-quote when the HyperCore USDH balance changes. + void balanceRefreshKey + const requestId = ++quoteRequestId.current + setQuote(null) + setRoute(null) + setDepthWarning(null) + setKnownDepthLimited(false) + if (!kit) { + setIsQuoting(false) + return + } + if (parsedAmount === null || parsedAmount <= 0n) { + setIsQuoting(false) + return + } + const timer = setTimeout(async () => { + setError(null) + setIsQuoting(true) + try { + // USDH -> USDC: sell side, HyperCore only. preflightSwap returns the + // quote plus the HyperCore balance coverage we gate the button on. + const nextRoute = await kit.preflightSwap({ + from: 'USDH', + to: 'USDC', + amount: parsedAmount, + slippageBps, + sourceChain: 'hypercore', + }) + if (requestId !== quoteRequestId.current) return + 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)) + } finally { + if (requestId === quoteRequestId.current) setIsQuoting(false) + } + }, QUOTE_DEBOUNCE_MS) + return () => clearTimeout(timer) + }, [kit, parsedAmount, slippageBps, balanceRefreshKey]) + + useEffect(() => { + setReadOnlyEstimate(null) + setReadOnlyQuoteUnavailable(false) + setDepthWarning(null) + setKnownDepthLimited(false) + if (isConnected || parsedAmount === null || parsedAmount <= 0n) { + setIsReadOnlyQuoting(false) + return + } + + let cancelled = false + const timer = window.setTimeout(async () => { + setIsReadOnlyQuoting(true) + 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', + ) + if (!pair) { + if (!cancelled) setReadOnlyQuoteUnavailable(true) + return + } + const book = await info.l2Book(pair.name) + const depth = estimateUsdcFromBidDepth(book.levels[0], parsedAmount) + if (depth === null || depth.spentUsdh === 0n) { + if (!cancelled) setReadOnlyQuoteUnavailable(true) + return + } + if (!depth.fullyCovered) { + if (!cancelled) { + setReadOnlyQuoteUnavailable(true) + setDepthWarning( + `Visible bid depth covers ${trimReceive(depth.spentUsdh, USDH_DECIMALS)} of ${trimReceive(parsedAmount, USDH_DECIMALS)} USDH.`, + ) + } + return + } + if (!cancelled) { + setReadOnlyEstimate(depth.receivedUsdc) + setReadOnlyQuoteUnavailable(false) + setDepthWarning(null) + } + } catch { + if (!cancelled) { + setReadOnlyEstimate(null) + setReadOnlyQuoteUnavailable(true) + } + } finally { + if (!cancelled) setIsReadOnlyQuoting(false) + } + }, QUOTE_DEBOUNCE_MS) + + return () => { + cancelled = true + window.clearTimeout(timer) + } + }, [isConnected, network, parsedAmount]) + + const belowMinOrderValue = + parsedAmount !== null && parsedAmount > 0n && parsedAmount < MIN_SWAP_AMOUNT + const hcCovers = route?.canSwap ?? false + const insufficientForRoute = + parsedAmount !== null && parsedAmount > 0n && !belowMinOrderValue && route !== null && !hcCovers + const routeLoaded = route !== null + + function reset() { + setPhase('idle') + setResult(null) + setError(null) + } + + function applySlippagePreset(bps: number) { + setSlippageBps(bps) + setShowCustomSlippage(false) + setCustomSlippageStr('') + } + + function applyCustomSlippage(input: string) { + setCustomSlippageStr(input) + const trimmed = input.trim() + if (trimmed === '') return + const pct = Number(trimmed) + if (!Number.isFinite(pct) || pct < 0 || pct > 50) return + setSlippageBps(Math.round(pct * 100)) + } + + async function executeMigration() { + if (!kit || parsedAmount === null || parsedAmount <= 0n) return + // Hard guard against double-clicks: setPhase is async, so without this a + // synchronous double-tap can dispatch two tx requests before the button's + // disabled state commits. + if (phase !== 'idle') return + setError(null) + setResult(null) + + if (!sessionReady) { + setPhase('approving') + try { + await enableTradingSession() + } catch (err) { + setError(friendlyError(err)) + } finally { + setPhase('idle') + } + return + } + + setPhase('swapping') + try { + const next = await kit.swap({ + from: 'USDH', + to: 'USDC', + amount: parsedAmount, + slippageBps, + }) + const payload: USDHMigrationResultPayload = { + orderId: next.orderId, + spentUsdh: next.spent, + receivedUsdc: next.received, + price: next.price, + slippageBps: next.slippageBps, + } + setResult(payload) + setPhase('done') + setRoute(null) + setQuote(null) + balances.refetch() + window.setTimeout(() => balances.refetch(), 2_500) + onMigrationComplete?.(payload) + } catch (err) { + setError(friendlyError(err)) + setPhase('idle') + } + } + + const inputDisabled = busy || showingResult + const canSwap = + !busy && + !showingResult && + isConnected && + kit !== null && + !isApprovingSession && + parsedAmount !== null && + parsedAmount > 0n && + !belowMinOrderValue && + routeLoaded && + hcCovers && + !knownDepthLimited + + const receiveDisplay = quote + ? trimReceive(quote.estimatedReceived, USDH_DECIMALS) + : readOnlyEstimate !== null + ? trimReceive(readOnlyEstimate, USDH_DECIMALS) + : readOnlyQuoteUnavailable + ? 'Quote unavailable' + : '0' + + const payUsdValue = parsedAmount ? formatUsd(parsedAmount, USDH_DECIMALS) : null + const receiveBigint = quote + ? quote.estimatedReceived + : readOnlyEstimate !== null + ? readOnlyEstimate + : null + const receiveUsdValue = receiveBigint ? formatUsd(receiveBigint, USDH_DECIMALS) : null + + function setMaxAmount() { + if (balances.hcUsdh === undefined || balances.hcUsdhDecimals === undefined) return + // Inverse of scaleAmount: pull native units back to USDH display units. + const usdhAmount = scaleAmount(balances.hcUsdh, USDH_DECIMALS - balances.hcUsdhDecimals) + setAmountStr(formatUnits(usdhAmount, USDH_DECIMALS)) + } + + const hasMaxBalance = + balances.hcUsdh !== undefined && balances.hcUsdhDecimals !== undefined && balances.hcUsdh > 0n + + return ( +
+
+

Migrate USDH to USDC

+ {!hideNetworkToggle && ( + + )} +
+ +

+ 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 + +
+ )} + +
+ + + +
+ + {!showingResult && ( + <> + {depthWarning && ( +

{depthWarning}

+ )} + {insufficientForRoute && ( +

+ Exceeds your HyperCore USDH balance. +

+ )} + {belowMinOrderValue && ( +

+ Hyperliquid spot orders need more than 10 USDH. Use {MIN_SWAP_DISPLAY}+ USDH. +

+ )} + + setShowCustomSlippage((v) => !v)} + customStr={customSlippageStr} + onCustomChange={applyCustomSlippage} + disabled={inputDisabled} + /> + + + + )} + {error && } + {result && ( + + )} + + {!hideAttribution && ( +
+ +
+ )} +
+ ) +} + +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/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/bundle-size.test.ts b/packages/widget/test/bundle-size.test.ts index 1da2a47..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: ~56.6 KB ESM after the browser agent-session flow, native - * USDC bridge support, and dual-token balance display. 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 = 58 +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 new file mode 100644 index 0000000..39c9d8e --- /dev/null +++ b/packages/widget/test/usdh-migration.test.tsx @@ -0,0 +1,511 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { USDHMigration } from '../src/usdh-migration.js' + +const STUB_ADDRESS = '0x1234567890abcdef1234567890abcdef12345678' as const + +const mockUseAccount = vi.fn<() => { isConnected: boolean; address?: `0x${string}` }>() +const mockUseWalletClient = + vi.fn< + () => { + data: + | { + sendTransaction: () => Promise + signTypedData: () => Promise<`0x${string}`> + signMessage: () => Promise<`0x${string}`> + } + | undefined + } + >() +const mockUseChainId = vi.fn<() => number>() +const mockUseReadContract = + vi.fn<() => { data: unknown; isLoading: boolean; refetch: () => void }>() +const mockUsePublicClient = vi.fn() +const mockGeneratePrivateKey = vi.fn<() => `0x${string}`>() +const mockPrivateKeyToAccount = vi.fn() + +vi.mock('wagmi', () => ({ + useAccount: () => mockUseAccount(), + useWalletClient: () => mockUseWalletClient(), + useSignTypedData: () => ({ signTypedDataAsync: vi.fn() }), + useSignMessage: () => ({ signMessageAsync: vi.fn() }), + useChainId: () => mockUseChainId(), + useSwitchChain: () => ({ switchChain: vi.fn(), isPending: false }), + useReadContract: () => mockUseReadContract(), + usePublicClient: () => mockUsePublicClient(), +})) + +vi.mock('viem/accounts', () => ({ + generatePrivateKey: () => mockGeneratePrivateKey(), + privateKeyToAccount: (privateKey: `0x${string}`) => mockPrivateKeyToAccount(privateKey), +})) + +const mockHcQueryData = vi.fn<() => { usdc: bigint; usdh: bigint | undefined } | undefined>( + () => undefined, +) +const mockTokenQueryData = vi.fn<() => unknown>(() => ({ + usdc: { + evmAddress: '0xb88339CB7199b77E23DB6E890353E22632Ba630f', + evmDecimals: 18, + hcWeiDecimals: 8, + hcTokenIndex: 0, + }, + usdh: { + evmAddress: '0x1111111111111111111111111111111111111111', + evmDecimals: 18, + hcWeiDecimals: 8, + hcTokenIndex: 1, + }, +})) + +vi.mock('@tanstack/react-query', () => ({ + useQuery: ({ queryKey }: { queryKey: unknown[] }) => { + if (queryKey.includes('stable-token-info')) { + return { data: mockTokenQueryData(), isLoading: false, refetch: vi.fn() } + } + return { data: mockHcQueryData(), isLoading: false, refetch: vi.fn() } + }, +})) + +const mockPreflightSwap = vi.fn() +const mockSwap = vi.fn() +const mockApproveAgent = vi.fn() +const mockKitGetBook = 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 { + from: 'USDH', + to: 'USDC', + pair: 'USDH/USDC', + midPrice: 1_000_000_000_000_000_000n, + estimatedReceived, + validUntil: Date.now() + 30_000, + } +} + +function makeRoute( + overrides: Partial<{ + amount: bigint + canSwap: boolean + estimatedReceived: bigint + hypercoreBalance: bigint + requiredHypercoreBalance: bigint + }> = {}, +) { + const hypercoreBalance = overrides.hypercoreBalance ?? 2_000_000_000n + return { + from: 'USDH', + to: 'USDC', + amount: overrides.amount ?? 11_000_000n, + sourceChain: 'hypercore', + requiresBridge: false, + canSwap: overrides.canSwap ?? true, + quote: makeQuote(overrides.estimatedReceived), + hypercoreBalance, + hypercoreTotal: hypercoreBalance, + hypercoreHold: 0n, + hypercoreDecimals: 8, + requiredHypercoreBalance: overrides.requiredHypercoreBalance ?? 11_000_000n, + } +} + +function makeSwapResult( + overrides: Partial<{ orderId: string; received: bigint; spent: bigint }> = {}, +) { + return { + orderId: overrides.orderId ?? 'order-42', + received: overrides.received ?? 11_000_000n, + spent: overrides.spent ?? 11_000_000n, + price: 1_000_000_000_000_000_000n, + slippageBps: 0, + } +} + +vi.mock('@usdh-kit/sdk', () => ({ + approveAgent: (...args: unknown[]) => mockApproveAgent(...args), + createUsdhKit: () => ({ + preflightSwap: mockPreflightSwap, + swap: mockSwap, + getBook: mockKitGetBook, + }), + createInfoClient: () => ({ + spotMeta: vi.fn(async () => ({})), + l2Book: (...args: unknown[]) => mockL2Book(...args), + spotClearinghouseState: vi.fn(), + }), + listUsdhSpotPairs: (...args: unknown[]) => mockListUsdhSpotPairs(...args), + getHyperEvmNativeUsdcAddress: () => '0xb88339cb7199b77e23db6e890353e22632ba630f', + BridgeAndSwapError: class extends Error {}, + isBridgeAndSwapError: () => false, + BridgeTimeoutError: class extends Error {}, + InsufficientBalanceError: class extends Error {}, + InvalidInputError: class extends Error {}, + MissingEvmWalletError: class extends Error {}, + NetworkError: class extends Error {}, + NotImplementedError: class extends Error {}, + SigningError: class extends Error {}, + UsdhKitError: class extends Error {}, +})) + +function setConnected({ withSession = true }: { withSession?: boolean } = {}) { + mockUseAccount.mockReturnValue({ isConnected: true, address: STUB_ADDRESS }) + mockUseWalletClient.mockReturnValue({ + data: { + sendTransaction: () => Promise.resolve('0xtx'), + signTypedData: () => Promise.resolve(`0x${'c'.repeat(130)}` as `0x${string}`), + signMessage: () => Promise.resolve('0x0'), + }, + }) + if (withSession) seedAgentSession() + mockUseChainId.mockReturnValue(999) + mockUseReadContract.mockReturnValue({ data: undefined, isLoading: false, refetch: vi.fn() }) + mockUsePublicClient.mockReturnValue({ + waitForTransactionReceipt: vi.fn(async () => undefined), + }) + // 20 USDH on HyperCore at 8 weiDecimals covers the 11 USDH default amount. + mockHcQueryData.mockReturnValue({ usdc: 0n, usdh: 2_000_000_000n }) +} + +function seedAgentSession(network: 'mainnet' | 'testnet' = 'mainnet') { + window.sessionStorage.setItem( + `usdh-kit:agent-session:${network}:${STUB_ADDRESS.toLowerCase()}`, + JSON.stringify({ + version: 1, + network, + accountAddress: STUB_ADDRESS.toLowerCase(), + agentAddress: '0x00000000000000000000000000000000000000aa', + agentName: `usdh-kit-${network}`, + privateKey: `0x${'1'.repeat(64)}`, + createdAt: Date.now(), + }), + ) +} + +describe('USDHMigration', () => { + beforeEach(() => { + vi.resetAllMocks() + window.sessionStorage.clear() + mockGeneratePrivateKey.mockReturnValue(`0x${'2'.repeat(64)}`) + mockPrivateKeyToAccount.mockReturnValue({ + address: '0x00000000000000000000000000000000000000aa', + signTypedData: () => Promise.resolve(`0x${'a'.repeat(64)}${'b'.repeat(64)}1c`), + signMessage: () => Promise.resolve('0x0'), + }) + mockApproveAgent.mockResolvedValue({ + agentAddress: '0x00000000000000000000000000000000000000aa', + }) + mockTokenQueryData.mockReturnValue({ + usdc: { + evmAddress: '0xb88339CB7199b77E23DB6E890353E22632Ba630f', + evmDecimals: 18, + hcWeiDecimals: 8, + hcTokenIndex: 0, + }, + usdh: { + evmAddress: '0x1111111111111111111111111111111111111111', + evmDecimals: 18, + hcWeiDecimals: 8, + hcTokenIndex: 1, + }, + }) + 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: '20', n: 1 }], [{ px: '1.0001', sz: '20', n: 1 }]], + }) + mockListUsdhSpotPairs.mockReturnValue([usdhUsdcPair]) + mockUseReadContract.mockReturnValue({ data: undefined, isLoading: false, refetch: vi.fn() }) + }) + + it('renders the migration form with the sunset notice and USDH/USDC tickers', () => { + mockUseAccount.mockReturnValue({ isConnected: false }) + mockUseWalletClient.mockReturnValue({ data: undefined }) + mockUseChainId.mockReturnValue(0) + + render() + + expect(screen.getByText('Migrate USDH to USDC')).toBeInTheDocument() + expect(screen.getByText(/USDH is being sunset on Hyperliquid/)).toBeInTheDocument() + 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 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('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 })) + + render() + + await waitFor( + () => { + expect(mockPreflightSwap).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'USDH', + to: 'USDC', + amount: 11_000_000n, + sourceChain: 'hypercore', + }), + ) + }, + { timeout: 2_000 }, + ) + await waitFor(() => { + 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')) + + render() + + await waitFor( + () => { + expect(screen.getByRole('alert')).toHaveTextContent('upstream down') + }, + { timeout: 2_000 }, + ) + }) + + it('blocks amounts below the Hyperliquid minimum notional', () => { + setConnected() + mockPreflightSwap.mockImplementation(() => new Promise(() => {})) + + render() + + expect(screen.getByText(/Use 11\+ USDH/)).toBeInTheDocument() + const button = screen.getByRole('button', { name: 'Minimum 11 USDH' }) + expect(button).toBeDisabled() + fireEvent.click(button) + expect(mockSwap).not.toHaveBeenCalled() + }) + + it('disables the button and labels it Insufficient when amount exceeds the USDH balance', async () => { + setConnected() + mockPreflightSwap.mockResolvedValue(makeRoute({ canSwap: false })) + + render() + + await waitFor(() => { + expect(screen.getByText(/Exceeds your HyperCore USDH balance/)).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'Insufficient HyperCore USDH' })).toBeDisabled() + }) + + it('requires an explicit trading session before the first migration', async () => { + setConnected({ withSession: false }) + + render() + + await waitFor( + () => { + expect(screen.getByRole('button', { name: 'Enable trading session' })).not.toBeDisabled() + }, + { timeout: 2_000 }, + ) + fireEvent.click(screen.getByRole('button', { name: 'Enable trading session' })) + + await waitFor(() => { + expect(mockApproveAgent).toHaveBeenCalledWith( + expect.objectContaining({ + network: 'mainnet', + agentName: 'usdh-kit-mainnet', + }), + ) + }) + expect(mockSwap).not.toHaveBeenCalled() + }) + + it('submits the migration swap and shows the filled USDC receipt', async () => { + setConnected() + mockSwap.mockResolvedValue(makeSwapResult({ orderId: 'order-7', received: 10_998_000n })) + const onMigrationComplete = vi.fn() + + render() + + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Migrate' })).not.toBeDisabled() + }) + fireEvent.click(screen.getByRole('button', { name: 'Migrate' })) + + await waitFor(() => { + expect(screen.getByText('Filled')).toBeInTheDocument() + }) + expect(screen.getByText(/10\.998 USDC/)).toBeInTheDocument() + expect(mockSwap).toHaveBeenCalledWith( + expect.objectContaining({ + from: 'USDH', + to: 'USDC', + amount: 11_000_000n, + slippageBps: 30, + }), + ) + expect(onMigrationComplete).toHaveBeenCalledWith({ + orderId: 'order-7', + spentUsdh: 11_000_000n, + receivedUsdc: 10_998_000n, + price: 1_000_000_000_000_000_000n, + slippageBps: 0, + }) + }) + + 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 }) + mockUseChainId.mockReturnValue(0) + + const { rerender } = render() + expect(screen.getByText(/Powered by/)).toBeInTheDocument() + + rerender() + expect(screen.queryByText(/Powered by/)).not.toBeInTheDocument() + }) +}) 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',