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
2 changes: 1 addition & 1 deletion prisma/schema/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
generator client {
provider = "prisma-client-js"
output = "../../node_modules/.prisma/client"
previewFeatures = ["prismaSchemaFolder"]
previewFeatures = ["prismaSchemaFolder", "metrics"]
}

datasource db {
Expand Down
2 changes: 2 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export const envSchema = z
.positive()
.default(300000),
SLOW_QUERY_THRESHOLD_MS: z.coerce.number().int().positive().default(500),
DB_POOL_WAIT_WARN_MS: z.coerce.number().int().positive().default(500),
DB_POOL_WAIT_ERROR_MS: z.coerce.number().int().positive().default(2000),
CREATOR_LIST_SLOW_QUERY_THRESHOLD_MS: z.coerce
.number()
.int()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ import { HoldingEntry } from '../wallet-holdings.schemas';
const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
const MALFORMED_ADDRESS = 'not-a-stellar-address';

function makeReq(params: Record<string, string> = {}): any {
return { params };
function makeReq(params: Record<string, string> = {}, query: Record<string, any> = {}): any {
return { params, query };
}

function makeRes(): any {
Expand Down Expand Up @@ -47,7 +47,7 @@ describe('GET /wallets/:address/holdings', () => {
jest.restoreAllMocks();
});

it('returns 200 with items and total for a wallet with holdings', async () => {
it('returns 200 with items and meta for a wallet with holdings', async () => {
const holdings: HoldingEntry[] = [
makeHolding({ creator_id: 'creator-1', creator_handle: 'alice', key_count: '5' }),
makeHolding({ creator_id: 'creator-2', creator_handle: 'bob', key_count: '3' }),
Expand All @@ -62,7 +62,7 @@ describe('GET /wallets/:address/holdings', () => {
const body = res.json.mock.calls[0][0];
expect(body.success).toBe(true);
expect(body.data.items).toHaveLength(2);
expect(body.data.total).toBe(2);
expect(body.data.meta.total).toBe(2);
});

it('each holding includes required fields', async () => {
Expand Down Expand Up @@ -98,7 +98,7 @@ describe('GET /wallets/:address/holdings', () => {
expect(res.status).toHaveBeenCalledWith(200);
const body = res.json.mock.calls[0][0];
expect(body.data.items).toEqual([]);
expect(body.data.total).toBe(0);
expect(body.data.meta.total).toBe(0);
});

it('returns 400 for a malformed Stellar address', async () => {
Expand Down
102 changes: 102 additions & 0 deletions src/modules/wallets/wallet-holdings-pagination.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import supertest from 'supertest';
import app from '../../app';
import { prisma } from '../../utils/prisma.utils';

const PAGE_SIZE = 20;
const TOTAL_HOLDINGS = 50;
const TEST_WALLET_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

describe('GET /api/v1/wallets/:address/holdings pagination', () => {
let creatorIds: string[] = [];

beforeAll(async () => {
// Create user
const user = await prisma.user.create({
data: {
id: 'wallet-holdings-pag-test-user',
email: 'wallet-holdings-pag@example.com',
passwordHash: 'dummy-hash',
firstName: 'Wallet',
lastName: 'HoldingsPagTest',
},
});

// Create creators
const creators = await Promise.all(
Array.from({ length: TOTAL_HOLDINGS }).map((_, i) =>
prisma.creatorProfile.create({
data: {
userId: user.id,
handle: `creator-${i}`,
displayName: `Creator ${i}`,
},
})
)
);
creatorIds = creators.map((c) => c.id);

// Create key ownerships for the test wallet
await prisma.keyOwnership.createMany({
data: creatorIds.map((creatorId, i) => ({
ownerAddress: TEST_WALLET_ADDRESS,
creatorId: creatorId,
balance: TOTAL_HOLDINGS - i,
createdAt: new Date(`2026-06-${String((i % 28) + 1).padStart(2, '0')}T00:00:00.000Z`),
})),
});
});

afterAll(async () => {
// Cleanup
await prisma.keyOwnership.deleteMany({
where: { ownerAddress: TEST_WALLET_ADDRESS },
});
await prisma.creatorProfile.deleteMany({
where: { id: { in: creatorIds } },
});
await prisma.user.delete({
where: { id: 'wallet-holdings-pag-test-user' },
});
await prisma.$disconnect();
});

it('paginates correctly across multiple pages with no duplicates and all items present', async () => {
const allPageItems: string[][] = [];
let offset = 0;
let hasMore = true;

while (hasMore) {
const res = await supertest(app)
.get(`/api/v1/wallets/${TEST_WALLET_ADDRESS}/holdings?limit=${PAGE_SIZE}&offset=${offset}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);

const items = res.body.data.items;
const meta = res.body.data.meta;

allPageItems.push(items.map((item: any) => item.creator_id));
hasMore = meta.hasMore;
offset += items.length;
}

const seenAcrossAllPages = allPageItems.flat();
expect(new Set(seenAcrossAllPages).size).toBe(TOTAL_HOLDINGS);
expect(seenAcrossAllPages.length).toBe(TOTAL_HOLDINGS);

// Check no duplicates between pages
const page1 = allPageItems[0];
const page2 = allPageItems[1];
const overlap = page1.filter((id) => page2.includes(id));
expect(overlap.length).toBe(0);
});

it('final page returns hasMore: false', async () => {
const res = await supertest(app)
.get(`/api/v1/wallets/${TEST_WALLET_ADDRESS}/holdings?limit=${PAGE_SIZE}&offset=${40}`);

expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.data.meta.hasMore).toBe(false);
});
});
29 changes: 26 additions & 3 deletions src/modules/wallets/wallet-holdings.controllers.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Request, Response, NextFunction } from 'express';
import { WalletHoldingsParamsSchema } from './wallet-holdings.schemas';
import { WalletHoldingsParamsSchema, WalletHoldingsQuerySchema } from './wallet-holdings.schemas';
import { fetchWalletHoldings } from './wallet-holdings.service';
import { sendSuccess, sendValidationError } from '../../utils/api-response.utils';
import { buildOffsetPaginationMeta } from '../../utils/pagination.utils';

export async function httpGetWalletHoldings(
req: Request,
Expand All @@ -22,9 +23,31 @@ export async function httpGetWalletHoldings(
return;
}

const [items, total] = await fetchWalletHoldings(parsedParams.data.address);
const parsedQuery = WalletHoldingsQuerySchema.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 fetchWalletHoldings(
parsedParams.data.address,
parsedQuery.data
);

const meta = buildOffsetPaginationMeta({
limit: parsedQuery.data.limit,
offset: parsedQuery.data.offset,
total,
});

sendSuccess(res, { items, total });
sendSuccess(res, { items, meta });
} catch (error) {
next(error);
}
Expand Down
21 changes: 21 additions & 0 deletions src/modules/wallets/wallet-holdings.schemas.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
import { z } from 'zod';
import { StellarAddressSchema } from '../wallet/wallet.schemas';
import { safeIntParam } from '../../utils/query.utils';
import { MIN_PAGE_SIZE, MAX_PAGE_SIZE } from '../../constants/pagination.constants';
import { PUBLIC_OFFSET_PAGINATION_DEFAULTS } from '../../utils/public-list-query-defaults';

export const WalletHoldingsParamsSchema = z.object({
address: StellarAddressSchema,
});

export const WalletHoldingsQuerySchema = 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',
}),
})
.strict();

export type WalletHoldingsParamsType = z.infer<typeof WalletHoldingsParamsSchema>;
export type WalletHoldingsQueryType = z.infer<typeof WalletHoldingsQuerySchema>;

export const HoldingEntrySchema = z.object({
creator_id: z.string(),
Expand Down
12 changes: 9 additions & 3 deletions src/modules/wallets/wallet-holdings.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { prisma } from '../../utils/prisma.utils';
import { isValidStellarAddress } from '../wallet/wallet.utils';
import { HoldingEntry } from './wallet-holdings.schemas';
import { HoldingEntry, WalletHoldingsQueryType } from './wallet-holdings.schemas';

/**
* Fetches all creator key holdings for a given Stellar wallet address.
Expand All @@ -14,7 +14,8 @@ import { HoldingEntry } from './wallet-holdings.schemas';
* - total_value (null — not calculated server-side; consumers derive it from key_count * current_price)
*/
export async function fetchWalletHoldings(
address: string
address: string,
query?: WalletHoldingsQueryType
): Promise<[HoldingEntry[], number]> {
if (!isValidStellarAddress(address)) {
const err = Object.assign(
Expand Down Expand Up @@ -83,5 +84,10 @@ export async function fetchWalletHoldings(
return valB - valA;
});

return [items, total];
// Apply pagination
const limit = query?.limit ?? 20;
const offset = query?.offset ?? 0;
const paginatedItems = items.slice(offset, offset + limit);

return [paginatedItems, total];
}
15 changes: 15 additions & 0 deletions src/modules/webhooks/webhook.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ async function attemptDelivery(
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const startTime = Date.now();

const response = await fetch(callbackUrl, {
method: 'POST',
Expand All @@ -129,13 +130,27 @@ async function attemptDelivery(
signal: controller.signal,
});

const endTime = Date.now();
const responseTimeMs = endTime - startTime;
clearTimeout(timeout);

if (response.ok) {
await prisma.webhookEvent.updateMany({
where: { webhookId, status: 'PENDING' },
data: { status: 'DELIVERED', retryCount: attempt },
});

logger.info(
{
webhook_id: webhookId,
creator_id: payload.creator_id,
event_type: payload.event_type,
response_status: response.status,
response_time_ms: responseTimeMs,
delivered_at: new Date().toISOString(),
},
'Webhook delivery succeeded'
);
return;
}

Expand Down
Loading
Loading