papertrade-sdk docs

Papertrade protocol reference (unofficial)

This is an independent, community-written reference for building on Papertrade. It is not affiliated with or endorsed by the Papertrade team. The official docs live at docs.papertrade.xyz.

Every number and formula on this page was checked against live mainnet data. The checks run in this repository's test suite (npm test for recorded fixtures, npm run test:live against production). Where something has not been verified, the page says so.

What Papertrade is#

Papertrade is a fully on-chain synthetic perpetuals exchange on Hyperliquid's HyperEVM (chain 999). It offers BTC and ETH, up to 1000x leverage, and one-click market opens and closes. There is no order book.

Deployment#

Item Value
Chain HyperEVM mainnet, chain id 999
RPC https://rpc.hyperliquid.xyz/evm (fallback https://rpc.hypurrscan.io)
Explorer https://hyperevmscan.io
Papertrade API https://exchange.papertrade.xyz
Exchange 0x6cd5661646289fb6e65ea5c032310fded797d0a2
BatchExecutor 0x233ff29b239abc1d7748605da9b205c3e008626e
PAPER token 0xe40f17915daa230324030003a197cdaef2261c0e
PaperStaking 0xaad6c7b0cc3014fc80ffedae0ed7ce5967b0f016
DepositProxyFactory 0x1629b7d46bef6e13ff754174fc9f32dea309b38f
CoreDepositWallet 0x6b9e773128f453f5c2c60935ee2de2cbc5390a24
USDC (HyperEVM, 6 dp) 0xb88339cb7199b77e23db6e890353e22632ba630f
Governance multisig 0x696292762fd5ac3677a45edbe54eaece4194d1f6
Timelock 0xb915fa41e824d6aa31bb833ee81220c0f2c8ea0f

Instruments:

id Symbol Hyperliquid asset index Price scale
0 BTC 0 10 (raw 829915 = $82,991.5)
1 ETH 1 100 (raw 249565 = $2,495.65)

Limits come from /state/trading at runtime: minimum margin $10, minimum notional $10,000, maximum notional per position $10M, and maximum leverage 1000x.

Units#

Read API#

Base URL https://exchange.papertrade.xyz. Every response is JSON.

Path What it returns
GET /state/trading Trading state: pause flag, minimums, accRewardPerShareRaw, the instrument tuples and intake. Decode with decodeTradingState.
GET /query/protocol/summary TVL, LP, reserve, queue, volume, open count, fees, and the PAPER supply, staked amount, tracked LP and tail progress.
GET /query/protocol/history?interval=1d|1h Columnar TVL, volume, PnL, users, liquidations, trades and PAPER series.
GET /query/protocol/market-stats 24h volume per market.
GET /query/protocol/queue-summary Payout queue total, count, largest and front.
GET /query/protocol/trades/recent The latest opens and closes across the protocol.
GET /query/protocol/paper/activity Recent PAPER mints, stakes and claims.
GET /query/markets/{id}/price-history?fromMs&intervalMs&toMs Exactly one price tile (see below).
GET /query/markets/{id}/liquidation-map?range=5 Long and short notional by bust-price bucket.
GET /state/protocol/position-topology Aggregate open-position topology.
GET /state/house/current, GET /state/house/delta?coin=BTC House (LP) exposure. current is columnar: head is the latest [timeMs, lp, pnl, q, bl, bs, el, es] (USD: LP equity, trader PnL, queue, then BTC long, BTC short, ETH long and ETH short open notional), and heads maps an interval in ms (1000, 60000, 3600000, 86400000) to {baseTimeMs, timeOffsetsMs[], columns}.
GET /state/leaderboard/accounts?page&pageSize&sortDir&sortKey&window Leaderboard. The response key is accounts.
GET /state/leaderboard/accounts/{wallet}?window=24h One wallet's placement.
GET /state/leaderboard/positions?window=24h|all Top settled positions (rows).
GET /query/accounts/{wallet}/trades?cursor&filter=all|losses Closed trades, 75 per page, with adjustedPnlRaw, userPaidFeeRaw, paperMintedRaw and paperMintBasisRaw. kind is PositionClosed or Liquidated.
GET /query/accounts/{wallet}/cashflows, /staking, /queue Paged account histories.
GET /query/accounts/{wallet}/portfolio-history?range=1h|1d|1w|all Equity and PnL series.
GET /query/accounts/{wallet}/funding Funding items plus coreAccount (whether a HyperCore account exists).
GET /queue-rank/accounts/{wallet} Queue position.
GET /relayer/health, GET /query/ready Health.
POST /intents Submit a signed intent (see below).
POST /intents/{intentId}/cancel Cancel a queued intent.
POST /funding/deposits/check Ask the relayer to sweep a deposit address.

