From 0fbe3867fbc54f343ce87c1fa2dd96b82d013bc8 Mon Sep 17 00:00:00 2001 From: bade2brazy <205598258+bade2brazy@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:16:49 +0100 Subject: [PATCH] feat: implement market-maker saga, versioned schemas, confidential traces, and wallet pnl - Make market-maker cycle a resumable saga with explicit step states, checkpoints, and creation recovery (Closes #152) - Publish versioned JSON Schemas for reasoning traces and question documents (Closes #153) - Seal premium reasoning traces with XChaCha20-Poly1305 envelope encryption and key release endpoints (Closes #154) - Compute per-wallet trading PnL, daily performance ledger, and leaderboard from indexed events (Closes #155) Closes #152, #153, #154, #155 --- .env.example | 16 + .github/workflows/ci.yml | 3 + CONTRIBUTING.md | 1 + docs/api.md | 64 ++ docs/architecture.md | 23 + docs/contract-requests.md | 19 + docs/trace-format.md | 137 +++++ package.json | 5 +- .../migration.sql | 41 ++ .../migration.sql | 18 + .../20261017100002_wallet_pnl/migration.sql | 55 ++ prisma/schema.prisma | 123 +++- scripts/gen-schemas.mjs | 204 +++++++ scripts/gen-schemas.ts | 200 ++++++ scripts/rebuild-wallet-pnl.ts | 30 + scripts/rewrap-trace-keys.ts | 65 ++ scripts/unpin-legacy-traces.ts | 77 +++ src/agents/market-maker.agent.ts | 130 ++-- src/app.ts | 1 + src/config/index.ts | 34 ++ src/controllers/admin.controller.ts | 74 +++ src/controllers/leaderboard.controller.ts | 350 +++++++++++ src/controllers/trace.controller.ts | 126 ++++ src/cron/ingestion.cron.ts | 4 + src/lib/logger.ts | 4 + src/routes/admin.routes.ts | 17 + src/routes/index.ts | 8 + src/routes/internal.routes.ts | 8 + src/routes/leaderboard.routes.ts | 9 + src/routes/schemas.routes.ts | 28 + src/routes/trace.routes.ts | 2 + src/schemas/question-v1.schema.json | 42 ++ src/schemas/question.schema.json | 62 ++ src/schemas/question.ts | 90 +++ src/schemas/trace.schema.json | 142 +++++ src/schemas/trace.ts | 191 ++++++ src/server.ts | 8 +- src/services/indexer.service.ts | 12 + src/services/ipfs.service.ts | 15 + src/services/market-chain.service.ts | 119 +++- src/services/market-saga.service.ts | 573 +++++++++++++++++ src/services/market.service.ts | 3 +- src/services/reasoning.service.ts | 66 +- src/services/subscription.service.ts | 13 +- src/services/trace-crypto.service.ts | 299 +++++++++ src/services/trace-publish.service.ts | 286 ++++++++- src/services/wallet-pnl.service.ts | 575 ++++++++++++++++++ src/utils/hash.util.ts | 52 ++ tests/fixtures/contracts-trace.json | 29 + tests/http.test.ts | 153 ++++- tests/market-saga.test.ts | 253 ++++++++ tests/trace-crypto.test.ts | 156 +++++ tests/trace-schema.test.ts | 223 +++++++ tests/wallet-pnl.test.ts | 133 ++++ 54 files changed, 5195 insertions(+), 176 deletions(-) create mode 100644 docs/trace-format.md create mode 100644 prisma/migrations/20261017100000_market_generation_runs/migration.sql create mode 100644 prisma/migrations/20261017100001_confidential_traces/migration.sql create mode 100644 prisma/migrations/20261017100002_wallet_pnl/migration.sql create mode 100644 scripts/gen-schemas.mjs create mode 100644 scripts/gen-schemas.ts create mode 100644 scripts/rebuild-wallet-pnl.ts create mode 100644 scripts/rewrap-trace-keys.ts create mode 100644 scripts/unpin-legacy-traces.ts create mode 100644 src/controllers/admin.controller.ts create mode 100644 src/controllers/leaderboard.controller.ts create mode 100644 src/routes/admin.routes.ts create mode 100644 src/routes/internal.routes.ts create mode 100644 src/routes/leaderboard.routes.ts create mode 100644 src/routes/schemas.routes.ts create mode 100644 src/schemas/question-v1.schema.json create mode 100644 src/schemas/question.schema.json create mode 100644 src/schemas/question.ts create mode 100644 src/schemas/trace.schema.json create mode 100644 src/schemas/trace.ts create mode 100644 src/services/market-saga.service.ts create mode 100644 src/services/trace-crypto.service.ts create mode 100644 src/services/wallet-pnl.service.ts create mode 100644 tests/fixtures/contracts-trace.json create mode 100644 tests/market-saga.test.ts create mode 100644 tests/trace-crypto.test.ts create mode 100644 tests/trace-schema.test.ts create mode 100644 tests/wallet-pnl.test.ts diff --git a/.env.example b/.env.example index bed94e6..dd80100 100644 --- a/.env.example +++ b/.env.example @@ -76,3 +76,19 @@ IPFS_GATEWAY_URL=https://gateway.pinata.cloud/ipfs # ── Data sources ─────────────────────────────────────────────────────────── NEWSAPI_KEY= FRED_API_KEY= + +# ── Public API Base URL & Schema ─────────────────────────────────────────── +PUBLIC_API_BASE_URL=http://localhost:8000 +QUESTION_DOC_VERSION=2 + +# ── Confidential Traces ──────────────────────────────────────────────────── +TRACE_CONFIDENTIAL=true +# JSON mapping kid to 32-byte base64-encoded key +TRACE_KEKS= +TRACE_KEK_ACTIVE=kek-2026-10 +X402_KEY_RELEASE_SECRET= + +# ── Saga Execution ───────────────────────────────────────────────────────── +SAGA_RECOVERY_SCAN=50 +SAGA_MAX_ATTEMPTS=8 + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59ab0c1..37a7a2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,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 - run: npm run build diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index aeb5e85..f40be50 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 npm run build npm test diff --git a/docs/api.md b/docs/api.md index d915afb..26cf1f6 100644 --- a/docs/api.md +++ b/docs/api.md @@ -561,5 +561,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/architecture.md b/docs/architecture.md index 24b23b9..97830af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -63,3 +63,26 @@ Outcomes are decided on-chain by the resolver contract. The backend only reads ` ## Generated code `src/generated/` is copied from the pinned `contracts` submodule by `scripts/sync-contracts.mjs`, with a do-not-edit header and `// @ts-nocheck`. CI fails if it's stale. + +## Market Maker Saga + +`src/services/market-saga.service.ts` converts the market maker cycle into an idempotent, resumable saga. +- **State Machine**: Explicit checkpoint states (`STARTED` → `SIGNALS_READY` → `PROPOSED` → `MARKET_PERSISTED` → `QUESTION_PINNED` → `CHAIN_SUBMITTED` → `CHAIN_CONFIRMED` → `TRACE_PERSISTED` → `TRACE_PINNED` → `TRACE_PUBLISHED` → `COMPLETED`). +- **Idempotency & Recovery**: Before submitting market creation on-chain, the saga performs downward scans from `market_count()` matching `question_hash` and `creator === CONTRACTS.treasury` to prevent duplicate seed liquidity expenditure after transient failures or crashes. +- **Resumer**: Runs at process boot and on every ingestion cron tick, with exponential back-off and terminal `NEEDS_ATTENTION` after `SAGA_MAX_ATTEMPTS`. +- **Compensation**: Admin endpoint `POST /admin/generation-runs/:id/cancel` safely cancels pre-chain runs, marking associated pending markets `CANCELLED`. + +## Confidential Traces + +When `TRACE_CONFIDENTIAL=true`, reasoning traces are sealed in authenticated encrypted envelopes (`oracledesk.trace.enc` v1) using XChaCha20-Poly1305. +- **Threat Model**: The backend and KEK holder can decrypt traces; verifiability for the public is commit-then-reveal. +- **Verification**: The on-chain `trace_hash` in `reasoning_registry` commits to the exact bytes of the sealed envelope. Once entitled users obtain the plaintext and data key, they verify that `sha256(plaintext) === envelope.plaintextHash`. +- **Key Management**: Per-trace 32-byte data keys are wrapped with server-side KEKs (`kid` in envelope header). Keys are never committed to IPFS or logged. + +## Wallet PnL and Performance Ledger + +`src/services/wallet-pnl.service.ts` projects indexed market-core events (`MarketCreated`, `Trade`, `Redeemed`, `MarketResolved`) into an exact integer accounting ledger (`wallet_positions` and `wallet_stats_daily`): +- **Accounting**: Average-cost basis, realized PnL on partial sells, and liquidation or mid-market mark-to-market. +- **Settlement**: Yes/No/Void settlement (void settles combined Yes and No positions with integer division `(yes + no) / 2`). +- **Idempotency**: Ledgered through `wallet_events_applied`. Out-of-order events trigger dirty market marks and rebuilding via `npm run pnl:rebuild`. + diff --git a/docs/contract-requests.md b/docs/contract-requests.md index 9f78060..03eec9b 100644 --- a/docs/contract-requests.md +++ b/docs/contract-requests.md @@ -62,3 +62,22 @@ duplicate it, but today the adapter: If dry-run simulated (and returned the simulated result), the bindings were injectable, and `agentSell` existed, `src/services/chain.service.ts` here could become a thin wrapper around it. Its method names already match. + +## 6. Adopt versioned reasoning trace schemas + +The contracts repo (`agents/core/trace.ts`) currently defines a `ReasoningTrace` shape without versioning: +`{ agent: 'market-maker'|'trader', marketId: number|null, action, reasoning, inputs, timestamp: unix seconds }`. +The backend publishes versioned traces using `schemaVersion: 1` and `kind: 'oracledesk.trace'` (`docs/trace-format.md`), validating against a published JSON Schema 2020-12 at `https://api.oracledesk.io/api/v1/schemas/trace/v1.json`. +We request that the contracts repo: +- Adopt the `schemaVersion: 1` / `kind: 'oracledesk.trace'` envelope or publish its own formal JSON Schema. +- Use explicit `null` for optional fields to preserve canonical JSON byte stability. + +## 7. x402 integration for confidential traces + +When `TRACE_CONFIDENTIAL=true`, premium reasoning traces pinned to IPFS are sealed inside an authenticated encrypted envelope (`oracledesk.trace.enc` v1). +The contracts repo's x402 trace API (`x402/trace-api.ts`) should: +- Detect `kind === 'oracledesk.trace.enc'`. +- After payment verification, call the backend's internal key release endpoint: + `POST /internal/traces/:onChainTraceId/key` with shared HMAC authentication (`X402_KEY_RELEASE_SECRET`), timestamp, and nonce replay protection. +- Decrypt the envelope using the retrieved data key and verify `sha256(plaintext) === envelope.plaintextHash` before returning the content to the paying user. + 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/package.json b/package.json index ed7eb80..97114aa 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,10 @@ "prisma:generate": "prisma generate", "prisma:studio": "prisma studio", "sync:contracts": "node scripts/sync-contracts.mjs", - "check:contracts": "node scripts/sync-contracts.mjs --check" + "check:contracts": "node scripts/sync-contracts.mjs --check", + "gen:schemas": "node scripts/gen-schemas.mjs", + "check:schemas": "node scripts/gen-schemas.mjs --check", + "pnl:rebuild": "npx tsx scripts/rebuild-wallet-pnl.ts" }, "keywords": [], "author": "", 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 dc21b3c..4ba96e6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,6 +137,7 @@ model Market { updatedAt DateTime @updatedAt generationJob MarketGenerationJob? @relation("MarketGenerationJobMarket") + generationRun MarketGenerationRun? trades Trade[] positions Position[] @@ -242,15 +243,18 @@ model ReasoningTrace { /// reasoning-registry u64 trace id. onChainTraceId BigInt? @unique publishTxHash String? - verified Boolean @default(false) - isPublic Boolean @default(false) - previewSources Json? // First 2 sources (free preview) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + verified Boolean @default(false) + isPublic Boolean @default(false) + previewSources Json? // First 2 sources (free preview) + contentMode TraceContentMode @default(PLAINTEXT_LEGACY) + plaintextHash String? @db.VarChar(64) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt market Market @relation(fields: [marketId], references: [id], onDelete: Cascade) copyTrades CopyTrade[] subscriptions Subscription[] + traceKey TraceKey? @@index([marketId]) @@index([ipfsCid]) @@ -413,3 +417,112 @@ model SpendingAllowance { @@index([userId]) @@map("spending_allowances") } + +enum GenerationRunState { + STARTED + SIGNALS_READY + PROPOSED + MARKET_PERSISTED + QUESTION_PINNED + CHAIN_SUBMITTED + CHAIN_CONFIRMED + TRACE_PERSISTED + TRACE_PINNED + TRACE_PUBLISHED + COMPLETED + SKIPPED + NEEDS_ATTENTION + CANCELLED +} + +model MarketGenerationRun { + id String @id @default(uuid()) + trigger String // 'cron' | 'admin' | 'resume' + state GenerationRunState @default(STARTED) + attempts Int @default(0) + nextAttemptAt DateTime? + lockedBy String? + lockedUntil DateTime? + inputs Json? // signals snapshot ids, proposal + outputs Json @default("{}") // { questionCid, questionHash, resolutionHash, chainTxHash, onChainMarketId, traceId, traceCid, traceHash, traceTxHash, onChainTraceId } + marketId String? @unique + lastError Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + market Market? @relation(fields: [marketId], references: [id], onDelete: SetNull) + + @@index([state, nextAttemptAt]) + @@map("market_generation_runs") +} + +enum TraceContentMode { + PLAINTEXT_LEGACY + ENCRYPTED +} + +model TraceKey { + traceId String @id + kid String + wrappedKey Bytes + plaintextHash String @db.VarChar(64) + createdAt DateTime @default(now()) + + trace ReasoningTrace @relation(fields: [traceId], references: [id], onDelete: Cascade) + + @@map("trace_keys") +} + +model WalletPosition { + wallet String + onChainMarketId BigInt + outcome String // 'Yes' | 'No' + shares Decimal @default(0) @db.Decimal(39, 0) + costBasis Decimal @default(0) @db.Decimal(39, 0) + realizedPnl Decimal @default(0) @db.Decimal(39, 0) + boughtCollateral Decimal @default(0) @db.Decimal(39, 0) + soldCollateral Decimal @default(0) @db.Decimal(39, 0) + feesPaid Decimal @default(0) @db.Decimal(39, 0) + redeemed Decimal @default(0) @db.Decimal(39, 0) + tradeCount Int @default(0) + lastEventId String @default("") + settled Boolean @default(false) + incomplete Boolean @default(false) + updatedAt DateTime @updatedAt + + @@id([wallet, onChainMarketId, outcome]) + @@index([onChainMarketId]) + @@map("wallet_positions") +} + +model WalletStatsDaily { + wallet String + day DateTime @db.Date + category String + volume Decimal @default(0) @db.Decimal(39, 0) + realizedPnl Decimal @default(0) @db.Decimal(39, 0) + fees Decimal @default(0) @db.Decimal(39, 0) + trades Int @default(0) + wins Int @default(0) + losses Int @default(0) + + @@id([wallet, day, category]) + @@index([day]) + @@map("wallet_stats_daily") +} + +model WalletEventApplied { + eventId String @id + appliedAt DateTime @default(now()) + + @@map("wallet_events_applied") +} + +model WalletPnlDirtyMarket { + onChainMarketId BigInt @id + dirtiedAt DateTime @default(now()) + reason String? + + @@map("wallet_pnl_dirty_markets") +} + 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 b5fcc92..9b3d3df 100644 --- a/src/agents/market-maker.agent.ts +++ b/src/agents/market-maker.agent.ts @@ -1,12 +1,4 @@ -import { aggregateSignals } from '../services/ingestion.service'; -import { - generateMarketQuestion, - isDuplicateMarket, - createMarketFromProposal, -} from '../services/market.service'; -import { generateReasoningTrace } from '../services/reasoning.service'; -import { createMarketOnChain } from '../services/market-chain.service'; -import { pinAndPublishTrace } from '../services/trace-publish.service'; +import { startRun, advance } from '../services/market-saga.service'; import { prisma } from '../lib/prisma'; import { logger } from '../lib/logger'; @@ -20,105 +12,53 @@ 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(): Promise { - logger.info('Market Maker Agent: starting cycle'); +export async function runMarketMakerCycle( + trigger: 'cron' | 'admin' = 'cron', +): Promise { + logger.info({ trigger }, 'Market Maker Agent: starting cycle via saga'); try { - // Step 1: Aggregate signals from all sources - const signals = await aggregateSignals(); - if (signals.signalCount === 0) { - logger.warn('Market Maker Agent: no signals available — skipping cycle'); - return null; - } - - // Step 2: Generate market proposal via the LLM - let proposal; - try { - proposal = await generateMarketQuestion(signals); - } catch (err) { - logger.error({ err }, 'Market Maker Agent: market generation failed'); - return null; - } + const runId = await startRun(trigger); + const run = await advance(runId, { maxSteps: 50 }); - // Step 3: Deduplicate - const isDuplicate = await isDuplicateMarket(proposal.market_question); - if (isDuplicate) { - logger.info({ question: proposal.market_question }, - 'Market Maker Agent: duplicate market — skipping'); + if (run.state === 'SKIPPED') { return null; } - // Step 4: Persist market to DB (PENDING until it exists on-chain) - const market = await createMarketFromProposal(proposal); - logger.info({ marketId: market.id, category: market.category }, 'Market Maker Agent: market created in DB'); - - // Step 5: Commit the resolution spec and create the market through the - // treasury. dry-run simulates; the market then stays PENDING. - let onChainMarketId: bigint | null = null; - try { - const result = await createMarketOnChain(market); - if (result.mode === 'live') onChainMarketId = result.value; - } catch (err) { - logger.error({ err, marketId: market.id }, 'Market Maker Agent: on-chain creation failed; market stays PENDING'); - } - - // Step 6: Reasoning trace for the creation decision - const { trace, tracePayload } = await generateReasoningTrace({ - marketId: market.id, - agentType: 'MARKET_MAKER', - decisionType: 'MARKET_CREATION', - sourcesUsed: signals.news.slice(0, 3).map(s => ({ - source: s.source, - weight: 0.7, - signal: s.title, - rawValue: proposal.initial_yes_probability, - })), - probabilityEstimate: proposal.initial_yes_probability, - marketProbability: 0.5, - edge: proposal.initial_yes_probability - 0.5, - confidenceInterval: proposal.confidence_interval, - }); - - // Step 7: Pin the trace and publish its hash to reasoning-registry - try { - await pinAndPublishTrace({ - traceId: trace.id, - payload: { ...tracePayload, onChainMarketId: onChainMarketId?.toString() ?? null }, - action: 'create_market', - onChainMarketId, + if (run.marketId) { + const market = await prisma.market.findUnique({ + where: { id: run.marketId }, }); - } catch (err) { - logger.error({ err, traceId: trace.id }, 'Market Maker Agent: trace pin/publish failed'); + if (market) { + const outputs = (run.outputs as any) || {}; + return { + marketId: market.id, + question: market.question, + category: String(market.category), + onChainMarketId: + outputs.onChainMarketId ?? + (market.onChainMarketId ? market.onChainMarketId.toString() : null), + }; + } } - logger.info({ marketId: market.id, traceId: trace.id }, 'Market Maker Agent: cycle complete'); - - return { - marketId: market.id, - question: market.question, - category: String(market.category), - onChainMarketId: onChainMarketId?.toString() ?? null, - }; + return null; } catch (err) { logger.error({ err }, 'Market Maker Agent: unhandled cycle error'); - await prisma.agentLog.create({ - data: { - agentType: 'MARKET_MAKER', - level: 'ERROR', - action: 'CYCLE_FAILED', - error: String(err), - }, - }).catch(() => {}); + await prisma.agentLog + .create({ + data: { + agentType: 'MARKET_MAKER', + level: 'ERROR', + action: 'CYCLE_FAILED', + error: String(err), + }, + }) + .catch(() => {}); - throw err; // Re-throw so the job tracker records FAILED status + throw err; } } diff --git a/src/app.ts b/src/app.ts index cca72c1..36aae0b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -56,6 +56,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 8ef478a..77c616f 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -91,6 +91,20 @@ const envSchema = z PINATA_SECRET_API_KEY: optionalString, IPFS_GATEWAY_URL: z.string().url().default('https://gateway.pinata.cloud/ipfs'), + // ── 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, @@ -121,6 +135,21 @@ const envSchema = z }); } } + if (env.NODE_ENV === 'production' && env.TRACE_CONFIDENTIAL) { + if (!env.TRACE_KEKS) { + ctx.addIssue({ code: 'custom', path: ['TRACE_KEKS'], message: 'is required in production when TRACE_CONFIDENTIAL=true' }); + } + } + if (env.TRACE_KEKS) { + try { + const parsed = JSON.parse(env.TRACE_KEKS); + if (typeof parsed !== 'object' || parsed === null) { + ctx.addIssue({ code: 'custom', path: ['TRACE_KEKS'], message: 'must be a JSON object mapping kid to base64 key' }); + } + } catch { + ctx.addIssue({ code: 'custom', path: ['TRACE_KEKS'], message: 'must be valid JSON' }); + } + } }); export type Config = z.infer & { @@ -147,6 +176,11 @@ export function loadConfig(env: NodeJS.ProcessEnv): Config { data.AUTH_SIGNING_SECRET = Keypair.random().secret(); ephemeralSecrets.push('AUTH_SIGNING_SECRET'); } + if (!data.TRACE_KEKS) { + const rawKey = crypto.randomBytes(32).toString('base64'); + data.TRACE_KEKS = JSON.stringify({ [data.TRACE_KEK_ACTIVE]: rawKey }); + ephemeralSecrets.push('TRACE_KEKS'); + } if (!data.AUTH_WEB_AUTH_DOMAIN) data.AUTH_WEB_AUTH_DOMAIN = data.AUTH_HOME_DOMAIN; return { ...data, ephemeralSecrets }; } diff --git a/src/controllers/admin.controller.ts b/src/controllers/admin.controller.ts new file mode 100644 index 0000000..3f61afd --- /dev/null +++ b/src/controllers/admin.controller.ts @@ -0,0 +1,74 @@ +import type { Request, Response } from 'express'; +import { prisma } from '../lib/prisma'; +import { sendSuccess, sendError, parsePagination, buildPaginationMeta } from '../utils/response.util'; +import { cancelRun } from '../services/market-saga.service'; + +/** + * GET /admin/generation-runs + * Lists market maker generation runs. + */ +export async function listGenerationRuns(req: Request, res: Response): Promise { + const { page, limit, skip } = parsePagination(req.query as any); + const state = req.query.state as string | undefined; + + const where = state ? { state: state as any } : {}; + + const [runs, total] = await Promise.all([ + prisma.marketGenerationRun.findMany({ + where, + skip, + take: limit, + orderBy: { createdAt: 'desc' }, + include: { + market: { + select: { + id: true, + question: true, + status: true, + onChainMarketId: true, + }, + }, + }, + }), + prisma.marketGenerationRun.count({ where }), + ]); + + sendSuccess(res, runs, 200, buildPaginationMeta(page, limit, total) as any); +} + +/** + * GET /admin/generation-runs/:id + * Fetches a single generation run by id. + */ +export async function getGenerationRun(req: Request, res: Response): Promise { + const id = String(req.params.id); + const run = await prisma.marketGenerationRun.findUnique({ + where: { id }, + include: { + market: true, + }, + }); + + if (!run) { + sendError(res, 404, 'RUN_NOT_FOUND', `Generation run ${id} not found`); + return; + } + + sendSuccess(res, run); +} + +/** + * POST /admin/generation-runs/:id/cancel + * Cancels an incomplete pre-chain generation run. + */ +export async function cancelGenerationRun(req: Request, res: Response): Promise { + const id = String(req.params.id); + const reason = (req.body?.reason as string) || 'Admin cancelled via API'; + + try { + const cancelled = await cancelRun(id, reason); + sendSuccess(res, cancelled); + } catch (err: any) { + sendError(res, err.statusCode || 400, err.code || 'CANCEL_FAILED', err.message); + } +} 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 4b929e5..77d4344 100644 --- a/src/controllers/trace.controller.ts +++ b/src/controllers/trace.controller.ts @@ -1,9 +1,13 @@ 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 { verifyTraceOnChain } from '../services/trace-publish.service'; +import { unwrapKey } from '../services/trace-crypto.service'; import { + checkAccess, getTracWithAccessControl, getSpendingAllowance, listPaymentEvents, @@ -186,4 +190,126 @@ export async function getMyPaymentEvents(req: Request, res: Response): Promise(); + +/** + * GET /traces/:id/key + * Returns decryption key for entitled users (daily pass / subscription). + */ +export async function getTraceKey(req: Request, res: Response): Promise { + if (!req.user) { + sendError(res, 401, 'UNAUTHORIZED', 'Authentication required'); + return; + } + + const traceId = String(req.params.id); + const access = await checkAccess(req.user.userId, traceId); + if (!access.hasAccess) { + sendError(res, 403, 'FORBIDDEN', 'Active pass or subscription required to access trace key'); + return; + } + + const traceKey = await prisma.traceKey.findUnique({ + where: { traceId }, + }); + + if (!traceKey) { + sendError(res, 404, 'TRACE_KEY_NOT_FOUND', 'No encryption key found for this trace'); + return; + } + + const dataKey = unwrapKey(traceKey.wrappedKey, traceKey.kid); + + await prisma.agentLog.create({ + data: { + agentType: 'SUBSCRIPTION', + action: 'TRACE_KEY_RELEASED', + details: { traceId, userId: req.user.userId, kid: traceKey.kid }, + }, + }); + + sendSuccess(res, { + kid: traceKey.kid, + dataKey: dataKey.toString('base64'), + plaintextHash: traceKey.plaintextHash, + }); +} + +/** + * POST /internal/traces/:onChainTraceId/key + * Secure x402 trace key release with shared HMAC and replay protection. + */ +export async function releaseTraceKeyInternal(req: Request, res: Response): Promise { + const secret = config.X402_KEY_RELEASE_SECRET; + if (!secret) { + sendError(res, 503, 'FEATURE_DISABLED', 'Internal key release is not configured'); + return; + } + + const signature = req.headers['x-signature'] as string; + const timestamp = req.headers['x-timestamp'] as string; + const nonce = req.headers['x-nonce'] as string; + + if (!signature || !timestamp || !nonce) { + sendError(res, 401, 'UNAUTHORIZED', 'Missing authentication headers (x-signature, x-timestamp, x-nonce required)'); + return; + } + + const now = Math.floor(Date.now() / 1000); + const ts = parseInt(timestamp, 10); + if (isNaN(ts) || Math.abs(now - ts) > 300) { + sendError(res, 401, 'EXPIRED_REQUEST', 'Request timestamp is outside the allowed window'); + return; + } + + if (usedNonces.has(nonce)) { + sendError(res, 401, 'REPLAYED_NONCE', 'Nonce has already been used'); + return; + } + usedNonces.add(nonce); + if (usedNonces.size > 10000) { + usedNonces.clear(); + } + + const onChainTraceId = String(req.params.onChainTraceId); + const expectedSig = crypto + .createHmac('sha256', secret) + .update(`${timestamp}:${nonce}:${onChainTraceId}`) + .digest('hex'); + + if (signature.toLowerCase() !== expectedSig.toLowerCase()) { + sendError(res, 401, 'INVALID_SIGNATURE', 'HMAC signature verification failed'); + return; + } + + let onChainBigInt: bigint; + try { + onChainBigInt = BigInt(onChainTraceId); + } catch { + sendError(res, 400, 'INVALID_TRACE_ID', 'Invalid onChainTraceId format'); + return; + } + + const trace = await prisma.reasoningTrace.findFirst({ + where: { onChainTraceId: onChainBigInt }, + include: { traceKeys: true }, + }); + + if (!trace || !trace.traceKeys) { + sendError(res, 404, 'TRACE_NOT_FOUND', 'Trace or encryption key not found'); + return; + } + + const traceKey = trace.traceKeys; + const dataKey = unwrapKey(traceKey.wrappedKey, traceKey.kid); + + sendSuccess(res, { + onChainTraceId, + traceId: trace.id, + kid: traceKey.kid, + dataKey: dataKey.toString('base64'), + plaintextHash: traceKey.plaintextHash, + }); } \ No newline at end of file diff --git a/src/cron/ingestion.cron.ts b/src/cron/ingestion.cron.ts index 6f8ccc2..e4e993f 100644 --- a/src/cron/ingestion.cron.ts +++ b/src/cron/ingestion.cron.ts @@ -1,5 +1,6 @@ import cron from 'node-cron'; import { runMarketMakerCycle } from '../agents/market-maker.agent'; +import { resumeIncomplete } from '../services/market-saga.service'; import { logger } from '../lib/logger'; let isRunning = false; @@ -23,6 +24,9 @@ export function startIngestionCron(): void { isRunning = true; try { + await resumeIncomplete().catch((err) => + logger.error({ err }, 'Ingestion cron: error resuming incomplete sagas'), + ); await runMarketMakerCycle(); } catch (err) { logger.error({ err }, 'Ingestion cron: cycle failed'); diff --git a/src/lib/logger.ts b/src/lib/logger.ts index 77ad02a..9e1e26a 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -26,6 +26,10 @@ export const logger = pino({ '*.PINATA_SECRET_API_KEY', '*.ANTHROPIC_API_KEY', '*.GEMINI_API_KEY', + '*.TRACE_KEKS', + '*.X402_KEY_RELEASE_SECRET', + 'wrappedKey', + 'dataKey', 'secret', 'token', ], diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts new file mode 100644 index 0000000..a23269f --- /dev/null +++ b/src/routes/admin.routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { + listGenerationRuns, + getGenerationRun, + cancelGenerationRun, +} from '../controllers/admin.controller'; +import { requireAuth, requireAdmin } from '../middlewares/auth.middleware'; + +const router = Router(); + +router.use(requireAuth, requireAdmin); + +router.get('/generation-runs', listGenerationRuns); +router.get('/generation-runs/:id', getGenerationRun); +router.post('/generation-runs/:id/cancel', cancelGenerationRun); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 8e77b60..1391e55 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -5,6 +5,10 @@ import traceRoutes from './trace.routes'; import portfolioRoutes from './portfolio.routes'; import tradeRoutes from './trade.routes'; import oracleRoutes from './oracle.routes'; +import schemasRoutes from './schemas.routes'; +import internalRoutes from './internal.routes'; +import adminRoutes from './admin.routes'; +import leaderboardRoutes from './leaderboard.routes'; const router = Router(); @@ -14,5 +18,9 @@ router.use('/traces', traceRoutes); router.use('/portfolio', portfolioRoutes); router.use('/trade', tradeRoutes); router.use('/oracle', oracleRoutes); +router.use('/schemas', schemasRoutes); +router.use('/internal', internalRoutes); +router.use('/admin', adminRoutes); +router.use('/', leaderboardRoutes); export default router; 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 2ac6efc..caee42e 100644 --- a/src/routes/trace.routes.ts +++ b/src/routes/trace.routes.ts @@ -3,6 +3,7 @@ import { getMyPaymentEvents, getMySpendingAllowance, getTrace, + getTraceKey, listTraces, setSpendingAllowance, unlockTrace, @@ -17,6 +18,7 @@ router.get('/', listTraces); router.get('/access/allowance', requireAuth, getMySpendingAllowance); router.put('/access/allowance', requireAuth, setSpendingAllowance); router.get('/payments', requireAuth, getMyPaymentEvents); +router.get('/:id/key', requireAuth, getTraceKey); router.get('/:id', optionalAuth, getTrace); // Preview without a token, full trace with a daily pass // Protected 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/server.ts b/src/server.ts index 2a73c5d..831ec6d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,6 +7,7 @@ import { prisma } from './lib/prisma'; import { startIngestionCron } from './cron/ingestion.cron'; import { startMonitorCron } from './cron/monitor.cron'; import { startEventListener } from './services/indexer.service'; +import { resumeIncomplete } from './services/market-saga.service'; import { closeRedis } from './lib/redis'; let stopEventListener: (() => void) | null = null; @@ -41,7 +42,12 @@ async function bootstrap(): Promise { // 3. Index Stellar events (resumes from the stored ledger cursor) stopEventListener = startEventListener((event, payload) => io.emit(event, payload)); - // 4. Start cron jobs + // 4. Resume any incomplete sagas from previous runs + resumeIncomplete().catch((err) => + logger.warn({ err }, 'Error during boot saga recovery'), + ); + + // 5. Start cron jobs startIngestionCron(); startMonitorCron(); diff --git a/src/services/indexer.service.ts b/src/services/indexer.service.ts index 674a393..140ff27 100644 --- a/src/services/indexer.service.ts +++ b/src/services/indexer.service.ts @@ -21,6 +21,7 @@ import { logger } from '../lib/logger'; import { readClients, rpcServer } from './stellar/clients'; import { printable } from './chain.service'; import { priceYesBps } from '../generated/fpmm'; +import { applyWalletEvent } from './wallet-pnl.service'; export type EventSource = Pick; @@ -103,6 +104,17 @@ const outcomeTag = (o: unknown): 'Yes' | 'No' | null => async function applySideEffects(event: DecodedEvent, emit: IndexerDeps['emit']): Promise { const p = event.params; + if ( + event.contractName === 'marketCore' && + ['MarketCreated', 'Trade', 'Redeemed', 'MarketResolved'].includes(event.eventName) + ) { + try { + await applyWalletEvent(event); + } catch (err: any) { + logger.error({ err, eventId: event.id }, 'Error applying wallet event'); + } + } + if (event.contractName === 'marketCore' && event.eventName === 'Trade') { const onChainMarketId = BigInt(p.market_id); const yesBps = Number(priceYesBps(BigInt(p.reserve_yes), BigInt(p.reserve_no))); diff --git a/src/services/ipfs.service.ts b/src/services/ipfs.service.ts index cef5860..8cc35be 100644 --- a/src/services/ipfs.service.ts +++ b/src/services/ipfs.service.ts @@ -94,6 +94,21 @@ export async function pinJson(document: unknown, name: string): Promise( + schema: { parse: (val: unknown) => T }, + document: T, + name: string, +): Promise { + let validated: T; + try { + validated = schema.parse(document); + } catch (err: any) { + logger.error({ err: err?.issues ?? err, name }, 'Document failed schema validation before pinning'); + throw new AppError(500, 'DOCUMENT_SCHEMA_INVALID', 'Document failed schema validation before pinning'); + } + return pinJson(validated, name); +} + export function fetchIpfsBytes(cid: string): Promise { return transport.fetch(cid); } diff --git a/src/services/market-chain.service.ts b/src/services/market-chain.service.ts index 6587775..617747b 100644 --- a/src/services/market-chain.service.ts +++ b/src/services/market-chain.service.ts @@ -15,15 +15,21 @@ import { USDC_UNIT, usdcFromNumber } from '../lib/amounts'; import { resolutionHash, type ResolutionSpecJson } from '../lib/resolution-spec'; import { agentCreateMarket, type WriteResult } from './chain.service'; import { agentSigner } from './stellar/clients'; -import { pinJson } from './ipfs.service'; +import { pinDocument } from './ipfs.service'; +import { + questionDocument as buildQuestionDoc, + QuestionDocV1, + QuestionDocV2, +} from '../schemas/question'; /** market-core MIN_SEED is 100 USDC. */ export const MIN_SEED_RAW = 100n * USDC_UNIT; 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, @@ -33,30 +39,67 @@ export function defaultResolutionSpec(): ResolutionSpecJson { } /** The question document pinned to IPFS; its sha256 is the on-chain question_hash. */ -export function questionDocument(market: Market, resolutionHashHex: string) { - return { - version: 1, - question: market.question, - category: market.category, - contractCategory: toContractCategory(market.category), - closeTime: Math.floor(market.expiryTimestamp.getTime() / 1000), - resolutionSource: market.resolutionOracle ?? null, - resolutionHash: resolutionHashHex, - backendMarketId: market.id, - }; +export function questionDocument( + market: Market, + resolutionHashHex: string, + version: 1 | 2 = (config.QUESTION_DOC_VERSION as 1 | 2) || 2, +) { + return buildQuestionDoc( + { + id: market.id, + question: market.question, + category: market.category, + expiryTimestamp: market.expiryTimestamp, + }, + { + specHash: resolutionHashHex, + source: market.resolutionOracle, + }, + toContractCategory(market.category), + version, + ); } export function initialYesBps(probability: number): number { return Math.min(9900, Math.max(100, Math.round(probability * 10_000))); } -export async function createMarketOnChain( +export interface PreparedMarketCommitment { + spec: ResolutionSpecJson; + resolutionHashBytes: Buffer; + resolutionHashHex: string; + questionCid: string; + questionHash: string; + metaUri: string; + seedAmountRaw: bigint; +} + +/** + * Prepares resolution spec, pins the question document to IPFS, and updates the database row. + * Idempotent: re-calling on a market with questionHash already set will not re-pin. + */ +export async function prepareMarketCommitment( market: Market, spec: ResolutionSpecJson = defaultResolutionSpec(), -): Promise> { +): Promise { const resolutionHashBytes = resolutionHash(spec); const resolutionHashHex = resolutionHashBytes.toString('hex'); - const pinned = await pinJson(questionDocument(market, resolutionHashHex), `oracledesk-market-${market.id}`); + + let questionHash = market.questionHash; + let metaUri = market.metaUri; + let questionCid = metaUri ? metaUri.replace(/^ipfs:\/\//, '') : ''; + + if (!questionHash || !metaUri) { + const doc = questionDocument(market, resolutionHashHex); + const pinned = + config.QUESTION_DOC_VERSION === 1 + ? await pinDocument(QuestionDocV1, doc as any, `oracledesk-market-${market.id}`) + : await pinDocument(QuestionDocV2, doc as any, `oracledesk-market-${market.id}`); + + questionHash = pinned.hash; + questionCid = pinned.cid; + metaUri = `ipfs://${pinned.cid}`; + } const requested = usdcFromNumber(market.minimumLiquidity); const seed = requested < MIN_SEED_RAW ? MIN_SEED_RAW : requested; @@ -66,19 +109,39 @@ export async function createMarketOnChain( data: { resolutionSpec: spec as object, resolutionHash: resolutionHashHex, - questionHash: pinned.hash, - metaUri: `ipfs://${pinned.cid}`, + questionHash, + metaUri, seedAmountRaw: seed.toString(), }, }); + return { + spec, + resolutionHashBytes, + resolutionHashHex, + questionCid, + questionHash, + metaUri, + seedAmountRaw: seed, + }; +} + +/** + * Submits the market creation transaction to the chain (or simulates in dry-run). + */ +export async function submitMarketCreation( + market: Market, + prepared?: PreparedMarketCommitment, +): Promise> { + const prep = prepared ?? (await prepareMarketCommitment(market)); + const result = await agentCreateMarket({ - question_hash: Buffer.from(pinned.hash, 'hex'), - resolution_hash: resolutionHashBytes, - meta_uri: `ipfs://${pinned.cid}`, + question_hash: Buffer.from(prep.questionHash, 'hex'), + resolution_hash: prep.resolutionHashBytes, + meta_uri: prep.metaUri, category: toContractCategoryValue(market.category), close_time: BigInt(Math.floor(market.expiryTimestamp.getTime() / 1000)), - seed_amount: seed, + seed_amount: prep.seedAmountRaw, initial_yes_bps: initialYesBps(market.initialYesProb), }); @@ -96,3 +159,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 d4338b6..83e0c72 100644 --- a/src/services/market.service.ts +++ b/src/services/market.service.ts @@ -269,13 +269,14 @@ export function generateExpiryDate(category: string): Date { */ export async function createMarketFromProposal( proposal: MarketProposal, + client: any = prisma, ): Promise { const upperCategory = String(proposal.category).toUpperCase(); const finalCategory = Object.values(MarketCategory).includes(upperCategory as MarketCategory) ? (upperCategory as MarketCategory) : MarketCategory.MACRO; - 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 7f08d88..5f73754 100644 --- a/src/services/reasoning.service.ts +++ b/src/services/reasoning.service.ts @@ -27,33 +27,56 @@ const traceReasoningSchema = z.object({ * 3. Call Claude/Gemini to generate hedge conditions and reasoning narrative * 4. Persist to DB. The hash is computed later, over the exact bytes pinned * to IPFS (services/trace-publish.service.ts), never over this object. - */ -export async function generateReasoningTrace(input: TraceInput) { +import crypto from 'node:crypto'; +import { buildTraceDoc, TraceDocV1 } from '../schemas/trace'; + +export async function generateReasoningTrace( + input: TraceInput & { + traceId?: string; + onChainMarketId?: string | bigint | number | null; + }, +): Promise<{ trace: any; tracePayload: TraceDocV1 }> { const edge = calculateEdge(input.probabilityEstimate, input.marketProbability); const ci = calculateConfidenceInterval(input.sourcesUsed, input.probabilityEstimate); const hedgeConditions = await generateHedgeConditions(input, edge, ci); + const traceId = input.traceId || crypto.randomUUID(); + + let onChainMarketId = input.onChainMarketId !== undefined ? input.onChainMarketId : null; + if (onChainMarketId === null && input.marketId) { + const market = await prisma.market.findUnique({ + where: { id: input.marketId }, + select: { onChainMarketId: true }, + }); + if (market?.onChainMarketId !== undefined && market.onChainMarketId !== null) { + onChainMarketId = market.onChainMarketId; + } + } - 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, - betSizeUsdc: input.betSizeUsdc, - hedgeConditions: hedgeConditions.conditions, - agentWallet: input.agentWallet, - timestamp: new Date().toISOString(), - }; + confidenceInterval: ci, + sourcesUsed: input.sourcesUsed, + betFraction: input.betFraction ?? null, + betSizeUsdc: input.betSizeUsdc ?? null, + hedgeConditions: hedgeConditions.conditions, + agentWallet: input.agentWallet ?? null, + }); const previewSources = input.sourcesUsed.slice(0, 2); - 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, @@ -62,18 +85,19 @@ export async function generateReasoningTrace(input: TraceInput) { 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, - agentWallet: input.agentWallet, + agentWallet: input.agentWallet ?? null, previewSources: previewSources as any, 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 e9a2d14..d0d343d 100644 --- a/src/services/subscription.service.ts +++ b/src/services/subscription.service.ts @@ -171,7 +171,16 @@ export async function getTracWithAccessControl( } if (access.hasAccess) { - return { ...trace, accessLevel: access.accessType }; + return { + ...trace, + accessLevel: access.accessType, + contentMode: trace.contentMode, + plaintextHash: trace.plaintextHash, + verification: { + envelopeHash: trace.traceHash, + plaintextHash: trace.plaintextHash, + }, + }; } // Return free preview — strip full sources @@ -189,6 +198,8 @@ export async function getTracWithAccessControl( verified: trace.verified, 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 c91abbe..0b98c41 100644 --- a/src/services/trace-publish.service.ts +++ b/src/services/trace-publish.service.ts @@ -1,66 +1,279 @@ /** * Reasoning trace lifecycle after generation: - * pin the canonical JSON → traceHash = sha256(pinned bytes) → - * reasoning_registry.publish_trace (dry-run simulates) → verify. + * seal (if confidential) → pin to IPFS → traceHash = sha256(pinned bytes) → + * reasoning_registry.publish_trace → verify. */ 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 { fetchIpfsBytes, pinJson } from './ipfs.service'; +import { verifyTraceHash, sha256Hex, canonicalBytesStrict } from '../utils/hash.util'; +import { fetchIpfsBytes, pinJson, pinDocument } from './ipfs.service'; import { getOnChainTrace, publishTrace } from './chain.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'; -export async function pinAndPublishTrace(params: { +export interface PinTraceResult { + cid: string; + traceHash: string; + contentMode: 'PLAINTEXT_LEGACY' | 'ENCRYPTED'; + plaintextHash: string; +} + +export interface PublishPinnedResult { + onChainTraceId: bigint | null; + publishTxHash: string | null; +} + +/** + * Pins a reasoning trace document to IPFS, encrypting the envelope if TRACE_CONFIDENTIAL=true. + */ +export async function pinTrace(params: { traceId: string; - payload: Record; + payload: Record | TraceDocV1; action: string; onChainMarketId: bigint | null; -}): Promise<{ cid: string; traceHash: string; onChainTraceId: bigint | null; publishTxHash: string | null }> { - const pinned = await pinJson(params.payload, `oracledesk-trace-${params.traceId}`); +}): Promise { + const isConfidential = config.TRACE_CONFIDENTIAL; + + if (isConfidential) { + // 1. Strict canonical bytes for the plaintext + const plaintextBytes = canonicalBytesStrict(params.payload); + const plaintextHash = sha256Hex(plaintextBytes); + + // 2. Build public preview part + const payload = params.payload as any; + const publicPart: TracePublicPart = { + traceId: params.traceId, + agentType: payload.agentType || 'MARKET_MAKER', + decisionType: payload.decisionType || params.action.toUpperCase(), + market: { + backendId: payload.market?.backendId || payload.marketId || '', + onChainId: + params.onChainMarketId !== null + ? params.onChainMarketId.toString() + : payload.market?.onChainId ?? null, + }, + probabilityEstimate: Number(payload.probabilityEstimate ?? 0.5), + marketProbability: Number(payload.marketProbability ?? 0.5), + edge: Number(payload.edge ?? 0), + previewSources: Array.isArray(payload.sourcesUsed) + ? payload.sourcesUsed.slice(0, 2).map((s: any) => ({ + source: String(s.source ?? ''), + weight: Number(s.weight ?? 0), + signal: String(s.signal ?? ''), + rawValue: + s.rawValue !== undefined && s.rawValue !== null + ? Number(s.rawValue) + : null, + })) + : [], + createdAt: payload.createdAt || new Date().toISOString(), + }; + + // 3. Load active KEK + const keks = loadKeks(config); + let activeKek = keks.get(config.TRACE_KEK_ACTIVE); + if (!activeKek) { + const ephemeralKey = crypto.randomBytes(32); + activeKek = createKek(config.TRACE_KEK_ACTIVE, ephemeralKey); + keks.set(config.TRACE_KEK_ACTIVE, activeKek); + } + + // 4. Seal trace + const sealed = sealTrace(plaintextBytes, publicPart, activeKek); + + // 5. Self-test decryption before pinning + const decrypted = openTrace( + sealed.envelope, + activeKek.unwrap(sealed.wrappedKey), + ); + if (sha256Hex(decrypted) !== sealed.plaintextHash) { + throw new AppError( + 500, + 'ENVELOPE_SELF_TEST_FAILED', + 'Sealed envelope failed decryption self-test', + ); + } + + // 6. Pin envelope to IPFS + const pinned = await pinJson( + sealed.envelope, + `oracledesk-trace-${params.traceId}`, + ); + + // 7. Atomic DB update + await prisma.$transaction(async (tx) => { + await tx.reasoningTrace.update({ + where: { id: params.traceId }, + data: { + ipfsCid: pinned.cid, + traceHash: pinned.hash, + contentMode: 'ENCRYPTED', + plaintextHash: sealed.plaintextHash, + }, + }); + + await tx.traceKey.upsert({ + where: { traceId: params.traceId }, + create: { + traceId: params.traceId, + kid: sealed.kid, + wrappedKey: sealed.wrappedKey, + plaintextHash: sealed.plaintextHash, + }, + update: { + kid: sealed.kid, + wrappedKey: sealed.wrappedKey, + plaintextHash: sealed.plaintextHash, + }, + }); + }); + + return { + cid: pinned.cid, + traceHash: pinned.hash, + contentMode: 'ENCRYPTED', + plaintextHash: sealed.plaintextHash, + }; + } + + // Plaintext path + const pinned = await pinJson( + params.payload, + `oracledesk-trace-${params.traceId}`, + ); await prisma.reasoningTrace.update({ where: { id: params.traceId }, - data: { ipfsCid: pinned.cid, traceHash: pinned.hash }, + data: { + ipfsCid: pinned.cid, + traceHash: pinned.hash, + contentMode: 'PLAINTEXT_LEGACY', + plaintextHash: pinned.hash, + }, }); - if (params.onChainMarketId === null) { - logger.info({ traceId: params.traceId }, 'Trace pinned; market not on-chain yet, so not published'); - return { cid: pinned.cid, traceHash: pinned.hash, onChainTraceId: null, publishTxHash: null }; - } + return { + cid: pinned.cid, + traceHash: pinned.hash, + contentMode: 'PLAINTEXT_LEGACY', + plaintextHash: pinned.hash, + }; +} +/** + * Publishes a pinned trace to the on-chain reasoning registry. + */ +export async function publishPinnedTrace(params: { + traceId: string; + onChainMarketId: bigint; + action: string; + ipfsCid: string; + traceHash: string; +}): Promise { const result = await publishTrace({ agent: agentSigner().publicKey as string, market_id: params.onChainMarketId, action: params.action, - trace_hash: Buffer.from(pinned.hash, 'hex'), - ipfs_cid: pinned.cid, + trace_hash: Buffer.from(params.traceHash, 'hex'), + ipfs_cid: params.ipfsCid, }); if (result.mode === 'dry-run') { - return { cid: pinned.cid, traceHash: pinned.hash, onChainTraceId: null, publishTxHash: null }; + return { onChainTraceId: null, publishTxHash: null }; } await prisma.reasoningTrace.update({ where: { id: params.traceId }, - data: { onChainTraceId: result.value, publishTxHash: result.txHash, verified: true }, + data: { + onChainTraceId: result.value, + publishTxHash: result.txHash, + verified: true, + }, }); - return { cid: pinned.cid, traceHash: pinned.hash, onChainTraceId: result.value, publishTxHash: result.txHash }; + + return { onChainTraceId: result.value, publishTxHash: result.txHash }; +} + +/** + * Pins and publishes a trace in one call. + */ +export async function pinAndPublishTrace(params: { + traceId: string; + payload: Record | TraceDocV1; + action: string; + onChainMarketId: bigint | null; +}): Promise<{ + cid: string; + traceHash: string; + onChainTraceId: bigint | null; + publishTxHash: string | null; +}> { + const pinned = await pinTrace(params); + + if (params.onChainMarketId === null) { + logger.info( + { traceId: params.traceId }, + 'Trace pinned; market not on-chain yet, so not published', + ); + return { + cid: pinned.cid, + traceHash: pinned.traceHash, + onChainTraceId: null, + publishTxHash: null, + }; + } + + const published = await publishPinnedTrace({ + traceId: params.traceId, + onChainMarketId: params.onChainMarketId, + action: params.action, + ipfsCid: pinned.cid, + traceHash: pinned.traceHash, + }); + + return { + cid: pinned.cid, + traceHash: pinned.traceHash, + onChainTraceId: published.onChainTraceId, + publishTxHash: published.publishTxHash, + }; } /** * 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); @@ -68,7 +281,32 @@ 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 0b46296..5e378eb 100644 --- a/src/utils/hash.util.ts +++ b/src/utils/hash.util.ts @@ -24,11 +24,63 @@ function sortKeysDeep(value: unknown): unknown { return value; } +/** + * Strict canonical JSON: throws on undefined, functions, symbols, non-finite numbers and Date. + * Enforces explicit nulls and deterministic serialization. + */ +export function canonicalJsonStrict(value: unknown): string { + function validateAndSort(val: unknown, path = '$'): unknown { + if (val === undefined) { + throw new TypeError(`canonicalJsonStrict: undefined encountered at ${path}`); + } + if (typeof val === 'function' || typeof val === 'symbol') { + throw new TypeError(`canonicalJsonStrict: disallowed ${typeof val} encountered at ${path}`); + } + if (typeof val === 'number' && !Number.isFinite(val)) { + throw new TypeError(`canonicalJsonStrict: non-finite number (${val}) encountered at ${path}`); + } + if (val instanceof Date) { + throw new TypeError(`canonicalJsonStrict: Date instance encountered at ${path}; convert to ISO string explicitly`); + } + if (typeof val === 'bigint') { + return val.toString(); + } + if (Array.isArray(val)) { + return val.map((item, idx) => validateAndSort(item, `${path}[${idx}]`)); + } + if (val !== null && typeof val === 'object') { + const sorted: Record = {}; + const keys = Object.keys(val as Record).sort(); + for (const key of keys) { + sorted[key] = validateAndSort((val as Record)[key], `${path}.${key}`); + } + return sorted; + } + return val; + } + + const sorted = validateAndSort(value); + const serialized = JSON.stringify(sorted); + + // Round-trip verification: parsing and re-serializing must produce identical output + const reparsed = JSON.parse(serialized); + const reSerialized = JSON.stringify(validateAndSort(reparsed)); + if (serialized !== reSerialized) { + throw new Error('canonicalJsonStrict: round-trip serialization mismatch'); + } + + return serialized; +} + /** Canonical bytes for a JSON document. Hash and upload these exact bytes. */ 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 813122a..b2c654e 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'); }); @@ -54,5 +66,138 @@ 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); + }); }); 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); +});