diff --git a/apps/docs/content/developers/contract-clients.mdx b/apps/docs/content/developers/contract-clients.mdx new file mode 100644 index 00000000..9f6c8b84 --- /dev/null +++ b/apps/docs/content/developers/contract-clients.mdx @@ -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). diff --git a/apps/docs/content/developers/indexer.mdx b/apps/docs/content/developers/indexer.mdx new file mode 100644 index 00000000..11fb7265 --- /dev/null +++ b/apps/docs/content/developers/indexer.mdx @@ -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). diff --git a/apps/docs/content/developers/reading-data.mdx b/apps/docs/content/developers/reading-data.mdx new file mode 100644 index 00000000..c8089afa --- /dev/null +++ b/apps/docs/content/developers/reading-data.mdx @@ -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). diff --git a/apps/docs/content/developers/writing-transactions.mdx b/apps/docs/content/developers/writing-transactions.mdx new file mode 100644 index 00000000..1a2ea8a7 --- /dev/null +++ b/apps/docs/content/developers/writing-transactions.mdx @@ -0,0 +1,126 @@ +--- +title: Write SO4 transactions +description: Build, simulate, assemble, sign, submit, confirm, and recover SO4 Soroban transactions using the web application's patterns. +updated: 2026-08-25 +status: stable +--- + +A reliable SO4 write builds from a fresh account, simulates, assembles the returned resources and authorisation, asks a wallet to sign, submits once, and confirms by transaction hash. + +## Open a position end to end + +This browser example follows `apps/web/src/features/trade/lib/stellar.ts` and `apps/web/src/lib/soroban/`. `buildCreateOrderTransaction` performs simulation and assembly; `prepareAndSign` prepares against fresh state and signs; `sendAndPoll` submits and confirms. + +```ts +import { Networks } from "@stellar/stellar-sdk" +import { buildCreateOrderTransaction } from "@/lib/contracts" +import { prepareAndSign } from "@/lib/soroban/tx-builder" +import { sendAndPoll } from "@/lib/tx-builder" +import { walletKit } from "@/features/wallet/lib/wallet-kit" + +const account = "GC55CL7SJMIYGYHH3RC4PPF5KMHGYUE3MK6DH6MFHYITWBL2IYRJ3UUG" +const tx = await buildCreateOrderTransaction(account, { + receiver: account, + market: "CCBUUSYZJTGVA6PYUNQDFPZFHTBZ2QSHOUO7YAGRQVA46T3ZLSIYULS4", + initialCollateralToken: + "CBAN5YU3KRDKPTQ2H76D6S7HQFPRBGUD524F65BUM2RQCITPTRLKWKES", + swapPath: [], + sizeDeltaUsd: 20n * 10n ** 30n, + collateralDeltaAmount: 10n * 10n ** 7n, + triggerPrice: 0n, + acceptablePrice: 100_000n * 10n ** 30n, + executionFee: 0n, + minOutputAmount: 0n, + orderType: "MarketIncrease", + isLong: true, +}) + +const signedXdr = await prepareAndSign(tx, walletKit, Networks.TESTNET) +const result = await sendAndPoll(signedXdr) +console.log(result.status, result.hash) +``` + +The equivalent testnet market-increase multicall was executed on 25 August 2026. It transferred 10 TUSDC to the order vault and created order key `172d5a0afe2f7766af89ef88da7f62aba6ab0e0744b67cb0d72cf2c8d9ab1497`: + +```text +SUCCESS 3b24ce253249555072e6d96b835e2f7f61500e6783e9bc9cebbed5852ccb3822 +``` + +The transaction is visible on [Stellar Expert](https://stellar.expert/explorer/testnet/tx/3b24ce253249555072e6d96b835e2f7f61500e6783e9bc9cebbed5852ccb3822). A keeper executes the pending market order in a later transaction; order creation and execution are deliberately separate protocol states. + +## Simulation is mandatory + +Simulation executes the call without committing it. It returns the Soroban resource fee, read/write footprint, authorisation entries, return value, and diagnostic events. `rpc.assembleTransaction` copies that footprint, authorisation, and resource fee into a new transaction. Signing the original unassembled transaction discards the information the network needs. + +The footprint lists ledger keys the call may read or write. Integrators normally inspect its size for cost and unexpected access, not hand-edit it. Authorisation entries describe nested contract invocations that an address permits. Wallets display and sign those entries after simulation; a changed argument or footprint requires a new simulation and signature. + +```ts +const simulation = await sorobanRpc.simulateTransaction(raw) +if (rpc.Api.isSimulationError(simulation)) { + throw new Error(`Transaction simulation failed: ${simulation.error}`) +} +const prepared = rpc.assembleTransaction(raw, simulation).build() +``` + +Read a failure from the outside inward: the client prefix, `simulation.error`, then diagnostic events. A reproduced invalid call is reported by the repository helper as: + +```text +Transaction simulation failed: HostError: Error(Contract, #...) +``` + +The numeric contract error maps through `packages/contracts/src/errors.ts`; keep the original diagnostic text in logs because the same UI message can have several causes. + +## Freighter and Wallets Kit + +Freighter signs directly with `signTransaction(xdr, { networkPassphrase })`. Wallets Kit first selects one of several wallet modules, then exposes the same `signedTxXdr` result through `kit.signTransaction`. The transaction lifecycle does not change; only wallet discovery, connection, and network-selection UI differ. `prepareAndSign` accepts the shared `SigningWallet` shape, so feature code stays wallet-agnostic. + +Never ask either wallet to sign before assembly, and always pass the exact testnet passphrase used to build the transaction. + +## Failure handling and real messages + +### Simulation failure + +No transaction hash exists because nothing was broadcast. Preserve the contract diagnostic and rebuild only after correcting input or refreshing stale state: + +```text +Transaction simulation failed: HostError: Error(Contract, #...) +``` + +### Submission rejection + +`sendTransaction` can return `ERROR` immediately. The app throws `TxFailedError` and retains diagnostic events. An overloaded node produces the repository's exact message: + +```text +RPC node is overloaded (TRY_AGAIN_LATER). Please retry shortly. +``` + +For `ERROR`, do not blindly resubmit. Inspect the result, reload the source account, simulate again, and obtain a fresh signature. + +### Confirmation timeout + +The app polls after 1, 2, 4, and at most 8 seconds until its deadline. Its reproduced timeout text is: + +```text +Transaction not confirmed within 30000 ms +``` + +A timeout is unknown state, not failure. Retain the hash and continue recovery rather than creating a second order. + +### Success but the client lost the result + +Persist the hash as soon as `sendTransaction` returns `PENDING` or `DUPLICATE`. On restart or reconnect, call: + +```ts +const recovered = await sorobanRpc.getTransaction(savedHash) +if (recovered.status === "SUCCESS") { + // Decode recovered.returnValue, then invalidate positions and orders. +} else if (recovered.status === "FAILED") { + // Display the terminal failure and its diagnostic events. +} else { + // NOT_FOUND: keep polling within RPC retention; do not submit a duplicate yet. +} +``` + +After `SUCCESS`, query the order by its returned key or transaction hash through the indexer, then refresh current positions through the contract reader. If the indexer is behind, the on-chain transaction result remains authoritative and the UI should show history as pending indexing. The [data-reading guide](/developers/reading-data) explains that merge and staleness policy. + +Fee bumping and sponsored reserves are intentionally not covered here. diff --git a/apps/docs/content/meta.json b/apps/docs/content/meta.json index 835dd920..d8f22a44 100644 --- a/apps/docs/content/meta.json +++ b/apps/docs/content/meta.json @@ -1,5 +1,14 @@ { "sections": [ + { + "label": "Developers", + "pages": [ + "developers/indexer", + "developers/contract-clients", + "developers/reading-data", + "developers/writing-transactions" + ] + }, { "label": "Concepts", "pages": [ diff --git a/packages/contracts/src/index.test-d.ts b/packages/contracts/src/index.test-d.ts index 91f730e7..4d53874a 100644 --- a/packages/contracts/src/index.test-d.ts +++ b/packages/contracts/src/index.test-d.ts @@ -1,6 +1,8 @@ import { expectTypeOf, test } from "vitest" import { i128ToScVal, + fromProtocolAmount, + toProtocolAmount, parseSorobanError, ExchangeRouterClient, SyntheticsReaderClient, @@ -23,6 +25,8 @@ test("public SDK exports retain their callable types", () => { expectTypeOf(parseSorobanError).parameter(0).toEqualTypeOf() expectTypeOf(parseSorobanError).returns.toEqualTypeOf() expectTypeOf(i128ToScVal).parameter(0).toEqualTypeOf() + expectTypeOf(toProtocolAmount).returns.toEqualTypeOf() + expectTypeOf(fromProtocolAmount).returns.toEqualTypeOf() }) test("client classes are constructors", () => { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 21260d61..3ab69099 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,6 +1,7 @@ export type { NetworkConfig } from "./types" export { parseSorobanError, ORDER_EXECUTION_FROZEN_MESSAGE } from "./errors" export { i128ToScVal } from "./scval" +export { fromProtocolAmount, toProtocolAmount } from "./scaling" export { referralCodeToScVal, scValToReferralCode } from "./soroban/referral-code" export {