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
6 changes: 3 additions & 3 deletions packages/backend/src/audit/audit.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,12 @@ import { QueryAuditLogsDto } from './dto/query-audit-logs.dto';
* The important thing is that these routes are protected before they reach this
* controller. The imports below are placeholder names; adjust to your auth module.
*/
// import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
// import { AdminGuard } from '../auth/guards/admin.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AdminGuard } from '../auth/guards/admin.guard';

@ApiTags('admin')
@ApiBearerAuth('JWT-auth')
// @UseGuards(JwtAuthGuard, AdminGuard) ← uncomment when your guards are wired up
@UseGuards(JwtAuthGuard, AdminGuard)
@Controller('admin/audit-logs')
export class AuditController {
constructor(private readonly auditService: AuditService) {}
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/audit/audit.module.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AuditLog } from './audit-log.entity';
import { AuditService } from './audit.service';
Expand All @@ -12,6 +13,10 @@ import { AuditInterceptor } from './interceptors/audit.interceptor';
providers: [
AuditService,
AuditInterceptor, // provided here so @Audited() can inject it via DI
{
provide: APP_INTERCEPTOR,
useClass: AuditInterceptor,
},
],
controllers: [AuditController],
exports: [AuditService, AuditInterceptor], // export so other modules can use @Audited()
Expand Down
4 changes: 1 addition & 3 deletions packages/backend/src/audit/decorators/audited.decorator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { SetMetadata, applyDecorators, UseInterceptors } from '@nestjs/common';
import { SetMetadata, applyDecorators } from '@nestjs/common';
import { AuditActionType } from '../audit-log.entity';
import { AuditInterceptor } from '../interceptors/audit.interceptor';

export const AUDIT_ACTION_KEY = 'audit:action';
export const AUDIT_RESOURCE_KEY = 'audit:resource';
Expand Down Expand Up @@ -30,6 +29,5 @@ export function Audited(
return applyDecorators(
SetMetadata(AUDIT_ACTION_KEY, actionType),
SetMetadata(AUDIT_RESOURCE_KEY, getResource),
UseInterceptors(AuditInterceptor),
);
}
17 changes: 17 additions & 0 deletions packages/backend/src/auth/guards/admin.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Observable } from 'rxjs';

@Injectable()
export class AdminGuard implements CanActivate {
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
const user = request.user;

// TODO: Implement real admin check logic here.
// For now, we assume all authenticated users are admins for development,
// or check if they have an 'isAdmin' flag / 'admin' role.
return !!user;
}
}
7 changes: 7 additions & 0 deletions packages/backend/src/calls/calls.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ export class CallsController {
return this.callsService.search(query);
}

@Get(':id/odds')
@UseInterceptors(CacheInterceptor)
@CacheTTL(30) // Cache odds for 30s
getOdds(@Param('id', ParseUUIDPipe) id: string) {
return this.callsService.getOdds(id);
}

@Post(':id/report')
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
Expand Down
29 changes: 28 additions & 1 deletion packages/backend/src/calls/calls.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { CallReport } from './entities/call-report.entity';
import { Call, CallStatus } from './entities/call.entity';
import { ReportCallDto } from './dto/report-call.dto';
import { QueryCallsDto } from './dto/query-calls.dto';
import { REPORT_THRESHOLD } from './constants/moderation.constants';
import { OracleService } from '../oracle/oracle.service';

@Injectable()
Expand Down Expand Up @@ -136,4 +135,32 @@ export class CallsService {

return this.callsRepository.save(call);
}

/**
* Calculate potential payout ratio (odds) for YES/NO selections.
*/
async getOdds(id: string) {
const call = await this.getCallOrThrow(id);

const yesStake = parseFloat(call.totalYesStake || '0');
const noStake = parseFloat(call.totalNoStake || '0');
const totalPool = yesStake + noStake;

if (totalPool === 0) {
return {
yes: 2.0,
no: 2.0,
totalPool: 0,
};
}

const yesOdds = yesStake > 0 ? (totalPool / yesStake) : 2.0;
const noOdds = noStake > 0 ? (totalPool / noStake) : 2.0;

return {
yes: Number(yesOdds.toFixed(2)),
no: Number(noOdds.toFixed(2)),
totalPool: Number(totalPool.toFixed(7)),
};
}
}
6 changes: 6 additions & 0 deletions packages/backend/src/calls/entities/call.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export class Call {
@Column({ type: 'decimal', precision: 20, scale: 8, nullable: true })
finalPrice: string | null;

@Column({ type: 'decimal', precision: 20, scale: 7, default: 0 })
totalYesStake: string;

@Column({ type: 'decimal', precision: 20, scale: 7, default: 0 })
totalNoStake: string;

// ─── timestamps ───────────────────────────────────────────────────────────

@CreateDateColumn()
Expand Down
43 changes: 43 additions & 0 deletions packages/backend/src/oracle/oracle.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,36 @@ import {
ParseIntPipe,
HttpCode,
HttpStatus,
Patch,
UseGuards,
} from '@nestjs/common';
import { OracleService } from './oracle.service';
import { AdminResolveDto } from './dto/admin-resolve.dto';
import { OracleCall } from './entities/oracle-call.entity';
import { Audited } from '../audit/decorators/audited.decorator';
import { AuditActionType } from '../audit/audit-log.entity';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AdminGuard } from '../auth/guards/admin.guard';

