From ff1dfd82118f395870437669ca7129a1e8c09e31 Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem Date: Fri, 25 Sep 2026 16:30:56 +0100 Subject: [PATCH] feat: add holding capacity precheck endpoint --- src/modules/keys/holding-capacity.test.ts | 45 ++++++++++++ src/modules/keys/keys.routes.ts | 86 +++++++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/modules/keys/holding-capacity.test.ts diff --git a/src/modules/keys/holding-capacity.test.ts b/src/modules/keys/holding-capacity.test.ts new file mode 100644 index 00000000..f06e7a46 --- /dev/null +++ b/src/modules/keys/holding-capacity.test.ts @@ -0,0 +1,45 @@ +import { calculateHoldingCapacity } from './keys.routes'; + +describe('calculateHoldingCapacity', () => { + it('allows a quantity that fits within the wallet cap', () => { + expect( + calculateHoldingCapacity({ + circulatingSupply: 1_000, + holderCapBps: 2_500, + currentHolding: 100, + quantity: 150, + }) + ).toEqual({ + allowed: true, + current_holding: 100, + maximum_holding: 250, + remaining_capacity: 150, + }); + }); + + it('rejects a quantity above the remaining capacity', () => { + expect( + calculateHoldingCapacity({ + circulatingSupply: 1_000, + holderCapBps: 2_500, + currentHolding: 100, + quantity: 150.01, + }).allowed + ).toBe(false); + }); + + it('returns no remaining capacity when a legacy holding exceeds the cap', () => { + expect( + calculateHoldingCapacity({ + circulatingSupply: 100, + holderCapBps: 1_000, + currentHolding: 20, + quantity: 1, + }) + ).toMatchObject({ + allowed: false, + maximum_holding: 10, + remaining_capacity: 0, + }); + }); +}); diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 13c0644c..c4c3151e 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -83,6 +83,39 @@ const walletQuerySchema = z.object({ wallet: StellarAddressSchema, }); +const holdingCapacityQuerySchema = walletQuerySchema.extend({ + quantity: z.coerce.number().finite().positive(), +}); + +/** + * Calculate whether a requested buy fits within a creator's per-wallet cap. + * The cap is stored in basis points of circulating supply (10_000 = 100%). + */ +export function calculateHoldingCapacity(input: { + circulatingSupply: number; + holderCapBps: number; + currentHolding: number; + quantity: number; +}): { + allowed: boolean; + remaining_capacity: number; + current_holding: number; + maximum_holding: number; +} { + const maximumHolding = Math.max( + 0, + input.circulatingSupply * (input.holderCapBps / 10_000) + ); + const remainingCapacity = Math.max(0, maximumHolding - input.currentHolding); + + return { + allowed: input.quantity <= remainingCapacity, + remaining_capacity: remainingCapacity, + current_holding: input.currentHolding, + maximum_holding: maximumHolding, + }; +} + const router = Router(); /** @@ -353,6 +386,59 @@ router.get('/:keyId/supply', async (req, res, next) => { } }); +/** + * GET /api/v1/keys/:keyId/holding-capacity?wallet=&quantity= + * + * Public pre-flight check for buy clients. This is intentionally read-only + * and does not require authentication; the wallet address is validated and + * the response is derived from the latest ownership read model and key cap. + */ +router.get('/:keyId/holding-capacity', async (req, res, next) => { + const parsed = holdingCapacityQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid holding capacity query', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + try { + const keyId = String(req.params.keyId); + const creator = await prisma.creatorProfile.findFirst({ + where: { OR: [{ id: keyId }, { handle: keyId }] }, + select: { id: true, circulatingSupply: true, holderCapBps: true }, + }); + if (!creator) { + sendNotFound(res, 'Key'); + return; + } + + const ownership = await prisma.keyOwnership.findUnique({ + where: { + ownerAddress_creatorId: { + ownerAddress: parsed.data.wallet, + creatorId: creator.id, + }, + }, + select: { balance: true }, + }); + + sendSuccess( + res, + calculateHoldingCapacity({ + circulatingSupply: Number(creator.circulatingSupply), + holderCapBps: creator.holderCapBps, + currentHolding: Number(ownership?.balance ?? 0), + quantity: parsed.data.quantity, + }) + ); + } catch (error) { + next(error); + } +}); + /** * GET /api/v1/keys/:keyId/freeze-status?wallet= * Frozen and liquid balance for a holder on a key.