diff --git a/docs/API_SPEC.md b/docs/API_SPEC.md
index 8bafdf17..02347e7e 100644
--- a/docs/API_SPEC.md
+++ b/docs/API_SPEC.md
@@ -12,12 +12,18 @@
| GET | `/api/v1/markets/featured` | `getFeaturedMarkets` | None |
| GET | `/api/v1/content` | `getContent` | None |
| GET | `/api/v1/blockchain/health` | `getBlockchainHealth` | None |
+| POST | `/api/v1/blockchain/markets` | `createMarket` | None |
| GET | `/api/v1/blockchain/markets/{market_id}` | `getBlockchainMarket` | None |
| GET | `/api/v1/blockchain/stats` | `getBlockchainStats` | None |
| GET | `/api/v1/blockchain/users/{user}/bets` | `getUserBets` | None |
| GET | `/api/v1/blockchain/oracle/{market_id}` | `getOracleResult` | None |
| GET | `/api/v1/blockchain/tx/{tx_hash}` | `getTransactionStatus` | None |
+> **Note:** `POST /api/v1/blockchain/markets` currently returns `501 Not Implemented`.
+> The full create flow requires client-side wallet signing (tracked in issue #77).
+> The route is registered and validates the request body; use the Stellar SDK to
+> sign and submit `CreateMarket` transactions directly until #77 is resolved.
+
## Newsletter Routes
| Method | Path | Operation | Auth |
diff --git a/frontend/src/app/markets/create/page.tsx b/frontend/src/app/markets/create/page.tsx
index 88391324..cba4f9eb 100644
--- a/frontend/src/app/markets/create/page.tsx
+++ b/frontend/src/app/markets/create/page.tsx
@@ -20,6 +20,14 @@ const EMPTY_FORM: MarketFormValues = {
outcomes: ['', ''],
closeTime: '',
asset: '',
+ resolutionDeadline: '',
+ oracleAddress: '',
+ feedId: '',
+ maxStalenessSeconds: 300,
+ maxConfidenceBps: 100,
+ tier: 'Basic',
+ parentId: 0,
+ parentOutcomeIdx: 0,
};
interface CreateMarketResponse {
@@ -78,18 +86,33 @@ export default function CreateMarketPage() {
try {
const parsed = marketFormSchema.parse(values);
const config = getEnvConfig();
- const res = await fetch(`${config.NEXT_PUBLIC_API_URL.replace(/\/$/, '')}/api/v1/blockchain/markets`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- creator: wallet,
- title: parsed.title,
- description: parsed.description,
- outcomes: parsed.outcomes,
- ends_at: new Date(parsed.closeTime).toISOString(),
- settlement_asset: getSupportedAsset(parsed.asset)?.address,
- }),
- });
+ // NOTE: Wallet signing for the on-chain transaction is tracked under #77.
+ const res = await fetch(
+ `${config.NEXT_PUBLIC_API_URL.replace(/\/$/, '')}/api/v1/blockchain/markets`,
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ creator: wallet,
+ description: parsed.description,
+ options: parsed.outcomes,
+ deadline: Math.floor(new Date(parsed.closeTime).getTime() / 1000),
+ resolution_deadline: Math.floor(
+ new Date(parsed.resolutionDeadline).getTime() / 1000
+ ),
+ oracle_config: {
+ oracle_address: parsed.oracleAddress,
+ feed_id: parsed.feedId,
+ max_staleness_seconds: parsed.maxStalenessSeconds,
+ max_confidence_bps: parsed.maxConfidenceBps,
+ },
+ tier: parsed.tier,
+ native_token: getSupportedAsset(parsed.asset)?.address,
+ parent_id: parsed.parentId ?? 0,
+ parent_outcome_idx: parsed.parentOutcomeIdx ?? 0,
+ }),
+ }
+ );
if (!res.ok) {
throw new Error(`Failed to create market (${res.status})`);
@@ -215,6 +238,30 @@ export default function CreateMarketPage() {
)}
+
+
+ setField('resolutionDeadline', e.target.value)}
+ aria-invalid={Boolean(errors.resolutionDeadline)}
+ aria-describedby={
+ errors.resolutionDeadline ? 'market-resolution-deadline-error' : undefined
+ }
+ />
+ {errors.resolutionDeadline && (
+
+ {errors.resolutionDeadline}
+
+ )}
+
+
+
+
+
+ {errors.tier && (
+
+ {errors.tier}
+
+ )}
+
+
+
+
+
+
diff --git a/frontend/src/lib/api/requestSchemas.ts b/frontend/src/lib/api/requestSchemas.ts
index 4ff6b290..5c12e144 100644
--- a/frontend/src/lib/api/requestSchemas.ts
+++ b/frontend/src/lib/api/requestSchemas.ts
@@ -78,6 +78,33 @@ export const placeBetSchema = z
})
.loose();
+/**
+ * POST /api/v1/blockchain/markets — create a new prediction market.
+ * Full wallet-signing flow is tracked under #77; this schema validates
+ * the API request body that will be sent once signing is wired.
+ */
+export const createMarketSchema = z
+ .object({
+ creator: z.string().min(1, 'A creator wallet address is required.'),
+ description: z.string().min(1, 'A description is required.'),
+ options: z.array(z.string().min(1)).min(2, 'At least 2 options are required.'),
+ deadline: z.number().int().positive('Deadline must be a positive unix timestamp.'),
+ resolution_deadline: z.number().int().positive('Resolution deadline must be a positive unix timestamp.'),
+ oracle_config: z.object({
+ oracle_address: z.string().min(1, 'Oracle address is required.'),
+ feed_id: z.string().min(1, 'Feed ID is required.'),
+ max_staleness_seconds: z.number().int().nonnegative(),
+ max_confidence_bps: z.number().int().nonnegative(),
+ }).loose(),
+ tier: z.enum(['Basic', 'Pro', 'Institutional']),
+ native_token: z.string().min(1, 'Native token address is required.'),
+ parent_id: z.number().int().nonnegative(),
+ parent_outcome_idx: z.number().int().nonnegative(),
+ })
+ .loose();
+
+export type CreateMarketBody = z.infer;
+
// === Compile-time contract: each schema stays assignable to its generated type
// The generated types carry `email` as a plain `string` (the `format: email`
diff --git a/frontend/src/lib/validation/marketForm.ts b/frontend/src/lib/validation/marketForm.ts
index c1dfcb8a..5b633e93 100644
--- a/frontend/src/lib/validation/marketForm.ts
+++ b/frontend/src/lib/validation/marketForm.ts
@@ -26,6 +26,11 @@ export const MAX_OUTCOMES = 100;
// through that window.
export const MIN_CLOSE_TIME_LEAD_MS = 5 * 60 * 1000;
+// The contract requires resolution_deadline >= deadline + 86400 (24 hours).
+// Mirror that here so the client rejects invalid submissions before spending
+// gas on a doomed transaction.
+export const MIN_RESOLUTION_LEAD_SECONDS = 86400;
+
/**
* Two outcome labels are considered duplicates if they're equal once
* surrounding whitespace is trimmed and case is normalized — e.g. "Yes",
@@ -45,44 +50,115 @@ export function hasDuplicateOutcome(outcomes: string[]): boolean {
return false;
}
-export const marketFormSchema = z.object({
- title: z
- .string()
- .trim()
- .min(1, 'Title is required.')
- .max(200, 'Title must be 200 characters or fewer.'),
- description: z
- .string()
- .trim()
- .min(1, 'Description is required.')
- .max(2000, 'Description must be 2000 characters or fewer.'),
- outcomes: z
- .array(z.string().trim().min(1, 'Outcome labels cannot be empty.'))
- .min(MIN_OUTCOMES, `At least ${MIN_OUTCOMES} outcomes are required.`)
- .max(MAX_OUTCOMES, `A market can have at most ${MAX_OUTCOMES} outcomes.`)
- .refine((outcomes) => !hasDuplicateOutcome(outcomes), {
- message: 'Outcome labels must be unique (case and whitespace insensitive).',
+export const marketFormSchema = z
+ .object({
+ title: z
+ .string()
+ .trim()
+ .min(1, 'Title is required.')
+ .max(200, 'Title must be 200 characters or fewer.'),
+ description: z
+ .string()
+ .trim()
+ .min(1, 'Description is required.')
+ .max(2000, 'Description must be 2000 characters or fewer.'),
+ outcomes: z
+ .array(z.string().trim().min(1, 'Outcome labels cannot be empty.'))
+ .min(MIN_OUTCOMES, `At least ${MIN_OUTCOMES} outcomes are required.`)
+ .max(MAX_OUTCOMES, `A market can have at most ${MAX_OUTCOMES} outcomes.`)
+ .refine((outcomes) => !hasDuplicateOutcome(outcomes), {
+ message: 'Outcome labels must be unique (case and whitespace insensitive).',
+ }),
+ closeTime: z
+ .string()
+ .min(1, 'Close time is required.')
+ .refine((value) => !Number.isNaN(Date.parse(value)), 'Close time must be a valid date.')
+ .refine(
+ (value) => Date.parse(value) >= Date.now() + MIN_CLOSE_TIME_LEAD_MS,
+ 'Close time must be at least 5 minutes in the future.'
+ ),
+ // An asset that's technically valid on-chain but not on the platform's
+ // supported list (see lib/assets.ts) must be rejected here, not left for
+ // the contract call to fail (#1373).
+ asset: z
+ .string()
+ .min(1, 'Select a settlement asset.')
+ .refine(isSupportedAsset, 'Select a supported settlement asset.'),
+
+ // Resolution deadline must be a valid date and is cross-validated against
+ // closeTime below (must be >= closeTime + 24 h) per the contract rule.
+ resolutionDeadline: z
+ .string()
+ .min(1, 'Resolution deadline is required.')
+ .refine(
+ (value) => !Number.isNaN(Date.parse(value)),
+ 'Resolution deadline must be a valid date.'
+ ),
+
+ // Oracle configuration fields (OracleConfig in the contract).
+ oracleAddress: z
+ .string()
+ .trim()
+ .min(1, 'Oracle contract address is required.'),
+ feedId: z
+ .string()
+ .trim()
+ .min(1, 'Oracle feed ID is required.'),
+ maxStalenessSeconds: z
+ .number({ invalid_type_error: 'Max staleness must be a number.' })
+ .int('Max staleness must be a whole number.')
+ .positive('Max staleness must be greater than zero.'),
+ maxConfidenceBps: z
+ .number({ invalid_type_error: 'Max confidence must be a number.' })
+ .int('Max confidence must be a whole number.')
+ .min(0, 'Max confidence cannot be negative.'),
+
+ // Market tier — mirrors MarketTier in the contract.
+ tier: z.enum(['Basic', 'Pro', 'Institutional'], {
+ errorMap: () => ({ message: 'Select a market tier.' }),
}),
- closeTime: z
- .string()
- .min(1, 'Close time is required.')
- .refine((value) => !Number.isNaN(Date.parse(value)), 'Close time must be a valid date.')
- .refine(
- (value) => Date.parse(value) >= Date.now() + MIN_CLOSE_TIME_LEAD_MS,
- 'Close time must be at least 5 minutes in the future.'
- ),
- // An asset that's technically valid on-chain but not on the platform's
- // supported list (see lib/assets.ts) must be rejected here, not left for
- // the contract call to fail (#1373).
- asset: z
- .string()
- .min(1, 'Select a settlement asset.')
- .refine(isSupportedAsset, 'Select a supported settlement asset.'),
-});
+
+ // Optional parent market linkage. Default to 0 (no parent) when omitted.
+ parentId: z
+ .number({ invalid_type_error: 'Parent ID must be a number.' })
+ .int('Parent ID must be a whole number.')
+ .min(0, 'Parent ID cannot be negative.')
+ .optional()
+ .default(0),
+ parentOutcomeIdx: z
+ .number({ invalid_type_error: 'Parent outcome index must be a number.' })
+ .int('Parent outcome index must be a whole number.')
+ .min(0, 'Parent outcome index cannot be negative.')
+ .optional()
+ .default(0),
+ })
+ // Cross-field refinement: resolution deadline must be at least 24 hours
+ // after close time, matching the on-chain invariant in the contract.
+ .refine(
+ (data) => {
+ if (!data.closeTime || !data.resolutionDeadline) return true;
+ const closeMs = Date.parse(data.closeTime);
+ const resolutionMs = Date.parse(data.resolutionDeadline);
+ if (Number.isNaN(closeMs) || Number.isNaN(resolutionMs)) return true;
+ return resolutionMs >= closeMs + MIN_RESOLUTION_LEAD_SECONDS * 1000;
+ },
+ {
+ message: 'Resolution deadline must be at least 24 hours after the close time.',
+ path: ['resolutionDeadline'],
+ }
+ );
export type MarketFormValues = z.infer;
-export type MarketFormErrors = Partial>;
+export type MarketFormErrors = Partial<
+ Record<
+ | keyof Omit
+ | 'parentId'
+ | 'parentOutcomeIdx'
+ | 'form',
+ string
+ >
+>;
/**
* Validates raw form state and returns a flat map of the first error per
@@ -95,7 +171,7 @@ export function validateMarketForm(values: MarketFormValues): MarketFormErrors {
const errors: MarketFormErrors = {};
for (const issue of result.error.issues) {
- const key = issue.path[0] as keyof MarketFormValues | undefined;
+ const key = issue.path[0] as keyof MarketFormErrors | undefined;
if (key && !errors[key]) {
errors[key] = issue.message;
}
diff --git a/services/api/openapi.yaml b/services/api/openapi.yaml
index f1c3ec7e..0570e13d 100644
--- a/services/api/openapi.yaml
+++ b/services/api/openapi.yaml
@@ -190,6 +190,47 @@ paths:
schema:
$ref: "#/components/schemas/BlockchainHealth"
+ /api/v1/blockchain/markets:
+ post:
+ tags: [blockchain]
+ operationId: createMarket
+ summary: Create a prediction market (wallet-signing required — see #77)
+ description: |
+ Registers a new prediction market.
+
+ **Status: 501 Not Implemented**
+
+ The full market-creation flow requires the caller to sign the Stellar
+ transaction with their wallet private key. The API server does not hold
+ user keys, so this step cannot be completed server-side until the
+ wallet-signing integration described in issue #77 is implemented.
+
+ Until then:
+ - The route **exists** and validates the request body (no more 404/405).
+ - A `501 Not Implemented` response is returned with a clear message.
+ - Callers should build and sign the `CreateMarket` contract-invocation
+ transaction directly with the Stellar SDK and submit it via Soroban RPC.
+ parameters:
+ - $ref: "#/components/parameters/apiVersion"
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateMarketRequest"
+ responses:
+ "400":
+ description: Validation error (missing/invalid fields)
+ $ref: "#/components/responses/ApiError"
+ "429":
+ $ref: "#/components/responses/ApiError"
+ "501":
+ description: Not yet implemented — use the Stellar SDK for wallet-signed market creation
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/CreateMarketResponse"
+
/api/v1/blockchain/markets/{market_id}:
get:
tags: [blockchain]
@@ -959,6 +1000,93 @@ components:
message_id:
type: string
+ OracleConfigRequest:
+ type: object
+ required: [oracle_address, feed_id, max_staleness_seconds, max_confidence_bps]
+ properties:
+ oracle_address:
+ type: string
+ description: Stellar account address of the oracle contract.
+ feed_id:
+ type: string
+ description: Feed identifier used by the oracle (e.g. "BTC/USD").
+ max_staleness_seconds:
+ type: integer
+ format: int64
+ minimum: 0
+ description: Maximum age of an accepted oracle reading, in seconds.
+ max_confidence_bps:
+ type: integer
+ format: int64
+ minimum: 0
+ description: Maximum confidence band accepted from the oracle, in basis points.
+
+ CreateMarketRequest:
+ type: object
+ required:
+ - creator
+ - description
+ - options
+ - deadline
+ - resolution_deadline
+ - oracle_config
+ - tier
+ - native_token
+ - parent_id
+ - parent_outcome_idx
+ properties:
+ creator:
+ type: string
+ description: Stellar account address that will be recorded as the market creator.
+ description:
+ type: string
+ description: Human-readable description of the prediction question.
+ options:
+ type: array
+ items:
+ type: string
+ minItems: 2
+ description: Ordered list of outcome labels (minimum 2).
+ deadline:
+ type: integer
+ format: int64
+ minimum: 0
+ description: Unix timestamp (seconds) at which the market stops accepting bets. Must be in the future.
+ resolution_deadline:
+ type: integer
+ format: int64
+ minimum: 0
+ description: Unix timestamp (seconds) after which the oracle may resolve the market. Must be at least `deadline + 86400`.
+ oracle_config:
+ $ref: "#/components/schemas/OracleConfigRequest"
+ tier:
+ type: string
+ enum: [Basic, Pro, Institutional]
+ description: Market tier.
+ native_token:
+ type: string
+ description: Native token contract address used for payouts.
+ parent_id:
+ type: integer
+ format: int64
+ minimum: 0
+ description: Parent market ID for conditional markets (0 = top-level market).
+ parent_outcome_idx:
+ type: integer
+ format: int32
+ minimum: 0
+ description: Outcome index within the parent market that activates this market.
+
+ CreateMarketResponse:
+ type: object
+ required: [success, message]
+ properties:
+ success:
+ type: boolean
+ message:
+ type: string
+ description: Human-readable explanation of why the request could not be completed server-side.
+
responses:
ApiError:
description: Error response with machine-readable code
diff --git a/services/api/src/handlers.rs b/services/api/src/handlers.rs
index 314bc8a3..7b141e19 100644
--- a/services/api/src/handlers.rs
+++ b/services/api/src/handlers.rs
@@ -1214,6 +1214,174 @@ pub async fn metrics(State(state): State>) -> Result,
+ /// Unix timestamp (seconds) at which the market stops accepting bets.
+ /// Must be in the future at the time of the request.
+ pub deadline: u64,
+ /// Unix timestamp (seconds) after which the oracle may resolve the market.
+ /// Must be at least `deadline + 86400` (24 h after betting closes).
+ pub resolution_deadline: u64,
+ /// Oracle configuration for automated resolution.
+ pub oracle_config: OracleConfigRequest,
+ /// Market tier: `"Basic"`, `"Pro"`, or `"Institutional"`.
+ pub tier: String,
+ /// Native token contract address used for payouts.
+ pub native_token: String,
+ /// Parent market ID for conditional markets (0 = top-level market).
+ pub parent_id: u64,
+ /// Outcome index within the parent market that activates this market.
+ pub parent_outcome_idx: u32,
+}
+
+/// Response body returned by `POST /api/v1/blockchain/markets`.
+#[derive(Debug, Clone, Serialize, utoipa::ToSchema)]
+pub struct CreateMarketResponse {
+ pub success: bool,
+ pub message: String,
+}
+
+/// Create a new prediction market.
+///
+/// **Status: 501 Not Implemented**
+///
+/// The full market-creation flow requires wallet signing of the Stellar
+/// transaction on the client side, which has not yet been wired into the
+/// API server (tracked in issue #77). This endpoint is registered so that
+/// clients receive a clear, actionable error instead of a 404 or 405.
+///
+/// Until #77 is resolved, callers should build and sign the `CreateMarket`
+/// contract-invocation transaction directly with the Stellar SDK and submit
+/// it via the Stellar Horizon or Soroban RPC endpoint.
+#[utoipa::path(
+ post,
+ path = "/api/v1/blockchain/markets",
+ tag = "blockchain",
+ request_body = CreateMarketRequest,
+ responses(
+ (status = 400, description = "Validation error", body = ApiError),
+ (status = 501, description = "Not yet implemented — use Stellar SDK for wallet-signed market creation", body = CreateMarketResponse),
+ )
+)]
+pub async fn create_market(
+ Json(payload): Json,
+) -> impl IntoResponse {
+ use std::time::{SystemTime, UNIX_EPOCH};
+
+ let now_secs = SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs();
+
+ // ── Validation ────────────────────────────────────────────────────────────
+
+ if payload.creator.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request("`creator` must not be empty")),
+ )
+ .into_response();
+ }
+
+ if payload.description.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request("`description` must not be empty")),
+ )
+ .into_response();
+ }
+
+ if payload.options.len() < 2 {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request("`options` must contain at least 2 entries")),
+ )
+ .into_response();
+ }
+
+ if payload.oracle_config.oracle_address.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request("`oracle_config.oracle_address` must not be empty")),
+ )
+ .into_response();
+ }
+
+ if payload.oracle_config.feed_id.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request("`oracle_config.feed_id` must not be empty")),
+ )
+ .into_response();
+ }
+
+ if payload.native_token.trim().is_empty() {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request("`native_token` must not be empty")),
+ )
+ .into_response();
+ }
+
+ if payload.deadline <= now_secs {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request("`deadline` must be in the future")),
+ )
+ .into_response();
+ }
+
+ if payload.resolution_deadline < payload.deadline + 86_400 {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::bad_request(
+ "`resolution_deadline` must be at least `deadline + 86400` (24 h after betting closes)",
+ )),
+ )
+ .into_response();
+ }
+
+ // ── Not yet implemented ───────────────────────────────────────────────────
+ // Full market creation requires the caller to sign the Stellar transaction
+ // with their private key. The API server does not hold user private keys,
+ // so this path cannot be completed server-side until the wallet-signing
+ // integration tracked in issue #77 is implemented.
+ (
+ StatusCode::NOT_IMPLEMENTED,
+ Json(CreateMarketResponse {
+ success: false,
+ message: "Market creation via API is not yet implemented. \
+ Direct contract invocation requires wallet signing, which must be \
+ performed client-side using the Stellar SDK. \
+ See issue #77 for the planned implementation."
+ .to_string(),
+ }),
+ )
+ .into_response()
+}
+
#[utoipa::path(
get,
path = "/api/v1/blockchain/health",
diff --git a/services/api/src/main.rs b/services/api/src/main.rs
index b6af3562..5188440d 100644
--- a/services/api/src/main.rs
+++ b/services/api/src/main.rs
@@ -315,6 +315,7 @@ async fn main() -> anyhow::Result<()> {
let public_routes = Router::new()
.route("/api/v1/blockchain/health", get(handlers::blockchain_health))
+ .route("/api/v1/blockchain/markets", post(handlers::create_market))
.route("/api/v1/blockchain/markets/:market_id", get(handlers::blockchain_market_data))
.route("/api/v1/blockchain/stats", get(handlers::blockchain_platform_stats))
.route("/api/v1/blockchain/users/:user/bets", get(handlers::blockchain_user_bets))