Connect an Exchange
What each venue needs before a trade account can trade, from deposit to approvals.
POST /trade-accounts stores credentials. It does not set up the venue side. Hyperliquid and Lighter both need on-chain approvals and a funded account first, and those steps happen against the exchange, not against Pear.
| Exchange | Before you can create the trade account |
|---|---|
| Hyperliquid | Deposit, approve Pear as builder, generate an API wallet, approve it. |
| Lighter | Deposit (this registers the account), create an API key, approve Pear as integrator. |
| Binance, Bybit, OKX | Create an API key on the venue. Nothing else. |
Hyperliquid
Two libraries split the work, along a clean line:
- Checking state, use the Hyperliquid helper in
@pear-protocol/exchanges-sdk. Every check below is one call, already pointed at the right network and already applying Pear's own thresholds. - Approving, use
@nktkas/hyperliquid. Approvals are transactions the user's wallet signs, so they cannot come from Pear.
import { createHyperliquidHelper } from '@pear-protocol/exchanges-sdk';
const hyperliquid = createHyperliquidHelper(); // pass true for testnetIt needs no SDK instance and no credentials. If you already hold an ExchangesSDK, exchanges.createHelper('hyperliquid') returns the same thing on the network that SDK is configured for.
1. Deposit
Hyperliquid has no account until it holds funds. Deposit USDC to the wallet you intend to trade with.
const hasAccount = await hyperliquid.checkAccountActivation(userAddress);A failed request throws rather than returning false, so an outage is never shown to a user as "you need to deposit". Let it throw and retry.
2. Approve Pear as builder
Pear attributes its fee through Hyperliquid's builder-code mechanism, and the venue will not accept the order until the user has approved that builder.
Read the address from the gateway rather than hardcoding it, so it stays right if it changes:
const { recipient } = await sdk.core.fee.recipient({ connector: 'hyperliquid' });The production builder address is currently:
0xA47D4d99191db54A4829cdf3de2417E527c3b042Then approve it:
import { HttpTransport } from '@nktkas/hyperliquid';
import { approveBuilderFee } from '@nktkas/hyperliquid/api/exchange';
await approveBuilderFee(
{ transport: new HttpTransport(), wallet: walletClient },
{ maxFeeRate: '0.10%', builder: recipient },
);Approve at least 0.06%, which is 6 basis points. maxFeeRate is a ceiling, not the fee: the fee actually charged is usually lower, and Pear never charges more than the ceiling. Pear's own UI approves 0.10% to leave headroom, so a later fee change does not force every user to re-approve.
Approve less than 6 bps and the account is stored but fails its health check with hyperliquid_builder_fee_not_approved.
To check an existing approval:
const approved = await hyperliquid.checkBuilderFeeApproval(userAddress, recipient, 60);The third argument is the required ceiling in tenths of a basis point, so 6 bps is 60. This is the same comparison the health check runs, which is why it is the check to use: an approval can name the right builder and still be too low, and only comparing the number catches that. Omit the argument and it degrades to "any positive approval".
3. Generate an API wallet
Pear trades through a Hyperliquid agent wallet, so the user's main private key is never involved. Generate a fresh keypair in the browser:
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts';
const signerKey = generatePrivateKey();
const agentWalletAddress = privateKeyToAccount(signerKey).address;The private key becomes credentials.signer_key on the trade account. The address becomes metadata.agentWalletAddress.
4. Approve the API wallet
Approve the agent on Hyperliquid with the user's main wallet:
import { HttpTransport } from '@nktkas/hyperliquid';
import { approveAgent } from '@nktkas/hyperliquid/api/exchange';
await approveAgent(
{ transport: new HttpTransport(), wallet: walletClient },
{ agentAddress: agentWalletAddress, agentName: `pear-${userAddress.slice(0, 6)}` },
);Confirm it landed:
const ok = await hyperliquid.checkApiWallet({ userAddress, agentWalletAddress });This also rejects an approval that has expired, which a plain presence check would miss.
5. Create the trade account
{
"connector": "hyperliquid",
"alias": "main",
"exchangeIdentifier": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984",
"credentials": { "signer_key": "0x…" },
"metadata": {
"agentWalletAddress": "0x…",
"isSubaccount": false,
"mainAccountAddress": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"
}
}exchangeIdentifier is the wallet that holds the funds. To trade a subaccount, set isSubaccount: true, put the subaccount address in exchangeIdentifier, and the parent in mainAccountAddress.
Listing a wallet's subaccounts is the one read the helper does not cover, so it goes to Hyperliquid directly:
import { HttpTransport, InfoClient } from '@nktkas/hyperliquid';
const info = new InfoClient({ transport: new HttpTransport() });
const subaccounts = await info.subAccounts({ user: userAddress });Creation validates all of this against Hyperliquid before it stores anything, and a failed invariant is a 400 carrying the message below:
| Rule | Message when it fails |
|---|---|
signer_key must control agentWalletAddress | Private key does not match the agent wallet address |
agentWalletAddress must differ from exchangeIdentifier | Agent wallet address must differ from the exchange identifier |
agentWalletAddress must differ from mainAccountAddress | Agent wallet address must differ from the main account address |
With isSubaccount: true, exchangeIdentifier must be a child of mainAccountAddress | Subaccount does not belong to the provided main account |
| The agent must be authorized on Hyperliquid | Agent wallet address not found for the provided user address. Ensure the agent wallet is authorized. |
The builder-fee approval is the one thing creation does not check. That surfaces later, through the health check.
Re-approving an expired agent
Agent approvals expire. When one does, generate a new keypair, approve it on Hyperliquid, then PATCH the account:
{
"connector": "hyperliquid",
"credentials": { "signer_key": "0x…" },
"metadata": {
"agentWalletAddress": "0x…",
"isSubaccount": false,
"mainAccountAddress": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984"
}
}Three fields are easy to leave out and all three are required:
connectorselects which venue's body shape the gateway reads. EveryPATCHneeds it.metadatais not partial. SendagentWalletAddress,isSubaccount, andmainAccountAddresstogether, even when only one changed.credentialsmust accompany any metadata change, or the call returns400 Private key is required to update Hyperliquid wallet metadata.
The new key must resolve to the same exchange account. One that does not returns 400 Cannot change exchange account. Delete this trade account and create a new one. See Trade Accounts.
Lighter
Lighter's steps run through Pear's gateway rather than a third-party library, because each one needs a transaction the backend signs and the user's L1 wallet authorizes. The Lighter helper in @pear-protocol/exchanges-sdk wraps each of those round trips into one call:
import { createLighterHelper } from '@pear-protocol/exchanges-sdk';
const lighter = createLighterHelper(sdk); // the core SDK, not an ExchangesSDKThe Lighter endpoints are listed under each step, for clients that are not on TypeScript.
1. Deposit first
Lighter creates the account when the first deposit lands. Until then the address has no account index, and there is nothing to make an API key for.
deposit() runs a whole deposit, from validating the amount through to Lighter crediting it. Pick a route and it handles the rest:
const result = await lighter.deposit({
route: 'funxyz',
ownerAddress,
connectedAddress,
chainId,
amount: '250',
market: 'perps',
wallet, // your wallet adapter, see below
onProgress: (p) => setDepositState(p.state),
settlement: { follow: 'outcome' },
});route | What it does |
|---|---|
funxyz | Sends USDC to the wallet's fun.xyz universal deposit address. Mints one per supported chain family, all crediting the same Lighter account. USDC only. |
cctp | Sends native USDC to a Lighter CCTP intent address bound to that exact amount. listCctpDepositNetworks() lists the source chains, which Lighter adds to without a release. |
ethereum | Calls Lighter's Ethereum gateway contract directly, signed by the owner's wallet. |
createDepositAddress() is the other half: it hands back an address that any wallet or exchange can fund, for callers who are not moving the money themselves. Use it for an ETH deposit too, which the funxyz route does not cover.
The SDK stays free of any wallet library, so you pass a small wallet adapter with three functions: readUsdcBalance, retrieveWallet, and confirmTransaction. Wire those to Wagmi, viem, or whatever you use.
Following a deposit to its outcome
onProgress reports the deposit's life after the transfer is submitted:
state | Meaning |
|---|---|
confirming | Waiting on the source chain. |
bridging | In flight to Lighter. |
credited | Lighter booked it. credit carries the record the route could actually read. |
registered | Terminal state of a first deposit. Reports the mainAccountIndex and subAccountIndexes the deposit created. |
unsettled | The watch window closed first, so nobody waits for an outcome that never arrives. |
untracked | You asked nobody to follow this transfer. |
failed, action-required | CCTP only. Lighter's deposit history is the one source that names them; the fun.xyz bridge documents no failure state. |
settlement decides what gets watched, and the first deposit is the special case: it cannot authenticate against an account it is about to create. On CCTP, say so explicitly with { follow: 'account-registration' }, and use { follow: 'deposit-history', selectedAccountIndex, retrieveCredentials } once the account exists. On fun.xyz, pass { follow: 'outcome' } and the bridge decides which record can answer. Omit settlement to submit a deposit nobody waits on.
Watch a first deposit with onProgress rather than blocking on it. The SDK polls for one hour before it gives up and reports unsettled; the CCTP route can shorten that with settlementTimeoutMs. Pear's own UI re-checks every 15 seconds and shows the wait. CCTP normally settles in minutes.
listUdaDeposits(walletAddress) reads what the fun.xyz bridge has seen for a wallet, with each deposit's bridge status, which is what you show someone who is still waiting. It is GET /misc/lighter/uda/{walletAddress} underneath, and POST /misc/lighter/uda mints the addresses. The wallet must be the caller's own linked wallet identity; any other address returns 403 This wallet does not belong to the authenticated user.
Fun.xyz deposit minimums
A deposit below the minimum for its source is not credited. Send more than the minimum, because bridging and swapping lose some of the amount to slippage.
| Source | New account | Existing account |
|---|---|---|
| EVM chain (not Ethereum mainnet) | $5 | $3 |
| Ethereum mainnet | $5 | $5 |
| Solana | $5 | $3 |
| Tron | $5 | $5 |
| Bitcoin | $10 | $10 |
The routable EVM chains are Ethereum, Optimism, Polygon, Monad, HyperEVM, Base, Arbitrum One, and Ink. Native USDC only, on every source.
The account is ready when Lighter returns an account index for the L1 address. checkOnboarding reports that as an object with a step, and account-ready already carries the indexes:
const onboarding = await lighter.checkOnboarding(l1Address);
if (onboarding.step === 'deposit-required') {
// No account yet. Deposit, then poll again.
} else {
const { mainAccountIndex, subAccountIndexes } = onboarding;
}Because the indexes ride on the result, the follow-up lookupAccount(l1Address) is only for callers that want them on their own.
2. Create an API key
One call. The helper picks a free key index, mints the key through the gateway, has the user's L1 wallet sign, and registers it on Lighter:
const { apiKeyIndex, apiPrivateKey, txHash } = await lighter.connectWallet(walletClient, {
accountIndex: mainAccountIndex,
});Underneath it is POST /misc/lighter/create-api-key → L1 signature → POST /misc/lighter/change-pub-key. Drive those yourself only if you are not on TypeScript.
api_key_index must be between 4 and 254; lower indexes are reserved. The helper chooses a free one for you, so hand-picking an index is only for a client that manages several keys itself.
3. Approve Pear as integrator
Read the integrator the same way you read the Hyperliquid builder, from the gateway rather than a constant:
const { recipient } = await sdk.core.fee.recipient({ connector: 'lighter' });For Lighter this returns the integrator account index rather than an address. Then approve:
await lighter.approveIntegrator(walletClient, {
accountIndex: mainAccountIndex,
apiKeyIndex,
apiPrivateKey,
});That is POST /misc/lighter/prepare-approve-integrator → L1 signature → POST /misc/lighter/submit-approve-integrator. The gateway signs the approval with the fee parameters it is configured with, so you never set the ceiling yourself. When no integrator is configured for the caller's role, the prepare call returns 404 and there is nothing to approve.
Lighter records the approval with a maker and taker fee ceiling in parts per million, plus an expiry. Pear requires at least 600 on each, which is 6 basis points — the same ceiling Hyperliquid's builder approval needs. The gateway sets the expiry five years out. Check an existing one:
const approved = await lighter.checkIntegratorApproval(mainAccountIndex, Number(recipient), {
minPerpsMakerFee: 600,
minPerpsTakerFee: 600,
});Like the Hyperliquid check, this compares the numbers rather than just looking for the integrator, and it rejects an approval that has expired. Either failure surfaces as lighter_integrator_not_approved.
4. Create the trade account
{
"connector": "lighter",
"alias": "main",
"exchangeIdentifier": "123456",
"credentials": { "api_private_key": "0x…", "api_key_index": 4 },
"metadata": { "l1_address": "0x1f9840a85d5af5bf1d1762f925bdaddc4201f984" }
}exchangeIdentifier is the Lighter account index, not an address. The L1 wallet goes in metadata.l1_address.
Private Lighter reads need a short-lived auth token, which POST /misc/lighter/create-auth-token mints. retrieveLighterAuthToken in the same package is the cache the account trackers use, and passing it to @pear-protocol/market-sdk as an authToken provider also raises Lighter's rate limit on public market data. See Market Data.
The rest of the Lighter helper
Onboarding is only part of what it covers. The same helper runs the account for its whole life:
| Method | Does | HTTP |
|---|---|---|
changeAccountTier({ accountIndex, apiKeyIndex, apiPrivateKey, tier }) | Moves the account to standard, plus, or premium. Lighter allows this once every 24 hours, and only while the account holds no open orders or position. It refuses the change itself and says which rule stopped it, so that wording arrives as the thrown error. | POST /misc/lighter/change-account-tier |
fastWithdraw(walletClient, params) | A wallet-authorized USDC withdrawal through Lighter's transfer pool. Returns { txHash, fee }, so you can reconcile what actually left. It enforces Lighter's minimum before spending a signature. | POST /misc/lighter/prepare-fast-withdraw → L1 signature → POST /misc/lighter/submit-fast-withdraw |
withdraw(walletClient, params) | An on-chain withdrawal through the gateway contract. | — (signed on chain) |
getDepositHistory(params), getWithdrawHistory(params) | One page each, newest first. | — (read from Lighter) |
applyReferralCode(params) | Applies a referrer's code. Lighter accepts this at most once per owner. | — (read from Lighter) |
getUserReferrals(params) | The owner's referral record, including the code that referred them. | — (read from Lighter) |
The tier endpoint separates the two kinds of refusal, so you know whether to retry: a change already running or one inside the 24-hour window is a 409, and a rule the caller can clear — an open order, an open position, or the tier the account already has — is a 400.
Every method that signs is mainnet only. Construct the helper with createLighterHelper(sdk, true) for testnet reads, and the signing paths throw rather than sending credentials somewhere they will not work.
Binance, Bybit, and OKX
These need only an API key from the venue. There is no deposit check and no on-chain approval.
Create the key against the market Pear trades:
| Exchange | Market | Credentials |
|---|---|---|
| Binance | USDⓈ-M Futures | read and write key pairs, each { api_key, api_secret } |
| Bybit | Linear Perpetuals | api_key, api_secret |
| OKX | Linear SWAP | api_key, api_secret, api_pass |
Binance takes two key pairs so a deployment can separate the reading credential from the trading one. Supplying the same pair for both is valid. Two rules apply to them:
- Both pairs must belong to the same Binance account. Different accounts fail creation with
Read and write API keys must belong to the same Binance account. - Both pairs must reach the Spot REST API. Pear trades USDⓈ-M Futures, but creation identifies the account through the Spot
accountendpoint. A key restricted to futures alone cannot be validated, so creation returns a400. Enable Spot reads on both keys as well as futures trading on the write key.
{
"connector": "okx",
"alias": "main",
"credentials": { "api_key": "…", "api_secret": "…", "api_pass": "…" }
}Check the account is ready
An account can be stored and still not be able to trade, because an approval is missing or a key was revoked at the venue. status() in @pear-protocol/exchanges-sdk names the problem.
Pass the approval targets on every call. status() takes them per call rather than at construction, because they come from the gateway and can change. Omit them and it skips the approval checks: it still reports a broken key, but it can never report hyperliquid_builder_fee_not_approved or lighter_integrator_not_approved.
import type { ConnectOptions } from '@pear-protocol/exchanges-sdk';
async function connectOptions(connector: HttpTypes.Connector): Promise<ConnectOptions> {
if (connector === 'hyperliquid') {
const { recipient } = await sdk.core.fee.recipient({ connector });
return {
...(recipient ? { builderAddress: recipient as `0x${string}` } : {}),
builderFeeTenthsBps: 60,
};
}
if (connector === 'lighter') {
const { recipient } = await sdk.core.fee.recipient({ connector });
return {
...(recipient ? { integratorAccountIndex: Number(recipient) } : {}),
integratorMinPerpsMakerFee: 600,
integratorMinPerpsTakerFee: 600,
};
}
// Binance, Bybit, and OKX have no approval to check.
return {};
}
const connection = await exchanges.connect(tradeAccountId);
const status = await exchanges.status(connection, await connectOptions(connection.connector));
if (status.status === 'warning') {
for (const reason of status.reasons) console.warn(reason);
}ConnectOptions has five fields, all optional:
| Field | For | Meaning |
|---|---|---|
builderAddress | Hyperliquid | The builder whose approval to check. |
builderFeeTenthsBps | Hyperliquid | The required ceiling, in tenths of a basis point. Pear uses 60. |
integratorAccountIndex | Lighter | The integrator whose approval to check. |
integratorMinPerpsMakerFee | Lighter | The required maker ceiling, in parts per million. Pear uses 600. |
integratorMinPerpsTakerFee | Lighter | The required taker ceiling, in parts per million. Pear uses 600. |
Send each venue's fields together. builderAddress without builderFeeTenthsBps throws, and so does integratorAccountIndex without both Lighter fees; a configured target with no threshold to compare against would pass an under-approved account. Binance, Bybit, and OKX take no options.
sdk.core.fee.recipient can return {}. recipient is absent when no recipient is configured for the caller's role, which means orders name no builder or integrator and there is nothing to approve. Leave the target out of ConnectOptions in that case rather than passing undefined through Number().
| Reason | Fix |
|---|---|
api_key_invalid | The venue rejected the credentials. Re-create the key. |
hyperliquid_api_wallet_invalid | The agent wallet is missing or expired. Generate and approve a new one. |
hyperliquid_builder_fee_not_approved | Approve the builder, at 0.06% or more. |
hyperliquid_account_mode_unsupported | The account is in a mode Pear does not model. |
hyperliquid_account_mode_check_failed | The account mode could not be read. Retry. |
lighter_api_key_invalid | The API key cannot mint an auth token. Re-create it. |
lighter_integrator_not_approved | Approve the integrator, at 600 ppm or more on both maker and taker, and check the approval has not expired. |
Once the account is ready, scope trading calls to it with x-trade-account-id. See Trade Accounts.