@UseGuards(JwtAuthGuard, AdminGuard)
@Controller('admin/markets')
export class OracleController {
constructor(private readonly oracleService: OracleService) {}

@Audited(AuditActionType.MARKET_MANUALLY_RESOLVED, (ctx) => {
const id = ctx.switchToHttp().getRequest().params.id;
return `market:${id}`;
})
@Post(':id/unpause')
@HttpCode(HttpStatus.OK)
unpause(@Param('id', ParseIntPipe) id: number): Promise<OracleCall> {
return this.oracleService.unpauseCall(id);
}

@Audited(AuditActionType.MARKET_MANUALLY_RESOLVED, (ctx) => {
const id = ctx.switchToHttp().getRequest().params.id;
return `market:${id}`;
})
@Post(':id/resolve')
@HttpCode(HttpStatus.OK)
resolve(
Expand All @@ -29,4 +44,32 @@ export class OracleController {
): Promise<OracleCall> {
return this.oracleService.adminResolveCall(id, dto.resolution, dto.finalPrice); // ✅ types now match
}

// ─── Oracle Parameters & Quorums ──────────────────────────────────────────

@Audited(AuditActionType.ORACLE_PARAMS_UPDATED, (ctx) => {
const feedId = ctx.switchToHttp().getRequest().params.feedId;
return `oracle:feed:${feedId}`;
})
@Patch('feeds/:feedId/params')
@HttpCode(HttpStatus.OK)
updateOracleParams(
@Param('feedId') feedId: string,
@Body() dto: { minResponses: number; heartbeatSeconds: number },
) {
return this.oracleService.updateParams(feedId, dto);
}

@Audited(AuditActionType.ORACLE_QUORUM_SET, (ctx) => {
const roundId = ctx.switchToHttp().getRequest().params.roundId;
return `oracle:round:${roundId}`;
})
@Patch('rounds/:roundId/quorum')
@HttpCode(HttpStatus.OK)
setQuorum(
@Param('roundId') roundId: string,
@Body() dto: { quorum: number },
) {
return this.oracleService.setQuorum(roundId, dto.quorum);
}
}
23 changes: 22 additions & 1 deletion packages/backend/src/oracle/oracle.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,10 +268,31 @@ export class OracleService {
call.failedAt = null;
if (finalPrice !== undefined) call.finalPrice = finalPrice;

this.logger.log(`Call ${callId} FORCE-RESOLVED by admin → ${resolution}`);

this.logger.log(`Call ${callId} FORCE-RESOLVED by admin → ${resolution}`);
return this.oracleCallRepository.save(call);
}

// ─── Admin: Oracle Configuration ──────────────────────────────────────────

async updateParams(
feedId: string,
params: { minResponses: number; heartbeatSeconds: number },
): Promise<{ success: boolean; feedId: string }> {
this.logger.log(`Oracle params updated for feed ${feedId}: ${JSON.stringify(params)}`);
// In a real app, this would send a Soroban transaction
return { success: true, feedId };
}

async setQuorum(
roundId: string,
quorum: number,
): Promise<{ success: boolean; roundId: string }> {
this.logger.log(`Oracle quorum set for round ${roundId}: ${quorum}`);
// In a real app, this would send a Soroban transaction
return { success: true, roundId };
}

// ─── Private Helpers ──────────────────────────────────────────────────────

private async findCallOrThrow(callId: number): Promise<OracleCall> {
Expand Down
75 changes: 52 additions & 23 deletions packages/backend/src/relay/relay.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,25 +50,40 @@ export class RelayService {
// Validate inner transaction
await this.validateTransaction(innerTx);

// If it's not a fee-bump, we wrap it in a fee-bump
// Actually, if the user sent a regular transaction, we create a fee-bump for it.
// The user should have signed the inner transaction already.

// Security: Ensure inner transaction has at least one signature from the user
if (!innerTx.signatures || innerTx.signatures.length === 0) {
throw new BadRequestException('Inner transaction must be signed by the user');
}

// Security: Check transaction expiration (timebounds)
if (innerTx.timeBounds) {
const now = Math.floor(Date.now() / 1000);
const { minTime, maxTime } = innerTx.timeBounds;
if (maxTime !== '0' && now > parseInt(maxTime)) {
throw new BadRequestException('Transaction has expired');
}
if (minTime !== '0' && now < parseInt(minTime)) {
throw new BadRequestException('Transaction is not yet valid');
}
}

let finalTx: FeeBumpTransaction;
if (tx instanceof FeeBumpTransaction) {
// If they already sent a fee-bump, they expect us to sign it?
// But they'd need to know our public key to set it as fee source.
// Usually, the relayer creates the fee-bump.
finalTx = tx;
// We should check if the fee source is us
if (finalTx.feeSource !== this.hotWallet.publicKey()) {
throw new BadRequestException('Fee source in fee-bump transaction must be the relayer');
// If it's already a fee-bump, we check if we are the intended sponsor
if (tx.feeSource !== this.hotWallet.publicKey()) {
throw new BadRequestException(`Fee source mismatch. Expected ${this.hotWallet.publicKey()}`);
}
finalTx = tx;
} else {
// Create a fee-bump transaction
// The outer fee must be at least inner_fee + base_fee.
// We add a small margin (500 stroops) to ensure acceptance.
const innerFee = BigInt(innerTx.fee);
const outerFee = (innerFee + 500n).toString();

finalTx = TransactionBuilder.buildFeeBumpTransaction(
this.hotWallet,
innerTx.fee, // Or we can compute a higher fee if needed
outerFee,
innerTx,
networkPassphrase,
);
Expand All @@ -80,10 +95,15 @@ export class RelayService {
// Submit back to Stellar
try {
const response = await this.rpcServer.sendTransaction(finalTx);

if (response.status === 'ERROR') {
this.logger.error(`Transaction failed: ${JSON.stringify(response.errorResult)}`);
throw new BadRequestException('Transaction submission failed');
const errorMsg = (response as any).errorResultXdr ||
(response.errorResult ? JSON.stringify(response.errorResult) : 'Unknown error');
this.logger.error(`Transaction failed: ${errorMsg}`);
throw new BadRequestException(`Transaction submission failed: ${errorMsg}`);
}

this.logger.log(`Relayed transaction ${response.hash} for contract call`);
return { hash: response.hash };
} catch (error) {
this.logger.error(`Relay submission error: ${error.message}`);
Expand All @@ -96,24 +116,32 @@ export class RelayService {
const allowedContractId = settings.contractId;

if (!allowedContractId) {
throw new BadRequestException('Target contract not configured');
this.logger.error('No allowed contract ID configured in platform settings');
throw new BadRequestException('Relay target contract not configured');
}

// Check all operations
if (tx.operations.length === 0) {
throw new BadRequestException('Transaction has no operations');
}

// Check all operations for contract ID
for (const op of tx.operations) {
// For Soroban, we only allow host function invocations in the relay
if (op.type !== 'invokeHostFunction') {
throw new BadRequestException(`Operation type ${op.type} not allowed in relay`);
throw new BadRequestException(`Operation type ${op.type} not allowed in relay. Only invokeHostFunction is permitted.`);
}

// Extract the HostFunction from the high-level operation object
const hostFunction = (op as any).func as xdr.HostFunction;
// Cast to the specific operation type to access Soroban-specific fields
const hostFunctionOp = op as any;
const hostFunction = hostFunctionOp.func as xdr.HostFunction;

if (!hostFunction) {
throw new BadRequestException('Malformed host function operation');
throw new BadRequestException('Malformed host function operation: missing function definition');
}

// Ensure it's a contract invocation
if (hostFunction.switch().value !== xdr.HostFunctionType.hostFunctionTypeInvokeContract().value) {
throw new BadRequestException('Only contract invocations are allowed');
throw new BadRequestException('Only contract invocations are allowed in this relay');
}

const invokeContractArgs = hostFunction.invokeContract();
Expand All @@ -123,11 +151,12 @@ export class RelayService {
if (contractAddress.switch().value === xdr.ScAddressType.scAddressTypeContract().value) {
contractId = StrKey.encodeContract(contractAddress.contractId());
} else {
throw new BadRequestException('Invalid contract address type');
throw new BadRequestException('Invalid contract address type: must be a contract ID');
}

if (contractId !== allowedContractId) {
throw new BadRequestException(`Transaction directed at unauthorized contract: ${contractId}`);
this.logger.warn(`Unauthorized relay attempt to contract: ${contractId}`);
throw new BadRequestException(`Transaction directed at unauthorized contract: ${contractId}. Only ${allowedContractId} is allowed.`);
}
}
}
Expand Down
23 changes: 23 additions & 0 deletions packages/frontend/src/app/api/calls/[id]/odds/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";

export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;

// In a real app, this would call the backend:
// const res = await fetch(`${process.env.BACKEND_URL}/calls/${id}/odds`);
// return res;

// For now, return mock odds based on ID or random
const mockOdds: Record<string, any> = {
"1": { yes: 1.5, no: 2.8, totalPool: 23500 },
"2": { yes: 2.1, no: 1.9, totalPool: 37000 },
"3": { yes: 1.2, no: 5.4, totalPool: 27000 },
};

const odds = mockOdds[id] || { yes: 2.0, no: 2.0, totalPool: 0 };

return NextResponse.json(odds);
}
Loading
Loading