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
18 changes: 18 additions & 0 deletions prisma/schema/trade.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// prisma/schema/trade.prisma

model Trade {
id String @id @default(cuid())
buyer String
creatorId String
quantity String
price String
ledger Int
txHash String
timestamp DateTime
createdAt DateTime @default(now())

@@unique([ledger, txHash])
@@index([creatorId])
@@index([buyer])
@@index([ledger])
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { httpGetCreatorStats } from './creators.controllers';
import { updateOwnership } from '../ownership/ownership.service';
import { prisma } from '../../utils/prisma.utils';

function makeReq(creatorId: string): any {
return {
params: { id: creatorId },
};
}

function makeRes(): any {
const res: any = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.setHeader = jest.fn().mockReturnValue(res);
res.set = jest.fn().mockReturnValue(res);
return res;
}

function makeNext(): jest.Mock {
return jest.fn();
}

describe('#630 Integration test: creator detail holder count after sequential buys and sells', () => {
const creatorId = '123';
const walletA = 'GWALLETA1111111111111111111111111111111111111111111111111';
const walletB = 'GWALLETB2222222222222222222222222222222222222222222222222';

beforeEach(() => {
jest.restoreAllMocks();
});

it('holder count updates accurately across sequential buys and sells across multiple wallets', async () => {
const ownershipStore = new Map<string, number>();
(prisma.creatorProfile.findFirst as any) = jest.fn(async () => ({ id: creatorId }));
(prisma.keyOwnership.count as any) = jest.fn(async (_args: any) => {
let count = 0;
for (const [key, bal] of ownershipStore.entries()) {
if (key.endsWith(`:${creatorId}`) && bal > 0) {
count++;
}
}
return count;
});

(prisma.keyOwnership.findFirst as any) = jest.fn(async (args: any) => {
const { ownerAddress, creatorId } = args.where;
const key = `${ownerAddress}:${creatorId}`;
const bal = ownershipStore.get(key) || 0;
return { balance: bal } as any;
});

(prisma.keyOwnership.upsert as any) = jest.fn(async (args: any) => {
const { ownerAddress, creatorId } = args.create;
const key = `${ownerAddress}:${creatorId}`;
const current = ownershipStore.get(key) || 0;
const change = args.update.balance.increment;
const newBal = current + change;
ownershipStore.set(key, newBal);
return { ownerAddress, creatorId, balance: newBal } as any;
});



// Step 0: Initial state - 0 holders
const req0 = makeReq(creatorId);
const res0 = makeRes();
await httpGetCreatorStats(req0, res0, makeNext());
expect(res0.json.mock.calls[0][0].data.holderCount).toBe(0);
expect(res0.json.mock.calls[0][0].data.holder_count).toBe(0);

// Step 1: Wallet A buys 1 key -> holder count is 1
await updateOwnership(walletA, creatorId, 1);
const req1 = makeReq(creatorId);
const res1 = makeRes();
await httpGetCreatorStats(req1, res1, makeNext());
expect(res1.json.mock.calls[0][0].data.holderCount).toBe(1);
expect(res1.json.mock.calls[0][0].data.holder_count).toBe(1);

// Step 2: Wallet B buys 1 key -> holder count is 2
await updateOwnership(walletB, creatorId, 1);
const req2 = makeReq(creatorId);
const res2 = makeRes();
await httpGetCreatorStats(req2, res2, makeNext());
expect(res2.json.mock.calls[0][0].data.holderCount).toBe(2);
expect(res2.json.mock.calls[0][0].data.holder_count).toBe(2);

// Step 3: Wallet A buys 2 more keys -> holder count remains 2 (existing holder buying more keys)
await updateOwnership(walletA, creatorId, 2);
const req3 = makeReq(creatorId);
const res3 = makeRes();
await httpGetCreatorStats(req3, res3, makeNext());
expect(res3.json.mock.calls[0][0].data.holderCount).toBe(2);
expect(res3.json.mock.calls[0][0].data.holder_count).toBe(2);

// Step 4: Wallet A sells its 3 keys -> holder count drops back to 1
await updateOwnership(walletA, creatorId, -3);
const req4 = makeReq(creatorId);
const res4 = makeRes();
await httpGetCreatorStats(req4, res4, makeNext());
expect(res4.json.mock.calls[0][0].data.holderCount).toBe(1);
expect(res4.json.mock.calls[0][0].data.holder_count).toBe(1);

// Step 5: Wallet B sells its 1 key -> holder count reaches 0
await updateOwnership(walletB, creatorId, -1);
const req5 = makeReq(creatorId);
const res5 = makeRes();
await httpGetCreatorStats(req5, res5, makeNext());
expect(res5.json.mock.calls[0][0].data.holderCount).toBe(0);
expect(res5.json.mock.calls[0][0].data.holder_count).toBe(0);
});
});
24 changes: 18 additions & 6 deletions src/modules/creators/creators.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,21 +117,33 @@ function categorizeParseError(
export const httpGetCreatorStats: AsyncController = async (req, res, next) => {
try {
const rawId = req.params.id;
const _creatorId = parseCreatorId(
const parsedId = parseCreatorId(
Array.isArray(rawId) ? rawId[0] : rawId
);
const creatorIdStr = String(parsedId);

// TODO: Fetch actual creator metrics from database/service using _creatorId
// For now, return placeholder data
const placeholderMetrics = {
holderCount: 0,
const creator = await prisma.creatorProfile.findFirst({
where: { OR: [{ id: creatorIdStr }, { handle: creatorIdStr }] },
select: { id: true },
});
const resolvedId = creator ? creator.id : creatorIdStr;

const holderCount = await prisma.keyOwnership.count({
where: {
creatorId: resolvedId,
balance: { gt: 0 },
},
});

const metrics = {
holderCount,
totalSupply: 0,
totalVolume: 0,
lastActivityAt: undefined,
};

// Serialize using the public stats mapper
const stats = mapPublicCreatorStats(placeholderMetrics);
const stats = mapPublicCreatorStats(metrics);

attachTimestampHeader(res);
sendSuccess(res, stats);
Expand Down
7 changes: 5 additions & 2 deletions src/modules/creators/creators.stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type CreatorStatsField = (typeof CREATOR_STATS_FIELDS)[number];
*/
export interface PublicCreatorStats {
holderCount: number;
holder_count: number;
totalSupply: number;
totalVolume: number;
lastActivityAt?: Date;
Expand Down Expand Up @@ -55,13 +56,15 @@ const CREATOR_STATS_FIELD_MAP = {
*
* @example
* mapPublicCreatorStats({ holderCount: 10, totalSupply: 100, totalVolume: 500 })
* // => { holderCount: 10, totalSupply: 100, totalVolume: 500 }
* // => { holderCount: 10, holder_count: 10, totalSupply: 100, totalVolume: 500 }
*/
export function mapPublicCreatorStats(
metrics: CreatorMetrics
): PublicCreatorStats {
const count = metrics[CREATOR_STATS_FIELD_MAP.holderCount];
return {
holderCount: metrics[CREATOR_STATS_FIELD_MAP.holderCount],
holderCount: count,
holder_count: count,
totalSupply: metrics[CREATOR_STATS_FIELD_MAP.totalSupply],
totalVolume: metrics[CREATOR_STATS_FIELD_MAP.totalVolume],
...(metrics[CREATOR_STATS_FIELD_MAP.lastActivityAt] !== undefined
Expand Down
123 changes: 123 additions & 0 deletions src/modules/indexer/trade-indexer.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { processTradeEvent, SorobanBuyEvent } from './trade-indexer.service';
import { logger } from '../../utils/logger.utils';

function makeMockDb() {
const store = new Map<string, any>();

return {
store,
trade: {
findUnique: jest.fn(async ({ where }: { where: { ledger_txHash: { ledger: number; txHash: string } } }) => {
const key = `${where.ledger_txHash.ledger}:${where.ledger_txHash.txHash}`;
return store.has(key) ? store.get(key) : null;
}),
create: jest.fn(async ({ data }: { data: any }) => {
const key = `${data.ledger}:${data.txHash}`;
const record = {
id: `trade-${store.size + 1}`,
...data,
};
store.set(key, record);
return record;
}),
findMany: jest.fn(async () => Array.from(store.values())),
},
};
}

describe('#619 Trade indexer — persisting Soroban buy events', () => {
let mockDb: ReturnType<typeof makeMockDb>;
let loggerWarnSpy: jest.SpyInstance;

beforeEach(() => {
mockDb = makeMockDb();
loggerWarnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => logger as any);
});

afterEach(() => {
jest.restoreAllMocks();
});

const validBuyEvent: SorobanBuyEvent = {
buyer: 'GBUYER11111111111111111111111111111111111111111111111111',
creator_id: 'creator-xyz',
quantity: '10',
price: '5000',
ledger: 1001,
tx_hash: '0xabc123hash',
timestamp: '2026-07-25T12:00:00.000Z',
};

it('creates a trade record with all six fields correct', async () => {
const processed = await processTradeEvent(validBuyEvent, mockDb as any);

expect(processed).toBe(true);
expect(mockDb.trade.create).toHaveBeenCalledTimes(1);

const records = await mockDb.trade.findMany();
expect(records).toHaveLength(1);

const record = records[0];
expect(record.buyer).toBe('GBUYER11111111111111111111111111111111111111111111111111');
expect(record.creatorId).toBe('creator-xyz');
expect(record.quantity).toBe('10');
expect(record.price).toBe('5000');
expect(record.ledger).toBe(1001);
expect(record.timestamp).toEqual(new Date('2026-07-25T12:00:00.000Z'));
});

it('is idempotent — duplicate event does not create a second record', async () => {
const firstRun = await processTradeEvent(validBuyEvent, mockDb as any);
expect(firstRun).toBe(true);

const secondRun = await processTradeEvent(validBuyEvent, mockDb as any);
expect(secondRun).toBe(false);

expect(mockDb.trade.create).toHaveBeenCalledTimes(1);
const records = await mockDb.trade.findMany();
expect(records).toHaveLength(1);
});

it('skips a malformed event missing a required field without crashing', async () => {
const malformedEvent: Partial<SorobanBuyEvent> = {
buyer: 'GBUYER11111111111111111111111111111111111111111111111111',
// creator_id missing!
quantity: '10',
price: '5000',
ledger: 1002,
tx_hash: '0xdef456hash',
timestamp: '2026-07-25T12:00:00.000Z',
};

const result = await processTradeEvent(malformedEvent, mockDb as any);

expect(result).toBe(false);
expect(mockDb.trade.create).not.toHaveBeenCalled();

const records = await mockDb.trade.findMany();
expect(records).toHaveLength(0);
});

it('produces a warn-level log when an event is skipped', async () => {
const malformedEvent: Partial<SorobanBuyEvent> = {
buyer: 'GBUYER11111111111111111111111111111111111111111111111111',
creator_id: 'creator-xyz',
// price missing!
quantity: '10',
ledger: 1003,
tx_hash: '0x789hash',
timestamp: '2026-07-25T12:00:00.000Z',
};

await processTradeEvent(malformedEvent, mockDb as any);

expect(loggerWarnSpy).toHaveBeenCalledTimes(1);
expect(loggerWarnSpy).toHaveBeenCalledWith(
expect.objectContaining({
type: 'trade_event_skipped',
missing_fields: ['price'],
}),
'Skipping trade event with missing required fields'
);
});
});
Loading
Loading