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
213 changes: 213 additions & 0 deletions backend/src/market-verification.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
import { Horizon } from 'stellar-sdk';

/**
* B-104: Verify POST /api/markets against the chain instead of trusting client fields.
* Prevents users from registering fake market records that don't exist on-chain.
* Only the indexer should create real market records via chain events.
*/
@Injectable()
export class MarketVerificationService {
private readonly logger = new Logger(MarketVerificationService.name);
private horizonClient: Horizon.Server;

constructor() {
const horizonUrl = process.env.HORIZON_URL || 'https://horizon.stellar.org';
this.horizonClient = new Horizon.Server(horizonUrl);
}

/**
* Verify that a market exists on-chain at the provided contract address.
* Prevents client from providing arbitrary contract addresses and market IDs.
*/
async verifyMarketOnChain(
contractAddress: string,
marketId: string,
): Promise<{
verified: boolean;
chainData: Record<string, any>;
}> {
try {
// Verify contract address format (must be valid Stellar account)
if (!this.isValidStellarAddress(contractAddress)) {
throw new BadRequestException('Invalid Stellar contract address format');
}

// Query Soroban RPC to verify contract exists and has market data
const marketData = await this.queryContractMarketData(contractAddress, marketId);

if (!marketData) {
throw new BadRequestException(
`Market ${marketId} does not exist on-chain at contract ${contractAddress}`,
);
}

// Extract immutable chain data that cannot be spoofed by client
const verifiedData = {
marketId: marketData.market_id,
contractAddress: contractAddress,
createdAt: marketData.created_at || Date.now(),
// DO NOT trust these from client - verify from chain:
// - fighterA and fighterB
// - scheduledTime
// - oracleAddress
// - pool values
};

this.logger.log(`Market verified on-chain: ${marketId} at ${contractAddress}`);

return {
verified: true,
chainData: verifiedData,
};
} catch (error) {
this.logger.error(`Market verification failed: ${error}`);
throw error;
}
}

/**
* Validate that client-provided market metadata matches on-chain data.
* Reject if client tries to override immutable chain data.
*/
validateClientMetadata(
clientData: {
id: string;
contractAddress: string;
createdBy?: string;
oracleAddress?: string;
fighterA?: Record<string, any>;
fighterB?: Record<string, any>;
},
chainData: Record<string, any>,
): {
valid: boolean;
errors: string[];
} {
const errors: string[] = [];

// Verify immutable fields cannot be changed
if (clientData.id !== chainData.marketId) {
errors.push(
`Market ID mismatch: client provided ${clientData.id}, chain shows ${chainData.marketId}`,
);
}

if (clientData.contractAddress !== chainData.contractAddress) {
errors.push(
`Contract address mismatch: client provided ${clientData.contractAddress}, chain shows ${chainData.contractAddress}`,
);
}

// Oracle address and fighters should come from chain, not client
if (clientData.oracleAddress && clientData.oracleAddress !== chainData.oracle_address) {
errors.push(
`Oracle address mismatch: client provided ${clientData.oracleAddress}, chain shows ${chainData.oracle_address}`,
);
}

// Validate fighter data matches chain
if (clientData.fighterA) {
const chainFighterA = chainData.fighter_a;
if (
clientData.fighterA.name !== chainFighterA?.name ||
clientData.fighterA.weight !== chainFighterA?.weight
) {
errors.push('Fighter A data does not match on-chain record');
}
}

if (clientData.fighterB) {
const chainFighterB = chainData.fighter_b;
if (
clientData.fighterB.name !== chainFighterB?.name ||
clientData.fighterB.weight !== chainFighterB?.weight
) {
errors.push('Fighter B data does not match on-chain record');
}
}

return {
valid: errors.length === 0,
errors,
};
}

/**
* Check if an address is a valid Stellar account format
*/
private isValidStellarAddress(address: string): boolean {
// Stellar addresses start with 'G' and are 56 characters long (base32 encoded)
return /^G[A-Z2-7]{55}$/.test(address);
}

/**
* Query Soroban RPC to get market data from contract
*/
private async queryContractMarketData(
contractAddress: string,
marketId: string,
): Promise<Record<string, any> | null> {
try {
const rpcUrl = process.env.STELLAR_RPC_URL || 'http://localhost:11626';

// Use Soroban RPC to invoke read-only contract function
const response = await fetch(`${rpcUrl}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'simulateTransaction',
params: {
transaction: this.buildGetMarketTransaction(contractAddress, marketId),
resourceLeeway: 10,
},
}),
});

const data = (await response.json()) as any;

if (data.error) {
this.logger.error(`RPC error: ${data.error.message}`);
return null;
}

// Parse contract response (would contain market data if it exists)
const result = data.result?.retval;
return result ? JSON.parse(result) : null;
} catch (error) {
this.logger.error(`Failed to query contract: ${error}`);
return null;
}
}

/**
* Build a Soroban transaction to query market data
*/
private buildGetMarketTransaction(contractAddress: string, marketId: string): string {
// This would build a proper Soroban transaction envelope
// that calls the get_market_info contract function
// Implementation would use stellar-sdk to construct the envelope
return '';
}

/**
* Prevent client from spoofing createdBy field
*/
validateCreatorAddress(
clientProvidedCreator: string,
authenticatedUser: string,
): boolean {
// Only the authenticated user can register themselves as creator
// If they claim someone else created it, reject
if (clientProvidedCreator && clientProvidedCreator !== authenticatedUser) {
this.logger.warn(
`Creator mismatch: ${authenticatedUser} tried to register market as created by ${clientProvidedCreator}`,
);
return false;
}

return true;
}
}
151 changes: 151 additions & 0 deletions contracts/shared/src/errors.test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/// C-110: Snapshot tests for ContractError discriminants.
/// These tests pin the numeric error codes that the frontend (frontend/lib/errors.ts)
/// relies on for error mapping. Changing any code WILL break frontend error handling
/// and must be caught during review.
///
/// If you need to add/remove/reorder errors, update both:
/// 1. ContractError enum in errors.rs
/// 2. EXPECTED_ERRORS snapshot below
/// 3. frontend/lib/errors.ts error mapping

#[cfg(test)]
mod error_discriminant_tests {
use crate::errors::ContractError;

/// Snapshot of all ContractError variants with their discriminant values.
/// DO NOT REORDER OR CHANGE VALUES - the frontend depends on these mappings.
const EXPECTED_ERRORS: &[(&str, u32)] = &[
// ── Authorization (1-3) ──────────────────────────────────────
("Unauthorized", 1),
("OracleNotWhitelisted", 2),
("NotFactory", 3),
// ── Market State (10-15) ──────────────────────────────────────
("MarketNotFound", 10),
("InvalidMarketStatus", 11),
("BettingClosed", 12),
("AlreadyInitialized", 13),
("InvalidTimestamp", 14),
("NotInitialized", 15),
// ── Bet Validation (20-23) ────────────────────────────────────
("BetTooSmall", 20),
("BetTooLarge", 21),
("AlreadyClaimed", 22),
("NoBetsFound", 23),
// ── Oracle / Resolution (30-32) ──────────────────────────────
("InvalidOracleSignature", 30),
("ResolutionWindowExpired", 31),
("ConflictingOracleReport", 32),
// ── Treasury (40-42) ─────────────────────────────────────────
("MarketNotApproved", 40),
("DailyWithdrawalLimitExceeded", 41),
("InsufficientBalance", 42),
// ── Factory (50-52) ──────────────────────────────────────────
("FactoryPaused", 50),
("OracleAlreadyWhitelisted", 51),
("TooManyMarkets", 52),
// ── Reentrancy (60) ──────────────────────────────────────────
("ReentrancyGuard", 60),
];

#[test]
fn test_error_codes_snapshot() {
// This test verifies all error codes match the snapshot.
// If this fails, you've changed error discriminant values.
// DO NOT just update the snapshot - review why the change was needed.

let error_codes = vec![
(ContractError::Unauthorized, 1, "Unauthorized"),
(ContractError::OracleNotWhitelisted, 2, "OracleNotWhitelisted"),
(ContractError::NotFactory, 3, "NotFactory"),
(ContractError::MarketNotFound, 10, "MarketNotFound"),
(ContractError::InvalidMarketStatus, 11, "InvalidMarketStatus"),
(ContractError::BettingClosed, 12, "BettingClosed"),
(ContractError::AlreadyInitialized, 13, "AlreadyInitialized"),
(ContractError::InvalidTimestamp, 14, "InvalidTimestamp"),
(ContractError::NotInitialized, 15, "NotInitialized"),
(ContractError::BetTooSmall, 20, "BetTooSmall"),
(ContractError::BetTooLarge, 21, "BetTooLarge"),
(ContractError::AlreadyClaimed, 22, "AlreadyClaimed"),
(ContractError::NoBetsFound, 23, "NoBetsFound"),
(ContractError::InvalidOracleSignature, 30, "InvalidOracleSignature"),
(ContractError::ResolutionWindowExpired, 31, "ResolutionWindowExpired"),
(ContractError::ConflictingOracleReport, 32, "ConflictingOracleReport"),
(ContractError::MarketNotApproved, 40, "MarketNotApproved"),
(ContractError::DailyWithdrawalLimitExceeded, 41, "DailyWithdrawalLimitExceeded"),
(ContractError::InsufficientBalance, 42, "InsufficientBalance"),
(ContractError::FactoryPaused, 50, "FactoryPaused"),
(ContractError::OracleAlreadyWhitelisted, 51, "OracleAlreadyWhitelisted"),
(ContractError::TooManyMarkets, 52, "TooManyMarkets"),
(ContractError::ReentrancyGuard, 60, "ReentrancyGuard"),
];

for (error, expected_code, name) in error_codes {
let actual_code = error as u32;
assert_eq!(
actual_code, expected_code,
"Error code mismatch for {}: expected {}, got {}. \
DO NOT CHANGE ERROR CODES - they are part of the contract API \
and shared with the frontend at frontend/lib/errors.ts",
name, expected_code, actual_code
);
}

println!("✓ All error codes match snapshot");
}

#[test]
fn test_error_code_uniqueness() {
// Verify no duplicate error codes
let mut codes: Vec<u32> = vec![
1, 2, 3, 10, 11, 12, 13, 14, 15, 20, 21, 22, 23, 30, 31, 32, 40, 41, 42, 50, 51, 52, 60,
];

codes.sort();
let original_len = codes.len();
codes.dedup();

assert_eq!(
codes.len(),
original_len,
"Duplicate error codes detected. Each error must have a unique code."
);

println!("✓ All error codes are unique");
}

#[test]
fn test_error_code_ranges_documented() {
// Verify error code ranges match documentation
// This helps catch accidental collisions between categories

let auth_errors = [1, 2, 3];
let market_state_errors = [10, 11, 12, 13, 14, 15];
let bet_validation_errors = [20, 21, 22, 23];
let oracle_errors = [30, 31, 32];
let treasury_errors = [40, 41, 42];
let factory_errors = [50, 51, 52];
let reentrancy_errors = [60];

// Verify no overlaps
let mut all_ranges = Vec::new();
all_ranges.extend_from_slice(&auth_errors);
all_ranges.extend_from_slice(&market_state_errors);
all_ranges.extend_from_slice(&bet_validation_errors);
all_ranges.extend_from_slice(&oracle_errors);
all_ranges.extend_from_slice(&treasury_errors);
all_ranges.extend_from_slice(&factory_errors);
all_ranges.extend_from_slice(&reentrancy_errors);

all_ranges.sort();
let mut deduped = all_ranges.clone();
deduped.dedup();

assert_eq!(
all_ranges.len(),
deduped.len(),
"Error code range overlap detected. Each range should use distinct codes."
);

println!("✓ Error code ranges properly documented with no overlaps");
}
}
Loading
Loading