Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions apps/docs/content/developers/contract-clients.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
title: Call SO4 contract clients
description: Use the committed SO4 testnet configuration and generated TypeScript bindings for browser and Node contract reads.
updated: 2026-08-25
status: stable
---

Use the clients exported by `@workspace/contracts` with the committed contract IDs; they simulate read-only calls and decode Soroban values into typed JavaScript values.

> **Generated-binding caveat:** `packages/contracts/src/generated` is checked in but hand-adapted. The files contain camel-case fields and helper types used by `src/clients`. Running `bun run contracts:gen:all` overwrites those adaptations. Do not regenerate bindings unless the same change reapplies the adaptations or updates every dependent client.

## Network and client

This runnable Node example reads the TETH/TUSDC market. Every address comes from `packages/contracts/contracts.json` or `apps/s03-indexer/config/contracts.testnet.json`; no contract-address environment variable is involved.

```ts
import { SyntheticsReaderClient } from "@workspace/contracts"

const reader = new SyntheticsReaderClient({
contractId: "CC6OZUHF3LVO6PNP3V2EB36ORB3YSVYSH3LWD3RFLO4NUO3BYCXSWSYC",
rpcUrl: "https://soroban-testnet.stellar.org",
networkPassphrase: "Test SDF Network ; September 2015",
dataStore: "CCZ3VKBEDLNBO2JM3EXL3SNBDJOV5BTN52FVQPER7F6D5GCE53PITQ3J",
oracle: "CBEMTV23SIJJBIST3V5HTMWHR4MHYGHNBIG4M26U4LGUJTWZXTFSVQEY",
orderHandler: "CC35OFZVWUTAZPV3B6UKSDVAVORZEWUUMOMTHO33H4YR4C5FKPEFODKY",
})

console.log(
await reader.getMarket(
"CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4"
)
)
```

Executed from `packages/contracts` against testnet on 25 August 2026, it returned:

```text
{
marketToken: "CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4",
indexToken: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU",
longToken: "CAJ6BZKGFT47ALGMVFZZGAOXBV2RWIVYVCU4WJCQIURKRNXU346RWVAU",
shortToken: "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES"
}
```

The generated reader builds a read-only transaction from the all-zero dummy account, calls `simulateTransaction`, checks `isSimulationError`, and decodes `result.retval`. No key or funded account is needed for these view calls.

## Scaling without floating-point loss

Contract values are `bigint`. Use the shared helpers rather than multiplying JavaScript numbers:

```ts
import {
fromProtocolAmount,
toProtocolAmount,
} from "@workspace/contracts"

const collateral = toProtocolAmount("10.25", 7)
console.log(collateral) // 102500000n
console.log(fromProtocolAmount(collateral, 7)) // "10.25"
```

USD and oracle values use 30 decimals in the trading client; token decimals come from token metadata. Keep values as strings or `bigint` until display formatting.

## Browser and Node are different

The read call is the same in both environments, but imports and signing are not.

```ts
// Browser/Vite: client-safe exports, public RPC, wallet owns the key.
import { SyntheticsReaderClient } from "@workspace/contracts"
import { walletKit } from "@/features/wallet/lib/wallet-kit"

// Node: no window, extension, or Vite import.meta.env is available.
// Import the package client and supply committed network configuration directly.
```

In the browser, never import a secret key or Node-only module; Freighter or Wallets Kit signs prepared XDR. In Node, a read-only reader needs no signer, while a write script must obtain its signer from a secure server-side source. The web-only singleton in `apps/web/src/lib/soroban/client.ts` reads `import.meta.env`, so do not import it into Node scripts.

Next, choose among live and historical sources in [Reading SO4 data](/developers/reading-data). Transaction construction belongs in [Writing transactions](/developers/writing-transactions).
82 changes: 82 additions & 0 deletions apps/docs/content/developers/indexer.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
title: Run the indexer locally
description: Build, configure, and troubleshoot the SO4 SubQuery indexer with the same commands used by repository integration checks.
updated: 2026-08-25
status: stable
---

