Appearance
How trading works
Papersol has one order type: a market order that fills in two steps, at the one oracle update selected by a pin time set when the order lands.
Submitting an order commits you to that pinned price, as long as the update is admissible. Orders have no limit price, slippage tolerance or minimum payout, and an adverse price move doesn't let you cancel.
Positions
- Each position is isolated: it has its own margin, market, side and leverage.
notional = margin × leverage. - Leverage is a whole number from 1 to the market's
max_leverage(currently 1000). - You can hold any number of positions, in the same market or different ones. They never net against each other.
- A position is opened and closed in full. The program has no partial close, no way to add margin, and no limit, stop or take-profit orders.
- There is no funding rate, borrow fee or expiry. A position stays open until you close it, it is liquidated, or its market is settled.
Lifecycle of an order
| Step | Who | What happens |
|---|---|---|
| 1. Request | You (wallet or session key) | request_open or request_close creates a request account and fixes its pin time pin_ts. You escrow SOL for rent and the execution fee. For an open, the margin moves from available to locked and the notional is reserved against the open-interest cap. |
| 2. Wait for the pin | Nobody | No fill is possible until the Solana clock reaches pin_ts. |
| 3. Fill | Anyone, normally the Papersol keeper | The filler posts the Pyth update pinned to pin_ts on Solana and calls execute_open or execute_close. The program checks the update and settles. |
| 4. Expiry (only if unfilled) | Anyone | After expires_ts the request can no longer fill. Anyone can then cancel it. Cancelling an open refunds its locked margin; cancelling a close leaves the position open. Either way most of the escrowed SOL is refunded. Expiry alone changes no accounts: until someone cancels, the request and its effects stay in place. |
The pin
pin_ts = max(clock, last_oracle_ts + 1) + pin_delay_s
expires_ts = pin_ts + fill_window_sclockis the Solana clock (unix seconds) when the request lands.last_oracle_tsis the newest verified Pyth publish time the program has recorded for the market. Fills, liquidations and heartbeats all record it, and it only moves forward.pin_delay_sis currently 6 s andfill_window_sis currently 30 s. Both are governance parameters.
A fill must use the unique Pyth update with prev_publish_time < pin_ts ≤ publish_time: the first update published at or after the pin. Exactly one update meets this condition, so neither you nor the filler can choose the price. The fill also requires that:
- the Solana clock is between
pin_tsandexpires_ts; - the update is fully Wormhole-verified, for this market's feed, and admissible (confidence and EMA limits, as recorded in the request when it was made; see Oracle and safety);
- the update was published no more than 60 s ahead of the Solana clock.
Why fills take a few seconds, and why that protects the house
The earliest a fill can happen is pin_ts, at least 6 s after your request lands. After that, a keeper has to fetch the pinned update from Pyth, post it on Solana and send the fill. No program rule bounds this step. Measured on devnet in October 2026, a Pyth posting landed in a median of about 3 s, with occasional tails of 7–12 s. The pin can be up to 6 s later than clock + 6 s: if the watermark is 5 s ahead of the clock, pin = clock + 5 + 1 + 6.
The delay blocks latency arbitrage. Without it, a trader who sees a price move first, on a faster venue or in a newer Pyth update, could trade at an older on-chain price that is already stale and collect a risk-free win. Pinning always stops anyone from choosing among published updates: exactly one update qualifies. The fill price is published at or after max(clock, last_oracle_ts + 1) + 6 s. That is later than any price you could have seen, provided the Solana clock or the market's latest oracle observation is close to real time. The program compares the watermark with the Solana clock, not with true real time. If the clock lags and the watermark is old, the pin can land on a price that has already been published. A request and its fill can never land in the same transaction or bundle, because the fill requires clock ≥ pin_ts.
Like other Solana perps, Papersol trusts the Solana clock (measured within about 1 s of real time on mainnet). There are extra layers for the case where it lags:
- The pin is never earlier than
last_oracle_ts + 1 + pin_delay_s. Every fill records a fresh price, and on a quiet market the keeper records one every 30 s. - The keeper compares the Solana clock with real time every 2 s and alerts the team if the clock falls more than 2.5 s behind, so new opens can be paused or heartbeats sped up.
- If a verified Pyth update is more than
max_clock_lag_s(5 s) ahead of the Solana clock, new requests are refused.
A fill at an already-published price needs both a lagging Solana clock and a watermark at least pin_delay_s + 1 seconds old in real time. The keeper still fills such requests, because leaving them unfilled would give the trader an option, and it raises an alert.
What a request needs
| Check | Applies to | Error |
|---|---|---|
| Protocol launched | open | NotLaunched |
| Signer is the owner or a live session key | open, close | Unauthorized |
Oracle watermark fresh: clock − 60 s ≤ last_oracle_ts (max_obs_age_s) | open, close | StaleHeartbeat |
Oracle not too far ahead of the clock: last_oracle_ts ≤ clock + 5 s (max_clock_lag_s) | open, close | ClockLag |
| Not paused; market not retired | open | Paused, MarketRetired |
1 ≤ leverage ≤ max_leverage | open | LeverageTooHigh |
margin ≥ min_margin; notional ≤ max_position_notional | open | MarginTooSmall, PositionTooLarge |
| Side open interest (live + pending + this order) within the cap | open | OiCap |
Your open plus pending notional within max_trader_notional | open | TraderNotionalCap |
margin ≤ available | open | InsufficientBalance |
| Position has no pending close; market has no settlement price; if a settlement is pending, the close is pinned before its settlement pin | close | ClosePending, MarketSettled, SettlementPending |
Closes are allowed while the protocol is paused and in retired markets.
execute_open re-checks the pause, retirement, leverage, minimum margin, maximum notional, trader cap and OI ceiling and brake. If governance or the guardian tightened one of them after you placed the request, that fill attempt fails but the request stays pending. It can still fill before expires_ts if the condition clears (for example, the pause is lifted). Otherwise it expires, and cancelling it returns your margin and escrow.
Expiry, cancellation and refunds
- Ordinary cancellation requires expiry:
cancel_requestfails at or beforeexpires_tsfor everyone, including you, the keeper and the admin. If a trader could cancel after seeing the pinned price, every order would be a free option against the house. The one exception isvoid_close(below), which needs proof that a close's pinned update is inadmissible. - Revoking or expiring a session key doesn't cancel requests it already placed. Accepted requests can still fill until they expire.
- After
expires_ts, anyone can callcancel_request:- Open: the margin goes back to available, and the OI reservation and your pending notional are released.
- Close: the position stays open with its margin. The pending-close marker is cleared, and you can request a close again. Until the expired close is cancelled, its marker stays and still affects liquidation (Liquidation). If the position was already liquidated, the leftover close request still needs cancelling to recover its escrow. (A settled market's position with a pending close isn't settled until that close is filled, voided, or expired and cancelled.)
- SOL: the caller receives ¼ of the execution fee (125,000 lamports at current settings). Everything else, meaning the rest of the fee, the request rent and any escrowed position or claim rent, goes back to whoever paid for the request. If you cancel your own request, you get all of it back minus the transaction fee. The keeper cancels expired requests automatically.
- A request reaches expiry when nobody fills it by
expires_ts. That happens when no filler acts, when a re-checked limit keeps failing until the deadline, or when the pinned Pyth update is inadmissible. - If a close's pinned update is inadmissible, anyone can call
void_closeright away instead of waiting for expiry. That closes the request, clears the pending-close marker, and refunds the request rent and escrowed claim rent. The execution fee goes to whoever posted that update.
SOL you pay per request
| Item | Open | Close | What happens to it |
|---|---|---|---|
| Request account rent | ≈ 0.0019 SOL | ≈ 0.0019 SOL | Refunded to the request's payer at fill, cancel or void |
| Execution fee | 500,000 lamports (0.0005 SOL) | same | Fill: paid to whoever posted the pinned Pyth update. Expiry: ¼ to the canceller, ¾ refunded. |
| Position rent escrow | ≈ 0.0019 SOL | — | Reimburses the filler, who creates the position account. Refunded to you when the position closes, is liquidated or is settled. |
| Queue-claim rent escrow | — | ≈ 0.0016 SOL | Refunded at fill unless the win is queued. If it is queued, refunded when the claim is fully paid. |
Rent figures are Solana's rent-exempt minimums for each account size, so they move with Solana's rent rate. Transaction fees come on top.
The execution fee goes to the poster of the pinned price update (its write authority), not necessarily the filler, because posting the update is the expensive part. If the poster's account is empty and the fee alone wouldn't make it rent-exempt, the filler gets the fee instead.
The request's rent payer is whoever signed the request. If a session key signed it, every refund goes to the session key's address.
One pending close per position
A position can have only one pending close. While it is pending, a liquidation can only use a price published before that close's pin: the close you committed to first takes precedence (see Liquidation).
Entry and exit prices
- Entry is the pinned Pyth aggregate price, normalized to 8 decimals and rounded down. Exit is the pinned price of your close request.
- No spread and no size-based slippage are added to entry or exit. The cost of a win is the haircut below.
- If the exit price is at or past your bust price, the close settles as a liquidation and you lose the whole margin (see Liquidation).
- Crowded-side averaging is compiled out of production builds. The code can price requests on a crowded side at the median of several pinned updates, but production builds reject any market configuration that enables it (
crowd_oimust beu64::MAX), and every request uses exactly one pinned update (samples = 1). Themax_samplesargument ofrequest_openandrequest_closehas no effect in production. Turning averaging on would take a program upgrade.
Price impact: the win haircut
Price impact in Papersol doesn't move your entry or exit price. It reduces winning PnL only. With m = |exit − entry| / entry for a winning close:
adjusted = notional × max(0, m − deadband) × (1 − base_rate) × m / (m + c)The program evaluates this in exact integer arithmetic, scaled by 1e9, and rounds down at each step:
d9 = dist × 1e9
eff9 = d9 − entry × deadband_e9 (adjusted = 0 if d9 ≤ entry × deadband_e9)
gain = floor(notional × eff9 / (entry × 1e9))
adjusted = floor( floor(gain × (10000 − base_rate_bps) / 10000) × d9 / (d9 + entry × c_e9) )Current parameters, the same for BTC, ETH and SOL and changeable by governance:
| Parameter | Value |
|---|---|
base_rate_bps | 770 (7.70%) |
c_e9 | 185,000 (c = 0.000185 = 1.85 bps) |
deadband_e9 | 31,000 (0.000031 = 0.31 bps) |
Properties:
- Losses are never haircut.
- The continuous formula doesn't depend on size: the same move keeps the same fraction of raw PnL at $10 or $10M notional. The integer rounding steps can change the exact fraction slightly, most noticeably on very small amounts.
- Small moves are haircut the most. The share of raw PnL you keep, before the 2% payout fee:
| Move in your favour | 1 bp | 5 bps | 10 bps | 1% | 5% | limit |
|---|---|---|---|---|---|---|
Kept (adjusted / raw) | 22.3% | 63.2% | 75.5% | 90.3% | 91.9% | 92.3% |
- Moves of 0.31 bps or less pay nothing.
Open-interest limits
Each side of each market caps the live + pending notional:
| Limit | Set by | Current value | Applies to |
|---|---|---|---|
Ceiling oi_cap_long / oi_cap_short | Governance (timelocked) | $5,000,000,000 per side on devnet; mainnet: TBD (the runbook says start very low) | New requests and fills |
Trailing limit oi_limit_* | Risk keeper, instantly, anywhere between current usage and the ceiling | The keeper aims for usage + $5,000,000 and re-targets once the headroom leaves ±10% of that | New requests only |
Emergency brake oi_brake_* | Admin or guardian lower it instantly; only the admin raises or removes it | Off | New requests and fills |
- New request:
live + pending (including this order) ≤ min(trailing limit, ceiling, brake). - Fill:
live + pending ≤ min(ceiling, brake). A routine change to the trailing limit never breaks an order that was already accepted. A brake or a lowered ceiling can: that order expires and is refunded. - Pending opens reserve capacity when they are accepted, so nobody can block other traders' fills by occupying the cap and releasing it selectively.
- Closes are never blocked by OI limits, and existing positions are never affected by them.
- Only the on-chain limits constrain an order. The keeper aims for $5M of headroom and re-targets when it leaves the ±10% band, checking about every 5 s. Actual headroom can be outside that band in between: limits start at the ceiling when a market is created, and closes free up headroom. So an order up to
max_position_notional($10M) may or may not be admissible at a given moment.
Other limits
| Limit | Current value | Set by |
|---|---|---|
min_margin | $1 | Governance, per market |
max_position_notional | $10,000,000 | Governance, per market |
max_trader_notional (open plus pending opens, all markets) | $10,000,000 | Governance |
Mainnet values: TBD. All of these are re-checked at fill.
Session keys (1-click trading)
A session key is a second keypair that you delegate trading to, so trades don't need a wallet pop-up.
set_session(key, expires_ts)is owner-only. There is one session key at a time; setting a new one replaces the old. The expiry must be in the future and no more than 30 days away, and the key can't be your own wallet.revoke_session(owner-only) removes it immediately for new actions. Requests the key already placed can still fill until they expire.- The program accepts a signer if it is the owner, or if it is the session key, the key is set, and
now < expires_ts.
| A session key can | A session key cannot |
|---|---|
request_open, request_close | withdraw |
stake, unstake, claim_rewards (rewards go to your available balance) | deposit |
| Anything permissionless that any wallet can do | set_session, revoke_session, create_trader |
- A session key can trade your whole available balance, not only what you deposited when you turned it on. A leaked key can't take your USDC, but it can open and close positions, including losing ones, until it expires or you revoke it.
- The session key pays the rent and execution fees of the requests it signs, and those refunds go back to the session key's address.
- In the Papersol app, the session key is generated in your browser and stored in its local storage. It is valid for 7 days and funded with 0.05 SOL. Turning 1-click off revokes the key and moves its SOL back to your wallet. Rent refunds from requests the key signed keep arriving at the key's address afterwards; you can recover them the same way.