Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/API_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
| Method | Path | Operation | Auth |
|--------|------|-----------|------|
| POST | `/api/v1/markets/{market_id}/resolve` | `resolveMarket` | ApiKeyAuth |
| POST | `/api/v1/markets/{market_id}/cancel` | `cancelMarket` | ApiKeyAuth |
| POST | `/api/blockchain/replay` | `blockchainReplay` | ApiKeyAuth |
| GET | `/api/v1/email/preview/{template_name}` | `emailPreview` | ApiKeyAuth |
| POST | `/api/v1/email/test` | `emailSendTest` | ApiKeyAuth |
Expand Down
41 changes: 11 additions & 30 deletions frontend/src/app/markets/[marketId]/cancel/page.tsx
Original file line number Diff line number Diff line change
@@ -1,51 +1,28 @@
'use client';

import { use, useEffect, useState } from 'react';
import { api } from '@/lib/api/public-client';
import { getEnvConfig } from '@/lib/env';
import { api, ApiError, getContractErrorMessage } from '@/lib/api/admin-client';
import { useWalletAddress } from '@/lib/hooks/useWalletAddress';
import { canCancelMarket, parseMarketView, type MarketView } from '@/lib/markets/marketState';
import { Modal } from '@/components/Modal';
import './cancel.css';

/**
* Market cancellation flow (#1376).
* Market cancellation flow (#1376, #1754).
*
* `contracts/predict-iq/src/test_cancellation.rs` defines when cancellation
* is contractually valid: only the creator/admin, only while `Active`, never
* once bets are locked in. This page mirrors that state machine client-side
* (`lib/markets/marketState.ts::canCancelMarket`) so the cancel action is
* disabled/hidden instead of failing after a wallet has already signed.
*
* There is no `POST /api/v1/markets/{id}/cancel` route in
* `services/api/openapi.yaml` yet (only `.../resolve` exists today), so the
* submit call below is a plain `fetch` rather than a `lib/api/*-client.ts`
* method — see the same note on `DepositTierBadge.tsx`. Swap it for the
* typed client once that endpoint is generated into `schema.d.ts`.
* `POST /api/v1/markets/{market_id}/cancel` is defined in
* `services/api/openapi.yaml` and typed as `api.cancelMarket` in
* `lib/api/admin-client.ts`.
*/

type SubmitState = 'idle' | 'submitting' | 'success' | 'error';

async function cancelMarket(marketId: string, signal: AbortSignal): Promise<void> {
const base = getEnvConfig().NEXT_PUBLIC_API_URL.replace(/\/$/, '');
const res = await fetch(`${base}/api/v1/markets/${encodeURIComponent(marketId)}/cancel`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal,
});

if (!res.ok) {
let message = `Failed to cancel market (HTTP ${res.status})`;
try {
const body = (await res.json()) as { message?: string };
if (body.message) message = body.message;
} catch {
// no JSON body — keep the generic message
}
throw new Error(message);
}
}