The SO4 indexer turns contract events into queryable historical records; the web app uses those records for positions, orders, markets, and activity while contracts remain the source of current protocol state.

## What runs

`apps/s03-indexer` maps Stellar and Soroban events into the entities in `schema.graphql`. Docker Compose starts PostgreSQL on port 5432, the SubQuery node, and the GraphQL query service on port 3000. The web app can operate without the indexer, but it then loses indexed history and falls back to contract reads.

## Clean setup

Run the integration-check sequence from the repository root. Do not substitute the root aliases: this sequence is the executable contract shared by this guide, `AGENTS.md`, and CI.

```bash
bun install --frozen-lockfile
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
```

Then start the services:

```bash
bun run --cwd apps/s03-indexer start
```

Open `http://localhost:3000` after all health checks pass. The generated `apps/s03-indexer/src/types` directory is intentionally gitignored. `codegen` must recreate it before any compile, including on a clean checkout.

## Contract manifests

`config/contracts.testnet.json` is the committed testnet deployment. `sync:contracts:local` reads deployment artifacts from the fixture contract repository and writes `config/contracts.local.json`. Validate either manifest before using it:

```bash
bash scripts/validate-manifest.sh apps/s03-indexer/config/contracts.local.json
```

To use another deployment, point `SO4_CONTRACTS_REPO` at a contracts checkout containing its `.deployed` and `.stellar/contract-ids` artifacts. Select a non-default manifest at runtime with `INDEXER_CONTRACTS_CONFIG`; do not edit contract IDs into `project.ts`.

## Real failure text

### Generated types are missing

A clean compile before code generation reports missing modules beneath `src/types`, such as:

```text
error TS2307: Cannot find module '../types/models' or its corresponding type declarations.
```

Run `bun run --cwd apps/s03-indexer codegen`, then build again. Do not commit the generated directory.

### The manifest is invalid

The validator identifies the field and exits non-zero. A malformed address produces:

```text
ERROR: contracts.exchange_router must be a valid Stellar contract ID (C...)
```

Regenerate the manifest from the intended deployment rather than weakening validation.

### Contract sync cannot find deployment output

An incorrect repository path produces an error naming the missing artifact, for example:

```text
No deployment artifacts found for network "local"
```

Confirm `SO4_CONTRACTS_REPO`, the selected network, and the deployment files. A warning about absent `MARKET_TOKEN_*` values is different: markets may not have been bootstrapped yet, while missing core contracts are fatal.

### Docker services do not become healthy

Use `docker compose -f apps/s03-indexer/docker-compose.yml ps` and inspect the failing service. PostgreSQL must be healthy before the node starts, and the node must be ready before GraphQL starts. Port conflicts on 5432 or 3000 must be resolved outside the compose file so local commands continue to match integration checks.

