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/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/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/core/src/bridge.ts b/packages/core/src/bridge.ts
index a90f190f..600312ac 100644
--- a/packages/core/src/bridge.ts
+++ b/packages/core/src/bridge.ts
@@ -140,11 +140,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:
@@ -212,18 +208,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 e2d0a5ea..3d110ac0 100644
--- a/packages/core/tests/bridge.test.ts
+++ b/packages/core/tests/bridge.test.ts
@@ -81,6 +81,7 @@ describe("Bridge.validateTransfer", () => {
if (chain === "Eth") return "eth:0x1234567890123456789012345678901234567890"
if (chain === "Sol") return "sol:So11111111111111111111111111111111111111112"
if (chain === "Zcash") return "zcash:u1testrecipient"
+ if (chain === "HlEvm") return "hlevm:0x1234567890123456789012345678901234567890"
}
return null
}
@@ -421,8 +422,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,
@@ -432,17 +433,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,
@@ -450,16 +448,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/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/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/README.md b/packages/hypercore/README.md
new file mode 100644
index 00000000..e1fb304b
--- /dev/null
+++ b/packages/hypercore/README.md
@@ -0,0 +1,120 @@
+# @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
+// 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({
+ 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: "",
+})
+
+// 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 `hlBridgeToken` and `decimals`. Pre-supply both to skip the network round-trip:
+
+```typescript
+const unsigned = await builder.buildTransfer({
+ spotId: "USDC:0x6d1e7cde53ba9467b783cb7c530ce054",
+ amount: 1_000_000n,
+ recipient: "near:alice.near",
+ hlBridgeToken: "0x1234567890123456789012345678901234567890",
+ decimals: 6,
+})
+```
+
+## 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, 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.
+
+### 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 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
+
+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
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..3d2e2a45
--- /dev/null
+++ b/packages/hypercore/src/builder.ts
@@ -0,0 +1,184 @@
+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 {
+ parseSpotId,
+ 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 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).
+ * Must satisfy `0n <= fee < amount`.
+ */
+ 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 HlBridgeToken ERC-20 `.decimals()` (= `weiDecimals + evm_extra_wei_decimals`
+ * from `/info spotMeta`). Required together with `hlBridgeToken` to skip the lookup.
+ */
+ decimals?: number
+}
+
+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 {
+ this.validateParams(params)
+
+ 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 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) {
+ // 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,
+ decimals: params.decimals,
+ }
+ }
+ const fetchOpts: SpotMetaFetchOptions = this.fetchImpl ? { fetch: this.fetchImpl } : {}
+ return resolveSpotTokenCached(this.apiUrl, params.spotId, fetchOpts)
+ }
+
+ 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..0f82ef05
--- /dev/null
+++ b/packages/hypercore/src/index.ts
@@ -0,0 +1,42 @@
+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,
+ parseSpotId,
+ 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..8bd4422c
--- /dev/null
+++ b/packages/hypercore/src/spot-meta.ts
@@ -0,0 +1,157 @@
+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
+ /**
+ * 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
+}
+
+/**
+ * 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
+}
+
+/**
+ * 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 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 "${spotId}" not found in /info spotMeta`)
+ }
+ if (!match.evmContract) {
+ throw new Error(
+ `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: 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.
+ */
+const CACHE: Map> = new Map()
+
+/**
+ * Like `resolveSpotToken`, but memoizes the `/info` response per `apiUrl` for
+ * 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,
+ spotId: 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
+ return toInfo(findToken(tokens, spotId))
+}
+
+/** 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..e70772ef
--- /dev/null
+++ b/packages/hypercore/tests/builder.test.ts
@@ -0,0 +1,250 @@
+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"
+
+// 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: [
+ {
+ name: "USDC",
+ fullName: "USDC",
+ szDecimals: 8,
+ weiDecimals: 8,
+ tokenId: USDC_TOKEN_ID,
+ evmContract: {
+ address: "0x1234567890123456789012345678901234567890",
+ evm_extra_wei_decimals: -2, // → on-EVM decimals = 6 (real testnet shape)
+ },
+ },
+ {
+ name: "PURR",
+ fullName: "PURR",
+ szDecimals: 0,
+ weiDecimals: 5,
+ tokenId: PURR_TOKEN_ID,
+ evmContract: {
+ address: "0x9999999999999999999999999999999999999999",
+ evm_extra_wei_decimals: 13, // → on-EVM decimals = 18
+ },
+ },
+ ],
+}
+
+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({
+ 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_SPOT_ID)
+ 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 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({
+ spotId: PURR_SPOT_ID,
+ amount: 12_300_000_000_000_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.0123")
+ })
+
+ it("skips /info lookup when hlBridgeToken+decimals are supplied", async () => {
+ const fetchImpl = makeFetch()
+ const builder = createHyperCoreBuilder({ network: "testnet", fetch: fetchImpl })
+ await builder.buildTransfer({
+ spotId: USDC_SPOT_ID,
+ amount: 1n,
+ recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress,
+ hlBridgeToken: "0xAaaaaaaAAaAaaAaAAAaAaAAaAAaAaAAaaaAAaAAa",
+ decimals: 6,
+ })
+ expect(fetchImpl).not.toHaveBeenCalled()
+ })
+
+ it("caches /info between calls", async () => {
+ const fetchImpl = makeFetch()
+ const builder = createHyperCoreBuilder({ network: "testnet", fetch: fetchImpl })
+ await builder.buildTransfer({
+ spotId: USDC_SPOT_ID,
+ amount: 1n,
+ recipient: "hlevm:0x000000000000000000000000000000000000DeaD" as OmniAddress,
+ })
+ await builder.buildTransfer({
+ spotId: PURR_SPOT_ID,
+ 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({
+ 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()
+ }
+ })
+})
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" }
]
}