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
25 changes: 21 additions & 4 deletions backend/__tests__/controllers/marketController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,11 @@
market: jest.fn()
}));

jest.mock('../../src/services/eventSourcingService', () => ({
appendEvent: jest.fn()
}));

const stellarService = require('../../src/services/stellarService');

Check warning on line 53 in backend/__tests__/controllers/marketController.test.js

View workflow job for this annotation

GitHub Actions / Backend (Lint, Test)

'stellarService' is assigned a value but never used
const sorobanService = require('../../src/services/sorobanService');
const MarketController = require('../../src/controllers/marketController');

Expand Down Expand Up @@ -107,13 +111,26 @@
it('updates allowed fields only', async () => {
req.params = { id: 'btc-1' };
req.body = { question: 'Updated', status: 'resolved' };
const market = { creatorWalletAddress: 'gcreator', status: 'draft', totalTrades: 0, save: jest.fn().mockResolvedValue(true) };
mockMarketModel.findOne.mockResolvedValue(market);
const market = { creatorWalletAddress: 'gcreator', status: 'draft', totalTrades: 0 };
const eventSourcingService = require('../../src/services/eventSourcingService');

// First findOne for validation
mockMarketModel.findOne.mockResolvedValueOnce(market);
// Second findOne for returning updated market
mockMarketModel.findOne.mockResolvedValueOnce({ ...market, question: 'Updated' });

await MarketController.updateMarket(req, res);

expect(market.question).toBe('Updated');
expect(market.status).toBe('draft');
expect(eventSourcingService.appendEvent).toHaveBeenCalledWith(
'btc-1',
'MARKET_UPDATED',
{ question: 'Updated' },
'gcreator'
);
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({
success: true,
data: expect.objectContaining({ question: 'Updated' })
}));
});

