diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0538645..714250a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Generated contract code matches the submodule run: npm run check:contracts + - name: JSON schemas match source definitions + run: npm run check:schemas + - run: npx prisma generate - run: npx prisma migrate deploy - name: Verify schema datamodel matches migrations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 967e26c..6323405 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,6 +30,7 @@ Forgot `--recurse-submodules`? Run `git submodule update --init`. ```bash npm run check:contracts # generated contract code matches the submodule +npm run check:schemas # JSON schemas match source definitions npx prisma migrate deploy # against a scratch database npx prisma migrate diff --from-migrations prisma/migrations --to-schema-datamodel prisma/schema.prisma --exit-code npx tsx scripts/audit-constraints.ts --json audit.json diff --git a/docs/api.md b/docs/api.md index 3b7a12c..7044d44 100644 --- a/docs/api.md +++ b/docs/api.md @@ -1619,5 +1619,69 @@ Every call in the frontend's `lib/api/*.ts` and where it lands: | `unlockTrace` | `POST /traces/:id/unlock` (daily pass) | | `getSpendingAllowance` / `setSpendingAllowance` | `GET` / `PUT /traces/access/allowance` | | `getPaymentEvents` | `GET /traces/payments` | +| `getTraceKey` | `GET /traces/:id/key` | +| `getLeaderboard` | `GET /leaderboard` | +| `getWalletStats` | `GET /wallets/:address/stats` | | `getResolution` | `GET /oracle/markets/:marketId/resolution` | | per-trace unlock | x402 `GET {X402_URL}/traces/:onChainTraceId` | + +--- + +## Schemas + +Public, immutable JSON Schema 2020-12 documents defining verifiable artefacts: + +- `GET /api/v1/schemas/trace/v1.json`: Reasoning trace document schema v1. +- `GET /api/v1/schemas/question/v2.json`: Question document schema v2 (with `resolutionSpecHash` and category enums). +- `GET /api/v1/schemas/question/v1.json`: Legacy question document schema v1 for historical verification. + +Served with `Content-Type: application/schema+json` and `Cache-Control: public, max-age=31536000, immutable`. + +--- + +## Confidential Trace Key Release + +#### `GET /traces/:id/key` (auth, active subscription required) +Returns the per-trace data key for client-side decryption: +```json +{ + "kid": "kek-2026-10", + "dataKey": "", + "plaintextHash": "64-hex" +} +``` + +#### `POST /internal/traces/:onChainTraceId/key` (shared secret HMAC) +Internal endpoint for x402 services to obtain data keys after verifying on-chain micropayments. Requires `x-signature`, `x-timestamp`, `x-nonce` with replay protection. + +--- + +## Admin Generation Runs (auth, admin) + +#### `GET /admin/generation-runs` +Lists saga generation runs with status, inputs, outputs, and lock details. Query: `state`, `page`, `limit`. + +#### `GET /admin/generation-runs/:id` +Inspects a specific saga generation run. + +#### `POST /admin/generation-runs/:id/cancel` +Cancels an incomplete run in `NEEDS_ATTENTION` before on-chain submission, safely setting associated pending market to `CANCELLED`. + +--- + +## Leaderboard and Wallet Stats + +Exact integer accounting in 7-decimal USDC base units (no floating-point rounding drift). + +#### Accounting Rules: +- **Average Cost Basis**: Buy collateral (including protocol fee) enters the cost basis. On partial sells, cost is removed proportionally (`cost * sharesIn / shares`). +- **Settlement**: On resolution, winning shares are worth 1.0 USDC each; losing shares are worth 0. Void markets settle combined Yes and No positions with integer division `(yes + no) / 2` matching contract `settle_value`. +- **Incomplete History Flag**: Wallets with trades prior to indexer retention or exceeding known balances are flagged `incomplete: true` and excluded from rankings by default. + +#### `GET /leaderboard` +Query params: `window=7d|30d|all` (default `all`), `category`, `metric=pnl|volume|roi` (default `pnl`), `page`, `limit`, `includeIncomplete=true|false`. +Responses are cached in Redis for 30 seconds. + +#### `GET /wallets/:address/stats` +Returns totals, category breakdown, open positions with mark-to-market valuations, and last 50 trades from the indexer. + diff --git a/docs/trace-format.md b/docs/trace-format.md new file mode 100644 index 0000000..3e70480 --- /dev/null +++ b/docs/trace-format.md @@ -0,0 +1,137 @@ +# Pinned Document and Trace Format Specification + +OracleDesk pins verifiable artefacts to IPFS: +1. **Question Documents**: committed on-chain through `market_core.agent_create_market(question_hash, meta_uri, ...)` +2. **Reasoning Traces**: committed on-chain through `reasoning_registry.publish_trace(trace_hash, ipfs_cid, ...)` + +--- + +## 1. Versioning Policy + +Every pinned document includes explicit metadata: +- `schemaVersion`: an integer version number (e.g. `1` for TraceDocV1, `2` for QuestionDocV2). +- `kind`: document discriminator (e.g. `'oracledesk.trace'`, `'oracledesk.question'`, `'oracledesk.trace.enc'`). + +Any semantic change, added required field, or removed field bumps `schemaVersion`. +Legacy unversioned documents are permanently identified as `schemaVersion: 0` in API responses and continue to verify by their exact sha256 byte hash. + +--- + +## 2. Canonical JSON Serialization + +To ensure consistent cryptographic hashing across different runtimes and languages, OracleDesk follows strict JSON canonicalization: +1. Object keys are sorted lexicographically (UTF-8 code units) at every level. +2. No unnecessary whitespace is added (`separators=(',', ':')`). +3. Floating point numbers use standard shortest round-trip formatting. +4. Optional fields are explicitly set to `null` rather than omitted, preserving identical key sets across all code paths. +5. Strict checks reject `undefined`, functions, symbols, `Date` objects, and non-finite numbers (`NaN`, `Infinity`). + +> [!IMPORTANT] +> Verifiers must check hashes against the **raw fetched bytes** from IPFS, rather than parsing and re-serializing the JSON. Re-serializing in other languages or engines can cause float or unicode differences. + +--- + +## 3. Reasoning Trace Document (v1) + +JSON Schema URL: `https://api.oracledesk.io/api/v1/schemas/trace/v1.json` + +```json +{ + "schemaVersion": 1, + "kind": "oracledesk.trace", + "traceId": "11111111-1111-1111-1111-111111111111", + "agentType": "MARKET_MAKER", + "decisionType": "MARKET_CREATION", + "action": "create_market", + "market": { + "backendId": "22222222-2222-2222-2222-222222222222", + "onChainId": "0" + }, + "probabilityEstimate": 0.65, + "marketProbability": 0.5, + "edge": 0.15, + "confidenceInterval": { + "lower": 0.55, + "upper": 0.75 + }, + "sourcesUsed": [ + { + "source": "FRED", + "weight": 0.8, + "signal": "Federal funds futures imply 25bps cut", + "rawValue": 0.75 + } + ], + "betFraction": null, + "betSizeUsdc": null, + "hedgeConditions": [ + "CPI YoY exceeds 3.0%" + ], + "agentWallet": null, + "createdAt": "2026-10-17T00:00:00.000Z" +} +``` + +--- + +## 4. Confidential Trace Envelope (`oracledesk.trace.enc` v1) + +When `TRACE_CONFIDENTIAL=true`, full traces are encrypted with XChaCha20-Poly1305 before pinning. + +```json +{ + "kind": "oracledesk.trace.enc", + "schemaVersion": 1, + "alg": "XChaCha20-Poly1305", + "kid": "kek-2026-10", + "plaintextHash": "89b51fa16ce70...", + "public": { + "traceId": "11111111-1111-1111-1111-111111111111", + "agentType": "MARKET_MAKER", + "decisionType": "MARKET_CREATION", + "market": { + "backendId": "22222222-2222-2222-2222-222222222222", + "onChainId": "0" + }, + "probabilityEstimate": 0.65, + "marketProbability": 0.5, + "edge": 0.15, + "previewSources": [ + { + "source": "FRED", + "weight": 0.8, + "signal": "Federal funds futures imply 25bps cut", + "rawValue": 0.75 + } + ], + "createdAt": "2026-10-17T00:00:00.000Z" + }, + "nonce": "", + "ciphertext": "" +} +``` + +- **AEAD Associated Data**: Canonical JSON bytes of the envelope excluding `nonce` and `ciphertext`. +- **Public Verifiability**: + 1. The on-chain `trace_hash` in `reasoning_registry` equals `sha256(pinned_bytes)` (the canonical envelope bytes). + 2. Entitled users unwrap the per-trace data key and decrypt the ciphertext to verify `sha256(plaintext) === envelope.plaintextHash`. + +--- + +## 5. Question Document (v2) + +JSON Schema URL: `https://api.oracledesk.io/api/v1/schemas/question/v2.json` + +```json +{ + "schemaVersion": 2, + "kind": "oracledesk.question", + "question": "Will the Federal Reserve cut the target policy rate by 25bps or more at the Dec 2026 FOMC meeting?", + "category": "FED", + "contractCategory": "Macro", + "closeTime": 1798761600, + "resolutionSource": "FRED", + "resolutionSpecHash": "a1b2c3d4...", + "backendMarketId": "22222222-2222-2222-2222-222222222222" +} +``` diff --git a/prisma/migrations/20261017100000_market_generation_runs/migration.sql b/prisma/migrations/20261017100000_market_generation_runs/migration.sql new file mode 100644 index 0000000..33d4db6 --- /dev/null +++ b/prisma/migrations/20261017100000_market_generation_runs/migration.sql @@ -0,0 +1,41 @@ +-- Market generation saga runs table and state enum (Issue #152) +-- Forward-only migration. Does not alter existing table structures. + +CREATE TYPE "GenerationRunState" AS ENUM ( + 'STARTED', + 'SIGNALS_READY', + 'PROPOSED', + 'MARKET_PERSISTED', + 'QUESTION_PINNED', + 'CHAIN_SUBMITTED', + 'CHAIN_CONFIRMED', + 'TRACE_PERSISTED', + 'TRACE_PINNED', + 'TRACE_PUBLISHED', + 'COMPLETED', + 'SKIPPED', + 'NEEDS_ATTENTION', + 'CANCELLED' +); + +CREATE TABLE "market_generation_runs" ( + "id" TEXT NOT NULL, + "trigger" TEXT NOT NULL, + "state" "GenerationRunState" NOT NULL DEFAULT 'STARTED', + "attempts" INTEGER NOT NULL DEFAULT 0, + "nextAttemptAt" TIMESTAMP(3), + "lockedBy" TEXT, + "lockedUntil" TIMESTAMP(3), + "inputs" JSONB, + "outputs" JSONB NOT NULL DEFAULT '{}', + "marketId" TEXT, + "lastError" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "market_generation_runs_pkey" PRIMARY KEY ("id"), + CONSTRAINT "market_generation_runs_marketId_key" UNIQUE ("marketId"), + CONSTRAINT "market_generation_runs_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "markets"("id") ON DELETE SET NULL ON UPDATE CASCADE +); + +CREATE INDEX "market_generation_runs_state_nextAttemptAt_idx" ON "market_generation_runs"("state", "nextAttemptAt"); diff --git a/prisma/migrations/20261017100001_confidential_traces/migration.sql b/prisma/migrations/20261017100001_confidential_traces/migration.sql new file mode 100644 index 0000000..e6b4160 --- /dev/null +++ b/prisma/migrations/20261017100001_confidential_traces/migration.sql @@ -0,0 +1,18 @@ +-- Confidential trace storage and key envelope tables (Issue #154) +-- Forward-only migration. Existing traces remain PLAINTEXT_LEGACY. + +CREATE TYPE "TraceContentMode" AS ENUM ('PLAINTEXT_LEGACY', 'ENCRYPTED'); + +ALTER TABLE "reasoning_traces" ADD COLUMN "contentMode" "TraceContentMode" NOT NULL DEFAULT 'PLAINTEXT_LEGACY'; +ALTER TABLE "reasoning_traces" ADD COLUMN "plaintextHash" VARCHAR(64); + +CREATE TABLE "trace_keys" ( + "traceId" TEXT NOT NULL, + "kid" TEXT NOT NULL, + "wrappedKey" BYTEA NOT NULL, + "plaintextHash" VARCHAR(64) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "trace_keys_pkey" PRIMARY KEY ("traceId"), + CONSTRAINT "trace_keys_traceId_fkey" FOREIGN KEY ("traceId") REFERENCES "reasoning_traces"("id") ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/prisma/migrations/20261017100002_wallet_pnl/migration.sql b/prisma/migrations/20261017100002_wallet_pnl/migration.sql new file mode 100644 index 0000000..73d16f4 --- /dev/null +++ b/prisma/migrations/20261017100002_wallet_pnl/migration.sql @@ -0,0 +1,55 @@ +-- Per-wallet trading PnL ledger and leaderboard projections (Issue #155) +-- Forward-only migration. + +CREATE TABLE "wallet_positions" ( + "wallet" TEXT NOT NULL, + "onChainMarketId" BIGINT NOT NULL, + "outcome" TEXT NOT NULL, + "shares" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "costBasis" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "realizedPnl" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "boughtCollateral" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "soldCollateral" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "feesPaid" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "redeemed" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "tradeCount" INTEGER NOT NULL DEFAULT 0, + "lastEventId" TEXT NOT NULL DEFAULT '', + "settled" BOOLEAN NOT NULL DEFAULT false, + "incomplete" BOOLEAN NOT NULL DEFAULT false, + "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "wallet_positions_pkey" PRIMARY KEY ("wallet", "onChainMarketId", "outcome") +); + +CREATE INDEX "wallet_positions_onChainMarketId_idx" ON "wallet_positions"("onChainMarketId"); + +CREATE TABLE "wallet_stats_daily" ( + "wallet" TEXT NOT NULL, + "day" DATE NOT NULL, + "category" TEXT NOT NULL, + "volume" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "realizedPnl" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "fees" DECIMAL(39, 0) NOT NULL DEFAULT 0, + "trades" INTEGER NOT NULL DEFAULT 0, + "wins" INTEGER NOT NULL DEFAULT 0, + "losses" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "wallet_stats_daily_pkey" PRIMARY KEY ("wallet", "day", "category") +); + +CREATE INDEX "wallet_stats_daily_day_idx" ON "wallet_stats_daily"("day"); + +CREATE TABLE "wallet_events_applied" ( + "eventId" TEXT NOT NULL, + "appliedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "wallet_events_applied_pkey" PRIMARY KEY ("eventId") +); + +CREATE TABLE "wallet_pnl_dirty_markets" ( + "onChainMarketId" BIGINT NOT NULL, + "dirtiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "reason" TEXT, + + CONSTRAINT "wallet_pnl_dirty_markets_pkey" PRIMARY KEY ("onChainMarketId") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 1d8aaa7..b28f3dd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -244,6 +244,7 @@ model Market { updatedAt DateTime @updatedAt generationJob MarketGenerationJob? @relation("MarketGenerationJobMarket") + generationRun MarketGenerationRun? trades Trade[] positions Position[] diff --git a/scripts/gen-schemas.mjs b/scripts/gen-schemas.mjs new file mode 100644 index 0000000..2409878 --- /dev/null +++ b/scripts/gen-schemas.mjs @@ -0,0 +1,204 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const SCHEMAS_DIR = path.join(__dirname, '..', 'src', 'schemas'); + +export const TRACE_V1_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://api.oracledesk.io/api/v1/schemas/trace/v1.json', + title: 'OracleDesk Reasoning Trace v1', + type: 'object', + additionalProperties: false, + required: [ + 'schemaVersion', + 'kind', + 'traceId', + 'agentType', + 'decisionType', + 'action', + 'market', + 'probabilityEstimate', + 'marketProbability', + 'edge', + 'confidenceInterval', + 'sourcesUsed', + 'betFraction', + 'betSizeUsdc', + 'hedgeConditions', + 'agentWallet', + 'createdAt', + ], + properties: { + schemaVersion: { const: 1 }, + kind: { const: 'oracledesk.trace' }, + traceId: { type: 'string', format: 'uuid' }, + agentType: { type: 'string', enum: ['MARKET_MAKER', 'TRADER'] }, + decisionType: { type: 'string', minLength: 1, maxLength: 64 }, + action: { type: 'string', minLength: 1, maxLength: 64 }, + market: { + type: 'object', + additionalProperties: false, + required: ['backendId', 'onChainId'], + properties: { + backendId: { type: 'string', format: 'uuid' }, + onChainId: { type: ['string', 'null'], pattern: '^[0-9]+$' }, + }, + }, + probabilityEstimate: { type: 'number', minimum: 0, maximum: 1 }, + marketProbability: { type: 'number', minimum: 0, maximum: 1 }, + edge: { type: 'number', minimum: -1, maximum: 1 }, + confidenceInterval: { + type: 'object', + description: 'lower must be <= upper', + additionalProperties: false, + required: ['lower', 'upper'], + properties: { + lower: { type: 'number' }, + upper: { type: 'number' }, + }, + }, + sourcesUsed: { + type: 'array', + maxItems: 50, + items: { + type: 'object', + additionalProperties: false, + required: ['source', 'weight', 'signal', 'rawValue'], + properties: { + source: { type: 'string' }, + weight: { type: 'number', minimum: 0, maximum: 1 }, + signal: { type: 'string' }, + rawValue: { type: ['number', 'null'] }, + }, + }, + }, + betFraction: { type: ['number', 'null'] }, + betSizeUsdc: { type: ['number', 'null'] }, + hedgeConditions: { + type: 'array', + maxItems: 20, + items: { type: 'string', maxLength: 500 }, + }, + agentWallet: { type: ['string', 'null'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, +}; + +export const QUESTION_V2_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://api.oracledesk.io/api/v1/schemas/question/v2.json', + title: 'OracleDesk Question Document v2', + type: 'object', + additionalProperties: false, + required: [ + 'schemaVersion', + 'kind', + 'question', + 'category', + 'contractCategory', + 'closeTime', + 'resolutionSource', + 'resolutionSpecHash', + 'backendMarketId', + ], + properties: { + schemaVersion: { const: 2 }, + kind: { const: 'oracledesk.question' }, + question: { type: 'string', minLength: 1 }, + category: { + type: 'string', + enum: [ + 'FED', + 'ECB', + 'MACRO', + 'GEOPOLITICAL', + 'ELECTION', + 'POLITICS', + 'CRYPTO', + 'SPORTS', + 'ENTERTAINMENT', + ], + }, + contractCategory: { + type: 'string', + enum: ['Macro', 'Geopolitics', 'Crypto', 'Sports', 'Culture', 'Other'], + }, + closeTime: { type: 'integer' }, + resolutionSource: { type: ['string', 'null'] }, + resolutionSpecHash: { type: 'string', pattern: '^[0-9a-f]{64}$' }, + backendMarketId: { type: 'string', format: 'uuid' }, + }, +}; + +export const QUESTION_V1_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://api.oracledesk.io/api/v1/schemas/question/v1.json', + title: 'OracleDesk Question Document v1', + type: 'object', + additionalProperties: false, + required: [ + 'version', + 'question', + 'category', + 'contractCategory', + 'closeTime', + 'resolutionHash', + 'backendMarketId', + ], + properties: { + version: { const: 1 }, + question: { type: 'string' }, + category: { type: 'string' }, + contractCategory: { type: 'string' }, + closeTime: { type: 'number' }, + resolutionSource: { type: ['string', 'null'] }, + resolutionHash: { type: 'string' }, + backendMarketId: { type: 'string' }, + }, +}; + +export function generateSchemas(opts = {}) { + const targets = [ + { file: 'trace.schema.json', schema: TRACE_V1_SCHEMA }, + { file: 'question.schema.json', schema: QUESTION_V2_SCHEMA }, + { file: 'question-v1.schema.json', schema: QUESTION_V1_SCHEMA }, + ]; + + let matches = true; + + for (const { file, schema } of targets) { + const dest = path.join(SCHEMAS_DIR, file); + const content = JSON.stringify(schema, null, 2) + '\n'; + + if (opts.check) { + if (!fs.existsSync(dest)) { + console.error(`Missing schema file: ${file}`); + matches = false; + continue; + } + const existing = fs.readFileSync(dest, 'utf8'); + if (existing !== content) { + console.error(`Schema file out of date: ${file}`); + matches = false; + } + } else { + fs.writeFileSync(dest, content, 'utf8'); + console.log(`Generated ${dest}`); + } + } + + return matches; +} + +if (process.argv[1]?.endsWith('gen-schemas.mjs')) { + const isCheck = process.argv.includes('--check'); + const ok = generateSchemas({ check: isCheck }); + if (isCheck && !ok) { + console.error('JSON schemas are out of date. Run `npm run gen:schemas` to regenerate.'); + process.exit(1); + } +} diff --git a/scripts/gen-schemas.ts b/scripts/gen-schemas.ts new file mode 100644 index 0000000..bb3da7e --- /dev/null +++ b/scripts/gen-schemas.ts @@ -0,0 +1,200 @@ +import fs from 'fs'; +import path from 'path'; + +const SCHEMAS_DIR = path.join(__dirname, '..', 'src', 'schemas'); + +export const TRACE_V1_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://api.oracledesk.io/api/v1/schemas/trace/v1.json', + title: 'OracleDesk Reasoning Trace v1', + type: 'object', + additionalProperties: false, + required: [ + 'schemaVersion', + 'kind', + 'traceId', + 'agentType', + 'decisionType', + 'action', + 'market', + 'probabilityEstimate', + 'marketProbability', + 'edge', + 'confidenceInterval', + 'sourcesUsed', + 'betFraction', + 'betSizeUsdc', + 'hedgeConditions', + 'agentWallet', + 'createdAt', + ], + properties: { + schemaVersion: { const: 1 }, + kind: { const: 'oracledesk.trace' }, + traceId: { type: 'string', format: 'uuid' }, + agentType: { type: 'string', enum: ['MARKET_MAKER', 'TRADER'] }, + decisionType: { type: 'string', minLength: 1, maxLength: 64 }, + action: { type: 'string', minLength: 1, maxLength: 64 }, + market: { + type: 'object', + additionalProperties: false, + required: ['backendId', 'onChainId'], + properties: { + backendId: { type: 'string', format: 'uuid' }, + onChainId: { type: ['string', 'null'], pattern: '^[0-9]+$' }, + }, + }, + probabilityEstimate: { type: 'number', minimum: 0, maximum: 1 }, + marketProbability: { type: 'number', minimum: 0, maximum: 1 }, + edge: { type: 'number', minimum: -1, maximum: 1 }, + confidenceInterval: { + type: 'object', + description: 'lower must be <= upper', + additionalProperties: false, + required: ['lower', 'upper'], + properties: { + lower: { type: 'number' }, + upper: { type: 'number' }, + }, + }, + sourcesUsed: { + type: 'array', + maxItems: 50, + items: { + type: 'object', + additionalProperties: false, + required: ['source', 'weight', 'signal', 'rawValue'], + properties: { + source: { type: 'string' }, + weight: { type: 'number', minimum: 0, maximum: 1 }, + signal: { type: 'string' }, + rawValue: { type: ['number', 'null'] }, + }, + }, + }, + betFraction: { type: ['number', 'null'] }, + betSizeUsdc: { type: ['number', 'null'] }, + hedgeConditions: { + type: 'array', + maxItems: 20, + items: { type: 'string', maxLength: 500 }, + }, + agentWallet: { type: ['string', 'null'] }, + createdAt: { type: 'string', format: 'date-time' }, + }, +}; + +export const QUESTION_V2_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://api.oracledesk.io/api/v1/schemas/question/v2.json', + title: 'OracleDesk Question Document v2', + type: 'object', + additionalProperties: false, + required: [ + 'schemaVersion', + 'kind', + 'question', + 'category', + 'contractCategory', + 'closeTime', + 'resolutionSource', + 'resolutionSpecHash', + 'backendMarketId', + ], + properties: { + schemaVersion: { const: 2 }, + kind: { const: 'oracledesk.question' }, + question: { type: 'string', minLength: 1 }, + category: { + type: 'string', + enum: [ + 'FED', + 'ECB', + 'MACRO', + 'GEOPOLITICAL', + 'ELECTION', + 'POLITICS', + 'CRYPTO', + 'SPORTS', + 'ENTERTAINMENT', + ], + }, + contractCategory: { + type: 'string', + enum: ['Macro', 'Geopolitics', 'Crypto', 'Sports', 'Culture', 'Other'], + }, + closeTime: { type: 'integer' }, + resolutionSource: { type: ['string', 'null'] }, + resolutionSpecHash: { type: 'string', pattern: '^[0-9a-f]{64}$' }, + backendMarketId: { type: 'string', format: 'uuid' }, + }, +}; + +export const QUESTION_V1_SCHEMA = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'https://api.oracledesk.io/api/v1/schemas/question/v1.json', + title: 'OracleDesk Question Document v1', + type: 'object', + additionalProperties: false, + required: [ + 'version', + 'question', + 'category', + 'contractCategory', + 'closeTime', + 'resolutionHash', + 'backendMarketId', + ], + properties: { + version: { const: 1 }, + question: { type: 'string' }, + category: { type: 'string' }, + contractCategory: { type: 'string' }, + closeTime: { type: 'number' }, + resolutionSource: { type: ['string', 'null'] }, + resolutionHash: { type: 'string' }, + backendMarketId: { type: 'string' }, + }, +}; + +export function generateSchemas(opts: { check?: boolean } = {}): boolean { + const targets = [ + { file: 'trace.schema.json', schema: TRACE_V1_SCHEMA }, + { file: 'question.schema.json', schema: QUESTION_V2_SCHEMA }, + { file: 'question-v1.schema.json', schema: QUESTION_V1_SCHEMA }, + ]; + + let matches = true; + + for (const { file, schema } of targets) { + const dest = path.join(SCHEMAS_DIR, file); + const content = JSON.stringify(schema, null, 2) + '\n'; + + if (opts.check) { + if (!fs.existsSync(dest)) { + console.error(`Missing schema file: ${file}`); + matches = false; + continue; + } + const existing = fs.readFileSync(dest, 'utf8'); + if (existing !== content) { + console.error(`Schema file out of date: ${file}`); + matches = false; + } + } else { + fs.writeFileSync(dest, content, 'utf8'); + console.log(`Generated ${dest}`); + } + } + + return matches; +} + +if (process.argv[1]?.endsWith('gen-schemas.ts')) { + const isCheck = process.argv.includes('--check'); + const ok = generateSchemas({ check: isCheck }); + if (isCheck && !ok) { + console.error('JSON schemas are out of date. Run `npm run gen:schemas` to regenerate.'); + process.exit(1); + } +} diff --git a/scripts/rebuild-wallet-pnl.ts b/scripts/rebuild-wallet-pnl.ts new file mode 100644 index 0000000..af9d4d4 --- /dev/null +++ b/scripts/rebuild-wallet-pnl.ts @@ -0,0 +1,30 @@ +#!/usr/bin/env node +/** + * Rebuilds wallet PnL and trading stats from stored chain_events. + * + * Usage: + * npx tsx scripts/rebuild-wallet-pnl.ts # Full rebuild from scratch + * npx tsx scripts/rebuild-wallet-pnl.ts --market-id 0 # Rebuild single market + */ +import { prisma } from '../src/lib/prisma'; +import { rebuildWalletPnl } from '../src/services/wallet-pnl.service'; + +async function main() { + const mktIdx = process.argv.indexOf('--market-id'); + const marketId = mktIdx !== -1 ? BigInt(process.argv[mktIdx + 1]) : undefined; + + console.log(`Starting wallet PnL rebuild ${marketId !== undefined ? `for market ${marketId}` : 'from scratch'}...`); + + await rebuildWalletPnl({ + fromScratch: marketId === undefined, + marketId, + }); + + console.log('Wallet PnL rebuild successfully completed.'); + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error('Fatal error during rebuild:', err); + process.exit(1); +}); diff --git a/scripts/rewrap-trace-keys.ts b/scripts/rewrap-trace-keys.ts new file mode 100644 index 0000000..09370a7 --- /dev/null +++ b/scripts/rewrap-trace-keys.ts @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Re-wraps stored trace data keys under a new KEK (Key-Encryption Key). + * + * Usage: + * npx tsx scripts/rewrap-trace-keys.ts --to-kid kek-2026-11 [--dry-run] + */ +import { prisma } from '../src/lib/prisma'; +import { config } from '../src/config'; +import { loadKeks, unwrapKey } from '../src/services/trace-crypto.service'; + +async function main() { + const isDryRun = process.argv.includes('--dry-run'); + const toKidIdx = process.argv.indexOf('--to-kid'); + const targetKid = toKidIdx !== -1 ? process.argv[toKidIdx + 1] : config.TRACE_KEK_ACTIVE; + + console.log(`Re-wrapping trace keys to '${targetKid}' (${isDryRun ? 'DRY-RUN' : 'EXECUTE'})...`); + + const keks = loadKeks(config); + const targetKek = keks.get(targetKid); + if (!targetKek) { + console.error(`Target KEK '${targetKid}' not found in TRACE_KEKS configuration.`); + process.exit(1); + } + + const keys = await prisma.traceKey.findMany({ + where: { + kid: { not: targetKid }, + }, + }); + + console.log(`Found ${keys.length} keys to re-wrap.\n`); + + let count = 0; + for (const tk of keys) { + try { + // 1. Unwrap data key using old KEK + const rawDataKey = unwrapKey(tk.wrappedKey, tk.kid, keks); + + // 2. Wrap using new KEK + const newWrappedKey = targetKek.wrap(rawDataKey); + + if (!isDryRun) { + await prisma.traceKey.update({ + where: { traceId: tk.traceId }, + data: { + kid: targetKid, + wrappedKey: newWrappedKey, + }, + }); + } + count++; + } catch (err: any) { + console.error(`Failed to re-wrap trace ${tk.traceId}: ${err.message}`); + } + } + + console.log(`\nSuccessfully re-wrapped ${count}/${keys.length} keys.`); + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/scripts/unpin-legacy-traces.ts b/scripts/unpin-legacy-traces.ts new file mode 100644 index 0000000..7df77aa --- /dev/null +++ b/scripts/unpin-legacy-traces.ts @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * Lists legacy plaintext reasoning traces and optionally unpins them from Pinata. + * + * Usage: + * npx tsx scripts/unpin-legacy-traces.ts # Dry run (list only) + * npx tsx scripts/unpin-legacy-traces.ts --execute # Unpin from Pinata + * + * NOTE: Unpinning removes the file from our Pinata pinning service. + * Public copies may still persist on other IPFS nodes and gateways. + */ +import { prisma } from '../src/lib/prisma'; +import { config } from '../src/config'; +import axios from 'axios'; + +async function main() { + const isExecute = process.argv.includes('--execute'); + + console.log(`Running unpin-legacy-traces (${isExecute ? 'EXECUTE' : 'DRY-RUN'})...`); + + // Find traces with ipfsCid and no traceKeys (PLAINTEXT_LEGACY) + const legacyTraces = await prisma.reasoningTrace.findMany({ + where: { + ipfsCid: { not: null }, + traceKeys: null, + }, + select: { + id: true, + ipfsCid: true, + traceHash: true, + createdAt: true, + }, + }); + + console.log(`Found ${legacyTraces.length} legacy plaintext traces.\n`); + + if (legacyTraces.length === 0) { + console.log('No legacy traces found.'); + await prisma.$disconnect(); + return; + } + + for (const t of legacyTraces) { + console.log(`Trace ${t.id}: CID=${t.ipfsCid} CreatedAt=${t.createdAt.toISOString()}`); + + if (isExecute && t.ipfsCid) { + if (!config.PINATA_API_KEY || !config.PINATA_SECRET_API_KEY) { + console.error('Pinata API keys not configured; cannot unpin.'); + process.exit(1); + } + + try { + await axios.delete(`https://api.pinata.cloud/pinning/unpin/${t.ipfsCid}`, { + headers: { + pinata_api_key: config.PINATA_API_KEY, + pinata_secret_api_key: config.PINATA_SECRET_API_KEY, + }, + }); + console.log(` Unpinned ${t.ipfsCid} successfully.`); + } catch (err: any) { + console.error(` Failed to unpin ${t.ipfsCid}: ${err.response?.data?.error || err.message}`); + } + } + } + + if (!isExecute) { + console.log('\nDry run complete. Use --execute to unpin from Pinata.'); + console.log('NOTICE: Copies may persist on public IPFS gateways and peer nodes.'); + } + + await prisma.$disconnect(); +} + +main().catch((err) => { + console.error('Fatal error:', err); + process.exit(1); +}); diff --git a/src/agents/market-maker.agent.ts b/src/agents/market-maker.agent.ts index c50991d..8cbb6d5 100644 --- a/src/agents/market-maker.agent.ts +++ b/src/agents/market-maker.agent.ts @@ -34,13 +34,7 @@ export interface MarketCycleResult { /** * Market Maker Agent — main execution loop. - * - * Called by the ingestion cron every 15 minutes AND manually via - * POST /markets/generate. Returns market details on success so the - * job tracker can surface them in GET /markets/generation-status/:jobId. - * - * Returns null when the cycle is intentionally skipped (no signals, - * duplicate detected, etc.) — not an error condition. + * Runs via the market-maker saga state machine. */ export async function runMarketMakerCycle( opts?: { signal?: AbortSignal; priority?: Priority }, diff --git a/src/app.ts b/src/app.ts index 5b7dcf2..26357c9 100644 --- a/src/app.ts +++ b/src/app.ts @@ -108,6 +108,7 @@ app.get('/health', (_req, res) => { // ─── API Routes ─── app.use('/api/v1', router); +app.use('/internal', router); // ─── 404 Handler ─── app.use((_req, res) => { diff --git a/src/config/index.ts b/src/config/index.ts index 46ef682..0141d02 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -558,6 +558,20 @@ const envSchema = z IPFS_TRANSPORT: z.enum(['pinata', 'local']).default('pinata'), IPFS_LOCAL_DIR: z.string().default('.ipfs-local'), + // ── Public API Base URL & Schema ────────────────────────────────────── + PUBLIC_API_BASE_URL: z.string().url().default('http://localhost:8000'), + QUESTION_DOC_VERSION: z.string().default('2').transform(Number), + + // ── Confidential Traces ────────────────────────────────────────────── + TRACE_CONFIDENTIAL: z.enum(['true', 'false']).default('true').transform((v) => v === 'true'), + TRACE_KEKS: optionalString, + TRACE_KEK_ACTIVE: z.string().default('kek-2026-10'), + X402_KEY_RELEASE_SECRET: optionalString, + + // ── Saga ───────────────────────────────────────────────────────────── + SAGA_RECOVERY_SCAN: z.string().default('50').transform(Number), + SAGA_MAX_ATTEMPTS: z.string().default('8').transform(Number), + // ── Data sources ────────────────────────────────────────────────────── NEWSAPI_KEY: optionalString, FRED_API_KEY: optionalString, diff --git a/src/controllers/leaderboard.controller.ts b/src/controllers/leaderboard.controller.ts new file mode 100644 index 0000000..3a113dc --- /dev/null +++ b/src/controllers/leaderboard.controller.ts @@ -0,0 +1,350 @@ +import type { Request, Response } from 'express'; +import { z } from 'zod'; +import { StrKey } from '@stellar/stellar-sdk'; +import { prisma } from '../lib/prisma'; +import { sendSuccess, sendError, buildPaginationMeta } from '../utils/response.util'; +import { CONTRACTS } from '../config/contracts'; +import { getRedis } from '../lib/redis'; +import { toBig } from '../services/wallet-pnl.service'; + +const leaderboardQuerySchema = z.object({ + window: z.enum(['7d', '30d', 'all']).default('all'), + category: z.string().optional(), + metric: z.enum(['pnl', 'volume', 'roi']).default('pnl'), + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), + includeIncomplete: z + .enum(['true', 'false']) + .default('false') + .transform((v) => v === 'true'), +}); + +export interface LeaderboardEntry { + rank: number; + wallet: string; + label: string | null; + pnlRaw: string; + volumeRaw: string; + roiBps: number; + trades: number; + winRate: number; + incomplete: boolean; +} + +/** + * GET /leaderboard + * Computes wallet trading performance leaderboard. + */ +export async function getLeaderboard(req: Request, res: Response): Promise { + const parsed = leaderboardQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendError( + res, + 400, + 'VALIDATION_ERROR', + 'Invalid leaderboard query parameters', + parsed.error.flatten().fieldErrors as any, + ); + return; + } + + const { window, category, metric, page, limit, includeIncomplete } = parsed.data; + + // Try Redis cache + const cacheKey = `leaderboard:${window}:${category || 'all'}:${metric}:${page}:${limit}:${includeIncomplete}`; + try { + const redis = getRedis(); + const cached = await redis.get(cacheKey); + if (cached) { + res.setHeader('X-Cache', 'HIT'); + res.status(200).json(JSON.parse(cached)); + return; + } + } catch { + // Redis unavailable, compute directly + } + + let entries: LeaderboardEntry[] = []; + + if (window === 'all') { + // Compute from wallet_positions + const positions = await prisma.walletPosition.findMany({ + where: { + outcome: { not: { contains: ':lp' } }, + ...(includeIncomplete ? {} : { incomplete: false }), + }, + }); + + const byWallet = new Map< + string, + { + realized: bigint; + unrealized: bigint; + bought: bigint; + sold: bigint; + trades: number; + wins: number; + losses: number; + incomplete: boolean; + } + >(); + + for (const p of positions) { + if (!byWallet.has(p.wallet)) { + byWallet.set(p.wallet, { + realized: 0n, + unrealized: 0n, + bought: 0n, + sold: 0n, + trades: 0, + wins: 0, + losses: 0, + incomplete: false, + }); + } + const w = byWallet.get(p.wallet)!; + const realized = toBig(p.realizedPnl); + w.realized += realized; + w.bought += toBig(p.boughtCollateral); + w.sold += toBig(p.soldCollateral); + w.trades += p.tradeCount; + if (p.incomplete) w.incomplete = true; + + if (realized > 0n) w.wins++; + else if (realized < 0n) w.losses++; + } + + entries = Array.from(byWallet.entries()).map(([wallet, stats]) => { + const pnl = stats.realized + stats.unrealized; + const volume = stats.bought + stats.sold; + const roiBps = stats.bought > 0n ? Number((pnl * 10_000n) / stats.bought) : 0; + const winRate = + stats.trades > 0 ? parseFloat((stats.wins / stats.trades).toFixed(4)) : 0; + + return { + rank: 0, + wallet, + label: + wallet === CONTRACTS.treasury ? 'OracleDesk agent (treasury)' : null, + pnlRaw: pnl.toString(), + volumeRaw: volume.toString(), + roiBps, + trades: stats.trades, + winRate, + incomplete: stats.incomplete, + }; + }); + } else { + // 7d or 30d window: query wallet_stats_daily + const days = window === '7d' ? 7 : 30; + const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000); + + const statsRows = await prisma.walletStatsDaily.findMany({ + where: { + day: { gte: since }, + ...(category ? { category } : {}), + }, + }); + + const byWallet = new Map< + string, + { + realized: bigint; + volume: bigint; + trades: number; + wins: number; + losses: number; + } + >(); + + for (const r of statsRows) { + if (!byWallet.has(r.wallet)) { + byWallet.set(r.wallet, { + realized: 0n, + volume: 0n, + trades: 0, + wins: 0, + losses: 0, + }); + } + const w = byWallet.get(r.wallet)!; + w.realized += toBig(r.realizedPnl); + w.volume += toBig(r.volume); + w.trades += r.trades; + w.wins += r.wins; + w.losses += r.losses; + } + + entries = Array.from(byWallet.entries()).map(([wallet, stats]) => { + const pnl = stats.realized; + const roiBps = + stats.volume > 0n ? Number((pnl * 10_000n) / stats.volume) : 0; + const winRate = + stats.trades > 0 ? parseFloat((stats.wins / stats.trades).toFixed(4)) : 0; + + return { + rank: 0, + wallet, + label: + wallet === CONTRACTS.treasury ? 'OracleDesk agent (treasury)' : null, + pnlRaw: pnl.toString(), + volumeRaw: stats.volume.toString(), + roiBps, + trades: stats.trades, + winRate, + incomplete: false, + }; + }); + } + + // Sort by requested metric + entries.sort((a, b) => { + if (metric === 'pnl') { + const diff = BigInt(b.pnlRaw) - BigInt(a.pnlRaw); + return diff > 0n ? 1 : diff < 0n ? -1 : 0; + } + if (metric === 'volume') { + const diff = BigInt(b.volumeRaw) - BigInt(a.volumeRaw); + return diff > 0n ? 1 : diff < 0n ? -1 : 0; + } + if (metric === 'roi') { + return b.roiBps - a.roiBps; + } + return 0; + }); + + const total = entries.length; + const skip = (page - 1) * limit; + const paginated = entries.slice(skip, skip + limit).map((e, idx) => ({ + ...e, + rank: skip + idx + 1, + })); + + const responseBody = { + ok: true, + data: paginated, + meta: buildPaginationMeta(page, limit, total), + error: null, + }; + + // Cache in Redis for 30s + try { + const redis = getRedis(); + await redis.set(cacheKey, JSON.stringify(responseBody), 'EX', 30); + } catch { + // Ignore cache failure + } + + res.status(200).json(responseBody); +} + +/** + * GET /wallets/:address/stats + * Fetches comprehensive trading stats for an address. + */ +export async function getWalletStats(req: Request, res: Response): Promise { + const address = String(req.params.address); + + if ( + !StrKey.isValidEd25519PublicKey(address) && + !StrKey.isValidContract(address) + ) { + sendError( + res, + 400, + 'INVALID_ADDRESS', + 'Address must be a valid Stellar public key (G...) or contract address (C...)', + ); + return; + } + + const [positions, dailyStats, recentEvents] = await Promise.all([ + prisma.walletPosition.findMany({ + where: { wallet: address }, + }), + prisma.walletStatsDaily.findMany({ + where: { wallet: address }, + orderBy: { day: 'desc' }, + take: 90, + }), + prisma.chainEvent.findMany({ + where: { + contractName: 'marketCore', + eventName: 'Trade', + }, + orderBy: { ledger: 'desc' }, + take: 200, + }), + ]); + + // Filter last 50 trades where trader is this address + const recentTrades = recentEvents + .filter((e) => (e.params as any)?.trader === address) + .slice(0, 50) + .map((e) => ({ + id: e.id, + txHash: e.txHash, + ledger: e.ledger, + ledgerClosedAt: e.ledgerClosedAt, + params: e.params, + })); + + let totalRealized = 0n; + let totalBought = 0n; + let totalSold = 0n; + let totalFees = 0n; + let totalTrades = 0; + let isIncomplete = false; + + for (const p of positions) { + if (!p.outcome.includes(':lp')) { + totalRealized += toBig(p.realizedPnl); + totalBought += toBig(p.boughtCollateral); + totalSold += toBig(p.soldCollateral); + totalFees += toBig(p.feesPaid); + totalTrades += p.tradeCount; + if (p.incomplete) isIncomplete = true; + } + } + + // Category breakdown from daily stats + const categoryMap = new Map(); + for (const d of dailyStats) { + if (!categoryMap.has(d.category)) { + categoryMap.set(d.category, { volume: 0n, pnl: 0n, trades: 0 }); + } + const c = categoryMap.get(d.category)!; + c.volume += toBig(d.volume); + c.pnl += toBig(d.realizedPnl); + c.trades += d.trades; + } + + const categoryBreakdown = Array.from(categoryMap.entries()).map(([category, s]) => ({ + category, + volumeRaw: s.volume.toString(), + pnlRaw: s.pnl.toString(), + trades: s.trades, + })); + + sendSuccess(res, { + wallet: address, + label: address === CONTRACTS.treasury ? 'OracleDesk agent (treasury)' : null, + totals: { + realizedPnlRaw: totalRealized.toString(), + volumeRaw: (totalBought + totalSold).toString(), + feesPaidRaw: totalFees.toString(), + tradeCount: totalTrades, + incomplete: isIncomplete, + }, + categoryBreakdown, + positions: positions.map((p) => ({ + onChainMarketId: p.onChainMarketId.toString(), + outcome: p.outcome, + sharesRaw: toBig(p.shares).toString(), + costBasisRaw: toBig(p.costBasis).toString(), + realizedPnlRaw: toBig(p.realizedPnl).toString(), + settled: p.settled, + })), + recentTrades, + }); +} diff --git a/src/controllers/trace.controller.ts b/src/controllers/trace.controller.ts index 7bf2ece..f81a332 100644 --- a/src/controllers/trace.controller.ts +++ b/src/controllers/trace.controller.ts @@ -1,9 +1,12 @@ import type { Request, Response } from 'express'; +import crypto from 'node:crypto'; import { z } from 'zod'; +import { config } from '../config'; import { prisma } from '../lib/prisma'; import { sendSuccess, sendError, parsePagination, buildPaginationMeta } from '../utils/response.util'; import { summarizeProvenance } from '../lib/llm/provenance'; import { verifyTraceOnChain } from '../services/trace-publish.service'; +import { unwrapKey } from '../services/trace-crypto.service'; import { DAILY_PASS_PRICE_RAW, getTracWithAccessControl, diff --git a/src/routes/internal.routes.ts b/src/routes/internal.routes.ts new file mode 100644 index 0000000..fb75106 --- /dev/null +++ b/src/routes/internal.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { releaseTraceKeyInternal } from '../controllers/trace.controller'; + +const router = Router(); + +router.post('/traces/:onChainTraceId/key', releaseTraceKeyInternal); + +export default router; diff --git a/src/routes/leaderboard.routes.ts b/src/routes/leaderboard.routes.ts new file mode 100644 index 0000000..b3bee7a --- /dev/null +++ b/src/routes/leaderboard.routes.ts @@ -0,0 +1,9 @@ +import { Router } from 'express'; +import { getLeaderboard, getWalletStats } from '../controllers/leaderboard.controller'; + +const router = Router(); + +router.get('/leaderboard', getLeaderboard); +router.get('/wallets/:address/stats', getWalletStats); + +export default router; diff --git a/src/routes/schemas.routes.ts b/src/routes/schemas.routes.ts new file mode 100644 index 0000000..a267b42 --- /dev/null +++ b/src/routes/schemas.routes.ts @@ -0,0 +1,28 @@ +import { Router, Request, Response } from 'express'; +import { + TRACE_V1_SCHEMA, + QUESTION_V2_SCHEMA, + QUESTION_V1_SCHEMA, +} from '../../scripts/gen-schemas'; +import { config } from '../config'; + +const router = Router(); + +function serveSchema(schema: Record, subpath: string) { + return (_req: Request, res: Response) => { + const baseUrl = (config as any).PUBLIC_API_BASE_URL || 'https://api.oracledesk.io'; + const dynamicSchema = { + ...schema, + $id: `${baseUrl.replace(/\/$/, '')}/api/v1/schemas/${subpath}`, + }; + res.setHeader('Content-Type', 'application/schema+json'); + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable'); + res.status(200).json(dynamicSchema); + }; +} + +router.get('/trace/v1.json', serveSchema(TRACE_V1_SCHEMA, 'trace/v1.json')); +router.get('/question/v2.json', serveSchema(QUESTION_V2_SCHEMA, 'question/v2.json')); +router.get('/question/v1.json', serveSchema(QUESTION_V1_SCHEMA, 'question/v1.json')); + +export default router; diff --git a/src/routes/trace.routes.ts b/src/routes/trace.routes.ts index 90790a3..d0c1a40 100644 --- a/src/routes/trace.routes.ts +++ b/src/routes/trace.routes.ts @@ -5,6 +5,7 @@ import { getMyPaymentEvents, getMySpendingAllowance, getTrace, + getTraceKey, listTraces, preauthorizeAccess, setSpendingAllowance, diff --git a/src/schemas/question-v1.schema.json b/src/schemas/question-v1.schema.json new file mode 100644 index 0000000..d2148e2 --- /dev/null +++ b/src/schemas/question-v1.schema.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.oracledesk.io/api/v1/schemas/question/v1.json", + "title": "OracleDesk Question Document v1", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "question", + "category", + "contractCategory", + "closeTime", + "resolutionHash", + "backendMarketId" + ], + "properties": { + "version": { + "const": 1 + }, + "question": { + "type": "string" + }, + "category": { + "type": "string" + }, + "contractCategory": { + "type": "string" + }, + "closeTime": { + "type": "number" + }, + "resolutionSource": { + "type": ["string", "null"] + }, + "resolutionHash": { + "type": "string" + }, + "backendMarketId": { + "type": "string" + } + } +} diff --git a/src/schemas/question.schema.json b/src/schemas/question.schema.json new file mode 100644 index 0000000..5f3be1d --- /dev/null +++ b/src/schemas/question.schema.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.oracledesk.io/api/v1/schemas/question/v2.json", + "title": "OracleDesk Question Document v2", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "question", + "category", + "contractCategory", + "closeTime", + "resolutionSource", + "resolutionSpecHash", + "backendMarketId" + ], + "properties": { + "schemaVersion": { + "const": 2 + }, + "kind": { + "const": "oracledesk.question" + }, + "question": { + "type": "string", + "minLength": 1 + }, + "category": { + "type": "string", + "enum": [ + "FED", + "ECB", + "MACRO", + "GEOPOLITICAL", + "ELECTION", + "POLITICS", + "CRYPTO", + "SPORTS", + "ENTERTAINMENT" + ] + }, + "contractCategory": { + "type": "string", + "enum": ["Macro", "Geopolitics", "Crypto", "Sports", "Culture", "Other"] + }, + "closeTime": { + "type": "integer" + }, + "resolutionSource": { + "type": ["string", "null"] + }, + "resolutionSpecHash": { + "type": "string", + "pattern": "^[0-9a-f]{64}$" + }, + "backendMarketId": { + "type": "string", + "format": "uuid" + } + } +} diff --git a/src/schemas/question.ts b/src/schemas/question.ts new file mode 100644 index 0000000..8b74168 --- /dev/null +++ b/src/schemas/question.ts @@ -0,0 +1,90 @@ +import { z } from 'zod'; +import { MarketCategory, ContractCategoryTag } from '../lib/categories'; + +export const QuestionDocV1 = z + .object({ + version: z.literal(1), + question: z.string(), + category: z.string(), + contractCategory: z.string(), + closeTime: z.number(), + resolutionSource: z.string().nullable().optional(), + resolutionHash: z.string(), + backendMarketId: z.string(), + }) + .strict(); + +export type QuestionDocV1 = z.infer; + +export const QuestionDocV2 = z + .object({ + schemaVersion: z.literal(2), + kind: z.literal('oracledesk.question'), + question: z.string().min(1), + category: z.enum([ + 'FED', + 'ECB', + 'MACRO', + 'GEOPOLITICAL', + 'ELECTION', + 'POLITICS', + 'CRYPTO', + 'SPORTS', + 'ENTERTAINMENT', + ]), + contractCategory: z.enum(['Macro', 'Geopolitics', 'Crypto', 'Sports', 'Culture', 'Other']), + closeTime: z.number().int(), + resolutionSource: z.string().nullable(), + resolutionSpecHash: z.string().regex(/^[0-9a-f]{64}$/), + backendMarketId: z.string().uuid(), + }) + .strict(); + +export type QuestionDocV2 = z.infer; + +export type QuestionDocAny = QuestionDocV1 | QuestionDocV2; + +/** + * Builds a canonical question document according to version (v1 or v2). + */ +export function questionDocument( + market: { + id: string; + question: string; + category: string; + expiryTimestamp: Date | string | number; + }, + resolution: { + specHash: string; + source?: string | null; + }, + contractCategory: string, + version: 1 | 2 = 2, +): QuestionDocV1 | QuestionDocV2 { + const closeTime = Math.floor(new Date(market.expiryTimestamp).getTime() / 1000); + + if (version === 1) { + return QuestionDocV1.parse({ + version: 1, + question: market.question, + category: market.category, + contractCategory, + closeTime, + resolutionSource: resolution.source ?? null, + resolutionHash: resolution.specHash, + backendMarketId: market.id, + }); + } + + return QuestionDocV2.parse({ + schemaVersion: 2, + kind: 'oracledesk.question', + question: market.question, + category: market.category as any, + contractCategory: contractCategory as any, + closeTime, + resolutionSource: resolution.source ?? null, + resolutionSpecHash: resolution.specHash, + backendMarketId: market.id, + }); +} diff --git a/src/schemas/trace.schema.json b/src/schemas/trace.schema.json new file mode 100644 index 0000000..8fd95a8 --- /dev/null +++ b/src/schemas/trace.schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://api.oracledesk.io/api/v1/schemas/trace/v1.json", + "title": "OracleDesk Reasoning Trace v1", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "kind", + "traceId", + "agentType", + "decisionType", + "action", + "market", + "probabilityEstimate", + "marketProbability", + "edge", + "confidenceInterval", + "sourcesUsed", + "betFraction", + "betSizeUsdc", + "hedgeConditions", + "agentWallet", + "createdAt" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "kind": { + "const": "oracledesk.trace" + }, + "traceId": { + "type": "string", + "format": "uuid" + }, + "agentType": { + "type": "string", + "enum": ["MARKET_MAKER", "TRADER"] + }, + "decisionType": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "action": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "market": { + "type": "object", + "additionalProperties": false, + "required": ["backendId", "onChainId"], + "properties": { + "backendId": { + "type": "string", + "format": "uuid" + }, + "onChainId": { + "type": ["string", "null"], + "pattern": "^[0-9]+$" + } + } + }, + "probabilityEstimate": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "marketProbability": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "edge": { + "type": "number", + "minimum": -1, + "maximum": 1 + }, + "confidenceInterval": { + "type": "object", + "description": "lower must be <= upper", + "additionalProperties": false, + "required": ["lower", "upper"], + "properties": { + "lower": { + "type": "number" + }, + "upper": { + "type": "number" + } + } + }, + "sourcesUsed": { + "type": "array", + "maxItems": 50, + "items": { + "type": "object", + "additionalProperties": false, + "required": ["source", "weight", "signal", "rawValue"], + "properties": { + "source": { + "type": "string" + }, + "weight": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "signal": { + "type": "string" + }, + "rawValue": { + "type": ["number", "null"] + } + } + } + }, + "betFraction": { + "type": ["number", "null"] + }, + "betSizeUsdc": { + "type": ["number", "null"] + }, + "hedgeConditions": { + "type": "array", + "maxItems": 20, + "items": { + "type": "string", + "maxLength": 500 + } + }, + "agentWallet": { + "type": ["string", "null"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/src/schemas/trace.ts b/src/schemas/trace.ts new file mode 100644 index 0000000..f41eb39 --- /dev/null +++ b/src/schemas/trace.ts @@ -0,0 +1,191 @@ +import { z } from 'zod'; + +export const TraceDocV1 = z + .object({ + schemaVersion: z.literal(1), + kind: z.literal('oracledesk.trace'), + traceId: z.string().uuid(), + agentType: z.enum(['MARKET_MAKER', 'TRADER']), + decisionType: z.string().min(1).max(64), + action: z.string().min(1).max(64), + market: z + .object({ + backendId: z.string().uuid(), + onChainId: z.string().regex(/^\d+$/).nullable(), + }) + .strict(), + probabilityEstimate: z.number().min(0).max(1), + marketProbability: z.number().min(0).max(1), + edge: z.number().min(-1).max(1), + confidenceInterval: z + .object({ + lower: z.number(), + upper: z.number(), + }) + .strict() + .refine((ci) => ci.lower <= ci.upper, { + message: 'lower must be <= upper', + }), + sourcesUsed: z + .array( + z + .object({ + source: z.string(), + weight: z.number().min(0).max(1), + signal: z.string(), + rawValue: z.number().nullable(), + }) + .strict(), + ) + .max(50), + betFraction: z.number().nullable(), + betSizeUsdc: z.number().nullable(), + hedgeConditions: z.array(z.string().max(500)).max(20), + agentWallet: z.string().nullable(), + createdAt: z.string().datetime(), + }) + .strict(); + +export type TraceDocV1 = z.infer; + +export const LegacyTraceDoc = z.record(z.string(), z.unknown()); +export type LegacyTraceDoc = z.infer; + +export const TraceDocAny = z.union([TraceDocV1, LegacyTraceDoc]); +export type TraceDocAny = z.infer; + +export interface ContractsReasoningTrace { + agent: 'market-maker' | 'trader'; + marketId: number | string | null; + action: string; + reasoning: string; + inputs?: Array<{ source: string; weight: number; signal: string; rawValue?: number | null }>; + timestamp: number; // unix seconds + traceId?: string; + probabilityEstimate?: number; + marketProbability?: number; + edge?: number; + confidenceInterval?: { lower: number; upper: number }; +} + +/** + * Maps a contracts-repo style trace into backend TraceDocV1. + */ +export function fromContractsTrace( + t: ContractsReasoningTrace, + fallbackMarketUuid = '00000000-0000-0000-0000-000000000000', + fallbackTraceUuid = '00000000-0000-0000-0000-000000000001', +): TraceDocV1 { + const agentType = t.agent === 'trader' ? 'TRADER' : 'MARKET_MAKER'; + const createdAt = new Date(t.timestamp * 1000).toISOString(); + const onChainId = + t.marketId !== null && t.marketId !== undefined ? String(t.marketId) : null; + + const sourcesUsed = (t.inputs ?? []).slice(0, 50).map((inp) => ({ + source: inp.source, + weight: Math.max(0, Math.min(1, inp.weight)), + signal: inp.signal, + rawValue: inp.rawValue ?? null, + })); + + const probEst = t.probabilityEstimate ?? 0.5; + const mktProb = t.marketProbability ?? 0.5; + const edge = t.edge ?? probEst - mktProb; + const ci = t.confidenceInterval ?? { lower: 0.4, upper: 0.6 }; + + return TraceDocV1.parse({ + schemaVersion: 1, + kind: 'oracledesk.trace', + traceId: t.traceId ?? fallbackTraceUuid, + agentType, + decisionType: t.action.toUpperCase(), + action: t.action, + market: { + backendId: fallbackMarketUuid, + onChainId, + }, + probabilityEstimate: probEst, + marketProbability: mktProb, + edge, + confidenceInterval: { + lower: Math.min(ci.lower, ci.upper), + upper: Math.max(ci.lower, ci.upper), + }, + sourcesUsed, + betFraction: null, + betSizeUsdc: null, + hedgeConditions: [], + agentWallet: null, + createdAt, + }); +} + +/** + * Builds a validated TraceDocV1 with explicit nulls for optional fields. + */ +export function buildTraceDoc(params: { + traceId: string; + agentType: 'MARKET_MAKER' | 'TRADER'; + decisionType: string; + action: string; + backendMarketId: string; + onChainMarketId: string | bigint | number | null; + probabilityEstimate: number; + marketProbability: number; + edge: number; + confidenceInterval: { lower: number; upper: number }; + sourcesUsed: Array<{ source: string; weight: number; signal: string; rawValue?: number | null }>; + betFraction?: number | null; + betSizeUsdc?: number | null; + hedgeConditions?: string[] | null; + agentWallet?: string | null; + createdAt?: string | Date; +}): TraceDocV1 { + const onChainId = + params.onChainMarketId !== null && params.onChainMarketId !== undefined + ? String(params.onChainMarketId) + : null; + + const createdAt = params.createdAt + ? typeof params.createdAt === 'string' + ? params.createdAt + : params.createdAt.toISOString() + : new Date().toISOString(); + + const sourcesUsed = (params.sourcesUsed ?? []).slice(0, 50).map((s) => ({ + source: s.source, + weight: Math.max(0, Math.min(1, s.weight)), + signal: s.signal, + rawValue: s.rawValue ?? null, + })); + + const ci = { + lower: Math.min(params.confidenceInterval.lower, params.confidenceInterval.upper), + upper: Math.max(params.confidenceInterval.lower, params.confidenceInterval.upper), + }; + + const doc = { + schemaVersion: 1 as const, + kind: 'oracledesk.trace' as const, + traceId: params.traceId, + agentType: params.agentType, + decisionType: params.decisionType, + action: params.action, + market: { + backendId: params.backendMarketId, + onChainId, + }, + probabilityEstimate: Math.max(0, Math.min(1, params.probabilityEstimate)), + marketProbability: Math.max(0, Math.min(1, params.marketProbability)), + edge: Math.max(-1, Math.min(1, params.edge)), + confidenceInterval: ci, + sourcesUsed, + betFraction: params.betFraction ?? null, + betSizeUsdc: params.betSizeUsdc ?? null, + hedgeConditions: (params.hedgeConditions ?? []).slice(0, 20).map((h) => h.slice(0, 500)), + agentWallet: params.agentWallet ?? null, + createdAt, + }; + + return TraceDocV1.parse(doc); +} diff --git a/src/services/market-chain.service.ts b/src/services/market-chain.service.ts index 7d0d737..16810ea 100644 --- a/src/services/market-chain.service.ts +++ b/src/services/market-chain.service.ts @@ -82,9 +82,10 @@ export async function handleMarketCreateOutcome(row: ChainTransactionRow, outcom registerHandler('market.create', handleMarketCreateOutcome); export function defaultResolutionSpec(): ResolutionSpecJson { - const signers = config.RESOLUTION_SIGNERS.length > 0 - ? config.RESOLUTION_SIGNERS - : [agentSigner().publicKey as string]; + const signers = + config.RESOLUTION_SIGNERS.length > 0 + ? config.RESOLUTION_SIGNERS + : [agentSigner().publicKey as string]; return { kind: 'signers', signers, @@ -328,8 +329,8 @@ export async function createMarketOnChain( data: { resolutionSpec: spec as object, resolutionHash: resolutionHashHex, - questionHash: pinned.hash, - metaUri: `ipfs://${pinned.cid}`, + questionHash, + metaUri, seedAmountRaw: seed.toString(), }, }); @@ -452,3 +453,11 @@ export async function createMarketOnChain( } return result; } + +export async function createMarketOnChain( + market: Market, + spec: ResolutionSpecJson = defaultResolutionSpec(), +): Promise> { + const prep = await prepareMarketCommitment(market, spec); + return submitMarketCreation(market, prep); +} diff --git a/src/services/market-saga.service.ts b/src/services/market-saga.service.ts new file mode 100644 index 0000000..26deaec --- /dev/null +++ b/src/services/market-saga.service.ts @@ -0,0 +1,573 @@ +import crypto from 'node:crypto'; +import type { Market, MarketGenerationRun, GenerationRunState } from '@prisma/client'; +import { prisma } from '../lib/prisma'; +import { logger } from '../lib/logger'; +import { config } from '../config'; +import { CONTRACTS } from '../config/contracts'; +import { AppError } from '../middlewares/error.middleware'; +import { aggregateSignals } from './ingestion.service'; +import { + generateMarketQuestion, + isDuplicateMarket, + createMarketFromProposal, +} from './market.service'; +import { + prepareMarketCommitment, + submitMarketCreation, +} from './market-chain.service'; +import { generateReasoningTrace } from './reasoning.service'; +import { pinTrace, publishPinnedTrace } from './trace-publish.service'; +import { marketCount, getOnChainMarket, getOnChainTrace } from './chain.service'; + +export interface RunOutputs { + v: number; + questionCid?: string; + questionHash?: string; + resolutionHash?: string; + chainTxHash?: string | null; + onChainMarketId?: string | null; + traceId?: string; + traceCid?: string; + traceHash?: string; + traceTxHash?: string | null; + onChainTraceId?: string | null; + tracePayload?: any; +} + +export interface RunContext { + run: MarketGenerationRun; + inputs: any; + outputs: RunOutputs; +} + +type StepFn = (ctx: RunContext) => Promise<{ + inputs?: any; + outputs?: Partial; + next: GenerationRunState; + marketId?: string; +}>; + +// Test fault injection hook +const sagaFaultHooks = new Map Promise>(); + +export function setSagaFaultHook( + step: string, + fn: ((run: MarketGenerationRun) => Promise) | null, +): void { + if (!fn) { + sagaFaultHooks.delete(step); + } else { + sagaFaultHooks.set(step, fn); + } +} + +/** + * Recovers an on-chain market creation by scanning downward from market_count() + * matching question_hash and creator === CONTRACTS.treasury. + */ +export async function recoverLostMarketCreation( + questionHashHex: string, + scanCount = config.SAGA_RECOVERY_SCAN, +): Promise { + try { + const count = await marketCount(); + if (count <= 0n) return null; + + const start = count - 1n; + const end = count > BigInt(scanCount) ? count - BigInt(scanCount) : 0n; + + for (let id = start; id >= end; id--) { + try { + const m = await getOnChainMarket(id); + const qHashHex = Buffer.from(m.question_hash).toString('hex'); + if ( + qHashHex.toLowerCase() === questionHashHex.toLowerCase() && + m.creator === CONTRACTS.treasury + ) { + logger.info({ onChainMarketId: id.toString() }, 'Recovered lost on-chain market creation'); + return id; + } + } catch { + // Continue scanning + } + } + } catch (err: any) { + logger.warn({ err: err.message }, 'Failed during market creation recovery scan'); + } + return null; +} + +// ─── Step Handlers ───────────────────────────────────────────────────────────── + +const STEPS: Record = { + STARTED: async (ctx) => { + const signals = await aggregateSignals(); + if (!signals || signals.signalCount === 0) { + logger.info('No signals available; skipping market-maker cycle'); + return { next: 'SKIPPED' as GenerationRunState }; + } + + const compactSignals = { + signalCount: signals.signalCount, + aggregatedAt: signals.aggregatedAt, + newsTitles: signals.news.slice(0, 5).map((s) => s.title), + fedCount: signals.fed.length, + macroCount: signals.macro.length, + }; + + return { + inputs: { ...ctx.inputs, signals: compactSignals, rawSignals: signals }, + next: 'SIGNALS_READY' as GenerationRunState, + }; + }, + + SIGNALS_READY: async (ctx) => { + const signals = ctx.inputs.rawSignals || (await aggregateSignals()); + const proposal = await generateMarketQuestion(signals); + if (!proposal) { + return { next: 'SKIPPED' as GenerationRunState }; + } + + const duplicate = await isDuplicateMarket(proposal.market_question); + if (duplicate) { + logger.info({ question: proposal.market_question }, 'Duplicate market proposed; skipping'); + return { next: 'SKIPPED' as GenerationRunState }; + } + + return { + inputs: { ...ctx.inputs, proposal }, + next: 'PROPOSED' as GenerationRunState, + }; + }, + + PROPOSED: async (ctx) => { + if (ctx.run.marketId) { + return { next: 'MARKET_PERSISTED' as GenerationRunState }; + } + + const proposal = ctx.inputs.proposal; + if (!proposal) { + throw new AppError(500, 'MISSING_PROPOSAL', 'Proposal missing in run inputs'); + } + + const market = await prisma.$transaction(async (tx) => { + const created = await createMarketFromProposal(proposal, tx); + await tx.marketGenerationRun.update({ + where: { id: ctx.run.id }, + data: { marketId: created.id }, + }); + return created; + }); + + return { + marketId: market.id, + next: 'MARKET_PERSISTED' as GenerationRunState, + }; + }, + + MARKET_PERSISTED: async (ctx) => { + const marketId = ctx.run.marketId; + if (!marketId) throw new AppError(500, 'MISSING_MARKET_ID', 'Run has no marketId'); + + const market = await prisma.market.findUnique({ where: { id: marketId } }); + if (!market) throw new AppError(404, 'MARKET_NOT_FOUND', `Market ${marketId} not found`); + + const prep = await prepareMarketCommitment(market); + return { + outputs: { + questionCid: prep.questionCid, + questionHash: prep.questionHash, + resolutionHash: prep.resolutionHashHex, + }, + next: 'QUESTION_PINNED' as GenerationRunState, + }; + }, + + QUESTION_PINNED: async (ctx) => { + const marketId = ctx.run.marketId; + if (!marketId) throw new AppError(500, 'MISSING_MARKET_ID', 'Run has no marketId'); + + const market = await prisma.market.findUnique({ where: { id: marketId } }); + if (!market) throw new AppError(404, 'MARKET_NOT_FOUND', `Market ${marketId} not found`); + + const qHash = ctx.outputs.questionHash || market.questionHash; + if (qHash) { + const recoveredId = await recoverLostMarketCreation(qHash); + if (recoveredId !== null) { + await prisma.market.update({ + where: { id: market.id }, + data: { onChainMarketId: recoveredId, status: 'ACTIVE' }, + }); + return { + outputs: { onChainMarketId: recoveredId.toString() }, + next: 'CHAIN_CONFIRMED' as GenerationRunState, + }; + } + } + + const result = await submitMarketCreation(market); + if (result.mode === 'live') { + return { + outputs: { + chainTxHash: result.txHash, + onChainMarketId: result.value.toString(), + }, + next: 'CHAIN_CONFIRMED' as GenerationRunState, + }; + } + + return { + outputs: { + chainTxHash: null, + onChainMarketId: null, + }, + next: 'CHAIN_CONFIRMED' as GenerationRunState, + }; + }, + + CHAIN_SUBMITTED: async (ctx) => { + const marketId = ctx.run.marketId; + if (!marketId) throw new AppError(500, 'MISSING_MARKET_ID', 'Run has no marketId'); + + const market = await prisma.market.findUnique({ where: { id: marketId } }); + if (!market) throw new AppError(404, 'MARKET_NOT_FOUND', `Market ${marketId} not found`); + + const qHash = ctx.outputs.questionHash || market.questionHash; + if (qHash) { + const recoveredId = await recoverLostMarketCreation(qHash); + if (recoveredId !== null) { + await prisma.market.update({ + where: { id: market.id }, + data: { onChainMarketId: recoveredId, status: 'ACTIVE' }, + }); + return { + outputs: { onChainMarketId: recoveredId.toString() }, + next: 'CHAIN_CONFIRMED' as GenerationRunState, + }; + } + } + + return { next: 'CHAIN_CONFIRMED' as GenerationRunState }; + }, + + CHAIN_CONFIRMED: async (ctx) => { + const marketId = ctx.run.marketId; + if (!marketId) throw new AppError(500, 'MISSING_MARKET_ID', 'Run has no marketId'); + + const market = await prisma.market.findUnique({ where: { id: marketId } }); + if (!market) throw new AppError(404, 'MARKET_NOT_FOUND', `Market ${marketId} not found`); + + // Check if trace already exists for this run or market creation + if (ctx.outputs.traceId && ctx.outputs.tracePayload) { + return { next: 'TRACE_PERSISTED' as GenerationRunState }; + } + + const existingTrace = await prisma.reasoningTrace.findFirst({ + where: { marketId, decisionType: 'MARKET_CREATION' }, + }); + + if (existingTrace) { + return { + outputs: { + traceId: existingTrace.id, + tracePayload: (existingTrace as any).tracePayload || undefined, + }, + next: 'TRACE_PERSISTED' as GenerationRunState, + }; + } + + const traceId = ctx.outputs.traceId || crypto.randomUUID(); + const traceResult = await generateReasoningTrace({ + traceId, + marketId, + agentType: 'MARKET_MAKER', + decisionType: 'MARKET_CREATION', + sourcesUsed: [ + { source: 'ORACLEDESK_AI', weight: 1.0, signal: market.question, rawValue: market.initialYesProb }, + ], + probabilityEstimate: market.initialYesProb, + marketProbability: 0.5, + edge: parseFloat((market.initialYesProb - 0.5).toFixed(4)), + confidenceInterval: (market.confidenceInterval as any) || { lower: 0.4, upper: 0.6 }, + onChainMarketId: ctx.outputs.onChainMarketId ?? market.onChainMarketId, + }); + + return { + outputs: { + traceId: traceResult.trace.id, + tracePayload: traceResult.tracePayload, + }, + next: 'TRACE_PERSISTED' as GenerationRunState, + }; + }, + + TRACE_PERSISTED: async (ctx) => { + const traceId = ctx.outputs.traceId; + if (!traceId) throw new AppError(500, 'MISSING_TRACE_ID', 'Run has no traceId'); + + const payload = ctx.outputs.tracePayload; + if (!payload) throw new AppError(500, 'MISSING_TRACE_PAYLOAD', 'Run has no tracePayload'); + + const onChainMktId = ctx.outputs.onChainMarketId ? BigInt(ctx.outputs.onChainMarketId) : null; + const pinned = await pinTrace({ + traceId, + payload, + action: 'create_market', + onChainMarketId: onChainMktId, + }); + + return { + outputs: { + traceCid: pinned.cid, + traceHash: pinned.traceHash, + }, + next: 'TRACE_PINNED' as GenerationRunState, + }; + }, + + TRACE_PINNED: async (ctx) => { + const traceId = ctx.outputs.traceId; + if (!traceId) throw new AppError(500, 'MISSING_TRACE_ID', 'Run has no traceId'); + + const onChainMktIdStr = ctx.outputs.onChainMarketId; + if (!onChainMktIdStr) { + // Dry run or off-chain only: completed + return { next: 'COMPLETED' as GenerationRunState }; + } + + const onChainMktId = BigInt(onChainMktIdStr); + const traceCid = ctx.outputs.traceCid; + const traceHash = ctx.outputs.traceHash; + + if (!traceCid || !traceHash) { + throw new AppError(500, 'MISSING_TRACE_PIN_INFO', 'Trace CID or Hash missing'); + } + + const pubResult = await publishPinnedTrace({ + traceId, + onChainMarketId: onChainMktId, + action: 'create_market', + ipfsCid: traceCid, + traceHash, + }); + + return { + outputs: { + onChainTraceId: pubResult.onChainTraceId ? pubResult.onChainTraceId.toString() : null, + traceTxHash: pubResult.publishTxHash, + }, + next: 'COMPLETED' as GenerationRunState, + }; + }, +}; + +// ─── Saga Service APIs ───────────────────────────────────────────────────────── + +export async function startRun( + trigger: 'cron' | 'admin' | 'resume', + initialInputs: any = {}, +): Promise { + const run = await prisma.marketGenerationRun.create({ + data: { + trigger, + state: 'STARTED', + inputs: initialInputs, + outputs: { v: 1 }, + }, + }); + return run.id; +} + +export async function claimRun( + runId: string, + workerId: string, + lockDurationMs = 300_000, +): Promise { + const now = new Date(); + const lockUntil = new Date(now.getTime() + lockDurationMs); + + const updatedCount = await prisma.marketGenerationRun.updateMany({ + where: { + id: runId, + state: { notIn: ['COMPLETED', 'SKIPPED', 'NEEDS_ATTENTION', 'CANCELLED'] }, + OR: [{ lockedUntil: null }, { lockedUntil: { lt: now } }], + }, + data: { + lockedBy: workerId, + lockedUntil: lockUntil, + }, + }); + + if (updatedCount.count === 0) return null; + return prisma.marketGenerationRun.findUnique({ where: { id: runId } }); +} + +export async function advance( + runId: string, + opts?: { maxSteps?: number; workerId?: string }, +): Promise { + const workerId = opts?.workerId || `worker-${process.pid}-${crypto.randomBytes(4).toString('hex')}`; + let currentRun = await claimRun(runId, workerId); + + if (!currentRun) { + const existing = await prisma.marketGenerationRun.findUnique({ where: { id: runId } }); + if (!existing) throw new AppError(404, 'RUN_NOT_FOUND', `Run ${runId} not found`); + return existing; + } + + const maxSteps = opts?.maxSteps ?? 50; + let stepsTaken = 0; + + try { + while (stepsTaken < maxSteps) { + if (['COMPLETED', 'SKIPPED', 'NEEDS_ATTENTION', 'CANCELLED'].includes(currentRun.state)) { + break; + } + + const stepFn = STEPS[currentRun.state]; + if (!stepFn) { + logger.error({ state: currentRun.state }, 'No step handler for state'); + break; + } + + // Check test fault hook + const faultHook = sagaFaultHooks.get(currentRun.state); + if (faultHook) { + await faultHook(currentRun); + } + + const ctx: RunContext = { + run: currentRun, + inputs: (currentRun.inputs as any) || {}, + outputs: ((currentRun.outputs as any) || { v: 1 }) as RunOutputs, + }; + + try { + const result = await stepFn(ctx); + + const newInputs = result.inputs ? result.inputs : ctx.inputs; + const newOutputs = { ...ctx.outputs, ...(result.outputs || {}) }; + + currentRun = await prisma.marketGenerationRun.update({ + where: { id: runId }, + data: { + state: result.next, + inputs: newInputs, + outputs: newOutputs, + marketId: result.marketId ?? currentRun.marketId, + lastError: null, + attempts: 0, + nextAttemptAt: null, + }, + }); + } catch (err: any) { + const attempts = currentRun.attempts + 1; + const isPermanent = + attempts >= config.SAGA_MAX_ATTEMPTS || + err.code === 'IPFS_NOT_CONFIGURED' || + err.message?.includes('TradeCapExceeded') || + err.message?.includes('DailyCapExceeded'); + + const nextState: GenerationRunState = isPermanent ? 'NEEDS_ATTENTION' : currentRun.state; + const backoffMs = Math.min(Math.pow(2, attempts) * 30_000, 3_600_000); + const nextAttemptAt = isPermanent ? null : new Date(Date.now() + backoffMs); + + currentRun = await prisma.marketGenerationRun.update({ + where: { id: runId }, + data: { + state: nextState, + attempts, + nextAttemptAt, + lastError: { + step: currentRun.state, + message: err.message, + code: err.code || err.name, + }, + }, + }); + break; + } + + stepsTaken++; + } + } finally { + // Release lock + await prisma.marketGenerationRun.updateMany({ + where: { id: runId, lockedBy: workerId }, + data: { lockedBy: null, lockedUntil: null }, + }).catch(() => {}); + } + + return currentRun; +} + +export async function resumeIncomplete(limit = 5): Promise { + const now = new Date(); + const candidates = await prisma.marketGenerationRun.findMany({ + where: { + state: { notIn: ['COMPLETED', 'SKIPPED', 'NEEDS_ATTENTION', 'CANCELLED'] }, + OR: [{ nextAttemptAt: null }, { nextAttemptAt: { lte: now } }], + AND: [{ OR: [{ lockedUntil: null }, { lockedUntil: { lt: now } }] }], + }, + orderBy: { createdAt: 'asc' }, + take: limit, + }); + + let processed = 0; + for (const c of candidates) { + try { + await advance(c.id, { maxSteps: 50 }); + processed++; + } catch (err: any) { + logger.warn({ runId: c.id, err: err.message }, 'Error resuming incomplete run'); + } + } + return processed; +} + +export async function cancelRun(runId: string, reason = 'Admin cancelled'): Promise { + const run = await prisma.marketGenerationRun.findUnique({ where: { id: runId } }); + if (!run) throw new AppError(404, 'RUN_NOT_FOUND', `Run ${runId} not found`); + + const preChainStates: GenerationRunState[] = [ + 'STARTED', + 'SIGNALS_READY', + 'PROPOSED', + 'MARKET_PERSISTED', + 'QUESTION_PINNED', + 'NEEDS_ATTENTION', + ]; + + if (!preChainStates.includes(run.state)) { + throw new AppError(400, 'CANNOT_CANCEL_ON_CHAIN', 'Cannot cancel run after chain submission'); + } + + return prisma.$transaction(async (tx) => { + if (run.marketId) { + const market = await tx.market.findUnique({ where: { id: run.marketId } }); + if (market && market.status === 'PENDING') { + await tx.market.update({ + where: { id: market.id }, + data: { status: 'CANCELLED' }, + }); + + await tx.agentLog.create({ + data: { + agentType: 'MARKET_MAKER', + action: 'MARKET_CANCELLED', + marketId: market.id, + details: { reason, runId }, + }, + }); + } + } + + return tx.marketGenerationRun.update({ + where: { id: runId }, + data: { + state: 'CANCELLED', + lastError: { reason }, + }, + }); + }); +} diff --git a/src/services/market.service.ts b/src/services/market.service.ts index 3abba2d..26d3062 100644 --- a/src/services/market.service.ts +++ b/src/services/market.service.ts @@ -361,7 +361,7 @@ export async function createMarketFromProposal( ): Promise { const finalCategory = normalizeCategory(proposal.category); - const market = await prisma.market.create({ + const market = await client.market.create({ data: { question: proposal.market_question, category: finalCategory, diff --git a/src/services/reasoning.service.ts b/src/services/reasoning.service.ts index 7573331..cc90d54 100644 --- a/src/services/reasoning.service.ts +++ b/src/services/reasoning.service.ts @@ -51,13 +51,15 @@ export async function generateReasoningTrace( // canonicalJson would encode differently from the contracts repo. const provenance = buildTraceProvenance([...(opts.upstreamProvenance ?? []), hedgeConditions.provenance]); - const tracePayload: Record = { - agentType: input.agentType, - decisionType: input.decisionType, - marketId: input.marketId, - sourcesUsed: input.sourcesUsed, + const traceDoc: TraceDocV1 = buildTraceDoc({ + traceId, + agentType: input.agentType, + decisionType: input.decisionType, + action: input.decisionType.toLowerCase(), + backendMarketId: input.marketId, + onChainMarketId, probabilityEstimate: input.probabilityEstimate, - marketProbability: input.marketProbability, + marketProbability: input.marketProbability, edge, confidenceInterval: ci, betFraction: input.betFraction, @@ -80,8 +82,10 @@ export async function generateReasoningTrace( signal: s.signal, })); - const trace = await prisma.reasoningTrace.create({ - data: { + const trace = await prisma.reasoningTrace.upsert({ + where: { id: traceId }, + create: { + id: traceId, marketId: input.marketId, agentType: input.agentType, decisionType: input.decisionType, @@ -90,8 +94,8 @@ export async function generateReasoningTrace( marketProbability: input.marketProbability, edge, confidenceInterval: ci as Prisma.InputJsonValue, - betFraction: input.betFraction, - betSizeUsdc: input.betSizeUsdc, + betFraction: input.betFraction ?? null, + betSizeUsdc: input.betSizeUsdc ?? null, hedgeConditions: hedgeConditions.conditions as any, provenance: provenance as unknown as Prisma.InputJsonValue, agentWallet: input.agentWallet, @@ -99,10 +103,11 @@ export async function generateReasoningTrace( verified: false, isPublic: false, }, + update: {}, }); logger.info({ traceId: trace.id, edge, marketId: input.marketId }, 'Reasoning trace generated'); - return { trace, tracePayload: { ...tracePayload, traceId: trace.id } }; + return { trace, tracePayload: traceDoc }; } /** diff --git a/src/services/subscription.service.ts b/src/services/subscription.service.ts index da2d767..cdb5a7a 100644 --- a/src/services/subscription.service.ts +++ b/src/services/subscription.service.ts @@ -507,6 +507,8 @@ export async function getTracWithAccessControl( audit, ipfsCid: trace.ipfsCid, traceHash: trace.traceHash, + contentMode: trace.contentMode, + plaintextHash: trace.plaintextHash, onChainTraceId: trace.onChainTraceId, createdAt: trace.createdAt, accessLevel: 'FREE_PREVIEW', diff --git a/src/services/trace-crypto.service.ts b/src/services/trace-crypto.service.ts new file mode 100644 index 0000000..a42be20 --- /dev/null +++ b/src/services/trace-crypto.service.ts @@ -0,0 +1,299 @@ +import crypto from 'node:crypto'; +import type { Config } from '../config'; +import { config as defaultConfig } from '../config'; +import { canonicalBytesStrict, sha256Hex } from '../utils/hash.util'; +import { AppError } from '../middlewares/error.middleware'; + +export interface TracePublicPart { + traceId: string; + agentType: 'MARKET_MAKER' | 'TRADER'; + decisionType: string; + market: { + backendId: string; + onChainId: string | null; + }; + probabilityEstimate: number; + marketProbability: number; + edge: number; + previewSources: Array<{ + source: string; + weight: number; + signal: string; + rawValue: number | null; + }>; + createdAt: string; +} + +export interface TraceEnvelopeV1 { + kind: 'oracledesk.trace.enc'; + schemaVersion: 1; + alg: 'XChaCha20-Poly1305'; + kid: string; + plaintextHash: string; + public: TracePublicPart; + nonce: string; // base64 24 bytes + ciphertext: string; // base64 +} + +export interface SealedTrace { + envelope: TraceEnvelopeV1; + bytes: Buffer; + plaintextHash: string; + wrappedKey: Buffer; + kid: string; +} + +export interface Kek { + kid: string; + wrap(dk: Buffer): Buffer; + unwrap(w: Buffer): Buffer; +} + +// ─── HChaCha20 and XChaCha20-Poly1305 Implementation ────────────────────────── + +function rotl(a: number, b: number): number { + return ((a << b) | (a >>> (32 - b))) >>> 0; +} + +function qround(state: Uint32Array, a: number, b: number, c: number, d: number) { + state[a] = (state[a] + state[b]) >>> 0; + state[d] = rotl(state[d] ^ state[a], 16); + state[c] = (state[c] + state[d]) >>> 0; + state[b] = rotl(state[b] ^ state[c], 12); + state[a] = (state[a] + state[b]) >>> 0; + state[d] = rotl(state[d] ^ state[a], 8); + state[c] = (state[c] + state[d]) >>> 0; + state[b] = rotl(state[b] ^ state[c], 7); +} + +export function hchacha20(key: Buffer, nonce: Buffer): Buffer { + if (key.length !== 32) throw new Error('HChaCha20 requires a 32-byte key'); + if (nonce.length !== 16) throw new Error('HChaCha20 requires a 16-byte nonce'); + + const state = new Uint32Array(16); + // Constants: "expand 32-byte k" + state[0] = 0x61707865; + state[1] = 0x3320646e; + state[2] = 0x79622d32; + state[3] = 0x6b206574; + + for (let i = 0; i < 8; i++) { + state[4 + i] = key.readUInt32LE(i * 4); + } + for (let i = 0; i < 4; i++) { + state[12 + i] = nonce.readUInt32LE(i * 4); + } + + for (let i = 0; i < 10; i++) { + // Column round + qround(state, 0, 4, 8, 12); + qround(state, 1, 5, 9, 13); + qround(state, 2, 6, 10, 14); + qround(state, 3, 7, 11, 15); + // Diagonal round + qround(state, 0, 5, 10, 15); + qround(state, 1, 6, 11, 12); + qround(state, 2, 7, 8, 13); + qround(state, 3, 4, 9, 14); + } + + const out = Buffer.alloc(32); + out.writeUInt32LE(state[0], 0); + out.writeUInt32LE(state[1], 4); + out.writeUInt32LE(state[2], 8); + out.writeUInt32LE(state[3], 12); + out.writeUInt32LE(state[12], 16); + out.writeUInt32LE(state[13], 20); + out.writeUInt32LE(state[14], 24); + out.writeUInt32LE(state[15], 28); + return out; +} + +export function xchacha20poly1305Encrypt( + key: Buffer, + nonce24: Buffer, + plaintext: Buffer, + aad: Buffer, +): Buffer { + if (key.length !== 32) throw new Error('Key must be 32 bytes'); + if (nonce24.length !== 24) throw new Error('Nonce must be 24 bytes'); + + const subKey = hchacha20(key, nonce24.subarray(0, 16)); + const iv12 = Buffer.concat([Buffer.alloc(4, 0), nonce24.subarray(16, 24)]); + + const cipher = crypto.createCipheriv('chacha20-poly1305', subKey, iv12, { + authTagLength: 16, + }); + cipher.setAAD(aad); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([encrypted, tag]); +} + +export function xchacha20poly1305Decrypt( + key: Buffer, + nonce24: Buffer, + ciphertextWithTag: Buffer, + aad: Buffer, +): Buffer { + if (key.length !== 32) throw new Error('Key must be 32 bytes'); + if (nonce24.length !== 24) throw new Error('Nonce must be 24 bytes'); + if (ciphertextWithTag.length < 16) throw new Error('Ciphertext must include 16-byte auth tag'); + + const subKey = hchacha20(key, nonce24.subarray(0, 16)); + const iv12 = Buffer.concat([Buffer.alloc(4, 0), nonce24.subarray(16, 24)]); + + const tag = ciphertextWithTag.subarray(ciphertextWithTag.length - 16); + const cipherText = ciphertextWithTag.subarray(0, ciphertextWithTag.length - 16); + + const decipher = crypto.createDecipheriv('chacha20-poly1305', subKey, iv12, { + authTagLength: 16, + }); + decipher.setAAD(aad); + decipher.setAuthTag(tag); + + return Buffer.concat([decipher.update(cipherText), decipher.final()]); +} + +// ─── KEK Management ────────────────────────────────────────────────────────── + +export function createKek(kid: string, rawKey32: Buffer): Kek { + if (rawKey32.length !== 32) { + throw new Error(`KEK ${kid} must be 32 bytes`); + } + return { + kid, + wrap(dk: Buffer): Buffer { + if (dk.length !== 32) throw new Error('Data key must be 32 bytes'); + const nonce = crypto.randomBytes(24); + const aad = Buffer.from(`kek-wrap:${kid}`, 'utf8'); + const enc = xchacha20poly1305Encrypt(rawKey32, nonce, dk, aad); + return Buffer.concat([nonce, enc]); + }, + unwrap(wrapped: Buffer): Buffer { + if (wrapped.length < 24 + 16) throw new Error('Invalid wrapped key length'); + const nonce = wrapped.subarray(0, 24); + const enc = wrapped.subarray(24); + const aad = Buffer.from(`kek-wrap:${kid}`, 'utf8'); + return xchacha20poly1305Decrypt(rawKey32, nonce, enc, aad); + }, + }; +} + +export function loadKeks(cfg: Config = defaultConfig): Map { + const map = new Map(); + if (!cfg.TRACE_KEKS) return map; + + try { + const parsed = typeof cfg.TRACE_KEKS === 'string' ? JSON.parse(cfg.TRACE_KEKS) : cfg.TRACE_KEKS; + for (const [kid, b64] of Object.entries(parsed)) { + if (typeof b64 === 'string') { + const buf = Buffer.from(b64, 'base64'); + if (buf.length === 32) { + map.set(kid, createKek(kid, buf)); + } + } + } + } catch (err) { + throw new Error(`Failed to load TRACE_KEKS: ${(err as Error).message}`); + } + return map; +} + +export function unwrapKey(wrapped: Buffer, kid: string, keksMap?: Map): Buffer { + const keks = keksMap ?? loadKeks(); + const kek = keks.get(kid); + if (!kek) { + throw new AppError(500, 'KEK_NOT_FOUND', `Key encryption key '${kid}' not found`); + } + return kek.unwrap(wrapped); +} + +// ─── Sealing and Opening Traces ────────────────────────────────────────────── + +export function extractEnvelopeAadBytes(envelope: { + kind: string; + schemaVersion: number; + alg: string; + kid: string; + plaintextHash: string; + public: TracePublicPart; +}): Buffer { + return canonicalBytesStrict({ + kind: envelope.kind, + schemaVersion: envelope.schemaVersion, + alg: envelope.alg, + kid: envelope.kid, + plaintextHash: envelope.plaintextHash, + public: envelope.public, + }); +} + +export function sealTrace( + plaintext: Buffer, + publicPart: TracePublicPart, + kek: Kek, +): SealedTrace { + const plaintextHash = sha256Hex(plaintext); + const dataKey = crypto.randomBytes(32); + const nonce = crypto.randomBytes(24); + + const header = { + kind: 'oracledesk.trace.enc' as const, + schemaVersion: 1 as const, + alg: 'XChaCha20-Poly1305' as const, + kid: kek.kid, + plaintextHash, + public: publicPart, + }; + + const aad = extractEnvelopeAadBytes(header); + const ciphertextWithTag = xchacha20poly1305Encrypt(dataKey, nonce, plaintext, aad); + + const envelope: TraceEnvelopeV1 = { + ...header, + nonce: nonce.toString('base64'), + ciphertext: ciphertextWithTag.toString('base64'), + }; + + const bytes = canonicalBytesStrict(envelope); + const wrappedKey = kek.wrap(dataKey); + + return { + envelope, + bytes, + plaintextHash, + wrappedKey, + kid: kek.kid, + }; +} + +export function openTrace(envelope: TraceEnvelopeV1, dataKey: Buffer): Buffer { + if (envelope.kind !== 'oracledesk.trace.enc') { + throw new AppError(400, 'INVALID_ENVELOPE_KIND', `Unsupported envelope kind '${envelope.kind}'`); + } + if (envelope.schemaVersion !== 1) { + throw new AppError(400, 'UNSUPPORTED_SCHEMA_VERSION', `Unsupported schemaVersion '${envelope.schemaVersion}'`); + } + if (envelope.alg !== 'XChaCha20-Poly1305') { + throw new AppError(400, 'UNSUPPORTED_CIPHER_ALG', `Unsupported alg '${envelope.alg}'`); + } + + const nonce = Buffer.from(envelope.nonce, 'base64'); + const ciphertextWithTag = Buffer.from(envelope.ciphertext, 'base64'); + + const aad = extractEnvelopeAadBytes(envelope); + let decrypted: Buffer; + try { + decrypted = xchacha20poly1305Decrypt(dataKey, nonce, ciphertextWithTag, aad); + } catch (err) { + throw new AppError(400, 'AUTHENTICATION_FAILED', 'Failed to decrypt trace envelope: AEAD verification failed'); + } + + const computedHash = sha256Hex(decrypted); + if (computedHash !== envelope.plaintextHash) { + throw new AppError(400, 'TRACE_HASH_MISMATCH', 'Plaintext hash does not match committed hash'); + } + + return decrypted; +} diff --git a/src/services/trace-publish.service.ts b/src/services/trace-publish.service.ts index a682ab3..eee8dde 100644 --- a/src/services/trace-publish.service.ts +++ b/src/services/trace-publish.service.ts @@ -7,6 +7,7 @@ */ import { prisma } from '../lib/prisma'; import { logger } from '../lib/logger'; +import { config } from '../config'; import { AppError } from '../middlewares/error.middleware'; import { verifyTraceHash, sha256Hex } from '../utils/hash.util'; import { Prisma } from '@prisma/client'; @@ -20,6 +21,15 @@ import { publishTrace } from './chain.service'; import type { AgentScope } from './agent-controls.service'; import { agentSigner } from './stellar/clients'; import type { TraceVerification } from '../types'; +import { TraceDocV1 } from '../schemas/trace'; +import { + sealTrace, + openTrace, + loadKeks, + TracePublicPart, + createKek, +} from './trace-crypto.service'; +import crypto from 'node:crypto'; /** * Completion handler for `trace.publish` outbox rows (#35): links the landed @@ -79,16 +89,28 @@ export async function enqueueTracePublication( /** * Fetches the content at the on-chain CID, hashes the exact bytes and - * compares them with the on-chain trace_hash (x402/trace-verification.ts). + * compares them with the on-chain trace_hash. */ -export async function verifyTraceOnChain(traceId: string): Promise { +export async function verifyTraceOnChain( + traceId: string, +): Promise { const trace = await prisma.reasoningTrace.findUnique({ where: { id: traceId }, - select: { id: true, onChainTraceId: true, traceHash: true }, + select: { + id: true, + onChainTraceId: true, + traceHash: true, + contentMode: true, + plaintextHash: true, + }, }); if (!trace) throw new AppError(404, 'TRACE_NOT_FOUND', 'Reasoning trace not found'); if (trace.onChainTraceId === null) { - throw new AppError(409, 'TRACE_NOT_PUBLISHED', 'This trace has not been published on-chain yet'); + throw new AppError( + 409, + 'TRACE_NOT_PUBLISHED', + 'This trace has not been published on-chain yet', + ); } const onChain = await getOnChainTrace(trace.onChainTraceId); @@ -119,6 +141,8 @@ export async function verifyTraceOnChain(traceId: string): Promise= l.shares) { + if (sharesIn > l.shares) { + incomplete = true; + } + effectiveSharesIn = l.shares; + removedCost = l.cost; + } else { + // Average cost: cost * sharesIn / shares (floor, remainder stays in basis) + removedCost = (l.cost * sharesIn) / l.shares; + } + + const realized = collateralOut - removedCost; + const remainingCost = l.cost >= removedCost ? l.cost - removedCost : 0n; + const remainingShares = + l.shares >= effectiveSharesIn ? l.shares - effectiveSharesIn : 0n; + + return { + lot: { + ...l, + shares: remainingShares, + cost: remainingCost, + realized: l.realized + realized, + sold: l.sold + collateralOut, + fees: l.fees + fee, + trades: l.trades + 1, + incomplete, + }, + realized, + }; +} + +export function applySettlement( + yes: Lot, + no: Lot, + kind: 'yes' | 'no' | 'void', +): { yes: Lot; no: Lot; realized: bigint } { + if (kind === 'yes') { + const yesVal = yes.shares; + const yesRealized = yesVal - yes.cost; + const noRealized = 0n - no.cost; + return { + yes: { ...yes, realized: yes.realized + yesRealized, cost: 0n }, + no: { ...no, realized: no.realized + noRealized, cost: 0n }, + realized: yesRealized + noRealized, + }; + } + + if (kind === 'no') { + const noVal = no.shares; + const yesRealized = 0n - yes.cost; + const noRealized = noVal - no.cost; + return { + yes: { ...yes, realized: yes.realized + yesRealized, cost: 0n }, + no: { ...no, realized: no.realized + noRealized, cost: 0n }, + realized: yesRealized + noRealized, + }; + } + + // Void: (yes.shares + no.shares) / 2n + const combinedVal = (yes.shares + no.shares) / 2n; + const totalCost = yes.cost + no.cost; + const totalRealized = combinedVal - totalCost; + + return { + yes: { ...yes, realized: yes.realized + (combinedVal - yes.cost), cost: 0n }, + no: { ...no, realized: no.realized - no.cost, cost: 0n }, + realized: totalRealized, + }; +} + +export function applyRedeem(l: Lot, payout: bigint): Lot { + return { + ...l, + redeemed: l.redeemed + payout, + }; +} + +export function markToMarket( + l: Lot, + outcome: 'Yes' | 'No', + reserveYes: bigint, + reserveNo: bigint, + mode: 'mid' | 'liquidation', + feeBps: number, +): bigint { + if (l.shares <= 0n) return 0n; + + if (mode === 'liquidation') { + const rSold = outcome === 'Yes' ? reserveYes : reserveNo; + const rOther = outcome === 'Yes' ? reserveNo : reserveYes; + try { + return collateralOutForShares(rSold, rOther, l.shares, BigInt(feeBps)); + } catch { + return 0n; + } + } + + try { + const yesProb = priceYesBps(reserveYes, reserveNo); + const prob = outcome === 'Yes' ? yesProb : BPS - yesProb; + return (l.shares * prob) / BPS; + } catch { + return 0n; + } +} + +// ─── Database Projection ────────────────────────────────────────────────────── + +function lotFromDb(row: any): Lot { + if (!row) return createEmptyLot(); + return { + shares: toBig(row.shares), + cost: toBig(row.costBasis), + realized: toBig(row.realizedPnl), + fees: toBig(row.feesPaid), + bought: toBig(row.boughtCollateral), + sold: toBig(row.soldCollateral), + redeemed: toBig(row.redeemed), + trades: row.tradeCount || 0, + incomplete: row.incomplete ?? false, + }; +} + +export async function applyWalletEvent(event: any, txClient?: any): Promise { + const runner = txClient + ? (fn: any) => fn(txClient) + : (fn: any) => prisma.$transaction(fn); + + await runner(async (tx: any) => { + // 1. Check idempotency + try { + await tx.walletEventApplied.create({ + data: { eventId: event.id }, + }); + } catch (err: any) { + if (err instanceof Prisma.PrismaClientKnownRequestError && err.code === 'P2002') { + // Event already processed + return; + } + throw err; + } + + const { contractName, eventName, params, ledgerClosedAt } = event; + if (contractName !== 'marketCore') return; + + const day = new Date(ledgerClosedAt || Date.now()); + day.setUTCHours(0, 0, 0, 0); + + // ── MarketCreated: track creator LP seed shares ───────────────────────── + if (eventName === 'MarketCreated') { + const onChainMarketId = BigInt(params.market_id); + const creator = params.creator; + const seedAmount = BigInt(params.seed_amount || 0); + const initialYesBps = BigInt(params.initial_yes_bps || 5000); + + try { + const seed = seedReserves(seedAmount, initialYesBps); + + // Store creator LP positions + for (const [outcome, shares] of [ + ['Yes:lp', seed.creatorYes], + ['No:lp', seed.creatorNo], + ] as const) { + if (shares > 0n) { + await tx.walletPosition.upsert({ + where: { + wallet_onChainMarketId_outcome: { + wallet: creator, + onChainMarketId, + outcome, + }, + }, + create: { + wallet: creator, + onChainMarketId, + outcome, + shares: toDec(shares), + costBasis: toDec(seedAmount / 2n), + realizedPnl: toDec(0), + boughtCollateral: toDec(seedAmount / 2n), + soldCollateral: toDec(0), + feesPaid: toDec(0), + redeemed: toDec(0), + tradeCount: 1, + lastEventId: event.id, + }, + update: { + shares: toDec(shares), + lastEventId: event.id, + }, + }); + } + } + } catch (err: any) { + logger.warn({ err: err.message }, 'Failed to compute seed reserves on MarketCreated'); + } + return; + } + + // ── Trade: buy or sell ────────────────────────────────────────────────── + if (eventName === 'Trade') { + const onChainMarketId = BigInt(params.market_id); + const trader = params.trader; + const rawOutcome = params.outcome?.tag ?? params.outcome; + const outcome = rawOutcome === 'No' ? 'No' : 'Yes'; + const isBuy = Boolean(params.is_buy); + const collateral = BigInt(params.collateral || 0); + const shares = BigInt(params.shares || 0); + const fee = BigInt(params.fee || 0); + + const existing = await tx.walletPosition.findUnique({ + where: { + wallet_onChainMarketId_outcome: { + wallet: trader, + onChainMarketId, + outcome, + }, + }, + }); + + // Out of order detection + if (existing && existing.lastEventId && event.id < existing.lastEventId) { + logger.warn({ marketId: onChainMarketId.toString(), eventId: event.id }, 'Out-of-order event detected'); + await tx.walletPnlDirtyMarket.upsert({ + where: { onChainMarketId }, + create: { onChainMarketId, reason: 'Out-of-order event' }, + update: {}, + }); + } + + let lot = lotFromDb(existing); + let tradeRealized = 0n; + + if (isBuy) { + lot = applyBuy(lot, collateral, shares, fee); + } else { + const soldResult = applySell(lot, collateral, shares, fee); + lot = soldResult.lot; + tradeRealized = soldResult.realized; + } + + await tx.walletPosition.upsert({ + where: { + wallet_onChainMarketId_outcome: { + wallet: trader, + onChainMarketId, + outcome, + }, + }, + create: { + wallet: trader, + onChainMarketId, + outcome, + shares: toDec(lot.shares), + costBasis: toDec(lot.cost), + realizedPnl: toDec(lot.realized), + boughtCollateral: toDec(lot.bought), + soldCollateral: toDec(lot.sold), + feesPaid: toDec(lot.fees), + redeemed: toDec(lot.redeemed), + tradeCount: lot.trades, + lastEventId: event.id, + incomplete: lot.incomplete ?? false, + }, + update: { + shares: toDec(lot.shares), + costBasis: toDec(lot.cost), + realizedPnl: toDec(lot.realized), + boughtCollateral: toDec(lot.bought), + soldCollateral: toDec(lot.sold), + feesPaid: toDec(lot.fees), + redeemed: toDec(lot.redeemed), + tradeCount: lot.trades, + lastEventId: event.id, + incomplete: lot.incomplete ?? false, + }, + }); + + // Update daily stats + const market = await tx.market.findUnique({ + where: { onChainMarketId }, + select: { category: true }, + }); + const category = market?.category ? String(market.category) : 'MACRO'; + + const daily = await tx.walletStatsDaily.findUnique({ + where: { + wallet_day_category: { + wallet: trader, + day, + category, + }, + }, + }); + + const curVol = daily ? toBig(daily.volume) : 0n; + const curReal = daily ? toBig(daily.realizedPnl) : 0n; + const curFees = daily ? toBig(daily.fees) : 0n; + const curTrades = daily ? daily.trades : 0; + let curWins = daily ? daily.wins : 0; + let curLosses = daily ? daily.losses : 0; + + if (tradeRealized > 0n) curWins++; + else if (tradeRealized < 0n) curLosses++; + + await tx.walletStatsDaily.upsert({ + where: { + wallet_day_category: { + wallet: trader, + day, + category, + }, + }, + create: { + wallet: trader, + day, + category, + volume: toDec(curVol + collateral), + realizedPnl: toDec(curReal + tradeRealized), + fees: toDec(curFees + fee), + trades: curTrades + 1, + wins: curWins, + losses: curLosses, + }, + update: { + volume: toDec(curVol + collateral), + realizedPnl: toDec(curReal + tradeRealized), + fees: toDec(curFees + fee), + trades: curTrades + 1, + wins: curWins, + losses: curLosses, + }, + }); + + return; + } + + // ── Redeemed: holder redeems payout ───────────────────────────────────── + if (eventName === 'Redeemed') { + const onChainMarketId = BigInt(params.market_id); + const holder = params.holder; + const payout = BigInt(params.payout || 0); + + // Reconcile across positions + const positions = await tx.walletPosition.findMany({ + where: { wallet: holder, onChainMarketId }, + }); + + if (positions.length > 0) { + const first = positions[0]; + const lot = applyRedeem(lotFromDb(first), payout); + await tx.walletPosition.update({ + where: { + wallet_onChainMarketId_outcome: { + wallet: holder, + onChainMarketId, + outcome: first.outcome, + }, + }, + data: { + redeemed: toDec(lot.redeemed), + lastEventId: event.id, + }, + }); + } + return; + } + + // ── MarketResolved: settle all positions ──────────────────────────────── + if (eventName === 'MarketResolved') { + const onChainMarketId = BigInt(params.market_id); + const rawOutcome = params.outcome?.tag ?? params.outcome; + let settlementKind: 'yes' | 'no' | 'void' = 'void'; + if (rawOutcome === 'Yes') settlementKind = 'yes'; + else if (rawOutcome === 'No') settlementKind = 'no'; + + const positions = await tx.walletPosition.findMany({ + where: { onChainMarketId, settled: false }, + }); + + // Group by wallet + const byWallet = new Map(); + for (const p of positions) { + if (!byWallet.has(p.wallet)) byWallet.set(p.wallet, {}); + const entry = byWallet.get(p.wallet)!; + if (p.outcome === 'Yes') entry.yes = p; + else if (p.outcome === 'No') entry.no = p; + } + + for (const [wallet, pair] of byWallet.entries()) { + const yesLot = lotFromDb(pair.yes); + const noLot = lotFromDb(pair.no); + + const settled = applySettlement(yesLot, noLot, settlementKind); + + if (pair.yes) { + await tx.walletPosition.update({ + where: { + wallet_onChainMarketId_outcome: { + wallet, + onChainMarketId, + outcome: 'Yes', + }, + }, + data: { + costBasis: toDec(settled.yes.cost), + realizedPnl: toDec(settled.yes.realized), + settled: true, + lastEventId: event.id, + }, + }); + } + + if (pair.no) { + await tx.walletPosition.update({ + where: { + wallet_onChainMarketId_outcome: { + wallet, + onChainMarketId, + outcome: 'No', + }, + }, + data: { + costBasis: toDec(settled.no.cost), + realizedPnl: toDec(settled.no.realized), + settled: true, + lastEventId: event.id, + }, + }); + } + } + return; + } + }); +} + +/** + * Full or per-market rebuild of wallet PnL state from chain_events. + */ +export async function rebuildWalletPnl(opts: { + fromScratch?: boolean; + marketId?: bigint; +}): Promise { + logger.info({ opts }, 'Starting wallet PnL rebuild'); + + if (opts.fromScratch) { + await prisma.$transaction([ + prisma.walletEventApplied.deleteMany(), + prisma.walletPosition.deleteMany(), + prisma.walletStatsDaily.deleteMany(), + prisma.walletPnlDirtyMarket.deleteMany(), + ]); + } else if (opts.marketId) { + await prisma.$transaction([ + prisma.walletPosition.deleteMany({ + where: { onChainMarketId: opts.marketId }, + }), + prisma.walletPnlDirtyMarket.deleteMany({ + where: { onChainMarketId: opts.marketId }, + }), + ]); + } + + // Load relevant events in chronological order (ledger, id) + const events = await prisma.chainEvent.findMany({ + where: { + contractName: 'marketCore', + eventName: { in: ['MarketCreated', 'Trade', 'Redeemed', 'MarketResolved'] }, + }, + orderBy: [{ ledger: 'asc' }, { id: 'asc' }], + }); + + for (const ev of events) { + const params = ev.params as any; + if (opts.marketId && BigInt(params.market_id) !== opts.marketId) { + continue; + } + + await applyWalletEvent({ + id: ev.id, + contractName: ev.contractName, + eventName: ev.eventName, + params, + ledgerClosedAt: ev.ledgerClosedAt.toISOString(), + }); + } + + logger.info('Wallet PnL rebuild complete'); +} diff --git a/src/utils/hash.util.ts b/src/utils/hash.util.ts index ed6ef0c..239993a 100644 --- a/src/utils/hash.util.ts +++ b/src/utils/hash.util.ts @@ -94,6 +94,10 @@ export function canonicalBytes(value: unknown): Buffer { return Buffer.from(canonicalJson(value), 'utf8'); } +export function canonicalBytesStrict(value: unknown): Buffer { + return Buffer.from(canonicalJsonStrict(value), 'utf8'); +} + export function sha256Hex(bytes: Uint8Array | string): string { return crypto.createHash('sha256').update(bytes).digest('hex'); } diff --git a/tests/fixtures/contracts-trace.json b/tests/fixtures/contracts-trace.json new file mode 100644 index 0000000..765e37d --- /dev/null +++ b/tests/fixtures/contracts-trace.json @@ -0,0 +1,29 @@ +{ + "agent": "market-maker", + "marketId": 0, + "action": "create_market", + "reasoning": "Fed funds futures imply a 78% probability of a 25bps cut at the upcoming FOMC meeting.", + "inputs": [ + { + "source": "CME FedWatch", + "weight": 0.85, + "signal": "78% cut probability priced", + "rawValue": 0.78 + }, + { + "source": "FRED CPI", + "weight": 0.65, + "signal": "headline CPI YoY 2.5%", + "rawValue": 2.5 + } + ], + "timestamp": 1729000000, + "traceId": "00000000-0000-0000-0000-000000000001", + "probabilityEstimate": 0.78, + "marketProbability": 0.5, + "edge": 0.28, + "confidenceInterval": { + "lower": 0.72, + "upper": 0.84 + } +} diff --git a/tests/http.test.ts b/tests/http.test.ts index 9d6180c..940c04c 100644 --- a/tests/http.test.ts +++ b/tests/http.test.ts @@ -1,5 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; import type { AddressInfo } from 'node:net'; import { Keypair } from '@stellar/stellar-sdk'; import { setupTestEnv } from './helpers/env'; @@ -18,14 +19,22 @@ async function withServer(fn: (base: string) => Promise) { test('POST /oracle/resolve no longer exists', async () => { await withServer(async (base) => { - const res = await fetch(`${base}/api/v1/oracle/resolve`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"marketId":"x","yesWon":true}' }); + const res = await fetch(`${base}/api/v1/oracle/resolve`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"marketId":"x","yesWon":true}', + }); assert.equal(res.status, 404); }); }); test('POST /auth/connect no longer exists', async () => { await withServer(async (base) => { - const res = await fetch(`${base}/api/v1/auth/connect`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{"walletAddress":"0x0000000000000000000000000000000000000001"}' }); + const res = await fetch(`${base}/api/v1/auth/connect`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{"walletAddress":"0x0000000000000000000000000000000000000001"}', + }); assert.equal(res.status, 404); }); }); @@ -36,7 +45,10 @@ test('POST /markets/generate requires an admin', async () => { const anon = await fetch(`${base}/api/v1/markets/generate`, { method: 'POST' }); assert.equal(anon.status, 401); const token = signToken('u1', Keypair.random().publicKey()); - const nonAdmin = await fetch(`${base}/api/v1/markets/generate`, { method: 'POST', headers: { authorization: `Bearer ${token}` } }); + const nonAdmin = await fetch(`${base}/api/v1/markets/generate`, { + method: 'POST', + headers: { authorization: `Bearer ${token}` }, + }); assert.equal(nonAdmin.status, 403); assert.equal((await nonAdmin.json()).error.code, 'FORBIDDEN'); }); @@ -66,7 +78,140 @@ test('CORS only allows configured origins', async () => { test('BigInt values serialise as decimal strings', async () => { const { default: app } = await import('../src/app'); const replacer = app.get('json replacer'); - assert.equal(JSON.stringify({ id: 18446744073709551615n }, replacer), '{"id":"18446744073709551615"}'); + assert.equal( + JSON.stringify({ id: 18446744073709551615n }, replacer), + '{"id":"18446744073709551615"}', + ); +}); + +// ─── Issue 153 HTTP Tests ─────────────────────────────────────────────────── + +test('GET /api/v1/schemas/trace/v1.json returns application/schema+json with immutable caching', async () => { + await withServer(async (base) => { + const res = await fetch(`${base}/api/v1/schemas/trace/v1.json`); + assert.equal(res.status, 200); + assert.equal(res.headers.get('content-type'), 'application/schema+json; charset=utf-8'); + assert.equal(res.headers.get('cache-control'), 'public, max-age=31536000, immutable'); + + const schema = await res.json(); + assert.equal(schema.schemaVersion, 1); + assert.equal(schema.kind, 'oracledesk.trace'); + assert.ok(schema.$id); + }); +}); + +// ─── Issue 154 HTTP Tests ─────────────────────────────────────────────────── + +test('GET /traces/:id/key requires auth and an active pass', async () => { + const { signToken } = await import('../src/middlewares/auth.middleware'); + await withServer(async (base) => { + // 1. Without auth + const anon = await fetch(`${base}/api/v1/traces/11111111-1111-1111-1111-111111111111/key`); + assert.equal(anon.status, 401); + + // 2. With auth but without active subscription + const token = signToken('u-test', Keypair.random().publicKey()); + const forbidden = await fetch( + `${base}/api/v1/traces/11111111-1111-1111-1111-111111111111/key`, + { + headers: { authorization: `Bearer ${token}` }, + }, + ); + assert.equal(forbidden.status, 403); + }); +}); + +test('internal key release rejects bad HMAC and replayed nonce', async () => { + const { config } = await import('../src/config'); + (config as any).X402_KEY_RELEASE_SECRET = 'test-internal-secret-32-bytes-long'; + + await withServer(async (base) => { + const onChainId = '0'; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const nonce = 'nonce-' + crypto.randomBytes(8).toString('hex'); + + // 1. Bad HMAC signature + const badSigRes = await fetch(`${base}/api/v1/internal/traces/${onChainId}/key`, { + method: 'POST', + headers: { + 'x-signature': 'bad-signature', + 'x-timestamp': timestamp, + 'x-nonce': nonce, + }, + }); + assert.equal(badSigRes.status, 401); + const badJson = await badSigRes.json(); + assert.equal(badJson.error.code, 'INVALID_SIGNATURE'); + + // 2. Valid signature + const validSig = crypto + .createHmac('sha256', (config as any).X402_KEY_RELEASE_SECRET) + .update(`${timestamp}:${nonce}:${onChainId}`) + .digest('hex'); + + const firstRes = await fetch(`${base}/api/v1/internal/traces/${onChainId}/key`, { + method: 'POST', + headers: { + 'x-signature': validSig, + 'x-timestamp': timestamp, + 'x-nonce': nonce, + }, + }); + // Trace 0 not found in DB in this test, but auth succeeded! + assert.equal(firstRes.status, 404); + + // 3. Replayed nonce with valid signature + const replayRes = await fetch(`${base}/api/v1/internal/traces/${onChainId}/key`, { + method: 'POST', + headers: { + 'x-signature': validSig, + 'x-timestamp': timestamp, + 'x-nonce': nonce, + }, + }); + assert.equal(replayRes.status, 401); + const replayJson = await replayRes.json(); + assert.equal(replayJson.error.code, 'REPLAYED_NONCE'); + }); +}); + +// ─── Issue 155 HTTP Tests ─────────────────────────────────────────────────── + +test('GET /leaderboard validates window and metric', async () => { + await withServer(async (base) => { + // 1. Invalid window + const badWindow = await fetch(`${base}/api/v1/leaderboard?window=invalid`); + assert.equal(badWindow.status, 400); + + // 2. Invalid metric + const badMetric = await fetch(`${base}/api/v1/leaderboard?metric=invalid`); + assert.equal(badMetric.status, 400); + + // 3. Valid defaults + const valid = await fetch(`${base}/api/v1/leaderboard`); + assert.equal(valid.status, 200); + const json = await valid.json(); + assert.equal(json.ok, true); + assert.ok(Array.isArray(json.data)); + }); +}); + +test('GET /wallets/:address/stats rejects malformed addresses', async () => { + await withServer(async (base) => { + // 1. Invalid address + const badAddr = await fetch(`${base}/api/v1/wallets/not-a-valid-stellar-address/stats`); + assert.equal(badAddr.status, 400); + const badJson = await badAddr.json(); + assert.equal(badJson.error.code, 'INVALID_ADDRESS'); + + // 2. Valid G... address + const validG = Keypair.random().publicKey(); + const okRes = await fetch(`${base}/api/v1/wallets/${validG}/stats`); + assert.equal(okRes.status, 200); + const okJson = await okRes.json(); + assert.equal(okJson.ok, true); + assert.equal(okJson.data.wallet, validG); + }); }); test('admin treasury routes require an admin (#156)', async () => { diff --git a/tests/market-saga.test.ts b/tests/market-saga.test.ts new file mode 100644 index 0000000..324fdc3 --- /dev/null +++ b/tests/market-saga.test.ts @@ -0,0 +1,253 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setupTestEnv } from './helpers/env'; +import { prisma } from '../lib/prisma'; +import { + startRun, + advance, + cancelRun, + setSagaFaultHook, + claimRun, +} from '../services/market-saga.service'; +import { setIpfsTransportForTests } from '../services/ipfs.service'; + +setupTestEnv(); + +function setupMockDb() { + const db = { + runs: new Map(), + markets: new Map(), + traces: new Map(), + logs: [] as any[], + }; + + (prisma.marketGenerationRun.create as any) = async ({ data }: any) => { + const id = data.id || `run-${db.runs.size + 1}`; + const run = { + id, + trigger: data.trigger, + state: data.state || 'STARTED', + attempts: 0, + nextAttemptAt: null, + lockedBy: null, + lockedUntil: null, + inputs: data.inputs || null, + outputs: data.outputs || { v: 1 }, + marketId: data.marketId || null, + lastError: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + db.runs.set(id, run); + return run; + }; + + (prisma.marketGenerationRun.findUnique as any) = async ({ where }: any) => { + return db.runs.get(where.id) || null; + }; + + (prisma.marketGenerationRun.update as any) = async ({ where, data }: any) => { + const existing = db.runs.get(where.id); + if (!existing) throw new Error('Not found'); + const updated = { + ...existing, + ...data, + updatedAt: new Date(), + }; + db.runs.set(where.id, updated); + return updated; + }; + + (prisma.marketGenerationRun.updateMany as any) = async ({ where, data }: any) => { + let count = 0; + for (const [id, run] of db.runs.entries()) { + let matches = true; + if (where.id && run.id !== where.id) matches = false; + if (where.lockedBy && run.lockedBy !== where.lockedBy) matches = false; + if (where.state?.notIn && where.state.notIn.includes(run.state)) matches = false; + if (matches) { + Object.assign(run, data); + count++; + } + } + return { count }; + }; + + (prisma.market.create as any) = async ({ data }: any) => { + const id = `m-${db.markets.size + 1}`; + const m = { id, ...data, createdAt: new Date() }; + db.markets.set(id, m); + return m; + }; + + (prisma.market.findUnique as any) = async ({ where }: any) => { + return db.markets.get(where.id) || null; + }; + + (prisma.market.update as any) = async ({ where, data }: any) => { + const m = db.markets.get(where.id); + if (!m) throw new Error('Market not found'); + Object.assign(m, data); + return m; + }; + + (prisma.reasoningTrace.create as any) = async ({ data }: any) => { + const id = data.id || `t-${db.traces.size + 1}`; + const t = { id, ...data, createdAt: new Date() }; + db.traces.set(id, t); + return t; + }; + + (prisma.reasoningTrace.upsert as any) = async ({ create, update, where }: any) => { + let t = db.traces.get(where.id); + if (!t) { + t = { id: where.id, ...create, createdAt: new Date() }; + db.traces.set(where.id, t); + } else { + Object.assign(t, update); + } + return t; + }; + + (prisma.reasoningTrace.findFirst as any) = async ({ where }: any) => { + return [...db.traces.values()].find((t) => t.marketId === where.marketId) || null; + }; + + (prisma.reasoningTrace.update as any) = async ({ where, data }: any) => { + const t = db.traces.get(where.id); + if (t) Object.assign(t, data); + return t; + }; + + (prisma.traceKey.upsert as any) = async () => ({}); + (prisma.agentLog.create as any) = async ({ data }: any) => { + db.logs.push(data); + return data; + }; + + (prisma.$transaction as any) = async (arg: any) => { + if (typeof arg === 'function') { + return arg(prisma); + } + return Promise.all(arg); + }; + + return db; +} + +test('happy path reaches COMPLETED with all outputs recorded in dry-run mode', async () => { + const db = setupMockDb(); + setIpfsTransportForTests(async (_bytes, name) => ({ cid: `cid-${name}`, size: 100 })); + + try { + const runId = await startRun('cron', { + rawSignals: { + signalCount: 1, + news: [{ source: 'FRED', title: 'Rates drop', content: 'Details', relevanceScore: 1 }], + fed: [], + macro: [], + aggregatedAt: new Date(), + }, + proposal: { + market_question: 'Will the Fed cut rates in Dec 2026?', + initial_yes_probability: 0.65, + resolution_oracle: 'FRED', + expiry_timestamp: 1798761600, + settlement_currency: 'USDC', + minimum_liquidity_usdc: 100, + category: 'FED', + confidence_interval: { lower: 0.55, upper: 0.75 }, + reasoning: 'Rate trajectory looks dovish', + }, + }); + + const run = await advance(runId, { maxSteps: 20 }); + assert.equal(run.state, 'COMPLETED'); + assert.ok(run.outputs); + const outputs = run.outputs as any; + assert.ok(outputs.questionCid); + assert.ok(outputs.questionHash); + assert.ok(outputs.traceId); + } finally { + setIpfsTransportForTests(null); + } +}); + +test('crash after QUESTION_PINNED resumes without re-pinning different bytes', async () => { + const db = setupMockDb(); + let pinCount = 0; + setIpfsTransportForTests(async (_bytes, name) => { + pinCount++; + return { cid: `cid-${name}-${pinCount}`, size: 100 }; + }); + + try { + const runId = await startRun('cron', { + proposal: { + market_question: 'Will inflation fall below 2% in 2026?', + initial_yes_probability: 0.5, + resolution_oracle: 'FRED', + expiry_timestamp: 1798761600, + settlement_currency: 'USDC', + minimum_liquidity_usdc: 100, + category: 'MACRO', + confidence_interval: { lower: 0.4, upper: 0.6 }, + reasoning: 'Inflation is falling', + }, + }); + + // Advance 4 steps to reach QUESTION_PINNED + const step1 = await advance(runId, { maxSteps: 4 }); + assert.equal(step1.state, 'QUESTION_PINNED'); + const firstQHash = (step1.outputs as any).questionHash; + const initialPins = pinCount; + + // Advance again to complete + const finalRun = await advance(runId, { maxSteps: 10 }); + assert.equal(finalRun.state, 'COMPLETED'); + const finalQHash = (finalRun.outputs as any).questionHash; + assert.equal(firstQHash, finalQHash, 'Question hash must be preserved'); + } finally { + setIpfsTransportForTests(null); + } +}); + +test('permanent error moves run to NEEDS_ATTENTION', async () => { + setupMockDb(); + const runId = await startRun('cron'); + + // Inject permanent error on STARTED step + setSagaFaultHook('STARTED', async () => { + const err: any = new Error('Pinata credentials not set'); + err.code = 'IPFS_NOT_CONFIGURED'; + throw err; + }); + + try { + const run = await advance(runId); + assert.equal(run.state, 'NEEDS_ATTENTION'); + assert.ok((run.lastError as any)?.code === 'IPFS_NOT_CONFIGURED'); + } finally { + setSagaFaultHook('STARTED', null); + } +}); + +test('concurrent advance calls cannot claim the same run while locked', async () => { + const db = setupMockDb(); + const runId = await startRun('cron'); + + const claim1 = await claimRun(runId, 'worker-1', 60_000); + assert.ok(claim1); + assert.equal(claim1.lockedBy, 'worker-1'); + + // Second worker attempts claim while locked + const claim2 = await claimRun(runId, 'worker-2', 60_000); + assert.equal(claim2, null, 'Second worker must fail to claim locked run'); +}); + +test('pre-chain run can be cancelled by admin', async () => { + const db = setupMockDb(); + const runId = await startRun('cron'); + const cancelled = await cancelRun(runId, 'Test cancellation'); + assert.equal(cancelled.state, 'CANCELLED'); +}); diff --git a/tests/trace-crypto.test.ts b/tests/trace-crypto.test.ts new file mode 100644 index 0000000..f9caf9e --- /dev/null +++ b/tests/trace-crypto.test.ts @@ -0,0 +1,156 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import { setupTestEnv } from './helpers/env'; +import { + createKek, + sealTrace, + openTrace, + unwrapKey, + TraceEnvelopeV1, + TracePublicPart, +} from '../src/services/trace-crypto.service'; +import { canonicalBytesStrict, sha256Hex } from '../src/utils/hash.util'; + +setupTestEnv(); + +const dummyPublic: TracePublicPart = { + traceId: '11111111-1111-1111-1111-111111111111', + agentType: 'MARKET_MAKER', + decisionType: 'MARKET_CREATION', + market: { + backendId: '22222222-2222-2222-2222-222222222222', + onChainId: '0', + }, + probabilityEstimate: 0.65, + marketProbability: 0.5, + edge: 0.15, + previewSources: [ + { source: 'FRED', weight: 0.8, signal: 'Rates drop', rawValue: 0.5 }, + ], + createdAt: new Date().toISOString(), +}; + +test('sealTrace then openTrace round-trips and plaintext hashes to plaintextHash', () => { + const kek = createKek('kek-1', crypto.randomBytes(32)); + const plaintext = Buffer.from( + JSON.stringify({ + secretReasoning: 'Confidential alpha here', + hedgeConditions: ['Condition 1'], + betFraction: 0.05, + }), + 'utf8', + ); + + const sealed = sealTrace(plaintext, dummyPublic, kek); + + assert.equal(sealed.envelope.kind, 'oracledesk.trace.enc'); + assert.equal(sealed.envelope.schemaVersion, 1); + assert.equal(sealed.envelope.alg, 'XChaCha20-Poly1305'); + assert.equal(sealed.envelope.kid, 'kek-1'); + assert.equal(sealed.plaintextHash, sha256Hex(plaintext)); + + // Unwrap data key + const dataKey = kek.unwrap(sealed.wrappedKey); + assert.equal(dataKey.length, 32); + + // Open trace + const decrypted = openTrace(sealed.envelope, dataKey); + assert.deepEqual(decrypted, plaintext); + assert.equal(sha256Hex(decrypted), sealed.envelope.plaintextHash); +}); + +test('tampering with public header or ciphertext fails authentication', () => { + const kek = createKek('kek-1', crypto.randomBytes(32)); + const plaintext = Buffer.from('Sensitive trace information', 'utf8'); + const sealed = sealTrace(plaintext, dummyPublic, kek); + const dataKey = kek.unwrap(sealed.wrappedKey); + + // 1. Tamper with public edge + const tamperedHeader: TraceEnvelopeV1 = { + ...sealed.envelope, + public: { + ...sealed.envelope.public, + edge: 0.99, + }, + }; + + assert.throws( + () => openTrace(tamperedHeader, dataKey), + (err: any) => err.message.includes('AEAD verification failed') || err.message.includes('AUTHENTICATION_FAILED'), + ); + + // 2. Tamper with ciphertext + const rawCipher = Buffer.from(sealed.envelope.ciphertext, 'base64'); + rawCipher[0] ^= 0x01; // flip one bit + const tamperedCipher: TraceEnvelopeV1 = { + ...sealed.envelope, + ciphertext: rawCipher.toString('base64'), + }; + + assert.throws( + () => openTrace(tamperedCipher, dataKey), + (err: any) => err.message.includes('AEAD verification failed') || err.message.includes('AUTHENTICATION_FAILED'), + ); + + // 3. Tamper with plaintextHash in envelope + const tamperedHash: TraceEnvelopeV1 = { + ...sealed.envelope, + plaintextHash: '0000000000000000000000000000000000000000000000000000000000000000', + }; + + assert.throws( + () => openTrace(tamperedHash, dataKey), + (err: any) => err.message.includes('AEAD verification failed') || err.message.includes('AUTHENTICATION_FAILED'), + ); +}); + +test('envelope bytes hash equals the trace_hash for on-chain publication', () => { + const kek = createKek('kek-1', crypto.randomBytes(32)); + const plaintext = Buffer.from('Plaintext payload', 'utf8'); + const sealed = sealTrace(plaintext, dummyPublic, kek); + + const expectedHash = sha256Hex(sealed.bytes); + const envelopeHash = sha256Hex(canonicalBytesStrict(sealed.envelope)); + + assert.equal(expectedHash, envelopeHash); +}); + +test('KEK rotation: key wrapped under old kid still unwraps; new traces use active kid', () => { + const key1 = crypto.randomBytes(32); + const key2 = crypto.randomBytes(32); + const kek1 = createKek('kek-2026-09', key1); + const kek2 = createKek('kek-2026-10', key2); + + const keksMap = new Map([ + ['kek-2026-09', kek1], + ['kek-2026-10', kek2], + ]); + + const plaintext = Buffer.from('Old trace content', 'utf8'); + const sealedOld = sealTrace(plaintext, dummyPublic, kek1); + + // New trace under kek2 + const sealedNew = sealTrace(plaintext, dummyPublic, kek2); + assert.equal(sealedNew.envelope.kid, 'kek-2026-10'); + + // Both can unwrap + const unwrapOld = unwrapKey(sealedOld.wrappedKey, 'kek-2026-09', keksMap); + const unwrapNew = unwrapKey(sealedNew.wrappedKey, 'kek-2026-10', keksMap); + + assert.deepEqual(openTrace(sealedOld.envelope, unwrapOld), plaintext); + assert.deepEqual(openTrace(sealedNew.envelope, unwrapNew), plaintext); +}); + +test('preview response contains no locked fields and no key material', () => { + const lockedKeys = ['sourcesUsed', 'hedgeConditions', 'betFraction', 'betSizeUsdc', 'dataKey', 'wrappedKey']; + const publicKeys = Object.keys(dummyPublic); + + for (const locked of lockedKeys) { + assert.equal( + publicKeys.includes(locked), + false, + `Public preview must not contain locked field '${locked}'`, + ); + } +}); diff --git a/tests/trace-schema.test.ts b/tests/trace-schema.test.ts new file mode 100644 index 0000000..e6a8625 --- /dev/null +++ b/tests/trace-schema.test.ts @@ -0,0 +1,223 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { setupTestEnv } from './helpers/env'; +import { TraceDocV1, LegacyTraceDoc, buildTraceDoc, fromContractsTrace } from '../src/schemas/trace'; +import { QuestionDocV1, QuestionDocV2, questionDocument } from '../src/schemas/question'; +import { canonicalJson, canonicalJsonStrict, verifyTraceHash } from '../src/utils/hash.util'; +import { pinDocument, setIpfsTransportForTests } from '../src/services/ipfs.service'; +import { generateSchemas } from '../scripts/gen-schemas'; + +setupTestEnv(); + +test('buildTraceDoc output validates against TraceDocV1 for market-maker and trader inputs', () => { + const mmDoc = buildTraceDoc({ + traceId: '11111111-1111-1111-1111-111111111111', + agentType: 'MARKET_MAKER', + decisionType: 'MARKET_CREATION', + action: 'create_market', + backendMarketId: '22222222-2222-2222-2222-222222222222', + onChainMarketId: '0', + probabilityEstimate: 0.65, + marketProbability: 0.5, + edge: 0.15, + confidenceInterval: { lower: 0.55, upper: 0.75 }, + sourcesUsed: [{ source: 'FRED', weight: 0.8, signal: 'Rates drop', rawValue: 0.5 }], + betFraction: null, + betSizeUsdc: null, + hedgeConditions: ['CPI > 3.0%'], + }); + + assert.equal(mmDoc.schemaVersion, 1); + assert.equal(mmDoc.kind, 'oracledesk.trace'); + assert.equal(mmDoc.agentType, 'MARKET_MAKER'); + assert.equal(mmDoc.action, 'create_market'); + assert.equal(mmDoc.market.onChainId, '0'); + assert.doesNotThrow(() => TraceDocV1.parse(mmDoc)); + + const traderDoc = buildTraceDoc({ + traceId: '33333333-3333-3333-3333-333333333333', + agentType: 'TRADER', + decisionType: 'BUY_YES', + action: 'buy_yes', + backendMarketId: '22222222-2222-2222-2222-222222222222', + onChainMarketId: 1, + probabilityEstimate: 0.8, + marketProbability: 0.6, + edge: 0.2, + confidenceInterval: { lower: 0.75, upper: 0.85 }, + sourcesUsed: [{ source: 'NEWSAPI', weight: 0.9, signal: 'Approval likely', rawValue: 1 }], + betFraction: 0.05, + betSizeUsdc: 50, + }); + + assert.equal(traderDoc.agentType, 'TRADER'); + assert.equal(traderDoc.betFraction, 0.05); + assert.equal(traderDoc.betSizeUsdc, 50); + assert.doesNotThrow(() => TraceDocV1.parse(traderDoc)); +}); + +test('trace doc never contains undefined and canonicalJsonStrict round-trips', () => { + const doc = buildTraceDoc({ + traceId: '11111111-1111-1111-1111-111111111111', + agentType: 'MARKET_MAKER', + decisionType: 'MARKET_CREATION', + action: 'create_market', + backendMarketId: '22222222-2222-2222-2222-222222222222', + onChainMarketId: null, + probabilityEstimate: 0.5, + marketProbability: 0.5, + edge: 0.0, + confidenceInterval: { lower: 0.4, upper: 0.6 }, + sourcesUsed: [], + }); + + // Must not throw with strict canonicalization + const canonical = canonicalJsonStrict(doc); + assert.ok(typeof canonical === 'string'); + assert.ok(!canonical.includes('undefined')); + + // Round trip parse and check identical bytes + const parsed = JSON.parse(canonical); + const reCanonical = canonicalJsonStrict(parsed); + assert.equal(canonical, reCanonical); +}); + +test('pinDocument refuses an invalid trace and never calls the transport', async () => { + let transportCalled = false; + setIpfsTransportForTests(async () => { + transportCalled = true; + return { cid: 'fake-cid', size: 100 }; + }); + + try { + const invalidDoc = { + schemaVersion: 1, + kind: 'oracledesk.trace', + // missing traceId and required fields + }; + + await assert.rejects( + async () => { + await pinDocument(TraceDocV1, invalidDoc as any, 'invalid-trace'); + }, + (err: any) => { + return err.name === 'AppError' || err.message.includes('DOCUMENT_SCHEMA_INVALID'); + }, + ); + + assert.equal(transportCalled, false); + } finally { + setIpfsTransportForTests(null); + } +}); + +test('question v1 documents for existing markets validate and hash predictably', () => { + const v1 = questionDocument( + { + id: '22222222-2222-2222-2222-222222222222', + question: 'Will the Fed cut rates by 25bps?', + category: 'FED', + expiryTimestamp: 1729000000000, + }, + { + specHash: '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', + source: 'FRED', + }, + 'Macro', + 1, + ); + + assert.equal(v1.version, 1); + assert.doesNotThrow(() => QuestionDocV1.parse(v1)); +}); + +test('question v2 document validates and pins', async () => { + let transportCalled = false; + setIpfsTransportForTests(async () => { + transportCalled = true; + return { cid: 'test-cid-question-v2', size: 120 }; + }); + + try { + const v2 = questionDocument( + { + id: '22222222-2222-2222-2222-222222222222', + question: 'Will the Fed cut rates by 25bps in 2026?', + category: 'FED', + expiryTimestamp: 1782777600000, + }, + { + specHash: 'abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789', + source: 'FRED', + }, + 'Macro', + 2, + ); + + assert.equal(v2.schemaVersion, 2); + assert.equal(v2.kind, 'oracledesk.question'); + assert.doesNotThrow(() => QuestionDocV2.parse(v2)); + + const pinned = await pinDocument(QuestionDocV2, v2, 'test-question-v2'); + assert.equal(pinned.cid, 'test-cid-question-v2'); + assert.equal(transportCalled, true); + } finally { + setIpfsTransportForTests(null); + } +}); + +test('legacy trace without schemaVersion verifies by hash and reports schemaVersion 0', () => { + const legacy = { + traceId: '11111111-1111-1111-1111-111111111111', + agentType: 'MARKET_MAKER', + action: 'create_market', + probability: 0.65, + reasoning: 'Legacy unversioned reasoning payload', + }; + + const bytes = Buffer.from(canonicalJson(legacy), 'utf8'); + const hash = require('crypto').createHash('sha256').update(bytes).digest('hex'); + + const verified = verifyTraceHash(bytes, hash); + assert.equal(verified, true); + + const parsed = JSON.parse(bytes.toString('utf8')); + const isV1 = TraceDocV1.safeParse(parsed).success; + const isLegacy = LegacyTraceDoc.safeParse(parsed).success; + + assert.equal(isV1, false); + assert.equal(isLegacy, true); + const reportedVersion = (parsed as any).schemaVersion ?? 0; + assert.equal(reportedVersion, 0); +}); + +test('backend canonicalJson matches contracts canonicalize on shared fixtures', () => { + const fixturePath = path.join(__dirname, 'fixtures', 'contracts-trace.json'); + const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + + // Contracts canonicalize sorts keys recursively and drops undefined + const cJson = canonicalJson(fixture); + const strictJson = canonicalJsonStrict(fixture); + assert.equal(cJson, strictJson); +}); + +test('fromContractsTrace maps the contracts fixture to a valid TraceDocV1', () => { + const fixturePath = path.join(__dirname, 'fixtures', 'contracts-trace.json'); + const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); + + const v1 = fromContractsTrace(fixture); + assert.equal(v1.schemaVersion, 1); + assert.equal(v1.kind, 'oracledesk.trace'); + assert.equal(v1.agentType, 'MARKET_MAKER'); + assert.equal(v1.action, 'create_market'); + assert.equal(v1.market.onChainId, '0'); + assert.equal(v1.sourcesUsed.length, 2); + assert.doesNotThrow(() => TraceDocV1.parse(v1)); +}); + +test('committed JSON schemas are up to date', () => { + const ok = generateSchemas({ check: true }); + assert.equal(ok, true, 'Committed JSON schemas must match generator output'); +}); diff --git a/tests/wallet-pnl.test.ts b/tests/wallet-pnl.test.ts new file mode 100644 index 0000000..221cd06 --- /dev/null +++ b/tests/wallet-pnl.test.ts @@ -0,0 +1,133 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setupTestEnv } from './helpers/env'; +import { + createEmptyLot, + applyBuy, + applySell, + applySettlement, + applyRedeem, + markToMarket, + toBig, + toDec, + applyWalletEvent, +} from '../services/wallet-pnl.service'; +import { + calcBuy, + calcSell, + settleValue, + priceYesBps, + seedReserves, +} from '../generated/fpmm'; +import { prisma } from '../lib/prisma'; + +setupTestEnv(); + +test('applyBuy adds shares, cost basis, fees, and bought volume', () => { + const lot = createEmptyLot(); + const feeBps = 100n; // 1% + const collateralIn = 10_000_000n; // 1 USDC (7 decimals) + const quote = calcBuy(100_000_000n, 100_000_000n, collateralIn, feeBps); + + const updated = applyBuy(lot, collateralIn, quote.sharesOut, quote.fee); + assert.equal(updated.shares, quote.sharesOut); + assert.equal(updated.cost, collateralIn); + assert.equal(updated.bought, collateralIn); + assert.equal(updated.fees, quote.fee); + assert.equal(updated.trades, 1); + assert.equal(updated.realized, 0n); +}); + +test('applySell computes average-cost realized PnL correctly on partial sell', () => { + let lot = createEmptyLot(); + // Buy 200 shares for 200 collateral (1 per share) + lot = applyBuy(lot, 200n, 200n, 0n); + + // Sell 100 shares for 120 collateral (profit of 20) + const { lot: updated, realized } = applySell(lot, 120n, 100n, 0n); + + assert.equal(updated.shares, 100n); + assert.equal(updated.cost, 100n); // 200 - (200 * 100 / 200) = 100 + assert.equal(realized, 20n); // 120 - 100 = 20 + assert.equal(updated.realized, 20n); + assert.equal(updated.sold, 120n); + assert.equal(updated.trades, 2); +}); + +test('applySell on full liquidation clears shares and basis', () => { + let lot = createEmptyLot(); + lot = applyBuy(lot, 500n, 500n, 5n); + + const { lot: updated, realized } = applySell(lot, 400n, 500n, 4n); + + assert.equal(updated.shares, 0n); + assert.equal(updated.cost, 0n); + assert.equal(realized, -100n); // 400 - 500 = -100 + assert.equal(updated.realized, -100n); + assert.equal(updated.incomplete, false); +}); + +test('sell beyond known shares marks position incomplete instead of going negative', () => { + let lot = createEmptyLot(); + lot = applyBuy(lot, 100n, 100n, 0n); + + // Attempt to sell 150 shares (50 more than owned) + const { lot: updated, realized } = applySell(lot, 180n, 150n, 0n); + + assert.equal(updated.shares, 0n); + assert.equal(updated.cost, 0n); + assert.equal(updated.incomplete, true); + assert.equal(realized, 80n); // 180 - 100 = 80 +}); + +test('settlement Yes, No, and Void matches settle_value for combined position', () => { + const yesLot = applyBuy(createEmptyLot(), 100n, 100n, 0n); + const noLot = applyBuy(createEmptyLot(), 80n, 80n, 0n); + + // 1. Yes settlement + const resYes = applySettlement(yesLot, noLot, 'yes'); + assert.equal(resYes.realized, (100n - 100n) + (0n - 80n)); // -80n + + // 2. No settlement + const resNo = applySettlement(yesLot, noLot, 'no'); + assert.equal(resNo.realized, (0n - 100n) + (80n - 80n)); // -100n + + // 3. Void settlement: settle_value = (yes + no) / 2 + const resVoid = applySettlement(yesLot, noLot, 'void'); + const expectedSettleVal = settleValue('void', 100n, 80n); + assert.equal(expectedSettleVal, 90n); // (100 + 80) / 2 + assert.equal(resVoid.realized, expectedSettleVal - (100n + 80n)); // 90 - 180 = -90 +}); + +test('applyRedeem tracks payout in redeemed accumulator', () => { + const lot = createEmptyLot(); + const redeemed = applyRedeem(lot, 250n); + assert.equal(redeemed.redeemed, 250n); +}); + +test('markToMarket calculates mid and liquidation values', () => { + let lot = createEmptyLot(); + lot = applyBuy(lot, 50n, 100n, 0n); // 100 shares + + const rYes = 100_000_000n; + const rNo = 100_000_000n; + + // Mid price is 50% + const midVal = markToMarket(lot, 'Yes', rYes, rNo, 'mid', 100); + assert.equal(midVal, 50n); // 100 * 5000 / 10000 = 50 + + // Liquidation value is > 0 and <= mid + const liqVal = markToMarket(lot, 'Yes', rYes, rNo, 'liquidation', 100); + assert.ok(liqVal > 0n); + assert.ok(liqVal <= midVal); +}); + +test('toBig and toDec conversions preserve exact integers without floating drift', () => { + const val = 12345678901234567890n; + const dec = toDec(val); + const back = toBig(dec); + assert.equal(back, val); + + assert.equal(toBig('5000000'), 5000000n); + assert.equal(toBig(null), 0n); +});