diff --git a/apps/docs/content/developers/architecture.mdx b/apps/docs/content/developers/architecture.mdx
new file mode 100644
index 00000000..c032bac4
--- /dev/null
+++ b/apps/docs/content/developers/architecture.mdx
@@ -0,0 +1,142 @@
+---
+title: SO4 architecture
+description: The monorepo map, data flow from contracts to screen, state locations, and repository invariants explained.
+updated: 2026-08-25
+status: stable
+---
+
+SO4 is a trading platform built on Soroban smart contracts. Understanding how data flows from the contract layer through to the web app helps you navigate both for changes and for integration.
+
+## Workspace layout
+
+The monorepo uses Turborepo with workspaces declared in `package.json`. Each workspace owns one concern.
+
+| Path | Purpose |
+| ------------------------ | -------------------------------------------------------- |
+| `apps/web` | React + Vite web app for traders, liquidity providers |
+| `apps/s03-indexer` | Node.js service that syncs contract state to a database |
+| `apps/docs` | Documentation site, MDX + React |
+| `packages/contracts` | TypeScript contract bindings and client layer |
+| `packages/ui` | Shared React component library: buttons, forms, tables |
+| `packages/vitest-config` | Shared Vitest configuration for unit tests |
+
+Most trading logic lives in `apps/web`. The indexer watches contracts and makes historical state queryable. Design tokens and UI components ship from `packages/ui` to avoid duplication.
+
+## Data flow: contracts to screen
+
+Market data, position state, and order history flow through four stops:
+
+```
+Soroban contracts (on-chain state)
+ ↓
+ RPC nodes (live reads)
+ ↓
+ s03-indexer (database sync)
+ ↓
+ web app (TanStack Query + Zustand)
+ ↓
+ trader screen
+```
+
+### 1. Contracts
+
+SO4 contracts live on Soroban and hold the authoritative market and position state:
+
+- **ExchangeRouter:** Entry point for all state-changing actions (open position, place order, close position).
+- **SyntheticsReader:** Market snapshots: open interest, prices, funding rates, oracle state.
+- **DataStore:** Persistent market and position data (pools, collateral, leverage).
+- **OrderVault:** Holds collateral until the order is filled or cancelled.
+
+Each contract enforces access control, validates amounts, and raises errors for invalid operations. Bindings for these contracts are committed in `packages/contracts/src/generated/` and hand-adapted in `src/clients/`.
+
+### 2. RPC nodes
+
+Soroban RPC is the live read path. `packages/contracts` exports clients for read-only calls (e.g., fetching the current market state). These simulation-based reads require no transaction or account, only the network URL and contract IDs from `contracts.json`.
+
+### 3. Indexer
+
+The indexer is a separate Node.js service (`apps/s03-indexer`) that:
+
+1. Watches contract events from Stellar for changes (positions opened, orders filled, liquidations).
+2. Syncs state into a local database.
+3. Exposes a GraphQL API for historical queries.
+
+The indexer is optional for read-only integrations (you can call RPC directly), but essential for the web app's order history, position tracking, and liquidation feeds.
+
+Running the indexer locally:
+
+```bash
+bun run --cwd apps/s03-indexer dev
+```
+
+It syncs from Stellar every N blocks and emits GraphQL types (see `apps/s03-indexer/codegen`).
+
+### 4. Web app state
+
+The web app holds three types of state:
+
+| Layer | Purpose | Lifespan | Write |
+| -------------------------- | ------------------------------------- | ------------------ | -------------------------------------------------- |
+| TanStack Query (server) | Cached contract reads, indexer data | Configurable TTL | Refetch on interval, manual invalidate on change |
+| Zustand store (UI) | Trading form, UI toggles, alerts | Session | User interaction (form inputs), UI events |
+| localStorage | Theme preference, previous orders | Browser storage | Set on preference change, trade submission |
+
+TanStack Query manages freshness: it refetches market data every 3 seconds and position state every 5 seconds. The Zustand store is lightweight—it holds form state and UI flags, not derived calculations. localStorage is only for UI preferences that should persist across browser sessions.
+
+## Where state lives
+
+Different concerns own different state:
+
+| State | Owner | Immutable after creation? | Edited by |
+| ---------------------------- | ----------------------- | ------------------------- | --------------------------------------------- |
+| Contract IDs, network config | `contracts.json` | Yes (on re-deploy) | Contract deployment script, committed to repo |
+| Market data (prices, OI) | Contracts + RPC | Per block | Soroban runtime, oracle updates |
+| Positions, orders | Contracts + indexer DB | Append-only after event | User transactions, liquidation bot |
+| Portfolio, P&L calculations | Web app (Query + Zustand) | Per calculation | User interactions, data refetches |
+| Form state (order size, etc) | Zustand | Per input | Keyboard/mouse input |
+| Visual preferences | localStorage | Per session | User toggle (theme, layout) |
+
+The rule: on-chain data is source of truth. The web app is a view layer that reads and writes that state.
+
+## Repository invariants
+
+These four principles are not negotiable and root every architectural decision:
+
+### 1. Generated bindings are hand-adapted
+
+Run `stellar contract bindings typescript` and the output uses snake_case field names and lacks helper types. The committed bindings in `packages/contracts/src/generated/` have been manually edited to use camelCase and to export `*Args` and `*Val` types that `src/clients/` imports.
+
+**Do not regenerate bindings casually.** Regenerating with `bun run contracts:gen:all` overwrites the hand-written adaptations and breaks the build until you reapply them or update `src/clients/`.
+
+If you must regenerate (e.g., after a contract upgrade), commit the changes and immediately apply the adaptations so the break is visible in CI.
+
+### 2. Every dependency must be declared
+
+Bun's isolated linker means undeclared dependencies fail silently in development and loudly in CI. Always run `bun install --frozen-lockfile` after editing `package.json` and commit `bun.lock`.
+
+### 3. Indexer codegen must run before build
+
+The indexer generates TypeScript types from the contract ABI and the GraphQL schema. Run `bun run --cwd apps/s03-indexer codegen` before building `apps/web` or any app that imports indexer types.
+
+The build will error if codegen is stale:
+
+```
+error: could not find a matching export in '/indexer/src/generated'
+```
+
+### 4. No design-token hardcoding
+
+Design tokens live in `packages/ui/tokens.json` and are generated into CSS, TypeScript, and JSON at build time. Never hardcode color values, spacing, or typography—import from `@workspace/ui/tokens` instead.
+
+Running `bun run check:tokens` validates that no hardcoded values sneak in. This check is part of the commit gate.
+
+## Development workflow
+
+Typical flows:
+
+1. **Edit contract bindings:** Regenerate with `bun run contracts:gen:all`, reapply hand-adaptations in `src/generated/`, commit.
+2. **Change contract IDs:** Update `packages/contracts/contracts.json`, run `bun run contracts:gen:all`, commit. CI rebuilds both the indexer and the web app.
+3. **Add an indexer type:** Edit the contract ABI or GraphQL schema, run `bun run --cwd apps/s03-indexer codegen`, then use the new type in `apps/web`.
+4. **Add a UI component:** Create it in `packages/ui`, export from `index.ts`, use in `apps/web`. No separate publish step—Turbo links workspaces.
+
+Every change runs through the commit gate (lint, typecheck, test, build) before it can be committed. See [Local setup](/developers/local-setup) for how to run it.
diff --git a/apps/docs/content/developers/local-setup.mdx b/apps/docs/content/developers/local-setup.mdx
new file mode 100644
index 00000000..af24e112
--- /dev/null
+++ b/apps/docs/content/developers/local-setup.mdx
@@ -0,0 +1,177 @@
+---
+title: Local setup
+description: Clone the repo, install dependencies, run the dev server, and understand the commit gate.
+updated: 2026-08-25
+status: stable
+---
+
+You can run the full SO4 stack locally: the indexer, web app, and all tooling. Everything after a fresh clone requires only `bun install --frozen-lockfile`, a dev server, and understanding the pre-commit checks.
+
+## Prerequisites
+
+**Node.js and Bun:**
+- Node.js ≥ 20 (from `package.json` `engines`)
+- Bun 1.3.13 (from `package.json` `packageManager`)
+
+Install Bun from [bun.sh](https://bun.sh). Verify your versions:
+
+```bash
+node --version # v20.x or later
+bun --version # v1.3.13 or later
+```
+
+**Optional: Stellar CLI** (for regenerating contract bindings)
+
+The indexer codegen and contract binding generation both use the Stellar CLI. If you plan to regenerate bindings or run indexer codegen locally:
+
+```bash
+cargo install stellar-cli --version 22.2.1
+stellar --version # stellar-cli 22.2.1
+```
+
+Without it, development works fine using committed bindings and generated types. You only need it if you're regenerating after a contract upgrade.
+
+## Clone and install
+
+Clone the repository:
+
+```bash
+git clone https://github.com/SO4-Markets/interface.git
+cd interface
+```
+
+Install all dependencies with the frozen lockfile (no updates):
+
+```bash
+bun install --frozen-lockfile
+```
+
+This installs workspaces for all apps and packages. Turbo caches dependencies per workspace, so installing is usually a few seconds even for a large monorepo.
+
+## Running the dev server
+
+Each workspace has its own dev server. Start them in separate terminals:
+
+### Web app
+
+```bash
+bun run --cwd apps/web dev
+```
+
+This starts Vite on http://localhost:5173 with hot reload. The web app reads contract IDs from `apps/web/.env.testnet`.
+
+### Indexer (optional, but recommended)
+
+```bash
+bun run --cwd apps/s03-indexer dev
+```
+
+The indexer syncs contract events from Stellar testnet and serves GraphQL on http://localhost:4000/graphql. The web app prefers indexer data for historical queries; RPC-only reads work but are slower.
+
+The indexer requires a PostgreSQL database locally. If you don't have one running, the indexer will error but the web app still works (it falls back to RPC-only reads).
+
+### Documentation site
+
+```bash
+bun run --cwd apps/docs dev
+```
+
+Docs site runs on http://localhost:3000 with live reload. This is optional—docs are not required for trading locally.
+
+## The commit gate
+
+Before opening a pull request, every commit must pass the commit gate: a sequence of checks that validate correctness, type safety, and test coverage. Run it from the repository root:
+
+```bash
+bun lint # zero errors
+bun typecheck # zero errors
+bun run check:tokens # zero violations
+bun run test # all pass
+bun run test:coverage # all pass, thresholds met
+bun run build # succeeds
+```
+
+If your change touches `apps/s03-indexer` or `apps/web`, also run integration checks:
+
+```bash
+bun run --cwd apps/s03-indexer codegen
+bun run --cwd apps/s03-indexer build
+bun run --cwd apps/s03-indexer test
+SO4_CONTRACTS_REPO="$PWD/apps/s03-indexer/tests/fixtures/contracts-repo" \
+ bun run --cwd apps/s03-indexer sync:contracts:local
+bash scripts/validate-manifest.sh apps/s03-indexer/config/contracts.local.json
+bun run --cwd apps/web typecheck
+bun run --cwd apps/web build
+```
+
+**Key rule: run the whole gate, not a subset.** Even if you only changed a comment, run all commands. Turbo caches aggressively, so the full run is usually just a few seconds.
+
+The gate exists to catch:
+
+- Lint errors (code style)
+- Type errors (TypeScript safety)
+- Design token violations (hardcoded colors, spacing)
+- Broken tests (functionality)
+- Coverage gaps (regression risk)
+- Build failures (dead code, missing types)
+
+Your PR will not merge until CI passes the gate.
+
+## Common failure modes
+
+### "Undeclared dependency" in Bun
+
+Bun's isolated linker is stricter than npm's. If a package is not explicitly declared in your `package.json`, Bun will not find it at runtime, even if npm would.
+
+**Fix:** Add it to the workspace that uses it:
+
+```bash
+cd apps/web
+bun add some-package
+```
+
+Then commit `bun.lock` and re-run the gate.
+
+### "Type not found" after changing contracts
+
+If the indexer or contract bindings changed, generated types are stale. Regenerate them:
+
+```bash
+bun run --cwd apps/s03-indexer codegen
+```
+
+Then re-run `bun typecheck`.
+
+### Bindings regeneration breaks the build
+
+If you ran `bun run contracts:gen:all` and now the build fails, the hand-written adaptations (camelCase fields, helper types) were overwritten.
+
+**Two options:**
+
+1. Revert the regeneration and update the contract IDs only in `contracts.json`, letting CI regenerate with adaptations applied.
+2. Manually reapply the camelCase and helper-type changes to `packages/contracts/src/generated/`.
+
+See [Architecture: Repository invariants](/developers/architecture#repository-invariants) for why this happens.
+
+### Clean-room verification
+
+If you added or upgraded a dependency, run a clean rebuild to ensure nothing was accidentally broken:
+
+```bash
+rm -rf node_modules bun.lock
+bun install --frozen-lockfile
+bun run build
+```
+
+This catches cases where a new dependency silently shadows an old one or changes how the build resolves modules.
+
+## Next steps
+
+Once the dev server is running locally:
+
+- Read [Architecture](/developers/architecture) to understand the data flow.
+- Check [Contract clients](/developers/contract-clients) to call contracts from Node or the browser.
+- See [Writing transactions](/developers/writing-transactions) to submit a trade.
+- Browse [Reading data](/developers/reading-data) for indexer queries.
+
+Questions? Run the commit gate—it will tell you exactly what failed and why.
diff --git a/apps/docs/content/meta.json b/apps/docs/content/meta.json
index d8f22a44..ddce92a0 100644
--- a/apps/docs/content/meta.json
+++ b/apps/docs/content/meta.json
@@ -3,6 +3,8 @@
{
"label": "Developers",
"pages": [
+ "developers/architecture",
+ "developers/local-setup",
"developers/indexer",
"developers/contract-clients",
"developers/reading-data",
@@ -17,7 +19,7 @@
"concepts/liquidation"
]
},
- { "label": "Reference", "pages": ["reference/glossary"] },
+ { "label": "Reference", "pages": ["reference/contracts.generated", "reference/exchange-router", "reference/glossary"] },
{
"label": "Resources",
"pages": [
diff --git a/apps/docs/content/reference/contracts.generated.mdx b/apps/docs/content/reference/contracts.generated.mdx
new file mode 100644
index 00000000..2157e40e
--- /dev/null
+++ b/apps/docs/content/reference/contracts.generated.mdx
@@ -0,0 +1,35 @@
+---
+title: Contract addresses
+description: Deployed SO4 contract IDs per network and their roles.
+updated: 2026-08-25
+status: stable
+---
+
+SO4 contract IDs are source of truth and committed in [`packages/contracts/contracts.json`](https://github.com/SO4-Markets/interface/blob/main/packages/contracts/contracts.json). This page is generated on every build; see [Architecture](/developers/architecture) for why.
+
+## Testnet
+
+| Contract | ID | Explorer | Role |
+|----------|-------|----------|------|
+| `exchange-router` | | [explorer](https://stellar.expert/explorer/testnet/contract/CBD6BQSQFROWIIT5QCYN7KL5LJJWUIH7CEWUSZIFMUJO6NPXE6CVGYNW) | Entry point for all trading operations: opening and closing positions, placing and cancelling orders. |
+| `synthetics-reader` | | [explorer](https://stellar.expert/explorer/testnet/contract/CC6OZUHF3LVO6PNP3V2EB36ORB3YSVYSH3LWD3RFLO4NUO3BYCXSWSYC) | Market snapshots including prices, open interest, funding rates, and oracle state. |
+| `test-faucet` | | [explorer](https://stellar.expert/explorer/testnet/contract/CCWXXBKXHHP5DXC6TYVIL22XUNHD5A75O6WM5D2KM5PY45IOV5VDMARJ) | Testnet utility to distribute test tokens to new traders. |
+| `test-token` | | [explorer](https://stellar.expert/explorer/testnet/contract/CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES) | Testnet representative Stellar asset (TUSDC); all test token contracts share this interface. |
+| `glv-router` | Not deployed | — | Generalized liquidity vault for multi-pool liquidity provision and risk management. |
+
+Use these addresses to:
+- Call contracts from [TypeScript](/developers/contract-clients)
+- Configure the indexer (see [`apps/s03-indexer/config/contracts.testnet.json`](https://github.com/SO4-Markets/interface/tree/main/apps/s03-indexer/config))
+- Submit transactions with [Freighter](https://www.freighter.app/)
+
+## `
+```
+
+Renders a copiable link to the contract on Stellar Expert.
diff --git a/apps/docs/content/reference/exchange-router.mdx b/apps/docs/content/reference/exchange-router.mdx
new file mode 100644
index 00000000..2b6ee320
--- /dev/null
+++ b/apps/docs/content/reference/exchange-router.mdx
@@ -0,0 +1,290 @@
+---
+title: ExchangeRouter contract
+description: Entry point for opening positions, placing orders, and claiming funding fees.
+updated: 2026-08-25
+status: stable
+---
+
+ExchangeRouter is the primary interface for traders and integrators. Every state-changing trading action—opening a position, placing an order, or closing a position—goes through this contract.
+
+## Role and position in the call graph
+
+When you submit a trade, your transaction calls ExchangeRouter, which then:
+
+1. Validates your collateral and approves it (via `OrderVault`).
+2. Encodes your order into `DataStore`.
+3. Emits an event that keepers watch.
+4. Holders can later claim funding fees accrued during your position.
+
+```
+Your transaction (Freighter or wallet)
+ ↓
+ ExchangeRouter.create_order() ← entry point
+ ↓
+ OrderVault (holds your collateral)
+ DataStore (stores the order)
+ ↓
+ Keepers submit execution (offchain)
+ ↓
+ Orders filled, positions opened/closed
+ ↓
+ claim_funding_fees() ← you call this to withdraw accrued fees
+```
+
+ExchangeRouter does not calculate prices, P&L, or liquidation prices. Those come from `SyntheticsReader` and the oracle layer. It is purely the sequencer for order placement and execution.
+
+## Public functions
+
+### `create_order(caller: Address, params: CreateOrderParams) -> OrderKey`
+
+Create a new order and reserve collateral in the OrderVault until the order is filled or cancelled.
+
+**Authorization:** Requires the caller's signature.
+
+**Parameters:**
+
+",
+ decimals: "-",
+ description: "Intermediate markets for multi-hop swaps. Empty for direct orders."
+ },
+ {
+ name: "params.sizeDeltaUsd",
+ type: "i128",
+ decimals: "30",
+ description: "Position size change in USD. Positive for long, negative for short. 1 USD = 10^30 units."
+ },
+ {
+ name: "params.collateralDeltaAmount",
+ type: "i128",
+ decimals: "token-native",
+ description: "Collateral amount change in token units. Use toProtocolAmount() to convert from decimal strings."
+ },
+ {
+ name: "params.triggerPrice",
+ type: "i128",
+ decimals: "30",
+ description: "Stop/take-profit trigger price in USD (30-decimal). Set to 0 for a market order."
+ },
+ {
+ name: "params.acceptablePrice",
+ type: "i128",
+ decimals: "30",
+ description: "Worst acceptable fill price in USD (30-decimal). Used for slippage protection."
+ },
+ {
+ name: "params.executionFee",
+ type: "i128",
+ decimals: "stroops",
+ description: "Keeper execution fee in XLM stroops. Typical values are 1-50 million stroops (0.01–0.5 XLM)."
+ },
+ {
+ name: "params.minOutputAmount",
+ type: "i128",
+ decimals: "token-native",
+ description: "Minimum output token amount for swaps. Set to 0 for position orders."
+ },
+ {
+ name: "params.orderType",
+ type: "OrderType enum",
+ decimals: "-",
+ description: "One of: MarketSwap, LimitSwap, MarketIncrease, LimitIncrease, MarketDecrease, LimitDecrease, StopLossDecrease, Liquidation, StopIncrease."
+ },
+ {
+ name: "params.isLong",
+ type: "boolean",
+ decimals: "-",
+ description: "True for long positions (profit if price rises), false for short."
+ }
+ ]}
+/>
+
+**Returns:** An `OrderKey` (32-byte hex string) that uniquely identifies the order on-chain. Use this key to cancel the order or track it.
+
+**Errors:**
+- `InsufficientCollateral`: Collateral amount is below minimum required.
+- `InvalidOrderType`: Order type is not recognized.
+- `PriceTooHigh` / `PriceTooLow`: Trigger price is outside acceptable range.
+- `ExecutionFeeInsufficient`: Keeper fee is too low.
+
+### `cancel_order(caller: Address, key: OrderKey) -> ()`
+
+Cancel an existing order and return reserved collateral to the caller.
+
+**Authorization:** Requires the order's creator's signature. Only the original caller or a liquidator can cancel.
+
+**Parameters:**
+
+)",
+ decimals: "-",
+ description: "The order key returned by create_order()."
+ }
+ ]}
+/>
+
+**Returns:** None. Collateral is transferred back to the caller's account.
+
+**Errors:**
+- `OrderNotFound`: Order key does not exist.
+- `UnauthorizedCaller`: Caller is not the order creator.
+- `OrderAlreadyFilled`: Order has been executed and cannot be cancelled.
+
+### `claim_funding_fees(caller: Address, markets: Vec, tokens: Vec) -> i128`
+
+Claim accumulated funding fees for closed positions. Funding fees accrue continuously while a position is open and are paid out when the position closes.
+
+**Authorization:** Requires the caller's signature.
+
+**Parameters:**
+
+",
+ decimals: "-",
+ description: "List of market addresses for which to claim fees (e.g., TETH/TUSDC market)."
+ },
+ {
+ name: "tokens",
+ type: "Vec",
+ decimals: "-",
+ description: "List of token addresses that fees will be claimed in (usually TUSDC)."
+ }
+ ]}
+/>
+
+**Returns:** Total amount of fees claimed in the token's native decimal units (e.g., TUSDC has 7 decimals).
+
+**Errors:**
+- `MarketNotFound`: One of the market addresses does not exist.
+- `NoFeesToClaim`: No accrued fees exist for the caller on the specified markets.
+- `TokenMismatch`: One of the fee tokens does not match the market's collateral token.
+
+## Worked example: opening a long position
+
+This example opens a long position on TETH/TUSDC for 10 TUSDC collateral with 2x leverage, using the client from `packages/contracts`.
+
+```ts
+import { ExchangeRouterClient, toProtocolAmount } from "@workspace/contracts"
+import { walletKit } from "@/features/wallet/lib/wallet-kit"
+
+const client = new ExchangeRouterClient({
+ contractId: "CBD6BQSQFROWIIT5QCYN7KL5LJJWUIH7CEWUSZIFMUJO6NPXE6CVGYNW",
+ rpcUrl: "https://soroban-testnet.stellar.org",
+ networkPassphrase: "Test SDF Network ; September 2015",
+})
+
+const collateralToken = "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES" // TUSDC
+const market = "CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4"
+const callerAddress = "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
+
+const params = {
+ receiver: callerAddress,
+ market,
+ initialCollateralToken: collateralToken,
+ swapPath: [],
+ sizeDeltaUsd: toProtocolAmount("20", 30), // 2x leverage on 10 USDC = 20 USD position
+ collateralDeltaAmount: toProtocolAmount("10", 7), // 10 TUSDC (7 decimals)
+ triggerPrice: 0n, // market order
+ acceptablePrice: toProtocolAmount("2500", 30), // accept up to 2500 USD/ETH
+ executionFee: 5_000_000n, // 0.05 XLM
+ minOutputAmount: 0n,
+ orderType: "MarketIncrease",
+ isLong: true,
+}
+
+// Build transaction XDR
+const tx = new TransactionBuilder(account, {
+ fee: BASE_FEE,
+ networkPassphrase: "Test SDF Network ; September 2015",
+})
+ .addOperation(
+ new Operation.InvokeHostFunction({
+ hostFunction: HostFunction.invokeContractFn({
+ contractId: client.contractId,
+ functionName: "create_order",
+ args: createOrderArgs(callerAddress, params),
+ }),
+ footprint: simResult.footprint,
+ }),
+ )
+ .setTimeout(300)
+ .build()
+
+// Sign with Freighter or Wallets Kit
+const signed = await walletKit.signTransaction(tx.toXDR(), "testnet")
+
+// Submit to Stellar
+const response = await fetch("https://horizon-testnet.stellar.org/transactions", {
+ method: "POST",
+ body: new URLSearchParams({ tx: signed }),
+})
+
+const result = await response.json()
+console.log("Order placed:", result.hash)
+```
+
+The transaction succeeds if:
+- Collateral is sufficient and approved.
+- Leverage is within exchange limits (typically 1x to 50x).
+- Prices are within oracle deviation bounds.
+
+Within seconds, a keeper submits the execution, and your position opens at the fill price.
+
+## Generated bindings caveat
+
+The bindings in `packages/contracts/src/generated/exchange-router/` are hand-adapted from `stellar contract bindings typescript`. The committed files use camelCase field names and export `*Args` and `*Val` helpers (like `createOrderArgs()` above) that the raw CLI output does not include.
+
+**Do not regenerate bindings casually.** Running `bun run contracts:gen:all` overwrites these adaptations and breaks the build. See [Architecture](/developers/architecture) for the full context.
+
+If you must regenerate (e.g., after an on-chain contract upgrade), re-apply the camelCase and helper changes immediately so the break is visible in CI, rather than silently breaking downstream code.
+
+## Next steps
+
+- See [Writing transactions](/developers/writing-transactions) for how to simulate, sign, and submit ExchangeRouter calls.
+- Read [Contract clients](/developers/contract-clients) for browser vs. Node differences.
+- Check [Contract addresses](/reference/contracts.generated) for deployed contract IDs.
diff --git a/packages/contracts/contract-descriptions.json b/packages/contracts/contract-descriptions.json
new file mode 100644
index 00000000..e1503db5
--- /dev/null
+++ b/packages/contracts/contract-descriptions.json
@@ -0,0 +1,9 @@
+{
+ "exchange-router": "Entry point for all trading operations: opening and closing positions, placing and cancelling orders.",
+ "synthetics-reader": "Market snapshots including prices, open interest, funding rates, and oracle state.",
+ "data-store": "Persistent storage for market configuration, pool state, and position collateral.",
+ "order-vault": "Holds collateral until an order is filled or cancelled, protecting against withdrawal during execution.",
+ "test-faucet": "Testnet utility to distribute test tokens to new traders.",
+ "test-token": "Testnet representative Stellar asset (TUSDC); all test token contracts share this interface.",
+ "glv-router": "Generalized liquidity vault for multi-pool liquidity provision and risk management."
+}
diff --git a/scripts/generate-contracts-reference.ts b/scripts/generate-contracts-reference.ts
new file mode 100644
index 00000000..dde68d82
--- /dev/null
+++ b/scripts/generate-contracts-reference.ts
@@ -0,0 +1,135 @@
+#!/usr/bin/env bun
+
+/**
+ * Generate the contracts reference page from contracts.json and contract descriptions.
+ * Output: apps/docs/content/reference/contracts.generated.mdx
+ *
+ * This ensures the contracts page always matches the deployed contract IDs
+ * and descriptions are never out of sync. CI verifies the generated file
+ * has not drifted.
+ */
+
+import * as fs from "fs"
+import * as path from "path"
+
+interface ContractBinding {
+ name: string
+ contractId: string | null
+ note?: string
+}
+
+interface ContractsConfig {
+ network: {
+ name: string
+ rpcUrl: string
+ networkPassphrase: string
+ }
+ outputDir: string
+ bindings: ContractBinding[]
+}
+
+async function generate() {
+ const contractsJsonPath = path.join(
+ process.cwd(),
+ "packages/contracts/contracts.json"
+ )
+ const descriptionsPath = path.join(
+ process.cwd(),
+ "packages/contracts/contract-descriptions.json"
+ )
+ const outputPath = path.join(
+ process.cwd(),
+ "apps/docs/content/reference/contracts.generated.mdx"
+ )
+
+ // Read contracts.json
+ let contractsConfig: ContractsConfig
+ try {
+ const content = fs.readFileSync(contractsJsonPath, "utf-8")
+ contractsConfig = JSON.parse(content)
+ } catch (err) {
+ console.error(`Failed to read ${contractsJsonPath}:`, err)
+ process.exit(1)
+ }
+
+ // Read descriptions
+ let descriptions: Record = {}
+ try {
+ const content = fs.readFileSync(descriptionsPath, "utf-8")
+ descriptions = JSON.parse(content)
+ } catch (err) {
+ console.error(`Failed to read ${descriptionsPath}:`, err)
+ process.exit(1)
+ }
+
+ // Generate MDX rows
+ const rows = contractsConfig.bindings
+ .map((binding) => {
+ const description = descriptions[binding.name] || "No description"
+ const network = contractsConfig.network.name
+ const explorerUrl =
+ binding.contractId && network === "testnet"
+ ? `https://stellar.expert/explorer/testnet/contract/${binding.contractId}`
+ : null
+
+ if (!binding.contractId) {
+ return `| \`${binding.name}\` | Not deployed | — | ${description} |`
+ }
+
+ const contractLink = explorerUrl
+ ? ``
+ : `\`${binding.contractId}\``
+
+ return `| \`${binding.name}\` | ${contractLink} | [explorer](${explorerUrl}) | ${description} |`
+ })
+ .join("\n")
+
+ // Generate MDX content
+ const mdxContent = `---
+title: Contract addresses
+description: Deployed SO4 contract IDs per network and their roles.
+updated: 2026-08-25
+status: stable
+---
+
+SO4 contract IDs are source of truth and committed in [\`packages/contracts/contracts.json\`](https://github.com/SO4-Markets/interface/blob/main/packages/contracts/contracts.json). This page is generated on every build; see [Architecture](/developers/architecture) for why.
+
+## Testnet
+
+| Contract | ID | Explorer | Role |
+|----------|-------|----------|------|
+${rows}
+
+Use these addresses to:
+- Call contracts from [TypeScript](/developers/contract-clients)
+- Configure the indexer (see [\`apps/s03-indexer/config/contracts.testnet.json\`](https://github.com/SO4-Markets/interface/tree/main/apps/s03-indexer/config))
+- Submit transactions with [Freighter](https://www.freighter.app/)
+
+## \`
+\`\`\`
+
+Renders a copiable link to the contract on Stellar Expert.
+`
+
+ // Write the generated file
+ try {
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
+ fs.writeFileSync(outputPath, mdxContent, "utf-8")
+ console.log(`✓ Generated ${outputPath}`)
+ } catch (err) {
+ console.error(`Failed to write ${outputPath}:`, err)
+ process.exit(1)
+ }
+}
+
+generate().catch((err) => {
+ console.error("Generation failed:", err)
+ process.exit(1)
+})