diff --git a/apps/s03-indexer/.gitignore b/apps/s03-indexer/.gitignore index 9e630ec..8277859 100644 --- a/apps/s03-indexer/.gitignore +++ b/apps/s03-indexer/.gitignore @@ -58,6 +58,12 @@ Thumbs.db .data .eslintcache +# Local smoke run reports +.smoke/ + +# Local contract manifest generated by sync:contracts:local +config/contracts.local.json + # ENV local files .env.local .env.develop.local diff --git a/apps/s03-indexer/README.md b/apps/s03-indexer/README.md index 4d7f659..5a01bc2 100644 --- a/apps/s03-indexer/README.md +++ b/apps/s03-indexer/README.md @@ -127,6 +127,89 @@ fails fast on malformed contract IDs and required missing values. Missing `MARKET_TOKEN_*` values are reported as warnings because they are expected before market bootstrap. +## Local Smoke Flow + +`smoke:local` is the one documented command that proves the whole stack works +together: it drives real protocol actions through the deployed contracts and then +asserts the indexer turned those on-chain events into GraphQL entities. + +```bash +bun run --cwd apps/s03-indexer build # verify the indexer compiles +bun run --cwd apps/s03-indexer smoke:local +``` + +### What it does + +The runner executes layered, timed steps and records each one in a JSON report: + +1. **preflight** — checks `stellar` (and `make`/`docker` for fresh deploys) are on + PATH, locates the contracts repo, and ensures local-only signing keys exist + (generating and friendbot-funding them when needed). It never uses keys outside + the local/test keystore. +2. **services** — confirms the Soroban RPC, Horizon, and SubQuery GraphQL endpoints + are reachable before any transaction is sent. +3. **contracts-deploy** — validates a pre-existing local deployment discovered from + `.deployed/local.env`, or, on a fresh network, deploys test tokens + faucet, + deploys the protocol contracts, and bootstraps a market via the contract repo + `make` targets. +4. **frontend-config** — runs `sync:contracts:local` so the indexer manifest matches + the freshly deployed contract IDs. +5. **indexer-runtime** — rebuilds the indexer for the local config and starts the + Docker stack (skippable when it is already running). +6. **contract-action** — submits fixed oracle prices, claims faucet tokens, then + creates and executes a deposit, a `MarketIncrease` that opens a long position, a + `MarketDecrease` that closes it, and (optionally) registers and sets a referral + code. +7. **graphql-query** — waits for the indexer to catch up to the latest ledger and + asserts the expected `market`, `deposit`, `order`, `position`, and `transfer` + entity counts. + +### Run modes + +| Mode | Behavior | +| --- | --- | +| `--mode auto` (default) | Reuse a complete local deployment if present, otherwise deploy + bootstrap. | +| `--mode fresh` | Always deploy test tokens, contracts, and bootstrap a market. | +| `--mode existing` | Require a complete `.deployed/local.env`; fail fast if missing. | + +### Common options + +All options also have `SMOKE_*` environment-variable equivalents. + +```bash +bun run --cwd apps/s03-indexer smoke:local \ + --contracts-repo /path/to/contracts \ # SO4_CONTRACTS_REPO + --source so4-local --keeper so4-local \ # local stellar CLI keys + --long-code TWBTC --short-code TUSDC \ + --soroban-endpoint http://localhost:8000/soroban/rpc \ + --graphql-endpoint http://localhost:3000 \ + --report .smoke/report.json +``` + +Useful flags: `--skip-referral` (skip the optional referral step), +`--skip-indexer-restart` (assume the stack is already running with fresh config), +and `--skip-indexer-check` (contracts-only run, no GraphQL assertions). + +### The report + +A JSON report is written to `.smoke/report.json` (override with `--report`). It +contains the run mode, service reachability, deployment artifacts (market token, +core contract IDs, faucet), each action's ledger/transaction-hash/key data, the +GraphQL entity counts with pass/fail assertions, and — when something breaks — a +top-level `failure` block naming the broken layer (`contracts-deploy`, +`contract-action`, `indexer-runtime`, `graphql-query`, or `frontend-config`) so +you know exactly where to look. + +### Rerunning cleanly + +The indexer persists entities in Postgres, so rerun against a clean database: + +```bash +bun run --cwd apps/s03-indexer smoke:clean # stop stack + drop DB + clear reports +bun run --cwd apps/s03-indexer smoke:clean --keep-db +bun run --cwd apps/s03-indexer smoke:clean --reports # only clear .smoke reports +``` + ## Generated Artifacts `project.ts`, `schema.graphql`, and the TypeScript mapping sources are the diff --git a/apps/s03-indexer/package.json b/apps/s03-indexer/package.json index 2ac0f22..7e7ae3b 100644 --- a/apps/s03-indexer/package.json +++ b/apps/s03-indexer/package.json @@ -8,6 +8,8 @@ "codegen": "subql codegen", "sync:contracts:testnet": "bun run scripts/sync-contracts.ts --network testnet", "sync:contracts:local": "bun run scripts/sync-contracts.ts --network local", + "smoke:local": "bun run scripts/local-smoke.ts", + "smoke:clean": "bun run scripts/clean-local.ts", "start": "docker compose pull && docker compose up --remove-orphans", "start:docker": "bun run start", "dev": "bun run codegen && bun run build && bun run start", diff --git a/apps/s03-indexer/project.ts b/apps/s03-indexer/project.ts index 38698bd..1df5199 100644 --- a/apps/s03-indexer/project.ts +++ b/apps/s03-indexer/project.ts @@ -90,8 +90,10 @@ const project: StellarProject = { dataSources: [ { kind: StellarDatasourceKind.Runtime, - /* Set this as a logical start block, it might be block 1 (genesis) or when your contract was deployed */ - startBlock: 228206, + /* Set this as a logical start block, it might be block 1 (genesis) or when your + contract was deployed. Override with INDEXER_START_LEDGER for local runs, where + a fresh standalone network must backfill from an early ledger. */ + startBlock: Number(process.env.INDEXER_START_LEDGER ?? 228206), mapping: { file: "./dist/index.js", handlers: indexedContractIds.map((contractId) => ({ diff --git a/apps/s03-indexer/scripts/clean-local.ts b/apps/s03-indexer/scripts/clean-local.ts new file mode 100644 index 0000000..a3a9638 --- /dev/null +++ b/apps/s03-indexer/scripts/clean-local.ts @@ -0,0 +1,76 @@ +// Reset local indexer state so the smoke flow can be rerun cleanly. +// +// SubQuery persists indexed entities in the Postgres volume started by +// docker-compose. Re-running the smoke flow against a stale database double-counts +// entities and makes assertions ambiguous, so this script tears the stack down +// (optionally removing the volume) and clears the local report. +// +// Usage: +// bun run scripts/clean-local.ts # stop stack + drop DB volume + clear report +// bun run scripts/clean-local.ts --keep-db # stop stack only, keep indexed data +// bun run scripts/clean-local.ts --reports # only delete the .smoke report dir + +import { existsSync, rmSync } from "node:fs"; +import path from "node:path"; + +import { parseArgs } from "./lib/config"; +import { hasBinary, run } from "./lib/process"; + +const packageRoot = path.resolve(import.meta.dir, ".."); +const { bools } = parseArgs(process.argv.slice(2)); + +const reportsOnly = bools.has("reports"); +const keepDb = bools.has("keepDb"); + +function clearReports(): void { + const smokeDir = path.join(packageRoot, ".smoke"); + if (existsSync(smokeDir)) { + rmSync(smokeDir, { recursive: true, force: true }); + process.stdout.write(`Removed ${smokeDir}\n`); + } else { + process.stdout.write("No .smoke report directory to remove.\n"); + } +} + +function stopStack(): void { + if (!hasBinary("docker")) { + process.stdout.write("docker not found — skipping stack teardown.\n"); + return; + } + + const args = ["compose", "down", "--remove-orphans"]; + if (!keepDb) { + args.push("--volumes"); + } + run("docker", args, { cwd: packageRoot, stream: true }); + + // The Postgres volume is also bind-mounted at .data/postgres in this repo. + // The container writes those files as root, so a plain rm may be denied — fall + // back to removing them from inside a throwaway container. + if (!keepDb) { + const dataDir = path.join(packageRoot, ".data"); + if (existsSync(dataDir)) { + try { + rmSync(dataDir, { recursive: true, force: true }); + process.stdout.write(`Removed ${dataDir}\n`); + } catch { + run("docker", ["run", "--rm", "-v", `${packageRoot}:/work`, "alpine", "rm", "-rf", "/work/.data"], { + stream: true, + }); + process.stdout.write(`Removed ${dataDir} (via container)\n`); + } + } + } +} + +if (reportsOnly) { + clearReports(); +} else { + stopStack(); + clearReports(); + process.stdout.write( + keepDb + ? "Indexer stack stopped (database preserved).\n" + : "Indexer stack stopped and local database cleared. Safe to rerun smoke:local.\n", + ); +} diff --git a/apps/s03-indexer/scripts/lib/config.ts b/apps/s03-indexer/scripts/lib/config.ts new file mode 100644 index 0000000..e60f059 --- /dev/null +++ b/apps/s03-indexer/scripts/lib/config.ts @@ -0,0 +1,138 @@ +// Resolve smoke-run configuration from CLI flags and environment variables. +// +// Precedence: CLI flag > environment variable > sensible local default. Every +// value has a local-friendly default so a contributor can run the smoke flow with +// zero flags against a standard stellar/quickstart standalone network. + +import { existsSync } from "node:fs"; +import path from "node:path"; + +import type { SmokeConfig, SmokeMode } from "./types"; + +export interface ParsedArgs { + flags: Record; + bools: Set; + positionals: string[]; +} + +export function parseArgs(argv: string[]): ParsedArgs { + const flags: Record = {}; + const bools = new Set(); + const positionals: string[] = []; + + for (let i = 0; i < argv.length; i += 1) { + const arg = argv[i]; + if (!arg.startsWith("--")) { + positionals.push(arg); + continue; + } + + const [rawKey, inlineValue] = arg.slice(2).split("=", 2); + const key = rawKey.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + + if (inlineValue !== undefined) { + flags[key] = inlineValue; + continue; + } + + const next = argv[i + 1]; + if (next === undefined || next.startsWith("--")) { + bools.add(key); + } else { + flags[key] = next; + i += 1; + } + } + + return { flags, bools, positionals }; +} + +const packageRoot = path.resolve(import.meta.dir, "..", ".."); +const repoRoot = path.resolve(packageRoot, "..", ".."); + +function resolveContractsRepo(explicit?: string): string { + const configured = + explicit ?? + process.env.SO4_CONTRACTS_REPO ?? + process.env.CONTRACTS_REPO_PATH ?? + process.env.CONTRACTS_REPO; + + if (configured) { + return path.resolve(configured); + } + + const candidates = [ + path.resolve(repoRoot, "..", "contracts"), + path.resolve(packageRoot, "..", "contracts"), + "/home/sunny/zero/so4-market-project/contracts", + ]; + + return candidates.find((candidate) => existsSync(path.join(candidate, "scripts"))) ?? candidates[0]; +} + +function resolveMode(value: string | undefined): SmokeMode { + switch ((value ?? "auto").toLowerCase()) { + case "fresh": + return "fresh"; + case "existing": + return "existing"; + default: + return "auto"; + } +} + +export function buildConfig(argv: string[]): SmokeConfig { + const { flags, bools } = parseArgs(argv); + + const network = (flags.network ?? process.env.SMOKE_NETWORK ?? "local").toLowerCase(); + const source = flags.source ?? process.env.SMOKE_SOURCE ?? "so4-local"; + const keeper = flags.keeper ?? process.env.SMOKE_KEEPER ?? source; + + const sorobanRpcEndpoint = + flags.sorobanEndpoint ?? + process.env.SMOKE_SOROBAN_ENDPOINT ?? + process.env.SOROBAN_ENDPOINT ?? + "http://localhost:8000/soroban/rpc"; + + const horizonEndpoint = + flags.horizonEndpoint ?? + process.env.SMOKE_HORIZON_ENDPOINT ?? + process.env.ENDPOINT ?? + "http://localhost:8000"; + + const friendbotUrl = + flags.friendbotUrl ?? process.env.SMOKE_FRIENDBOT_URL ?? "http://localhost:8000/friendbot"; + + const graphqlEndpoint = + flags.graphqlEndpoint ?? + process.env.SMOKE_GRAPHQL_ENDPOINT ?? + process.env.INDEXER_GRAPHQL_URL ?? + "http://localhost:3000"; + + const reportPath = path.resolve( + flags.report ?? process.env.SMOKE_REPORT ?? path.join(packageRoot, ".smoke", "report.json"), + ); + + return { + network, + contractsRepo: resolveContractsRepo(flags.contractsRepo), + source, + keeper, + longCode: flags.longCode ?? process.env.SMOKE_LONG_CODE ?? "TWBTC", + shortCode: flags.shortCode ?? process.env.SMOKE_SHORT_CODE ?? "TUSDC", + mode: resolveMode(flags.mode), + horizonEndpoint, + sorobanRpcEndpoint, + friendbotUrl, + graphqlEndpoint, + indexerLagTolerance: Number(flags.lagTolerance ?? process.env.SMOKE_LAG_TOLERANCE ?? 2), + waitTimeoutMs: Number(flags.waitTimeout ?? process.env.SMOKE_WAIT_TIMEOUT_MS ?? 180_000), + skipReferral: bools.has("skipReferral") || process.env.SMOKE_SKIP_REFERRAL === "1", + skipIndexerRestart: + bools.has("skipIndexerRestart") || process.env.SMOKE_SKIP_INDEXER_RESTART === "1", + skipIndexerCheck: bools.has("skipIndexerCheck") || process.env.SMOKE_SKIP_INDEXER_CHECK === "1", + reportPath, + }; +} + +export const SMOKE_PATHS = { packageRoot, repoRoot }; diff --git a/apps/s03-indexer/scripts/lib/deployment.ts b/apps/s03-indexer/scripts/lib/deployment.ts new file mode 100644 index 0000000..368271d --- /dev/null +++ b/apps/s03-indexer/scripts/lib/deployment.ts @@ -0,0 +1,136 @@ +// Read and validate contract-repo deployment outputs for the smoke flow. +// +// The contracts repo writes addresses to `.deployed/.env` (core protocol +// contracts + market token triplets) and `.deployed/tokens-.env` (test +// tokens + faucet). The smoke runner needs those addresses to drive deposits, +// orders, and price submission, and to decide whether a usable deployment already +// exists before it spends time redeploying. + +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +export type EnvMap = Record; + +const CONTRACT_ID = /^C[A-Z2-7]{55}$/; + +export function isContractId(value: string | undefined): value is string { + return typeof value === "string" && CONTRACT_ID.test(value); +} + +export function readEnvFile(filePath: string): EnvMap { + if (!existsSync(filePath)) { + return {}; + } + + const env: EnvMap = {}; + for (const line of readFileSync(filePath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + const eq = trimmed.indexOf("="); + if (eq === -1) { + continue; + } + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + env[key] = value; + } + return env; +} + +export interface DeploymentPaths { + deployEnv: string; + tokenEnv: string; +} + +export function deploymentPaths(contractsRepo: string, network: string): DeploymentPaths { + return { + deployEnv: path.join(contractsRepo, ".deployed", `${network}.env`), + tokenEnv: path.join(contractsRepo, ".deployed", `tokens-${network}.env`), + }; +} + +export interface LoadedDeployment { + env: EnvMap; + paths: DeploymentPaths; +} + +/** Merge core + token env files (token values do not override core values). */ +export function loadDeployment(contractsRepo: string, network: string): LoadedDeployment { + const paths = deploymentPaths(contractsRepo, network); + const env = { ...readEnvFile(paths.tokenEnv), ...readEnvFile(paths.deployEnv) }; + return { env, paths }; +} + +/** Core protocol contracts the smoke actions invoke directly. */ +export const REQUIRED_CONTRACTS = [ + "ROLE_STORE", + "DATA_STORE", + "ORACLE", + "MARKET_FACTORY", + "DEPOSIT_HANDLER", + "ORDER_HANDLER", + "ORDER_VAULT", + "EXCHANGE_ROUTER", + "REFERRAL_STORAGE", +] as const; + +export interface DeploymentCheck { + ok: boolean; + missing: string[]; + invalid: string[]; +} + +/** Verify all required core contracts are present and well-formed. */ +export function checkCoreContracts(env: EnvMap): DeploymentCheck { + const missing: string[] = []; + const invalid: string[] = []; + + for (const key of REQUIRED_CONTRACTS) { + const value = env[key]; + if (!value) { + missing.push(key); + } else if (!isContractId(value)) { + invalid.push(key); + } + } + + return { ok: missing.length === 0 && invalid.length === 0, missing, invalid }; +} + +export interface MarketTriplet { + envKey: string; + marketToken: string; + indexToken: string; + longToken: string; + shortToken: string; +} + +/** Resolve the market-token triplet for a long/short code pair, if bootstrapped. */ +export function resolveMarket( + env: EnvMap, + longCode: string, + shortCode: string, +): MarketTriplet | undefined { + const envKey = `MARKET_TOKEN_${longCode}_${shortCode}`; + const marketToken = env[envKey]; + const longToken = env[`${envKey}_LONG`]; + const shortToken = env[`${envKey}_SHORT`]; + const indexToken = env[`${envKey}_INDEX`] ?? longToken; + + if (![marketToken, longToken, shortToken, indexToken].every(isContractId)) { + return undefined; + } + + return { envKey, marketToken, indexToken, longToken, shortToken }; +} + +/** Resolve test-token contract IDs by ticker code. */ +export function resolveToken(env: EnvMap, code: string): string | undefined { + const value = env[code] ?? env[`${code}_NATIVE`]; + return isContractId(value) ? value : undefined; +} diff --git a/apps/s03-indexer/scripts/lib/graphql.ts b/apps/s03-indexer/scripts/lib/graphql.ts new file mode 100644 index 0000000..2078a06 --- /dev/null +++ b/apps/s03-indexer/scripts/lib/graphql.ts @@ -0,0 +1,117 @@ +// SubQuery GraphQL helpers for the smoke runner. +// +// After protocol actions run, the smoke flow queries the GraphQL layer to prove +// the indexer turned on-chain events into entities. SubQuery exposes a `_metadata` +// node for indexing progress and auto-generated `s { totalCount }` queries +// for every schema type. + +import type { EntityCounts } from "./types"; + +export interface GraphqlResult { + data?: T; + errors?: Array<{ message: string }>; +} + +export async function gqlQuery( + endpoint: string, + query: string, + variables?: Record, +): Promise> { + const response = await fetch(endpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + return { errors: [{ message: `GraphQL HTTP ${response.status}` }] }; + } + + return (await response.json()) as GraphqlResult; +} + +export interface IndexerMetadata { + lastProcessedHeight?: number; + targetHeight?: number; + indexerHealthy?: boolean; +} + +export async function fetchMetadata(endpoint: string): Promise { + const result = await gqlQuery<{ + _metadata: { + lastProcessedHeight?: number; + targetHeight?: number; + indexerHealthy?: boolean; + }; + }>( + endpoint, + `{ _metadata { lastProcessedHeight targetHeight indexerHealthy } }`, + ); + + return result.data?._metadata; +} + +/** True once the indexer is reachable and within `lagTolerance` of `targetLedger`. */ +export async function waitForIndexer( + endpoint: string, + targetLedger: number, + options: { lagTolerance: number; timeoutMs: number; pollMs?: number }, +): Promise<{ caughtUp: boolean; metadata?: IndexerMetadata }> { + const deadline = Date.now() + options.timeoutMs; + const pollMs = options.pollMs ?? 3000; + let metadata: IndexerMetadata | undefined; + + while (Date.now() < deadline) { + metadata = await fetchMetadata(endpoint); + const height = metadata?.lastProcessedHeight ?? 0; + if (height >= targetLedger - options.lagTolerance) { + return { caughtUp: true, metadata }; + } + await sleep(pollMs); + } + + return { caughtUp: false, metadata }; +} + +const COUNT_QUERY = `{ + markets { totalCount } + deposits { totalCount } + orders { totalCount } + positions { totalCount } + marketTokenTransfers { totalCount } + positionChanges { totalCount } + referralCodes { totalCount } +}`; + +type CountNode = { totalCount: number }; + +export async function fetchEntityCounts(endpoint: string): Promise { + const result = await gqlQuery<{ + markets: CountNode; + deposits: CountNode; + orders: CountNode; + positions: CountNode; + marketTokenTransfers: CountNode; + positionChanges: CountNode; + referralCodes: CountNode; + }>(endpoint, COUNT_QUERY); + + if (result.errors?.length) { + throw new Error(`GraphQL count query failed: ${result.errors.map((e) => e.message).join("; ")}`); + } + + const data = result.data; + return { + markets: data?.markets.totalCount ?? 0, + deposits: data?.deposits.totalCount ?? 0, + orders: data?.orders.totalCount ?? 0, + positions: data?.positions.totalCount ?? 0, + transfers: data?.marketTokenTransfers.totalCount ?? 0, + positionChanges: data?.positionChanges.totalCount ?? 0, + referralCodes: data?.referralCodes.totalCount ?? 0, + }; +} + +export function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/apps/s03-indexer/scripts/lib/process.ts b/apps/s03-indexer/scripts/lib/process.ts new file mode 100644 index 0000000..c3e892a --- /dev/null +++ b/apps/s03-indexer/scripts/lib/process.ts @@ -0,0 +1,78 @@ +// Thin wrapper around child_process for the smoke runner. +// +// Two modes: `run` captures stdout/stderr (used when we need the output, e.g. a +// contract key returned by an invoke) and `runStreaming` inherits stdio (used for +// long deploy/bootstrap commands so the contributor sees live progress). + +import { spawnSync } from "node:child_process"; + +export interface RunOptions { + cwd?: string; + env?: NodeJS.ProcessEnv; + /** When true, a non-zero exit code throws ProcessError instead of returning. */ + check?: boolean; + /** Inherit the parent stdio so output streams live (no capture). */ + stream?: boolean; + /** Max buffer for captured output in bytes (default 32 MiB). */ + maxBuffer?: number; +} + +export interface RunResult { + command: string; + code: number; + stdout: string; + stderr: string; +} + +export class ProcessError extends Error { + constructor( + message: string, + readonly result: RunResult, + ) { + super(message); + this.name = "ProcessError"; + } +} + +export function run(command: string, args: string[], options: RunOptions = {}): RunResult { + const printable = `${command} ${args.join(" ")}`.trim(); + + const completed = spawnSync(command, args, { + cwd: options.cwd, + env: options.env ?? process.env, + encoding: "utf8", + stdio: options.stream ? "inherit" : "pipe", + maxBuffer: options.maxBuffer ?? 32 * 1024 * 1024, + }); + + if (completed.error) { + const result: RunResult = { command: printable, code: -1, stdout: "", stderr: completed.error.message }; + if (options.check) { + throw new ProcessError(`Failed to run ${printable}: ${completed.error.message}`, result); + } + return result; + } + + const result: RunResult = { + command: printable, + code: completed.status ?? -1, + stdout: (completed.stdout ?? "").toString(), + stderr: (completed.stderr ?? "").toString(), + }; + + if (options.check && result.code !== 0) { + const tail = result.stderr.trim() || result.stdout.trim(); + throw new ProcessError(`Command exited ${result.code}: ${printable}\n${tail}`, result); + } + + return result; +} + +/** True when a binary is resolvable on PATH. */ +export function hasBinary(binary: string): boolean { + const probe = + process.platform === "win32" + ? run("where", [binary]) + : run("sh", ["-c", `command -v ${binary}`]); + return probe.code === 0 && probe.stdout.trim().length > 0; +} diff --git a/apps/s03-indexer/scripts/lib/protocol.ts b/apps/s03-indexer/scripts/lib/protocol.ts new file mode 100644 index 0000000..3c7f4b6 --- /dev/null +++ b/apps/s03-indexer/scripts/lib/protocol.ts @@ -0,0 +1,372 @@ +// SO4 protocol actions for the smoke flow. +// +// These composites mirror the proven contract-repo shell scripts +// (seed_liquidity.sh, test_positions.sh) but build the Soroban argument JSON +// directly in TypeScript — no python dependency — and submit fixed local oracle +// prices via `set_prices_simple`, which the local oracle build exposes for testing. +// +// Amount conventions (matching the contracts): +// TOKEN_PRECISION = 1e7 → 1 token = 10_000_000 raw units (7 decimals) +// FLOAT_PRECISION = 1e30 → USD/price values are scaled by 1e30 + +import { invoke, invokeRaw, type StellarContext } from "./stellar"; + +export const FLOAT_PRECISION = 10n ** 30n; +export const ONE_TOKEN = 10_000_000n; // 1 token at 7 decimals + +/** Fixed local USD prices per ticker (min == max for deterministic execution). */ +const PRICE_USD: Record = { + TUSDC: 1n, + TWBTC: 2000n, + TETH: 2000n, + TXLM: 1n, +}; + +export function priceFor(code: string): bigint { + return (PRICE_USD[code] ?? 1n) * FLOAT_PRECISION; +} + +export interface PriceEntry { + token: string; + price: bigint; +} + +/** Pull a transaction hash out of stellar CLI stderr, when present. */ +export function extractTxHash(stderr: string): string | undefined { + const match = stderr.match(/\b[0-9a-f]{64}\b/); + return match?.[0]; +} + +interface InvokeOutcome { + stdout: string; + txHash?: string; +} + +/** Invoke and return both the parsed value and any tx hash from the logs. */ +function invokeWithHash( + ctx: StellarContext, + contractId: string, + fn: string, + args: string[], +): InvokeOutcome { + const result = invokeRaw(ctx, contractId, fn, args); + if (result.code !== 0) { + const tail = result.stderr.trim() || result.stdout.trim(); + throw new Error(`invoke ${fn} on ${contractId} failed (${result.code}): ${tail}`); + } + return { stdout: result.stdout.trim(), txHash: extractTxHash(result.stderr) }; +} + +function unquote(value: string): string { + return value.replace(/^"|"$/g, ""); +} + +// ── Oracle ──────────────────────────────────────────────────────────────────── + +/** Submit fixed min==max prices for the given tokens via set_prices_simple. */ +export function submitPrices( + ctx: StellarContext, + oracle: string, + caller: string, + prices: PriceEntry[], +): void { + const payload = prices.map((entry) => ({ + token: entry.token, + min: entry.price.toString(), + max: entry.price.toString(), + })); + + invoke(ctx, oracle, "set_prices_simple", [ + "--caller", + caller, + "--prices", + JSON.stringify(payload), + ]); +} + +// ── Tokens / faucet ───────────────────────────────────────────────────────────── + +/** Claim test tokens from the faucet. Cooldown failures are tolerated. */ +export function claimFaucet( + ctx: StellarContext, + faucet: string, + account: string, + token: string, +): boolean { + const result = invokeRaw(ctx, faucet, "claim", ["--account", account, "--token", token]); + return result.code === 0; +} + +/** Approve a spender to pull tokens until `expirationLedger`. */ +export function approve( + ctx: StellarContext, + token: string, + from: string, + spender: string, + amount: bigint, + expirationLedger: number, +): void { + invoke(ctx, token, "approve", [ + "--from", + from, + "--spender", + spender, + "--amount", + amount.toString(), + "--expiration_ledger", + String(expirationLedger), + ]); +} + +// ── Deposit ───────────────────────────────────────────────────────────────────── + +export interface DepositFlowInput { + depositHandler: string; + oracle: string; + market: string; + longToken: string; + shortToken: string; + keeperAddr: string; + longAmount: bigint; + shortAmount: bigint; + expirationLedger: number; + prices: PriceEntry[]; +} + +export interface DepositFlowResult { + depositKey: string; + createTxHash?: string; + executeTxHash?: string; +} + +/** Approve, create, and execute a liquidity deposit into the market pool. */ +export function depositFlow(ctx: StellarContext, input: DepositFlowInput): DepositFlowResult { + submitPrices(ctx, input.oracle, input.keeperAddr, input.prices); + + approve(ctx, input.longToken, input.keeperAddr, input.depositHandler, input.longAmount, input.expirationLedger); + if (input.shortToken !== input.longToken) { + approve(ctx, input.shortToken, input.keeperAddr, input.depositHandler, input.shortAmount, input.expirationLedger); + } + + const params = { + receiver: input.keeperAddr, + market: input.market, + initial_long_token: input.longToken, + initial_short_token: input.shortToken, + long_token_amount: input.longAmount.toString(), + short_token_amount: input.shortAmount.toString(), + min_market_tokens: "0", + execution_fee: "0", + }; + + const created = invokeWithHash(ctx, input.depositHandler, "create_deposit", [ + "--caller", + input.keeperAddr, + "--params", + JSON.stringify(params), + ]); + const depositKey = unquote(created.stdout); + + // Oracle temp prices may expire between transactions — refresh before execute. + submitPrices(ctx, input.oracle, input.keeperAddr, input.prices); + + const executed = invokeWithHash(ctx, input.depositHandler, "execute_deposit", [ + "--keeper", + input.keeperAddr, + "--key", + depositKey, + ]); + + return { depositKey, createTxHash: created.txHash, executeTxHash: executed.txHash }; +} + +// ── Orders / positions ────────────────────────────────────────────────────────── + +type OrderType = "MarketIncrease" | "MarketDecrease"; + +export interface OpenPositionInput { + orderHandler: string; + orderVault: string; + exchangeRouter: string; + oracle: string; + market: string; + keeperAddr: string; + collateralToken: string; + collateralAmount: bigint; + sizeDeltaUsd: bigint; + acceptablePrice: bigint; + isLong: boolean; + expirationLedger: number; + prices: PriceEntry[]; +} + +export interface OrderFlowResult { + orderKey: string; + createTxHash?: string; + executeTxHash?: string; +} + +function buildOrderParams(args: { + keeperAddr: string; + market: string; + collateralToken: string; + sizeDeltaUsd: bigint; + collateralDeltaAmount: bigint; + acceptablePrice: bigint; + orderType: OrderType; + isLong: boolean; +}): string { + return JSON.stringify({ + receiver: args.keeperAddr, + market: args.market, + initial_collateral_token: args.collateralToken, + swap_path: [], + size_delta_usd: args.sizeDeltaUsd.toString(), + collateral_delta_amount: args.collateralDeltaAmount.toString(), + trigger_price: "0", + acceptable_price: args.acceptablePrice.toString(), + execution_fee: "0", + min_output_amount: "0", + order_type: { [args.orderType]: null }, + is_long: args.isLong, + }); +} + +/** Approve collateral, fund the order vault, create and execute a MarketIncrease. */ +export function openPositionFlow(ctx: StellarContext, input: OpenPositionInput): OrderFlowResult { + approve( + ctx, + input.collateralToken, + input.keeperAddr, + input.exchangeRouter, + input.collateralAmount, + input.expirationLedger, + ); + + submitPrices(ctx, input.oracle, input.keeperAddr, input.prices); + + invoke(ctx, input.exchangeRouter, "send_tokens", [ + "--caller", + input.keeperAddr, + "--token", + input.collateralToken, + "--receiver", + input.orderVault, + "--amount", + input.collateralAmount.toString(), + ]); + + const created = invokeWithHash(ctx, input.orderHandler, "create_order", [ + "--caller", + input.keeperAddr, + "--params", + buildOrderParams({ + keeperAddr: input.keeperAddr, + market: input.market, + collateralToken: input.collateralToken, + sizeDeltaUsd: input.sizeDeltaUsd, + collateralDeltaAmount: 0n, + acceptablePrice: input.acceptablePrice, + orderType: "MarketIncrease", + isLong: input.isLong, + }), + ]); + const orderKey = unquote(created.stdout); + + submitPrices(ctx, input.oracle, input.keeperAddr, input.prices); + + const executed = invokeWithHash(ctx, input.orderHandler, "execute_order", [ + "--keeper", + input.keeperAddr, + "--key", + orderKey, + ]); + + return { orderKey, createTxHash: created.txHash, executeTxHash: executed.txHash }; +} + +export interface ClosePositionInput { + orderHandler: string; + oracle: string; + market: string; + keeperAddr: string; + collateralToken: string; + sizeDeltaUsd: bigint; + acceptablePrice: bigint; + isLong: boolean; + prices: PriceEntry[]; +} + +/** Create and execute a MarketDecrease that reduces or closes a position. */ +export function closePositionFlow(ctx: StellarContext, input: ClosePositionInput): OrderFlowResult { + submitPrices(ctx, input.oracle, input.keeperAddr, input.prices); + + const created = invokeWithHash(ctx, input.orderHandler, "create_order", [ + "--caller", + input.keeperAddr, + "--params", + buildOrderParams({ + keeperAddr: input.keeperAddr, + market: input.market, + collateralToken: input.collateralToken, + sizeDeltaUsd: input.sizeDeltaUsd, + collateralDeltaAmount: 0n, + acceptablePrice: input.acceptablePrice, + orderType: "MarketDecrease", + isLong: input.isLong, + }), + ]); + const orderKey = unquote(created.stdout); + + submitPrices(ctx, input.oracle, input.keeperAddr, input.prices); + + const executed = invokeWithHash(ctx, input.orderHandler, "execute_order", [ + "--keeper", + input.keeperAddr, + "--key", + orderKey, + ]); + + return { orderKey, createTxHash: created.txHash, executeTxHash: executed.txHash }; +} + +// ── Referral (optional) ───────────────────────────────────────────────────────── + +export interface ReferralFlowResult { + code: string; + registerTxHash?: string; + setTxHash?: string; +} + +/** A deterministic 32-byte referral code derived from a short label. */ +export function referralCodeHex(label: string): string { + const bytes = Buffer.alloc(32); + Buffer.from(label, "utf8").copy(bytes, 0, 0, Math.min(label.length, 32)); + return bytes.toString("hex"); +} + +/** Register a referral code and assign the trader to it. */ +export function referralFlow( + ctx: StellarContext, + referralStorage: string, + trader: string, + label: string, +): ReferralFlowResult { + const code = referralCodeHex(label); + + const registered = invokeWithHash(ctx, referralStorage, "register_code", [ + "--caller", + trader, + "--code", + code, + ]); + + const set = invokeWithHash(ctx, referralStorage, "set_trader_referral_code", [ + "--trader", + trader, + "--code", + code, + ]); + + return { code, registerTxHash: registered.txHash, setTxHash: set.txHash }; +} diff --git a/apps/s03-indexer/scripts/lib/report.ts b/apps/s03-indexer/scripts/lib/report.ts new file mode 100644 index 0000000..414f5c6 --- /dev/null +++ b/apps/s03-indexer/scripts/lib/report.ts @@ -0,0 +1,165 @@ +// Step runner and JSON report writer for the smoke flow. +// +// Each protocol action is wrapped in `step()`, which records timing, status, the +// owning layer, and any structured data (ledgers, tx hashes, contract keys). The +// first failure is promoted to `report.failure` so the report's top tells you which +// layer broke. Fatal steps abort the run; non-fatal steps (e.g. the optional +// referral path) record a failure and continue. + +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +import type { + EntityCounts, + SmokeConfig, + SmokeLayer, + SmokeReport, + StepResult, +} from "./types"; + +export class StepAborted extends Error { + constructor(readonly step: StepResult) { + super(`Aborted at step: ${step.name}`); + this.name = "StepAborted"; + } +} + +export interface StepOptions { + /** Abort the whole run when this step fails (default true). */ + fatal?: boolean; +} + +export class SmokeRun { + private readonly startedAt = Date.now(); + private readonly steps: StepResult[] = []; + private readonly artifacts: Record = {}; + private readonly services: Record = {}; + private keeperAddress?: string; + private graphql?: SmokeReport["graphql"]; + private failure?: SmokeReport["failure"]; + + constructor(private readonly config: SmokeConfig) {} + + /** Run a labelled step, recording its outcome on the report. */ + async step( + name: string, + layer: SmokeLayer, + fn: () => Promise | T, + options: StepOptions = {}, + ): Promise { + const fatal = options.fatal ?? true; + const startedAt = new Date().toISOString(); + const started = Date.now(); + process.stdout.write(`\n▸ [${layer}] ${name}\n`); + + try { + const value = await fn(); + const record: StepResult = { + name, + layer, + status: "ok", + startedAt, + durationMs: Date.now() - started, + }; + if (value && typeof value === "object" && !Array.isArray(value)) { + record.data = value as Record; + } + this.steps.push(record); + process.stdout.write(` ✔ ${name}\n`); + return value; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const record: StepResult = { + name, + layer, + status: "failed", + startedAt, + durationMs: Date.now() - started, + error: message, + }; + this.steps.push(record); + this.failure ??= { step: name, layer, error: message }; + process.stderr.write(` ✖ ${name}: ${message}\n`); + + if (fatal) { + throw new StepAborted(record); + } + return undefined; + } + } + + /** Record a skipped step so the report shows the decision explicitly. */ + skip(name: string, layer: SmokeLayer, detail: string): void { + this.steps.push({ + name, + layer, + status: "skipped", + startedAt: new Date().toISOString(), + durationMs: 0, + detail, + }); + process.stdout.write(`\n⊘ [${layer}] ${name} — skipped: ${detail}\n`); + } + + setArtifact(key: string, value: unknown): void { + this.artifacts[key] = value; + } + + mergeArtifacts(values: Record): void { + Object.assign(this.artifacts, values); + } + + setService(name: string, reachable: boolean): void { + this.services[name] = reachable; + } + + setKeeperAddress(address: string): void { + this.keeperAddress = address; + } + + setGraphql(graphql: SmokeReport["graphql"]): void { + this.graphql = graphql; + } + + hasFailure(): boolean { + return this.failure !== undefined; + } + + build(): SmokeReport { + return { + network: this.config.network, + mode: this.config.mode, + startedAt: new Date(this.startedAt).toISOString(), + finishedAt: new Date().toISOString(), + durationMs: Date.now() - this.startedAt, + success: !this.failure, + contractsRepo: this.config.contractsRepo, + source: this.config.source, + keeperAddress: this.keeperAddress, + services: this.services, + artifacts: this.artifacts, + graphql: this.graphql, + steps: this.steps, + failure: this.failure, + }; + } + + /** Write the report JSON to disk and return its path. */ + write(): string { + const report = this.build(); + mkdirSync(path.dirname(this.config.reportPath), { recursive: true }); + writeFileSync(this.config.reportPath, `${JSON.stringify(report, null, 2)}\n`); + return this.config.reportPath; + } +} + +/** Compare observed counts against the minimums the smoke flow expects. */ +export function buildAssertions( + counts: EntityCounts, + expected: Partial, +): Array<{ entity: string; expected: number; actual: number; ok: boolean }> { + return Object.entries(expected).map(([entity, min]) => { + const actual = counts[entity as keyof EntityCounts] ?? 0; + return { entity, expected: min ?? 0, actual, ok: actual >= (min ?? 0) }; + }); +} diff --git a/apps/s03-indexer/scripts/lib/stellar.ts b/apps/s03-indexer/scripts/lib/stellar.ts new file mode 100644 index 0000000..6443b2b --- /dev/null +++ b/apps/s03-indexer/scripts/lib/stellar.ts @@ -0,0 +1,140 @@ +// Stellar CLI + Soroban RPC helpers for the smoke runner. +// +// All on-chain actions go through the `stellar` CLI so the smoke flow uses the +// exact same code path a contributor would type by hand, and so signing stays +// inside the CLI keystore (no raw secret keys ever touch this process). + +import { run, type RunResult } from "./process"; + +export interface StellarContext { + network: string; + source: string; + rpcUrl: string; +} + +/** Resolve a CLI key name to its G... public address. */ +export function keyAddress(name: string): string | undefined { + const result = run("stellar", ["keys", "address", name]); + if (result.code !== 0) { + return undefined; + } + const address = result.stdout.trim(); + return /^G[A-Z2-7]{55}$/.test(address) ? address : undefined; +} + +/** Generate a local-only CLI key if it does not already exist. */ +export function ensureKey(name: string, network: string): string { + const existing = keyAddress(name); + if (existing) { + return existing; + } + + run("stellar", ["keys", "generate", "--global", name, "--network", network], { check: true }); + const address = keyAddress(name); + if (!address) { + throw new Error(`Generated key "${name}" but could not resolve its address.`); + } + return address; +} + +/** Fund an address from a local friendbot. Best-effort; returns success. */ +export async function fundFromFriendbot(friendbotUrl: string, address: string): Promise { + try { + const url = `${friendbotUrl}?addr=${encodeURIComponent(address)}`; + const response = await fetch(url, { method: "GET" }); + return response.ok; + } catch { + return false; + } +} + +/** Invoke a contract function, returning the raw RunResult. */ +export function invokeRaw( + ctx: StellarContext, + contractId: string, + fn: string, + args: string[], + options: { stream?: boolean } = {}, +): RunResult { + return run( + "stellar", + [ + "contract", + "invoke", + "--id", + contractId, + "--source", + ctx.source, + "--network", + ctx.network, + "--", + fn, + ...args, + ], + { stream: options.stream }, + ); +} + +/** Invoke a contract function and throw on failure, returning trimmed stdout. */ +export function invoke( + ctx: StellarContext, + contractId: string, + fn: string, + args: string[], +): string { + const result = invokeRaw(ctx, contractId, fn, args); + if (result.code !== 0) { + const tail = result.stderr.trim() || result.stdout.trim(); + throw new Error(`invoke ${fn} on ${contractId} failed (${result.code}): ${tail}`); + } + return result.stdout.trim(); +} + +/** Invoke and JSON-parse the result. Falls back to the raw string when not JSON. */ +export function invokeJson( + ctx: StellarContext, + contractId: string, + fn: string, + args: string[], +): T { + const out = invoke(ctx, contractId, fn, args); + try { + return JSON.parse(out) as T; + } catch { + return out as unknown as T; + } +} + +/** Latest ledger sequence from the Soroban RPC, or undefined when unreachable. */ +export async function getLatestLedger(rpcUrl: string): Promise { + try { + const response = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestLedger" }), + }); + if (!response.ok) { + return undefined; + } + const body = (await response.json()) as { result?: { sequence?: number } }; + return body.result?.sequence; + } catch { + return undefined; + } +} + +/** True when the Soroban RPC answers getLatestLedger. */ +export async function rpcReachable(rpcUrl: string): Promise { + return (await getLatestLedger(rpcUrl)) !== undefined; +} + +/** Basic HTTP reachability probe (used for Horizon). */ +export async function httpReachable(url: string): Promise { + try { + const response = await fetch(url, { method: "GET" }); + // Any HTTP answer (even 4xx) proves the service is listening. + return response.status > 0; + } catch { + return false; + } +} diff --git a/apps/s03-indexer/scripts/lib/types.ts b/apps/s03-indexer/scripts/lib/types.ts new file mode 100644 index 0000000..1deb658 --- /dev/null +++ b/apps/s03-indexer/scripts/lib/types.ts @@ -0,0 +1,108 @@ +// Shared types for the local smoke flow. +// +// The smoke runner exercises the full stack — contract deploy/bootstrap, on-chain +// protocol actions, the SubQuery indexer runtime, and the GraphQL query layer — so +// every step is tagged with the layer it belongs to. When a step fails the report +// names that layer, which is what lets a contributor tell "contracts didn't deploy" +// apart from "the indexer never caught up" without reading the whole log. + +export type SmokeLayer = + | "preflight" + | "services" + | "contracts-deploy" + | "contract-action" + | "indexer-runtime" + | "graphql-query" + | "frontend-config"; + +export type StepStatus = "ok" | "failed" | "skipped"; + +export interface StepResult { + name: string; + layer: SmokeLayer; + status: StepStatus; + startedAt: string; + durationMs: number; + /** Human-readable note (skip reason, summary, etc.). */ + detail?: string; + /** Error message when status is "failed". */ + error?: string; + /** Structured payload — ledger numbers, tx hashes, contract keys, counts. */ + data?: Record; +} + +/** How the smoke run sources its deployment. */ +export type SmokeMode = "auto" | "fresh" | "existing"; + +export interface SmokeConfig { + /** Stellar network alias used by the stellar CLI and deploy scripts. */ + network: string; + /** Absolute path to the SO4 contracts repo. */ + contractsRepo: string; + /** stellar CLI key name that signs admin/deploy transactions. */ + source: string; + /** stellar CLI key name granted keeper roles (defaults to source). */ + keeper: string; + /** Long/index token ticker for the bootstrapped market. */ + longCode: string; + /** Short token ticker for the bootstrapped market. */ + shortCode: string; + /** Deployment sourcing strategy. */ + mode: SmokeMode; + /** Horizon endpoint reachable from the host. */ + horizonEndpoint: string; + /** Soroban RPC endpoint reachable from the host. */ + sorobanRpcEndpoint: string; + /** Optional friendbot URL used to fund a freshly generated local key. */ + friendbotUrl?: string; + /** SubQuery GraphQL endpoint. */ + graphqlEndpoint: string; + /** Max ledgers the indexer may lag behind head and still count as caught up. */ + indexerLagTolerance: number; + /** How long to wait for the indexer to catch up, in milliseconds. */ + waitTimeoutMs: number; + /** Skip the optional referral register/set step. */ + skipReferral: boolean; + /** Skip indexer rebuild/restart (assume it is already running with fresh config). */ + skipIndexerRestart: boolean; + /** Skip the GraphQL assertion phase (contracts-only smoke). */ + skipIndexerCheck: boolean; + /** Where to write the JSON smoke report. */ + reportPath: string; +} + +export interface EntityCounts { + markets: number; + deposits: number; + orders: number; + positions: number; + transfers: number; + positionChanges: number; + referralCodes: number; +} + +export interface SmokeReport { + network: string; + mode: SmokeMode; + startedAt: string; + finishedAt: string; + durationMs: number; + success: boolean; + contractsRepo: string; + source: string; + keeperAddress?: string; + services: Record; + /** Deployment + action artifacts: contract IDs, market token, keys, tx hashes. */ + artifacts: Record; + graphql?: { + endpoint: string; + reachable: boolean; + lastProcessedHeight?: number; + targetHeight?: number; + counts?: EntityCounts; + assertions?: Array<{ entity: string; expected: number; actual: number; ok: boolean }>; + }; + steps: StepResult[]; + /** First failing step, surfaced at the top so the broken layer is obvious. */ + failure?: { step: string; layer: SmokeLayer; error: string }; +} diff --git a/apps/s03-indexer/scripts/local-smoke.ts b/apps/s03-indexer/scripts/local-smoke.ts new file mode 100644 index 0000000..08b61de --- /dev/null +++ b/apps/s03-indexer/scripts/local-smoke.ts @@ -0,0 +1,515 @@ +// Local SO4 indexing smoke flow. +// +// One command that proves contracts + indexer + GraphQL work together end to end: +// deploy/bootstrap (or validate an existing local deployment), run real protocol +// actions (deposit, open position, close position, optional referral), then assert +// the SubQuery indexer turned those events into GraphQL entities. Writes a JSON +// report whose first failure names the broken layer. +// +// Usage: +// bun run scripts/local-smoke.ts [--mode auto|fresh|existing] [--contracts-repo PATH] +// bun run --cwd apps/s03-indexer smoke:local +// +// See README "Local Smoke Flow" for the full option list. + +import path from "node:path"; + +import { buildConfig, SMOKE_PATHS } from "./lib/config"; +import { + checkCoreContracts, + loadDeployment, + resolveMarket, + resolveToken, + type MarketTriplet, +} from "./lib/deployment"; +import { fetchEntityCounts, waitForIndexer } from "./lib/graphql"; +import { hasBinary, run } from "./lib/process"; +import { + claimFaucet, + closePositionFlow, + depositFlow, + FLOAT_PRECISION, + ONE_TOKEN, + openPositionFlow, + priceFor, + referralFlow, + submitPrices, + type PriceEntry, +} from "./lib/protocol"; +import { existsSync } from "node:fs"; +import { buildAssertions, SmokeRun, StepAborted } from "./lib/report"; +import { + ensureKey, + fundFromFriendbot, + getLatestLedger, + httpReachable, + keyAddress, + rpcReachable, + type StellarContext, +} from "./lib/stellar"; +import type { SmokeConfig } from "./lib/types"; + +const config = buildConfig(process.argv.slice(2)); +const ctx: StellarContext = { + network: config.network, + source: config.source, + rpcUrl: config.sorobanRpcEndpoint, +}; +const smoke = new SmokeRun(config); + +main() + .then((ok) => finish(ok)) + .catch((error) => { + if (!(error instanceof StepAborted)) { + process.stderr.write(`\nUnexpected error: ${error instanceof Error ? error.stack : error}\n`); + } + finish(false); + }); + +async function main(): Promise { + printHeader(); + + await preflight(); + const keeperAddr = await ensureKeys(); + await checkServices(); + + const market = await ensureDeployment(); + const deployment = loadDeployment(config.contractsRepo, config.network); + recordDeploymentArtifacts(deployment.env, market); + + await syncIndexerConfig(); + await prepareIndexer(); + + const ledgerBefore = await getLatestLedger(config.sorobanRpcEndpoint); + await runProtocolActions(keeperAddr, deployment.env, market); + const ledgerAfter = (await getLatestLedger(config.sorobanRpcEndpoint)) ?? ledgerBefore; + + await assertIndexedEntities(ledgerAfter ?? 0); + + return !smoke.hasFailure(); +} + +// ── Phase: preflight ──────────────────────────────────────────────────────────── + +async function preflight(): Promise { + await smoke.step("Check required binaries", "preflight", () => { + const required = ["stellar"]; + const missing = required.filter((bin) => !hasBinary(bin)); + if (missing.length) { + throw new Error(`Missing required CLI tools: ${missing.join(", ")}`); + } + return { + stellar: true, + make: hasBinary("make"), + docker: hasBinary("docker"), + cargo: hasBinary("cargo"), + }; + }); + + await smoke.step("Locate contracts repo", "preflight", () => { + const scripts = path.join(config.contractsRepo, "scripts"); + if (!existsSync(scripts)) { + throw new Error( + `Contracts repo not usable at ${config.contractsRepo} (no scripts/ directory). Set SO4_CONTRACTS_REPO.`, + ); + } + return { contractsRepo: config.contractsRepo }; + }); +} + +async function ensureKeys(): Promise { + const result = await smoke.step("Ensure local signing keys", "preflight", async () => { + const sourceAddr = ensureKey(config.source, config.network); + const keeperAddr = config.keeper === config.source ? sourceAddr : ensureKey(config.keeper, config.network); + + // Fresh local networks need the keys funded before any transaction. + if (config.friendbotUrl) { + await fundFromFriendbot(config.friendbotUrl, sourceAddr); + if (keeperAddr !== sourceAddr) { + await fundFromFriendbot(config.friendbotUrl, keeperAddr); + } + } + return { sourceAddr, keeperAddr }; + }); + + const keeperAddr = (result?.keeperAddr as string) ?? keyAddress(config.keeper) ?? ""; + if (keeperAddr) { + smoke.setKeeperAddress(keeperAddr); + } + return keeperAddr; +} + +// ── Phase: services ───────────────────────────────────────────────────────────── + +async function checkServices(): Promise { + await smoke.step("Soroban RPC reachable", "services", async () => { + const reachable = await rpcReachable(config.sorobanRpcEndpoint); + smoke.setService("sorobanRpc", reachable); + if (!reachable) { + throw new Error(`Soroban RPC not reachable at ${config.sorobanRpcEndpoint}. Start your local network first.`); + } + return { endpoint: config.sorobanRpcEndpoint }; + }); + + await smoke.step("Horizon reachable", "services", async () => { + const reachable = await httpReachable(config.horizonEndpoint); + smoke.setService("horizon", reachable); + return { endpoint: config.horizonEndpoint, reachable }; + }, { fatal: false }); + + if (!config.skipIndexerCheck) { + await smoke.step("GraphQL endpoint reachable", "services", async () => { + const reachable = await httpReachable(config.graphqlEndpoint); + smoke.setService("graphql", reachable); + return { endpoint: config.graphqlEndpoint, reachable }; + }, { fatal: false }); + } +} + +// ── Phase: deployment ─────────────────────────────────────────────────────────── + +async function ensureDeployment(): Promise { + const initial = loadDeployment(config.contractsRepo, config.network); + const coreOk = checkCoreContracts(initial.env).ok; + const existingMarket = resolveMarket(initial.env, config.longCode, config.shortCode); + const usable = coreOk && existingMarket !== undefined; + + if (config.mode === "existing") { + await smoke.step("Validate existing deployment", "contracts-deploy", () => { + const check = checkCoreContracts(initial.env); + if (!check.ok) { + throw new Error( + `Existing deployment incomplete. Missing: ${check.missing.join(", ") || "none"}; invalid: ${check.invalid.join(", ") || "none"}.`, + ); + } + if (!existingMarket) { + throw new Error(`No bootstrapped ${config.longCode}/${config.shortCode} market in ${initial.paths.deployEnv}.`); + } + return { deployEnv: initial.paths.deployEnv, market: existingMarket.marketToken }; + }); + return existingMarket as MarketTriplet; + } + + if (config.mode === "auto" && usable) { + smoke.skip("Deploy contracts", "contracts-deploy", "existing deployment detected"); + return existingMarket as MarketTriplet; + } + + // Fresh path: deploy/bootstrap through the contract repo's maintained targets. + if (!hasBinary("make")) { + throw new Error("`make` is required to deploy contracts. Provide an existing deployment with --mode existing."); + } + + await smoke.step("Deploy test tokens + faucet", "contracts-deploy", () => { + makeTarget("test-tokens-with-faucet", [ + `LONG_CODE=${config.longCode}`, + `SHORT_CODE=${config.shortCode}`, + ]); + return { target: "test-tokens-with-faucet" }; + }); + + await smoke.step("Deploy protocol contracts", "contracts-deploy", () => { + makeTarget("deploy-all", []); + return { target: "deploy-all" }; + }); + + await smoke.step("Bootstrap market", "contracts-deploy", () => { + makeTarget("bootstrap", [ + `LONG_CODE=${config.longCode}`, + `SHORT_CODE=${config.shortCode}`, + `KEEPER=${config.keeper}`, + ]); + return { target: "bootstrap" }; + }); + + const reloaded = loadDeployment(config.contractsRepo, config.network); + const market = resolveMarket(reloaded.env, config.longCode, config.shortCode); + if (!market) { + throw new Error(`Bootstrap completed but no ${config.longCode}/${config.shortCode} market was found.`); + } + return market; +} + +function makeTarget(target: string, vars: string[]): void { + run( + "make", + [target, `NETWORK=${config.network}`, `SOURCE=${config.source}`, ...vars], + { cwd: config.contractsRepo, stream: true, check: true }, + ); +} + +function recordDeploymentArtifacts(env: Record, market: MarketTriplet): void { + smoke.mergeArtifacts({ + market: { + key: market.envKey, + marketToken: market.marketToken, + indexToken: market.indexToken, + longToken: market.longToken, + shortToken: market.shortToken, + }, + contracts: { + oracle: env.ORACLE, + depositHandler: env.DEPOSIT_HANDLER, + orderHandler: env.ORDER_HANDLER, + orderVault: env.ORDER_VAULT, + exchangeRouter: env.EXCHANGE_ROUTER, + referralStorage: env.REFERRAL_STORAGE, + }, + faucet: env.FAUCET, + }); +} + +// ── Phase: indexer config + runtime ────────────────────────────────────────────── + +async function syncIndexerConfig(): Promise { + await smoke.step("Sync indexer contract manifest", "frontend-config", () => { + run("bun", ["run", "scripts/sync-contracts.ts", "--network", config.network], { + cwd: SMOKE_PATHS.packageRoot, + env: { ...process.env, SO4_CONTRACTS_REPO: config.contractsRepo }, + stream: true, + check: true, + }); + return { config: `config/contracts.${config.network}.json` }; + }); +} + +async function prepareIndexer(): Promise { + if (config.skipIndexerCheck) { + smoke.skip("Prepare indexer runtime", "indexer-runtime", "--skip-indexer-check"); + return; + } + + if (config.skipIndexerRestart) { + smoke.skip("Rebuild + restart indexer", "indexer-runtime", "--skip-indexer-restart"); + } else { + await smoke.step("Rebuild indexer for local config", "indexer-runtime", () => { + // project.ts reads endpoints/contracts from the synced local config; clear the + // host-only endpoint overrides so the container uses host.docker.internal. + const buildEnv = { ...process.env, INDEXER_NETWORK: config.network, INDEXER_START_LEDGER: "1" }; + delete buildEnv.ENDPOINT; + delete buildEnv.SOROBAN_ENDPOINT; + delete buildEnv.CHAIN_ID; + run("bun", ["run", "build"], { cwd: SMOKE_PATHS.packageRoot, env: buildEnv, stream: true, check: true }); + return { startLedger: 1 }; + }); + + await smoke.step("Start indexer stack", "indexer-runtime", () => { + if (!hasBinary("docker")) { + throw new Error("`docker` is required to start the indexer stack. Re-run with --skip-indexer-restart if it is already running."); + } + run("docker", ["compose", "up", "-d", "--remove-orphans"], { + cwd: SMOKE_PATHS.packageRoot, + stream: true, + check: true, + }); + return { stack: "docker compose" }; + }); + } +} + +// ── Phase: protocol actions ────────────────────────────────────────────────────── + +async function runProtocolActions( + keeperAddr: string, + env: Record, + market: MarketTriplet, +): Promise { + const oracle = requireContract(env, "ORACLE"); + const depositHandler = requireContract(env, "DEPOSIT_HANDLER"); + const orderHandler = requireContract(env, "ORDER_HANDLER"); + const orderVault = requireContract(env, "ORDER_VAULT"); + const exchangeRouter = requireContract(env, "EXCHANGE_ROUTER"); + + const prices = marketPrices(market); + const currentLedger = (await getLatestLedger(config.sorobanRpcEndpoint)) ?? 0; + const expirationLedger = currentLedger + 50_000; + + await smoke.step("Submit initial oracle prices", "contract-action", () => { + // Submit up front so we fail early if the oracle rejects fixed prices + // (wrong keeper role, missing testutils build, etc.). + submitPrices(ctx, oracle, keeperAddr, prices); + return { tokens: prices.length }; + }); + + await smoke.step("Claim test tokens from faucet", "contract-action", () => { + const faucet = env.FAUCET; + if (!faucet) { + return { claimed: false, reason: "no FAUCET in deployment" }; + } + const long = claimFaucet(ctx, faucet, keeperAddr, market.longToken); + const short = claimFaucet(ctx, faucet, keeperAddr, market.shortToken); + return { longClaimed: long, shortClaimed: short }; + }, { fatal: false }); + + const deposit = await smoke.step("Create + execute deposit", "contract-action", () => + depositFlow(ctx, { + depositHandler, + oracle, + market: market.marketToken, + longToken: market.longToken, + shortToken: market.shortToken, + keeperAddr, + longAmount: 10n * ONE_TOKEN, + shortAmount: 10n * ONE_TOKEN, + expirationLedger, + prices, + }), + ); + smoke.setArtifact("deposit", deposit); + + const longPrice = priceFor(config.longCode); + const sizeDeltaUsd = longPrice * 2n; // 2x leverage on 1 token of collateral + const open = await smoke.step("Open long position (MarketIncrease)", "contract-action", () => + openPositionFlow(ctx, { + orderHandler, + orderVault, + exchangeRouter, + oracle, + market: market.marketToken, + keeperAddr, + collateralToken: market.longToken, + collateralAmount: ONE_TOKEN, + sizeDeltaUsd, + acceptablePrice: (longPrice * 101n) / 100n, + isLong: true, + expirationLedger, + prices, + }), + ); + smoke.setArtifact("openOrder", open); + + const close = await smoke.step("Close long position (MarketDecrease)", "contract-action", () => + closePositionFlow(ctx, { + orderHandler, + oracle, + market: market.marketToken, + keeperAddr, + collateralToken: market.longToken, + sizeDeltaUsd, + acceptablePrice: (longPrice * 99n) / 100n, + isLong: true, + prices, + }), + ); + smoke.setArtifact("closeOrder", close); + + if (config.skipReferral) { + smoke.skip("Register + set referral code", "contract-action", "--skip-referral"); + } else { + const referral = await smoke.step( + "Register + set referral code", + "contract-action", + () => referralFlow(ctx, requireContract(env, "REFERRAL_STORAGE"), keeperAddr, "SO4LOCAL"), + { fatal: false }, + ); + if (referral) { + smoke.setArtifact("referral", referral); + } + } +} + +function marketPrices(market: MarketTriplet): PriceEntry[] { + const entries = new Map(); + entries.set(market.longToken, priceFor(config.longCode)); + entries.set(market.indexToken, priceFor(config.longCode)); + entries.set(market.shortToken, priceFor(config.shortCode)); + return [...entries].map(([token, price]) => ({ token, price })); +} + +// ── Phase: GraphQL assertions ──────────────────────────────────────────────────── + +async function assertIndexedEntities(targetLedger: number): Promise { + if (config.skipIndexerCheck) { + smoke.skip("Assert indexed entities", "graphql-query", "--skip-indexer-check"); + return; + } + + await smoke.step("Wait for indexer to catch up", "indexer-runtime", async () => { + const { caughtUp, metadata } = await waitForIndexer(config.graphqlEndpoint, targetLedger, { + lagTolerance: config.indexerLagTolerance, + timeoutMs: config.waitTimeoutMs, + }); + if (!caughtUp) { + throw new Error( + `Indexer did not reach ledger ${targetLedger} within ${config.waitTimeoutMs}ms ` + + `(last processed: ${metadata?.lastProcessedHeight ?? "unknown"}).`, + ); + } + return { targetLedger, lastProcessedHeight: metadata?.lastProcessedHeight }; + }); + + await smoke.step("Assert indexed entities via GraphQL", "graphql-query", async () => { + const counts = await fetchEntityCounts(config.graphqlEndpoint); + const expected = { + markets: 1, + deposits: 1, + orders: 2, + positions: 1, + transfers: 1, + }; + const assertions = buildAssertions(counts, expected); + const failed = assertions.filter((a) => !a.ok); + + smoke.setGraphql({ + endpoint: config.graphqlEndpoint, + reachable: true, + counts, + assertions, + }); + + if (failed.length) { + throw new Error( + `Entity assertions failed: ${failed.map((a) => `${a.entity} ${a.actual}<${a.expected}`).join(", ")}`, + ); + } + return counts; + }); +} + +// ── Helpers ────────────────────────────────────────────────────────────────────── + +function requireContract(env: Record, key: string): string { + const value = env[key]; + if (!value) { + throw new Error(`Required contract ${key} missing from deployment output.`); + } + return value; +} + +function printHeader(): void { + process.stdout.write( + [ + "SO4 local indexing smoke flow", + ` network : ${config.network}`, + ` mode : ${config.mode}`, + ` contracts repo : ${config.contractsRepo}`, + ` source / keeper: ${config.source} / ${config.keeper}`, + ` soroban rpc : ${config.sorobanRpcEndpoint}`, + ` graphql : ${config.graphqlEndpoint}`, + ` report : ${config.reportPath}`, + "", + ].join("\n") + "\n", + ); +} + +function finish(success: boolean): void { + const reportPath = smoke.write(); + const report = smoke.build(); + + process.stdout.write("\n──────────────────────────────────────────────\n"); + process.stdout.write(`Smoke run ${success ? "PASSED" : "FAILED"} in ${report.durationMs}ms\n`); + if (report.graphql?.counts) { + const c = report.graphql.counts; + process.stdout.write( + `Entity counts: markets=${c.markets} deposits=${c.deposits} orders=${c.orders} ` + + `positions=${c.positions} transfers=${c.transfers}\n`, + ); + } + if (report.failure) { + process.stdout.write(`Broken layer: ${report.failure.layer} — ${report.failure.step}\n`); + process.stdout.write(` ${report.failure.error}\n`); + } + process.stdout.write(`Report: ${reportPath}\n`); + + process.exit(success ? 0 : 1); +}