> For the complete documentation index, see [llms.txt](https://streamlock.gitbook.io/streamlock-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://streamlock.gitbook.io/streamlock-docs/developers/operator-guide.md).

# Operator Guide

## Streamlock Operator Guide

**Audience:** game developers and protocol integrators building on Streamlock's conviction-locking primitive. **Status:** Live on mainnet-beta as of 2026-04-28. **SDK:** [`@streamlock/operator-sdk`](https://github.com/0xNike/streamlockfun/tree/main/packages/operator-sdk) v0.1.0 **Companion docs:**

* [`docs/architecture/OPERATOR_API.md`](https://github.com/0xNike/streamlockfun/blob/main/docs/architecture/OPERATOR_API.md) — full HTTP API spec.
* OpenAPI 3.1 schema at `https://www.streamlock.fun/v1/openapi.json`.

This is the only doc you need to integrate with Streamlock. Concepts, onboarding, SDK reference, and the raw HTTP fallback all live here.

***

### Table of contents

**Part 1 — Concepts**

1. [What an operator is](#1-what-an-operator-is)
2. [The trust model — what Streamlock never does](#2-the-trust-model--what-streamlock-never-does)
3. [Mental model — the lifecycle of a game](#3-mental-model--the-lifecycle-of-a-game)

**Part 2 — Onboarding**

4. [Becoming an operator](#4-becoming-an-operator)
5. [Security responsibilities](#5-security-responsibilities)
6. [The two identities](#6-the-two-identities)

**Part 3 — Integration with the TypeScript SDK**

7. [Install and quickstart](#7-install-and-quickstart)
8. [Configuration](#8-configuration)
9. [The five namespaces](#9-the-five-namespaces)
10. [The `.build*()` escape hatches](#10-the-build-escape-hatches)
11. [Idempotency](#11-idempotency)
12. [Error handling, retries, and backoff](#12-error-handling-retries-and-backoff)
13. [Testing locally](#13-testing-locally)
14. [Type imports](#14-type-imports)

**Part 4 — HTTP surface (for non-TS callers)**

15. [Authentication, envelope, and error codes](#15-authentication-envelope-and-error-codes)
16. [Endpoint reference](#16-endpoint-reference)
17. [Rate limits](#17-rate-limits)

**Part 5 — Limits, scope, and what's next**

18. [Hard limits to plan around](#18-hard-limits-to-plan-around)
19. [v1 scope notes](#19-v1-scope-notes)
20. [Common pitfalls](#20-common-pitfalls)
21. [Versioning](#21-versioning)
22. [Where to go from here](#22-where-to-go-from-here)

***

## Part 1 — Concepts

### 1. What an operator is

A Streamlock **operator** is any third party that runs a zero-sum game whose outcome rewrites entitlement on Streamlock streams.

Every locked stream on Streamlock carries an **entitlement ledger** — a list of `(holder → bps)` rows that sum to exactly 10,000. At unlock, SOL proceeds from the sale of that stream's tokens are paid out pro-rata against the ledger's bps. The operator's job is to mutate that ledger in zero-sum increments.

A few examples of what an operator might run:

* **Poker / PvP card games** — N players ante into a pot, the winner's bps in the host stream go up, losers' bps go down.
* **Prediction markets** — wrong-side bps drain to right-side bps after the resolution oracle fires.
* **Fantasy leagues / leaderboards** — end-of-season bps redistribution by score rank.
* **Skill challenges / asynchronous PvP** — anything you can score and zero-sum.

Streamlock provides the primitive (locked tokens + ledger + settlement). The operator provides the game.

***

### 2. The trust model — what Streamlock never does

This is the single most important section. Read it twice.

* **We never custody operator signing keys.** The operator's Solana keypair lives entirely on the operator's infrastructure (Keypair, HSM, remote signer, anywhere with a signing callback).
* **We never sign on-chain transactions on the operator's behalf.** Every `create_game_session`, `submit_results`, `finalize_results`, `apply_delta_to_ledger`, and `cancel_session` instruction is signed by the operator's key client-side.
* **We never accept raw instruction data.** Operators submit semantic inputs (a participants list, a scoresheet) and we return an unsigned `VersionedTransaction`. We control the instruction shape; you control the signature.
* **We never let one operator's API key build a tx that signs as another operator's pubkey.** The whitelist binds API key → pubkey 1:1; tx-builder routes refuse mismatches.
* **We never put your API key in a URL.** The hosted WSS feed authenticates via a first-message frame, not a query string, so keys never land in CDN or proxy logs.

What this means in practice: the worst that can happen if our HTTP API is compromised is that an attacker can read your session metadata. They cannot move SOL, cannot rewrite entitlement, cannot impersonate you.

***

### 3. Mental model — the lifecycle of a game

```
   ┌───────────────┐
   │  Discover     │   GET /v1/operator/tokens/:mint/streams
   │  eligible     │   ──────────────────────────────────────►
   │  players      │
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │  Create       │   POST /v1/operator/sessions
   │  session      │   ──── unsigned tx ───►  operator signs
   │               │   ◄────── confirmed signature ──────────
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │  Run game     │   (off-chain — your code)
   │  off-chain    │   produces a zero-sum scoresheet:
   │               │   loser bps go down, winner bps go up,
   │               │   sum across all deltas == 0
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │  Submit       │   POST /v1/operator/sessions/:id/submit
   │  scoresheet   │   server validates zero-sum + chunks
   │               │   into 50-entry packs → returns N txs
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │  Wait dispute │   server-enforced disputeWindowSec
   │  window       │   (no automatic disputes in v1)
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │  Finalize +   │   POST /v1/operator/sessions/:id/finalize
   │  apply deltas │   then per-delta apply-delta calls
   │               │   (or use SDK's finalizeAndApplyAll helper)
   └───────┬───────┘
           │
           ▼
   ┌───────────────┐
   │  Stream       │   when token enters UNLOCK phase, anyone
   │  unlocks /    │   can call settle_stream + claim_proceeds
   │  settle       │   (ledger drives payout, not the buyer)
   └───────────────┘
```

Two things to internalize:

1. **The ledger is what matters.** Tokens don't move during a game — only the bps row a holder owns. SOL only flows at unlock.
2. **Zero-sum is enforced server-side and on-chain.** A scoresheet whose deltas don't sum to exactly 0 will be rejected before it ever hits Solana, and `apply_delta_to_ledger` re-validates on-chain. There is no path for an operator to mint bps out of thin air.

***

## Part 2 — Onboarding

### 4. Becoming an operator

Onboarding is **manual** for the hackathon and immediate post-hackathon period. Self-serve enrollment is a fast-follow.

#### Step 1 — Contact us

Reach out via either channel — both go to the team:

* Email <streamlockfun@gmail.com>, or
* Telegram [@hao\_ssh](https://t.me/hao_ssh) (Hao, founder)

Include:

* Your Solana pubkey (the on-chain signer that will appear in `game_session.operator`).
* Display name + a one-paragraph description of what you're building.
* Token mints you want scoped access to (or "any" for unrestricted within your tier).
* Expected request volume (sessions/hour, deltas/hour) so we can pick a rate-limit tier.

#### Step 2 — We provision

A Streamlock admin enrolls you via `/admin/operators`:

* Approves your pubkey, sets `status = active`.
* Issues an API key in `sk_<base64url>` format. **This key is shown exactly once.**
* Sets `allowedTokenMints` (optional scoping field).
* Picks a rate-limit tier (default: 120 writes/min keyed on your API key).

You'll receive the key out-of-band (encrypted email, signal, etc.). After that single reveal we only store the 11-character prefix; lose the key and we cannot recover it — we can only revoke and reissue.

> **Note:** v1 keys are not chain-scoped. The same `sk_…` works against both devnet and mainnet — the network you hit is determined by the `chain` you set in your client. We may add chain-scoped keys later; for now, treat the key as a credential to your operator account, not to a specific cluster.

#### Step 3 — Verify

Confirm the key is live with a read against the v1 namespace:

```bash
curl -H "Authorization: Bearer sk_<your_key>" \
     "https://www.streamlock.fun/v1/operator/tokens/<some_mint>/streams"
```

A 200 with `meta.chain: "mainnet"` means you're cleared for takeoff. A 401 with `error.code: "malformed_key"` means you copy-pasted wrong (mind whitespace). A 403 with `error.code: "not_whitelisted"` means our enrollment hasn't propagated — wait 30s and retry.

***

### 5. Security responsibilities

The operator key is the only thing standing between an attacker and your game's state. Treat it accordingly.

#### API key handling

* **Store in a secrets manager.** AWS Secrets Manager, GCP Secret Manager, Vercel project envs, 1Password CLI — anywhere with audit logging and rotation. Not in git, not in `.env` files committed to repos, not in CI logs.
* **Never log it.** The SDK's `describe()` method returns a redacted prefix specifically so you can debug config without leaking the secret.
* **Never expose it to a browser.** The Operator API is a server-to-server interface. If your game has a frontend, the frontend talks to your backend, your backend talks to Streamlock. A leaked key in client JS is game over.
* **Rotate on team changes.** If anyone with key access leaves, request a new key and revoke the old one via `/admin/operators`.
* **Rotate on any suspicion of compromise.** False positive cost is 30 minutes of your time. False negative cost is your entire session history being mutated by an attacker.

#### Solana keypair handling

The pubkey on `operator_whitelist` and the secret key your `signer` callback uses must match. If they don't, the server-built tx will reject your signature.

* **Use a dedicated keypair for the operator role.** Don't reuse a personal wallet — segregate by purpose.
* **Prefer an HSM or remote signer in production.** Local file-on-disk keypairs are fine for dev and small-scale; serious volume should use a signing service.
* **Fund a working balance of \~0.05 SOL per active session.** Mainnet Test 15 cost 0.0284 SOL across 5 txs and the cost is dominated by PDA rent (most of which is refunded on session close + claim). Keep balances small; top up as needed.

#### Network handling

* **Pin TLS to the official host.** `https://www.streamlock.fun` is the only authoritative production endpoint. There is no `api.streamlock.fun` subdomain (yet).
* **Validate the envelope on every response.** Every v1 response is `{ data, error, meta: { chain, asOf, requestId } }`. If `meta.chain` doesn't match what you configured, you've crossed wires — fail loud.
* **Capture `meta.requestId` in your logs.** When you need our help debugging, that ID is the only fast path to finding the request in our logs.

#### SDK consumer checklist

* [ ] `apiKey` is loaded from a secrets manager or process env, never hardcoded.
* [ ] No raw `console.log(cfg.apiKey)` in your code. Use `op.describe()`.
* [ ] The operator keypair is segregated from any other wallet you operate (separate file, separate HSM slot).
* [ ] `signer` callback does not log the signed tx bytes (which include your signature on a real on-chain action).
* [ ] You catch `OperatorApiError` and `ZeroSumError` distinctly from generic `Error`.
* [ ] You honor `Retry-After` on 429s — no hot-loop retries.
* [ ] You log `meta.requestId` (or the equivalent on errors) so we can find the request when debugging.
* [ ] Your CI does not echo env vars on failure.
* [ ] Your codebase doesn't commit `.env` files containing real keys.

***

### 6. The two identities

Every operator has exactly two credentials:

|                              | **Solana pubkey**                                                                                                              | **API key**                                                                |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| **What it is**               | A `base58` address derived from your operator keypair.                                                                         | A 35-char secret in `sk_<base64url>` format.                               |
| **What it signs/authorizes** | On-chain instructions: `create_game_session`, `submit_results`, `finalize_results`, `apply_delta_to_ledger`, `cancel_session`. | HTTP requests against `/v1/operator/*` and the WSS feed.                   |
| **Where it lives**           | `game_session.operator` field on-chain (enforced by `has_one = operator`).                                                     | `operator_whitelist.apiKeyHash` row in our database.                       |
| **Rotated by**               | You generate a new keypair → email us → admin updates `operator_whitelist.pubkey`.                                             | Self-service via `/admin/operators` once your account is provisioned.      |
| **Revoked by**               | You can't revoke the keypair globally; rotating to a new one means the old one stops being whitelisted.                        | Admin sets `revokedAt`; the prefix is blacklisted server-side immediately. |

The two **must** match for any write. The HTTP layer checks `apiKey → pubkey` before building any tx; the on-chain runtime checks the signer matches `game_session.operator`. Mismatches fail at the cheaper layer first.

***

## Part 3 — Integration with the TypeScript SDK

The SDK is a thin TypeScript wrapper over Streamlock's `/v1/operator/*` HTTP API and `/v1/operator/stream` WSS feed. It does three things and only three things:

1. **HTTP envelopes.** Every method returns `data`, throws an `OperatorApiError` on `error`, and surfaces `meta.requestId` for debugging.
2. **Sign + broadcast.** Write methods accept a `signer` callback `(tx) => Promise<signedTx>`, sign locally, broadcast via your RPC URL, and return `{ signature, result }`.
3. **Validate before round-trip.** Zero-sum scoresheets, malformed deltas, missing fields — caught client-side before the request leaves your process.

Things it deliberately doesn't do: custody keys, abstract Solana primitives, pretend the API is something it isn't. The HTTP routes are first-class; the SDK is a courtesy.

If you're building in something other than TypeScript (Python, Go, Rust, etc.), skip to [Part 4 — HTTP surface](#part-4--http-surface-for-non-ts-callers).

***

### 7. Install and quickstart

```bash
npm install @streamlock/operator-sdk @solana/web3.js
# or
pnpm add @streamlock/operator-sdk @solana/web3.js
# or
yarn add @streamlock/operator-sdk @solana/web3.js
```

`@solana/web3.js` is a peer dep — bring your own version. The SDK is built against `@solana/web3.js` v1.x.

Node.js: 18+ (the SDK uses native `fetch` and `WebSocket`).

```ts
import { StreamlockOperator } from "@streamlock/operator-sdk";
import { Keypair } from "@solana/web3.js";

const operatorKp = Keypair.fromSecretKey(
  Buffer.from(process.env.OPERATOR_SECRET_KEY_B64!, "base64"),
);

const op = new StreamlockOperator({
  apiKey: process.env.STREAMLOCK_OPERATOR_KEY!,
  chain: "mainnet",
  rpcUrl: process.env.SOLANA_RPC_URL!,
  signer: async (tx) => {
    tx.sign([operatorKp]);
    return tx;
  },
});

// 1. Discover players holding bps on a token
const { streams } = await op.tokens.streams("<token_mint_pubkey>");

// 2. Open a session
const session = await op.sessions.create({
  tokenMint: "<token_mint_pubkey>",
  participants: streams.slice(0, 8).map((s: any) => ({
    wallet: s.holder,
    streamId: s.streamId,
  })),
  endTs: Math.floor(Date.now() / 1000) + 3600,
  disputeWindowSec: 600,
});

// 3. Run your game off-chain, produce a zero-sum scoresheet
const deltas = [
  { player: "<loser_wallet>", streamId: "<loser_stream_id>", deltaBps: -3000 },
  { player: "<winner_wallet>", streamId: "<loser_stream_id>", deltaBps: +3000 },
];

// 4. Submit to chain
await op.sessions.submit(session.result.gameSessionPda, {
  startChunkIndex: 0,
  deltas,
});

// 5. After dispute window: finalize + apply
await op.sessions.finalizeAndApplyAll(session.result.gameSessionPda, [
  { chunkIndex: 0, deltas },
]);
```

A complete runnable example is [`examples/poker-operator.ts`](https://github.com/0xNike/streamlockfun/blob/main/packages/operator-sdk/examples/poker-operator.ts) — \~80 lines, runs an end-to-end poker hand on devnet.

***

### 8. Configuration

```ts
new StreamlockOperator({
  apiKey: string,        // sk_<base64url> — required
  chain: Chain,          // "mainnet" | "sol" | "devnet" | "soldev" — required
  signer?: Signer,       // (tx: VersionedTransaction) => Promise<VersionedTransaction>
  rpcUrl?: string,       // Solana RPC URL — required for writes (broadcast)
  baseUrl?: string,      // override the API host (default: https://www.streamlock.fun)
  fetch?: typeof fetch,  // override the fetch implementation
})
```

#### `apiKey`

The operator key issued via `/admin/operators`. Format: `sk_<base64url>`.

**Never hardcode this.** Read from the environment, a secrets manager, or a runtime injection. The SDK validates the format on first use; malformed keys throw immediately rather than burning a 401 round-trip.

#### `chain`

Logical name for the network:

* `"mainnet"` or `"sol"` → mainnet-beta cluster, `meta.chain: "mainnet"` in responses.
* `"devnet"` or `"soldev"` → devnet cluster, `meta.chain: "devnet"` in responses.

The SDK resolves `chain` to a host (currently both map to `https://www.streamlock.fun`). Override with `baseUrl` for local dev.

#### `signer`

A callback that takes an unsigned `VersionedTransaction` and returns the signed copy:

```ts
signer: async (tx) => {
  tx.sign([yourKeypair]);
  return tx;
}
```

For HSM, Ledger, remote signer — anything that can produce a signature. The contract is exact:

* Input: an unsigned `VersionedTransaction`.
* Output: the same `VersionedTransaction`, fully signed.
* The callback must not modify the message bytes — only add signatures.

Write methods need a signer; reads don't. Constructing the SDK without a signer is fine for read-only usage:

```ts
const reader = new StreamlockOperator({
  apiKey: process.env.STREAMLOCK_OPERATOR_KEY!,
  chain: "mainnet",
});

await reader.tokens.streams("<mint>");  // works
await reader.sessions.create({ ... });   // throws — needs signer
```

#### `rpcUrl`

A Solana RPC endpoint used **only** for broadcasting signed transactions and waiting for confirmation. Reads against `/v1/operator/*` go through Streamlock's API and use Streamlock's RPCs server-side.

For mainnet, use a paid RPC (Helius, QuickNode, Triton). The free public endpoint (`https://api.mainnet-beta.solana.com`) is rate-limited and will burn confirmations. For devnet, `https://api.devnet.solana.com` works for low-volume testing.

#### `baseUrl`

Override the API host. Use for local dev:

```ts
baseUrl: "http://localhost:3000"
```

In production, leave it unset.

#### `fetch`

Inject a custom fetch (e.g. `node-fetch`, `undici.fetch` with custom dispatcher, an Axe-instrumented test fetch). Defaults to global `fetch`.

***

### 9. The five namespaces

```
StreamlockOperator
├── tokens     — token metadata, lifecycle, streams on a token
├── streams    — per-stream ledger reads + settlement helpers
├── players    — what streams does this wallet have bps in?
├── sessions   — game session lifecycle (the bulk of the SDK)
└── stream     — WSS event subscriber
```

#### 9.1 `op.tokens`

Read-only.

```ts
await op.tokens.get(mint);
// v0.1.4 — wired to real server data. Returns:
// {
//   tokenAddress: string,
//   pool:       { poolConfig, solVault, tokenVault, ... } | null,  // PDA addresses
//   poolConfig: { lifecycle phase, target price, caps, freeze/unlock ts, ... } | null,
// }
// Each is independently nullable — RPC outage on one path doesn't kill the other.
// For SPL-token name/symbol/decimals, use a DAS RPC directly (Helius getAsset etc.) —
// Streamlock doesn't proxy DAS reads.

await op.tokens.streams(mint);
// → { tokenMint, count, streams: Stream[] }
// Stream shape (v0.1.4):
// {
//   holder, streamId, tokenMint, vestingBackend,
//   entryPrice,                  // SOL per token at buy time
//   lockedTokenAmount,           // raw u64 base units, BigInt-safe string. NEW in v0.1.4.
//                                // null on legacy rows pre-2026-04-29.
//   settled, closed, createdAt, chain
// }
//
// Render concrete stake amounts:
//   const stake = (BigInt(s.lockedTokenAmount ?? "0") * BigInt(stakeBps)) / 10000n;
//   const display = Number(stake) / 10 ** decimals;  // decimals from DAS lookup
//   `Wagering ${display.toLocaleString()} ${symbol}`

// v0.1.6 — aggregated on-chain game-delta history for one player across every
// stream they hold on this token. Reads chain (NOT first-party Mongo) so
// third-party operators' settlements appear here even though they were never
// registered with us. The common shape for any "your activity on token X" UI.
await op.tokens.deltas(mint, playerPubkey);
// → {
//     tokenMint, player,
//     deltas: [{ streamId, signature, finalizedAt, operator, deltaBps }, ...],
//     netGameDeltaBps,
//     streamIds: [...]   // streams walked
//   }
// Cost: 1 + N requests (N = player's stream count on the token, typically small).
```

#### 9.2 `op.streams`

Per-stream reads + permissionless settlement helpers.

```ts
await op.streams.get(streamId);
// → full ledger: { holders: [{ holder, effectiveBps, ... }], chunks, ... }

await op.streams.entitlement(streamId, holder);
// → { holder, effectiveBps, ... }

await op.streams.claimStatus(streamId, holder);
// → { streamId, holder, claimedLamports, entitledLamports, fullyClaimed, effectiveBps }

// Pre-flight check: would `apply_delta_to_ledger` succeed for this delta?
// Avoids paying for an on-chain underflow failure (added in v0.1.1).
const check = await op.streams.canApplyDelta(streamId, holder, -1000);
// → { ok: true, currentBps: 9900, resultingBps: 8900 }
//   or { ok: false, reason: "underflow", currentBps: 500, deltaBps: -1000, resultingBps: -500 }
if (!check.ok) {
  // refuse the bet, pick a different stream, etc.
}

// Lifecycle-aware "is this stream still in play?" check (added in v0.1.1).
// NOTE: does NOT check the parent token's lifecycle phase — query
// op.tokens.get(mint).lifecycle separately for FREEZE/UNLOCK awareness.
const wager = await op.streams.isWagerable(streamId);
// → { ok: true }
//   or { ok: false, reason: "settled" | "closed" | "stream_not_found" }

// settle_stream is permissionless on-chain — anyone can broadcast.
// SDK signs as the operator key for convenience.
await op.streams.settle(streamId);
// → { signature, result }

// claim_proceeds must be signed by the holder.
// Use buildClaim, hand the unsigned bytes to the holder's signer.
const built = await op.streams.buildClaim(streamId, holderPubkey);
// → { transaction: <base64 unsigned tx>, blockhash, lastValidBlockHeight }

// v0.1.6 — AUTHORITATIVE on-chain game-delta history. Reads every
// apply_delta_to_ledger ix that touched the stream's ledger PDA and returns
// the deltas for this player. Operator-agnostic — third-party operators'
// settlements appear here even though they never reached our Mongo. THIS is
// the source any "session history" UI must consume; the legacy session-aware
// paths only see games registered with our first-party operator.
await op.streams.deltas(streamId, playerPubkey);
// → {
//     success: true, streamId, player, ledgerPda,
//     deltas: [{ signature, finalizedAt, operator, deltaBps }, ...],
//     netGameDeltaBps   // sum, negative = net loss
//   }

// v0.1.6 — protocol-vs-game decomposition for a fair "where did my share go"
// view. Combines `entitlement` + `deltas` and computes the residual
// protocol slice (allocated at stream *creation*, not from a game).
//
//   effectiveBps + protocolAllocBps - netGameDeltaBps === 10000
//
// If you only show `effectiveBps < 10000` as "Game results modified your
// share" you WILL mislabel a creation-time protocol cut as a game loss. We
// hit this bug in our own UI (streamlockfun v0.4.5) — see §20.
await op.streams.breakdown(streamId, playerPubkey);
// → {
//     streamId, player,
//     effectiveBps, netGameDeltaBps, protocolAllocBps,
//     deltas, hasGameDeltas, ledgerExists
//   }
```

#### 9.3 `op.players`

```ts
await op.players.streams(walletPubkey);
// → { streams: [{ tokenMint, streamId, effectiveBps, ... }] }
```

Useful for "what games is this player eligible for?"

#### 9.4 `op.sessions` — the main event

**Create**

```ts
const session = await op.sessions.create({
  tokenMint: "<mint>",
  participants: [
    { wallet: "<wallet1>", streamId: "<stream1>" },
    { wallet: "<wallet2>", streamId: "<stream1>" },
    // ...
  ],
  endTs: Math.floor(Date.now() / 1000) + 3600,  // unix seconds
  disputeWindowSec: 600,                          // dispute window after submit
  sessionIdHex: optional,                         // 64-char hex for idempotent retries
});
// → { signature, result: { gameSessionPda, sessionIdHex, transaction, blockhash, ... } }
```

The `gameSessionPda` is what every subsequent call needs. Store it.

`sessionIdHex` is optional but useful: passing the same hex on retry produces the same on-chain PDA, so `create` can be addressed for recovery.

> **v0.1.3 recovery flow.** As of v0.1.3, `op.sessions.create()` survives confirmation timeouts and "account already in use" rejections by probing the derived PDA on-chain. If the account exists (the tx landed despite the failure, or a prior attempt with the same `sessionIdHex` already created it), `create()` resolves with `signature: "(recovered)"` and the original `result`. Caller can branch on `signature === "(recovered)"` to know it's a recovery — or just treat the session as live and proceed. To make recovery work across process restarts, persist `sessionIdHex` to your DB *before* calling `create()`.

**Derive a session PDA without a round-trip&#x20;*****(v0.1.3)***

```ts
const sessionIdHex = "abcdef…"; // 64 hex chars, 32 bytes
const pda = op.sessions.derivePda(sessionIdHex);
// → "4hUHaZawAE76ssyRSJA1SuagZ8qNFiQsmSXb7EoseEbV" — base58 PDA address
```

Pure client-side. Useful for: probing whether a session exists before retrying create, addressing an existing session in a tx your operator backend builds itself, or surfacing the deterministic PDA in your own DB before the network round-trip lands.

**Submit**

```ts
await op.sessions.submit(sessionPda, {
  startChunkIndex: 0,
  deltas: [
    { player: "<loser>", streamId: "<stream>", deltaBps: -3000 },
    { player: "<winner>", streamId: "<stream>", deltaBps: +3000 },
    // ...
  ],
});
// → { signature, result: BuiltSubmitTx }
```

The SDK validates zero-sum **client-side** before the round-trip. Sum of `deltaBps` across all entries must be exactly 0. A non-zero sum throws `ZeroSumError` synchronously.

For >50 entries, split the array yourself across multiple `submit` calls with incrementing `startChunkIndex` (0, 50, 100, ...). The server packs each call into a single 50-entry chunk PDA.

**Finalize + apply**

After the dispute window has closed, finalize each chunk and apply each delta:

```ts
await op.sessions.finalizeAndApplyAll(sessionPda, [
  { chunkIndex: 0, deltas: chunkZeroDeltas },
  { chunkIndex: 1, deltas: chunkOneDeltas },
]);
// → { finalize: [sigs...], apply: [sigs...] }
```

Or do it manually:

```ts
await op.sessions.finalize(sessionPda, 0);  // one per chunk
for (const d of deltas) {
  await op.sessions.applyDelta(sessionPda, d);
}
```

Both `finalize` and `applyDelta` are permissionless on-chain — operator-signed in the SDK for ergonomic defaults, but `buildFinalize`/`buildApplyDelta` exist if you want to delegate broadcasting (e.g., to a third-party cranker).

> **One tx per `(stream, holder)` pair.** Each `applyDelta` call is its own on-chain transaction. A 16-player session that mutates one host-stream produces 16 sequential txs. Budget RPC throughput and SOL-for-fees accordingly. The on-chain `apply_delta_to_ledger` instruction is currently pinned to entitlement chunk index `0`, so each stream's ledger can hold at most 50 holders that are reachable for delta application — design game sizes around that cap.

**Cancel**

Operator-only escape hatch during the dispute window:

```ts
await op.sessions.cancel(sessionPda);
// → { signature, result }
```

After cancel, the session's deltas are discarded and the session is closed. PDA rent is refunded.

**Read**

```ts
const state = await op.sessions.get(sessionPda);
// Typed return as of v0.1.2:
// state.gameSession.status         → "Open" | "Submitted" | "Finalized" | "Cancelled"
// state.gameSession.disputeEndTs   → unix seconds; finalize allowed after, cancel before
// state.gameSession.endTs          → unix seconds; submit blocked after this
// state.gameSession.totalChunks    → 1+ for chunked submits
// state.deltaChunks[i].entries     → submitted (not necessarily applied) deltas

if (state.gameSession.status === "Open" && now > state.gameSession.disputeEndTs) {
  // safe to op.sessions.finalize(...) and applyDelta the entries
}

await op.sessions.disputes(sessionPda);
// → { disputes: [...] } — currently a stub, returns empty array + header
//   `X-Streamlock-Disputes-Status: not-implemented`
```

**Confirm hint**

After broadcasting, optionally hint the indexer to start polling sooner:

```ts
await op.sessions.confirm(sessionPda, signature, "submit");
```

Optional — passive cron picks it up either way; this just shaves indexer latency.

#### 9.5 `op.stream` — WSS events

Lazy: connects on first `.on(...)` call.

```ts
op.stream.on("session.finalized", (event) => {
  console.log("Finalized:", event.sessionPda);
});

op.stream.on("session.cancelled", (event) => {
  // operator cancelled — refund game state, free engaged stream lock
});

op.stream.on("stream.unlocked", (event) => {
  // proactively settle
  op.streams.settle(event.streamId);
});
```

Available channels (corrected in v0.1.2):

* `session.created`
* `session.submitted`
* `session.finalized`
* `session.cancelled` *(added v0.1.2)*
* `stream.unlocked`

> **What changed in v0.1.2:** earlier SDK versions declared `dispute.filed` (which the publisher never emitted, since `file_dispute` doesn't exist on-chain in v1) and was missing `session.cancelled` (which the publisher does emit on `cancel_session`). The SDK now matches the publisher exactly.

Auth: first-message frame `{ apiKey, channels: [...] }`. The SDK handles this; you never see the wire.

> **Permissive auth.** Today the WSS endpoint accepts any well-formed `sk_<base64url>` string without round-tripping the operator whitelist. Events contain only on-chain-public data (session PDAs and transaction signatures), so unauthorized read is low-stakes — but do not treat the WSS as a confidentiality boundary. Sensitive metadata stays in HTTP responses where the auth is fully verified.

Reconnection: exponential backoff up to \~30s. Calling `.on(...)` again after a manual disconnect re-arms.

To disconnect:

```ts
op.stream.close();
```

***

### 10. The `.build*()` escape hatches

Every writer has a `.build*()` sibling that returns the unsigned tx **without signing or broadcasting**:

```ts
// Returns { transaction: <base64>, blockhash, lastValidBlockHeight, ... }
await op.sessions.buildCreate({ ... });
await op.sessions.buildSubmit(sessionPda, { ... });
await op.sessions.buildFinalize(sessionPda, chunkIndex);
await op.sessions.buildApplyDelta(sessionPda, delta);
await op.sessions.buildCancel(sessionPda);
await op.streams.buildSettle(streamId);
await op.streams.buildClaim(streamId, holder);  // claim is holder-signed; no .claim() that signs as operator
```

Use cases:

* **Inspecting the tx before broadcast.** Decode the base64 with `VersionedTransaction.deserialize` and verify accounts/data match your expectations.
* **Delegating broadcast.** Hand the unsigned bytes to another service (a separate broadcaster, a queue worker, a multisig wallet) that has the keypair.
* **Coalescing into a larger tx.** If you're chaining the operator action with your own ix, build the bytes here and weave them in.
* **HTTP-free writes.** All the `.build*()` calls still hit our HTTP API (we build the bytes server-side). True HTTP-free writes require dropping the SDK and using the on-chain IDL directly — out of scope for v1.

***

### 11. Idempotency

Every write generates a fresh `Idempotency-Key` header per call (UUIDv4). You don't need to manage it manually.

> **Server-side memoization is rolled out incrementally.** The header is recorded today, but do not depend on it for correctness yet — treat it as a debugging aid and a forward-compatibility hint. The reliable idempotency primitive in v1 is `sessionIdHex` on `POST /sessions`, described below.

If you want a stable key across retries (recommended for `create` and `submit`):

```ts
import { generateIdempotencyKey } from "@streamlock/operator-sdk";

const idem = generateIdempotencyKey();
// stash in your DB keyed on (sessionId, attempt)

// re-send the same request with the same idempotency key on retry
// (the SDK does NOT yet expose an override; this is the wire-level contract
// that future versions will surface)
```

For `create` specifically, pin a deterministic on-chain PDA across retries via `sessionIdHex`:

```ts
import { randomBytes } from "node:crypto";

const sessionIdHex = randomBytes(32).toString("hex");

await op.sessions.create({ ..., sessionIdHex });  // first attempt
await op.sessions.create({ ..., sessionIdHex });  // retry — same PDA
```

**v0.1.3 recovery semantics:** the second call doesn't double-create. Internally the on-chain System Program rejects duplicate PDA allocation, but the SDK catches that error, probes the derived PDA via `op.sessions.derivePda(sessionIdHex)` against your `rpcUrl`, and returns a `ConfirmedTx` with `signature: "(recovered)"` once it confirms the account exists. The same flow handles the case where the *first* call's confirmation timed out but the tx actually landed (a common Alchemy pathology — see feedback #10 in the build log). Always pass and persist `sessionIdHex` in production code; without it, retries can't address the same on-chain PDA.

If you're hitting the API with raw `curl` or `fetch`:

```http
POST /v1/operator/sessions/:id/submit HTTP/1.1
Authorization: Bearer sk_<your_key>
Content-Type: application/json
Idempotency-Key: 2026-04-28-game-7-submit-attempt-3
```

***

### 12. Error handling, retries, and backoff

The SDK throws three error types:

#### `OperatorApiError`

Thrown when the server returns `error != null` in the envelope:

```ts
import { OperatorApiError } from "@streamlock/operator-sdk";

try {
  await op.sessions.submit(sessionPda, { ... });
} catch (err) {
  if (err instanceof OperatorApiError) {
    console.error(err.code);       // e.g. "rate_limited"
    console.error(err.message);    // human-readable
    console.error(err.requestId);  // for support / log search
    console.error(err.status);     // HTTP status
  } else {
    throw err;
  }
}
```

See [§15 Authentication, envelope, and error codes](#15-authentication-envelope-and-error-codes) for the full code table.

#### `ZeroSumError`

Thrown synchronously by `submit` (and `buildSubmit`) when client-side validation catches a non-zero-sum scoresheet:

```ts
import { ZeroSumError } from "@streamlock/operator-sdk";

try {
  await op.sessions.submit(pda, { startChunkIndex: 0, deltas });
} catch (err) {
  if (err instanceof ZeroSumError) {
    console.error("Sum:", err.sum);  // the bad sum value
  }
}
```

This catch happens before the network request. Fix the scoresheet, retry.

#### Generic `Error`

Anything unrelated: network errors, signer failures, RPC rejections during broadcast, keypair mismatches. Use the message + cause to triage.

#### Retry policy

The SDK does **not** auto-retry. Reasons:

* Reads are cheap to retry — your code knows the retry budget better than we do.
* Writes have on-chain side effects — auto-retry without an idempotency strategy is a footgun.

Recommended pattern for production:

```ts
async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  let lastErr: unknown;
  for (let attempt = 0; attempt < max; attempt++) {
    try {
      return await fn();
    } catch (err) {
      lastErr = err;
      if (err instanceof OperatorApiError) {
        if (err.code === "validation_error" || err.code === "zero_sum_violation") {
          throw err;  // don't retry — your input is broken
        }
        if (err.code === "rate_limited") {
          const retryAfter = parseInt(err.headers?.["retry-after"] ?? "1", 10);
          await sleep(retryAfter * 1000);
          continue;
        }
      }
      // network or 5xx — exponential backoff
      await sleep(2 ** attempt * 250);
    }
  }
  throw lastErr;
}
```

For `create` retries specifically, always pass a stable `sessionIdHex` so retries land on the same PDA.

***

### 13. Testing locally

#### Read-only, against hosted devnet

```ts
const op = new StreamlockOperator({
  apiKey: process.env.STREAMLOCK_OPERATOR_KEY!,
  chain: "soldev",
});

await op.tokens.streams("<your_devnet_mint>");
```

No signer, no RPC, no localhost — just confirms your API key works.

#### Full E2E, against a local Streamlock dev server

```bash
# In streamlockfun repo
npm run dev
```

```ts
const op = new StreamlockOperator({
  apiKey: process.env.STREAMLOCK_OPERATOR_KEY!,
  chain: "soldev",
  baseUrl: "http://localhost:3000",
  rpcUrl: "https://api.devnet.solana.com",
  signer: async (tx) => { tx.sign([kp]); return tx; },
});
```

Write methods will hit your local HTTP server, get unsigned tx bytes back, sign with `kp`, and broadcast to devnet. Same shape as production; only the API host changes.

#### `describe()` for safe debugging

```ts
console.log(op.describe());
// {
//   apiKey: "sk_AbCdEfG...",  // first 11 chars only — safe to log
//   chain: "mainnet",
//   rpcUrl: "https://...",
//   baseUrl: undefined,
//   hasSigner: true,
// }
```

The full key is **never** returned by `describe()`. Use this in logs instead of inspecting the constructor input.

***

### 14. Type imports

```ts
import type {
  Chain,
  Signer,
  ClientConfig,
  Envelope,
  BuiltTx,
  BuiltSubmitTx,
  CreatedSession,
  DeltaEntry,
  SessionParticipant,
  CreateSessionRequest,
  SubmitRequest,
  SettlementInfo,
  ConfirmedTx,
} from "@streamlock/operator-sdk";

import {
  StreamlockOperator,
  OperatorApiError,
  ZeroSumError,
  generateIdempotencyKey,
} from "@streamlock/operator-sdk";
```

***

## Part 4 — HTTP surface (for non-TS callers)

If you're integrating from Python, Go, Rust, or any non-TypeScript runtime, talk to `/v1/operator/*` directly. The OpenAPI 3.1 schema at [`https://www.streamlock.fun/v1/openapi.json`](https://www.streamlock.fun/v1/openapi.json) is machine-readable and can be code-gen'd into client libraries.

### 15. Authentication, envelope, and error codes

#### Auth

```http
Authorization: Bearer sk_<your_key>
Content-Type: application/json
```

#### Response envelope

Every v1 response uses this shape:

```jsonc
// Success
{
  "data": { /* route-specific payload */ },
  "error": null,
  "meta": {
    "chain": "mainnet",
    "asOf": "2026-04-28T05:30:14.221Z",
    "requestId": "de2650bd-eb57-42d2-920a-7a8405f7c5f0"
  }
}

// Error
{
  "data": null,
  "error": {
    "code": "rate_limited",
    "message": "Too many requests. Slow down or upgrade your tier."
  },
  "meta": { /* same shape */ }
}
```

#### Error code reference

| Code                  | HTTP | Meaning                                | What to do                             |
| --------------------- | ---- | -------------------------------------- | -------------------------------------- |
| `malformed_key`       | 401  | API key isn't `sk_<base64url>`         | Fix the key in your config             |
| `unauthorized`        | 401  | Key revoked or never whitelisted       | Email us                               |
| `not_whitelisted`     | 403  | Pubkey not in `operator_whitelist`     | Email us                               |
| `mint_not_allowed`    | 403  | Mint not in your `allowedTokenMints`   | Request scope expansion                |
| `rate_limited`        | 429  | 120/min per-key cap hit                | Backoff and retry; honor `Retry-After` |
| `validation_error`    | 400  | Body shape wrong — see `error.message` | Fix the request                        |
| `zero_sum_violation`  | 400  | Scoresheet doesn't sum to 0            | Fix the scoresheet                     |
| `session_not_found`   | 404  | Session PDA doesn't exist or is closed | Check the ID                           |
| `dispute_window_open` | 400  | Tried to finalize too early            | Wait `disputeWindowSec` from submit    |

***

### 16. Endpoint reference

#### Reads (no signer needed)

| Method | Path                                                    | Purpose                                                                    |
| ------ | ------------------------------------------------------- | -------------------------------------------------------------------------- |
| `GET`  | `/v1/operator/tokens/:mint`                             | Token metadata, lifecycle phase, next unlock ETA                           |
| `GET`  | `/v1/operator/tokens/:mint/streams`                     | List of active streams on a token                                          |
| `GET`  | `/v1/operator/streams/:streamId`                        | Full ledger for a stream                                                   |
| `GET`  | `/v1/operator/streams/:streamId/entitlement/:holder`    | One holder's effective bps                                                 |
| `GET`  | `/v1/operator/streams/:streamId/claim-status/:holder`   | Has this holder claimed?                                                   |
| `GET`  | `/v1/operator/streams/:streamId/deltas?player=<pubkey>` | On-chain `apply_delta` history for a player (operator-agnostic) *(v0.1.6)* |
| `GET`  | `/v1/operator/players/:wallet/streams`                  | All streams a player has bps in                                            |

#### Writes (signer required)

| Method | Path                                    | Purpose                                               |
| ------ | --------------------------------------- | ----------------------------------------------------- |
| `POST` | `/v1/operator/sessions`                 | Build `create_game_session`                           |
| `POST` | `/v1/operator/sessions/:id/submit`      | Build `submit_results` (chunked)                      |
| `POST` | `/v1/operator/sessions/:id/finalize`    | Build `finalize_results` (one per chunk)              |
| `POST` | `/v1/operator/sessions/:id/apply-delta` | Build `apply_delta_to_ledger` (one per holder/stream) |
| `POST` | `/v1/operator/sessions/:id/cancel`      | Build `cancel_session` (escape hatch)                 |

#### Settlement helpers (permissionless on-chain)

| Method | Path                                    | Purpose                             |
| ------ | --------------------------------------- | ----------------------------------- |
| `POST` | `/v1/operator/streams/:streamId/settle` | Build `settle_stream`               |
| `POST` | `/v1/operator/streams/:streamId/claim`  | Build `claim_proceeds` for a holder |

#### Session inspection

| Method | Path                                 | Purpose                                |
| ------ | ------------------------------------ | -------------------------------------- |
| `GET`  | `/v1/operator/sessions/:id`          | Unified Mongo + on-chain session state |
| `GET`  | `/v1/operator/sessions/:id/disputes` | Pending disputes for the session       |
| `POST` | `/v1/operator/sessions/:id/confirm`  | Hint the indexer about a broadcast tx  |

#### Events

`wss://price-wss.fly.dev/v1/operator/stream` — first-message JSON `{ apiKey, channels: [...] }` to authenticate.

Channels: `session.created`, `session.submitted`, `session.finalized`, `session.cancelled`, `stream.unlocked`.

***

### 17. Rate limits

Per-API-key, **not** per-IP. An operator with one key + many IPs is still bounded.

* **Default tier:** 120 requests/minute across all `/v1/operator/*` writes.
* **Reads:** 120/minute on the same backstop.
* **WSS:** 5 concurrent connections per key.

A `429` response includes `Retry-After`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. Honor `Retry-After`; don't hot-loop.

If you legitimately need more, reply to the email thread that issued your key and we'll bump the tier.

***

## Part 5 — Limits, scope, and what's next

### 18. Hard limits to plan around

* **50 holders per stream maximum** for `apply_delta_to_ledger`. The on-chain instruction's chunk-index seed is currently pinned to `0`, so only the first 50-entry chunk is reachable. A token with a stream whose ledger has >50 holders cannot have new deltas applied to those overflow entries until the on-chain program gains a `chunk_index` argument. Plan game session sizes around this — split a 60-holder competition across two parallel sessions on different streams instead of one mega-session.
* **`applyDelta` is one tx per `(stream, holder)` pair.** A 16-player session that mutates one shared host-stream takes 16 separate `apply_delta_to_ledger` calls after `finalize`. Budget tx fees and round-trip latency accordingly. The SDK's `finalizeAndApplyAll` helper sequences them for you.
* **Disputes endpoint is a stub.** `GET /v1/operator/sessions/:id/disputes` returns `{ disputes: [] }` with header `X-Streamlock-Disputes-Status: not-implemented`. There is no on-chain `file_dispute` instruction in v1. The actual operator escape hatch during the dispute window is `POST /v1/operator/sessions/:id/cancel`. If your game needs real disputes, implement them off-chain and use `cancel` to roll back a contested session.
* **`Idempotency-Key` is best-effort.** The SDK sends the header on every write, but server-side memoization is rolled out incrementally. The reliable idempotency primitive today is `sessionIdHex` on `POST /sessions` — passing the same hex on retry produces the same on-chain PDA and so the same session, regardless of whether the server has memoized your `Idempotency-Key`.
* **WSS first-message auth is permissive.** Any well-formed `sk_<base64url>` string is accepted by the WSS endpoint without round-tripping the Supabase whitelist (D-6.2 in the build log). Reasoning: the events contain only on-chain-public data (session PDAs, transaction signatures), so unauthorized read is low-stakes. Don't rely on the WSS auth as a confidentiality boundary.

***

### 19. v1 scope notes

V1 is deliberately scoped. Things you might expect that aren't there yet:

* **Single-mint sessions only.** A session's deltas all reference the same `tokenMint`. Cross-token games (operator A's token loses bps to operator B's token) are tracked but not v1.
* **No generic trading API.** This is operator surface, not a swap API. Buys, sells, and LP injects use the public Streamlock UI or the internal `/api/buy-execute` path.
* **No self-serve onboarding.** Manual whitelisting via email + admin page. Self-serve with $LOCK staking deposit is post-hackathon.
* **No on-chain operator registry.** The whitelist is off-chain Supabase. A registry PDA is a fast-follow if integrators ask for trust-minimization.
* **No webhooks (yet).** WSS is live; HTTP webhooks with HMAC signing are planned but unshipped.

***

### 20. Common pitfalls

#### "I'm getting `zero_sum_violation` but my deltas look fine"

The sum is computed across **all** deltas in the submit call. Adding `+3000` and `−3000` works, but `+3000`, `−1500`, `−1499` does not (off by 1). Round to integers up front; never let floats touch `deltaBps`.

#### "My `apiKeyHash` shows the right prefix but the request is 401"

Check for trailing whitespace and a missing `sk_` prefix. The validator is strict. Copy-paste through a `printf %s` to strip newlines.

#### "Sessions.create succeeded but `get(sessionPda)` returns `session_not_found`"

Wait 2-3s. Mongo mirror reconciliation is async; on-chain confirmation lands first, indexer catches up second. The SDK's `await op.sessions.create(...)` waits for on-chain confirmation but **not** indexer reconciliation.

If `get()` still 404s after several seconds *and* you got a `create()` recovery (`signature === "(recovered)"`), you've hit a known gap: when the SDK recovers from a confirmation timeout, the mongo mirror may never have been written even though the on-chain account exists. The SDK can't fix this alone — server-side reconciliation is needed (carry-over from v0.1.4 to v0.1.5; needs an indexer or eager-write change). Workarounds:

* Verify on-chain via your `rpcUrl`: `new Connection(rpcUrl).getAccountInfo(new PublicKey(pda))`. Non-null = session is live.
* Treat the recovered session as authoritative on-chain; don't depend on `get()` for state until you confirm the mirror has caught up.

#### "WSS keeps disconnecting"

The hosted feed has a 60-minute ping/pong timeout. The SDK auto-reconnects with backoff; you don't need to do anything. If reconnects fail repeatedly, check that your network egress allows outbound WSS to `price-wss.fly.dev:443`.

#### "I rotated my API key but the old one still works"

Revocation happens server-side immediately when admin sets `revokedAt`. If the old key still works, either revocation hasn't been issued (check with admin) or you're hitting a stale cache (wait 60s).

#### "Different `meta.chain` than I configured"

You crossed wires — the host you're hitting and the chain you set don't match. Fail fast: assert `meta.chain` matches your expectation on every response.

#### "My player has 99% effective share but no game history shows — or vice versa"

This is the **chain vs first-party DB** distinction. Three layers, three sources of truth, and only one of them is complete:

| Layer             | Question                                    | Source                                                                | Complete?                  |
| ----------------- | ------------------------------------------- | --------------------------------------------------------------------- | -------------------------- |
| Current state     | "What's my entitlement now?"                | On-chain `EntitlementLedger` (`op.streams.entitlement`)               | yes                        |
| Financial history | "What deltas moved me 100% → X%, and when?" | On-chain `apply_delta_to_ledger` trail (`op.streams.deltas` — v0.1.6) | **yes, operator-agnostic** |
| Game narrative    | "Which RPS rounds caused each delta?"       | Operator-private store (your DB)                                      | per-operator only          |

Two real bugs we hit in our own UI (streamlockfun v0.4.5 / v0.4.6) — both fixed via `op.streams.deltas` / `op.streams.breakdown`:

**Mislabel.** Any sub-100% effective share got captioned "Game results have modified your share." But a sub-100% share can also be the **router authority's creation-time cut** (the standard \~1% protocol slice). When that's the only deviation, no game ever ran — but the UI claimed one had. The fix: use `op.streams.breakdown(streamId, player)` and only show game framing when `hasGameDeltas === true`. The math identity:

```
effectiveBps + protocolAllocBps - netGameDeltaBps === 10000
```

…lets you display a creation-time protocol allocation as a separate line item ("X% allocated at stream creation") instead of falsely attributing it to a game.

**Foreign-operator blindness.** Our session-history panel read the first-party Mongo game-sessions index. A real settlement applied on-chain by a third-party operator never reached that index, so the panel showed "No session history found" while the on-chain bps were clearly moved. The fix: read `op.streams.deltas(streamId, player)` (chain), not the legacy session-aware path. Every delta links to its settlement tx on Solscan, regardless of who applied it.

**Rule of thumb:** if a UI presents the **financial truth** (effective share, gains/losses, "where did my % go"), it MUST be backed by the on-chain primitives. Use the legacy session paths only for game **narrative** ("which round did I lose"), and only when you wrote that narrative yourself.

***

### 21. Versioning

`@streamlock/operator-sdk@0.1.0` ships with the v1 API. Breaking changes to the SDK (changes that require consumer code edits) bump the minor version under semver-zero. Once we hit `1.0.0`, semver applies normally.

The HTTP API is versioned independently (`/v1/operator/*`). New routes can land within v1; breaking shape changes will land under `/v2/operator/*` with v1 supported in parallel during a deprecation window.

***

### 22. Where to go from here

* **Spec:** [`docs/architecture/OPERATOR_API.md`](https://github.com/0xNike/streamlockfun/blob/main/docs/architecture/OPERATOR_API.md) for the canonical HTTP contract.
* **Live schema:** [`https://www.streamlock.fun/v1/openapi.json`](https://www.streamlock.fun/v1/openapi.json) — code-gen this into your client of choice.
* **Reference operator:** [`packages/operator-sdk/examples/poker-operator.ts`](https://github.com/0xNike/streamlockfun/blob/main/packages/operator-sdk/examples/poker-operator.ts) — \~80 lines, runs end-to-end on devnet.
* **SDK source:** [`packages/operator-sdk/src/`](https://github.com/0xNike/streamlockfun/tree/main/packages/operator-sdk/src) — small enough to read in one sitting.

When something goes wrong, the request is in our logs by `meta.requestId` (or the equivalent on errors). Capture and quote it.

For your own debugging:

* **HTTP status + envelope** is the first signal. `error.code` is machine-readable; `error.message` is for humans.
* **Solana tx signature** is the second — every confirmed write returns one. Drop it into `https://solscan.io/tx/<sig>?cluster=devnet|mainnet-beta` for a full ix breakdown.
* **WSS feed** is the third. `session.finalized`, `session.cancelled`, and `stream.unlocked` give you push semantics; if they don't fire when you expect, your server-side state and on-chain state have drifted — file a request with `requestId` + `signature` and we'll reconcile.

Questions or stuck? Email <streamlockfun@gmail.com>, ping [@hao\_ssh](https://t.me/hao_ssh) on Telegram, or open an issue on the [streamlockfun GitHub repo](https://github.com/0xNike/streamlockfun/issues).
