From 9d3714fb076524ef45651bcc5fb439e17c9cfec4 Mon Sep 17 00:00:00 2001 From: sumfxn Date: Mon, 11 May 2026 12:52:26 +0200 Subject: [PATCH 1/2] feat(sdk): add useful usdh flows --- .changeset/sdk-useful-usdh-flows.md | 5 + README.md | 12 +- docs/README.md | 7 +- docs/architecture.md | 56 +++++--- docs/bridge-and-swap.md | 27 +++- docs/glossary.md | 2 + docs/troubleshooting.md | 7 + packages/sdk/README.md | 45 +++++- packages/sdk/src/bridge.ts | 161 ++++++++++++++++++++- packages/sdk/src/index.ts | 12 +- packages/sdk/src/kit.ts | 184 +++++++++++++++++++----- packages/sdk/src/pair-resolver.ts | 3 + packages/sdk/src/pricing.ts | 8 ++ packages/sdk/src/signing.ts | 68 +++++++++ packages/sdk/src/types/bridge.ts | 26 +++- packages/sdk/src/types/swap.ts | 29 ++-- packages/sdk/test/bridge.test.ts | 179 ++++++++++++++++++++++- packages/sdk/test/kit.test.ts | 116 +++++++++++++++ packages/sdk/test/pair-resolver.test.ts | 1 + 19 files changed, 860 insertions(+), 88 deletions(-) create mode 100644 .changeset/sdk-useful-usdh-flows.md diff --git a/.changeset/sdk-useful-usdh-flows.md b/.changeset/sdk-useful-usdh-flows.md new file mode 100644 index 0000000..f323614 --- /dev/null +++ b/.changeset/sdk-useful-usdh-flows.md @@ -0,0 +1,5 @@ +--- +'@usdh-kit/sdk': minor +--- + +Add useful USDH flows: reverse USDH to USDC swaps on HyperCore and bridgeFromCore for linked USDC/USDH spot assets via Hyperliquid sendAsset. diff --git a/README.md b/README.md index 4a4592f..e67f85e 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ Pre-release. Public API is unstable until `1.0.0`. What works today: -- `getQuote()` and `swap()` for `USDC → USDH` end to end (signing + msgpack + IOC limit submission) +- `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 @@ -43,7 +44,6 @@ What works today: Deferred to follow-up PRs: - USDT pricing and swap (USDT/USDC/USDH double-hop) -- Reverse direction (USDH → USDC) and `bridgeFromCore` - Multi-chain source via LiFi/Squid (Ethereum, Arbitrum, Base) ## Install @@ -97,6 +97,10 @@ 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', @@ -107,7 +111,7 @@ console.log(`route: ${routed.route.sourceChain}`) console.log(`order: ${routed.swap.orderId}`) ``` -`swap()` submits an IOC limit order priced `slippageBps` above the mid; max slippage is enforced pre-fill by Hyperliquid's matcher. The returned `result.slippageBps` is the realised slippage versus mid. +`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 @@ -143,7 +147,9 @@ A few real flows the SDK is shaped for today. Runnable examples are still on the ## 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 diff --git a/docs/README.md b/docs/README.md index 021f0b3..292a9e1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,20 +2,21 @@ TypeScript SDK and React widget for USDH on Hyperliquid. -`usdh-kit` helps apps convert USDC into USDH without reimplementing Hyperliquid spot routing, EIP-712 order signing, HyperEVM bridge transactions, or bridge-credit polling. +`usdh-kit` helps apps work with USDH without reimplementing Hyperliquid spot discovery, EIP-712 order signing, HyperEVM bridge transactions, or bridge-credit polling. ## Packages | Package | Purpose | |---|---| -| `@usdh-kit/sdk` | Quote, route, bridge, and swap `USDC -> USDH`. | +| `@usdh-kit/sdk` | Quote, route, bridge, and swap USDH-focused flows. | | `@usdh-kit/widget` | Embeddable React swap widget built on the SDK. | ## What works today -* Quote and swap `USDC -> USDH` on the canonical Hyperliquid spot pair. +* 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. * 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. diff --git a/docs/architecture.md b/docs/architecture.md index aca1ce6..6a2a290 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -9,7 +9,7 @@ packages/sdk/src ├── kit.ts entry point: createUsdhKit(config) → UsdhKit ├── pair-resolver.ts caches the USDH/USDC spot pair from /info ├── pricing.ts decimal parsing, mid-price computation -├── bridge.ts HyperEVM → HyperCore transfer + credit polling +├── bridge.ts HyperEVM ↔ HyperCore transfers + credit polling ├── signing.ts EIP-712 typed-data signing for HL L1 actions ├── msgpack.ts canonical msgpack encoding (action_hash input) ├── abi.ts ERC-20 approve/deposit/transfer encoding for bridge txs @@ -27,7 +27,7 @@ packages/sdk/src ## Initial setup -`createUsdhKit({ network, signer, accountAddress?, evmWallet?, slippageBps?, fetch?, timeoutMs?, logger? })` validates the config synchronously and returns an object exposing `swap`, `getQuote`, `getRoute`, `preflightSwap`, `bridgeAndSwap`, and `bridgeToCore`. Two transport clients are created lazily — one for read (`/info`) and one for write (`/exchange`). +`createUsdhKit({ network, signer, accountAddress?, evmWallet?, slippageBps?, fetch?, timeoutMs?, logger? })` validates the config synchronously and returns an object exposing `swap`, `getQuote`, `getRoute`, `preflightSwap`, `bridgeAndSwap`, `bridgeToCore`, and `bridgeFromCore`. Two transport clients are created lazily — one for read (`/info`) and one for write (`/exchange`). When `signer` is an approved Hyperliquid agent wallet, set `accountAddress` to the user's master wallet. Reads, routing, balances, and bridge ownership use `accountAddress`; L1 order signatures use `signer`. @@ -36,9 +36,13 @@ The USDH/USDC pair is resolved on first call (cached for the kit's lifetime) by ## getQuote ``` -QuoteInput → resolvePair() → info.l2Book(pair.name) → midPrice18(book) - → estimatedReceived - → return Quote +QuoteInput + ↓ resolve direction (USDC → USDH buy, or USDH → USDC sell) + ↓ resolvePair() + ↓ info.l2Book(pair.name) + ↓ midPrice18(book) + ↓ amount / price or amount * price + ↓ return Quote { from, to, estimatedReceived } ``` No signing. No state. Quote is valid for 30 seconds (`validUntil`). @@ -47,12 +51,12 @@ No signing. No state. Quote is valid for 30 seconds (`validUntil`). ``` RouteInput - ↓ validate source + amount + slippage + ↓ validate source + target + amount + slippage ↓ resolvePair() ↓ info.l2Book(pair.name) → Quote ↓ info.spotClearinghouseState(user) → HyperCore source balance ↓ spendable = total - hold (floored at zero) - ↓ requiredHypercoreBalance = amount + slippage buffer + HC fee buffer + ↓ requiredHypercoreBalance = source amount + optional buy buffer + HC fee buffer ↓ choose sourceChain: ├── HyperCore covers → sourceChain: 'hypercore' └── otherwise → sourceChain: 'hyperevm' @@ -60,17 +64,19 @@ RouteInput ``` `preflightSwap()` is an alias for `getRoute()` so UI code can use the name that -best matches its intent. These helpers inspect spendable HyperCore balance only; they do not read the user's HyperEVM ERC20 balance. `getHypercoreBalance()` is exposed separately for apps that want to display `total`, `hold`, and `available` without computing a route. +best matches its intent. These helpers inspect spendable HyperCore balance only; they do not read the user's HyperEVM ERC20 balance. `USDH → USDC` is HyperCore-only in v1 because there is no HyperEVM direct swap router in scope. `getHypercoreBalance()` is exposed separately for apps that want to display `total`, `hold`, and `available` without computing a route. -## swap (USDC path) +## swap ``` SwapInput + ↓ resolve direction ↓ resolvePair() ↓ info.l2Book(pair.name) ↓ midPrice18(book) - ↓ limitPrice18 = mid * (10000 + slippageBps) / 10000 - ↓ build msgpack action: { type: 'order', orders: [{ a, b: true, p, s, r: false, t: { limit: { tif: 'Ioc' } } }], grouping: 'na' } + ↓ buy: limitPrice18 = mid * (10000 + slippageBps) / 10000 + ↓ sell: limitPrice18 = mid * (10000 - slippageBps) / 10000 + ↓ build msgpack action: { type: 'order', orders: [{ a, b, p, s, r: false, t: { limit: { tif: 'Ioc' } } }], grouping: 'na' } ↓ signL1Action({ signer, action, nonce, network, expiresAfter }) │ ├── canonical msgpack encode of action │ ├── keccak256 → action_hash @@ -83,7 +89,7 @@ SwapInput └── error → throw NetworkError(`order error: ${...}`) ``` -The IOC limit ensures Hyperliquid's matcher rejects fills at worse than `mid + slippageBps`. The kit's realised slippage (`SwapResult.slippageBps`) is computed from `avgPx` vs `mid`. +For `USDC → USDH`, the order buys USDH (`b: true`) with a max price of `mid + slippageBps`. For `USDH → USDC`, the order sells USDH (`b: false`) with a min price of `mid - slippageBps`. The kit's realised slippage (`SwapResult.slippageBps`) is computed from `avgPx` vs `mid`. ## bridgeToCore @@ -102,6 +108,22 @@ No explicit HyperCore-side signing — the credit is automatic once the EVM tx c For USDC, the wallet can see two HyperEVM transactions: `approve` if allowance is insufficient, then `deposit`. The final `USDC → USDH` trade is a HyperCore order signed separately by the configured `signer` (usually an approved agent in browser apps). +## bridgeFromCore + +``` +BridgeFromCoreInput + ↓ resolve linked token + system address from spotMeta + ↓ require signer.address === accountAddress + ↓ read spendable HyperCore balance + ↓ sign user-signed sendAsset action + ├── EIP-712 typed data domain ('HyperliquidSignTransaction', chainId 0x66eee) + └── token = USDC or tokenName:tokenId, destination = system address + ↓ exchange.submit({ action, signature, nonce }) + ↓ return BridgeFromCoreResult { systemAddress, recipient, submittedAt } +``` + +`bridgeFromCore()` supports linked `USDC` and `USDH` spot assets. It does not accept an arbitrary HyperEVM recipient in v1: Hyperliquid credits the EVM-side account associated with the Core action sender, so the safest public API is sender-owned bridge-out only. + ## bridgeAndSwap ``` @@ -120,8 +142,10 @@ BridgeAndSwapInput The helper emits optional progress events: `route`, `bridging`, `swapping`, `done`. It intentionally re-quotes inside `swap()` after a bridge completes so -the order limit is based on fresh book state. `BridgeAndSwapError` is reserved -for lifecycle failures where phase context matters; route blockers still throw +the order limit is based on fresh book state. Reverse `USDH → USDC` routes do +not bridge from HyperEVM first; callers can run `bridgeFromCore()` after the +swap if they need the USDC on HyperEVM. `BridgeAndSwapError` is reserved for +lifecycle failures where phase context matters; route blockers still throw `MissingEvmWalletError` or `InsufficientBalanceError` directly. ## Errors @@ -132,7 +156,7 @@ All SDK errors extend `UsdhKitError`. Subclasses give consumers `instanceof` gra - `InsufficientBalanceError` — pre-flight balance check failed - `BridgeTimeoutError` — credit never landed within timeout - `BridgeAndSwapError` — wraps unexpected `bridgeAndSwap` route, bridge, or swap failures with `phase`, `route`, optional `bridge`, and `cause`; `isBridgeAndSwapError()` narrows both class instances and structural copies -- `InvalidInputError` — amount, decimal string, or other input is malformed +- `InvalidInputError` — amount, decimal string, bridge ownership, or other input is malformed - `SigningError` — `signer.signTypedData` rejected or returned invalid sig - `NetworkError` — `/info` or `/exchange` fetch failed, or HL returned a protocol-level error - `NotImplementedError` — feature deferred (e.g. USDT path) @@ -147,7 +171,7 @@ Outcome reads are experimental and read-only. The SDK validates `outcomeMeta`, derives encoded side coins like `#200`, and reuses the hardened `l2Book()` path for books. It does not place outcome orders or claim a settlement asset. -`ExchangeClient` is internal — consumers should call `kit.swap()` rather than building actions themselves. +`ExchangeClient` is internal — consumers should call `kit.swap()`, `kit.bridgeFromCore()`, or the order helpers rather than building actions themselves. ## Bridge polling internals diff --git a/docs/bridge-and-swap.md b/docs/bridge-and-swap.md index 12e1436..3658282 100644 --- a/docs/bridge-and-swap.md +++ b/docs/bridge-and-swap.md @@ -39,6 +39,31 @@ The wallet may show two Rabby prompts: After the HyperCore credit lands, the approved agent signs the USDH order. The connected wallet should not receive a third popup for the order itself. +## Reverse direction + +`USDH -> USDC` is a HyperCore-only spot swap: + +```ts +await kit.swap({ + from: 'USDH', + to: 'USDC', + amount: 11_000_000n, +}) +``` + +If the user wants USDC back on HyperEVM, call `bridgeFromCore()` after the swap: + +```ts +const swap = await kit.swap({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) +const bridgeOut = await kit.bridgeFromCore({ asset: 'USDC', amount: swap.received }) +console.log(bridgeOut.submittedAt) +``` + +`bridgeFromCore()` uses Hyperliquid's user-signed `sendAsset` action to the +token system address. The HyperEVM recipient is the sender of that Core action, +so the configured `signer` must be the master account, not an approved agent +for a separate `accountAddress`. + ## Progress events Use `onProgress` for UI state: @@ -74,7 +99,5 @@ try { ## Current limitations -* Reverse direction (`USDH -> USDC`) is not part of V1. -* `bridgeFromCore` is deferred. * USDT routing is deferred. * Allowance-aware approval skipping is a planned UX optimization. diff --git a/docs/glossary.md b/docs/glossary.md index e6ddbf6..1962a3b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -12,6 +12,8 @@ Hyperliquid-specific terms used across `@usdh-kit/sdk` and `@usdh-kit/widget`. **Bridge polling** — `bridgeToCore` submits the EVM bridge transaction, then polls `spotClearinghouseState` until the credit lands (default timeout 180s). The kit returns once the credit is confirmed; no extra HyperCore signing needed. +**Bridge out** — `bridgeFromCore` submits a Hyperliquid `sendAsset` action to the linked token system address so a HyperCore spot asset can move back to HyperEVM. The signer must be the same account whose HyperCore balance is being spent. + ## Trading **Spot pair** — Hyperliquid spot market. Identified by an alias like `@230` (USDH/USDC) and a numeric `assetIndex`. The pair has a base token (USDH), a quote token (USDC), and decimal conventions for both. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index a252f3c..574e9fe 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -16,6 +16,13 @@ const kit = createUsdhKit({ }) ``` +### `InvalidInputError: bridgeFromCore requires signer.address to match accountAddress` + +`bridgeFromCore()` signs a user-owned HyperCore `sendAsset` action. Approved +agent wallets are useful for spot orders, but they cannot bridge funds out for a +master account. Create a separate kit with the master wallet as `signer` before +calling `bridgeFromCore()`. + Browser apps that use an approved agent should also pass `accountAddress` so bridge ownership and balance reads stay tied to the master wallet: ```ts diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 229e700..b6d8487 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -18,8 +18,9 @@ Pre-release. Public API is unstable until `1.0.0`. What works today: -* `getQuote()` and `swap()` for `USDC → USDH` end to end (signing + msgpack + IOC limit submission) +* `getQuote()` and `swap()` for `USDC → USDH` and `USDH → USDC` end to end (signing + msgpack + IOC limit submission) * `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 @@ -27,7 +28,7 @@ What works today: * 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, reverse direction (USDH → USDC), multi-chain source. +Deferred to follow-up PRs: USDT pricing/swap and multi-chain source. ## Install @@ -62,10 +63,10 @@ try { } ``` -`swap()` submits an IOC limit order priced `slippageBps` above the mid (max -slippage is enforced pre-fill by Hyperliquid's matcher). The returned -`result.slippageBps` is the realised slippage versus mid; tighten the tolerance -and retry if it's higher than you expected. +`swap()` submits an IOC limit order priced from the book mid plus/minus +`slippageBps` depending on direction. The returned `result.slippageBps` is the +realised slippage versus mid; tighten the tolerance and retry if it's higher +than you expected. ## Agent wallets @@ -107,11 +108,18 @@ if (Date.now() < quote.validUntil) { console.log(`mid-price on ${quote.pair}: ${quote.midPrice}`) console.log(`would receive ~${quote.estimatedReceived} USDH`) } + +const reverse = await kit.getQuote({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) +console.log(`would receive ~${reverse.estimatedReceived} USDC`) ``` ## Route and preflight -`getHypercoreBalance()` returns total, held, and spendable HyperCore balance for a source stable. `getRoute()` decides whether the user can swap directly from HyperCore or needs to bridge from HyperEVM first. It checks spendable HyperCore source balance (`total - hold`), applies the configured slippage plus a small HC fee buffer, and returns a quote alongside the route decision. +`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 +`bridgeFromCore()` if you need the resulting USDC on HyperEVM. ```ts const balance = await kit.getHypercoreBalance({ asset: 'USDC' }) @@ -132,6 +140,27 @@ if (route.requiresBridge) { the bridge route, `canSwap` only means the kit has an `evmWallet` configured; the wallet/RPC will still reject an underfunded bridge transaction. +## Reverse swap and bridge out + +`USDH -> USDC` uses the same `USDH/USDC` spot pair, but sells USDH instead of +buying it. It is intentionally HyperCore-only. + +```ts +const sold = await kit.swap({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) +console.log(`got ${sold.received} USDC`) +``` + +`bridgeFromCore()` sends linked spot assets from HyperCore to their token system +address. Hyperliquid credits the EVM recipient as the sender of the Core action, +so this helper requires the configured signer to be the master account +(`signer.address === accountAddress`); approved agent wallets cannot withdraw +Core funds for another account. + +```ts +const out = await kit.bridgeFromCore({ asset: 'USDC', amount: sold.received }) +console.log(out.systemAddress, out.submittedAt) +``` + ## Discover USDH spot markets `listPairs()` returns every Hyperliquid spot pair where USDH is either base or @@ -223,6 +252,8 @@ Unexpected route, bridge, or swap failures are wrapped in `BridgeAndSwapError`. * `USDC → USDH` quote and swap via the canonical HL spot pair * HyperEVM → HyperCore bridge with credit polling (`bridgeToCore`) +* HyperCore → HyperEVM bridge-out for linked USDC/USDH spot assets (`bridgeFromCore`) +* `USDH → USDC` reverse swap on HyperCore * `getRoute()` / `preflightSwap()` route selection and preflight metadata * `bridgeAndSwap()` high-level orchestration with progress callbacks * USDH spot market discovery and read-only books/mids for USDH pairs diff --git a/packages/sdk/src/bridge.ts b/packages/sdk/src/bridge.ts index d08e3b4..82b0619 100644 --- a/packages/sdk/src/bridge.ts +++ b/packages/sdk/src/bridge.ts @@ -2,17 +2,28 @@ import { encodeCoreDeposit, encodeErc20Approve, encodeErc20Transfer } from './ab import { bigintToBytesBE, bytesToHex } from './bytes.js' import { BridgeTimeoutError, + InsufficientBalanceError, InvalidInputError, MissingEvmWalletError, NetworkError, } from './errors.js' +import { signSendAssetAction } from './signing.js' +import type { ExchangeClient, ExchangeResponse } from './transport/exchange.js' import type { InfoClient } from './transport/info.js' import type { SpotMeta, SpotToken } from './transport/types.js' -import type { BridgeAsset, BridgeInput, BridgeResult } from './types/bridge.js' +import type { + BridgeFromCoreAsset, + BridgeFromCoreInput, + BridgeFromCoreResult, + BridgeInput, + BridgeResult, + BridgeToCoreAsset, +} from './types/bridge.js' import type { EvmWallet } from './types/evm-wallet.js' import type { Address, Hex } from './types/hex.js' import type { Logger } from './types/logger.js' import type { Network } from './types/network.js' +import type { Signer } from './types/signer.js' import { getHyperEvmNativeUsdcAddress } from './usdc.js' /** HyperEVM chain ids per network. */ @@ -24,6 +35,7 @@ const HYPER_EVM_CHAIN_ID: Record = { const DEFAULT_CREDIT_TIMEOUT_MS = 180_000 const CREDIT_POLL_INTERVAL_MS = 1_000 const SPOT_DEX_ID = 0xffffffff +const STABLE_DECIMALS = 6 const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/ @@ -38,6 +50,17 @@ export interface BridgeDeps { sleep?: (ms: number) => Promise } +export interface BridgeFromCoreDeps { + info: InfoClient + exchange: ExchangeClient + signer: Signer + network: Network + logger: Logger + accountAddress: Address + /** Override `Date.now`; injected for tests. */ + now?: () => number +} + /** Lowercase an address so equality checks across HL/EVM sources stay consistent. */ function normalizeAddress(addr: Address): Address { return addr.toLowerCase() as Address @@ -60,7 +83,10 @@ export function tokenSystemAddress(tokenIndex: number): Address { return bytesToHex(sys) as Address } -function findStableToken(meta: SpotMeta, asset: BridgeAsset): SpotToken { +function findStableToken( + meta: SpotMeta, + asset: BridgeToCoreAsset | BridgeFromCoreAsset, +): SpotToken { const token = meta.tokens.find((t) => t.name === asset) if (!token) { throw new NetworkError(`${asset} not found in spotMeta`) @@ -71,7 +97,11 @@ function findStableToken(meta: SpotMeta, asset: BridgeAsset): SpotToken { return token } -function evmSourceAddress(network: Network, asset: BridgeAsset, linkedContract: Address): Address { +function evmSourceAddress( + network: Network, + asset: BridgeToCoreAsset, + linkedContract: Address, +): Address { if (asset === 'USDC') { return getHyperEvmNativeUsdcAddress(network) } @@ -116,6 +146,38 @@ async function readCoreBalance( return parseHcAmount(row.total, weiDecimals) } +async function readCoreAvailable( + info: InfoClient, + user: Address, + tokenIndex: number, + weiDecimals: number, +): Promise<{ total: bigint; hold: bigint; available: bigint }> { + const state = await info.spotClearinghouseState(user) + const row = state.balances.find((b) => b.token === tokenIndex) + if (!row) { + return { total: 0n, hold: 0n, available: 0n } + } + const total = parseHcAmount(row.total, weiDecimals) + const hold = parseHcAmount(row.hold, weiDecimals) + return { total, hold, available: total > hold ? total - hold : 0n } +} + +function scaleAmountExact(amount: bigint, fromDecimals: number, toDecimals: number): bigint { + const diff = toDecimals - fromDecimals + if (diff >= 0) return amount * 10n ** BigInt(diff) + const divisor = 10n ** BigInt(-diff) + if (amount % divisor !== 0n) { + throw new InvalidInputError('amount has too much precision for the linked HyperCore asset') + } + return amount / divisor +} + +function sendAssetToken(asset: BridgeFromCoreAsset, token: SpotToken): string { + // Circle's HyperCore -> HyperEVM USDC guide uses bare "USDC"; generic linked + // spot assets use the broader tokenName:tokenId form from Hyperliquid docs. + return asset === 'USDC' ? 'USDC' : `${asset}:${token.tokenId}` +} + export interface BridgeRunArgs extends BridgeInput { user: Address } @@ -211,8 +273,86 @@ export async function runBridgeToCore( throw new BridgeTimeoutError(txHash, timeoutMs) } +/** + * Send a linked spot asset from HyperCore back to HyperEVM by using + * Hyperliquid's user-signed `sendAsset` action to the token system address. + * + * Protocol caveat: the EVM recipient is the sender of the Core action. Because + * API wallets/agents are separate users, this helper requires the configured + * signer to match `accountAddress`. + */ +export async function runBridgeFromCore( + args: BridgeFromCoreInput, + deps: BridgeFromCoreDeps, +): Promise { + if (typeof args.amount !== 'bigint' || args.amount <= 0n) { + throw new InvalidInputError('amount must be a positive bigint') + } + if (args.asset !== 'USDC' && args.asset !== 'USDH') { + throw new InvalidInputError(`asset must be 'USDC' or 'USDH', got ${String(args.asset)}`) + } + + const accountAddress = normalizeAddress(deps.accountAddress) + const signerAddress = normalizeAddress(deps.signer.address) + if (accountAddress !== signerAddress) { + throw new InvalidInputError('bridgeFromCore requires signer.address to match accountAddress') + } + + const meta = await deps.info.spotMeta() + const token = findStableToken(meta, args.asset) + const systemAddress = tokenSystemAddress(token.index) + const requiredCore = scaleAmountExact(args.amount, STABLE_DECIMALS, token.weiDecimals) + const balance = await readCoreAvailable(deps.info, accountAddress, token.index, token.weiDecimals) + if (balance.available < requiredCore) { + throw new InsufficientBalanceError(requiredCore, balance.available, args.asset) + } + + const submittedAt = deps.now?.() ?? Date.now() + const nonce = BigInt(submittedAt) + const amount = formatStableAmount(args.amount) + const tokenWire = sendAssetToken(args.asset, token) + const { action, signature } = await signSendAssetAction({ + signer: deps.signer, + network: deps.network, + destination: systemAddress, + sourceDex: 'spot', + destinationDex: 'spot', + token: tokenWire, + amount, + fromSubAccount: '', + nonce, + }) + + deps.logger.debug('bridgeFromCore.signing', { + asset: args.asset, + amount, + systemAddress, + token: tokenWire, + }) + const response: ExchangeResponse = await deps.exchange.submit({ action, signature, nonce }) + if (response.status === 'err') { + throw new NetworkError(`exchange error: ${response.response}`) + } + if (!isDefaultExchangeResponse(response.response)) { + throw new NetworkError('unexpected /exchange response shape for sendAsset action') + } + deps.logger.info('bridgeFromCore.submitted', { + asset: args.asset, + amount, + systemAddress, + submittedAt, + }) + return { + asset: args.asset, + amount: args.amount, + systemAddress, + recipient: accountAddress, + submittedAt, + } +} + async function submitBridgeTransaction(args: { - asset: BridgeAsset + asset: BridgeToCoreAsset amount: bigint evmWallet: EvmWallet network: Network @@ -251,3 +391,16 @@ async function sendAndValidate( } return txHashRaw.toLowerCase() as Hex } + +function formatStableAmount(amount: bigint): string { + const padded = amount.toString().padStart(STABLE_DECIMALS + 1, '0') + const intPart = padded.slice(0, -STABLE_DECIMALS) + const fracPart = padded.slice(-STABLE_DECIMALS).replace(/0+$/, '') + return fracPart === '' ? intPart : `${intPart}.${fracPart}` +} + +function isDefaultExchangeResponse(value: unknown): value is { type: 'default' } { + return ( + value !== null && typeof value === 'object' && (value as { type?: unknown }).type === 'default' + ) +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index d846104..d2ed27f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -68,7 +68,15 @@ export { isBridgeAndSwapError, } from './errors.js' -export type { BridgeAsset, BridgeInput, BridgeResult } from './types/bridge.js' +export type { + BridgeAsset, + BridgeFromCoreAsset, + BridgeFromCoreInput, + BridgeFromCoreResult, + BridgeInput, + BridgeResult, + BridgeToCoreAsset, +} from './types/bridge.js' export type { KitConfig } from './types/config.js' export type { EvmTransactionRequest, EvmWallet } from './types/evm-wallet.js' export type { Address, Hex } from './types/hex.js' @@ -93,7 +101,9 @@ export type { RouteInput, SourceChain, SourceStable, + SwapAsset, SwapInput, SwapResult, SwapRoute, + TargetStable, } from './types/swap.js' diff --git a/packages/sdk/src/kit.ts b/packages/sdk/src/kit.ts index 8ddea0a..2dde562 100644 --- a/packages/sdk/src/kit.ts +++ b/packages/sdk/src/kit.ts @@ -1,4 +1,4 @@ -import { runBridgeToCore } from './bridge.js' +import { runBridgeFromCore, runBridgeToCore } from './bridge.js' import { type GetMidsOpts, type GetUsdhPairInput, @@ -31,6 +31,7 @@ import { } from './outcomes.js' import { type ResolvedPair, createPairResolver } from './pair-resolver.js' import { + applyPrice, applyPriceInverse, formatDecimal, formatSpotPrice, @@ -47,7 +48,12 @@ import { } from './transport/exchange.js' import { type InfoClient, type NSigFigs, createInfoClient } from './transport/info.js' import type { L2Book, OpenOrder, OrderStatusResponse } from './transport/types.js' -import type { BridgeInput, BridgeResult } from './types/bridge.js' +import type { + BridgeFromCoreInput, + BridgeFromCoreResult, + BridgeInput, + BridgeResult, +} from './types/bridge.js' import type { KitConfig } from './types/config.js' import type { Logger } from './types/logger.js' import { silentLogger } from './types/logger.js' @@ -60,10 +66,11 @@ import type { QuoteInput, RouteInput, SourceChain, - SourceStable, + SwapAsset, SwapInput, SwapResult, SwapRoute, + TargetStable, } from './types/swap.js' const DEFAULT_SLIPPAGE_BPS = 20 @@ -102,6 +109,12 @@ export interface UsdhKit { * timeout 180s). Requires `KitConfig.evmWallet`. */ bridgeToCore(input: BridgeInput): Promise + /** + * Bridge a linked spot asset from HyperCore to HyperEVM by sending it to the + * token's system address. Requires the configured signer to be the master + * account because the HyperEVM recipient is the sender of the Core action. + */ + bridgeFromCore(input: BridgeFromCoreInput): Promise /** List USDH-bearing spot pairs from `spotMeta`. Cached after the first call. */ listPairs(opts?: ListUsdhPairsOpts): Promise /** Find one USDH-bearing spot pair by base/quote token names. */ @@ -170,15 +183,19 @@ export function createUsdhKit(config: KitConfig): UsdhKit { async function swap(input: SwapInput): Promise { validateSwapInput(input) + const direction = resolveSwapDirection(input) const slippageBps = input.slippageBps ?? defaultSlippageBps - if (input.from === 'USDT') { + if (direction.from === 'USDT') { throw new NotImplementedError('USDT swap lands in a follow-up PR') } if (input.amount < MIN_ORDER_SOURCE_AMOUNT) { - throw new InvalidInputError('amount must be greater than 10 USDC for Hyperliquid spot orders') + throw new InvalidInputError( + `amount must be greater than 10 ${direction.from} for Hyperliquid spot orders`, + ) } logger.debug('swap.requested', { - from: input.from, + from: direction.from, + to: direction.to, amount: input.amount.toString(), slippageBps, }) @@ -187,16 +204,21 @@ export function createUsdhKit(config: KitConfig): UsdhKit { const book = await info.l2Book(pair.name) const mid = midPrice18(book) - const limitPrice18 = (mid * (10_000n + BigInt(slippageBps))) / 10_000n + const limitPrice18 = + direction.side === 'buy' + ? (mid * (10_000n + BigInt(slippageBps))) / 10_000n + : (mid * (10_000n - BigInt(slippageBps))) / 10_000n const limitPriceStr = formatSpotPrice(limitPrice18, pair.baseSzDecimals) - const wireLimitPrice18 = parseDecimal(limitPriceStr, PRICE_DECIMALS) - const sizeUsdh = applyPriceInverse(input.amount, wireLimitPrice18) + const sizeUsdh = + direction.side === 'buy' + ? applyPriceInverse(input.amount, parseDecimal(limitPriceStr, PRICE_DECIMALS)) + : input.amount if (sizeUsdh === 0n) { throw new InvalidInputError('amount too small to fill at the slippage-tolerant limit') } const sizeStr = formatDecimal(sizeUsdh, STABLE_DECIMALS, pair.baseSzDecimals) - const action = buildOrderAction(pair, limitPriceStr, sizeStr) + const action = buildOrderAction(pair, direction.side, limitPriceStr, sizeStr) const nonce = nextNonce() const expiresAfter = nonce + ORDER_EXPIRES_AFTER_MS @@ -228,7 +250,7 @@ export function createUsdhKit(config: KitConfig): UsdhKit { throw new NetworkError('exchange returned no order status') } - return finalizeFill(status, mid, logger) + return finalizeFill(status, mid, direction, logger) } return { @@ -236,7 +258,7 @@ export function createUsdhKit(config: KitConfig): UsdhKit { swap, async getHypercoreBalance(input: HypercoreBalanceInput): Promise { - assertSourceStable(input.asset) + assertBalanceAsset(input.asset) const meta = await info.spotMeta() const token = meta.tokens.find((t) => t.name === input.asset) if (!token) { @@ -280,7 +302,7 @@ export function createUsdhKit(config: KitConfig): UsdhKit { try { bridge = await runBridgeToCore( { - asset: input.from, + asset: route.from as BridgeInput['asset'], amount: input.amount, user: accountAddress, ...(input.waitForCreditTimeoutMs !== undefined && { @@ -327,6 +349,17 @@ export function createUsdhKit(config: KitConfig): UsdhKit { ) }, + async bridgeFromCore(input: BridgeFromCoreInput): Promise { + return runBridgeFromCore(input, { + info, + exchange, + signer: config.signer, + network: config.network, + logger, + accountAddress, + }) + }, + listPairs(opts) { return discovery.listPairs(opts) }, @@ -377,18 +410,25 @@ export function createUsdhKit(config: KitConfig): UsdhKit { async getQuote(input: QuoteInput): Promise { validateQuoteInput(input) - if (input.from === 'USDT') { + const direction = resolveSwapDirection(input) + if (direction.from === 'USDT') { throw new NotImplementedError('USDT pricing lands in a follow-up PR') } logger.debug('quote.requested', { - from: input.from, + from: direction.from, + to: direction.to, amount: input.amount.toString(), }) const pair = await resolvePair() const book = await info.l2Book(pair.name) const midPrice = midPrice18(book) - const estimatedReceived = applyPriceInverse(input.amount, midPrice) + const estimatedReceived = + direction.side === 'buy' + ? applyPriceInverse(input.amount, midPrice) + : applyPrice(input.amount, midPrice) return { + from: direction.from, + to: direction.to, estimatedReceived, midPrice, pair: pair.name, @@ -399,47 +439,70 @@ export function createUsdhKit(config: KitConfig): UsdhKit { async function getRoute(input: RouteInput): Promise { validateRouteInput(input) - if (input.from === 'USDT') { + const direction = resolveSwapDirection(input) + if (direction.from === 'USDT') { throw new NotImplementedError('USDT routing lands in a follow-up PR') } + if (direction.side === 'sell' && input.sourceChain === 'hyperevm') { + throw new InvalidInputError('USDH -> USDC only supports sourceChain=hypercore') + } const slippageBps = input.slippageBps ?? defaultSlippageBps const belowMinOrderValue = input.amount < MIN_ORDER_SOURCE_AMOUNT const pair = await resolvePair() const book = await info.l2Book(pair.name) const midPrice = midPrice18(book) + const estimatedReceived = + direction.side === 'buy' + ? applyPriceInverse(input.amount, midPrice) + : applyPrice(input.amount, midPrice) const quote: Quote = { - estimatedReceived: applyPriceInverse(input.amount, midPrice), + from: direction.from, + to: direction.to, + estimatedReceived, midPrice, pair: pair.name, validUntil: Date.now() + QUOTE_TTL_MS, } + const sourceTokenIndex = direction.side === 'buy' ? pair.tokens[1] : pair.tokens[0] + const sourceWeiDecimals = + direction.side === 'buy' ? pair.quoteWeiDecimals : pair.baseWeiDecimals const hypercore = await readHypercoreBalance( info, accountAddress, - input.from, - pair.tokens[1], - pair.quoteWeiDecimals, + direction.from, + sourceTokenIndex, + sourceWeiDecimals, ) const hypercoreBalance = hypercore.available const requiredSourceAmount = - input.amount + (input.amount * (BigInt(slippageBps) + HC_FEE_BUFFER_BPS)) / BPS_DENOMINATOR + direction.side === 'buy' + ? input.amount + + (input.amount * (BigInt(slippageBps) + HC_FEE_BUFFER_BPS)) / BPS_DENOMINATOR + : input.amount const requiredHypercoreBalance = scaleAmount( requiredSourceAmount, STABLE_DECIMALS, - pair.quoteWeiDecimals, + sourceWeiDecimals, ) const hypercoreCovers = hypercoreBalance >= requiredHypercoreBalance const requestedSource = input.sourceChain ?? 'auto' const sourceChain: SourceChain = - requestedSource === 'auto' ? (hypercoreCovers ? 'hypercore' : 'hyperevm') : requestedSource + direction.side === 'sell' + ? 'hypercore' + : requestedSource === 'auto' + ? hypercoreCovers + ? 'hypercore' + : 'hyperevm' + : requestedSource const requiresBridge = sourceChain === 'hyperevm' const canSwap = !belowMinOrderValue && (requiresBridge ? config.evmWallet !== undefined : hypercoreCovers) return { - from: input.from, + from: direction.from, + to: direction.to, amount: input.amount, sourceChain, requiresBridge, @@ -454,19 +517,29 @@ export function createUsdhKit(config: KitConfig): UsdhKit { hypercoreBalance, hypercoreTotal: hypercore.total, hypercoreHold: hypercore.hold, - hypercoreDecimals: pair.quoteWeiDecimals, + hypercoreDecimals: sourceWeiDecimals, requiredHypercoreBalance, } } } -function buildOrderAction(pair: ResolvedPair, priceStr: string, sizeStr: string): unknown { +type SwapDirection = + | { from: 'USDC'; to: 'USDH'; side: 'buy' } + | { from: 'USDH'; to: 'USDC'; side: 'sell' } + | { from: 'USDT'; to: 'USDH'; side: 'buy' } + +function buildOrderAction( + pair: ResolvedPair, + side: SwapDirection['side'], + priceStr: string, + sizeStr: string, +): unknown { return { type: 'order', orders: [ { a: pair.assetIndex, - b: true, + b: side === 'buy', p: priceStr, s: sizeStr, r: false, @@ -477,7 +550,12 @@ function buildOrderAction(pair: ResolvedPair, priceStr: string, sizeStr: string) } } -function finalizeFill(status: OrderStatus, midPrice: bigint, logger: Logger): SwapResult { +function finalizeFill( + status: OrderStatus, + midPrice: bigint, + direction: SwapDirection, + logger: Logger, +): SwapResult { if ('error' in status) { throw new NetworkError(`order error: ${status.error}`) } @@ -485,14 +563,17 @@ function finalizeFill(status: OrderStatus, midPrice: bigint, logger: Logger): Sw throw new NetworkError('IOC order rested unexpectedly') } const { totalSz, avgPx, oid } = status.filled - const received = parseDecimal(totalSz, STABLE_DECIMALS) + const filledBase = parseDecimal(totalSz, STABLE_DECIMALS) const fillPrice18 = parseDecimal(avgPx, PRICE_DECIMALS) - const spent = (received * fillPrice18) / TEN_PRICE + const received = direction.side === 'buy' ? filledBase : (filledBase * fillPrice18) / TEN_PRICE + const spent = direction.side === 'buy' ? (filledBase * fillPrice18) / TEN_PRICE : filledBase const diff = fillPrice18 - midPrice const absDiff = diff < 0n ? -diff : diff const slippageBps = midPrice === 0n ? 0 : Number((absDiff * 10_000n) / midPrice) logger.info('swap.filled', { oid, + from: direction.from, + to: direction.to, received: received.toString(), spent: spent.toString(), slippageBps, @@ -509,7 +590,7 @@ function finalizeFill(status: OrderStatus, midPrice: bigint, logger: Logger): Sw async function readHypercoreBalance( info: InfoClient, user: KitConfig['signer']['address'], - asset: SourceStable, + asset: SwapAsset, tokenIndex: number, decimals: number, ): Promise { @@ -573,23 +654,50 @@ function validateRouteInput(input: RouteInput): void { function validateQuoteInput(input: QuoteInput): void { assertFromAndAmount(input.from, input.amount) + resolveSwapDirection(input) +} + +function resolveSwapDirection(input: { from: SwapAsset; to?: TargetStable }): SwapDirection { + assertSwapAsset(input.from) + if (input.from === 'USDC') { + if (input.to !== undefined && input.to !== 'USDH') { + throw new InvalidInputError(`to must be 'USDH' when from is 'USDC'`) + } + return { from: 'USDC', to: 'USDH', side: 'buy' } + } + if (input.from === 'USDH') { + if (input.to !== undefined && input.to !== 'USDC') { + throw new InvalidInputError(`to must be 'USDC' when from is 'USDH'`) + } + return { from: 'USDH', to: 'USDC', side: 'sell' } + } + if (input.to !== undefined && input.to !== 'USDH') { + throw new InvalidInputError(`to must be 'USDH' when from is 'USDT'`) + } + return { from: 'USDT', to: 'USDH', side: 'buy' } } function assertFromAndAmount(from: unknown, amount: unknown): void { - assertSourceStable(from) + assertSwapAsset(from) if (typeof amount !== 'bigint' || amount <= 0n) { throw new InvalidInputError('amount must be a positive bigint') } } -function assertSourceStable(from: unknown): asserts from is SourceStable { - if (from !== 'USDC' && from !== 'USDT') { - throw new InvalidInputError(`from must be 'USDC' or 'USDT'`) +function assertSwapAsset(from: unknown): asserts from is SwapAsset { + if (from !== 'USDC' && from !== 'USDT' && from !== 'USDH') { + throw new InvalidInputError(`from must be 'USDC', 'USDH', or 'USDT'`) + } +} + +function assertBalanceAsset(asset: unknown): asserts asset is SwapAsset { + if (asset !== 'USDC' && asset !== 'USDT' && asset !== 'USDH') { + throw new InvalidInputError(`asset must be 'USDC', 'USDH', or 'USDT'`) } } function assertSlippage(bps: number): void { - if (!Number.isFinite(bps) || bps < 0 || bps > 10_000) { - throw new InvalidInputError('slippageBps must be a finite number in [0, 10000]') + if (!Number.isFinite(bps) || !Number.isInteger(bps) || bps < 0 || bps > 10_000) { + throw new InvalidInputError('slippageBps must be an integer in [0, 10000]') } } diff --git a/packages/sdk/src/pair-resolver.ts b/packages/sdk/src/pair-resolver.ts index 19eecf2..1658629 100644 --- a/packages/sdk/src/pair-resolver.ts +++ b/packages/sdk/src/pair-resolver.ts @@ -13,6 +13,8 @@ export interface ResolvedPair { tokens: [number, number] /** Base token size decimals (HL `szDecimals`). */ baseSzDecimals: number + /** Base token wei decimals (HL `weiDecimals` of the base token). */ + baseWeiDecimals: number /** Quote token wei decimals (HL `weiDecimals` of the quote token). */ quoteWeiDecimals: number } @@ -51,6 +53,7 @@ export function findUsdhUsdcPair(meta: SpotMeta): ResolvedPair { assetIndex: SPOT_ASSET_OFFSET + pair.index, tokens: pair.tokens, baseSzDecimals: baseToken.szDecimals, + baseWeiDecimals: baseToken.weiDecimals, quoteWeiDecimals: quoteToken.weiDecimals, } } diff --git a/packages/sdk/src/pricing.ts b/packages/sdk/src/pricing.ts index e1860e2..823606c 100644 --- a/packages/sdk/src/pricing.ts +++ b/packages/sdk/src/pricing.ts @@ -62,6 +62,14 @@ export function applyPriceInverse(amount: bigint, pricePerBase18: bigint): bigin return (amount * TEN_18) / pricePerBase18 } +/** Convert a base-token amount to quote-token amount at a quote-per-base price. */ +export function applyPrice(amount: bigint, pricePerBase18: bigint): bigint { + if (pricePerBase18 <= 0n) { + throw new InvalidInputError('price must be positive') + } + return (amount * pricePerBase18) / TEN_18 +} + /** * Format a fixed-point bigint as a decimal string with no trailing zeros. * `formatDecimal(1_000_100n, 6)` -> "1.0001". Returns "0" for zero. diff --git a/packages/sdk/src/signing.ts b/packages/sdk/src/signing.ts index fed05f3..50566d0 100644 --- a/packages/sdk/src/signing.ts +++ b/packages/sdk/src/signing.ts @@ -22,6 +22,23 @@ const PHANTOM_AGENT_TYPES = { ], } as const +const USER_SIGNED_DOMAIN_NAME = 'HyperliquidSignTransaction' +const USER_SIGNED_CHAIN_ID_HEX = '0x66eee' +const USER_SIGNED_CHAIN_ID = 0x66eee + +const SEND_ASSET_TYPES = { + 'HyperliquidTransaction:SendAsset': [ + { name: 'hyperliquidChain', type: 'string' }, + { name: 'destination', type: 'string' }, + { name: 'sourceDex', type: 'string' }, + { name: 'destinationDex', type: 'string' }, + { name: 'token', type: 'string' }, + { name: 'amount', type: 'string' }, + { name: 'fromSubAccount', type: 'string' }, + { name: 'nonce', type: 'uint64' }, + ], +} as const + export interface L1Signature { r: Hex s: Hex @@ -37,6 +54,18 @@ export interface SignL1ActionArgs { expiresAfter?: bigint } +export interface SignSendAssetActionArgs { + signer: Signer + network: Network + destination: Address + sourceDex: string + destinationDex: string + token: string + amount: string + fromSubAccount: string + nonce: bigint +} + /** * Sign a Hyperliquid L1 action (order, cancel, etc.). * @@ -69,6 +98,45 @@ export async function signL1Action(args: SignL1ActionArgs): Promise return parseSignature(sigHex) } +export async function signSendAssetAction( + args: SignSendAssetActionArgs, +): Promise<{ action: Record; signature: L1Signature }> { + const hyperliquidChain = args.network === 'mainnet' ? 'Mainnet' : 'Testnet' + const nonce = Number(args.nonce) + const message = { + hyperliquidChain, + destination: args.destination, + sourceDex: args.sourceDex, + destinationDex: args.destinationDex, + token: args.token, + amount: args.amount, + fromSubAccount: args.fromSubAccount, + nonce, + } + const action = { + type: 'sendAsset', + signatureChainId: USER_SIGNED_CHAIN_ID_HEX, + ...message, + } + let sigHex: Hex + try { + sigHex = await args.signer.signTypedData({ + domain: { + name: USER_SIGNED_DOMAIN_NAME, + version: '1', + chainId: USER_SIGNED_CHAIN_ID, + verifyingContract: '0x0000000000000000000000000000000000000000', + }, + types: SEND_ASSET_TYPES, + primaryType: 'HyperliquidTransaction:SendAsset', + message, + }) + } catch (err) { + throw new SigningError('signer.signTypedData rejected', { cause: err }) + } + return { action, signature: parseSignature(sigHex) } +} + /** * Compute the HL action hash used as `connectionId` in the phantom-agent * EIP-712 message. Encodes the action via msgpack, appends nonce as diff --git a/packages/sdk/src/types/bridge.ts b/packages/sdk/src/types/bridge.ts index e12694f..0f774e3 100644 --- a/packages/sdk/src/types/bridge.ts +++ b/packages/sdk/src/types/bridge.ts @@ -1,11 +1,13 @@ -import type { Hex } from './hex.js' +import type { Address, Hex } from './hex.js' /** Stablecoins supported by `bridgeToCore`. V1: USDC only; USDT lands later. */ -export type BridgeAsset = 'USDC' | 'USDT' +export type BridgeToCoreAsset = 'USDC' | 'USDT' +export type BridgeAsset = BridgeToCoreAsset +export type BridgeFromCoreAsset = 'USDC' | 'USDH' export interface BridgeInput { /** Asset to bridge from HyperEVM to HyperCore. */ - asset: BridgeAsset + asset: BridgeToCoreAsset /** * Amount in the EVM ERC20 smallest unit. USDC has 6 decimals on HyperEVM, * so `1_000_000n` = 1.00 USDC. The HyperCore credit is scaled to HC native @@ -22,3 +24,21 @@ export interface BridgeResult { /** Wall-clock ms when HyperCore reflected the deposit. */ creditedAt: number } + +export interface BridgeFromCoreInput { + /** Linked spot asset to bridge from HyperCore to HyperEVM. */ + asset: BridgeFromCoreAsset + /** Amount in the asset's smallest user unit (6 decimals for USDH/USDC). */ + amount: bigint +} + +export interface BridgeFromCoreResult { + asset: BridgeFromCoreAsset + amount: bigint + /** HyperCore system address used as the sendAsset destination. */ + systemAddress: Address + /** HyperEVM recipient. Protocol credits the sender of the Core action. */ + recipient: Address + /** Nonce/submission time used for the signed user action. */ + submittedAt: number +} diff --git a/packages/sdk/src/types/swap.ts b/packages/sdk/src/types/swap.ts index cce5838..0db46a9 100644 --- a/packages/sdk/src/types/swap.ts +++ b/packages/sdk/src/types/swap.ts @@ -1,12 +1,17 @@ import type { BridgeResult } from './bridge.js' -/** Stablecoins accepted as swap input. */ +/** Stablecoins accepted as swap input on the original acquisition path. */ export type SourceStable = 'USDC' | 'USDT' +export type SwapAsset = 'USDC' | 'USDH' | 'USDT' +export type TargetStable = 'USDC' | 'USDH' + export interface SwapInput { /** Source stablecoin to spend. */ - from: SourceStable - /** Amount in the source token's smallest unit (6 decimals). */ + from: SwapAsset + /** Target stablecoin. Defaults to USDH for USDC/USDT input and USDC for USDH input. */ + to?: TargetStable + /** Amount in the source token's smallest unit (6 decimals for USDH/USDC). */ amount: bigint /** Override the kit's default `slippageBps` for this call. */ slippageBps?: number @@ -30,11 +35,11 @@ export type RouteBlockReason = | 'missing_evm_wallet' export interface HypercoreBalanceInput { - asset: SourceStable + asset: TargetStable | 'USDT' } export interface HypercoreBalance { - asset: SourceStable + asset: TargetStable | 'USDT' tokenIndex: number decimals: number /** Total HyperCore balance in native token units. */ @@ -46,7 +51,8 @@ export interface HypercoreBalance { } export interface SwapRoute { - from: SourceStable + from: SwapAsset + to: TargetStable amount: bigint sourceChain: SourceChain requiresBridge: boolean @@ -87,9 +93,9 @@ export interface BridgeAndSwapResult { export interface SwapResult { /** Hyperliquid order id as decimal string. */ orderId: string - /** USDH received, smallest unit (6 decimals). */ + /** Target token received, smallest unit (6 decimals for USDH/USDC). */ received: bigint - /** Source spent, smallest unit. */ + /** Source token spent, smallest unit (6 decimals for USDH/USDC). */ spent: bigint /** Effective fill price (quote-per-base), fixed-point 18 decimals. */ price: bigint @@ -98,12 +104,15 @@ export interface SwapResult { } export interface QuoteInput { - from: SourceStable + from: SwapAsset + to?: TargetStable amount: bigint } export interface Quote { - /** Estimated USDH out, smallest unit (6 decimals). */ + from: SwapAsset + to: TargetStable + /** Estimated target out, smallest unit (6 decimals for USDH/USDC). */ estimatedReceived: bigint /** * Mid-price of the on-pair orderbook, quote-token per base-token in 18 decimals. diff --git a/packages/sdk/test/bridge.test.ts b/packages/sdk/test/bridge.test.ts index f9b4a01..4184f8d 100644 --- a/packages/sdk/test/bridge.test.ts +++ b/packages/sdk/test/bridge.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { evmToCoreUnits, runBridgeToCore, tokenSystemAddress } from '../src/bridge.js' +import { + evmToCoreUnits, + runBridgeFromCore, + runBridgeToCore, + tokenSystemAddress, +} from '../src/bridge.js' import { BridgeTimeoutError, InvalidInputError, @@ -11,6 +16,7 @@ import type { InfoClient } from '../src/transport/info.js' import type { SpotClearinghouseState, SpotMeta } from '../src/transport/types.js' import type { EvmWallet } from '../src/types/evm-wallet.js' import { silentLogger } from '../src/types/logger.js' +import type { Signer } from '../src/types/signer.js' const usdcEvmContract = '0x6b9e773128f453f5c2c60935ee2de2cbc5390a24' const nativeUsdc = '0xb88339cb7199b77e23db6e890353e22632ba630f' @@ -47,6 +53,17 @@ function stateWith(usdcTotal: string): SpotClearinghouseState { } } +function stateWithToken( + coin: string, + token: number, + total: string, + hold = '0', +): SpotClearinghouseState { + return { + balances: [{ coin, token, total, hold, entryNtl: '0' }], + } +} + function stubInfo(states: SpotClearinghouseState[]): InfoClient { let i = 0 return { @@ -76,6 +93,20 @@ function stubWallet(txHash = `0x${'f'.repeat(64)}`): EvmWallet & { calls: unknow } } +function stubSigner(address = '0x0000000000000000000000000000000000000abc'): Signer { + return { + address: address as `0x${string}`, + signTypedData: vi.fn(async () => `0x${'1'.repeat(64)}${'2'.repeat(64)}1b` as const), + signMessage: vi.fn(), + } +} + +function stubExchange(response: unknown = { type: 'default' }) { + return { + submit: vi.fn(async () => ({ status: 'ok' as const, response })), + } +} + function stubInclusionAwareWallet( txHash = `0x${'f'.repeat(64)}`, ): EvmWallet & { calls: unknown[]; receipts: unknown[] } { @@ -438,3 +469,149 @@ describe('runBridgeToCore', () => { ).rejects.toBeInstanceOf(InvalidInputError) }) }) + +describe('runBridgeFromCore', () => { + const baseUser = '0x0000000000000000000000000000000000000abc' as const + + it('submits a signed sendAsset action to the token system address', async () => { + const signer = stubSigner(baseUser) + const exchange = stubExchange() + const result = await runBridgeFromCore( + { asset: 'USDH', amount: 1_250_000n }, + { + info: stubInfo([stateWithToken('USDH', 360, '2')]), + exchange, + signer, + network: 'mainnet', + logger: silentLogger, + accountAddress: baseUser, + now: () => 1_775_000_000_000, + }, + ) + + expect(signer.signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ + domain: expect.objectContaining({ + name: 'HyperliquidSignTransaction', + chainId: 0x66eee, + }), + primaryType: 'HyperliquidTransaction:SendAsset', + message: expect.objectContaining({ + hyperliquidChain: 'Mainnet', + destination: '0x2000000000000000000000000000000000000168', + sourceDex: 'spot', + destinationDex: 'spot', + token: 'USDH:0xbbbb', + amount: '1.25', + fromSubAccount: '', + nonce: 1_775_000_000_000, + }), + }), + ) + expect(exchange.submit).toHaveBeenCalledWith({ + action: expect.objectContaining({ + type: 'sendAsset', + signatureChainId: '0x66eee', + destination: '0x2000000000000000000000000000000000000168', + token: 'USDH:0xbbbb', + amount: '1.25', + nonce: 1_775_000_000_000, + }), + signature: { r: `0x${'1'.repeat(64)}`, s: `0x${'2'.repeat(64)}`, v: 27 }, + nonce: 1_775_000_000_000n, + }) + expect(result).toEqual({ + asset: 'USDH', + amount: 1_250_000n, + systemAddress: '0x2000000000000000000000000000000000000168', + recipient: baseUser, + submittedAt: 1_775_000_000_000, + }) + }) + + it('uses the Circle-compatible bare USDC token for Core -> HyperEVM withdraws', async () => { + const signer = stubSigner(baseUser) + const exchange = stubExchange() + await runBridgeFromCore( + { asset: 'USDC', amount: 1_500_000n }, + { + info: stubInfo([stateWithToken('USDC', 0, '2')]), + exchange, + signer, + network: 'testnet', + logger: silentLogger, + accountAddress: baseUser, + now: () => 1_775_000_000_001, + }, + ) + + expect(exchange.submit).toHaveBeenCalledWith({ + action: expect.objectContaining({ + type: 'sendAsset', + hyperliquidChain: 'Testnet', + destination: '0x2000000000000000000000000000000000000000', + sourceDex: 'spot', + destinationDex: 'spot', + token: 'USDC', + amount: '1.5', + }), + signature: { r: `0x${'1'.repeat(64)}`, s: `0x${'2'.repeat(64)}`, v: 27 }, + nonce: 1_775_000_000_001n, + }) + }) + + it('rejects agent-style accountAddress mismatch', async () => { + await expect( + runBridgeFromCore( + { asset: 'USDC', amount: 1_000_000n }, + { + info: stubInfo([stateWith('10')]), + exchange: stubExchange(), + signer: stubSigner('0x0000000000000000000000000000000000000abc'), + network: 'mainnet', + logger: silentLogger, + accountAddress: '0x0000000000000000000000000000000000000def', + }, + ), + ).rejects.toThrow(/signer.address to match accountAddress/) + }) + + it('rejects insufficient HyperCore balance before signing', async () => { + const signer = stubSigner(baseUser) + const exchange = stubExchange() + await expect( + runBridgeFromCore( + { asset: 'USDC', amount: 2_000_000n }, + { + info: stubInfo([stateWithToken('USDC', 0, '1')]), + exchange, + signer, + network: 'mainnet', + logger: silentLogger, + accountAddress: baseUser, + }, + ), + ).rejects.toMatchObject({ name: 'InsufficientBalanceError' }) + expect(signer.signTypedData).not.toHaveBeenCalled() + expect(exchange.submit).not.toHaveBeenCalled() + }) + + it('surfaces exchange errors from sendAsset', async () => { + const exchange = { + submit: vi.fn(async () => ({ status: 'err' as const, response: 'rate limited' })), + } + await expect( + runBridgeFromCore( + { asset: 'USDC', amount: 1_000_000n }, + { + info: stubInfo([stateWithToken('USDC', 0, '2')]), + exchange, + signer: stubSigner(baseUser), + network: 'mainnet', + logger: silentLogger, + accountAddress: baseUser, + }, + ), + ).rejects.toThrow(/exchange error: rate limited/) + }) +}) diff --git a/packages/sdk/test/kit.test.ts b/packages/sdk/test/kit.test.ts index cd5bf4b..3156d8f 100644 --- a/packages/sdk/test/kit.test.ts +++ b/packages/sdk/test/kit.test.ts @@ -42,10 +42,21 @@ const sampleSpotMeta: SpotMeta = { index: 1, tokenId: '0xbbbb', isCanonical: true, + evmContract: { + address: '0x111111a1a0667d36bd57c0a9f569b98057111111', + evm_extra_wei_decimals: -2, + }, }, ], } +const reverseSpotMeta: SpotMeta = { + ...sampleSpotMeta, + tokens: sampleSpotMeta.tokens.map((token) => + token.name === 'USDH' ? { ...token, szDecimals: 2 } : token, + ), +} + const sampleL2Book: L2Book = { coin: 'USDH/USDC', time: 1735300000000, @@ -93,6 +104,28 @@ function backend(exchangeResponse: unknown): { return { fetch, getExchangeBody: () => exchangeBody } } +function reverseSwapBackend(exchangeResponse: unknown): { + fetch: typeof fetch + getExchangeBody: () => Record | undefined +} { + let exchangeBody: Record | undefined + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString() + const body = JSON.parse(init?.body as string) as Record + if (url.endsWith('/info')) { + if (body.type === 'spotMeta') return jsonResponse(reverseSpotMeta) + if (body.type === 'l2Book') return jsonResponse(sampleL2Book) + throw new Error(`unexpected /info body: ${JSON.stringify(body)}`) + } + if (url.endsWith('/exchange')) { + exchangeBody = body + return jsonResponse(exchangeResponse) + } + throw new Error(`unexpected url: ${url}`) + }) as unknown as typeof fetch + return { fetch, getExchangeBody: () => exchangeBody } +} + function outcomeBackend(): { fetch: typeof fetch getInfoBodies: () => Record[] @@ -298,6 +331,42 @@ describe('swap', () => { expect('txHash' in result).toBe(false) }) + it('sells USDH for USDC with a slippage-adjusted IOC order', async () => { + const filledResponse = { + status: 'ok', + response: { + type: 'order', + data: { + statuses: [{ filled: { totalSz: '11', avgPx: '1', oid: 54321 } }], + }, + }, + } + const { fetch, getExchangeBody } = reverseSwapBackend(filledResponse) + const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) + + const result = await kit.swap({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) + + const body = getExchangeBody() + expect(body?.action).toMatchObject({ + type: 'order', + grouping: 'na', + orders: [ + expect.objectContaining({ + a: 10000, + b: false, + p: '0.998', + r: false, + s: '11', + t: { limit: { tif: 'Ioc' } }, + }), + ], + }) + expect(result.orderId).toBe('54321') + expect(result.received).toBe(11_000_000n) + expect(result.spent).toBe(11_000_000n) + expect(result.price).toBe(1_000_000_000_000_000_000n) + }) + it('still applies Hyperliquid spot tick rules to per-call slippage limits', async () => { const filledResponse = { status: 'ok', @@ -395,12 +464,23 @@ describe('getQuote', () => { const { fetch } = backend({}) const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) const quote = await kit.getQuote({ from: 'USDC', amount: 1_000_000n }) + expect(quote.from).toBe('USDC') + expect(quote.to).toBe('USDH') expect(quote.pair).toBe('USDH/USDC') expect(quote.midPrice).toBe(1_000_000_000_000_000_000n) expect(quote.estimatedReceived).toBe(1_000_000n) expect(quote.validUntil).toBeGreaterThan(Date.now()) }) + it('returns USDC estimate for USDH input', async () => { + const { fetch } = backend({}) + const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) + const quote = await kit.getQuote({ from: 'USDH', to: 'USDC', amount: 2_000_000n }) + expect(quote.from).toBe('USDH') + expect(quote.to).toBe('USDC') + expect(quote.estimatedReceived).toBe(2_000_000n) + }) + it('caches the pair resolution across quote calls', async () => { const { fetch } = backend({}) const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) @@ -443,6 +523,42 @@ describe('getRoute', () => { expect(route.hypercoreHold).toBe(0n) }) + it('routes USDH -> USDC as a HyperCore-only swap', async () => { + const fetch = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input.toString() + const body = JSON.parse(init?.body as string) as Record + if (!url.endsWith('/info')) throw new Error(`unexpected url: ${url}`) + if (body.type === 'spotMeta') return jsonResponse(reverseSpotMeta) + if (body.type === 'l2Book') return jsonResponse(sampleL2Book) + if (body.type === 'spotClearinghouseState') { + return jsonResponse({ + balances: [{ coin: 'USDH', token: 1, total: '20', hold: '0', entryNtl: '0' }], + }) + } + throw new Error(`unexpected /info body: ${JSON.stringify(body)}`) + }) as unknown as typeof fetch + const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) + + const route = await kit.getRoute({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) + + expect(route.from).toBe('USDH') + expect(route.to).toBe('USDC') + expect(route.sourceChain).toBe('hypercore') + expect(route.requiresBridge).toBe(false) + expect(route.canSwap).toBe(true) + expect(route.hypercoreBalance).toBe(2_000_000_000n) + expect(route.requiredHypercoreBalance).toBe(1_100_000_000n) + }) + + it('rejects HyperEVM source selection for USDH -> USDC', async () => { + const { fetch } = routingBackend({}, ['20']) + const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) + + await expect( + kit.getRoute({ from: 'USDH', to: 'USDC', amount: 11_000_000n, sourceChain: 'hyperevm' }), + ).rejects.toThrow(/only supports sourceChain=hypercore/) + }) + it('routes through HyperEVM when HC total covers but open-order hold leaves it short', async () => { const { fetch } = routingBackend({}, [{ total: '20', hold: '15' }]) const kit = createUsdhKit({ diff --git a/packages/sdk/test/pair-resolver.test.ts b/packages/sdk/test/pair-resolver.test.ts index bcca3ba..e937728 100644 --- a/packages/sdk/test/pair-resolver.test.ts +++ b/packages/sdk/test/pair-resolver.test.ts @@ -35,6 +35,7 @@ describe('findUsdhUsdcPair', () => { assetIndex: 10230, tokens: [1, 0], baseSzDecimals: 8, + baseWeiDecimals: 8, quoteWeiDecimals: 8, }) }) From 81a1bbf6650b77161751f5b61bd9c7922a6e6828 Mon Sep 17 00:00:00 2001 From: sumfxn Date: Mon, 11 May 2026 13:25:35 +0200 Subject: [PATCH 2/2] fix(sdk): harden useful usdh flows --- docs/architecture.md | 4 +- docs/bridge-and-swap.md | 5 +- docs/troubleshooting.md | 4 ++ packages/sdk/README.md | 6 ++- packages/sdk/src/bridge.ts | 33 ++++++++++--- packages/sdk/src/kit.ts | 9 ++++ packages/sdk/src/types/bridge.ts | 6 ++- packages/sdk/test/bridge.test.ts | 83 +++++++++++++++++++++++++++++++- packages/sdk/test/kit.test.ts | 26 ++++++++++ 9 files changed, 160 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 6a2a290..d35a3e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -119,10 +119,12 @@ BridgeFromCoreInput ├── EIP-712 typed data domain ('HyperliquidSignTransaction', chainId 0x66eee) └── token = USDC or tokenName:tokenId, destination = system address ↓ exchange.submit({ action, signature, nonce }) - ↓ return BridgeFromCoreResult { systemAddress, recipient, submittedAt } + ↓ return BridgeFromCoreResult { status: 'submitted', systemAddress, recipient, submittedAt } ``` `bridgeFromCore()` supports linked `USDC` and `USDH` spot assets. It does not accept an arbitrary HyperEVM recipient in v1: Hyperliquid credits the EVM-side account associated with the Core action sender, so the safest public API is sender-owned bridge-out only. +The helper resolves after the `sendAsset` action is accepted; it does not poll +for the later HyperEVM credit. ## bridgeAndSwap diff --git a/docs/bridge-and-swap.md b/docs/bridge-and-swap.md index 3658282..eb1d9b5 100644 --- a/docs/bridge-and-swap.md +++ b/docs/bridge-and-swap.md @@ -56,13 +56,14 @@ If the user wants USDC back on HyperEVM, call `bridgeFromCore()` after the swap: ```ts const swap = await kit.swap({ from: 'USDH', to: 'USDC', amount: 11_000_000n }) const bridgeOut = await kit.bridgeFromCore({ asset: 'USDC', amount: swap.received }) -console.log(bridgeOut.submittedAt) +console.log(bridgeOut.status, bridgeOut.submittedAt) ``` `bridgeFromCore()` uses Hyperliquid's user-signed `sendAsset` action to the token system address. The HyperEVM recipient is the sender of that Core action, so the configured `signer` must be the master account, not an approved agent -for a separate `accountAddress`. +for a separate `accountAddress`. The helper resolves when Hyperliquid accepts +the action, not when the later HyperEVM credit is confirmed. ## Progress events diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 574e9fe..19ea012 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -23,6 +23,10 @@ agent wallets are useful for spot orders, but they cannot bridge funds out for a master account. Create a separate kit with the master wallet as `signer` before calling `bridgeFromCore()`. +`bridgeFromCore()` returns `status: 'submitted'` after Hyperliquid accepts the +action. The HyperEVM-side credit is asynchronous, so confirm the EVM balance +before starting a dependent HyperEVM transaction. + Browser apps that use an approved agent should also pass `accountAddress` so bridge ownership and balance reads stay tied to the master wallet: ```ts diff --git a/packages/sdk/README.md b/packages/sdk/README.md index b6d8487..e79216a 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -154,11 +154,13 @@ console.log(`got ${sold.received} USDC`) address. Hyperliquid credits the EVM recipient as the sender of the Core action, so this helper requires the configured signer to be the master account (`signer.address === accountAddress`); approved agent wallets cannot withdraw -Core funds for another account. +Core funds for another account. It resolves when Hyperliquid accepts the +`sendAsset` action; the HyperEVM credit is asynchronous and should be confirmed +separately before treating funds as spendable on HyperEVM. ```ts const out = await kit.bridgeFromCore({ asset: 'USDC', amount: sold.received }) -console.log(out.systemAddress, out.submittedAt) +console.log(out.status, out.systemAddress, out.submittedAt) ``` ## Discover USDH spot markets diff --git a/packages/sdk/src/bridge.ts b/packages/sdk/src/bridge.ts index 82b0619..d1a78fe 100644 --- a/packages/sdk/src/bridge.ts +++ b/packages/sdk/src/bridge.ts @@ -35,8 +35,6 @@ const HYPER_EVM_CHAIN_ID: Record = { const DEFAULT_CREDIT_TIMEOUT_MS = 180_000 const CREDIT_POLL_INTERVAL_MS = 1_000 const SPOT_DEX_ID = 0xffffffff -const STABLE_DECIMALS = 6 - const TX_HASH_PATTERN = /^0x[0-9a-fA-F]{64}$/ export interface BridgeDeps { @@ -178,6 +176,18 @@ function sendAssetToken(asset: BridgeFromCoreAsset, token: SpotToken): string { return asset === 'USDC' ? 'USDC' : `${asset}:${token.tokenId}` } +function linkedEvmDecimals(token: SpotToken, asset: BridgeFromCoreAsset): number { + const extra = token.evmContract?.evm_extra_wei_decimals + if (typeof extra !== 'number' || !Number.isInteger(extra)) { + throw new NetworkError(`${asset} has invalid evm_extra_wei_decimals metadata`) + } + const decimals = token.weiDecimals + extra + if (!Number.isInteger(decimals) || decimals < 0) { + throw new NetworkError(`${asset} resolved to invalid HyperEVM decimals`) + } + return decimals +} + export interface BridgeRunArgs extends BridgeInput { user: Address } @@ -301,7 +311,8 @@ export async function runBridgeFromCore( const meta = await deps.info.spotMeta() const token = findStableToken(meta, args.asset) const systemAddress = tokenSystemAddress(token.index) - const requiredCore = scaleAmountExact(args.amount, STABLE_DECIMALS, token.weiDecimals) + const evmDecimals = linkedEvmDecimals(token, args.asset) + const requiredCore = scaleAmountExact(args.amount, evmDecimals, token.weiDecimals) const balance = await readCoreAvailable(deps.info, accountAddress, token.index, token.weiDecimals) if (balance.available < requiredCore) { throw new InsufficientBalanceError(requiredCore, balance.available, args.asset) @@ -309,7 +320,7 @@ export async function runBridgeFromCore( const submittedAt = deps.now?.() ?? Date.now() const nonce = BigInt(submittedAt) - const amount = formatStableAmount(args.amount) + const amount = formatAmount(args.amount, evmDecimals) const tokenWire = sendAssetToken(args.asset, token) const { action, signature } = await signSendAssetAction({ signer: deps.signer, @@ -345,6 +356,8 @@ export async function runBridgeFromCore( return { asset: args.asset, amount: args.amount, + status: 'submitted', + evmDecimals, systemAddress, recipient: accountAddress, submittedAt, @@ -392,10 +405,14 @@ async function sendAndValidate( return txHashRaw.toLowerCase() as Hex } -function formatStableAmount(amount: bigint): string { - const padded = amount.toString().padStart(STABLE_DECIMALS + 1, '0') - const intPart = padded.slice(0, -STABLE_DECIMALS) - const fracPart = padded.slice(-STABLE_DECIMALS).replace(/0+$/, '') +function formatAmount(amount: bigint, decimals: number): string { + if (!Number.isInteger(decimals) || decimals < 0) { + throw new InvalidInputError('decimals must be a non-negative integer') + } + if (decimals === 0) return amount.toString() + const padded = amount.toString().padStart(decimals + 1, '0') + const intPart = padded.slice(0, -decimals) + const fracPart = padded.slice(-decimals).replace(/0+$/, '') return fracPart === '' ? intPart : `${intPart}.${fracPart}` } diff --git a/packages/sdk/src/kit.ts b/packages/sdk/src/kit.ts index 2dde562..468e6db 100644 --- a/packages/sdk/src/kit.ts +++ b/packages/sdk/src/kit.ts @@ -185,6 +185,7 @@ export function createUsdhKit(config: KitConfig): UsdhKit { validateSwapInput(input) const direction = resolveSwapDirection(input) const slippageBps = input.slippageBps ?? defaultSlippageBps + assertDirectionSlippage(direction, slippageBps) if (direction.from === 'USDT') { throw new NotImplementedError('USDT swap lands in a follow-up PR') } @@ -448,6 +449,7 @@ export function createUsdhKit(config: KitConfig): UsdhKit { } const slippageBps = input.slippageBps ?? defaultSlippageBps + assertDirectionSlippage(direction, slippageBps) const belowMinOrderValue = input.amount < MIN_ORDER_SOURCE_AMOUNT const pair = await resolvePair() const book = await info.l2Book(pair.name) @@ -701,3 +703,10 @@ function assertSlippage(bps: number): void { throw new InvalidInputError('slippageBps must be an integer in [0, 10000]') } } + +function assertDirectionSlippage(direction: SwapDirection, bps: number): void { + assertSlippage(bps) + if (direction.side === 'sell' && bps >= 10_000) { + throw new InvalidInputError('slippageBps must be less than 10000 for USDH -> USDC') + } +} diff --git a/packages/sdk/src/types/bridge.ts b/packages/sdk/src/types/bridge.ts index 0f774e3..f000b40 100644 --- a/packages/sdk/src/types/bridge.ts +++ b/packages/sdk/src/types/bridge.ts @@ -28,13 +28,17 @@ export interface BridgeResult { export interface BridgeFromCoreInput { /** Linked spot asset to bridge from HyperCore to HyperEVM. */ asset: BridgeFromCoreAsset - /** Amount in the asset's smallest user unit (6 decimals for USDH/USDC). */ + /** Amount in the linked HyperEVM asset's smallest unit. */ amount: bigint } export interface BridgeFromCoreResult { + /** The sendAsset action was accepted; HyperEVM credit is asynchronous. */ + status: 'submitted' asset: BridgeFromCoreAsset amount: bigint + /** EVM decimals derived from `spotMeta` for formatting the sendAsset amount. */ + evmDecimals: number /** HyperCore system address used as the sendAsset destination. */ systemAddress: Address /** HyperEVM recipient. Protocol credits the sender of the Core action. */ diff --git a/packages/sdk/test/bridge.test.ts b/packages/sdk/test/bridge.test.ts index 4184f8d..e0915ef 100644 --- a/packages/sdk/test/bridge.test.ts +++ b/packages/sdk/test/bridge.test.ts @@ -64,10 +64,10 @@ function stateWithToken( } } -function stubInfo(states: SpotClearinghouseState[]): InfoClient { +function stubInfo(states: SpotClearinghouseState[], meta = sampleSpotMeta): InfoClient { let i = 0 return { - spotMeta: vi.fn(async () => sampleSpotMeta), + spotMeta: vi.fn(async () => meta), outcomeMeta: vi.fn(), l2Book: vi.fn(), spotClearinghouseState: vi.fn(async () => { @@ -521,8 +521,10 @@ describe('runBridgeFromCore', () => { nonce: 1_775_000_000_000n, }) expect(result).toEqual({ + status: 'submitted', asset: 'USDH', amount: 1_250_000n, + evmDecimals: 6, systemAddress: '0x2000000000000000000000000000000000000168', recipient: baseUser, submittedAt: 1_775_000_000_000, @@ -560,6 +562,83 @@ describe('runBridgeFromCore', () => { }) }) + it('derives bridge-out amount decimals from linked EVM metadata', async () => { + const signer = stubSigner(baseUser) + const exchange = stubExchange() + const meta: SpotMeta = { + ...sampleSpotMeta, + tokens: sampleSpotMeta.tokens.map((token) => + token.name === 'USDH' + ? { + ...token, + evmContract: { + address: '0x111111a1a0667d36bd57c0a9f569b98057111111' as const, + evm_extra_wei_decimals: 0, + }, + } + : token, + ), + } + + const result = await runBridgeFromCore( + { asset: 'USDH', amount: 125_000_000n }, + { + info: stubInfo([stateWithToken('USDH', 360, '2')], meta), + exchange, + signer, + network: 'mainnet', + logger: silentLogger, + accountAddress: baseUser, + now: () => 1_775_000_000_002, + }, + ) + + expect(exchange.submit).toHaveBeenCalledWith({ + action: expect.objectContaining({ + token: 'USDH:0xbbbb', + amount: '1.25', + }), + signature: { r: `0x${'1'.repeat(64)}`, s: `0x${'2'.repeat(64)}`, v: 27 }, + nonce: 1_775_000_000_002n, + }) + expect(result.evmDecimals).toBe(8) + }) + + it('rejects bridge-out amounts that cannot map exactly to HyperCore decimals', async () => { + const signer = stubSigner(baseUser) + const exchange = stubExchange() + const meta: SpotMeta = { + ...sampleSpotMeta, + tokens: sampleSpotMeta.tokens.map((token) => + token.name === 'USDH' + ? { + ...token, + evmContract: { + address: '0x111111a1a0667d36bd57c0a9f569b98057111111' as const, + evm_extra_wei_decimals: 1, + }, + } + : token, + ), + } + + await expect( + runBridgeFromCore( + { asset: 'USDH', amount: 1n }, + { + info: stubInfo([stateWithToken('USDH', 360, '2')], meta), + exchange, + signer, + network: 'mainnet', + logger: silentLogger, + accountAddress: baseUser, + }, + ), + ).rejects.toThrow(/too much precision/) + expect(signer.signTypedData).not.toHaveBeenCalled() + expect(exchange.submit).not.toHaveBeenCalled() + }) + it('rejects agent-style accountAddress mismatch', async () => { await expect( runBridgeFromCore( diff --git a/packages/sdk/test/kit.test.ts b/packages/sdk/test/kit.test.ts index 3156d8f..75a60ed 100644 --- a/packages/sdk/test/kit.test.ts +++ b/packages/sdk/test/kit.test.ts @@ -279,6 +279,22 @@ describe('swap', () => { await expect(kit.swap({ from: 'USDC', amount: 0n })).rejects.toThrow(InvalidInputError) }) + it('rejects full sell slippage before fetching or signing', async () => { + const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof fetch + const signTypedData = vi.fn(stubSigner.signTypedData) + const kit = createUsdhKit({ + network: 'mainnet', + signer: { ...stubSigner, signTypedData }, + fetch, + }) + + await expect( + kit.swap({ from: 'USDH', to: 'USDC', amount: 11_000_000n, slippageBps: 10_000 }), + ).rejects.toThrow(/less than 10000/) + expect(fetch).not.toHaveBeenCalled() + expect(signTypedData).not.toHaveBeenCalled() + }) + it('throws NotImplementedError for USDT', async () => { const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner }) await expect(kit.swap({ from: 'USDT', amount: 1_000_000n })).rejects.toThrow( @@ -559,6 +575,16 @@ describe('getRoute', () => { ).rejects.toThrow(/only supports sourceChain=hypercore/) }) + it('rejects full sell slippage during route preflight', async () => { + const fetch = vi.fn(async () => jsonResponse({})) as unknown as typeof fetch + const kit = createUsdhKit({ network: 'mainnet', signer: stubSigner, fetch }) + + await expect( + kit.getRoute({ from: 'USDH', to: 'USDC', amount: 11_000_000n, slippageBps: 10_000 }), + ).rejects.toThrow(/less than 10000/) + expect(fetch).not.toHaveBeenCalled() + }) + it('routes through HyperEVM when HC total covers but open-order hold leaves it short', async () => { const { fetch } = routingBackend({}, [{ total: '20', hold: '15' }]) const kit = createUsdhKit({