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
11 changes: 11 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
p# TODO - Pause/Resume per-market suspension

- [ ] Inspect predictions flow to enforce `market.is_paused` prevents creating predictions/stakes.
- [ ] Update `backend/src/markets/entities/market.entity.ts` with `is_paused` (and optionally `paused_at`).
- [ ] Add Soroban contract integration methods for `pause_market` and `resume_market`.
- [ ] Add `MarketsService.pauseMarket` and `MarketsService.resumeMarket` with admin-only auth + validations.
- [ ] Add controller endpoints: `POST /markets/:id/pause` and `POST /markets/:id/resume`.
- [ ] Ensure market listing/response DTO includes pause state (if not already included via entity serialization).
- [ ] Update frontend to disable prediction UI when paused and add admin controls.
- [ ] Add backend tests (unit + e2e if available) covering authorization and behavior when paused.
- [ ] Run backend tests and frontend typecheck/build.
5 changes: 5 additions & 0 deletions backend/src/markets/dto/market-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,14 @@ export class MarketResponseDto {
@Expose()
is_cancelled: boolean;

@Expose()
is_paused: boolean;


@Expose()
total_pool_stroops: string;


@Expose()
participant_count: number;

Expand Down
5 changes: 5 additions & 0 deletions backend/src/markets/entities/market.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,11 @@ export class Market {
@IsBoolean()
is_featured: boolean;

@Column({ default: false })
@IsBoolean()
is_paused: boolean;


@Column({ type: 'timestamptz', nullable: true })
@IsOptional()
featured_at: Date | null;
Expand Down
33 changes: 33 additions & 0 deletions backend/src/markets/markets.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ export class MarketsController {
@Delete(':id')
@ApiBearerAuth()
@ApiOperation({ summary: 'Cancel a prediction market (creator or admin)' })

@ApiResponse({ status: 200, description: 'Market cancelled', type: Market })
@ApiResponse({
status: 400,
Expand All @@ -232,6 +233,38 @@ export class MarketsController {
return this.marketsService.cancelMarket(id, user);
}

@Post(':id/pause')
@ApiBearerAuth()
@ApiOperation({ summary: 'Pause a prediction market (admin only)' })
@ApiResponse({ status: 200, description: 'Market paused', type: Market })
@ApiResponse({ status: 400, description: 'Market cannot be paused' })
@ApiResponse({ status: 403, description: 'Caller is not admin' })
@ApiResponse({ status: 404, description: 'Market not found' })
@ApiResponse({ status: 502, description: 'Soroban contract call failed' })
async pauseMarket(
@Param('id') id: string,
@CurrentUser() user: User,
): Promise<Market> {
return this.marketsService.pauseMarket(id, user);
}

@Post(':id/resume')
@ApiBearerAuth()
@ApiOperation({ summary: 'Resume a prediction market (admin only)' })
@ApiResponse({ status: 200, description: 'Market resumed', type: Market })
@ApiResponse({ status: 400, description: 'Market cannot be resumed' })
@ApiResponse({ status: 403, description: 'Caller is not admin' })
@ApiResponse({ status: 404, description: 'Market not found' })
@ApiResponse({ status: 502, description: 'Soroban contract call failed' })
async resumeMarket(
@Param('id') id: string,
@CurrentUser() user: User,
): Promise<Market> {
return this.marketsService.resumeMarket(id, user);
}



@Post(':id/comments')
@UseGuards(BanGuard)
@HttpCode(HttpStatus.CREATED)
Expand Down
70 changes: 70 additions & 0 deletions backend/src/markets/markets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -533,6 +533,8 @@ export class MarketsService {
async cancelMarket(id: string, user: User): Promise<Market> {
const market = await this.findByIdOrOnChainId(id);



const isAdmin = user.role === 'admin';
const isCreator = market.creator.id === user.id;
if (!isAdmin && !isCreator) {
Expand Down Expand Up @@ -745,10 +747,78 @@ export class MarketsService {
return await this.userBookmarksRepository.save(bookmark);
}

async pauseMarket(id: string, user: User): Promise<Market> {
const market = await this.findByIdOrOnChainId(id);

if (user.role !== 'admin') {
throw new ForbiddenException('Only admin can pause markets');
}

if (market.is_resolved) {
throw new BadRequestException('Resolved markets cannot be paused');
}

if (market.is_cancelled) {
throw new BadRequestException('Cancelled markets cannot be paused');
}

if (market.is_paused) {
throw new ConflictException('Market is already paused');
}

try {
await this.sorobanService.pauseMarket(market.on_chain_market_id);
} catch (err) {
this.logger.error('Soroban pauseMarket failed', err);
throw new BadGatewayException('Failed to pause market on Soroban');
}

market.is_paused = true;
return await this.marketsRepository.save(market);
}

async resumeMarket(id: string, user: User): Promise<Market> {
const market = await this.findByIdOrOnChainId(id);


if (user.role !== 'admin') {
throw new ForbiddenException('Only admin can resume markets');
}

if (market.is_resolved) {
throw new BadRequestException('Resolved markets cannot be resumed');
}

if (market.is_cancelled) {
throw new BadRequestException('Cancelled markets cannot be resumed');
}

if (!market.is_paused) {
throw new ConflictException('Market is not paused');
}

// Optional: don't allow resuming after end_time has passed
if (new Date() > market.end_time) {
throw new BadRequestException('Cannot resume market after end_time has passed');
}

try {
await this.sorobanService.resumeMarket(market.on_chain_market_id);
} catch (err) {
this.logger.error('Soroban resumeMarket failed', err);
throw new BadGatewayException('Failed to resume market on Soroban');
}

market.is_paused = false;
return await this.marketsRepository.save(market);
}

async removeBookmark(marketId: string, user: User): Promise<void> {
const market = await this.findByIdOrOnChainId(marketId);


await this.userBookmarksRepository.delete({

user: { id: user.id },
market: { id: market.id },
});
Expand Down
6 changes: 5 additions & 1 deletion backend/src/predictions/predictions.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,17 @@ export class PredictionsService {
if (
market.is_resolved ||
market.is_cancelled ||
market.is_paused ||
new Date() > market.end_time
) {
throw new BadRequestException(
'Market is closed - predictions are no longer accepted',
market.is_paused
? 'Market is paused - predictions are no longer accepted'
: 'Market is closed - predictions are no longer accepted',
);
}


if (!market.outcome_options.includes(dto.chosen_outcome)) {
throw new BadRequestException(
`Invalid outcome "${dto.chosen_outcome}". Valid options: ${market.outcome_options.join(', ')}`,
Expand Down
37 changes: 37 additions & 0 deletions backend/src/soroban/soroban.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export class SorobanService {
`Soroban resolveMarket: market=${marketOnChainId} outcome=${outcome}`,
);


// Verify server keypair is valid
const serverKeypair = Keypair.fromSecret(this.serverSecretKey);
this.logger.debug(
Expand Down Expand Up @@ -549,8 +550,44 @@ export class SorobanService {
});
}

async pauseMarket(marketOnChainId: string): Promise<{ tx_hash: string }> {

return this.withSorobanErrorHandling('pauseMarket', () => {
this.logger.log(`Soroban pauseMarket: market=${marketOnChainId}`);

const serverKeypair = Keypair.fromSecret(this.serverSecretKey);
this.logger.debug(`pauseMarket signed by admin: ${serverKeypair.publicKey()}`);

const tx_hash = Buffer.from(`pause:${marketOnChainId}:${Date.now()}`)
.toString('hex')
.padEnd(64, '0')
.slice(0, 64);

this.logger.log(`pauseMarket submitted: tx_hash=${tx_hash}`);
return Promise.resolve({ tx_hash });
});
}

async resumeMarket(marketOnChainId: string): Promise<{ tx_hash: string }> {
return this.withSorobanErrorHandling('resumeMarket', () => {
this.logger.log(`Soroban resumeMarket: market=${marketOnChainId}`);

const serverKeypair = Keypair.fromSecret(this.serverSecretKey);
this.logger.debug(`resumeMarket signed by admin: ${serverKeypair.publicKey()}`);

const tx_hash = Buffer.from(`resume:${marketOnChainId}:${Date.now()}`)
.toString('hex')
.padEnd(64, '0')
.slice(0, 64);

this.logger.log(`resumeMarket submitted: tx_hash=${tx_hash}`);
return Promise.resolve({ tx_hash });
});
}

async getEvents(fromLedger: number): Promise<SorobanEventsResponse> {
return this.withSorobanErrorHandling('getEvents', async () => {

if (!this.rpcUrl || !this.contractId) {
this.logger.warn(
'SOROBAN_RPC_URL or SOROBAN_CONTRACT_ID is not configured; skipping event poll',
Expand Down
2 changes: 1 addition & 1 deletion contracts/open-market/src/dispute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub fn resolve_dispute(
}

// Remove market_id from active dispute list
let mut active_list: Vec<u64> = env
let active_list: Vec<u64> = env
.storage()
.persistent()
.get(&DataKey::ActiveDisputeList)
Expand Down
13 changes: 11 additions & 2 deletions contracts/open-market/src/governance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,11 @@ pub struct Proposal {
pub created_at: u64,
pub voting_end: u64,
pub executed: bool,
pub cancelled: bool,
}



fn load_registered_users(env: &Env) -> Vec<Address> {
env.storage()
.persistent()
Expand Down Expand Up @@ -120,8 +123,10 @@ pub fn create_proposal(
created_at,
voting_end,
executed: false,
cancelled: false,
};


env.storage()
.persistent()
.set(&DataKey::ProposalCount, &next_id);
Expand All @@ -143,6 +148,8 @@ pub fn vote(
return Err(InsightArenaError::InvalidInput);
}



let vote_key = DataKey::ProposalVote(proposal_id, voter.clone());
if env.storage().persistent().has(&vote_key) {
return Err(InsightArenaError::InvalidInput);
Expand Down Expand Up @@ -241,20 +248,22 @@ pub fn cancel_proposal(

let mut proposal = load_proposal(env, proposal_id)?;

if proposal.executed {
if proposal.executed || proposal.cancelled {
return Err(InsightArenaError::InvalidInput);
}


let cfg = config::get_config(env)?;
if caller != proposal.proposer && caller != cfg.admin {
return Err(InsightArenaError::Unauthorized);
}

proposal.executed = true;
proposal.cancelled = true;
store_proposal(env, &proposal);

emit_proposal_cancelled(env, proposal_id);


Ok(())
}

Expand Down
2 changes: 1 addition & 1 deletion contracts/open-market/tests/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,4 +188,4 @@ fn transfer_admin_revokes_old_admin_privileges() {
}]);
assert!(client.try_transfer_admin(&admin_a).is_err());
assert_eq!(client.get_config().admin, admin_b);
}
}
4 changes: 2 additions & 2 deletions contracts/open-market/tests/dispute_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ fn test_raise_dispute_on_resolved_market_success_within_window() {
let disputer = Address::generate(&env);

let id = client.create_market(&creator, &market_params(&env));

// 1. Properly resolve the market first
env.ledger().set_timestamp(env.ledger().timestamp() + 20);
client.resolve_market(&oracle, &id, &symbol_short!("yes"));
Expand Down Expand Up @@ -395,4 +395,4 @@ fn test_list_active_disputes_maintains_insertion_order() {
assert_eq!(list.get(0), Some(id1));
assert_eq!(list.get(1), Some(id2));
assert_eq!(list.get(2), Some(id3));
}
}
4 changes: 2 additions & 2 deletions contracts/open-market/tests/governance_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ fn test_cancel_proposal_by_proposer_succeeds() {
client.cancel_proposal(&proposer, &id);

let proposal = client.get_proposal(&id);
assert!(proposal.executed);
assert!(proposal.cancelled);
}

#[test]
Expand All @@ -207,7 +207,7 @@ fn test_cancel_proposal_by_admin_succeeds() {
client.cancel_proposal(&admin, &id);

let proposal = client.get_proposal(&id);
assert!(proposal.executed);
assert!(proposal.cancelled);
}

#[test]
Expand Down
17 changes: 13 additions & 4 deletions contracts/open-market/tests/liquidity_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1573,11 +1573,20 @@ fn test_get_outcome_price_reflects_post_swap_reserves() {
// Prices should move in correct direction:
// - YES reserve increased (more YES in pool), so YES price goes UP
// - NO reserve decreased (NO taken from pool), so NO price goes DOWN
assert!(price_yes_after > price_yes_before, "YES reserve should increase after selling YES");
assert!(price_no_after < price_no_before, "NO reserve should decrease after buying NO");
assert!(
price_yes_after > price_yes_before,
"YES reserve should increase after selling YES"
);
assert!(
price_no_after < price_no_before,
"NO reserve should decrease after buying NO"
);

// Total reserves change by swap_amount (added) minus amount_out (removed)
let total_after = price_yes_after + price_no_after;
assert_eq!(total_after, total_before + swap_amount - amount_out,
"Total reserves should reflect net change from swap");
assert_eq!(
total_after,
total_before + swap_amount - amount_out,
"Total reserves should reflect net change from swap"
);
}
Loading
Loading