diff --git a/apps/docs/content/concepts/liquidation.mdx b/apps/docs/content/concepts/liquidation.mdx
index 8c6d06fc..d8968e2e 100644
--- a/apps/docs/content/concepts/liquidation.mdx
+++ b/apps/docs/content/concepts/liquidation.mdx
@@ -1,12 +1,150 @@
---
title: Liquidation
-description: How maintenance margin and changing position value determine whether a leveraged position may be liquidated.
-updated: 2026-08-25
-status: stable
+description: How to compute your own liquidation price on SO4, what the maintenance margin threshold is, what happens when it breaks, and what you keep.
+updated: 2026-09-02
+status: beta
---
-Liquidation is the protocol process that closes an under-collateralised position before its losses exceed the collateral available to support it.
+A position is liquidated when its equity falls below the maintenance margin — a small fraction of position size that must remain after losses. The interface computes the price at which that happens as `entryPrice ∓ (collateral − maintenanceMargin) ÷ positionTokens`, using a maintenance margin of 0.5% of size. On a 10x position, that is a 9.5% adverse move. When it is reached, the position is closed against the pool, fees are taken from what is left, and in practice you should expect to keep close to nothing.
-The displayed liquidation price is an estimate. A position becomes eligible when it breaches maintenance margin; fees, funding, price impact, and fast markets can move the actual result.
+Read this page before opening a leveraged position rather than during one.
-Read the broader [risk overview](/concepts/risk) before opening a leveraged position.
+## Maintenance margin
+
+Maintenance margin is a percentage of **position size**, not of collateral. It is the buffer the protocol keeps so that a position can be closed before its losses exceed the collateral backing it.
+
+The interface uses 50 basis points — 0.5% of size — as its default: `maintenanceMarginRateBps = 50` in `estimateLiquidationPrice`, in `apps/web/src/features/trade/lib/trade-math.ts`. Nothing in the interface overrides that default; every caller uses it.
+
+Where it is enforced is a different question from where it is displayed, and the honest answer has two halves:
+
+- **The interface only estimates.** The function above is a client-side calculation. Its own source comment reads "TODO: Replicate exact formula from Soroban contract once deployed", and it notes the contract's inputs as `maintenanceMarginRate`, `fundingFeeDebt`, and `borrowingFeeDebt` — two of which the estimate ignores.
+- **The contracts decide.** The authoritative liquidation price for an open position is returned by the contracts themselves, as the `liquidation_price` field of `PositionInfo` from `SyntheticsReader.get_account_positions` (`packages/contracts/src/generated/synthetics-reader/src/index.ts`, read in `apps/web/src/features/trade/hooks/usePositions.ts`). That is the number the positions table shows.
+
+The Soroban contract source is not part of this repository, so the 0.5% threshold could not be checked against the code that enforces it. Treat 0.5% as what the interface assumes, and the value returned for your open position as what the protocol says.
+
+There is a second copy of the same estimator in `apps/web/src/features/trade/lib/liquidation.ts`, with identical arithmetic and the same 50 bps default. The trade panel imports the `trade-math.ts` one.
+
+## Computing your liquidation price
+
+```
+maintenanceMargin = sizeUsd × 0.005
+maxLoss = collateralUsd − maintenanceMargin
+positionTokens = sizeUsd ÷ entryPrice
+
+liquidationPrice = entryPrice − maxLoss ÷ positionTokens for a long
+liquidationPrice = entryPrice + maxLoss ÷ positionTokens for a short
+```
+
+Divide through by `entryPrice` and the leverage falls out of it. The adverse move a position survives, as a fraction of the entry price, is:
+
+```
+adverseMove = (1 ÷ leverage) − 0.005
+```
+
+That identity is worth keeping. It says the distance to liquidation depends on leverage alone — not on the market, not on the size, not on how much collateral you posted in absolute terms.
+
+| Leverage | Adverse move to liquidation | Long liquidation at 60,000 | Short liquidation at 60,000 |
+| --- | --- | --- | --- |
+| 2x | 49.5% | 30,300 | 89,700 |
+| 5x | 19.5% | 48,300 | 71,700 |
+| 10x | 9.5% | 54,300 | 65,700 |
+| 20x | 4.5% | 57,300 | 62,700 |
+| 50x | 1.5% | 59,100 | 60,900 |
+
+At the interface's 50x maximum, a 1.5% move against you ends the position.
+
+### Worked example, both directions
+
+A trader opens a 10,000 USD position on BTC/USD at an entry price of 60,000 USD, with 1,000 USD of collateral — 10x leverage.
+
+- Maintenance margin: `10,000 × 0.005 = 50 USD`
+- Maximum loss before liquidation: `1,000 − 50 = 950 USD`
+- Position tokens: `10,000 ÷ 60,000 = 0.166667 BTC`
+- Price move that costs 950 USD: `950 ÷ 0.166667 = 5,700 USD`
+
+**Long:** `60,000 − 5,700 = 54,300 USD`. A 9.5% fall.
+
+**Short:** `60,000 + 5,700 = 65,700 USD`. A 9.5% rise.
+
+Both figures come from the same function the trade panel calls, run against these inputs.
+
+## What the estimate leaves out
+
+Four things move your real liquidation price away from the formula above. Each one moves it against you.
+
+**Funding and borrow fees.** Both accrue against the position for as long as it is open and reduce the collateral supporting it. The estimate treats collateral as a constant. A position that has paid 100 USD of funding on the 10x example above liquidates at 54,900 rather than 54,300 — 600 USD nearer, with the market having done nothing new. See [funding and fees](/concepts/funding-and-fees).
+
+**Fees charged on the way in.** Position fee and price impact are taken at execution, so the collateral actually backing the position is smaller than the amount you typed.
+
+**Collateral that revalues.** A long is collateralised in the index token, so when the price falls, the collateral falls with it. The estimate ignores that second exposure: it fixes `collateralUsd` at the value it had when the estimate ran.
+
+Take the long from the margin page: 0.02 TWBTC of collateral, worth 1,200 USD at 60,000, backing a 6,000 USD position at 5x. The estimate says 48,300. Solving instead for the price where equity — revalued collateral plus profit and loss — equals the 30 USD maintenance margin:
+
+```
+0.02 × P + 0.1 × (P − 60,000) = 30
+0.12 × P = 6,030
+P = 50,250
+```
+
+The position runs out of margin at 50,250, nearly 2,000 USD earlier than the displayed estimate. Short positions on SO4's markets are collateralised in TUSDC and do not carry this effect.
+
+**Execution, not detection.** The liquidation price is where a position becomes eligible. It is closed at whatever price the oracle reports when the transaction executes, which in a fast market is worse.
+
+## What happens during a liquidation
+
+The lifecycle is visible in the events the indexer handles (`apps/s03-indexer/src/mappings/mappingHandlers.ts`, `handleLiquidation`):
+
+1. **`liq_req`** — the position is flagged as liquidatable. The indexer records a `Liquidation` row with status `REQUESTED`, carrying the account, market, direction, and position key.
+2. **`liq_exe`** — the liquidation executes. The same row moves to status `EXECUTED` and is filled in with `sizeDeltaUsd`, `collateralLiquidatedAmount`, `remainingCollateralAmount`, `liquidationPrice`, `pnlUsd`, `priceImpactUsd`, `liquidationFeeUsd`, and the `liquidator` address.
+
+The order of operations inside `liq_exe` is set by the contracts, and the amounts are theirs to compute. What the event schema tells you is which quantities exist. The loss is realised at an execution price, price impact is applied, and a liquidation fee is charged. A remaining collateral amount is also recorded, so a residue is representable rather than merely assumed.
+
+Automatic deleveraging is a separate mechanism with its own events, `adl_req` and `adl_exe`, handled by `handleAdl` in the same file. It can reduce a position that is not in breach of maintenance margin. It is not liquidation, and this page does not cover it.
+
+## What you keep
+
+At the liquidation price, by construction, everything above the maintenance margin has already been lost. On the 10x example, that leaves 50 USD of a 1,000 USD deposit — and the fees come out of that 50, not out of anything else.
+
+Using the interface's current fee configuration (`apps/web/src/features/trade/lib/data-store.ts`), on a 10,000 USD position:
+
+| Item | Amount |
+| --- | --- |
+| Maintenance margin remaining at the liquidation price | 50.00 USD |
+| Position fee on the closing side, 0.10% of size | −10.00 USD |
+| Price impact, 0.05% of size | −5.00 USD |
+| Keeper execution fee, 0.3 XLM at 0.17 USD | −0.05 USD |
+| Liquidation fee (`liquidation_fee_usd`) | set by the contracts |
+| **Left before the liquidation fee** | **34.95 USD** |
+
+If the contract's liquidation fee is 0.35% of size or more, nothing is returned. Plan on the outcome of a liquidation being a total loss of the collateral behind that position, and treat any residue as a surprise rather than an entitlement. A stop-loss closed at your price is worth far more than a liquidation at the protocol's — see [order types](/concepts/order-types), including the cases where a stop does not protect you.
+
+## Is there partial liquidation
+
+No — not in anything this repository implements. The interface has no partial-liquidation path: `estimateLiquidationPrice` returns a single price, the positions table shows a single price, and no code reduces a position by a fraction on a margin breach.
+
+Two qualifications, both honest rather than reassuring. The indexer's `Liquidation` entity records a `sizeDeltaUsd`, so an event closing part of a position is representable in the schema. And the contracts that decide are not in this repository, so this page can tell you what the interface does and not what the deployed contract will do. If you are relying on a partial close to save a position, do not.
+
+## Where the interface shows the liquidation price
+
+Three places, and they do not all come from the same source:
+
+- **Trade panel, "Liq. price" row** — the client-side estimate for the position you are about to open, computed in `TradeInfoRows.tsx` from the estimated entry price. The confirmation dialog repeats it.
+- **Positions table, "Liq." column** — the contract's `liquidation_price` for the open position. It turns red when the mark price is within 10% of it (`PositionsList.tsx`).
+- **Chart** — a dashed line at the same contract-supplied price for the position (`TVChartContainer.tsx`).
+
+A gap between the panel's estimate before you trade and the table's figure afterwards is expected. The table is the one to believe.
+
+## How oracle behaviour moves it
+
+The liquidation check runs against an oracle price, so the properties of that feed are properties of your liquidation:
+
+- Prices come from a chain of sources tried in order, and the interface marks a price stale after 30 seconds without blocking trading. A stale display does not mean a stale check — the price used at execution is the one the transaction sees.
+- If every networked source fails, the client falls back to static placeholder prices. Nothing on screen says the feed is dead beyond the staleness dot.
+- The feed does not cross-check sources for deviation. A source that moves sharply away from the others is used as-is.
+- A gap through your liquidation price does not stop at it. The position closes at the next price the oracle reports, which is why a liquidation can leave less than the arithmetic above suggests.
+
+[Oracles](/concepts/oracles) documents the source order, the staleness thresholds, and the failure modes in full.
+
+## Related
+
+[Margin and leverage](/concepts/margin-and-leverage) is where the collateral and size in these formulas come from, including how adding collateral moves the liquidation price. [Funding and fees](/concepts/funding-and-fees) covers the costs that move it while you hold. [Risk](/concepts/risk) is the wider enumeration of what can go wrong.
diff --git a/apps/docs/content/concepts/margin-and-leverage.mdx b/apps/docs/content/concepts/margin-and-leverage.mdx
new file mode 100644
index 00000000..dd4b04a7
--- /dev/null
+++ b/apps/docs/content/concepts/margin-and-leverage.mdx
@@ -0,0 +1,116 @@
+---
+title: Margin and leverage
+description: How collateral, position size, and effective leverage relate on SO4, with the formula, two worked examples, and where the maximum is enforced.
+updated: 2026-09-02
+status: stable
+---
+
+Leverage on SO4 is one ratio: the USD value of your position divided by the USD value of the collateral behind it. Choosing 10x on 1,000 USD of collateral opens a 10,000 USD position, and every 1% the index moves is 10% of your collateral. The whole page is that sentence, made precise enough to compute before you open a position rather than after.
+
+## The five words, and what each one means
+
+| Term | Meaning on SO4 | Where you see it |
+| --- | --- | --- |
+| **Collateral** | A token balance transferred into the protocol, valued in USD at that token's current price | "Collateral" input in the trade panel; "Collateral" column in the positions table |
+| **Position size** | Notional exposure in USD — collateral value × leverage | "Position size" row in the trade panel; "Size" column in the positions table |
+| **Initial margin** | The collateral required to open a given size — size ÷ leverage. It is the same number as the collateral you post | The amount you type into the collateral input |
+| **Effective leverage** | Size ÷ collateral value, recomputed as prices move | Leverage badge on the slider; the "Leverage" preview in the collateral dialog |
+| **Maintenance margin** | The minimum equity a position must keep before it can be liquidated — a percentage of size, not of collateral | Not displayed directly; it sets the liquidation price |
+
+Maintenance margin is the one that ends positions, and it has its own page: [liquidation](/concepts/liquidation).
+
+## The formula
+
+```
+sizeUsd = collateralUsd × leverage
+leverage = sizeUsd ÷ collateralUsd
+```
+
+Both directions are implemented as one-line helpers — `sizeFromCollateralAndLeverage` and `collateralFromSizeAndLeverage` in `apps/web/src/features/trade/lib/trade-math.ts`. The trade panel calls the first to turn your collateral input and slider position into the size it submits (`TradePanel.tsx`). For a position that already exists, the interface divides instead: `leverage = sizeUsd ÷ collateralUsd` in `usePositions.ts`, rounded to a whole number for the positions table.
+
+Two details in that second calculation matter more than they look.
+
+**Collateral is revalued, size is not.** `collateralUsd` is the collateral token amount multiplied by that token's current price. `sizeUsd` is the notional the contracts recorded when the position opened. So displayed leverage moves when the collateral token's price moves, not when your profit or loss changes.
+
+**Unrealised profit and loss is not in the ratio.** The leverage the interface shows you is size over collateral, not size over equity. A position 400 USD underwater still displays the leverage it opened at. The number that decides liquidation is equity, and equity is collateral value plus profit and loss.
+
+## Which collateral you can post
+
+Collateral is fixed per market and per direction. It comes from the market definitions in `apps/web/src/features/pools/data/markets.ts`, applied as the defaults in `useTradeState.ts`: a long is collateralised in the market's long token, a short in its short token. The shipped interface has no collateral selector, so these are the only combinations available today.
+
+| Market | Long collateral | Short collateral |
+| --- | --- | --- |
+| BTC/USD | TWBTC | TUSDC |
+| ETH/USD | TETH | TUSDC |
+| XLM/USD | TXLM | TUSDC |
+
+All four are testnet tokens with no real value; [the faucet guide](/guides/faucet) covers getting them.
+
+The consequence is that a long carries its market's price twice: once through the position, and once through the collateral backing it. The first worked example below shows that working in the trader's favour; [liquidation](/concepts/liquidation) shows what it costs when the price falls instead.
+
+## Worked example 1: a long that wins, with volatile collateral
+
+A trader opens a long on BTC/USD with the index at 60,000 USD, posting 0.02 TWBTC as collateral and choosing 5x.
+
+- Collateral value: `0.02 × 60,000 = 1,200 USD`
+- Position size: `1,200 × 5 = 6,000 USD`
+- Exposure: `6,000 ÷ 60,000 = 0.1 BTC`
+
+BTC rises 10%, to 66,000.
+
+- Profit and loss: `(66,000 − 60,000) ÷ 60,000 × 6,000 = +600 USD`
+- Collateral value: `0.02 × 66,000 = 1,320 USD`
+- Equity: `1,320 + 600 = 1,920 USD`
+- Displayed leverage: `6,000 ÷ 1,320 = 4.55x`
+
+The trader made 600 USD on the position and another 120 USD on the collateral revaluing, and their leverage fell without them touching anything. The same mechanism runs in reverse when BTC falls: the collateral shrinks while the position loses, which is why a long's real liquidation price sits closer than the interface's estimate — [liquidation](/concepts/liquidation) works that case through.
+
+## Worked example 2: a short that loses, and what the numbers do
+
+A trader opens a short on BTC/USD at the same 60,000 USD index price, posting 1,000 TUSDC and choosing 10x.
+
+- Position size: `1,000 × 10 = 10,000 USD`
+- Exposure: `10,000 ÷ 60,000 = 0.166667 BTC`
+- Liquidation price, from the formula on the [liquidation](/concepts/liquidation) page: `65,700 USD`, a 9.5% adverse move
+
+BTC rises 4%, to 62,400.
+
+- Profit and loss: `(60,000 − 62,400) ÷ 60,000 × 10,000 = −400 USD`
+- Collateral value: still `1,000 USD` — TUSDC does not move with BTC
+- Equity: `1,000 − 400 = 600 USD`
+- Displayed leverage: `10,000 ÷ 1,000 = 10x`, unchanged
+- Leverage against equity: `10,000 ÷ 600 = 16.67x`
+
+The position is 40% of the way to losing its collateral, and the leverage figure on screen has not moved. This is the gap to hold in mind: the interface reports the ratio it opened with, while the ratio that matters has risen by two thirds.
+
+## Changing leverage on an open position
+
+You cannot edit the leverage of an open position directly. You change the collateral under it, and the ratio follows. The trade page's collateral dialog (`CollateralDialog.tsx`) submits a size-zero increase order to add collateral, or a size-zero decrease order to remove it, and previews the resulting leverage as `sizeUsd ÷ newCollateralUsd` before you confirm.
+
+Continuing the short above — 10,000 USD of size against 1,000 TUSDC:
+
+| Action | New collateral | New leverage | New liquidation price |
+| --- | --- | --- | --- |
+| Add 500 TUSDC | 1,500 USD | 6.67x | 68,700 USD |
+| No change | 1,000 USD | 10x | 65,700 USD |
+| Remove 400 TUSDC | 600 USD | 16.67x | 63,300 USD |
+
+Adding collateral moves the liquidation price 3,000 USD further away; removing 400 moves it 2,400 USD closer. Both are size-zero orders, and both pay a keeper execution fee, so the move is not free — see [funding and fees](/concepts/funding-and-fees).
+
+The dialog refuses two cases outright. Withdrawing the entire collateral balance fails with "Cannot remove all collateral (Close position instead)". Withdrawing enough to push the position past 50x fails with "New leverage exceeds maximum allowed (50x)" — on this position, removing more than 800 TUSDC.
+
+## The maximum, and where it is enforced
+
+Three separate places in the interface cap leverage at 50x:
+
+- The leverage slider's `max` default, in `LeverageSlider.tsx`.
+- `maxLeverage: 50`, hard-coded per market in `useMarketsInfo.ts` with a comment marking the DataStore read as a follow-up.
+- The removal check in `CollateralDialog.tsx` described above.
+
+None of those is the authority. The real limit is market configuration held by the contracts. The indexer's schema carries `maxLeverage` and `minCollateralUsd` on `MarketConfigSnapshot` (`apps/s03-indexer/schema.graphql`). A position that exceeds the contract's cap is rejected with `LEVERAGE_TOO_HIGH`, shown as "Leverage is above the maximum allowed for this market" — see [the error reference](/reference/errors#leverage-too-high).
+
+Two honest caveats about that. The client's 50x is a placeholder, not a value read from the contracts, so a market configured lower will reject an order the slider allowed. And the indexer's mapping for market-creation events stores only the raw event payload in `rawConfig` today; it does not populate the `maxLeverage` or `minCollateralUsd` columns (`apps/s03-indexer/src/mappings/mappingHandlers.ts`). Neither the interface nor the indexer can currently tell you a market's real cap.
+
+## Related
+
+[Liquidation](/concepts/liquidation) converts these numbers into the price at which the position ends. [Funding and fees](/concepts/funding-and-fees) covers the costs that reduce collateral while a position is open, which moves the liquidation price without the market moving. [Perpetuals](/concepts/perpetuals) is the instrument itself, if the terms above are new.
diff --git a/apps/docs/content/concepts/perpetuals.mdx b/apps/docs/content/concepts/perpetuals.mdx
new file mode 100644
index 00000000..545a9a3c
--- /dev/null
+++ b/apps/docs/content/concepts/perpetuals.mdx
@@ -0,0 +1,85 @@
+---
+title: Perpetuals
+description: What a perpetual contract is, how it differs from spot and from a dated future, and why funding is what keeps its price tied to the index.
+updated: 2026-09-02
+status: stable
+---
+
+A perpetual — a perp — is a contract that tracks the price of an asset without ever expiring and without you owning the asset. You post collateral, choose a direction, and your position gains or loses value as the index price moves. Because the contract never settles on a delivery date, nothing forces its price back toward the asset's price; funding, a recurring payment between the two sides of the market, does that job instead.
+
+## How a perp differs from spot and from a dated future
+
+| | Spot | Dated future | Perpetual |
+| --- | --- | --- | --- |
+| What you hold | The asset itself | A contract to trade at a set date | A contract with no end date |
+| What ends it | Selling | Expiry and settlement | You closing it, or [liquidation](/concepts/liquidation) |
+| What ties its price to spot | It is spot | Convergence at expiry | Funding, paid continuously |
+| Collateral needed | The full amount | Margin | Margin |
+
+Buying 0.1 BTC on a spot venue costs the full price of 0.1 BTC, and the coins are yours until you sell them. A dated future can drift from spot while it lives, but the drift has a deadline: at expiry the contract settles against the reference price, so an arbitrageur can hold the difference until it closes. A perp has no such deadline. Left alone, a perp trading above the index would stay there.
+
+## Why funding exists
+
+Funding replaces the discipline that expiry provides. It is a payment made at fixed intervals from one side of the market to the other, sized by how far the market has leaned. When the perp trades above the index, longs pay shorts, which makes holding a long more expensive and holding a short more attractive until the imbalance closes. When it trades below, the flow reverses.
+
+The point is the incentive, not the payment. Funding does not push the perp price directly; it pays traders to push it, by making the crowded side pay the empty one for as long as the gap lasts.
+
+On SO4 the funding rate for a market is read from the contracts as a signed per-second factor and converted to a per-hour rate, settling in fixed eight-hour epochs. The rate, the sign convention, and the other four costs a position can carry are covered in [funding and fees](/concepts/funding-and-fees); this page does not repeat them.
+
+## Long and short
+
+The two directions are symmetric. A long profits when the index price rises and loses when it falls; a short does the reverse. Both post collateral, both pay the same fees, and both can be liquidated. Neither is the default or the safe one.
+
+Profit and loss on an open position is computed as a fraction of position size, not of collateral:
+
+```
+pnlUsd = (priceDelta / entryPrice) × sizeUsd
+
+ where priceDelta = currentPrice − entryPrice for a long
+ priceDelta = entryPrice − currentPrice for a short
+```
+
+That formula is `calculatePnl` in `apps/web/src/features/trade/lib/trade-math.ts`.
+
+### Worked example, both directions
+
+A trader posts 1,000 USD of collateral on BTC/USD at 5x leverage, with the index at 60,000 USD.
+
+Position size is `1,000 × 5 = 5,000 USD`, which is `5,000 ÷ 60,000 = 0.083333 BTC` of exposure.
+
+**If BTC rises 10%, to 66,000:**
+
+- Long: `(66,000 − 60,000) ÷ 60,000 × 5,000 = +500 USD` — a 50% gain on the 1,000 USD of collateral.
+- Short: `(60,000 − 66,000) ÷ 60,000 × 5,000 = −500 USD` — a 50% loss on the same collateral.
+
+**If BTC falls 10%, to 54,000:**
+
+- Long: `(54,000 − 60,000) ÷ 60,000 × 5,000 = −500 USD`.
+- Short: `(60,000 − 54,000) ÷ 60,000 × 5,000 = +500 USD`.
+
+The mirror is exact. So is the multiplier between the index and the trader's money: a 10% move in BTC is a 50% move in their collateral, because the position is five times that collateral. Both positions in this example would be liquidated by a 19.5% adverse move — 48,300 for the long, 71,700 for the short — before the collateral is fully gone. [Liquidation](/concepts/liquidation) shows that calculation.
+
+Fees and funding are left out above so the direction arithmetic stays visible. They are real, and they come out of the same collateral. At a funding rate of 0.003% per hour, this 5,000 USD position pays `5,000 × 0.00003 = 0.15 USD` an hour, or 3.60 USD a day. The fees charged when it opens and closes are on top of that.
+
+## What the words mean here
+
+Three terms have a specific meaning on SO4, and reading them loosely is where mistakes start.
+
+**Position size** is notional value in USD — what your exposure is worth, not what you paid for it. The contracts store it as `size_in_usd` alongside `size_in_tokens`, the token quantity that USD figure bought (`PositionProps` in `packages/contracts/src/generated/synthetics-reader/src/index.ts`). The trade panel's "Position size" row is collateral value multiplied by the leverage you selected.
+
+**Collateral** is a token balance you transfer in, not a USD deposit. The interface converts it to USD for display at the current price of that token (`usePositions.ts`). Which token depends on the market and the direction. On SO4's markets a long is collateralised in the index token and a short in TUSDC, so a long's collateral is itself exposed to the price it is betting on. [Margin and leverage](/concepts/margin-and-leverage) works through what that does to the numbers.
+
+**Entry price** is the average price your size was filled at, derived as `size_in_usd ÷ size_in_tokens` rather than stored directly. Before you trade, the panel shows an estimate: the current mid price adjusted for price impact. The price you actually get is set when a keeper executes the order, not when you sign it. [Order types](/concepts/order-types) explains what each order type does and does not guarantee about that.
+
+## Where SO4 differs from the generic description
+
+Most of this page is true of any perpetual venue. Four things are specific to SO4, and each has its own page:
+
+- **One pool is the counterparty to both sides.** There is no order book matching you against another trader. A single GM pool per market backs every long and every short — see [unified liquidity](/concepts/unified-liquidity).
+- **The index price comes from a chain of external sources.** SO4's own oracle service first, then Pyth, Binance, and GMX, with a static fallback if all of them fail — see [oracles](/concepts/oracles).
+- **Orders are executed by keepers in a second transaction.** Signing does not fill your order; it creates one — see [order types](/concepts/order-types).
+- **Positions settle on Stellar.** Collateral sits in the protocol's contracts, and every change is a transaction your wallet signs — see [the introduction](/get-started/introduction).
+
+## Related
+
+[Margin and leverage](/concepts/margin-and-leverage) turns collateral and size into the numbers you can check before opening a position. [Liquidation](/concepts/liquidation) is where those numbers decide whether you keep anything. [Risk](/concepts/risk) is the unflattering list of everything else.
diff --git a/apps/docs/content/get-started/quickstart.mdx b/apps/docs/content/get-started/quickstart.mdx
index 16c50f63..350d8bc0 100644
--- a/apps/docs/content/get-started/quickstart.mdx
+++ b/apps/docs/content/get-started/quickstart.mdx
@@ -1,7 +1,7 @@
---
title: Quickstart
description: Place your first SO4 testnet trade in about ten minutes — install Freighter, connect it, switch to Testnet, fund the account, and submit an order.
-updated: 2026-08-30
+updated: 2026-09-02
status: beta
---
@@ -15,7 +15,7 @@ You need the Freighter wallet extension in your browser and a Freighter account
Install Freighter from its official website and follow the account creation flow. Freighter walks you through setting a password and saving a recovery phrase. The recovery phrase is the only way to restore the account, so keep it somewhere safe.
-A dedicated wallet guide that covers Freighter, xBull, and Hana in more depth is planned. This page uses Freighter because it is the wallet the interface is tested with.
+[The wallets page](/get-started/wallets) covers Freighter, xBull, and Hana in more depth, along with what connecting and signing authorise. This page uses Freighter because it is the wallet the interface is tested with.
## 2. Connect the wallet to SO4
diff --git a/apps/docs/content/get-started/wallets.mdx b/apps/docs/content/get-started/wallets.mdx
new file mode 100644
index 00000000..4c7eab6b
--- /dev/null
+++ b/apps/docs/content/get-started/wallets.mdx
@@ -0,0 +1,89 @@
+---
+title: Wallets
+description: Which Stellar wallets SO4 supports, what connecting and signing actually authorise, how sessions persist, and what disconnect clears.
+updated: 2026-09-02
+status: stable
+---
+
+SO4's connect dialog offers three wallets: Freighter, xBull, and Hana. All three are browser extensions, reached through Stellar Wallets Kit, which the app initialises on load. Connecting reveals your public address to the interface and nothing else — no spending authority, no allowance, no standing permission. Every movement of your funds needs a separate signature on a specific transaction.
+
+## The supported wallets
+
+The list is defined as `WALLET_OPTIONS` in `apps/web/src/features/wallet/components/ConnectButton.tsx`, and the dialog shows exactly these:
+
+| Wallet | Install from | How the dialog detects it |
+| --- | --- | --- |
+| Freighter | `freighter.app` | The Freighter API reports the extension as connected |
+| xBull | `xbull.app` | Always reported available — see below |
+| Hana | Chrome Web Store | The extension exposes a Stellar interface on the page |
+
+The app initialises Stellar Wallets Kit with the kit's `defaultModules()`, which registers a wider set — Albedo, Fordefi, Rabet, Lobstr, Klever, OneKey, Bitget, CactusLink, and D'Cent alongside the three above. The connect dialog then filters that list down to its own three options, so those are the only wallets reachable from the interface today. A wallet that the kit supports but the dialog does not list cannot be selected.
+
+When the dialog opens it calls `refreshSupportedWallets()` and marks each option available or not, sorting the detected ones first. Choosing a wallet marked "Not installed" does not attempt a connection; the dialog shows an install panel instead, with the text "Install it, then return here and connect without closing this modal".
+
+### What goes wrong, per wallet
+
+Every failed connection produces the same message: **"Could not connect Freighter. Please try again."**, with the chosen wallet's name substituted. The interface does not surface the wallet's own error, so the message is the same for a locked extension as for a rejected prompt. The conditions differ:
+
+- **Freighter** — selecting it calls the wallet's `requestAccess`, which raises Freighter's own approval prompt. Dismissing that prompt, or having the extension locked, produces the failure message. Unlock Freighter, retry, and approve the prompt. The kit also reports Freighter's mobile build as unavailable on purpose, so a mobile Freighter never appears as detected. The failure that costs the most time comes later, at signing. A Freighter left on Mainnet refuses the transaction with "Freighter is set to Main Net", then "The transaction you're trying to sign is on Test Net", then "Signing this transaction is not possible at the moment". [The quickstart](/get-started/quickstart) shows that screen.
+- **xBull** — the kit's xBull module reports itself available unconditionally, so xBull is listed as "Detected" whether or not anything is installed. Selecting it opens xBull's connection bridge; with no xBull present, nothing completes and you get the same message. A "Detected" label next to xBull is not evidence that you have it.
+- **Hana** — detected only when the extension exposes its Stellar interface to the page. Hana installed without that interface active shows as "Not installed", and the kit's own error, "Hana Wallet is not installed", is replaced by SO4's message before you see it.
+
+If the wallet connects and the failure appears later, at signing time, the error reference is organised by the exact on-screen text: see [rejected or declined](/reference/errors#rejected-or-declined) and [`tx_bad_auth`](/reference/errors#tx-bad-auth).
+
+## Networks
+
+SO4 runs against one network at a time, fixed at build time by the `VITE_NETWORK` variable and resolved in `apps/web/src/app/config/network.ts`. The current deployment is testnet, so your wallet must also be on testnet. Stellar Wallets Kit is initialised with the same network the app is configured for, so the passphrase the wallet signs against comes from the app rather than from the wallet's current selection.
+
+Switching networks is done in the wallet, not in SO4. There is no network switcher in the interface.
+
+Be aware of one gap. The app carries a `network` value in its wallet store, but that value is seeded from the app's own configuration and persisted — nothing reads the network your wallet is actually on. The mismatch banner that reads "Your wallet is connected to Mainnet but this app is running on Testnet. Please switch networks in your wallet." can therefore only appear if the app's configured network changed between your visits, not because your wallet disagrees. A wallet on the wrong network usually fails at signing or submission instead, as `tx_bad_auth`: "Transaction signature invalid. Try reconnecting your wallet."
+
+## What connecting grants, and what signing authorises
+
+This section is worth reading closely; it is the part of using any DEX where users are exploited.
+
+**Connecting** hands the interface your public address. With it, the app reads public ledger data on your behalf — your XLM balance and token balances from Horizon's `/accounts/{address}` endpoint (`useBalance.ts`, `useTokenBalances.ts`), and your positions and orders from the contracts. Reading is all it can do. A public address cannot authorise a transfer.
+
+**Signing** authorises exactly one transaction, once. The path is `prepareAndSign` in `apps/web/src/lib/soroban/tx-builder.ts`:
+
+1. The app builds the transaction and simulates it against Soroban RPC, which attaches the resource footprint and the authorisation entries the call needs.
+2. The prepared XDR goes to the wallet with the network passphrase.
+3. The wallet returns a signed envelope. The app submits it.
+
+For an order that posts collateral, the transaction the wallet shows is a single `multicall` on ExchangeRouter containing two actions: a `SendTokens` action moving your stated collateral amount to OrderVault, and a `CreateOrder` action (`packages/contracts/src/clients/exchange-router.ts`). That is the whole authorisation. The amount is fixed in the transaction you sign.
+
+What that means in practice:
+
+- **SO4 never asks for an unlimited token approval.** There is no allowance to grant and none to revoke later. Each collateral transfer is written into the transaction that uses it.
+- **A signature does not authorise a future transaction.** Adding collateral, removing collateral, and closing a position are each a separate signature.
+- **A signature does not fill an order.** It creates one. A keeper executes it in a later transaction at a price set then — see [order types](/concepts/order-types).
+- **Read the amounts in the wallet prompt, not only in the interface.** The wallet is showing the transaction that will execute; the interface is showing an estimate of what it will do.
+
+Note that the collateral leaves your wallet at execution and sits in the protocol's contracts until the position closes. It is not in your wallet while the position is open, and it is not in SO4's custody as a company balance either — it is contract state on the public ledger.
+
+## Sessions, persistence, and disconnect
+
+The wallet store (`apps/web/src/features/wallet/store/wallet-store.ts`) persists to `localStorage` under the key `so4-wallet`, and it saves three fields: `address`, `walletId`, and `network`. Connection status is deliberately not persisted.
+
+The consequence is worth stating plainly: after a page reload the app has your address in storage but shows the Connect button until Stellar Wallets Kit re-emits its own stored session. The kit's session, not SO4's, is what keeps you connected across reloads.
+
+**Disconnect** — from the account dropdown — calls the kit's `disconnect()` and clears `address` and `walletId` in the store. What it does not do is remove the `so4-wallet` entry itself, which stays behind holding the network value, or clear anything in your wallet extension. The extension keeps its own record of sites it has connected to; removing SO4 there is done in the wallet.
+
+Two smaller pieces of state are separate from all this: your trade panel selections persist under `so4-trade-state-v2`, and a dismissed network banner is recorded in `sessionStorage` for the tab.
+
+## Mobile wallets
+
+The connect dialog renders a QR code alongside the extension options, built from a SEP-7 URI (`apps/web/src/features/wallet/lib/sep7.ts`). It encodes a `web+stellar:signin` request with the app's origin as the callback and the network passphrase the app is configured for.
+
+Honestly: this is not a working mobile connection path today. No route in the app consumes that callback, and the code that would switch the QR to a transaction request depends on a pending-transaction value that nothing in the app ever sets. Treat the QR as a placeholder and use a browser extension.
+
+## Passkeys and smart accounts
+
+Not supported. The repository contains no passkey, WebAuthn, or smart-account code, no dependency providing it, and no contract client for a smart-account wallet. Every connection path in the interface expects a classic Stellar account: the signing path takes a `G…` address and the order builder validates that shape before submitting.
+
+Stellar Wallets Kit is the layer where such support would arrive, and using it keeps that door open. Until it lands, treat any documentation elsewhere describing passkey login on SO4 as describing an intention rather than the current build. [The roadmap](/resources/roadmap) is where planned work is dated.
+
+## Related
+
+[The introduction](/get-started/introduction) covers what is on-chain and what is served off-chain, which is the other half of what you are trusting. [The faucet guide](/guides/faucet) gets testnet tokens into a connected wallet. [Troubleshooting](/guides/troubleshooting) is organised by symptom when a transaction fails after signing.
diff --git a/apps/docs/content/meta.json b/apps/docs/content/meta.json
index 00c7c5af..104eea4a 100644
--- a/apps/docs/content/meta.json
+++ b/apps/docs/content/meta.json
@@ -2,7 +2,11 @@
"sections": [
{
"label": "Get started",
- "pages": ["get-started/introduction", "get-started/quickstart"]
+ "pages": [
+ "get-started/introduction",
+ "get-started/quickstart",
+ "get-started/wallets"
+ ]
},
{
"label": "Developers",
@@ -20,12 +24,14 @@
{
"label": "Concepts",
"pages": [
- "concepts/order-types",
- "concepts/risk",
- "concepts/funding-and-fees",
+ "concepts/perpetuals",
+ "concepts/unified-liquidity",
+ "concepts/margin-and-leverage",
"concepts/liquidation",
+ "concepts/funding-and-fees",
+ "concepts/order-types",
"concepts/oracles",
- "concepts/unified-liquidity"
+ "concepts/risk"
]
},
{
diff --git a/apps/docs/link-ignore.json b/apps/docs/link-ignore.json
index d319555b..cdc231fb 100644
--- a/apps/docs/link-ignore.json
+++ b/apps/docs/link-ignore.json
@@ -4,7 +4,5 @@
"https://so4.market/*",
"https://www.freighter.app/*",
"https://github.com/jsonfeed/jsonfeed-validator",
- "/concepts/perpetuals",
- "/concepts/margin-and-leverage",
"/reference/exchange-router#create_order"
]