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
- Deployment
- Units
- Read API
- Live wallet stream
- Writing: signed intents
- EIP-712 schemas
- Intent ids and nonces
- Settlement math
- PAPER and staking
- Deposits
- Withdrawals
- Errors
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.
- Pricing. Every trade is a synthetic swap against the protocol LP at Hyperliquid's BBO mid, read through a HyperCore precompile. There is no spread, slippage or funding.
- Fees. There is no fee on notional. Winning closes pay an asymmetric impact haircut on the gain, then a 2% win fee. Losing closes pay exactly the loss.
- The LP. The LP starts at $0 and grows from trader losses (the Martingaler design). When the LP cannot cover a win, the payout joins a FIFO queue, and later losses pay it.
- PAPER. Losses mint PAPER, the LP fee-claim token. Staked PAPER earns USDC.
- Balances. USDC lives on HyperCore. Trading state lives in the Exchange contract on HyperEVM.
- Relayer. Traders sign EIP-712 intents. A relayer batches them on chain through a BatchExecutor contract.
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#
- USD amounts (balances, margin, PnL) are 18-decimal integers serialized as decimal strings ("wad").
- USDC on chain has 6 decimals. Convert with
wad = usdc * 10^12. - Prices are integers in raw units:
price = raw / priceScale. - PAPER has 18 decimals.
Read API#
Base URL https://exchange.papertrade.xyz. Every response is JSON.
- Canonical query strings. The API rejects a query string that is not canonical. Each parameter may appear once, sorted by name, URLSearchParams-encoded, with no unknown keys.
canonicalQuery()builds one. - No CORS headers. Browser apps on another origin need a same-origin proxy. Every papertrade-* site ships one as a Cloudflare Pages Function.
| 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.
livenessevents carry{heartbeatIntervalMs}. Treat 3x that interval (capped at 45 s) of silence as a dead connection.- The first
walletevent is a full snapshot. It carries balances,funds,paper{balance, staked, pendingBase, rewardDebt},lifetimetotals,sessionKeys[[key, expiry]],openPositionsandoperations. - Later
walletevents are patches carryingbaseAccountRevision. A patch applies only whenbaseAccountRevisionequals the currentaccountRevision. Anything else means you are out of sync: reconnect and take a fresh snapshot.
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 }.
signedis the message in wire form: uints become decimal strings, addresses and bytes become lowercase hex, bools stay bools.v,randsare added alongside.- The relayer recomputes the intent id, admits or rejects the intent, and batches it on chain.
- The receipt echoes
actionandintentId. A client should check both match.
| 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#
- Registering. The wallet signs
RegisterSessionKeyonce for a locally generated key, withfeeAddress = 0x0and an expiry up to 30 days out. - What a key can do. It signs opens and closes with no wallet prompt.
- What it cannot do. It cannot withdraw, stake, unstake or claim.
- Revoking. Revoke a key on chain with
revokeSessionKeyfrom the wallet. - Intent lifetime. Every intent's
deadlineis at most one hour out.
Verified on mainnet:
- A Close intent signed by a key the wallet never registered is rejected with
403 unauthorized_intent. The relayer accepts the wire format and checks authorization. - A real batched Close from tx
0xde30ba04...f1d9recovers the trader's registered session key under the exchange domain below.
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:
- whether trading or the market is paused,
- whether the intake action is enabled,
- maximum leverage,
- minimum margin and minimum notional,
- maximum position size,
- the side's OI cap, when you pass
markPriceRaw(OI and caps are base-asset quantities, so the check needs a price).
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):
- Lanes. PAPER actions use the
paperlane. Everything else uses thesessionlane. - Seeding. The official client starts each lane at a random 24-bit value times 256 and increments it by one per intent.
- Persistence.
NonceAllocatordoes the same and persists through anylocalStorage-shaped store.
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#
- Transfers. PAPER cannot be transferred at launch. It can only be staked and unstaked through intents.
- What stakers earn. Staked PAPER earns USDC: 1% of the realised PnL of settled trades while no payouts are queued, plus the LP overflow above the $5M staker reward cap.
Pending reward, exact against PaperStaking.pendingReward(user):
pending = pendingBase + staked * accRewardPerShare / 1e27 - rewardDebt
staked,pendingBaseandrewardDebtcome from the live wallet stream'spaperobject.accRewardPerSharecomes from/state/trading(or on chain).- The precision is 1e27, not 1e18.
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.
- Minimum. At least 10 USDC must arrive after fees.
- Activation fee. The first deposit pays a one-time 1 USDC activation fee.
Routes:
- From HyperCore. Send spot USDC to the deposit address with a HyperCore
spotSend, signed by the wallet.buildHyperCoreDeposit()builds it. - From HyperEVM. Approve
CoreDepositWalletfor USDC, then calldepositFor(depositAddress, amount, 4294967295). That moves USDC from HyperEVM onto HyperCore for the deposit address.buildHyperEvmDeposit()returns the exact calls. - From other chains. Ethereum, Arbitrum and Solana bridge through Circle CCTP. Robinhood Chain USDG bridges through Across. Both land on HyperEVM or HyperCore first.
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).
- Activation fee. If the wallet has never withdrawn (
lifetime.withdrawnRaw == 0) and/query/accounts/{w}/fundingreportscoreAccount: false, 1 USDC is deducted for HyperCore account activation. - Minimum. After that fee, at least 10 USDC must be sent.
- Route
hypercore. Funds land in the wallet's HyperCore spot balance. - Route
hyperevm. The request also carries an onwardsendAssetHyperCore action to the USDC system address0x2000000000000000000000000000000000000000, signed by the wallet, forsent - 0.1 USDC(gas allowance). The relayer submits it after the withdrawal lands, andPOST /intents/{id}/onwardretries it.
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:
invalid_intentdeadline_expiredunauthorized_intentintent_not_foundintent_conflictbelow_minimum_withdrawalbelow_minimum_paper_stakeintake_action_pausedinvalid_onwardrate_limited(HTTP 429, honorRetry-After)
PapertradeApiError maps all of them to plain-language messages.