diff --git a/src/modules/keys/holding-capacity.test.ts b/src/modules/keys/holding-capacity.test.ts new file mode 100644 index 0000000..f06e7a4 --- /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/key-holding-capacity.service.test.ts b/src/modules/keys/key-holding-capacity.service.test.ts index 0a15e74..daa9644 100644 --- a/src/modules/keys/key-holding-capacity.service.test.ts +++ b/src/modules/keys/key-holding-capacity.service.test.ts @@ -101,6 +101,18 @@ describe('key-holding-capacity.service', () => { }); }); + it('reports whether a requested buy fits within the remaining capacity', async () => { + mockKey(); + mockPrisma.keyOwnership.findUnique.mockResolvedValue({ balance: '100' }); + + await expect(getKeyHoldingCapacity('key-1', WALLET, 150)).resolves.toMatchObject({ + allowed: true, + current_holding: 100, + maximum_holding: 250, + remaining_capacity: 150, + }); + }); + it('treats a wallet with no position as zero holding', async () => { mockKey(); mockPrisma.keyOwnership.findUnique.mockResolvedValue(null); diff --git a/src/modules/keys/key-holding-capacity.service.ts b/src/modules/keys/key-holding-capacity.service.ts index 3905b3b..7dba47a 100644 --- a/src/modules/keys/key-holding-capacity.service.ts +++ b/src/modules/keys/key-holding-capacity.service.ts @@ -16,6 +16,9 @@ export interface KeyHoldingCapacity { cap: number; remaining: number; cap_percentage: number; + allowed?: boolean; + remaining_capacity?: number; + maximum_holding?: number; } /** @@ -51,9 +54,12 @@ export function computeCapPercentage(holding: number, cap: number): number { */ export async function getKeyHoldingCapacity( keyId: string, - walletAddress: string + walletAddress: string, + quantity?: number ): Promise { - const cacheKey = `keys:holding-capacity:${keyId}:${walletAddress}`; + const cacheKey = `keys:holding-capacity:${keyId}:${walletAddress}${ + quantity === undefined ? '' : `:${quantity}` + }`; const cached = await cacheGetJson(cacheKey); if (cached) { return cached; @@ -98,6 +104,12 @@ export async function getKeyHoldingCapacity( cap_percentage: computeCapPercentage(currentHolding, cap), }; + if (quantity !== undefined) { + capacity.allowed = quantity <= capacity.remaining; + capacity.remaining_capacity = capacity.remaining; + capacity.maximum_holding = capacity.cap; + } + await cacheSetJson(cacheKey, capacity, HOLDING_CAPACITY_CACHE_TTL_SECONDS); return capacity; } diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index ffdde93..1eed0e9 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -158,11 +158,47 @@ const walletQuerySchema = z.object({ wallet: StellarAddressSchema, }); +const holdingCapacityQuerySchema = walletQuerySchema.extend({ + quantity: z.coerce.number().finite().positive().optional(), +}); + +/** + * Calculate whether a requested buy fits within a creator's per-wallet cap. + * Kept as a pure helper for callers and regression tests that use the legacy + * basis-point cap representation. + */ +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 lpHistoryQuerySchema = z.object({ limit: z.coerce.number().int().positive().max(100).optional().default(20), cursor: z.string().min(1).optional(), }); - const priceImpactQuerySchema = z.object({ quantity: z.string().transform(v => { const num = parseInt(v, 10); @@ -1289,11 +1325,11 @@ router.get('/:keyId/cooldown', async (req, res, next) => { */ /** - * GET /api/v1/keys/:keyId/holding-capacity?wallet= - * Wallet holding, holder cap, and remaining purchase capacity on a key. + * GET /api/v1/keys/:keyId/holding-capacity?wallet=&quantity= + * Wallet holding, holder cap, and optional buy pre-check on a key. */ router.get('/:keyId/holding-capacity', async (req, res, next) => { - const parsed = walletQuerySchema.safeParse(req.query); + const parsed = holdingCapacityQuerySchema.safeParse(req.query); if (!parsed.success) { sendValidationError( res, @@ -1307,7 +1343,8 @@ router.get('/:keyId/holding-capacity', async (req, res, next) => { res, await getKeyHoldingCapacity( String(req.params.keyId), - parsed.data.wallet + parsed.data.wallet, + parsed.data.quantity ) ); } catch (error) {