The [data-reading guide](/developers/reading-data) shows how GraphQL results differ from live contract state. The complete schema is the [GraphQL reference source](https://github.com/SO4-Markets/interface/blob/main/apps/s03-indexer/schema.graphql).
115 changes: 115 additions & 0 deletions apps/docs/content/developers/reading-data.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
---
title: Read SO4 data
description: Choose between Stellar RPC, the SO4 indexer, and contract readers using measured latency, history, and trust trade-offs.
updated: 2026-08-25
status: stable
---

Use contract readers for current computed protocol state, RPC for low-level ledger data and recent events, and the indexer for filtered history and relationships.

## Choosing a read path

Measurements below were taken from this repository in Lagos on 25 August 2026. Five sequential public-testnet `getLatestLedger` calls took 2,078, 836, 847, 504, and 568 ms (836 ms median). A generated `getMarket` reader simulation took 2,303 ms. Local GraphQL latency depends on the contributor's machine and dataset; measure it with the command below rather than treating a local number as a network guarantee.

| Path | Measured latency | Historical depth | Trust requirement |
| ------------------------- | ------------------------------------------------------------: | --------------------------------------------------------------------- | --------------------------------------------------------------------- |
| Stellar RPC | 836 ms median | Current state and the node's retained recent event/transaction window | The selected RPC node's response and Stellar consensus proof context |
| Generated contract reader | 2,303 ms | Current contract state only | The RPC node plus the deployed reader and contracts |
| Local indexer GraphQL | Measure locally with `curl`; normally one database round trip | Everything this indexer has processed and retained | Stellar, mapping code, manifest, database, and current indexed height |

```bash
curl -sS -o /tmp/so4-graphql.json \
-w 'latency_seconds=%{time_total}\n' \
-H 'content-type: application/json' \
--data '{"query":"{ markets(first: 1) { nodes { key name } } }"}' \
http://localhost:3000
```

## RPC: ledger and recent events

```ts
import { rpc } from "@stellar/stellar-sdk"

const server = new rpc.Server("https://soroban-testnet.stellar.org")
const latest = await server.getLatestLedger()
console.log({
sequence: latest.sequence,
protocolVersion: latest.protocolVersion,
})
```

Observed output:

```text
{ sequence: 4327793, protocolVersion: 27 }
```

Use `getEvents` with `startLedger` and carry the returned cursor into the next request. RPC is useful for recent order events, but it is not a replacement for complete position history.

## Contract reader: positions, orders, and markets

Construct `SyntheticsReaderClient` as shown in [Call SO4 contract clients](/developers/contract-clients), then call:

```ts
const market = await reader.getMarket(TETH_TUSDC_MARKET)
const positions = await reader.getAccountPositions(account, 1, 20)
const orders = await reader.getAccountOrders(account, 1, 50)

console.log(market)
console.log({ positionCount: positions.length, orderCount: orders.length })
```

`getAccountPositions` returns current enriched PnL, fees, and liquidation price. `getAccountOrders` returns current orders. Page numbers start at 1. The market call's executed output appears on the contract-client page.

## Indexer: GraphQL history and cursors

The web app's typed queries live in `apps/web/src/lib/graphql/queries.ts`. A direct request can page positions without loading the entire account history:

```ts
const query = `
query Positions($account: String!, $first: Int!, $after: Cursor) {
positions(
filter: { account: { equalTo: $account } }
orderBy: UPDATED_TIMESTAMP_DESC
first: $first
after: $after
) {
nodes { key status sizeUsd updatedTimestamp }
pageInfo { endCursor hasNextPage }
}
}
`

let after: string | null = null
do {
const response = await fetch("http://localhost:3000", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ query, variables: { account, first: 50, after } }),
})
if (!response.ok) throw new Error(`GraphQL ${response.status}`)
const { data, errors } = await response.json()
if (errors?.length)
throw new Error(
errors.map((e: { message: string }) => e.message).join(", ")
)
console.log(data.positions.nodes)
after = data.positions.pageInfo.hasNextPage
? data.positions.pageInfo.endCursor
: null
} while (after)
```

Replace `positions` with `orders` or `markets` and select fields from the [GraphQL schema reference](https://github.com/SO4-Markets/interface/blob/main/apps/s03-indexer/schema.graphql).

## Polling, streaming, and what the app does

Stellar RPC has no WebSocket stream. `useOrderEventPolling.ts` polls order events every 5 seconds and advances a cursor. Indexed positions use a 5-second stale time and 10-second refetch interval; fresh contract values are merged into them every 10 seconds. The contract-only fallback uses a 10-second stale time and 15-second refetch interval. Orders refetch every 15 seconds. Live oracle bars use a stream first and switch permanently to 1.5-second polling when no message arrives within 4 seconds.

## Availability and staleness

- **RPC error:** retain the last rendered value, mark it stale, and retry with bounded backoff. Do not silently convert an error to an empty position list.
- **Indexer unavailable:** `executeGraphQLQuery` reports HTTP status, GraphQL errors, or a missing `data` field. The app disables indexer queries when no GraphQL URL exists and falls back to contract reads.
- **Indexer lag:** compare the latest indexed entity ledger or SubQuery metadata height with `getLatestLedger().sequence`. Display the indexed value as stale when the gap exceeds the product's tolerance; for actionable PnL, use the fresh reader result even when history is available.

For local GraphQL setup, continue with [Run the indexer locally](/developers/indexer).
Loading
Loading