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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions apps/demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,16 @@
"@usdh-kit/sdk": "workspace:*",
"@usdh-kit/widget": "workspace:*",
"connectkit": "1.8.2",
"next": "15.1.0",
"react": "19.0.0",
"react-dom": "19.0.0",
"next": "15.5.18",
"react": "18.3.1",
"react-dom": "18.3.1",
"viem": "2.21.55",
"wagmi": "2.14.6"
},
"devDependencies": {
"@types/node": "22.10.2",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"autoprefixer": "10.4.20",
"postcss": "8.4.49",
"tailwindcss": "3.4.17",
Expand Down
8 changes: 4 additions & 4 deletions packages/widget/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@
"@tanstack/react-query": "5.62.7",
"@testing-library/jest-dom": "6.6.3",
"@testing-library/react": "16.1.0",
"@types/react": "19.0.1",
"@types/react-dom": "19.0.2",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"jsdom": "25.0.1",
"react": "19.0.0",
"react-dom": "19.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"tailwindcss": "3.4.17",
"tsup": "8.3.5",
"typescript": "5.7.2",
Expand Down
4 changes: 4 additions & 0 deletions packages/widget/src/components/action-button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export function ActionButton(props: {
phase: ActionPhase
insufficient: boolean
belowMinOrderValue: boolean
isConnected: boolean
requiresBridge: boolean
sourceChain: SourceChain
needsTradingSession: boolean
Expand All @@ -17,6 +18,7 @@ export function ActionButton(props: {
phase,
insufficient,
belowMinOrderValue,
isConnected,
requiresBridge,
sourceChain,
needsTradingSession,
Expand Down Expand Up @@ -46,6 +48,8 @@ export function ActionButton(props: {
`Insufficient ${sourceChain === 'evm' ? 'HyperEVM' : 'HyperCore'} USDC`
) : belowMinOrderValue ? (
'Minimum 11 USDC'
) : !isConnected ? (
'Connect wallet to swap'
) : needsTradingSession ? (
'Enable trading session'
) : requiresBridge ? (
Expand Down
10 changes: 10 additions & 0 deletions packages/widget/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
*/

.usdh-widget {
width: 100%;
max-width: min(480px, calc(100vw - 2rem));

/* Surfaces */
--usdh-bg: 255 255 255; /* #ffffff — outer card background */
--usdh-surface: 250 250 250; /* #fafafa — pay/receive cards */
Expand Down Expand Up @@ -47,6 +50,13 @@
--usdh-chip-active-text: 250 250 250; /* #fafafa — chip active text */
}

@media (max-width: 420px) {
.usdh-widget {
width: calc(100vw - 4rem);
max-width: calc(100vw - 4rem);
}
}

.usdh-widget.dark {
/* Surfaces */
--usdh-bg: 10 10 10; /* #0a0a0a (neutral-950) */
Expand Down
216 changes: 130 additions & 86 deletions packages/widget/src/usdh-swap.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
'use client'

import { BridgeTimeoutError, isBridgeAndSwapError } from '@usdh-kit/sdk'
import {
BridgeTimeoutError,
createInfoClient,
isBridgeAndSwapError,
listUsdhSpotPairs,
} from '@usdh-kit/sdk'
import type { Quote, SwapRoute } from '@usdh-kit/sdk'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useAccount, useChainId, useSwitchChain } from 'wagmi'

import { HYPER_EVM_CHAIN_ID, networkLabel } from './chains.js'
import { HYPER_EVM_CHAIN_ID } from './chains.js'
import { ActionButton } from './components/action-button.js'
import { ArrowDivider } from './components/arrow-divider.js'
import { BalanceRow } from './components/balance-row.js'
Expand All @@ -32,6 +37,8 @@ const USDC_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

type Phase = 'idle' | 'approving' | 'bridging' | 'swapping' | 'done'

Expand Down Expand Up @@ -83,6 +90,8 @@ export function USDHSwap(props: USDHSwapProps) {
const [quote, setQuote] = useState<Quote | null>(null)
const [route, setRoute] = useState<SwapRoute | null>(null)
const [isQuoting, setIsQuoting] = useState(false)
const [readOnlyEstimate, setReadOnlyEstimate] = useState<bigint | null>(null)
const [isReadOnlyQuoting, setIsReadOnlyQuoting] = useState(false)
const [result, setResult] = useState<SwapResultPayload | null>(null)
const [error, setError] = useState<string | null>(null)
const [bridgeStartedAt, setBridgeStartedAt] = useState<number | null>(null)
Expand Down Expand Up @@ -166,6 +175,43 @@ export function USDHSwap(props: USDHSwapProps) {
return () => clearTimeout(timer)
}, [kit, parsedAmount, slippageBps, manualSource, onWrongChain, balanceRefreshKey])

useEffect(() => {
setReadOnlyEstimate(null)
if (isConnected || onWrongChain || 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') ??
pairs[0]
if (!pair) return
const book = await info.l2Book(pair.name)
const ask = book.levels[1]?.[0]?.px
if (!ask) return
const askPrice18 = parseUnits(ask, PRICE_DECIMALS)
if (askPrice18 <= 0n) return
const nextEstimate = (parsedAmount * 10n ** BigInt(PRICE_DECIMALS)) / askPrice18
if (!cancelled) setReadOnlyEstimate(nextEstimate)
} catch {
if (!cancelled) setReadOnlyEstimate(null)
} finally {
if (!cancelled) setIsReadOnlyQuoting(false)
}
}, QUOTE_DEBOUNCE_MS)

return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [isConnected, network, onWrongChain, parsedAmount])

const hcCovers = route?.sourceChain === 'hypercore' && route.canSwap

const evmCovers =
Expand Down Expand Up @@ -304,16 +350,20 @@ export function USDHSwap(props: USDHSwapProps) {
// in the headline — the slippage tolerance is the user-facing knob.
const receiveDisplay = quote
? trimReceive(quote.estimatedReceived, USDC_DECIMALS)
: parsedAmount && parsedAmount > 0n
? trimReceive(parsedAmount, USDC_DECIMALS)
: '0'
: readOnlyEstimate !== null
? trimReceive(readOnlyEstimate, USDC_DECIMALS)
: parsedAmount && parsedAmount > 0n
? trimReceive(parsedAmount, USDC_DECIMALS)
: '0'

const payUsdValue = parsedAmount ? formatUsd(parsedAmount, USDC_DECIMALS) : null
const receiveBigint = quote
? quote.estimatedReceived
: parsedAmount && parsedAmount > 0n
? parsedAmount
: null
: readOnlyEstimate !== null
? readOnlyEstimate
: parsedAmount && parsedAmount > 0n
? parsedAmount
: null
const receiveUsdValue = receiveBigint ? formatUsd(receiveBigint, USDC_DECIMALS) : null

// Active source drives the MAX button.
Expand All @@ -339,12 +389,13 @@ export function USDHSwap(props: USDHSwapProps) {
}

const showInlineNote =
requiresBridge && parsedAmount !== null && parsedAmount > 0n && phase === 'idle'
isConnected && requiresBridge && parsedAmount !== null && parsedAmount > 0n && phase === 'idle'

return (
<div
data-theme={effectiveTheme}
className={`usdh-widget mx-auto w-full max-w-[480px] rounded-2xl border border-usdh-border bg-usdh-bg/70 p-4 shadow-[0_1px_0_0_rgba(255,255,255,0.02)_inset] backdrop-blur ${effectiveTheme === 'dark' ? 'dark' : ''}`}
className={`usdh-widget mx-auto min-w-0 w-full rounded-2xl border border-usdh-border bg-usdh-bg/70 p-4 shadow-[0_1px_0_0_rgba(255,255,255,0.02)_inset] backdrop-blur ${effectiveTheme === 'dark' ? 'dark' : ''}`}
style={{ maxWidth: 'min(480px, calc(100vw - 2rem))' }}
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-usdh-text">Swap to USDH</h3>
Expand All @@ -353,88 +404,81 @@ export function USDHSwap(props: USDHSwapProps) {
)}
</div>

{!isConnected ? (
<p className="mt-6 rounded-xl border border-usdh-border/60 bg-usdh-bg/50 p-5 text-center text-xs text-usdh-text-soft">
Connect a wallet on {networkLabel(network)} to continue.
</p>
) : (
{onWrongChain && (
<WrongNetworkBanner
onSwitch={() => switchChain({ chainId: expectedChainId })}
isSwitching={isSwitching}
/>
)}

{isConnected && <BalanceRow balances={balances} sourceChain={sourceChain} />}

<div className="mt-3">
<PayCard
amountStr={amountStr}
onAmountChange={setAmountStr}
inputDisabled={inputDisabled}
sourceChain={sourceChain}
onSourceToggle={toggleSourceChain}
payUsdValue={payUsdValue}
hasMaxBalance={hasMaxBalance}
onMax={setMaxAmount}
/>
<ArrowDivider />
<ReceiveCard
receiveDisplay={receiveDisplay}
receiveUsdValue={receiveUsdValue}
isQuoting={isQuoting || isReadOnlyQuoting}
hasQuote={quote !== null || readOnlyEstimate !== null}
/>
</div>

{!showingResult && (
<>
{onWrongChain && (
<WrongNetworkBanner
onSwitch={() => switchChain({ chainId: expectedChainId })}
isSwitching={isSwitching}
/>
{insufficientForRoute && (
<p className="mt-2 text-[11px] text-usdh-text-soft">
Exceeds your {sourceChain === 'evm' ? 'HyperEVM' : 'HyperCore'} USDC balance.
</p>
)}
{belowMinOrderValue && (
<p className="mt-2 text-[11px] text-usdh-text-soft">
Hyperliquid spot orders need more than 10 USDC. Use {MIN_SWAP_DISPLAY}+ USDC.
</p>
)}

<BalanceRow balances={balances} sourceChain={sourceChain} />

<div className="mt-3">
<PayCard
amountStr={amountStr}
onAmountChange={setAmountStr}
inputDisabled={inputDisabled}
sourceChain={sourceChain}
onSourceToggle={toggleSourceChain}
payUsdValue={payUsdValue}
hasMaxBalance={hasMaxBalance}
onMax={setMaxAmount}
/>
<ArrowDivider />
<ReceiveCard
receiveDisplay={receiveDisplay}
receiveUsdValue={receiveUsdValue}
isQuoting={isQuoting}
hasQuote={quote !== null}
/>
</div>

{!showingResult && (
<>
{insufficientForRoute && (
<p className="mt-2 text-[11px] text-usdh-text-soft">
Exceeds your {sourceChain === 'evm' ? 'HyperEVM' : 'HyperCore'} USDC balance.
</p>
)}
{belowMinOrderValue && (
<p className="mt-2 text-[11px] text-usdh-text-soft">
Hyperliquid spot orders need more than 10 USDC. Use {MIN_SWAP_DISPLAY}+ USDC.
</p>
)}

<SlippageRow
slippageBps={slippageBps}
onPreset={applySlippagePreset}
showCustom={showCustomSlippage}
onToggleCustom={() => setShowCustomSlippage((v) => !v)}
customStr={customSlippageStr}
onCustomChange={applyCustomSlippage}
disabled={inputDisabled}
/>

<ActionButton
phase={phase}
insufficient={insufficientForRoute}
belowMinOrderValue={belowMinOrderValue}
requiresBridge={requiresBridge}
sourceChain={sourceChain}
needsTradingSession={!sessionReady}
disabled={!canSwap}
onClick={executeBridgeAndSwap}
/>

{phase === 'bridging' && (
<p className="mt-2 text-center text-[11px] leading-snug text-usdh-text-faint">
Bridging usually credits in about 1-2 minutes. Checking HyperCore credit for{' '}
<span className="font-mono text-usdh-text-soft">{bridgeElapsedSeconds}s</span>.
</p>
)}
{showInlineNote && <InlineSystemAddressNote />}
</>
<SlippageRow
slippageBps={slippageBps}
onPreset={applySlippagePreset}
showCustom={showCustomSlippage}
onToggleCustom={() => setShowCustomSlippage((v) => !v)}
customStr={customSlippageStr}
onCustomChange={applyCustomSlippage}
disabled={inputDisabled}
/>

<ActionButton
phase={phase}
insufficient={insufficientForRoute}
belowMinOrderValue={belowMinOrderValue}
isConnected={isConnected}
requiresBridge={requiresBridge}
sourceChain={sourceChain}
needsTradingSession={!sessionReady}
disabled={!canSwap}
onClick={executeBridgeAndSwap}
/>

{phase === 'bridging' && (
<p className="mt-2 text-center text-[11px] leading-snug text-usdh-text-faint">
Bridging usually credits in about 1-2 minutes. Checking HyperCore credit for{' '}
<span className="font-mono text-usdh-text-soft">{bridgeElapsedSeconds}s</span>.
</p>
)}
{error && <ErrorAlert message={error} />}
{result && <ResultPanel result={result} onReset={reset} />}
{showInlineNote && <InlineSystemAddressNote />}
</>
)}
{error && <ErrorAlert message={error} />}
{result && <ResultPanel result={result} onReset={reset} />}

{!hideAttribution && (
<div className="mt-3 border-t border-usdh-border pt-2.5">
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/use-balances.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export function useUsdcBalances(

const tokenQuery = useQuery({
queryKey: ['usdh-kit', network, 'stable-token-info'],
enabled: Boolean(address),
queryFn: async () => resolveStableTokens(network, await info.spotMeta()),
staleTime: 5 * 60_000,
})
Expand Down
6 changes: 3 additions & 3 deletions packages/widget/test/bundle-size.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,19 @@ 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: ~55.1 KB ESM after the browser agent-session flow, native
* 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.
*/
const BUDGET_KB = 56
const BUDGET_KB = 58

describe('widget bundle size', () => {
it('ESM bundle stays under budget', () => {
const distPath = resolve(__dirname, '../dist/index.js')
if (!existsSync(distPath)) {
throw new Error(
`dist/index.js missing at ${distPath} run \`pnpm --filter @usdh-kit/widget build\` before \`pnpm test\``,
`dist/index.js missing at ${distPath} -- run \`pnpm --filter @usdh-kit/widget build\` before \`pnpm test\``,
)
}
const sizeBytes = statSync(distPath).size
Expand Down
Loading
Loading