Price tiles#

Price history is served as fixed tiles at three resolutions, and a request must cover exactly one tile:

Interval Tile length Retained for
125 ms 1 minute 5 minutes
1 s 15 minutes 3 hours
1 min 6 hours forever

planPriceTiles() picks the finest tier whose retention still covers the window and keeps it under 5000 points. It returns the aligned tile requests. mergePriceTiles() joins the responses, and toOhlc() buckets them into candles.

Live wallet stream#

GET /state/user/live?wallet=0x... with Accept: text/event-stream streams a wallet's state.

Position tuple: [positionId, instrumentId, isLong, useDebt, entryPriceRaw, marginRaw, leverage, bustPriceRaw, openedAtMs, intentId].

streamWallet() implements all of this, including reconnect with backoff. fetchWalletState() resolves the first snapshot.

Writing: signed intents#

Every write is an EIP-712 message signed off-chain and posted to POST /intents as { action, signed, ...extra }.

action Primary type Domain Signed by
registerSessionKey RegisterSessionKey exchange wallet
openPosition OpenPosition exchange session key
closePositions Close exchange session key
withdrawToCore WithdrawToCore exchange wallet
paperStake / paperUnstake / paperClaim Stake / Unstake / Claim PaperStaking wallet
paperDistribute Distribute PaperStaking relayer keeper only: the official client never submits it, and the relayer rejects user-signed Distribute intents with HTTP 400
cancel (POST /intents/{id}/cancel) CancelIntent cancellation the intent's signer

Session keys#

Verified on mainnet:

Opening#

OpenPosition.size is the margin in wad (not notional). leverage is an integer.

PapertradeTrader.validateOpen() checks the same limits the relayer enforces, so the user gets a plain-language error before signing. It checks:

Closing#

Close.positionIds takes up to 12 ids per intent. The SDK dedupes ids and splits larger closes into batches.

EIP-712 schemas#

Domains (all version fields are strings):

Domain name version verifyingContract
exchange String 1 Exchange
PaperStaking PaperStaking 1 PaperStaking
cancellation PaperTrade Intent Cancel 2 Exchange

The exchange domain name is literally String. A mainnet Close signature recovers to the trader's session key only under that name, and the test suite asserts that other names fail. The PaperStaking domain hashes to 0x71706d56c8b436981e93987cd97df512528491b243c6d8d1614cad52659e7eb0, the value PaperStaking.DOMAIN_SEPARATOR() returns on chain.

RegisterSessionKey(address user,address key,uint64 expiry,address feeAddress,uint256 nonce,uint64 deadline)
OpenPosition(address user,uint32 instrumentId,bool isLong,uint128 size,uint32 leverage,bool useDebt,uint256 nonce,uint64 deadline)
Close(address user,uint256[] positionIds,uint256 nonce,uint64 deadline)
WithdrawToCore(address user,uint256 amount,uint256 nonce,uint64 deadline)
Stake(address user,uint256 amount,uint256 nonce,uint64 deadline)
Unstake(address user,uint256 amount,uint256 nonce,uint64 deadline)
Claim(address user,uint256 nonce,uint64 deadline)
Distribute(address user,uint256 nonce,uint64 deadline)
CancelIntent(address user,bytes32 intentId,uint256 deadline)

Intent ids and nonces#

digest   = EIP-712 hash of the message under its domain
intentId = keccak256( keccak256("papertrade:intent-id:v2") ++ digest ++ signerAddress )

This reproduces the intent ids the relayer records for mainnet intents.