it('aggregates market stats', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ describe('Property 9: oracle health response covers all configured sources', ()
fc.property(
// Generate 1–10 unique source names
fc.uniqueArray(
fc.string({ minLength: 1, maxLength: 20 }).filter(s => s.trim().length > 0),
fc.string({ minLength: 1, maxLength: 20 }).filter(s => s !== '__proto__' && s !== 'constructor'),
{ minLength: 1, maxLength: 10 }
),
(sourceNames) => {
Expand Down
49 changes: 30 additions & 19 deletions backend/__tests__/services/contractEventIndexer.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ jest.mock('../../src/services/sorobanService', () => ({
getAllRecentEvents: jest.fn()
}));

jest.mock('../../src/services/eventSourcingService', () => ({
appendEvent: jest.fn()
}));

jest.mock('../../src/config/contracts', () => ({
DEPLOYED_CONTRACTS: {
MARKET_FACTORY: 'FACTORY_CONTRACT',
Expand Down Expand Up @@ -146,22 +150,32 @@ describe('ContractEventIndexer', () => {
});

it('updates market stats from an indexed trade payload', async () => {
const eventSourcingService = require('../../src/services/eventSourcingService');
MockMarket.findOne.mockResolvedValueOnce({ currentYesPrice: 0.5, currentNoPrice: 0.5 });

await indexer.updateMarketStats('btc-100k', {
userWalletAddress: 'guser',
tokenType: 'yes',
price: 0.65,
totalCost: 1.95
});

expect(MockMarket.findOneAndUpdate).toHaveBeenCalledWith(
{ marketId: 'btc-100k' },
{
$inc: { totalVolume: 1.95, totalTrades: 1 },
currentYesPrice: 0.65
}
expect(eventSourcingService.appendEvent).toHaveBeenCalledWith(
'btc-100k',
'TRADE_EXECUTED',
{ amount: 1.95 },
'guser'
);
expect(eventSourcingService.appendEvent).toHaveBeenCalledWith(
'btc-100k',
'PRICES_UPDATED',
{ yesPrice: 0.65, noPrice: 0.35 },
'guser'
);
});

it('indexes market creation events once per market id', async () => {
const eventSourcingService = require('../../src/services/eventSourcingService');
MockMarket.findOne.mockResolvedValue(null);

await indexer.handleMarketCreated({
Expand All @@ -176,11 +190,16 @@ describe('ContractEventIndexer', () => {
noToken: 'NOABC'
}, { txHash: 'tx-create' });

expect(MockMarket).toHaveBeenCalledWith(expect.objectContaining({
marketId: 'market-1',
creatorWalletAddress: 'GCREATOR',
blockchainTxHash: 'tx-create'
}));
expect(eventSourcingService.appendEvent).toHaveBeenCalledWith(
'market-1',
'MARKET_CREATED',
expect.objectContaining({
marketId: 'market-1',
creatorWalletAddress: 'GCREATOR',
blockchainTxHash: 'tx-create'
}),
'GCREATOR'
);
});

it('updates reputation after market resolution based on winning positions', async () => {
Expand Down Expand Up @@ -346,14 +365,6 @@ describe('ContractEventIndexer', () => {
},
{ upsert: true }
);

expect(MockMarket.findOneAndUpdate).toHaveBeenCalledWith(
{ marketId: 'market-btc' },
expect.objectContaining({
resolutionFinalizationTxHash: 'tx-final-1',
resolutionFinalizationTimestamp: expect.any(Date)
})
);
});

it('commits the ResolutionEvent even when the Market update fails', async () => {
Expand Down
28 changes: 28 additions & 0 deletions backend/src/controllers/adminController.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const { User, Trade, Position, Market } = require('../models');
const logger = require('../config/logger');
const stellarService = require('../services/stellarService');
const sorobanService = require('../services/sorobanService');
const eventSourcingService = require('../services/eventSourcingService');
const { NotFoundError, ForbiddenError, ValidationError } = require('../middleware/errorHandler');

class AdminController {
Expand Down Expand Up @@ -429,6 +430,33 @@ class AdminController {
throw error;
}
}

// Recover all markets from event store (Event Sourcing)
static async recoverAllMarkets(req, res) {
try {
// Require highest admin level (double check to be safe)
if (req.user.userData?.level !== 'admin') {
throw new ForbiddenError('Only admins can initiate full market recovery');
}

logger.warn(`Admin ${req.user.walletAddress} initiated full market recovery`);

const result = await eventSourcingService.recoverAllMarkets();

res.json({
success: true,
message: 'Market recovery completed',
data: result
});
} catch (error) {
logger.error('Full market recovery failed:', error);
res.status(500).json({
success: false,
message: 'Market recovery failed',
error: error.message
});
}
}
}

module.exports = AdminController;
64 changes: 30 additions & 34 deletions backend/src/controllers/marketController.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const contractConfig = require('../config/contracts');
const logger = require('../config/logger');
const { NotFoundError, ValidationError, ForbiddenError, BadRequestError } = require('../middleware/errorHandler');
const cacheService = require('../services/cacheService');
const eventSourcingService = require('../services/eventSourcingService');

class MarketController {
// Get all markets with filtering and pagination
Expand Down Expand Up @@ -573,8 +574,8 @@ class MarketController {
// Create market assets on Stellar
const { yesAsset, noAsset, issuerKeypair } = await stellarService.createMarketAssets(marketId);

// Create market in database
const market = new Market({
// Create market via event sourcing
const marketPayload = {
marketId,
question,
category,
Expand All @@ -586,9 +587,9 @@ class MarketController {
noTokenAssetCode: noAsset.code,
yesTokenIssuer: yesAsset.issuer,
noTokenIssuer: noAsset.issuer
});
};

await market.save();
await eventSourcingService.appendEvent(marketId, 'MARKET_CREATED', marketPayload, req.user.walletAddress);

// Update user stats
const user = await User.findOne({ walletAddress: req.user.walletAddress });
Expand All @@ -609,20 +610,13 @@ class MarketController {
noTokenAddress: noAsset.issuer
});

market.metadata.contractAddress = contractResult.contractAddress;
await market.save();
await eventSourcingService.appendEvent(marketId, 'MARKET_UPDATED', { 'metadata.contractAddress': contractResult.contractAddress }, req.user.walletAddress);
} catch (error) {
logger.error('Failed to create Soroban contract for market:', error);
// Continue without contract - market can still function via traditional DEX
}

logger.market('Market created', {
marketId,
creator: req.user.walletAddress,
category,
initialLiquidity,
expiresAt
});
const market = await Market.findOne({ marketId });

res.status(201).json({
success: true,
Expand Down Expand Up @@ -664,18 +658,14 @@ class MarketController {
return obj;
}, {});

Object.assign(market, filteredUpdates);
await market.save();

logger.market('Market updated', {
marketId: id,
updater: req.user.walletAddress,
updates: Object.keys(filteredUpdates)
});
await eventSourcingService.appendEvent(id, 'MARKET_UPDATED', filteredUpdates, req.user.walletAddress);

// Fetch updated market to return
const updatedMarket = await Market.findOne({ marketId: id });

res.json({
success: true,
data: market,
data: updatedMarket,
message: 'Market updated successfully'
});
}
Expand Down Expand Up @@ -719,12 +709,25 @@ class MarketController {
transactionHash = resolveResult.transactionHash;
}

// Update market in database
market.resolve(outcome, req.user.walletAddress, transactionHash);
// Update market via event sourcing
const resolvePayload = {
outcome,
resolvedBy: req.user.walletAddress,
resolutionTxHash: transactionHash,
resolvedAt: new Date()
};

if (resolutionSource) {
market.metadata.resolutionSource = resolutionSource;
// First append resolution event
await eventSourcingService.appendEvent(id, 'MARKET_RESOLVED', resolvePayload, req.user.walletAddress);
// Then append update event for metadata
await eventSourcingService.appendEvent(id, 'MARKET_UPDATED', { 'metadata.resolutionSource': resolutionSource }, req.user.walletAddress);
} else {
await eventSourcingService.appendEvent(id, 'MARKET_RESOLVED', resolvePayload, req.user.walletAddress);
}
await market.save();

// Fetch the updated market
const resolvedMarket = await Market.findOne({ marketId: id });

// Update all positions for this market
const positions = await Position.find({
Expand All @@ -750,16 +753,9 @@ class MarketController {
}
}

logger.market('Market resolved', {
marketId: id,
resolver: req.user.walletAddress,
outcome,
positionsUpdated: positions.length
});

res.json({
success: true,
data: market,
data: resolvedMarket,
message: `Market resolved with outcome: ${outcome}`
});
} catch (error) {
Expand Down
56 changes: 56 additions & 0 deletions backend/src/models/MarketEvent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
const mongoose = require('mongoose');

const marketEventSchema = new mongoose.Schema({
marketId: {
type: String,
required: true,
index: true
},
eventType: {
type: String,
required: true,
enum: [
'MARKET_CREATED',
'MARKET_UPDATED',
'MARKET_RESOLVED',
'MARKET_CANCELLED',
'MARKET_ARCHIVED',
'TRADE_EXECUTED',
'PRICES_UPDATED',
'LIQUIDITY_ADDED',
'LIQUIDITY_REMOVED'
],
index: true
},
schemaVersion: {
type: Number,
required: true,
default: 1
},
payload: {
type: mongoose.Schema.Types.Mixed,
required: true
},
actorAddress: {
type: String,
index: true
},
timestamp: {
type: Date,
default: Date.now,
index: true
},
sequenceNumber: {
type: Number,
required: true,
index: true
}
}, {
timestamps: true,
collection: 'market_events'
});

// Ensure sequence numbers are unique per market
marketEventSchema.index({ marketId: 1, sequenceNumber: 1 }, { unique: true });

module.exports = mongoose.model('MarketEvent', marketEventSchema);
28 changes: 28 additions & 0 deletions backend/src/models/MarketSnapshot.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const mongoose = require('mongoose');

const marketSnapshotSchema = new mongoose.Schema({
marketId: {
type: String,
required: true,
index: true,
unique: true
},
lastEventSequenceNumber: {
type: Number,
required: true,
default: 0
},
stateData: {
type: mongoose.Schema.Types.Mixed,
required: true
},
timestamp: {
type: Date,
default: Date.now
}
}, {
timestamps: true,
collection: 'market_snapshots'
});

module.exports = mongoose.model('MarketSnapshot', marketSnapshotSchema);
Loading
Loading