From 5cee6746a7025d7b2543a517b63339e5580c62c4 Mon Sep 17 00:00:00 2001 From: Nathaniel Nanle Date: Tue, 23 Jun 2026 09:16:58 +0100 Subject: [PATCH] feat: add wallet activity feed endpoint (#424) - Add GET /wallets/:address/activity endpoint for full trade history - Returns paginated list of trade events with type, creator info, amount, price, fee, ledger sequence, and timestamp - Supports type filter (buy|sell) and creator_id filter - Default sort is most recent first (reverse chronological) - Returns 400 for malformed Stellar addresses - Returns empty array (not 404) for wallets with no activity - Includes comprehensive integration tests covering all acceptance criteria - Route registered at /api/v1/wallets/:address/activity - Updated API inventory documentation --- docs/api-inventory.md | 8 + src/modules/index.ts | 2 + .../wallets/wallet-activity.controllers.ts | 55 ++++ .../wallet-activity.integration.test.ts | 248 ++++++++++++++++++ .../wallets/wallet-activity.schemas.ts | 41 +++ .../wallets/wallet-activity.service.ts | 72 +++++ src/modules/wallets/wallets.routes.ts | 21 ++ 7 files changed, 447 insertions(+) create mode 100644 src/modules/wallets/wallet-activity.controllers.ts create mode 100644 src/modules/wallets/wallet-activity.integration.test.ts create mode 100644 src/modules/wallets/wallet-activity.schemas.ts create mode 100644 src/modules/wallets/wallet-activity.service.ts create mode 100644 src/modules/wallets/wallets.routes.ts diff --git a/docs/api-inventory.md b/docs/api-inventory.md index 62c35e6..b9a9043 100644 --- a/docs/api-inventory.md +++ b/docs/api-inventory.md @@ -50,6 +50,14 @@ Public activity feed endpoints. | :----- | :---------- | :------------------------------- | | `GET` | `/activity` | Return the public activity feed. | +## Wallets Module + +Wallet activity and trade history endpoints. + +| Method | Path | Description | +| :----- | :--------------------------- | :------------------------------------------------------------ | +| `GET` | `/wallets/:address/activity` | Return paginated trade history (buys and sells) for a wallet. | + ## Ownership Module Ownership lookup endpoints. diff --git a/src/modules/index.ts b/src/modules/index.ts index dd4cec5..76bad1e 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -10,6 +10,7 @@ import adminRouter from './admin/admin.routes'; import activityRouter from './activity/activity.routes'; import ownershipRouter from './ownership/ownership.routes'; import webhookRouter from './webhooks/webhook.router'; +import walletsRouter from './wallets/wallets.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -25,5 +26,6 @@ router.use('/admin', adminRouter); router.use('/activity', activityRouter); router.use('/ownership', ownershipRouter); router.use(CREATORS_BASE, webhookRouter); +router.use('/wallets', walletsRouter); export default router; diff --git a/src/modules/wallets/wallet-activity.controllers.ts b/src/modules/wallets/wallet-activity.controllers.ts new file mode 100644 index 0000000..a50d4e9 --- /dev/null +++ b/src/modules/wallets/wallet-activity.controllers.ts @@ -0,0 +1,55 @@ +import { Request, Response, NextFunction } from 'express'; +import { WalletActivityParamsSchema, WalletActivityQuerySchema } from './wallet-activity.schemas'; +import { fetchWalletActivity } from './wallet-activity.service'; +import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; +import { buildOffsetPaginationMeta } from '../../utils/pagination.utils'; + +export async function httpGetWalletActivity( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = WalletActivityParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + sendValidationError( + res, + 'Invalid wallet address', + parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ + field: `address`, + message: issue.message, + })) + ); + return; + } + + const parsedQuery = WalletActivityQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + sendValidationError( + res, + 'Invalid query parameters', + parsedQuery.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ + field: issue.path.join('.'), + message: issue.message, + })) + ); + return; + } + + const [items, total] = await fetchWalletActivity( + parsedParams.data.address, + parsedQuery.data + ); + + sendSuccess(res, { + items, + meta: buildOffsetPaginationMeta({ + limit: parsedQuery.data.limit, + offset: parsedQuery.data.offset, + total, + }), + }); + } catch (error) { + next(error); + } +} diff --git a/src/modules/wallets/wallet-activity.integration.test.ts b/src/modules/wallets/wallet-activity.integration.test.ts new file mode 100644 index 0000000..edc7874 --- /dev/null +++ b/src/modules/wallets/wallet-activity.integration.test.ts @@ -0,0 +1,248 @@ +// Integration test: wallet activity feed endpoint (#424) +// +// Covers: mixed history, type filter, creator_id filter, empty wallet, +// 400 on malformed address, pagination metadata. +// Uses Jest mocks — no database required. + +import { httpGetWalletActivity } from './wallet-activity.controllers'; +import * as walletActivityService from './wallet-activity.service'; +import { WalletActivityItem } from './wallet-activity.schemas'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MALFORMED_ADDRESS = 'not-a-stellar-address'; + +function makeReq(params: Record = {}, query: Record = {}): any { + return { params, query }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +function makeActivity(overrides: Partial = {}): WalletActivityItem { + return { + type: 'buy', + creator_id: 'creator-1', + creator_handle: 'alice', + amount: '10', + price_at_trade: '50', + fee_paid: '1', + ledger_sequence: 100, + timestamp: new Date('2026-01-01T00:00:00Z'), + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /wallets/:address/activity', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ── Happy path: mixed history ───────────────────────────────────────────── + + it('returns 200 with items and meta for a wallet with mixed trade history', async () => { + const activities: WalletActivityItem[] = [ + makeActivity({ type: 'buy', creator_id: 'creator-1', creator_handle: 'alice' }), + makeActivity({ type: 'sell', creator_id: 'creator-2', creator_handle: 'bob' }), + ]; + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([activities, 2]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(2); + expect(body.data.meta.total).toBe(2); + }); + + it('each item includes required trade fields', async () => { + const activity = makeActivity({ + type: 'buy', + creator_id: 'creator-1', + creator_handle: 'alice', + amount: '5', + price_at_trade: '100', + fee_paid: '2', + ledger_sequence: 42, + timestamp: new Date('2026-03-01T00:00:00Z'), + }); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[activity], 1]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const item = res.json.mock.calls[0][0].data.items[0]; + expect(item).toMatchObject({ + type: 'buy', + creator_id: 'creator-1', + creator_handle: 'alice', + amount: '5', + price_at_trade: '100', + fee_paid: '2', + ledger_sequence: 42, + }); + expect(item.timestamp).toBeDefined(); + }); + + // ── Empty wallet ────────────────────────────────────────────────────────── + + it('returns 200 with empty items array (not 404) for a wallet with no activity', async () => { + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toEqual([]); + expect(body.data.meta.total).toBe(0); + expect(body.data.meta.hasMore).toBe(false); + }); + + // ── type filter ─────────────────────────────────────────────────────────── + + it('passes type=buy filter to the service', async () => { + const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith( + VALID_ADDRESS, + expect.objectContaining({ type: 'buy' }) + ); + }); + + it('passes type=sell filter to the service', async () => { + const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }, { type: 'sell' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith( + VALID_ADDRESS, + expect.objectContaining({ type: 'sell' }) + ); + }); + + it('returns only buy events when type=buy', async () => { + const buys: WalletActivityItem[] = [makeActivity({ type: 'buy' })]; + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([buys, 1]); + + const req = makeReq({ address: VALID_ADDRESS }, { type: 'buy' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items.every((i: WalletActivityItem) => i.type === 'buy')).toBe(true); + }); + + // ── creator_id filter ───────────────────────────────────────────────────── + + it('passes creator_id filter to the service', async () => { + const spy = jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(spy).toHaveBeenCalledWith( + VALID_ADDRESS, + expect.objectContaining({ creator_id: 'creator-abc' }) + ); + }); + + it('returns only trades for the specified creator when creator_id is set', async () => { + const items: WalletActivityItem[] = [ + makeActivity({ creator_id: 'creator-abc', creator_handle: 'target' }), + ]; + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1]); + + const req = makeReq({ address: VALID_ADDRESS }, { creator_id: 'creator-abc' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const body = res.json.mock.calls[0][0]; + expect(body.data.items.every((i: WalletActivityItem) => i.creator_id === 'creator-abc')).toBe(true); + }); + + // ── Malformed address → 400 ─────────────────────────────────────────────── + + it('returns 400 for a malformed Stellar address', async () => { + const req = makeReq({ address: MALFORMED_ADDRESS }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for an invalid type filter value', async () => { + const req = makeReq({ address: VALID_ADDRESS }, { type: 'transfer' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + // ── Pagination ──────────────────────────────────────────────────────────── + + it('meta reflects limit and offset correctly', async () => { + const items = Array.from({ length: 5 }, () => makeActivity()); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 50]); + + const req = makeReq({ address: VALID_ADDRESS }, { limit: '5', offset: '10' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + const meta = res.json.mock.calls[0][0].data.meta; + expect(meta.limit).toBe(5); + expect(meta.offset).toBe(10); + expect(meta.total).toBe(50); + expect(meta.hasMore).toBe(true); + }); + + it('hasMore is false when all items fit in one page', async () => { + const items = [makeActivity()]; + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockResolvedValue([items, 1]); + + const req = makeReq({ address: VALID_ADDRESS }, { limit: '20', offset: '0' }); + const res = makeRes(); + await httpGetWalletActivity(req, res, makeNext()); + + expect(res.json.mock.calls[0][0].data.meta.hasMore).toBe(false); + }); + + it('forwards service errors to next()', async () => { + const err = new Error('db down'); + jest.spyOn(walletActivityService, 'fetchWalletActivity').mockRejectedValue(err); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + const next = makeNext(); + await httpGetWalletActivity(req, res, next); + + expect(next).toHaveBeenCalledWith(err); + }); +}); diff --git a/src/modules/wallets/wallet-activity.schemas.ts b/src/modules/wallets/wallet-activity.schemas.ts new file mode 100644 index 0000000..6fc4cf6 --- /dev/null +++ b/src/modules/wallets/wallet-activity.schemas.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; +import { safeIntParam } from '../../utils/query.utils'; +import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults'; +import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants'; + +export const WalletActivityParamsSchema = z.object({ + address: StellarAddressSchema, +}); + +export const WalletActivityQuerySchema = z.object({ + limit: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.limit, + min: MIN_PAGE_SIZE, + max: MAX_PAGE_SIZE, + label: 'Limit', + }), + offset: safeIntParam({ + defaultValue: PUBLIC_OFFSET_PAGINATION_DEFAULTS.offset, + min: 0, + max: Number.MAX_SAFE_INTEGER, + label: 'Offset', + }), + type: z.enum(['buy', 'sell']).optional(), + creator_id: z.string().optional(), +}).strict(); + +export type WalletActivityQueryType = z.infer; + +export const WalletActivityItemSchema = z.object({ + type: z.enum(['buy', 'sell']), + creator_id: z.string(), + creator_handle: z.string().nullable(), + amount: z.any(), + price_at_trade: z.any(), + fee_paid: z.any(), + ledger_sequence: z.number().nullable(), + timestamp: z.date(), +}); + +export type WalletActivityItem = z.infer; diff --git a/src/modules/wallets/wallet-activity.service.ts b/src/modules/wallets/wallet-activity.service.ts new file mode 100644 index 0000000..d148158 --- /dev/null +++ b/src/modules/wallets/wallet-activity.service.ts @@ -0,0 +1,72 @@ +import { prisma } from '../../utils/prisma.utils'; +import { WalletActivityItem, WalletActivityQueryType } from './wallet-activity.schemas'; + +/** + * Fetches the paginated trade activity (KEY_BOUGHT / KEY_SOLD) for a single + * wallet address. Returns a tuple of [items, total] so the controller can + * build pagination metadata without a second call. + * + * The payload stored in Activity for trades is expected to contain: + * { amount, price_at_trade, fee_paid, ledger_sequence } + */ +export async function fetchWalletActivity( + address: string, + query: WalletActivityQueryType +): Promise<[WalletActivityItem[], number]> { + const { limit, offset, type, creator_id } = query; + + // Map the public-facing type param to the internal ActivityType enum values. + const typeFilter = + type === 'buy' ? 'KEY_BOUGHT' : + type === 'sell' ? 'KEY_SOLD' : + undefined; + + const where: any = { + actor: address, + type: typeFilter + ? typeFilter + : { in: ['KEY_BOUGHT', 'KEY_SOLD'] }, + }; + + if (creator_id) { + where.creatorId = creator_id; + } + + const [rows, total] = await Promise.all([ + prisma.activity.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: offset, + take: limit, + }), + prisma.activity.count({ where }), + ]); + + if (rows.length === 0) { + return [[], total]; + } + + // Resolve creator handles in a single batched query. + const creatorIds = [...new Set(rows.map((r: { creatorId: string | null }) => r.creatorId).filter(Boolean))] as string[]; + const creatorProfiles = await prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { id: true, handle: true }, + }); + const handleMap = new Map(creatorProfiles.map((c: { id: string; handle: string }) => [c.id, c.handle])); + + const items: WalletActivityItem[] = rows.map((row: { type: string; creatorId: string | null; payload: unknown; createdAt: Date }) => { + const payload = (row.payload ?? {}) as Record; + return { + type: row.type === 'KEY_BOUGHT' ? 'buy' : 'sell', + creator_id: row.creatorId ?? '', + creator_handle: row.creatorId ? (handleMap.get(row.creatorId) ?? null) : null, + amount: payload.amount ?? null, + price_at_trade: payload.price_at_trade ?? null, + fee_paid: payload.fee_paid ?? null, + ledger_sequence: payload.ledger_sequence != null ? Number(payload.ledger_sequence) : null, + timestamp: row.createdAt, + }; + }); + + return [items, total]; +} diff --git a/src/modules/wallets/wallets.routes.ts b/src/modules/wallets/wallets.routes.ts new file mode 100644 index 0000000..69db5c3 --- /dev/null +++ b/src/modules/wallets/wallets.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import { httpGetWalletActivity } from './wallet-activity.controllers'; +import { cacheControl } from '../../middlewares/cache-control.middleware'; +import { ACTIVITY_FEED_CACHE_PRESET } from '../../constants/activity-feed-cache.constants'; + +const walletsRouter = Router(); + +/** + * GET /api/v1/wallets/:address/activity + * + * Returns the paginated trade history (buys and sells) for a given Stellar + * wallet address across all creators. Supports optional `type` (buy|sell) + * and `creator_id` filters. + */ +walletsRouter.get( + '/:address/activity', + cacheControl(ACTIVITY_FEED_CACHE_PRESET), + httpGetWalletActivity +); + +export default walletsRouter;