Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion src/routes/markets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

/* eslint-disable @typescript-eslint/no-explicit-any */
import { Router } from "express";
import { listMarkets, listUpcomingMarkets, getMarketById, updateMarket, VersionConflictError } from "../services/marketService";
import { listMarkets, listUpcomingMarkets, getMarketById, updateMarket, VersionConflictError, createMarket, MarketExistsError } from "../services/marketService";
import { searchMarkets } from "../repositories/marketRepository";
import { requireAdmin, AuthenticatedRequest } from "../middleware/auth";
import { rateLimitAnon } from "../middleware/rateLimitAnon";
Expand All @@ -28,6 +28,73 @@ const patchMarketSchema = z.object({
expectedVersion: z.number().int().nonnegative(),
}).strict();

/**
* Zod schema for POST /api/markets.
*
* - id: on-chain contract id (non-empty, max 128 chars)
* - question: canonical prediction question (1–500 chars)
* - resolutionTime: ISO 8601 datetime string, must be in the future
* - metadata: optional free-form object (validated for size in service layer)
*/
const createMarketSchema = z.object({
id: z.string().min(1).max(128),
question: z.string().min(1).max(500),
resolutionTime: z.string().datetime({ message: "resolutionTime must be an ISO 8601 datetime" }),
metadata: z.record(z.unknown()).optional(),
}).strict();

/**
* POST /api/markets — create an off-chain market shell (admin only).
*
* The caller must supply the on-chain contract id generated by the Soroban
* deployer. The row is inserted with status="pending", indexedLedger=0, and
* archived=false so the indexer can hydrate it once the contract is live.
*
* Returns 409 `market_exists` on duplicate id.
* Returns 403 `forbidden` for non-admin callers.
*/
marketsRouter.post("/", requireAdmin, async (req: AuthenticatedRequest, res, next) => {
const reqId = String((req as any).id ?? "anon");
const adminAddress = req.user?.stellarAddress;

try {
const parsed = createMarketSchema.safeParse(req.body);
if (!parsed.success) {
logger.warn({ reqId, adminAddress, issues: parsed.error.issues }, "markets_create_validation_failed");
return res.status(400).json({
error: {
code: "validation_error",
message: "Invalid request body",
details: parsed.error.issues,
correlationId: reqId,
},
});
}

const { id, question, resolutionTime, metadata } = parsed.data;

logger.info({ reqId, adminAddress, marketId: id }, "markets_create_start");

const market = await createMarket(
{ id, question, resolutionTime: new Date(resolutionTime), metadata },
adminAddress!,
);

logger.info({ reqId, adminAddress, marketId: id }, "markets_create_success");
return res.status(201).json({ data: market });
} catch (e: any) {
if (e instanceof MarketExistsError) {
logger.warn({ reqId, adminAddress, marketId: req.body?.id }, "markets_create_duplicate");
return res.status(409).json({ error: { code: "market_exists", correlationId: reqId } });
}
if (e?.status === 400) {
return res.status(400).json({ error: { code: "validation_error", message: e.message, correlationId: reqId } });
}
logger.error({ reqId, adminAddress, err: e }, "markets_create_failed");
return next(e);
}
});

marketsRouter.get("/search", async (req, res, next) => {
const reqId = String((req as any).id ?? "anon");
try {
Expand Down
92 changes: 92 additions & 0 deletions src/services/marketService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,98 @@ import { markets, marketAuditLog } from "../db/schema";
import { and, asc, eq, gt, inArray } from "drizzle-orm";
import { emitMarketEvent, LogEvent } from "../logging/events";

/** Max character lengths enforced before hitting the DB */
const QUESTION_MAX_LEN = 500;
const METADATA_MAX_JSON_LEN = 4096;

export class MarketExistsError extends Error {
status = 409;
code = "market_exists";
constructor() {
super("Market already exists");
Object.setPrototypeOf(this, MarketExistsError.prototype);
}
}

/** Zod-validated shape coming in from the route layer */
export interface CreateMarketInput {
id: string;
question: string;
resolutionTime: Date;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
metadata?: any;
}

/**
* Creates an off-chain market shell keyed by the on-chain contract id.
*
* Idempotent on `id`: throws `MarketExistsError` (409) on duplicate.
* The row is inserted with `status = "pending"`, `indexedLedger = 0`,
* and `archived = false` so the indexer can later hydrate it.
*
* @param input - Validated market creation payload
* @param adminAddress - Stellar address of the admin creating the market
* @returns The persisted market row
* @throws MarketExistsError if a market with the same id already exists
*/
export async function createMarket(
input: CreateMarketInput,
adminAddress: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Promise<any> {
const { id, question, resolutionTime, metadata } = input;

// Guard: validate lengths before hitting Postgres
if (question.length > QUESTION_MAX_LEN) {
const err = new Error(`question must be at most ${QUESTION_MAX_LEN} characters`);
(err as any).status = 400;
throw err;
}

if (metadata !== undefined) {
const serialized = JSON.stringify(metadata);
if (serialized.length > METADATA_MAX_JSON_LEN) {
const err = new Error(`metadata JSON must be at most ${METADATA_MAX_JSON_LEN} characters`);
(err as any).status = 400;
throw err;
}
}

// Check for duplicate before insert to return a clear 409
const existing = await getDb()
.select({ id: markets.id })
.from(markets)
.where(eq(markets.id, id))
.limit(1);

if (existing.length > 0) {
throw new MarketExistsError();
}

const [row] = await getDb()
.insert(markets)
.values({
id,
question,
resolutionTime,
metadata: metadata ?? null,
status: "pending",
indexedLedger: 0,
archived: false,
version: 1,
})
.returning();

emitMarketEvent(LogEvent.MARKET_UPDATED, {
marketId: id,
actor: adminAddress,
version: row.version,
fieldsUpdated: ["id", "question", "resolutionTime", "metadata"],
});

return row;
}

export interface Market {
id: string;
question: string;
Expand Down