import {
type ActionSignerContribution,
type DepositFlow,
HYPERLIQUID_USDC,
type LiquidationEstimateParams,
PerpsError,
PerpsErrorMessage,
type PerpsProviderPlugin,
type PerpsSDKClient,
type ProviderAccountExistsParams,
type ProviderGetAccountParams,
type ProviderGetActivityParams,
type ProviderGetFillsParams,
type ProviderGetMarketSettingsParams,
type ProviderGetOrderParams,
type ProviderGetOrdersParams,
type ProviderGetPortfolioHistoryParams,
type ProviderGetPositionsParams,
type ProviderGetQuoteParams,
resolveQuote,
type SDKRequestOptions,
type SignActionsContext,
type StorageAdapter,
} from '@lifi/perps-sdk'
import {
type AccountConfig,
type AccountConfigSetting,
type AccountResponse,
type AccountSummary,
type ActionStep,
ActionType,
type ActivitiesResponse,
type FillsResponse,
type Market,
type MarketSettings,
type Order,
type OrdersResponse,
PerpsErrorCode,
type PerpsMarket,
PerpsSigner,
type PortfolioHistoryResponse,
type Position,
type PositionsResponse,
type ProviderAction,
type Quote,
type SignedActionStep,
type SigningMethod,
} from '@lifi/perps-types'
import { type Address, type Hex, isAddress } from 'viem'
import { projectHyperliquidConfigSettings } from './accountConfig.js'
import { getAccountSummary } from './accountSummary.js'
import {
DEFAULT_HYPERLIQUID_API_URL,
HYPERLIQUID_FEE_TIER_FALLBACK,
PROVIDER_KEY,
SPOT_MARKET_ID,
} from './constants.js'
import { HyperliquidContextRef } from './context.js'
import { getAccount } from './services/getAccount.js'
import { getAccountExists } from './services/getAccountExists.js'
import { getActivity } from './services/getActivity.js'
import { getFills } from './services/getFills.js'
import { getMarketSettings } from './services/getMarketSettings.js'
import { getOrder } from './services/getOrder.js'
import { getOrders } from './services/getOrders.js'
import { getPortfolioHistory } from './services/getPortfolioHistory.js'
import { getPositions } from './services/getPositions.js'
import {
type HyperliquidAgent,
HyperliquidAgentStore,
} from './signers/HyperliquidAgentStore.js'
import { hyperliquidSignActions } from './signers/signActions.js'
import type { HlExtraAgents } from './types/index.js'
import { hlInfoOptions, infoRequest } from './utils/infoClient.js'
import { calculateLiquidationPrice } from './utils/liquidation.js'
import { formatOrderPrice, formatOrderSize } from './utils/orderFormatting.js'
import { positionMarginConstraints } from './utils/transferMargin.js'
/**
* Options for {@link hyperliquidProvider}.
*
* @public
*/
export interface HyperliquidProviderOptions {
/**
* Base URL for the Hyperliquid REST surface. Defaults to
* `https://api.hyperliquid.xyz`. Override to point at a custom or
* third-party endpoint — e.g. a reverse proxy, a self-hosted mirror, or a
* rate-limit-managed gateway in front of Hyperliquid.
*/
apiUrl?: string
/**
* Storage adapter for the user's per-address Hyperliquid agent keypair.
* Defaults to browser `localStorage`. Pass a custom adapter for SSR /
* non-browser hosts or encrypted storage.
*/
storage?: StorageAdapter
}
/**
* Hyperliquid provider plugin extended with agent-keypair lifecycle methods.
* Hyperliquid signs trading actions with a per-user agent wallet the user
* approves via `APPROVE_AGENT`; the plugin owns that keypair's generation,
* persistence, and revocation. The base {@link PerpsProviderPlugin} contract
* stays provider-agnostic — this extension is opt-in for callers that
* explicitly type against it (e.g. to surface a "revoke agent" affordance).
* @public
*/
export interface HyperliquidPerpsProvider extends PerpsProviderPlugin {
/**
* Resolve the stored agent wallet address for a user.
* @throws {PerpsError} With `AgentNotFound` when no agent has been created.
*/
getAgentAddress(address: Address): Promise
/** Return whether a persisted agent keypair exists for the user address. */
hasAgent(address: Address): Promise
/**
* Remove the user's persisted agent keypair and local authorization.
* This does not submit an on-chain `APPROVE_AGENT` revocation.
*/
removeAgent(address: Address): Promise
/**
* Validate, persist, and return an existing agent private key for a user.
* The returned private key is stored by the configured {@link StorageAdapter}.
*/
importAgent(address: Address, privateKey: Hex): Promise
}
/**
* Factory for the Hyperliquid {@link PerpsProviderPlugin}.
*
* Account-specific state is read direct from `${apiUrl}/info`; enriched asset
* metadata and public/shared data come from the LI.FI backend (Valkey-cached).
* Pass to `createPerpsClient({ providers: [hyperliquidProvider()] })` and look
* up via `client.getProvider('hyperliquid')`.
*
* Write actions are EIP-712 typed data signed by the user's Hyperliquid agent
* keypair; the plugin owns that keypair (generation, storage, revocation) and
* dispatches the agent-signed arm via `signActions`.
*
* @example
* ```ts
* const client = createPerpsClient({
* apiKey: 'your-api-key',
* providers: [hyperliquidProvider()],
* })
* ```
* @public
*/
export function hyperliquidProvider(
options: HyperliquidProviderOptions = {}
): HyperliquidPerpsProvider {
const apiUrl = options.apiUrl ?? DEFAULT_HYPERLIQUID_API_URL
const agentStore = new HyperliquidAgentStore(options.storage)
const contextRef = new HyperliquidContextRef(apiUrl)
const toLowerAddress = (value: unknown): string | null =>
typeof value === 'string' && isAddress(value) ? value.toLowerCase() : null
const toValidUntilMs = (value: unknown): number | null => {
if (typeof value === 'number' && Number.isFinite(value)) {
return value
}
if (typeof value === 'string') {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
return null
}
const isKnownExpiredRemoteAgent = async (
address: Address,
localAgentAddress: Address
): Promise => {
let context: ReturnType | null = null
try {
context = contextRef.require()
} catch {
// Unbound provider (e.g. direct unit tests) cannot query /info.
return false
}
const agents = await infoRequest(
apiUrl,
{ type: 'extraAgents', user: address },
hlInfoOptions(context.client)
)
const local = localAgentAddress.toLowerCase()
const match = agents.find(
(agent) => toLowerAddress(agent.address) === local
) as Record | undefined
if (!match) {
return false
}
const validUntilMs = toValidUntilMs(match.validUntil)
return validUntilMs !== null && validUntilMs <= Date.now()
}
const resolveApproveAgentAddress = async (
address: Address
): Promise => {
try {
const localAgent = await agentStore.get(address)
try {
if (await isKnownExpiredRemoteAgent(address, localAgent.address)) {
await agentStore.remove(address)
return (await agentStore.getOrCreate(address)).address
}
} catch {
// If the upstream extra-agent probe fails, keep the existing local key.
}
return localAgent.address
} catch (error) {
if (
error instanceof PerpsError &&
error.message === PerpsErrorMessage.AgentNotFound
) {
return (await agentStore.getOrCreate(address)).address
}
throw error
}
}
return {
type: PROVIDER_KEY,
internalSetupActions: [ActionType.SET_REFERRAL],
// REVOKE_AGENT stages a step only when every named agent slot is taken,
// so `checkSetup` omits it from `ProviderSetup.checklist` instead of
// rendering it as a satisfied step.
conditionalSetupActions: [ActionType.REVOKE_AGENT],
bind: (client: PerpsSDKClient): void => contextRef.bind(client),
getAgentAddress: async (address: Address): Promise =>
(await agentStore.get(address)).address,
hasAgent: (address: Address): Promise => agentStore.has(address),
removeAgent: (address: Address): Promise =>
agentStore.remove(address),
importAgent: (
address: Address,
privateKey: Hex
): Promise => agentStore.import(address, privateKey),
resolveActionRequest: async (
action: ActionType,
address: Address,
signers: PerpsSigner[]
): Promise => {
// APPROVE_AGENT is user-signed: the user authorises the agent, so the
// agent address rides as a param (not signerAddress). The agent is
// provisioned on first use so its address is known before the backend
// builds the typed data.
if (action === ActionType.APPROVE_AGENT) {
const agentAddress = await resolveApproveAgentAddress(address)
return { params: { agentAddress } }
}
// SDK-signed actions (trades, account-mode) carry the agent as the
// on-wire signerAddress. User-signed actions (builder-fee, withdrawal)
// contribute nothing — core submits under the user's own address.
if (signers.includes(PerpsSigner.SDK)) {
const agent = await agentStore.get(address)
return { signerAddress: agent.address }
}
return {}
},
signActions: (
method: SigningMethod,
steps: ActionStep[],
address: Address,
ctx?: SignActionsContext
): Promise =>
hyperliquidSignActions(agentStore, method, steps, address, ctx),
getAccount: (
params: ProviderGetAccountParams,
opts?: SDKRequestOptions
): Promise =>
getAccount(contextRef.require(), { address: params.address }, opts),
accountExists: (
params: ProviderAccountExistsParams,
opts?: SDKRequestOptions
): Promise =>
getAccountExists(contextRef.require(), { address: params.address }, opts),
// Hyperliquid credits the venue account from a deposit of its venue-chain
// USDC whether or not the account exists yet, so the flow does not depend on
// account state.
getDepositFlow: async (): Promise => ({
kind: 'lifiSwap',
destination: HYPERLIQUID_USDC,
}),
getPositions: (
params: ProviderGetPositionsParams,
opts?: SDKRequestOptions
): Promise =>
getPositions(
contextRef.require(),
{
address: params.address,
marketId: params.marketId,
limit: params.limit,
},
opts
),
getMarketSettings: (
params: ProviderGetMarketSettingsParams,
opts?: SDKRequestOptions
): Promise =>
getMarketSettings(
contextRef.require(),
{ address: params.address, market: params.market },
opts
),
getOrders: (
params: ProviderGetOrdersParams,
opts?: SDKRequestOptions
): Promise =>
getOrders(
contextRef.require(),
{
address: params.address,
statuses: params.statuses,
marketId: params.marketId,
limit: params.limit,
},
opts
),
getOrder: (
params: ProviderGetOrderParams,
opts?: SDKRequestOptions
): Promise =>
getOrder(
contextRef.require(),
{ address: params.address, id: params.id },
opts
),
getFills: (
params: ProviderGetFillsParams,
opts?: SDKRequestOptions
): Promise =>
getFills(
contextRef.require(),
{
address: params.address,
limit: params.limit,
cursor: params.cursor,
startTime: params.startTime,
endTime: params.endTime,
},
opts
),
getActivity: (
params: ProviderGetActivityParams,
opts?: SDKRequestOptions
): Promise =>
getActivity(
contextRef.require(),
{
address: params.address,
limit: params.limit,
cursor: params.cursor,
startTime: params.startTime,
endTime: params.endTime,
type: params.type,
},
opts
),
getPortfolioHistory: (
params: ProviderGetPortfolioHistoryParams,
opts?: SDKRequestOptions
): Promise =>
getPortfolioHistory(
contextRef.require(),
{ address: params.address, range: params.range },
opts
),
getQuote: (
params: ProviderGetQuoteParams,
opts?: SDKRequestOptions
): Promise =>
resolveQuote(
contextRef.require().client,
PROVIDER_KEY,
params,
HYPERLIQUID_FEE_TIER_FALLBACK,
opts
),
getAccountSummary: (
account: AccountResponse,
positions: Position[]
): AccountSummary => getAccountSummary(account, positions),
formatOrderPrice: (market: Market, price: number): string =>
formatOrderPrice(
price,
market.szDecimals,
market.categoryId === SPOT_MARKET_ID ? 'spot' : undefined
),
formatOrderSize: (market: Market, size: number): string =>
formatOrderSize(size, market.szDecimals),
estimateLiquidationPrice: (
market: PerpsMarket,
params: LiquidationEstimateParams
): number | undefined =>
calculateLiquidationPrice(
params.entryPrice,
params.leverage,
params.isLong,
market.maxLeverage
),
positionMarginConstraints,
projectConfig: (
config: AccountConfig,
setup: ProviderAction[],
options: ProviderAction[]
): AccountConfigSetting[] => {
if (config.provider !== PROVIDER_KEY) {
throw new PerpsError(
PerpsErrorCode.SDKError,
`hyperliquidProvider.projectConfig received config for provider ` +
`'${config.provider}'.`
)
}
return projectHyperliquidConfigSettings(config, setup, options)
},
}
}