From 278f495d02b2c81c81279a106eebe0b9f3898f2e Mon Sep 17 00:00:00 2001 From: kiseln <3428059+kiseln@users.noreply.github.com> Date: Thu, 28 May 2026 14:20:18 -0300 Subject: [PATCH 1/4] feat(hypercore): support transfers from HyperEVM and HyperCore Co-Authored-By: Claude Opus 4.7 (1M context) --- bun.lock | 15 +- packages/core/src/bridge.ts | 18 +- packages/core/src/config.ts | 5 + packages/core/src/index.ts | 3 + packages/core/src/utils/address.ts | 4 +- packages/core/src/utils/hyperliquid.ts | 49 +++++ packages/core/tests/bridge.test.ts | 34 ++-- packages/evm/src/proof.ts | 12 +- packages/hypercore/package.json | 39 ++++ packages/hypercore/src/builder.ts | 159 ++++++++++++++++ packages/hypercore/src/config.ts | 36 ++++ packages/hypercore/src/encoders.ts | 39 ++++ packages/hypercore/src/format-amount.ts | 24 +++ packages/hypercore/src/index.ts | 41 +++++ packages/hypercore/src/spot-meta.ts | 130 +++++++++++++ packages/hypercore/src/submit.ts | 63 +++++++ packages/hypercore/src/typed-data.ts | 172 ++++++++++++++++++ packages/hypercore/src/types.ts | 37 ++++ packages/hypercore/tests/builder.test.ts | 144 +++++++++++++++ packages/hypercore/tests/encoders.test.ts | 50 +++++ .../hypercore/tests/format-amount.test.ts | 27 +++ packages/hypercore/tests/typed-data.test.ts | 75 ++++++++ packages/hypercore/tsconfig.json | 9 + packages/sdk/package.json | 3 +- packages/sdk/src/index.ts | 1 + packages/sdk/tsconfig.json | 3 +- tsconfig.json | 1 + 27 files changed, 1152 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/utils/hyperliquid.ts create mode 100644 packages/hypercore/package.json create mode 100644 packages/hypercore/src/builder.ts create mode 100644 packages/hypercore/src/config.ts create mode 100644 packages/hypercore/src/encoders.ts create mode 100644 packages/hypercore/src/format-amount.ts create mode 100644 packages/hypercore/src/index.ts create mode 100644 packages/hypercore/src/spot-meta.ts create mode 100644 packages/hypercore/src/submit.ts create mode 100644 packages/hypercore/src/typed-data.ts create mode 100644 packages/hypercore/src/types.ts create mode 100644 packages/hypercore/tests/builder.test.ts create mode 100644 packages/hypercore/tests/encoders.test.ts create mode 100644 packages/hypercore/tests/format-amount.test.ts create mode 100644 packages/hypercore/tests/typed-data.test.ts create mode 100644 packages/hypercore/tsconfig.json diff --git a/bun.lock b/bun.lock index 3f2286e3..d0f7c023 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "omni-bridge-sdk", @@ -59,6 +58,17 @@ "typescript": "^5.9.3", }, }, + "packages/hypercore": { + "name": "@omni-bridge/hypercore", + "version": "0.9.0", + "dependencies": { + "@omni-bridge/core": "workspace:*", + "viem": "^2.43.5", + }, + "devDependencies": { + "typescript": "^5.9.3", + }, + }, "packages/near": { "name": "@omni-bridge/near", "version": "0.9.0", @@ -81,6 +91,7 @@ "@omni-bridge/btc": "workspace:*", "@omni-bridge/core": "workspace:*", "@omni-bridge/evm": "workspace:*", + "@omni-bridge/hypercore": "workspace:*", "@omni-bridge/near": "workspace:*", "@omni-bridge/solana": "workspace:*", "@omni-bridge/starknet": "workspace:*", @@ -430,6 +441,8 @@ "@omni-bridge/evm": ["@omni-bridge/evm@workspace:packages/evm"], + "@omni-bridge/hypercore": ["@omni-bridge/hypercore@workspace:packages/hypercore"], + "@omni-bridge/near": ["@omni-bridge/near@workspace:packages/near"], "@omni-bridge/sdk": ["@omni-bridge/sdk@workspace:packages/sdk"], diff --git a/packages/core/src/bridge.ts b/packages/core/src/bridge.ts index f5252b2a..344ba9a5 100644 --- a/packages/core/src/bridge.ts +++ b/packages/core/src/bridge.ts @@ -138,11 +138,7 @@ function getContractAddress(addresses: ChainAddresses, chain: ChainKind): string case ChainKind.Pol: return addresses.pol.bridge case ChainKind.HyperEvm: - throw new ValidationError( - "HyperEVM bridge is not yet configured in the SDK", - "UNSUPPORTED_CHAIN", - { chain: ChainKind[chain] }, - ) + return addresses.hlevm.bridge case ChainKind.Abs: return addresses.abs.bridge case ChainKind.Strk: @@ -210,18 +206,6 @@ class BridgeImpl implements Bridge { }) } - // HyperEVM is present in the ChainKind enum so borsh discriminants align - // with the on-chain contract, but transfers involving it are not yet - // supported by the SDK until RPC URLs and chain configs are added. - if (sourceChain === ChainKind.HyperEvm || destChain === ChainKind.HyperEvm) { - const offending = sourceChain === ChainKind.HyperEvm ? sourceChain : destChain - throw new ValidationError( - "HyperEVM transfers are not yet supported by the SDK", - "UNSUPPORTED_CHAIN", - { chain: ChainKind[offending] }, - ) - } - // Validate EVM addresses have proper checksum if (isEvmChain(sourceChain)) { const senderAddr = getAddress(params.sender) diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index e611e2d8..2bd59111 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -49,6 +49,7 @@ export interface ChainAddresses { bnb: EvmAddresses pol: EvmAddresses abs: EvmAddresses + hlevm: EvmAddresses near: NearAddresses sol: SolanaAddresses btc: BtcAddresses @@ -64,6 +65,7 @@ const MAINNET_ADDRESSES: ChainAddresses = { bnb: { bridge: "0x073C8a225c8Cf9d3f9157F5C1a1DbE02407f5720" }, pol: { bridge: "0xd025b38762B4A4E36F0Cde483b86CB13ea00D989" }, abs: { bridge: "0xd2490A00bDB97C1EDE4fdf207CFE2664AFB9C20D" }, + hlevm: { bridge: "0xf353b40fC144d1c6c5BCdda712fa6De833016aF9" }, near: { contract: "omni.bridge.near", rpcUrls: ["https://free.rpc.fastnear.com"], @@ -106,6 +108,7 @@ const TESTNET_ADDRESSES: ChainAddresses = { bnb: { bridge: "0x7Fd1E9F9ed48ebb64476ba9E06e5F1a90e31DA74" }, pol: { bridge: "0xEC81aFc3485a425347Ac03316675e58a680b283A" }, abs: { bridge: "0x5C79627d2cD753d45B41839d187619f99c7B8D78" }, + hlevm: { bridge: "0xf353b40fC144d1c6c5BCdda712fa6De833016aF9" }, near: { contract: "omni.n-bridge.testnet", rpcUrls: ["https://test.rpc.fastnear.com"], @@ -155,6 +158,7 @@ export const EVM_CHAIN_IDS: Record> = { bnb: 56, pol: 137, abs: 2741, + hlevm: 999, }, testnet: { eth: 11155111, // Sepolia @@ -163,6 +167,7 @@ export const EVM_CHAIN_IDS: Record> = { bnb: 97, // BSC Testnet pol: 80002, // Polygon Amoy abs: 11124, // Abstract Testnet + hlevm: 998, // HyperEVM Testnet }, } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 0058dd95..3003051b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -87,6 +87,9 @@ export { verifyTransferAmount, } from "./utils/decimals.js" +// Hyperliquid helpers +export { buildHyperliquidTransferParams, HYPERLIQUID_MESSAGE } from "./utils/hyperliquid.js" + // Token utilities export { isBridgeToken, parseOriginChain } from "./utils/token.js" diff --git a/packages/core/src/utils/address.ts b/packages/core/src/utils/address.ts index 06c805b9..467d4f82 100644 --- a/packages/core/src/utils/address.ts +++ b/packages/core/src/utils/address.ts @@ -93,6 +93,7 @@ export type EvmChainKind = | ChainKind.Bnb | ChainKind.Pol | ChainKind.Abs + | ChainKind.HyperEvm /** * Checks if a chain is an EVM-compatible chain @@ -104,7 +105,8 @@ export function isEvmChain(chain: ChainKind): chain is EvmChainKind { chain === ChainKind.Arb || chain === ChainKind.Bnb || chain === ChainKind.Pol || - chain === ChainKind.Abs + chain === ChainKind.Abs || + chain === ChainKind.HyperEvm ) } diff --git a/packages/core/src/utils/hyperliquid.ts b/packages/core/src/utils/hyperliquid.ts new file mode 100644 index 00000000..3a0332af --- /dev/null +++ b/packages/core/src/utils/hyperliquid.ts @@ -0,0 +1,49 @@ +/** + * Helpers for bridging into Hyperliquid (HyperCore L1) via HyperEVM. + */ + +import { ChainKind, type OmniAddress, type TransferParams } from "../types.js" +import { omniAddress } from "./address.js" + +/** + * Marker placed in `TransferParams.message` to signal a Hyperliquid-bound + * transfer. The on-chain `HlBridgeToken` only checks whether the message is + * non-empty to choose the 3-arg `mint` path (which forwards the minted balance + * to the configured system address so HyperCore credits it to the user's + * spot balance). The content itself is ignored — we use a self-describing + * string so it's clear in logs and the indexer. + */ +export const HYPERLIQUID_MESSAGE = "hypercore" + +/** + * Build `TransferParams` for a NEAR → Hyperliquid (HyperCore) transfer. + * + * The destination is HyperEVM — HyperCore uses the same 20-byte EVM addresses, + * so `hypercoreRecipient` is the user's HyperEVM/HyperCore address. + * + * @param params.token NEAR-side token to send (e.g. `near:wrap.near`). + * @param params.amount Transfer amount (origin decimals). + * @param params.fee Bridge fee (origin decimals). Defaults to 0n. + * @param params.nativeFee Native NEAR fee. Defaults to 0n. + * @param params.sender NEAR sender (e.g. `near:alice.near`). + * @param params.hypercoreRecipient + * Recipient's 20-byte EVM-style address on HyperEVM/HyperCore (`0x…`). + */ +export function buildHyperliquidTransferParams(params: { + token: OmniAddress + amount: bigint + sender: OmniAddress + hypercoreRecipient: string + fee?: bigint + nativeFee?: bigint +}): TransferParams { + return { + token: params.token, + amount: params.amount, + fee: params.fee ?? 0n, + nativeFee: params.nativeFee ?? 0n, + sender: params.sender, + recipient: omniAddress(ChainKind.HyperEvm, params.hypercoreRecipient), + message: HYPERLIQUID_MESSAGE, + } +} diff --git a/packages/core/tests/bridge.test.ts b/packages/core/tests/bridge.test.ts index 0ad5d7cc..a8389a9e 100644 --- a/packages/core/tests/bridge.test.ts +++ b/packages/core/tests/bridge.test.ts @@ -80,6 +80,7 @@ describe("Bridge.validateTransfer", () => { if (address === "near:wrap.testnet") { if (chain === "Eth") return "eth:0x1234567890123456789012345678901234567890" if (chain === "Sol") return "sol:So11111111111111111111111111111111111111112" + if (chain === "HlEvm") return "hlevm:0x1234567890123456789012345678901234567890" } return null } @@ -348,8 +349,8 @@ describe("Bridge.validateTransfer", () => { }) }) - describe("HyperEVM rejection", () => { - it("throws when source chain is HyperEVM (hlevm -> NEAR)", async () => { + describe("HyperEVM support", () => { + it("validates a HyperEVM -> NEAR transfer", async () => { const params: TransferParams = { token: "hlevm:0x1234567890123456789012345678901234567890" as OmniAddress, amount: 1000000000000000000n, @@ -359,17 +360,14 @@ describe("Bridge.validateTransfer", () => { recipient: "near:alice.testnet" as OmniAddress, } - await expect(bridge.validateTransfer(params)).rejects.toThrow(ValidationError) - await expect(bridge.validateTransfer(params)).rejects.toThrow( - "HyperEVM transfers are not yet supported by the SDK", - ) - await expect(bridge.validateTransfer(params)).rejects.toMatchObject({ - code: "UNSUPPORTED_CHAIN", - details: { chain: "HyperEvm" }, - }) + const result = await bridge.validateTransfer(params) + + expect(result.sourceChain).toBe(ChainKind.HyperEvm) + expect(result.destChain).toBe(ChainKind.Near) + expect(result.contractAddress).toBe("0xf353b40fC144d1c6c5BCdda712fa6De833016aF9") }) - it("throws when destination chain is HyperEVM (NEAR -> hlevm)", async () => { + it("validates a NEAR -> HyperEVM transfer (used for Hyperliquid bridging)", async () => { const params: TransferParams = { token: "near:wrap.testnet" as OmniAddress, amount: 1000000000000000000n, @@ -377,16 +375,14 @@ describe("Bridge.validateTransfer", () => { nativeFee: 0n, sender: "near:alice.testnet" as OmniAddress, recipient: "hlevm:0xABCDEF0123456789ABCDEF0123456789ABCDEF01" as OmniAddress, + message: "hypercore", } - await expect(bridge.validateTransfer(params)).rejects.toThrow(ValidationError) - await expect(bridge.validateTransfer(params)).rejects.toThrow( - "HyperEVM transfers are not yet supported by the SDK", - ) - await expect(bridge.validateTransfer(params)).rejects.toMatchObject({ - code: "UNSUPPORTED_CHAIN", - details: { chain: "HyperEvm" }, - }) + const result = await bridge.validateTransfer(params) + + expect(result.sourceChain).toBe(ChainKind.Near) + expect(result.destChain).toBe(ChainKind.HyperEvm) + expect(result.bridgedToken).toBe("hlevm:0x1234567890123456789012345678901234567890") }) }) diff --git a/packages/evm/src/proof.ts b/packages/evm/src/proof.ts index 1578fa62..8a0cd624 100644 --- a/packages/evm/src/proof.ts +++ b/packages/evm/src/proof.ts @@ -10,7 +10,11 @@ import { ChainKind, type Network } from "@omni-bridge/core" import { type Chain, createPublicClient, type Hex, http, numberToHex } from "viem" import * as chains from "viem/chains" // `abstract` is a reserved keyword, so we import the chain definition directly -import { abstract as abstractChain } from "viem/chains" +import { + abstract as abstractChain, + hyperEvm as hyperEvmChain, + hyperliquidEvmTestnet as hyperEvmTestnet, +} from "viem/chains" export interface EvmProof { log_index: bigint @@ -68,6 +72,7 @@ const RPC_URLS: Record> = { [ChainKind.Bnb]: "https://bsc-rpc.publicnode.com", [ChainKind.Pol]: "https://polygon-bor-rpc.publicnode.com", [ChainKind.Abs]: "https://api.mainnet.abs.xyz", + [ChainKind.HyperEvm]: "https://rpc.hyperliquid.xyz/evm", }, testnet: { [ChainKind.Eth]: "https://ethereum-sepolia.publicnode.com", @@ -76,6 +81,7 @@ const RPC_URLS: Record> = { [ChainKind.Bnb]: "https://bsc-testnet-rpc.publicnode.com", [ChainKind.Pol]: "https://polygon-amoy-bor-rpc.publicnode.com", [ChainKind.Abs]: "https://api.testnet.abs.xyz", + [ChainKind.HyperEvm]: "https://rpc.hyperliquid-testnet.xyz/evm", }, } @@ -94,6 +100,8 @@ function getChainConfig(network: Network, chain: EvmChainKind): Chain { return chains.polygon case ChainKind.Abs: return abstractChain + case ChainKind.HyperEvm: + return hyperEvmChain } } else { switch (chain) { @@ -109,6 +117,8 @@ function getChainConfig(network: Network, chain: EvmChainKind): Chain { return chains.polygonAmoy case ChainKind.Abs: return chains.abstractTestnet + case ChainKind.HyperEvm: + return hyperEvmTestnet } } } diff --git a/packages/hypercore/package.json b/packages/hypercore/package.json new file mode 100644 index 00000000..453d07b2 --- /dev/null +++ b/packages/hypercore/package.json @@ -0,0 +1,39 @@ +{ + "name": "@omni-bridge/hypercore", + "version": "0.9.0", + "description": "HyperCore (Hyperliquid L1) action builder for Omni Bridge", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/Near-One/bridge-sdk-js", + "directory": "packages/hypercore" + }, + "license": "MIT", + "author": "NEAR One", + "dependencies": { + "@omni-bridge/core": "workspace:*", + "viem": "^2.43.5" + }, + "devDependencies": { + "typescript": "^5.9.3" + } +} diff --git a/packages/hypercore/src/builder.ts b/packages/hypercore/src/builder.ts new file mode 100644 index 00000000..42fa8355 --- /dev/null +++ b/packages/hypercore/src/builder.ts @@ -0,0 +1,159 @@ +import { ChainKind, getAddress, getChain, type Network, type OmniAddress } from "@omni-bridge/core" +import type { Address, Hex } from "viem" +import { + DEFAULT_GAS_LIMIT_INIT_TRANSFER, + DEFAULT_GAS_LIMIT_TRANSFER, + DEFAULT_SIGNATURE_CHAIN_ID, + HYPERCORE_API_URL, + HYPEREVM_CHAIN_ID, + HYPERLIQUID_CHAIN, +} from "./config.js" +import { + ACTION_INIT_TRANSFER, + ACTION_TRANSFER, + encodeInitTransferAction, + encodeTransferAction, +} from "./encoders.js" +import { formatAmount } from "./format-amount.js" +import { + resolveSpotTokenCached, + type SpotMetaFetchOptions, + type SpotTokenInfo, +} from "./spot-meta.js" +import { buildSendToEvmWithDataTypedData, type HyperCoreTypedData } from "./typed-data.js" +import type { SendToEvmWithDataAction } from "./types.js" + +export interface HyperCoreBuilderConfig { + network: Network + /** Override Hyperliquid REST base URL. Defaults to the per-network mainnet/testnet API. */ + apiUrl?: string + /** Override `signatureChainId` (hex string). Defaults to `0x66eee` (Arb-Sepolia). */ + signatureChainId?: string + /** Custom fetch (e.g. for tests or proxies). Defaults to global `fetch`. */ + fetch?: typeof fetch +} + +export interface HyperCoreTransferParams { + /** Hyperliquid spot token name (e.g. "USDC", "PURR"). */ + spotToken: string + /** Amount in bridge ERC-20 wei units. */ + amount: bigint + /** Recipient as an OmniAddress. HyperEVM destination → pool release; any other chain → routed via `OmniBridge.initTransfer`. */ + recipient: OmniAddress + /** Bridge fee in bridge ERC-20 wei (only used when `recipient` is not HyperEVM). */ + fee?: bigint + /** Optional message forwarded with the bridge event (only used for non-HyperEVM recipients). */ + message?: string + /** Override gas limit. Defaults to 800k for `initTransfer`, 300k for pool release. */ + gasLimit?: number + /** + * Pre-resolved HlBridgeToken contract on HyperEVM. When supplied together with `decimals`, + * skips the `/info spotMeta` lookup — useful for offline/deterministic builds. + */ + hlBridgeToken?: Address + /** Pre-resolved bridge-token decimals (szDecimals + evmExtraWeiDecimals). */ + decimals?: number + /** Pre-resolved Hyperliquid spot identifier (`NAME:0x<32hex>`). */ + spotId?: string +} + +export interface HyperCoreUnsignedAction { + /** Ready-to-post action JSON (omitting the signature/envelope). */ + action: SendToEvmWithDataAction + /** EIP-712 typed-data envelope including a precomputed digest. */ + typedData: HyperCoreTypedData + /** Resolved HlBridgeToken address (also present in `action.destinationRecipient`). */ + hlBridgeToken: Address +} + +export interface HyperCoreBuilder { + readonly network: Network + readonly apiUrl: string + buildTransfer(params: HyperCoreTransferParams): Promise +} + +class HyperCoreBuilderImpl implements HyperCoreBuilder { + readonly network: Network + readonly apiUrl: string + private readonly signatureChainId: string + private readonly fetchImpl: typeof fetch | undefined + + constructor(config: HyperCoreBuilderConfig) { + this.network = config.network + this.apiUrl = config.apiUrl ?? HYPERCORE_API_URL[config.network] + this.signatureChainId = config.signatureChainId ?? DEFAULT_SIGNATURE_CHAIN_ID + this.fetchImpl = config.fetch + } + + async buildTransfer(params: HyperCoreTransferParams): Promise { + const spotInfo = await this.resolveSpotInfo(params) + + const data = this.encodeData(params) + const isPoolRelease = data.actionTag === ACTION_TRANSFER + const gasLimit = + params.gasLimit ?? + (isPoolRelease ? DEFAULT_GAS_LIMIT_TRANSFER : DEFAULT_GAS_LIMIT_INIT_TRANSFER) + + const action: SendToEvmWithDataAction = { + type: "sendToEvmWithData", + hyperliquidChain: HYPERLIQUID_CHAIN[this.network], + signatureChainId: this.signatureChainId, + token: spotInfo.spotId, + amount: formatAmount(params.amount, spotInfo.decimals), + sourceDex: "spot", + destinationRecipient: spotInfo.hlBridgeToken.toLowerCase(), + addressEncoding: "hex", + destinationChainId: HYPEREVM_CHAIN_ID[this.network], + gasLimit, + data: data.hex, + nonce: currentMsNonce(), + } + + return { + action, + typedData: buildSendToEvmWithDataTypedData(action), + hlBridgeToken: spotInfo.hlBridgeToken, + } + } + + private async resolveSpotInfo(params: HyperCoreTransferParams): Promise { + if ( + params.hlBridgeToken !== undefined && + params.decimals !== undefined && + params.spotId !== undefined + ) { + return { + spotId: params.spotId, + hlBridgeToken: params.hlBridgeToken, + decimals: params.decimals, + } + } + const fetchOpts: SpotMetaFetchOptions = this.fetchImpl ? { fetch: this.fetchImpl } : {} + const resolved = await resolveSpotTokenCached(this.apiUrl, params.spotToken, fetchOpts) + return { + spotId: params.spotId ?? resolved.spotId, + hlBridgeToken: params.hlBridgeToken ?? resolved.hlBridgeToken, + decimals: params.decimals ?? resolved.decimals, + } + } + + private encodeData(params: HyperCoreTransferParams): { hex: Hex; actionTag: number } { + const recipientChain = getChain(params.recipient) + if (recipientChain === ChainKind.HyperEvm) { + const evmAddr = getAddress(params.recipient) as Address + return { hex: encodeTransferAction(evmAddr), actionTag: ACTION_TRANSFER } + } + return { + hex: encodeInitTransferAction(params.fee ?? 0n, params.recipient, params.message ?? ""), + actionTag: ACTION_INIT_TRANSFER, + } + } +} + +export function createHyperCoreBuilder(config: HyperCoreBuilderConfig): HyperCoreBuilder { + return new HyperCoreBuilderImpl(config) +} + +function currentMsNonce(): number { + return Date.now() +} diff --git a/packages/hypercore/src/config.ts b/packages/hypercore/src/config.ts new file mode 100644 index 00000000..a7f2ed7b --- /dev/null +++ b/packages/hypercore/src/config.ts @@ -0,0 +1,36 @@ +import type { Network } from "@omni-bridge/core" + +/** + * Hyperliquid REST API base URL (without `/exchange` or `/info` suffix). + */ +export const HYPERCORE_API_URL: Record = { + mainnet: "https://api.hyperliquid.xyz", + testnet: "https://api.hyperliquid-testnet.xyz", +} + +/** + * `hyperliquidChain` value embedded in the signed action JSON. + */ +export const HYPERLIQUID_CHAIN: Record = { + mainnet: "Mainnet", + testnet: "Testnet", +} + +/** + * HyperEVM chain id used as `destinationChainId` in the action JSON. + */ +export const HYPEREVM_CHAIN_ID: Record = { + mainnet: 999, + testnet: 998, +} + +/** + * Default `signatureChainId` (Arb-Sepolia, `0x66eee`) — matches the Hyperliquid + * Python SDK convention. The value only needs to be unique enough to prevent + * signature replay across chains; mirroring the canonical SDK reduces interop + * surprises. + */ +export const DEFAULT_SIGNATURE_CHAIN_ID = "0x66eee" + +export const DEFAULT_GAS_LIMIT_INIT_TRANSFER = 800_000 +export const DEFAULT_GAS_LIMIT_TRANSFER = 300_000 diff --git a/packages/hypercore/src/encoders.ts b/packages/hypercore/src/encoders.ts new file mode 100644 index 00000000..19c2afda --- /dev/null +++ b/packages/hypercore/src/encoders.ts @@ -0,0 +1,39 @@ +import type { OmniAddress } from "@omni-bridge/core" +import { type Address, concatHex, encodeAbiParameters, type Hex } from "viem" + +export const ACTION_TRANSFER = 0x00 +export const ACTION_INIT_TRANSFER = 0x01 + +/** + * Encode the `data` payload for `HlBridgeToken` `ACTION_TRANSFER`: release + * `amount` from the system-address pool to `recipient` on HyperEVM. + * + * Layout: `0x00 || abi.encode(address recipient)`. + */ +export function encodeTransferAction(recipient: Address): Hex { + // viem's `encodeAbiParameters` rejects non-EIP-55 mixed-case addresses; the + // on-chain encoding only cares about the 20 raw bytes, so lowercase before + // encoding to accept any caller-provided form. + const normalized = recipient.toLowerCase() as Address + return concatHex(["0x00", encodeAbiParameters([{ type: "address" }], [normalized])]) +} + +/** + * Encode the `data` payload for `HlBridgeToken` `ACTION_INIT_TRANSFER`: bridge + * `amount` via `OmniBridge.initTransfer` to `recipient` with `fee`. + * + * Layout: `0x01 || abi.encode(uint128 fee, string recipient, string message)`. + */ +export function encodeInitTransferAction( + fee: bigint, + recipient: OmniAddress, + message: string, +): Hex { + return concatHex([ + "0x01", + encodeAbiParameters( + [{ type: "uint128" }, { type: "string" }, { type: "string" }], + [fee, recipient, message], + ), + ]) +} diff --git a/packages/hypercore/src/format-amount.ts b/packages/hypercore/src/format-amount.ts new file mode 100644 index 00000000..16145b5d --- /dev/null +++ b/packages/hypercore/src/format-amount.ts @@ -0,0 +1,24 @@ +/** + * Format an integer amount + decimals as a minimal Hyperliquid decimal string + * (no trailing zeros, single leading zero before the decimal point). + * + * Mirrors `format_amount` in `bridge-sdk-rs/.../hypercore-bridge-client/src/action.rs`. + */ +export function formatAmount(amount: bigint, decimals: number): string { + if (amount < 0n) throw new RangeError(`amount must be non-negative, got ${amount}`) + if (decimals < 0 || !Number.isInteger(decimals)) { + throw new RangeError(`decimals must be a non-negative integer, got ${decimals}`) + } + if (decimals === 0) return amount.toString() + + const raw = amount.toString() + if (raw.length <= decimals) { + const frac = raw.padStart(decimals, "0").replace(/0+$/, "") + return frac === "" ? "0" : `0.${frac}` + } + + const split = raw.length - decimals + const intPart = raw.slice(0, split) + const fracPart = raw.slice(split).replace(/0+$/, "") + return fracPart === "" ? intPart : `${intPart}.${fracPart}` +} diff --git a/packages/hypercore/src/index.ts b/packages/hypercore/src/index.ts new file mode 100644 index 00000000..e553a997 --- /dev/null +++ b/packages/hypercore/src/index.ts @@ -0,0 +1,41 @@ +export { + createHyperCoreBuilder, + type HyperCoreBuilder, + type HyperCoreBuilderConfig, + type HyperCoreTransferParams, + type HyperCoreUnsignedAction, +} from "./builder.js" +export { + DEFAULT_GAS_LIMIT_INIT_TRANSFER, + DEFAULT_GAS_LIMIT_TRANSFER, + DEFAULT_SIGNATURE_CHAIN_ID, + HYPERCORE_API_URL, + HYPEREVM_CHAIN_ID, + HYPERLIQUID_CHAIN, +} from "./config.js" +export { + ACTION_INIT_TRANSFER, + ACTION_TRANSFER, + encodeInitTransferAction, + encodeTransferAction, +} from "./encoders.js" +export { formatAmount } from "./format-amount.js" +export { + fetchSpotMeta, + resolveSpotToken, + resolveSpotTokenCached, + type SpotMetaFetchOptions, + type SpotTokenInfo, +} from "./spot-meta.js" +export { type PostExchangeActionOptions, postExchangeAction } from "./submit.js" +export { + buildSendToEvmWithDataTypedData, + type HyperCoreTypedData, + SEND_TO_EVM_WITH_DATA_TYPE_NAME, + splitSignature, +} from "./typed-data.js" +export type { + ActionSignature, + ExchangeEnvelope, + SendToEvmWithDataAction, +} from "./types.js" diff --git a/packages/hypercore/src/spot-meta.ts b/packages/hypercore/src/spot-meta.ts new file mode 100644 index 00000000..be9c2e2d --- /dev/null +++ b/packages/hypercore/src/spot-meta.ts @@ -0,0 +1,130 @@ +import type { Address } from "viem" + +/** + * Resolved metadata for a Hyperliquid spot token, sufficient to build a + * `sendToEvmWithData` action against it. + */ +export interface SpotTokenInfo { + /** Spot identifier shape `NAME:0x<32hex>` — the action JSON's `token` field. */ + spotId: string + /** HlBridgeToken ERC-20 on HyperEVM — the action JSON's `destinationRecipient`. */ + hlBridgeToken: Address + /** szDecimals + evmExtraWeiDecimals — used by `formatAmount`. */ + decimals: number +} + +/** + * Shape of Hyperliquid `/info { type: "spotMeta" }` token entries we depend on. + * The endpoint returns additional fields we ignore. + */ +interface SpotMetaToken { + name: string + fullName?: string | null + szDecimals: number + weiDecimals: number + tokenId: string + evmContract?: { + address: string + evm_extra_wei_decimals: number + } | null +} + +interface SpotMetaResponse { + tokens: SpotMetaToken[] +} + +export interface SpotMetaFetchOptions { + /** Custom fetch implementation (e.g. for tests). Defaults to global `fetch`. */ + fetch?: typeof fetch +} + +/** + * Fetch the full spot-token table from Hyperliquid `/info`. + */ +export async function fetchSpotMeta( + apiUrl: string, + options: SpotMetaFetchOptions = {}, +): Promise { + const fetchImpl = options.fetch ?? fetch + const response = await fetchImpl(`${apiUrl}/info`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ type: "spotMeta" }), + }) + if (!response.ok) { + throw new Error(`Hyperliquid /info request failed: HTTP ${response.status}`) + } + const json = (await response.json()) as SpotMetaResponse + return json.tokens +} + +/** + * Look up `SpotTokenInfo` for a single spot token by name (e.g. "USDC", "PURR"). + * Throws if the name isn't found or has no linked HyperEVM contract. + */ +export async function resolveSpotToken( + apiUrl: string, + spotTokenName: string, + options: SpotMetaFetchOptions = {}, +): Promise { + const tokens = await fetchSpotMeta(apiUrl, options) + const match = tokens.find((t) => t.name === spotTokenName || t.fullName === spotTokenName) + if (!match) { + throw new Error(`Hyperliquid spot token "${spotTokenName}" not found in /info spotMeta`) + } + if (!match.evmContract) { + throw new Error( + `Spot token "${spotTokenName}" has no linked HyperEVM contract (cannot be bridged via HlBridgeToken)`, + ) + } + return { + spotId: `${match.name}:${match.tokenId}`, + hlBridgeToken: match.evmContract.address as Address, + decimals: match.szDecimals + match.evmContract.evm_extra_wei_decimals, + } +} + +/** + * Process-local cache of `/info spotMeta` results, keyed by api URL. + */ +const CACHE: Map> = new Map() + +/** + * Like `resolveSpotToken`, but memoizes the `/info` response per `apiUrl` for + * the lifetime of the process. Subsequent lookups for any token name reuse the + * cached table — typically one network round-trip per session. + */ +export async function resolveSpotTokenCached( + apiUrl: string, + spotTokenName: string, + options: SpotMetaFetchOptions = {}, +): Promise { + let pending = CACHE.get(apiUrl) + if (!pending) { + pending = fetchSpotMeta(apiUrl, options).catch((err) => { + CACHE.delete(apiUrl) + throw err + }) + CACHE.set(apiUrl, pending) + } + const tokens = await pending + const match = tokens.find((t) => t.name === spotTokenName || t.fullName === spotTokenName) + if (!match) { + throw new Error(`Hyperliquid spot token "${spotTokenName}" not found in /info spotMeta`) + } + if (!match.evmContract) { + throw new Error( + `Spot token "${spotTokenName}" has no linked HyperEVM contract (cannot be bridged via HlBridgeToken)`, + ) + } + return { + spotId: `${match.name}:${match.tokenId}`, + hlBridgeToken: match.evmContract.address as Address, + decimals: match.szDecimals + match.evmContract.evm_extra_wei_decimals, + } +} + +/** Test-only — clear the memoized `/info` cache. */ +export function _clearSpotMetaCache(): void { + CACHE.clear() +} diff --git a/packages/hypercore/src/submit.ts b/packages/hypercore/src/submit.ts new file mode 100644 index 00000000..7bb944cd --- /dev/null +++ b/packages/hypercore/src/submit.ts @@ -0,0 +1,63 @@ +import type { ActionSignature, ExchangeEnvelope, SendToEvmWithDataAction } from "./types.js" + +export interface PostExchangeActionOptions { + /** Hyperliquid REST base URL (no `/exchange` suffix). */ + apiUrl: string + /** Signed action body. */ + action: SendToEvmWithDataAction + /** Signature produced by signing the action's EIP-712 digest. */ + signature: ActionSignature + /** Custom fetch implementation. Defaults to global `fetch`. */ + fetch?: typeof fetch +} + +export interface PostExchangeResult { + /** Parsed `/exchange` response body. */ + raw: unknown +} + +/** + * POST `{action, nonce, signature}` to Hyperliquid `/exchange`. Throws on + * non-2xx status or `status: "err"` response. Does NOT wait for the + * downstream HyperEVM `CoreReceived` log — consumers should subscribe via + * their own tooling if they need landing confirmation. + */ +export async function postExchangeAction( + options: PostExchangeActionOptions, +): Promise { + const fetchImpl = options.fetch ?? fetch + const envelope: ExchangeEnvelope = { + action: options.action, + nonce: options.action.nonce, + signature: options.signature, + } + + const response = await fetchImpl(`${options.apiUrl}/exchange`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(envelope), + }) + + const text = await response.text() + if (!response.ok) { + throw new Error(`Hyperliquid /exchange HTTP ${response.status}: ${text}`) + } + + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw new Error(`Hyperliquid /exchange returned non-JSON body: ${text}`) + } + + const status = (parsed as { status?: unknown }).status + if (status === "err") { + const message = (parsed as { response?: unknown }).response ?? text + throw new Error(`Hyperliquid /exchange rejected action: ${String(message)}`) + } + if (status !== "ok") { + throw new Error(`Hyperliquid /exchange returned unexpected status: ${text}`) + } + + return { raw: parsed } +} diff --git a/packages/hypercore/src/typed-data.ts b/packages/hypercore/src/typed-data.ts new file mode 100644 index 00000000..8849e675 --- /dev/null +++ b/packages/hypercore/src/typed-data.ts @@ -0,0 +1,172 @@ +import { + encodeAbiParameters, + type Hex, + hexToBytes, + keccak256, + pad, + type TypedDataDomain, + toHex, +} from "viem" +import type { SendToEvmWithDataAction } from "./types.js" + +/** + * EIP-712 primary type name for Hyperliquid `sendToEvmWithData` user-signed + * actions. The colon is non-standard for EIP-712 identifiers but matches the + * Hyperliquid wire format — the typehash is computed by hashing the full + * encoded-type string literally, so the colon is just bytes inside the hash + * input. See `bridge-sdk-rs/.../hypercore-bridge-client/src/signing.rs:22`. + */ +export const SEND_TO_EVM_WITH_DATA_TYPE_NAME = "HyperliquidTransaction:SendToEvmWithData" + +const SEND_TO_EVM_WITH_DATA_FIELDS = [ + { name: "hyperliquidChain", type: "string" }, + { name: "token", type: "string" }, + { name: "amount", type: "string" }, + { name: "sourceDex", type: "string" }, + { name: "destinationRecipient", type: "string" }, + { name: "addressEncoding", type: "string" }, + { name: "destinationChainId", type: "uint32" }, + { name: "gasLimit", type: "uint64" }, + { name: "data", type: "bytes" }, + { name: "nonce", type: "uint64" }, +] as const + +const EIP712_DOMAIN_TYPE = + "EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)" +const HL_DOMAIN_NAME = "HyperliquidSignTransaction" +const HL_DOMAIN_VERSION = "1" +const VERIFYING_CONTRACT = "0x0000000000000000000000000000000000000000" + +export interface HyperCoreTypedData { + domain: TypedDataDomain + types: { [SEND_TO_EVM_WITH_DATA_TYPE_NAME]: typeof SEND_TO_EVM_WITH_DATA_FIELDS } + primaryType: typeof SEND_TO_EVM_WITH_DATA_TYPE_NAME + message: Omit + /** + * EIP-712 digest (0x19 0x01 || domainSeparator || structHash) precomputed for + * direct signing (e.g. `viem.wallet.sign({ hash })`). Wallets that prefer the + * structured typed-data prompt can use `domain`/`types`/`message` instead. + */ + digest: Hex +} + +export function parseSignatureChainId(signatureChainId: string): bigint { + const stripped = signatureChainId.startsWith("0x") ? signatureChainId.slice(2) : signatureChainId + if (!/^[0-9a-fA-F]+$/.test(stripped)) { + throw new Error(`signatureChainId must be a hex string, got: ${signatureChainId}`) + } + return BigInt(`0x${stripped}`) +} + +/** + * Build the EIP-712 typed-data envelope (including precomputed digest) for a + * Hyperliquid `sendToEvmWithData` action. + */ +export function buildSendToEvmWithDataTypedData( + action: SendToEvmWithDataAction, +): HyperCoreTypedData { + const chainId = parseSignatureChainId(action.signatureChainId) + + const domainSeparator = computeDomainSeparator(chainId) + const structHash = computeStructHash(action) + const digest = keccak256( + new Uint8Array([0x19, 0x01, ...hexToBytes(domainSeparator), ...hexToBytes(structHash)]), + ) + + return { + domain: { + name: HL_DOMAIN_NAME, + version: HL_DOMAIN_VERSION, + chainId, + verifyingContract: VERIFYING_CONTRACT, + }, + types: { [SEND_TO_EVM_WITH_DATA_TYPE_NAME]: SEND_TO_EVM_WITH_DATA_FIELDS }, + primaryType: SEND_TO_EVM_WITH_DATA_TYPE_NAME, + message: { + hyperliquidChain: action.hyperliquidChain, + signatureChainId: action.signatureChainId, + token: action.token, + amount: action.amount, + sourceDex: action.sourceDex, + destinationRecipient: action.destinationRecipient, + addressEncoding: action.addressEncoding, + destinationChainId: action.destinationChainId, + gasLimit: action.gasLimit, + data: action.data, + nonce: action.nonce, + }, + digest, + } +} + +function computeDomainSeparator(chainId: bigint): Hex { + return keccak256( + encodeAbiParameters( + [ + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "uint256" }, + { type: "address" }, + ], + [ + keccak256(toHex(EIP712_DOMAIN_TYPE)), + keccak256(toHex(HL_DOMAIN_NAME)), + keccak256(toHex(HL_DOMAIN_VERSION)), + chainId, + VERIFYING_CONTRACT, + ], + ), + ) +} + +function computeStructHash(action: SendToEvmWithDataAction): Hex { + const typeString = `${SEND_TO_EVM_WITH_DATA_TYPE_NAME}(${SEND_TO_EVM_WITH_DATA_FIELDS.map( + (f) => `${f.type} ${f.name}`, + ).join(",")})` + const typeHash = keccak256(toHex(typeString)) + + return keccak256( + encodeAbiParameters( + [ + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "bytes32" }, + { type: "uint256" }, + { type: "uint256" }, + { type: "bytes32" }, + { type: "uint256" }, + ], + [ + typeHash, + keccak256(toHex(action.hyperliquidChain)), + keccak256(toHex(action.token)), + keccak256(toHex(action.amount)), + keccak256(toHex(action.sourceDex)), + keccak256(toHex(action.destinationRecipient)), + keccak256(toHex(action.addressEncoding)), + BigInt(action.destinationChainId), + BigInt(action.gasLimit), + keccak256(action.data), + BigInt(action.nonce), + ], + ), + ) +} + +/** + * Recover (r, s, v) components from a 65-byte 0x-prefixed compact signature, + * shaped for Hyperliquid's `/exchange` envelope. + */ +export function splitSignature(signature: Hex): { r: Hex; s: Hex; v: number } { + const bytes = hexToBytes(signature) + if (bytes.length !== 65) throw new Error(`signature must be 65 bytes, got ${bytes.length}`) + const r = pad(toHex(bytes.slice(0, 32)), { size: 32 }) + const s = pad(toHex(bytes.slice(32, 64)), { size: 32 }) + const v = bytes[64] as number + return { r, s, v: v < 27 ? v + 27 : v } +} diff --git a/packages/hypercore/src/types.ts b/packages/hypercore/src/types.ts new file mode 100644 index 00000000..7c17a6fe --- /dev/null +++ b/packages/hypercore/src/types.ts @@ -0,0 +1,37 @@ +import type { Hex } from "viem" + +/** + * JSON body of a `sendToEvmWithData` Hyperliquid Core action. + * + * Field order matches the EIP-712 type list; reordering will change the + * type hash and invalidate signatures. + */ +export interface SendToEvmWithDataAction { + type: "sendToEvmWithData" + hyperliquidChain: "Mainnet" | "Testnet" + signatureChainId: string + token: string + amount: string + sourceDex: "spot" + destinationRecipient: string + addressEncoding: "hex" + destinationChainId: number + gasLimit: number + data: Hex + nonce: number +} + +export interface ActionSignature { + r: Hex + s: Hex + v: number +} + +/** + * Envelope POSTed to Hyperliquid's `/exchange` endpoint. + */ +export interface ExchangeEnvelope { + action: SendToEvmWithDataAction + nonce: number + signature: ActionSignature +} diff --git a/packages/hypercore/tests/builder.test.ts b/packages/hypercore/tests/builder.test.ts new file mode 100644 index 00000000..e65987a1 --- /dev/null +++ b/packages/hypercore/tests/builder.test.ts @@ -0,0 +1,144 @@ +import type { OmniAddress } from "@omni-bridge/core" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { decodeAbiParameters } from "viem" +import { createHyperCoreBuilder } from "../src/builder.js" +import { ACTION_INIT_TRANSFER, ACTION_TRANSFER } from "../src/encoders.js" +import { _clearSpotMetaCache } from "../src/spot-meta.js" + +const SPOT_META_RESPONSE = { + tokens: [ + { + name: "USDC", + fullName: "USDC", + szDecimals: 8, + weiDecimals: 8, + tokenId: "0x6d1e7cde53ba9467b783cb7c530ce054", + evmContract: { + address: "0x1234567890123456789012345678901234567890", + evm_extra_wei_decimals: 0, + }, + }, + { + name: "PURR", + fullName: "PURR", + szDecimals: 8, + weiDecimals: 8, + tokenId: "0xc4bf3f870c0e9465323c0b6ed28096c2", + evmContract: { + address: "0x9999999999999999999999999999999999999999", + evm_extra_wei_decimals: 0, + }, + }, + ], +} + +function makeFetch() { + return vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === "string" ? input : input.toString() + if (url.endsWith("/info") && init?.method === "POST") { + return new Response(JSON.stringify(SPOT_META_RESPONSE), { + status: 200, + headers: { "Content-Type": "application/json" }, + }) + } + throw new Error(`unexpected fetch: ${url}`) + }) as unknown as typeof fetch +} + +describe("createHyperCoreBuilder.buildTransfer", () => { + beforeEach(() => { + _clearSpotMetaCache() + }) + afterEach(() => { + _clearSpotMetaCache() + }) + + it("picks ACTION_TRANSFER for HyperEVM recipients (pool release)", async () => { + const builder = createHyperCoreBuilder({ network: "testnet", fetch: makeFetch() }) + const unsigned = await builder.buildTransfer({ + spotToken: "USDC", + amount: 100_000_000n, // 1 USDC at 8 decimals + recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, + }) + + expect(unsigned.action.type).toBe("sendToEvmWithData") + expect(unsigned.action.hyperliquidChain).toBe("Testnet") + expect(unsigned.action.token).toBe("USDC:0x6d1e7cde53ba9467b783cb7c530ce054") + expect(unsigned.action.amount).toBe("1") + expect(unsigned.action.destinationChainId).toBe(998) + expect(unsigned.action.destinationRecipient).toBe( + "0x1234567890123456789012345678901234567890", + ) + expect(unsigned.action.data.slice(0, 4)).toBe(`0x0${ACTION_TRANSFER}`) + const [decodedRecipient] = decodeAbiParameters( + [{ type: "address" }], + `0x${unsigned.action.data.slice(4)}`, + ) + expect(decodedRecipient.toLowerCase()).toBe( + "0x000000000000000000000000000000000000dead", + ) + expect(unsigned.hlBridgeToken).toBe("0x1234567890123456789012345678901234567890") + }) + + it("picks ACTION_INIT_TRANSFER for non-HyperEVM recipients", async () => { + const builder = createHyperCoreBuilder({ network: "testnet", fetch: makeFetch() }) + const unsigned = await builder.buildTransfer({ + spotToken: "PURR", + amount: 12_300_000n, + recipient: "near:alice.near" as OmniAddress, + fee: 7n, + message: "ref=test", + }) + + expect(unsigned.action.data.slice(0, 4)).toBe(`0x0${ACTION_INIT_TRANSFER}`) + const [fee, recipient, message] = decodeAbiParameters( + [{ type: "uint128" }, { type: "string" }, { type: "string" }], + `0x${unsigned.action.data.slice(4)}`, + ) + expect(fee).toBe(7n) + expect(recipient).toBe("near:alice.near") + expect(message).toBe("ref=test") + expect(unsigned.action.amount).toBe("0.123") + }) + + it("skips /info lookup when hlBridgeToken+decimals+spotId are supplied", async () => { + const fetchImpl = makeFetch() + const builder = createHyperCoreBuilder({ network: "testnet", fetch: fetchImpl }) + await builder.buildTransfer({ + spotToken: "USDC", + amount: 1n, + recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, + hlBridgeToken: "0xAaaaaaaAAaAaaAaAAAaAaAAaAAaAaAAaaaAAaAAa", + decimals: 6, + spotId: "USDC:0xdeadbeef", + }) + expect(fetchImpl).not.toHaveBeenCalled() + }) + + it("caches /info between calls", async () => { + const fetchImpl = makeFetch() + const builder = createHyperCoreBuilder({ network: "testnet", fetch: fetchImpl }) + await builder.buildTransfer({ + spotToken: "USDC", + amount: 1n, + recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, + }) + await builder.buildTransfer({ + spotToken: "PURR", + amount: 1n, + recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, + }) + expect(fetchImpl).toHaveBeenCalledTimes(1) + }) + + it("uses mainnet defaults when network=mainnet", async () => { + const builder = createHyperCoreBuilder({ network: "mainnet", fetch: makeFetch() }) + const unsigned = await builder.buildTransfer({ + spotToken: "USDC", + amount: 100_000_000n, + recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, + }) + expect(unsigned.action.hyperliquidChain).toBe("Mainnet") + expect(unsigned.action.destinationChainId).toBe(999) + }) +}) diff --git a/packages/hypercore/tests/encoders.test.ts b/packages/hypercore/tests/encoders.test.ts new file mode 100644 index 00000000..afb58a49 --- /dev/null +++ b/packages/hypercore/tests/encoders.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest" +import { decodeAbiParameters } from "viem" +import { + ACTION_INIT_TRANSFER, + ACTION_TRANSFER, + encodeInitTransferAction, + encodeTransferAction, +} from "../src/encoders.js" + +describe("encodeTransferAction", () => { + it("round-trips a recipient address", () => { + const recipient = "0x00000000000000000000000000000000DeaDBeef" + const encoded = encodeTransferAction(recipient) + expect(encoded.slice(0, 4)).toBe(`0x0${ACTION_TRANSFER}`) + const [decoded] = decodeAbiParameters([{ type: "address" }], `0x${encoded.slice(4)}`) + expect(decoded.toLowerCase()).toBe(recipient.toLowerCase()) + }) +}) + +describe("encodeInitTransferAction", () => { + it("round-trips fee, recipient, message", () => { + const fee = 10n + const recipient = "near:alice.near" + const message = "ref=hypercore" + const encoded = encodeInitTransferAction(fee, recipient, message) + expect(encoded.slice(0, 4)).toBe(`0x0${ACTION_INIT_TRANSFER}`) + const [decodedFee, decodedRecipient, decodedMessage] = decodeAbiParameters( + [{ type: "uint128" }, { type: "string" }, { type: "string" }], + `0x${encoded.slice(4)}`, + ) + expect(decodedFee).toBe(fee) + expect(decodedRecipient).toBe(recipient) + expect(decodedMessage).toBe(message) + }) + + it("supports empty message", () => { + const encoded = encodeInitTransferAction( + 0n, + "sol:11111111111111111111111111111111", + "", + ) + const [fee, recipient, message] = decodeAbiParameters( + [{ type: "uint128" }, { type: "string" }, { type: "string" }], + `0x${encoded.slice(4)}`, + ) + expect(fee).toBe(0n) + expect(recipient).toBe("sol:11111111111111111111111111111111") + expect(message).toBe("") + }) +}) diff --git a/packages/hypercore/tests/format-amount.test.ts b/packages/hypercore/tests/format-amount.test.ts new file mode 100644 index 00000000..fdc8fe83 --- /dev/null +++ b/packages/hypercore/tests/format-amount.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest" +import { formatAmount } from "../src/format-amount.js" + +describe("formatAmount", () => { + // Vectors copied from bridge-sdk-rs/.../hypercore-bridge-client/src/action.rs. + it.each([ + [0n, 8, "0"], + [1n, 8, "0.00000001"], + [100_000_000n, 8, "1"], + [123_456_789n, 8, "1.23456789"], + [100_000_000_000n, 8, "1000"], + [10n, 0, "10"], + [1_000n, 2, "10"], + [123n, 2, "1.23"], + [120n, 2, "1.2"], + ])("formatAmount(%s, %s) → %s", (amount, decimals, expected) => { + expect(formatAmount(amount, decimals)).toBe(expected) + }) + + it("rejects negative amounts", () => { + expect(() => formatAmount(-1n, 8)).toThrow(/non-negative/) + }) + + it("rejects negative decimals", () => { + expect(() => formatAmount(1n, -1)).toThrow(/decimals/) + }) +}) diff --git a/packages/hypercore/tests/typed-data.test.ts b/packages/hypercore/tests/typed-data.test.ts new file mode 100644 index 00000000..27681cb5 --- /dev/null +++ b/packages/hypercore/tests/typed-data.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest" +import { recoverAddress } from "viem" +import { privateKeyToAccount } from "viem/accounts" +import { + buildSendToEvmWithDataTypedData, + parseSignatureChainId, +} from "../src/typed-data.js" +import type { SendToEvmWithDataAction } from "../src/types.js" + +// The Rust `sample_action` test vector (signing.rs:121). We reuse it verbatim +// so the digest math is directly comparable to the Rust reference. +const SAMPLE_ACTION: SendToEvmWithDataAction = { + type: "sendToEvmWithData", + hyperliquidChain: "Testnet", + signatureChainId: "0x66eee", + token: "PURR:0xc4bf3f870c0e9465323c0b6ed28096c2", + amount: "0.01", + sourceDex: "spot", + destinationRecipient: "0x000000000000000000000000000000000000dead", + addressEncoding: "hex", + destinationChainId: 998, + gasLimit: 800_000, + data: "0x0100", + nonce: 1_716_531_066_415, +} + +describe("parseSignatureChainId", () => { + it("parses Arb-Sepolia hex", () => { + expect(parseSignatureChainId("0x66eee")).toBe(421_614n) + }) + it("parses Arbitrum hex", () => { + expect(parseSignatureChainId("0xa4b1")).toBe(42_161n) + }) + it("accepts unprefixed hex", () => { + expect(parseSignatureChainId("a4b1")).toBe(42_161n) + }) + it("rejects non-hex", () => { + expect(() => parseSignatureChainId("not-hex")).toThrow() + }) +}) + +describe("buildSendToEvmWithDataTypedData", () => { + it("produces a deterministic digest for a fixed action", () => { + const a = buildSendToEvmWithDataTypedData(SAMPLE_ACTION) + const b = buildSendToEvmWithDataTypedData(SAMPLE_ACTION) + expect(a.digest).toBe(b.digest) + }) + + it("digest changes when any field changes", () => { + const base = buildSendToEvmWithDataTypedData(SAMPLE_ACTION).digest + const changedAmount = buildSendToEvmWithDataTypedData({ ...SAMPLE_ACTION, amount: "0.02" }) + .digest + expect(changedAmount).not.toBe(base) + const changedGas = buildSendToEvmWithDataTypedData({ ...SAMPLE_ACTION, gasLimit: 900_000 }) + .digest + expect(changedGas).not.toBe(base) + const changedData = buildSendToEvmWithDataTypedData({ ...SAMPLE_ACTION, data: "0x01ff" }) + .digest + expect(changedData).not.toBe(base) + }) + + // Mirrors Rust `sign_action_recovers_signer`: sign with a known key, then + // recover the address from the digest. Matching addresses prove that the + // EIP-712 domain, type list, field order, and integer widths all align + // with what Hyperliquid's L1 expects. + it("digest signed with Anvil key #0 recovers the known address", async () => { + const privateKey = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + const account = privateKeyToAccount(privateKey) + const { digest } = buildSendToEvmWithDataTypedData(SAMPLE_ACTION) + const signature = await account.sign({ hash: digest }) + const recovered = await recoverAddress({ hash: digest, signature }) + expect(recovered.toLowerCase()).toBe(account.address.toLowerCase()) + }) +}) diff --git a/packages/hypercore/tsconfig.json b/packages/hypercore/tsconfig.json new file mode 100644 index 00000000..70b2a81c --- /dev/null +++ b/packages/hypercore/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"], + "references": [{ "path": "../core" }] +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 85797fd2..f5d26a45 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -35,7 +35,8 @@ "@omni-bridge/near": "workspace:*", "@omni-bridge/solana": "workspace:*", "@omni-bridge/btc": "workspace:*", - "@omni-bridge/starknet": "workspace:*" + "@omni-bridge/starknet": "workspace:*", + "@omni-bridge/hypercore": "workspace:*" }, "devDependencies": { "typescript": "^5.9.3" diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 14a6ce8c..ec102f1c 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -4,6 +4,7 @@ export * from "@omni-bridge/btc" export * from "@omni-bridge/core" export * from "@omni-bridge/evm" +export * from "@omni-bridge/hypercore" export * from "@omni-bridge/near" export * from "@omni-bridge/solana" export * from "@omni-bridge/starknet" diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json index 21336c96..93c27412 100644 --- a/packages/sdk/tsconfig.json +++ b/packages/sdk/tsconfig.json @@ -11,6 +11,7 @@ { "path": "../near" }, { "path": "../solana" }, { "path": "../btc" }, - { "path": "../starknet" } + { "path": "../starknet" }, + { "path": "../hypercore" } ] } diff --git a/tsconfig.json b/tsconfig.json index efd23076..815a004a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,6 +7,7 @@ { "path": "packages/solana" }, { "path": "packages/btc" }, { "path": "packages/starknet" }, + { "path": "packages/hypercore" }, { "path": "packages/sdk" } ] } From 4c195a1b17e4b6e5ce4d8d8c831e3ffba00c36ea Mon Sep 17 00:00:00 2001 From: kiseln <3428059+kiseln@users.noreply.github.com> Date: Thu, 28 May 2026 14:25:35 -0300 Subject: [PATCH 2/4] docs(hypercore): add README and changeset for HyperEVM/HyperCore support Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/config.json | 1 + .changeset/hypercore-support.md | 7 ++ packages/evm/README.md | 3 +- packages/hypercore/README.md | 115 ++++++++++++++++++++++++++++++++ 4 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 .changeset/hypercore-support.md create mode 100644 packages/hypercore/README.md diff --git a/.changeset/config.json b/.changeset/config.json index 3fabfd33..14e9be2d 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -10,6 +10,7 @@ "@omni-bridge/solana", "@omni-bridge/btc", "@omni-bridge/starknet", + "@omni-bridge/hypercore", "@omni-bridge/sdk" ] ], diff --git a/.changeset/hypercore-support.md b/.changeset/hypercore-support.md new file mode 100644 index 00000000..4d56a05f --- /dev/null +++ b/.changeset/hypercore-support.md @@ -0,0 +1,7 @@ +--- +"@omni-bridge/hypercore": minor +"@omni-bridge/core": minor +"@omni-bridge/evm": minor +--- + +support transfers from HyperEVM and HyperCore diff --git a/packages/evm/README.md b/packages/evm/README.md index f1608d27..35925f63 100644 --- a/packages/evm/README.md +++ b/packages/evm/README.md @@ -71,6 +71,7 @@ const txResponse = await wallet.sendTransaction(tx) | BNB | 56 | 97 (BSC Testnet) | | Polygon | 137 | 80002 (Amoy) | | Abstract | 2741 | 11124 (Abstract Testnet) | +| HyperEVM | 999 | 998 (HyperEVM Testnet) | ```typescript import { ChainKind } from "@omni-bridge/core" @@ -92,7 +93,7 @@ console.log(ethBuilder.bridgeAddress) // 0xe00c629afaccb0510995a2b95560e446a24c8 ```typescript const builder = createEvmBuilder({ network: "mainnet" | "testnet", - chain: ChainKind.Eth | ChainKind.Arb | ChainKind.Base | ChainKind.Bnb | ChainKind.Pol | ChainKind.Abs + chain: ChainKind.Eth | ChainKind.Arb | ChainKind.Base | ChainKind.Bnb | ChainKind.Pol | ChainKind.Abs | ChainKind.HyperEvm }) // Properties diff --git a/packages/hypercore/README.md b/packages/hypercore/README.md new file mode 100644 index 00000000..47481738 --- /dev/null +++ b/packages/hypercore/README.md @@ -0,0 +1,115 @@ +# @omni-bridge/hypercore + +HyperCore (Hyperliquid L1) action builder for [Omni Bridge](https://github.com/nearone/bridge-sdk-js). + +Builds the EIP-712 `sendToEvmWithData` user-signed action posted to Hyperliquid's `/exchange` endpoint. Use this when **the user is on HyperCore** and wants to bridge a spot balance to HyperEVM or any other supported chain. + +> Bridging *to* HyperCore from another chain is a regular bridge transfer with `recipient: "hlevm:0x..."` and a non-empty `message` — see the inbound helper `buildHyperliquidTransferParams` in `@omni-bridge/core`. For outbound from HyperEVM (regular EVM source), use `@omni-bridge/evm` with `chain: ChainKind.HyperEvm`. + +## Installation + +```bash +npm install @omni-bridge/hypercore @omni-bridge/core viem +``` + +## Quick Start + +```typescript +import { createHyperCoreBuilder, postExchangeAction, splitSignature } from "@omni-bridge/hypercore" +import { privateKeyToAccount } from "viem/accounts" + +const builder = createHyperCoreBuilder({ network: "mainnet" }) + +// 1. Build the unsigned action. The SDK resolves the HlBridgeToken contract +// and spot token id from Hyperliquid /info { type: "spotMeta" }. +const unsigned = await builder.buildTransfer({ + spotToken: "USDC", // Hyperliquid-native spot identifier + amount: 1_00000000n, // 1 USDC at 8 decimals (szDecimals + evmExtraWeiDecimals) + recipient: "near:alice.near", // any OmniAddress + fee: 0n, + message: "", +}) + +// 2. Sign the precomputed EIP-712 digest with the user's HyperCore wallet. +const account = privateKeyToAccount("0x...") +const signature = await account.sign({ hash: unsigned.typedData.digest }) + +// 3. Post to Hyperliquid /exchange. +await postExchangeAction({ + apiUrl: builder.apiUrl, + action: unsigned.action, + signature: splitSignature(signature), +}) +``` + +Wallets that prefer the structured EIP-712 prompt can use `unsigned.typedData.domain`, `.types`, and `.message` with `walletClient.signTypedData(...)` instead of signing the raw digest. + +## Action dispatch + +The first byte of the `data` payload routes the on-chain call inside `HlBridgeToken`: + +| Recipient chain | Action tag | Effect | +|---|---|---| +| `hlevm:0x...` | `0x00` `ACTION_TRANSFER` | Pool release from `HlBridgeToken._systemAddress` directly to the HyperEVM address. | +| Anything else (`near:`, `eth:`, `sol:`, ...) | `0x01` `ACTION_INIT_TRANSFER` | Calls `OmniBridge.initTransfer(fee, recipient, message)` to route via the bridge. | + +`buildTransfer` picks the right action automatically based on the recipient OmniAddress. + +## Skipping the `/info` lookup + +`buildTransfer` calls `/info spotMeta` once per process (cached by api URL) to resolve `spotId`, `hlBridgeToken`, and `decimals`. Pre-supply all three to skip the network round-trip: + +```typescript +const unsigned = await builder.buildTransfer({ + spotToken: "USDC", + amount: 1_00000000n, + recipient: "near:alice.near", + spotId: "USDC:0x6d1e7cde53ba9467b783cb7c530ce054", + hlBridgeToken: "0x1234567890123456789012345678901234567890", + decimals: 8, +}) +``` + +## API + +### `createHyperCoreBuilder(config)` + +```typescript +const builder = createHyperCoreBuilder({ + network: "mainnet" | "testnet", + apiUrl?: string, // override Hyperliquid REST base + signatureChainId?: string, // hex, defaults to "0x66eee" (Hyperliquid Python SDK convention) + fetch?: typeof fetch, // custom fetch (tests, proxies) +}) +``` + +### `builder.buildTransfer(params)` + +Returns `{ action, typedData: { domain, types, primaryType, message, digest }, hlBridgeToken }`. + +### Helpers + +- `postExchangeAction({ apiUrl, action, signature })` — POSTs to `/exchange`, throws on non-2xx or `status: "err"` response. +- `splitSignature(sig)` — splits a 65-byte hex signature into the `{ r, s, v }` envelope expected by `/exchange`. +- `resolveSpotToken(apiUrl, name)` / `resolveSpotTokenCached(...)` — direct access to the `/info spotMeta` resolver. +- `encodeTransferAction(address)` / `encodeInitTransferAction(fee, recipient, message)` — low-level `data` encoders. +- `formatAmount(amount, decimals)` — bigint → Hyperliquid decimal string. + +### Constants + +- `HYPERCORE_API_URL` — per-network `/info` + `/exchange` base. +- `HYPEREVM_CHAIN_ID` — `999` (mainnet) / `998` (testnet); used as `destinationChainId` in the action JSON. +- `DEFAULT_SIGNATURE_CHAIN_ID = "0x66eee"` — Arb-Sepolia; only used for cross-chain replay protection inside the EIP-712 domain. Not tied to Arbitrum execution. +- `DEFAULT_GAS_LIMIT_TRANSFER = 300_000` / `DEFAULT_GAS_LIMIT_INIT_TRANSFER = 800_000`. + +## Decimals + +The action JSON's `amount` is a decimal string. `formatAmount` converts a raw bigint using `szDecimals + evmExtraWeiDecimals` from `/info spotMeta` — the HyperEVM↔HyperCore linking invariant. If the bridge token's `decimals()` doesn't match this sum, formatted amounts won't line up with the user's Core spot balance precision. + +## Confirmation + +This package does **not** poll HyperEVM for the resulting `CoreReceived` log. After `/exchange` accepts the action, the system transaction lands on HyperEVM asynchronously; subscribe via your own RPC tooling if you need landing confirmation. + +## License + +MIT From 3f45c998388050d207bc2c37a5c88fb433c3c231 Mon Sep 17 00:00:00 2001 From: kiseln <3428059+kiseln@users.noreply.github.com> Date: Thu, 28 May 2026 20:21:44 -0300 Subject: [PATCH 3/4] fix: HL decimals --- packages/hypercore/README.md | 4 ++-- packages/hypercore/src/spot-meta.ts | 13 ++++++++++--- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/hypercore/README.md b/packages/hypercore/README.md index 47481738..e1b5a2b5 100644 --- a/packages/hypercore/README.md +++ b/packages/hypercore/README.md @@ -24,7 +24,7 @@ const builder = createHyperCoreBuilder({ network: "mainnet" }) // and spot token id from Hyperliquid /info { type: "spotMeta" }. const unsigned = await builder.buildTransfer({ spotToken: "USDC", // Hyperliquid-native spot identifier - amount: 1_00000000n, // 1 USDC at 8 decimals (szDecimals + evmExtraWeiDecimals) + amount: 1_000000n, // 1 USDC at 6 decimals (weiDecimals + evm_extra_wei_decimals) recipient: "near:alice.near", // any OmniAddress fee: 0n, message: "", @@ -104,7 +104,7 @@ Returns `{ action, typedData: { domain, types, primaryType, message, digest }, h ## Decimals -The action JSON's `amount` is a decimal string. `formatAmount` converts a raw bigint using `szDecimals + evmExtraWeiDecimals` from `/info spotMeta` — the HyperEVM↔HyperCore linking invariant. If the bridge token's `decimals()` doesn't match this sum, formatted amounts won't line up with the user's Core spot balance precision. +The action JSON's `amount` is a decimal string. `formatAmount` converts the raw bridge-wei bigint using **`weiDecimals + evm_extra_wei_decimals`** from `/info spotMeta` — that sum is the HlBridgeToken ERC-20's `.decimals()` per the HyperEVM↔HyperCore linking invariant. (`szDecimals` is order-size precision in the orderbook and is **not** the same thing — using it would over-divide for tokens where `szDecimals < weiDecimals`, e.g. PURR/HFUN.) ## Confirmation diff --git a/packages/hypercore/src/spot-meta.ts b/packages/hypercore/src/spot-meta.ts index be9c2e2d..9fbdb6f6 100644 --- a/packages/hypercore/src/spot-meta.ts +++ b/packages/hypercore/src/spot-meta.ts @@ -9,7 +9,14 @@ export interface SpotTokenInfo { spotId: string /** HlBridgeToken ERC-20 on HyperEVM — the action JSON's `destinationRecipient`. */ hlBridgeToken: Address - /** szDecimals + evmExtraWeiDecimals — used by `formatAmount`. */ + /** + * HlBridgeToken ERC-20 `.decimals()`, used by `formatAmount` when converting + * the bridge-wei `amount` to the action JSON's decimal string. By the + * HyperEVM↔HyperCore linking invariant this equals + * `weiDecimals + evm_extra_wei_decimals`. Note: `szDecimals` is order-size + * precision and is unrelated — using it would over-divide for tokens like + * PURR/HFUN where `szDecimals < weiDecimals`. + */ decimals: number } @@ -80,7 +87,7 @@ export async function resolveSpotToken( return { spotId: `${match.name}:${match.tokenId}`, hlBridgeToken: match.evmContract.address as Address, - decimals: match.szDecimals + match.evmContract.evm_extra_wei_decimals, + decimals: match.weiDecimals + match.evmContract.evm_extra_wei_decimals, } } @@ -120,7 +127,7 @@ export async function resolveSpotTokenCached( return { spotId: `${match.name}:${match.tokenId}`, hlBridgeToken: match.evmContract.address as Address, - decimals: match.szDecimals + match.evmContract.evm_extra_wei_decimals, + decimals: match.weiDecimals + match.evmContract.evm_extra_wei_decimals, } } From c40335b01a2f0c9f7fddfd092bb625b9a4208f9f Mon Sep 17 00:00:00 2001 From: kiseln <3428059+kiseln@users.noreply.github.com> Date: Thu, 28 May 2026 21:26:52 -0300 Subject: [PATCH 4/4] fix: code review --- docs/docs.json | 3 +- docs/reference/hypercore.mdx | 288 +++++++++++++++++++++++ packages/hypercore/README.md | 23 +- packages/hypercore/src/builder.ts | 63 +++-- packages/hypercore/src/index.ts | 1 + packages/hypercore/src/spot-meta.ts | 80 ++++--- packages/hypercore/tests/builder.test.ts | 146 ++++++++++-- 7 files changed, 525 insertions(+), 79 deletions(-) create mode 100644 docs/reference/hypercore.mdx diff --git a/docs/docs.json b/docs/docs.json index b3202b81..5d84d79a 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -64,7 +64,8 @@ "reference/evm", "reference/near", "reference/solana", - "reference/btc" + "reference/btc", + "reference/hypercore" ] } ] diff --git a/docs/reference/hypercore.mdx b/docs/reference/hypercore.mdx new file mode 100644 index 00000000..3afc21cf --- /dev/null +++ b/docs/reference/hypercore.mdx @@ -0,0 +1,288 @@ +--- +title: "@omni-bridge/hypercore" +description: HyperCore (Hyperliquid L1) action builder for outbound transfers from spot balances +--- + +## Import + +```typescript +import { + createHyperCoreBuilder, + postExchangeAction, + splitSignature, + buildSendToEvmWithDataTypedData, + parseSpotId, + resolveSpotToken, + resolveSpotTokenCached, + fetchSpotMeta, + encodeTransferAction, + encodeInitTransferAction, + formatAmount, + ACTION_TRANSFER, + ACTION_INIT_TRANSFER, + HYPERCORE_API_URL, + HYPEREVM_CHAIN_ID, + HYPERLIQUID_CHAIN, + DEFAULT_SIGNATURE_CHAIN_ID, + DEFAULT_GAS_LIMIT_TRANSFER, + DEFAULT_GAS_LIMIT_INIT_TRANSFER, + SEND_TO_EVM_WITH_DATA_TYPE_NAME, +} from "@omni-bridge/hypercore" +``` + +This package builds the EIP-712 `sendToEvmWithData` user-signed action that lets a HyperCore (Hyperliquid L1) spot holder bridge out — either to a HyperEVM address (pool release) or to any other chain (routed via `OmniBridge.initTransfer`). For bridging *into* HyperCore from another chain, use the inbound helper `buildHyperliquidTransferParams` in `@omni-bridge/core`. + +--- + +## createHyperCoreBuilder + +Factory function that returns a `HyperCoreBuilder` scoped to a network. + +### Signature + +```typescript +function createHyperCoreBuilder(config: HyperCoreBuilderConfig): HyperCoreBuilder +``` + +### Parameters + + + Configuration object. + + + Selects the Hyperliquid REST endpoint, `hyperliquidChain` action field, and HyperEVM `destinationChainId`. + + + + Override the Hyperliquid REST base URL. Defaults to `HYPERCORE_API_URL[network]`. + + + + Hex chain id embedded in the EIP-712 domain. Defaults to `"0x66eee"` (Arb-Sepolia, matching the Hyperliquid Python SDK convention). Only its uniqueness across chains matters for replay protection — not tied to Arbitrum execution. + + + + Custom `fetch` implementation (for tests, proxies, or polyfills). Defaults to the global `fetch`. + + + + +### Returns + + + A HyperCore action builder instance. + + + The configured network (readonly). + + + + The configured Hyperliquid REST base URL (readonly). Useful when passing `apiUrl` to `postExchangeAction`. + + + + Builds the unsigned `sendToEvmWithData` action and EIP-712 envelope. See below. + + + + +--- + +## buildTransfer + +Builds the unsigned `sendToEvmWithData` action. Picks `ACTION_TRANSFER` (`0x00`, pool release to a HyperEVM address) when `recipient` is `hlevm:0x...`, otherwise `ACTION_INIT_TRANSFER` (`0x01`, calls `OmniBridge.initTransfer`). Resolves the `HlBridgeToken` contract and on-EVM decimals via `/info spotMeta` unless those are supplied explicitly. + +### Signature + +```typescript +buildTransfer(params: HyperCoreTransferParams): Promise +``` + +### Parameters + + + + + Hyperliquid spot identifier in the canonical `"NAME:0x<32hex>"` form (e.g. `"USDC:0x6d1e7cde53ba9467b783cb7c530ce054"`). Names alone are **not** accepted: Hyperliquid permits multiple tokens to share a `name`, so the 32-byte `tokenId` is the only unambiguous handle. + + + + Amount in **bridge ERC-20 wei** (= `weiDecimals + evm_extra_wei_decimals` from `/info spotMeta`). Must be `> 0n`. + + + + Destination address. `hlevm:0x...` → on-HyperEVM pool release; any other chain (`near:`, `eth:`, `sol:`, …) → routed via `OmniBridge.initTransfer`. + + + + Bridge fee in bridge ERC-20 wei. Only used when `recipient` is **not** HyperEVM. Must satisfy `0n <= fee < amount`. Defaults to `0n`. + + + + Optional message forwarded with the `OmniBridge.initTransfer` event. Ignored for HyperEVM recipients. Defaults to `""`. + + + + HyperEVM-side gas limit for the resulting system tx. Defaults to `DEFAULT_GAS_LIMIT_TRANSFER` (300k) for pool release, `DEFAULT_GAS_LIMIT_INIT_TRANSFER` (800k) for routed transfers. + + + + Pre-resolved HlBridgeToken contract. When supplied **together with `decimals`**, skips the `/info spotMeta` lookup. Useful for offline/deterministic builds. + + + + Pre-resolved HlBridgeToken ERC-20 `.decimals()` (= `weiDecimals + evm_extra_wei_decimals`). Required together with `hlBridgeToken`. + + + + +### Returns + + + + + Ready-to-post action JSON (without signature). POST this in the `/exchange` envelope along with the signature and nonce. + + + + EIP-712 envelope: `{ domain, types, primaryType, message, digest }`. Sign `digest` directly with any 65-byte ECDSA wallet, or pass `domain`/`types`/`message` to `walletClient.signTypedData(...)` for a structured wallet prompt. + + + + Resolved HlBridgeToken contract on HyperEVM (also present in `action.destinationRecipient`, lowercased). + + + + +### Example + +```typescript +import { createHyperCoreBuilder, postExchangeAction, splitSignature } from "@omni-bridge/hypercore" +import { privateKeyToAccount } from "viem/accounts" + +const builder = createHyperCoreBuilder({ network: "mainnet" }) + +// 1. Build the unsigned action. +const unsigned = await builder.buildTransfer({ + spotId: "USDC:0x6d1e7cde53ba9467b783cb7c530ce054", + amount: 1_000_000n, // 1 USDC at on-EVM 6 decimals + recipient: "near:alice.near", + fee: 0n, + message: "", +}) + +// 2. Sign the precomputed EIP-712 digest with the user's HyperCore wallet. +const account = privateKeyToAccount("0x...") +const signature = await account.sign({ hash: unsigned.typedData.digest }) + +// 3. POST to Hyperliquid /exchange. +await postExchangeAction({ + apiUrl: builder.apiUrl, + action: unsigned.action, + signature: splitSignature(signature), +}) +``` + +--- + +## postExchangeAction + +Thin POST helper that submits the signed action envelope to Hyperliquid's `/exchange`. Throws on non-2xx or `status: "err"` response. + +### Signature + +```typescript +function postExchangeAction( + options: PostExchangeActionOptions, +): Promise +``` + +### Parameters + + + + + Hyperliquid REST base URL (no `/exchange` suffix). + + + + The action returned by `buildTransfer`. + + + + `{ r, s, v }` produced from signing the EIP-712 digest. Use `splitSignature(sig)` to split a 65-byte hex signature. + + + + Custom `fetch` implementation. Defaults to the global `fetch`. + + + + +This call does **not** wait for the downstream HyperEVM `CoreReceived` log. The system transaction lands asynchronously after `/exchange` accepts the action — subscribe via your own RPC tooling if you need landing confirmation. + +--- + +## Spot meta resolvers + +`/info spotMeta` is the source of truth for the HlBridgeToken contract and on-EVM decimals of every Hyperliquid spot token. The builder calls it transparently; the helpers below let you query it directly. + + + Validates and splits a canonical `"NAME:0x<32hex>"` identifier. Throws on malformed input. + + + + Fetch `/info spotMeta` and return `{ spotId, hlBridgeToken, decimals }` for the entry whose `tokenId` matches. + + + + Same as `resolveSpotToken`, but memoizes the `/info` response per `apiUrl` for the life of the process. The builder uses this internally so repeat builds within a session don't re-hit `/info`. + + + + Low-level: returns the full token table. + + +--- + +## Action `data` encoders and EIP-712 + + + `0x00 || abi.encode(address recipient)` — the `data` payload routed to the `ACTION_TRANSFER` path in `HlBridgeToken` (pool release). + + + + `0x01 || abi.encode(uint128 fee, string recipient, string message)` — the `data` payload that triggers `OmniBridge.initTransfer`. + + + + Build the EIP-712 envelope (`domain`, `types`, `primaryType`, `message`) plus a precomputed `digest` for an already-assembled action. `buildTransfer` does this for you; reach for it only when you've constructed an action by hand. + + + + Splits a 65-byte hex signature (`viem.sign({ hash })` output) into the `{ r, s, v }` envelope expected by Hyperliquid's `/exchange`. + + +--- + +## Utilities + + + Convert a raw bigint to Hyperliquid's compact decimal-string format (no trailing zeros, single leading zero before the decimal point). `decimals` is the HlBridgeToken ERC-20's `.decimals()` (i.e. `weiDecimals + evm_extra_wei_decimals`), **not** `szDecimals`. + + +--- + +## Constants + +| Constant | Type | Value / role | +|---|---|---| +| `ACTION_TRANSFER` | `number` | `0x00` — pool-release action tag. | +| `ACTION_INIT_TRANSFER` | `number` | `0x01` — `OmniBridge.initTransfer` action tag. | +| `HYPERCORE_API_URL` | `Record` | Per-network Hyperliquid REST base URL. | +| `HYPEREVM_CHAIN_ID` | `Record` | `999` (mainnet) / `998` (testnet). Used as `destinationChainId` in the action JSON. | +| `HYPERLIQUID_CHAIN` | `Record` | Embedded in the action JSON's `hyperliquidChain` field. | +| `DEFAULT_SIGNATURE_CHAIN_ID` | `string` | `"0x66eee"` (Arb-Sepolia). EIP-712 domain `chainId`; only its cross-chain uniqueness matters. | +| `DEFAULT_GAS_LIMIT_TRANSFER` | `number` | `300_000` — pool-release gas limit. | +| `DEFAULT_GAS_LIMIT_INIT_TRANSFER` | `number` | `800_000` — routed-transfer gas limit. | +| `SEND_TO_EVM_WITH_DATA_TYPE_NAME` | `string` | `"HyperliquidTransaction:SendToEvmWithData"` — EIP-712 primary type. | diff --git a/packages/hypercore/README.md b/packages/hypercore/README.md index e1b5a2b5..e1fb304b 100644 --- a/packages/hypercore/README.md +++ b/packages/hypercore/README.md @@ -21,10 +21,15 @@ import { privateKeyToAccount } from "viem/accounts" const builder = createHyperCoreBuilder({ network: "mainnet" }) // 1. Build the unsigned action. The SDK resolves the HlBridgeToken contract -// and spot token id from Hyperliquid /info { type: "spotMeta" }. +// address and decimals from Hyperliquid /info { type: "spotMeta" }. +// +// `spotId` is the canonical Hyperliquid spot identifier "NAME:0x<32hex>" +// — names alone are NOT accepted because Hyperliquid allows multiple +// tokens to share a `name`. Look the tokenId up in /info, or copy it +// from a Hyperliquid spot explorer. const unsigned = await builder.buildTransfer({ - spotToken: "USDC", // Hyperliquid-native spot identifier - amount: 1_000000n, // 1 USDC at 6 decimals (weiDecimals + evm_extra_wei_decimals) + spotId: "USDC:0x6d1e7cde53ba9467b783cb7c530ce054", + amount: 1_000_000n, // 1 USDC at 6 decimals (weiDecimals + evm_extra_wei_decimals) recipient: "near:alice.near", // any OmniAddress fee: 0n, message: "", @@ -57,16 +62,15 @@ The first byte of the `data` payload routes the on-chain call inside `HlBridgeTo ## Skipping the `/info` lookup -`buildTransfer` calls `/info spotMeta` once per process (cached by api URL) to resolve `spotId`, `hlBridgeToken`, and `decimals`. Pre-supply all three to skip the network round-trip: +`buildTransfer` calls `/info spotMeta` once per process (cached by api URL) to resolve `hlBridgeToken` and `decimals`. Pre-supply both to skip the network round-trip: ```typescript const unsigned = await builder.buildTransfer({ - spotToken: "USDC", - amount: 1_00000000n, - recipient: "near:alice.near", spotId: "USDC:0x6d1e7cde53ba9467b783cb7c530ce054", + amount: 1_000_000n, + recipient: "near:alice.near", hlBridgeToken: "0x1234567890123456789012345678901234567890", - decimals: 8, + decimals: 6, }) ``` @@ -91,7 +95,8 @@ Returns `{ action, typedData: { domain, types, primaryType, message, digest }, h - `postExchangeAction({ apiUrl, action, signature })` — POSTs to `/exchange`, throws on non-2xx or `status: "err"` response. - `splitSignature(sig)` — splits a 65-byte hex signature into the `{ r, s, v }` envelope expected by `/exchange`. -- `resolveSpotToken(apiUrl, name)` / `resolveSpotTokenCached(...)` — direct access to the `/info spotMeta` resolver. +- `resolveSpotToken(apiUrl, spotId)` / `resolveSpotTokenCached(...)` — direct access to the `/info spotMeta` resolver. Takes a full `NAME:0x<32hex>` identifier. +- `parseSpotId(spotId)` — validates and splits a spot identifier into `{ name, tokenId }`. - `encodeTransferAction(address)` / `encodeInitTransferAction(fee, recipient, message)` — low-level `data` encoders. - `formatAmount(amount, decimals)` — bigint → Hyperliquid decimal string. diff --git a/packages/hypercore/src/builder.ts b/packages/hypercore/src/builder.ts index 42fa8355..3d2e2a45 100644 --- a/packages/hypercore/src/builder.ts +++ b/packages/hypercore/src/builder.ts @@ -16,6 +16,7 @@ import { } from "./encoders.js" import { formatAmount } from "./format-amount.js" import { + parseSpotId, resolveSpotTokenCached, type SpotMetaFetchOptions, type SpotTokenInfo, @@ -34,13 +35,19 @@ export interface HyperCoreBuilderConfig { } export interface HyperCoreTransferParams { - /** Hyperliquid spot token name (e.g. "USDC", "PURR"). */ - spotToken: string - /** Amount in bridge ERC-20 wei units. */ + /** + * Hyperliquid spot identifier in the canonical `NAME:0x<32hex>` form (e.g. + * `"USDC:0x6d1e7cde53ba9467b783cb7c530ce054"`). + */ + spotId: string + /** Amount in bridge ERC-20 wei units. Must be `> 0n`. */ amount: bigint /** Recipient as an OmniAddress. HyperEVM destination → pool release; any other chain → routed via `OmniBridge.initTransfer`. */ recipient: OmniAddress - /** Bridge fee in bridge ERC-20 wei (only used when `recipient` is not HyperEVM). */ + /** + * Bridge fee in bridge ERC-20 wei (only used when `recipient` is not HyperEVM). + * Must satisfy `0n <= fee < amount`. + */ fee?: bigint /** Optional message forwarded with the bridge event (only used for non-HyperEVM recipients). */ message?: string @@ -51,10 +58,11 @@ export interface HyperCoreTransferParams { * skips the `/info spotMeta` lookup — useful for offline/deterministic builds. */ hlBridgeToken?: Address - /** Pre-resolved bridge-token decimals (szDecimals + evmExtraWeiDecimals). */ + /** + * Pre-resolved HlBridgeToken ERC-20 `.decimals()` (= `weiDecimals + evm_extra_wei_decimals` + * from `/info spotMeta`). Required together with `hlBridgeToken` to skip the lookup. + */ decimals?: number - /** Pre-resolved Hyperliquid spot identifier (`NAME:0x<32hex>`). */ - spotId?: string } export interface HyperCoreUnsignedAction { @@ -86,8 +94,9 @@ class HyperCoreBuilderImpl implements HyperCoreBuilder { } async buildTransfer(params: HyperCoreTransferParams): Promise { - const spotInfo = await this.resolveSpotInfo(params) + this.validateParams(params) + const spotInfo = await this.resolveSpotInfo(params) const data = this.encodeData(params) const isPoolRelease = data.actionTag === ACTION_TRANSFER const gasLimit = @@ -116,12 +125,33 @@ class HyperCoreBuilderImpl implements HyperCoreBuilder { } } + private validateParams(params: HyperCoreTransferParams): void { + if (params.amount <= 0n) { + throw new Error(`amount must be > 0, got ${params.amount}`) + } + const fee = params.fee ?? 0n + if (fee < 0n) { + throw new Error(`fee must be >= 0, got ${fee}`) + } + const recipientChain = getChain(params.recipient) + if (recipientChain !== ChainKind.HyperEvm && fee >= params.amount) { + throw new Error( + `fee (${fee}) must be strictly less than amount (${params.amount}) for ${ChainKind[recipientChain]} recipients`, + ) + } + if (params.hlBridgeToken !== undefined && params.decimals === undefined) { + throw new Error("decimals must be supplied together with hlBridgeToken") + } + if (params.decimals !== undefined && params.hlBridgeToken === undefined) { + throw new Error("hlBridgeToken must be supplied together with decimals") + } + } + private async resolveSpotInfo(params: HyperCoreTransferParams): Promise { - if ( - params.hlBridgeToken !== undefined && - params.decimals !== undefined && - params.spotId !== undefined - ) { + if (params.hlBridgeToken !== undefined && params.decimals !== undefined) { + // Validate the spotId format up front even on the offline path so callers + // can't sign a malformed `token` field. + parseSpotId(params.spotId) return { spotId: params.spotId, hlBridgeToken: params.hlBridgeToken, @@ -129,12 +159,7 @@ class HyperCoreBuilderImpl implements HyperCoreBuilder { } } const fetchOpts: SpotMetaFetchOptions = this.fetchImpl ? { fetch: this.fetchImpl } : {} - const resolved = await resolveSpotTokenCached(this.apiUrl, params.spotToken, fetchOpts) - return { - spotId: params.spotId ?? resolved.spotId, - hlBridgeToken: params.hlBridgeToken ?? resolved.hlBridgeToken, - decimals: params.decimals ?? resolved.decimals, - } + return resolveSpotTokenCached(this.apiUrl, params.spotId, fetchOpts) } private encodeData(params: HyperCoreTransferParams): { hex: Hex; actionTag: number } { diff --git a/packages/hypercore/src/index.ts b/packages/hypercore/src/index.ts index e553a997..0f82ef05 100644 --- a/packages/hypercore/src/index.ts +++ b/packages/hypercore/src/index.ts @@ -22,6 +22,7 @@ export { export { formatAmount } from "./format-amount.js" export { fetchSpotMeta, + parseSpotId, resolveSpotToken, resolveSpotTokenCached, type SpotMetaFetchOptions, diff --git a/packages/hypercore/src/spot-meta.ts b/packages/hypercore/src/spot-meta.ts index 9fbdb6f6..8bd4422c 100644 --- a/packages/hypercore/src/spot-meta.ts +++ b/packages/hypercore/src/spot-meta.ts @@ -66,31 +66,64 @@ export async function fetchSpotMeta( } /** - * Look up `SpotTokenInfo` for a single spot token by name (e.g. "USDC", "PURR"). - * Throws if the name isn't found or has no linked HyperEVM contract. + * Parse a Hyperliquid spot identifier in the canonical `NAME:0x<32hex>` form + * (the same shape used in `/info spotMeta` and the action JSON `token` field). */ -export async function resolveSpotToken( - apiUrl: string, - spotTokenName: string, - options: SpotMetaFetchOptions = {}, -): Promise { - const tokens = await fetchSpotMeta(apiUrl, options) - const match = tokens.find((t) => t.name === spotTokenName || t.fullName === spotTokenName) +export function parseSpotId(spotId: string): { name: string; tokenId: string } { + const colon = spotId.indexOf(":") + if (colon === -1) { + throw new Error( + `Invalid spot identifier "${spotId}": expected "NAME:0x<32hex>" — names alone are ambiguous and not accepted`, + ) + } + const name = spotId.slice(0, colon) + const tokenId = spotId.slice(colon + 1).toLowerCase() + if (!/^0x[0-9a-f]{32}$/.test(tokenId)) { + throw new Error( + `Invalid spot identifier "${spotId}": tokenId must be a 32-hex string (16 bytes), got "${tokenId}"`, + ) + } + return { name, tokenId } +} + +function findToken(tokens: SpotMetaToken[], spotId: string): SpotMetaToken { + const { tokenId } = parseSpotId(spotId) + const match = tokens.find((t) => t.tokenId.toLowerCase() === tokenId) if (!match) { - throw new Error(`Hyperliquid spot token "${spotTokenName}" not found in /info spotMeta`) + throw new Error(`Hyperliquid spot token "${spotId}" not found in /info spotMeta`) } if (!match.evmContract) { throw new Error( - `Spot token "${spotTokenName}" has no linked HyperEVM contract (cannot be bridged via HlBridgeToken)`, + `Spot token "${spotId}" has no linked HyperEVM contract (cannot be bridged via HlBridgeToken)`, ) } + return match +} + +function toInfo(match: SpotMetaToken): SpotTokenInfo { + // Safe: findToken throws when evmContract is null. + const evm = match.evmContract as NonNullable return { spotId: `${match.name}:${match.tokenId}`, - hlBridgeToken: match.evmContract.address as Address, - decimals: match.weiDecimals + match.evmContract.evm_extra_wei_decimals, + hlBridgeToken: evm.address as Address, + decimals: match.weiDecimals + evm.evm_extra_wei_decimals, } } +/** + * Look up `SpotTokenInfo` by full spot identifier (`NAME:0x<32hex>`). + * Throws if the identifier is malformed, the tokenId isn't in `/info spotMeta`, + * or the token has no linked HyperEVM contract. + */ +export async function resolveSpotToken( + apiUrl: string, + spotId: string, + options: SpotMetaFetchOptions = {}, +): Promise { + const tokens = await fetchSpotMeta(apiUrl, options) + return toInfo(findToken(tokens, spotId)) +} + /** * Process-local cache of `/info spotMeta` results, keyed by api URL. */ @@ -98,12 +131,12 @@ const CACHE: Map> = new Map() /** * Like `resolveSpotToken`, but memoizes the `/info` response per `apiUrl` for - * the lifetime of the process. Subsequent lookups for any token name reuse the - * cached table — typically one network round-trip per session. + * the lifetime of the process. Subsequent lookups for any spot identifier + * reuse the cached table — typically one network round-trip per session. */ export async function resolveSpotTokenCached( apiUrl: string, - spotTokenName: string, + spotId: string, options: SpotMetaFetchOptions = {}, ): Promise { let pending = CACHE.get(apiUrl) @@ -115,20 +148,7 @@ export async function resolveSpotTokenCached( CACHE.set(apiUrl, pending) } const tokens = await pending - const match = tokens.find((t) => t.name === spotTokenName || t.fullName === spotTokenName) - if (!match) { - throw new Error(`Hyperliquid spot token "${spotTokenName}" not found in /info spotMeta`) - } - if (!match.evmContract) { - throw new Error( - `Spot token "${spotTokenName}" has no linked HyperEVM contract (cannot be bridged via HlBridgeToken)`, - ) - } - return { - spotId: `${match.name}:${match.tokenId}`, - hlBridgeToken: match.evmContract.address as Address, - decimals: match.weiDecimals + match.evmContract.evm_extra_wei_decimals, - } + return toInfo(findToken(tokens, spotId)) } /** Test-only — clear the memoized `/info` cache. */ diff --git a/packages/hypercore/tests/builder.test.ts b/packages/hypercore/tests/builder.test.ts index e65987a1..e70772ef 100644 --- a/packages/hypercore/tests/builder.test.ts +++ b/packages/hypercore/tests/builder.test.ts @@ -5,6 +5,14 @@ import { createHyperCoreBuilder } from "../src/builder.js" import { ACTION_INIT_TRANSFER, ACTION_TRANSFER } from "../src/encoders.js" import { _clearSpotMetaCache } from "../src/spot-meta.js" +// szDecimals != weiDecimals deliberately — covers the formula +// `decimals = weiDecimals + evm_extra_wei_decimals` and would fail with the +// (incorrect) szDecimals-based formula. +const USDC_TOKEN_ID = "0x6d1e7cde53ba9467b783cb7c530ce054" +const USDC_SPOT_ID = `USDC:${USDC_TOKEN_ID}` +const PURR_TOKEN_ID = "0xc4bf3f870c0e9465323c0b6ed28096c2" +const PURR_SPOT_ID = `PURR:${PURR_TOKEN_ID}` + const SPOT_META_RESPONSE = { tokens: [ { @@ -12,21 +20,21 @@ const SPOT_META_RESPONSE = { fullName: "USDC", szDecimals: 8, weiDecimals: 8, - tokenId: "0x6d1e7cde53ba9467b783cb7c530ce054", + tokenId: USDC_TOKEN_ID, evmContract: { address: "0x1234567890123456789012345678901234567890", - evm_extra_wei_decimals: 0, + evm_extra_wei_decimals: -2, // → on-EVM decimals = 6 (real testnet shape) }, }, { name: "PURR", fullName: "PURR", - szDecimals: 8, - weiDecimals: 8, - tokenId: "0xc4bf3f870c0e9465323c0b6ed28096c2", + szDecimals: 0, + weiDecimals: 5, + tokenId: PURR_TOKEN_ID, evmContract: { address: "0x9999999999999999999999999999999999999999", - evm_extra_wei_decimals: 0, + evm_extra_wei_decimals: 13, // → on-EVM decimals = 18 }, }, ], @@ -56,14 +64,14 @@ describe("createHyperCoreBuilder.buildTransfer", () => { it("picks ACTION_TRANSFER for HyperEVM recipients (pool release)", async () => { const builder = createHyperCoreBuilder({ network: "testnet", fetch: makeFetch() }) const unsigned = await builder.buildTransfer({ - spotToken: "USDC", - amount: 100_000_000n, // 1 USDC at 8 decimals + spotId: USDC_SPOT_ID, + amount: 1_000_000n, // 1 USDC at 6 decimals (wei=8, evmExtra=-2) recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, }) expect(unsigned.action.type).toBe("sendToEvmWithData") expect(unsigned.action.hyperliquidChain).toBe("Testnet") - expect(unsigned.action.token).toBe("USDC:0x6d1e7cde53ba9467b783cb7c530ce054") + expect(unsigned.action.token).toBe(USDC_SPOT_ID) expect(unsigned.action.amount).toBe("1") expect(unsigned.action.destinationChainId).toBe(998) expect(unsigned.action.destinationRecipient).toBe( @@ -80,11 +88,14 @@ describe("createHyperCoreBuilder.buildTransfer", () => { expect(unsigned.hlBridgeToken).toBe("0x1234567890123456789012345678901234567890") }) - it("picks ACTION_INIT_TRANSFER for non-HyperEVM recipients", async () => { + it("picks ACTION_INIT_TRANSFER for non-HyperEVM recipients and uses weiDecimals", async () => { const builder = createHyperCoreBuilder({ network: "testnet", fetch: makeFetch() }) + // PURR has szDec=0, weiDec=5, evmExtra=13 → on-EVM 18 decimals. + // 12300000000000000n / 10^18 = 0.0123 — would mis-format as "12300" if + // the (wrong) szDecimals formula were used. const unsigned = await builder.buildTransfer({ - spotToken: "PURR", - amount: 12_300_000n, + spotId: PURR_SPOT_ID, + amount: 12_300_000_000_000_000n, recipient: "near:alice.near" as OmniAddress, fee: 7n, message: "ref=test", @@ -98,19 +109,18 @@ describe("createHyperCoreBuilder.buildTransfer", () => { expect(fee).toBe(7n) expect(recipient).toBe("near:alice.near") expect(message).toBe("ref=test") - expect(unsigned.action.amount).toBe("0.123") + expect(unsigned.action.amount).toBe("0.0123") }) - it("skips /info lookup when hlBridgeToken+decimals+spotId are supplied", async () => { + it("skips /info lookup when hlBridgeToken+decimals are supplied", async () => { const fetchImpl = makeFetch() const builder = createHyperCoreBuilder({ network: "testnet", fetch: fetchImpl }) await builder.buildTransfer({ - spotToken: "USDC", + spotId: USDC_SPOT_ID, amount: 1n, recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, hlBridgeToken: "0xAaaaaaaAAaAaaAaAAAaAaAAaAAaAaAAaaaAAaAAa", decimals: 6, - spotId: "USDC:0xdeadbeef", }) expect(fetchImpl).not.toHaveBeenCalled() }) @@ -119,12 +129,12 @@ describe("createHyperCoreBuilder.buildTransfer", () => { const fetchImpl = makeFetch() const builder = createHyperCoreBuilder({ network: "testnet", fetch: fetchImpl }) await builder.buildTransfer({ - spotToken: "USDC", + spotId: USDC_SPOT_ID, amount: 1n, recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, }) await builder.buildTransfer({ - spotToken: "PURR", + spotId: PURR_SPOT_ID, amount: 1n, recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, }) @@ -134,11 +144,107 @@ describe("createHyperCoreBuilder.buildTransfer", () => { it("uses mainnet defaults when network=mainnet", async () => { const builder = createHyperCoreBuilder({ network: "mainnet", fetch: makeFetch() }) const unsigned = await builder.buildTransfer({ - spotToken: "USDC", - amount: 100_000_000n, + spotId: USDC_SPOT_ID, + amount: 1_000_000n, recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, }) expect(unsigned.action.hyperliquidChain).toBe("Mainnet") expect(unsigned.action.destinationChainId).toBe(999) }) + + describe("validation", () => { + let builder: ReturnType + beforeEach(() => { + builder = createHyperCoreBuilder({ network: "testnet", fetch: makeFetch() }) + }) + + it("rejects amount <= 0", async () => { + await expect( + builder.buildTransfer({ + spotId: USDC_SPOT_ID, + amount: 0n, + recipient: "near:alice.near" as OmniAddress, + }), + ).rejects.toThrow(/amount must be > 0/) + }) + + it("rejects negative fee", async () => { + await expect( + builder.buildTransfer({ + spotId: USDC_SPOT_ID, + amount: 1_000_000n, + fee: -1n, + recipient: "near:alice.near" as OmniAddress, + }), + ).rejects.toThrow(/fee must be >= 0/) + }) + + it("rejects fee >= amount for non-HyperEVM recipients", async () => { + await expect( + builder.buildTransfer({ + spotId: USDC_SPOT_ID, + amount: 100n, + fee: 100n, + recipient: "near:alice.near" as OmniAddress, + }), + ).rejects.toThrow(/fee.*must be strictly less than amount/) + }) + + it("allows fee >= amount for HyperEVM recipients (fee is unused there)", async () => { + // Pool-release path ignores fee entirely — no rejection. + await expect( + builder.buildTransfer({ + spotId: USDC_SPOT_ID, + amount: 100n, + fee: 999n, + recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress, + }), + ).resolves.toBeDefined() + }) + + it("rejects hlBridgeToken without decimals (and vice versa)", async () => { + await expect( + builder.buildTransfer({ + spotId: USDC_SPOT_ID, + amount: 1n, + recipient: "near:alice.near" as OmniAddress, + hlBridgeToken: "0xAaaaaaaAAaAaaAaAAAaAaAAaAAaAaAAaaaAAaAAa", + }), + ).rejects.toThrow(/decimals must be supplied together with hlBridgeToken/) + await expect( + builder.buildTransfer({ + spotId: USDC_SPOT_ID, + amount: 1n, + recipient: "near:alice.near" as OmniAddress, + decimals: 6, + }), + ).rejects.toThrow(/hlBridgeToken must be supplied together with decimals/) + }) + + it("rejects malformed spot identifier (name only)", async () => { + await expect( + builder.buildTransfer({ + spotId: "USDC", + amount: 1n, + recipient: "near:alice.near" as OmniAddress, + }), + ).rejects.toThrow(/Invalid spot identifier.*names alone are ambiguous/) + }) + }) + + it("uses Date.now() for the action nonce", async () => { + const builder = createHyperCoreBuilder({ network: "testnet", fetch: makeFetch() }) + const now = 1_700_000_000_123 + vi.spyOn(Date, "now").mockReturnValue(now) + try { + const unsigned = await builder.buildTransfer({ + spotId: USDC_SPOT_ID, + amount: 1_000_000n, + recipient: "near:alice.near" as OmniAddress, + }) + expect(unsigned.action.nonce).toBe(now) + } finally { + vi.restoreAllMocks() + } + }) })