export default function CancelMarketPage({ params }: { params: Promise<{ marketId: string }> }) {
const { marketId } = use(params);
const { address: walletAddress } = useWalletAddress();
Expand Down Expand Up @@ -83,12 +60,16 @@ export default function CancelMarketPage({ params }: { params: Promise<{ marketI
setSubmitState('submitting');
setSubmitError(null);
try {
await cancelMarket(marketId, new AbortController().signal);
await api.cancelMarket(marketId);
setSubmitState('success');
setConfirmOpen(false);
} catch (err) {
setSubmitState('error');
setSubmitError(err instanceof Error ? err.message : 'Failed to cancel market.');
if (err instanceof ApiError && err.code === 'CONTRACT_ERROR' && typeof err.details?.['contract_code'] === 'number') {
setSubmitError(getContractErrorMessage(err.details['contract_code'] as number));
} else {
setSubmitError(err instanceof Error ? err.message : 'Failed to cancel market.');
}
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,12 @@ describe('API client paths match schema.d.ts (contract test, #3)', () => {
params: { market_id: '42' },
call: () => adminApi.resolveMarket('42'),
},
{
name: 'cancelMarket',
template: '/api/v1/markets/{market_id}/cancel',
params: { market_id: '42' },
call: () => adminApi.cancelMarket('42'),
},
];

it('every path used by the client is declared in schema.d.ts', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type _UserBets = AssertPath<'/api/v1/blockchain/users/{user}/bets'>;
type _OracleResult = AssertPath<'/api/v1/blockchain/oracle/{market_id}'>;
type _TransactionStatus = AssertPath<'/api/v1/blockchain/tx/{tx_hash}'>;
type _ResolveMarket = AssertPath<'/api/v1/markets/{market_id}/resolve'>;
type _CancelMarket = AssertPath<'/api/v1/markets/{market_id}/cancel'>;

// Deliberately-wrong path proving the check has teeth. Uncommenting this
// line fails `tsc --noEmit` with:
Expand Down
19 changes: 19 additions & 0 deletions frontend/src/lib/api/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ describe('API Client', () => {
['newsletterGdprExport', () => api.newsletterGdprExport('a@b.com'), 'POST', '/api/v1/newsletter/gdpr/export'],
['newsletterGdprDelete', () => api.newsletterGdprDelete('a@b.com'), 'DELETE', '/api/v1/newsletter/gdpr/delete'],
['resolveMarket', () => api.resolveMarket(3), 'POST', '/api/v1/markets/3/resolve'],
['cancelMarket', () => api.cancelMarket(3), 'POST', '/api/v1/markets/3/cancel'],
['emailPreview', () => api.emailPreview('welcome'), 'GET', '/api/v1/email/preview/welcome'],
['emailSendTest', () => api.emailSendTest({ recipient: 'a@b.com', template_name: 'welcome' }), 'POST', '/api/v1/email/test'],
['getEmailAnalytics', () => api.getEmailAnalytics({ days: 7 }), 'GET', '/api/v1/email/analytics'],
Expand Down Expand Up @@ -753,6 +754,15 @@ describe('API Client', () => {
);
});

it('cancelMarket encodes a slash in marketId', async () => {
mockOk();
await api.cancelMarket('10/20');
expect(global.fetch).toHaveBeenCalledWith(
`${base}/api/v1/markets/10%2F20/cancel`,
expect.any(Object),
);
});

it('emailPreview encodes a slash in templateName', async () => {
mockOk();
await api.emailPreview('welcome/v2');
Expand Down Expand Up @@ -847,6 +857,15 @@ describe('API Client', () => {
);
});

it('cancelMarket encodes a slash in marketId', async () => {
mockOk();
await api.cancelMarket('10/20');
expect(global.fetch).toHaveBeenCalledWith(
`${base}/api/v1/markets/10%2F20/cancel`,
expect.any(Object),
);
});

it('emailPreview encodes a slash in templateName', async () => {
mockOk();
await api.emailPreview('welcome/v2');
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/api/__tests__/public-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ describe('Module boundary: public-client must not expose admin surface (#1158)',
expect((publicClientModule.api as Record<string, unknown>)['resolveMarket']).toBeUndefined();
});

it('does NOT include cancelMarket on the public api object', () => {
expect((publicClientModule.api as Record<string, unknown>)['cancelMarket']).toBeUndefined();
});

it('does NOT include emailPreview on the public api object', () => {
expect((publicClientModule.api as Record<string, unknown>)['emailPreview']).toBeUndefined();
});
Expand Down Expand Up @@ -92,6 +96,10 @@ describe('Module boundary: public-client must not expose admin surface (#1158)',
expect(typeof adminClientModule.api.resolveMarket).toBe('function');
});

it('includes cancelMarket on the admin api object', () => {
expect(typeof adminClientModule.api.cancelMarket).toBe('function');
});

it('includes emailPreview on the admin api object', () => {
expect(typeof adminClientModule.api.emailPreview).toBe('function');
});
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/lib/api/admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type HttpMethod = "GET" | "POST" | "DELETE";
*/
const PATHS = {
resolveMarket: "/api/v1/markets/{market_id}/resolve",
cancelMarket: "/api/v1/markets/{market_id}/cancel",
blockchainReplay: "/api/blockchain/replay",
content: "/api/v1/content",
auditLogs: "/api/v1/audit/logs",
Expand Down Expand Up @@ -363,6 +364,13 @@ export const api = {
{ cacheTags: [CacheTag.MARKETS, CacheTag.BLOCKCHAIN, CacheTag.STATISTICS], signal },
),

cancelMarket: (marketId: number | string, signal?: AbortSignal) =>
request<components['schemas']['InvalidationResult']>(
"POST",
fillPath(PATHS.cancelMarket, 'market_id', marketId),
{ cacheTags: [CacheTag.MARKETS, CacheTag.BLOCKCHAIN, CacheTag.STATISTICS], signal },
),

getAuditLogs: (params: Record<string, string | number | undefined> = {}, signal?: AbortSignal) =>
request<components['schemas']['AnyObject']>("GET", PATHS.auditLogs, {
params, cacheTtl: CACHE_TTL.SHORT, cacheTags: [CacheTag.AUDIT], signal,
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/lib/api/schema.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/markets/{market_id}/cancel": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Cancel a market and invalidate caches (admin) */
post: operations["cancelMarket"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/blockchain/health": {
parameters: {
query?: never;
Expand Down Expand Up @@ -740,6 +757,40 @@ export interface operations {
500: components["responses"]["ApiError"];
};
};
cancelMarket: {
parameters: {
query?: never;
header?: {
/**
* @description Target API version. Defaults to the current stable version (`v1`).
* Responses for deprecated versions include `Deprecation`, `Sunset`, and `Link` headers.
*/
"API-Version"?: components["parameters"]["apiVersion"];
};
path: {
market_id: components["parameters"]["marketId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Cache invalidation result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["InvalidationResult"];
};
};
400: components["responses"]["ApiError"];
401: components["responses"]["ApiError"];
403: components["responses"]["ApiError"];
404: components["responses"]["ApiError"];
429: components["responses"]["ApiError"];
500: components["responses"]["ApiError"];
};
};
getBlockchainHealth: {
parameters: {
query?: never;
Expand Down
30 changes: 30 additions & 0 deletions services/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,36 @@ paths:
"500":
$ref: "#/components/responses/ApiError"

/api/v1/markets/{market_id}/cancel:
post:
tags: [markets]
operationId: cancelMarket
summary: Cancel a market and invalidate caches (admin)
security:
- ApiKeyAuth: []
parameters:
- $ref: "#/components/parameters/marketId"
- $ref: "#/components/parameters/apiVersion"
responses:
"200":
description: Cache invalidation result
content:
application/json:
schema:
$ref: "#/components/schemas/InvalidationResult"
"400":
$ref: "#/components/responses/ApiError"
"401":
$ref: "#/components/responses/ApiError"
"403":
$ref: "#/components/responses/ApiError"
"404":
$ref: "#/components/responses/ApiError"
"429":
$ref: "#/components/responses/ApiError"
"500":
$ref: "#/components/responses/ApiError"

/api/v1/blockchain/health:
get:
tags: [blockchain]
Expand Down
6 changes: 6 additions & 0 deletions services/api/src/audit_middleware.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,12 @@ fn parse_admin_action(path: &str, method: &axum::http::Method) -> (String, Strin
.find_map(|s| s.parse::<i64>().ok())
.map(|id| id.to_string());
("resolve_market".to_string(), "market".to_string(), market_id)
} else if path.contains("/markets/") && path.contains("/cancel") {
let market_id = path
.split('/')
.find_map(|s| s.parse::<i64>().ok())
.map(|id| id.to_string());
("cancel_market".to_string(), "market".to_string(), market_id)
} else if path.contains("/email/preview/") {
let template = path.split('/').last().map(|s| s.to_string());
("preview_email".to_string(), "email_template".to_string(), template)
Expand Down
15 changes: 15 additions & 0 deletions services/api/src/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1124,6 +1124,16 @@ pub enum InvalidationTag {
network: String,
featured_limit: i64,
},

/// A market was cancelled.
///
/// Invalidates the per-market chain entry, the oracle result, and the
/// aggregate statistics / featured-markets lists.
MarketCancelled {
market_id: i64,
network: String,
featured_limit: i64,
},
}

impl InvalidationTag {
Expand All @@ -1134,6 +1144,11 @@ impl InvalidationTag {
market_id,
network,
featured_limit,
}
| InvalidationTag::MarketCancelled {
market_id,
network,
featured_limit,
} => vec![
keys::chain_market(*market_id),
keys::chain_oracle_result(network, *market_id),
Expand Down
Loading