Nonces only need to be unique per (user, lane):

Settlement math#

Here e is the entry price (raw), x the exit price (raw), L the leverage, and notional = margin * L. Instrument parameters come from /state/trading:

Tuple index Field
12 rateMultiplier
13 positionMultiplier
14 maxLeverage
15 maxPosition
16 bustBuffer
17 baseRate
18 referenceNotional
19-22 long/short OI and caps, as base-asset quantities (wad BTC or ETH, not USD). OI times the mark equals the house bl/bs/el/es notional; openInterestUsd() converts.

All are wad except maxLeverage.

Bust (liquidation) price#

The bust price is exact in integer math and matches all 205 sampled live positions to the raw unit.

long:  p0 = ceil(e * (L - 1) / L);   bust = floor(p0 * (1e18 + bustBuffer) / 1e18)
short: p0 = floor(e * (L + 1) / L);  bust = ceil(p0 * (1e18 - bustBuffer) / 1e18)

bustBuffer is about 0.048%, so liquidation fires slightly before the zero-equity price. Crossing the bust price is a hard bust: the full margin is forfeited.

Winning close#

This matches 15 of 15 leaderboard settlements to float precision.

deadband = floor(e / 50000)                        (integer raw units)
gainRaw  = (isLong ? x - e : e - x) - deadband
move     = gainRaw / e
scale    = (1 - baseRate) / (1 + 1/(move * rateMultiplier) + referenceNotional/(1e6 * move * positionMultiplier))
adjusted = notional * move * scale                 (= adjustedPnlRaw)
winFee   = adjusted * 0.02                         (= userPaidFeeRaw)
net      = adjusted * 0.98

Small moves keep a small fraction of the raw gain, and large moves keep most of it. If gainRaw <= 0, the close pays nothing.

Losing close#

loss = notional * |x - e| / e        (no deadband, no haircut, no fee on the trader)
paperMintBasis = loss * 0.98         (queue empty: the 2% LP-side fee is carved from the LP's gain)

Liquidation#

loss = margin
paperMintBasis = margin              (verified on mainnet Liquidated rows)

PAPER minted#

paperMinted = paperMintBasis * rate, where:

rate = 100                                     while trackedLp < $2,000,000 (including underwater)
rate = 100 * (S / (S + H))^2                   otherwise, S = $120,000,000, H = tailProgress

trackedLp and tailProgress come from /query/protocol/summary (paper.trackedLp, paper.tailProgress).

PAPER and staking#

Pending reward, exact against PaperStaking.pendingReward(user):

pending = pendingBase + staked * accRewardPerShare / 1e27 - rewardDebt

PaperStaking exposes pendingReward(address), accRewardPerShare(), totalStaked() and DOMAIN_SEPARATOR().

Deposits#

Each wallet has a personal CREATE2 deposit address:

DepositProxyFactory.computeProxyAddress(user)

USDC that reaches that address on HyperCore is swept into the trader's balance. POST /funding/deposits/check asks the relayer to sweep now.

Routes:

Withdrawals#

WithdrawToCore is signed by the wallet. amount is in USDC units (6 dp) and must not exceed funds.availableBalance from the live stream (converted to 6 dp).

Errors#

Errors come back as { code, reason?, message? }. admission_rejected carries a reason:

reason Meaning
tradingPaused Trading is paused protocol-wide.
marketPaused This market is paused.
inactiveInstrument This market is no longer open for trading.
belowMinimum Margin is below the minimum trade size.
belowMinimumNotional Margin times leverage is below the minimum notional.
leverageTooHigh Leverage is above the market maximum.
positionTooLarge The position is above the per-position notional limit.
openInterest The market has hit its OI cap on that side.
funding Available collateral changed.
noOpenPositions Those positions are closed or already closing.
paperBalance Not enough unstaked PAPER.
stakedBalance Not enough staked PAPER.
sessionKeyLimit The wallet has too many active session keys.
noDeposit No deposit has arrived yet.
noRewards There are no staking rewards to claim.
alreadyQueued An identical request is already queued.

Other codes:

PapertradeApiError maps all of them to plain-language messages.

Raw Markdown: /docs/protocol.md