diff --git a/src/modules/index.ts b/src/modules/index.ts index d29781d..9696cc5 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -13,6 +13,7 @@ import subscriptionRouter from './subscriptions/subscription.routes'; import webhookRouter from './webhooks/webhook.router'; import walletsRouter from './wallets/wallets.routes'; import alertsRouter from './alerts/alert.router'; +import tradingRouter from './trading/multi-buy.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; import { routeBodySizeLimit } from '../middlewares/body-size-limit.middleware'; @@ -36,5 +37,6 @@ router.use('/subscriptions', routeBodySizeLimit('default'), subscriptionRouter); router.use(CREATORS_BASE, routeBodySizeLimit('creators'), webhookRouter); router.use('/wallets', routeBodySizeLimit('default'), walletsRouter); router.use('/alerts', routeBodySizeLimit('default'), alertsRouter); +router.use('/trading', routeBodySizeLimit('default'), tradingRouter); export default router; diff --git a/src/modules/trading/multi-buy.controllers.ts b/src/modules/trading/multi-buy.controllers.ts new file mode 100644 index 0000000..7b11f09 --- /dev/null +++ b/src/modules/trading/multi-buy.controllers.ts @@ -0,0 +1,103 @@ +import { AsyncController } from '../../types/auth.types'; +import { MultiBuyRequestSchema } from './multi-buy.schemas'; +import { executeMultiBuy, MultiBuyError } from './multi-buy.service'; +import { + sendSuccess, + sendValidationError, + sendError, + zodIssuesToDetails, + ErrorCode, +} from '../../utils/api-response.utils'; +import { horizonGet } from '../../clients/horizon.client'; + +async function getCurrentLedger(): Promise { + const res = await horizonGet('/'); + const data = (await res.json()) as { + core_latest_ledger?: number; + }; + return data.core_latest_ledger ?? 0; +} + +async function getXlmBalance(address: string): Promise { + const res = await horizonGet(`/accounts/${address}`); + if (!res.ok) { + return 0n; + } + const data = (await res.json()) as { + balances?: Array<{ + asset_type: string; + balance: string; + }>; + }; + const native = data.balances?.find((b) => b.asset_type === 'native'); + if (!native) return 0n; + const stroops = BigInt(Math.floor(parseFloat(native.balance) * 10_000_000)); + return stroops; +} + +async function getCreatorSupply(creatorId: string): Promise { + const { prisma } = await import('../../utils/prisma.utils'); + const aggregate = await prisma.keyOwnership.aggregate({ + where: { creatorId }, + _sum: { balance: true }, + }); + return Number(aggregate._sum.balance ?? 0); +} + +export const httpMultiBuy: AsyncController = async (req, res, next) => { + try { + const parsed = MultiBuyRequestSchema.safeParse(req.body); + if (!parsed.success) { + const firstIssue = parsed.error.issues[0]; + if (firstIssue?.message === 'legs_empty') { + sendError(res, 400, ErrorCode.BAD_REQUEST, 'legs_empty'); + return; + } + if (firstIssue?.message === 'too_many_legs') { + sendError(res, 400, ErrorCode.BAD_REQUEST, 'too_many_legs'); + return; + } + sendValidationError( + res, + 'Invalid multi-buy request', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const { buyer, legs, global_deadline_ledger } = parsed.data; + + const results = await executeMultiBuy( + buyer, + legs, + global_deadline_ledger, + { + ledger: { getCurrentLedger }, + balance: { getXlmBalance }, + supply: { getCreatorSupply }, + } + ); + + sendSuccess(res, results); + } catch (err) { + if (err instanceof MultiBuyError) { + const statusMap: Record = { + legs_empty: 400, + too_many_legs: 400, + duplicate_creator: 400, + deadline_passed: 400, + insufficient_funds: 400, + slippage_exceeded: 409, + }; + const status = statusMap[err.code] ?? 500; + sendError( + res, + status, + err.code as any, + err.message + ); + return; + } + next(err); + } +}; diff --git a/src/modules/trading/multi-buy.routes.ts b/src/modules/trading/multi-buy.routes.ts new file mode 100644 index 0000000..484a6d6 --- /dev/null +++ b/src/modules/trading/multi-buy.routes.ts @@ -0,0 +1,8 @@ +import { Router } from 'express'; +import { httpMultiBuy } from './multi-buy.controllers'; + +const tradingRouter = Router(); + +tradingRouter.post('/multi-buy', httpMultiBuy); + +export default tradingRouter; diff --git a/src/modules/trading/multi-buy.schemas.ts b/src/modules/trading/multi-buy.schemas.ts new file mode 100644 index 0000000..07bd17a --- /dev/null +++ b/src/modules/trading/multi-buy.schemas.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +const MultiBuyLegSchema = z.object({ + creator: z.string().min(1, 'Creator address is required'), + amount: z.number().int().positive('Amount must be a positive integer'), + max_price: z + .string() + .min(1, 'max_price is required') + .refine((val) => { + try { + return BigInt(val) > 0n; + } catch { + return false; + } + }, 'max_price must be a positive integer string'), +}); + +export const MultiBuyRequestSchema = z.object({ + buyer: z.string().min(1, 'Buyer address is required'), + legs: z + .array(MultiBuyLegSchema) + .min(1, 'legs_empty') + .max(10, 'too_many_legs'), + global_deadline_ledger: z + .number() + .int() + .positive('global_deadline_ledger must be a positive integer'), +}); + +export type MultiBuyLeg = z.infer; +export type MultiBuyRequest = z.infer; + +export interface MultiBuyResult { + creator: string; + amount: number; + total_cost: string; + new_supply: number; +} diff --git a/src/modules/trading/multi-buy.service.test.ts b/src/modules/trading/multi-buy.service.test.ts new file mode 100644 index 0000000..50c3a9f --- /dev/null +++ b/src/modules/trading/multi-buy.service.test.ts @@ -0,0 +1,162 @@ +import { executeMultiBuy, MultiBuyError } from './multi-buy.service'; + +jest.mock('../../utils/logger.utils', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +const DEFAULT_SUPPLY = 5; +const CURRENT_LEDGER = 1000; +const BUYER_BALANCE = 1_000_000_000_000n; + +function makeProviders(overrides: { + ledger?: number; + balance?: bigint; + supply?: number | Record; +} = {}) { + const supplyMap = + typeof overrides.supply === 'object' + ? overrides.supply + : undefined; + const flatSupply = + typeof overrides.supply === 'number' + ? overrides.supply + : DEFAULT_SUPPLY; + + return { + ledger: { + getCurrentLedger: jest + .fn() + .mockResolvedValue(overrides.ledger ?? CURRENT_LEDGER), + }, + balance: { + getXlmBalance: jest + .fn() + .mockResolvedValue(overrides.balance ?? BUYER_BALANCE), + }, + supply: { + getCreatorSupply: jest.fn().mockImplementation((creatorId: string) => { + if (supplyMap && creatorId in supplyMap) { + return Promise.resolve(supplyMap[creatorId]); + } + return Promise.resolve(flatSupply); + }), + }, + }; +} + +describe('executeMultiBuy (#717)', () => { + it('executes 3 legs all within max_price and returns correct results', async () => { + const legs = [ + { creator: 'creator-a', amount: 1, max_price: '100000000' }, + { creator: 'creator-b', amount: 1, max_price: '100000000' }, + { creator: 'creator-c', amount: 1, max_price: '100000000' }, + ]; + + const results = await executeMultiBuy( + 'buyer-1', + legs, + 2000, + makeProviders() + ); + + expect(results).toHaveLength(3); + for (let i = 0; i < 3; i++) { + expect(results[i].creator).toBe(legs[i].creator); + expect(results[i].amount).toBe(1); + expect(results[i].new_supply).toBe(DEFAULT_SUPPLY + 1); + expect(BigInt(results[i].total_cost)).toBeGreaterThan(0n); + } + }); + + it('rolls back all legs when the second leg exceeds max_price', async () => { + const legs = [ + { creator: 'creator-a', amount: 1, max_price: '100000000' }, + { creator: 'creator-b', amount: 1, max_price: '1' }, + { creator: 'creator-c', amount: 1, max_price: '100000000' }, + ]; + + await expect( + executeMultiBuy('buyer-1', legs, 2000, makeProviders()) + ).rejects.toThrow(MultiBuyError); + + try { + await executeMultiBuy('buyer-1', legs, 2000, makeProviders()); + } catch (err) { + expect((err as MultiBuyError).code).toBe('slippage_exceeded'); + } + }); + + it('rejects duplicate creator in legs', async () => { + const legs = [ + { creator: 'creator-a', amount: 1, max_price: '100000000' }, + { creator: 'creator-a', amount: 2, max_price: '100000000' }, + ]; + + try { + await executeMultiBuy('buyer-1', legs, 2000, makeProviders()); + fail('Expected MultiBuyError'); + } catch (err) { + expect(err).toBeInstanceOf(MultiBuyError); + expect((err as MultiBuyError).code).toBe('duplicate_creator'); + } + }); + + it('rejects legs vector with more than 10 entries', async () => { + const legs = Array.from({ length: 11 }, (_, i) => ({ + creator: `creator-${i}`, + amount: 1, + max_price: '100000000', + })); + + try { + await executeMultiBuy('buyer-1', legs, 2000, makeProviders()); + fail('Expected MultiBuyError'); + } catch (err) { + expect(err).toBeInstanceOf(MultiBuyError); + expect((err as MultiBuyError).code).toBe('too_many_legs'); + } + }); + + it('rejects when global_deadline_ledger is in the past', async () => { + const legs = [ + { creator: 'creator-a', amount: 1, max_price: '100000000' }, + ]; + + try { + await executeMultiBuy( + 'buyer-1', + legs, + 500, + makeProviders({ ledger: CURRENT_LEDGER }) + ); + fail('Expected MultiBuyError'); + } catch (err) { + expect(err).toBeInstanceOf(MultiBuyError); + expect((err as MultiBuyError).code).toBe('deadline_passed'); + } + }); + + it('rejects when buyer balance is insufficient for worst-case cost', async () => { + const legs = [ + { creator: 'creator-a', amount: 1, max_price: '100000000' }, + ]; + + try { + await executeMultiBuy( + 'buyer-1', + legs, + 2000, + makeProviders({ balance: 1n }) + ); + fail('Expected MultiBuyError'); + } catch (err) { + expect(err).toBeInstanceOf(MultiBuyError); + expect((err as MultiBuyError).code).toBe('insufficient_funds'); + } + }); +}); diff --git a/src/modules/trading/multi-buy.service.ts b/src/modules/trading/multi-buy.service.ts new file mode 100644 index 0000000..6cf609f --- /dev/null +++ b/src/modules/trading/multi-buy.service.ts @@ -0,0 +1,135 @@ +import { logger } from '../../utils/logger.utils'; +import { computeBuyCost } from '../../utils/pricing.utils'; +import { MultiBuyLeg, MultiBuyResult } from './multi-buy.schemas'; + +const PROTOCOL_FEE_BPS = 500; + +export class MultiBuyError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message); + this.name = 'MultiBuyError'; + } +} + +interface LedgerProvider { + getCurrentLedger(): Promise; +} + +interface BalanceProvider { + getXlmBalance(address: string): Promise; +} + +interface SupplyProvider { + getCreatorSupply(creatorId: string): Promise; +} + +export async function executeMultiBuy( + buyer: string, + legs: MultiBuyLeg[], + globalDeadlineLedger: number, + providers: { + ledger: LedgerProvider; + balance: BalanceProvider; + supply: SupplyProvider; + } +): Promise { + if (legs.length === 0) { + throw new MultiBuyError('legs_empty', 'Legs vector must not be empty'); + } + + if (legs.length > 10) { + throw new MultiBuyError( + 'too_many_legs', + 'Legs vector must not exceed 10 entries' + ); + } + + const creatorSet = new Set(); + for (const leg of legs) { + if (creatorSet.has(leg.creator)) { + throw new MultiBuyError( + 'duplicate_creator', + `Duplicate creator in legs: ${leg.creator}` + ); + } + creatorSet.add(leg.creator); + } + + const currentLedger = await providers.ledger.getCurrentLedger(); + if (currentLedger > globalDeadlineLedger) { + throw new MultiBuyError( + 'deadline_passed', + `Current ledger ${currentLedger} exceeds deadline ${globalDeadlineLedger}` + ); + } + + let worstCaseTotal = 0n; + for (const leg of legs) { + worstCaseTotal += BigInt(leg.amount) * BigInt(leg.max_price); + } + + const buyerBalance = await providers.balance.getXlmBalance(buyer); + if (buyerBalance < worstCaseTotal) { + throw new MultiBuyError( + 'insufficient_funds', + `Buyer balance ${buyerBalance} is less than worst-case cost ${worstCaseTotal}` + ); + } + + const results: MultiBuyResult[] = []; + let totalCostAllLegs = 0n; + + for (const leg of legs) { + const currentSupply = await providers.supply.getCreatorSupply( + leg.creator + ); + const cost = computeBuyCost(currentSupply, leg.amount, PROTOCOL_FEE_BPS); + const maxAllowed = BigInt(leg.max_price) * BigInt(leg.amount); + + if (cost > maxAllowed) { + throw new MultiBuyError( + 'slippage_exceeded', + `Cost ${cost} for creator ${leg.creator} exceeds max_price ${leg.max_price} * ${leg.amount} = ${maxAllowed}` + ); + } + + const newSupply = currentSupply + leg.amount; + totalCostAllLegs += cost; + + logger.debug( + { + event: 'key_purchased', + buyer, + creator: leg.creator, + amount: leg.amount, + total_cost: cost.toString(), + new_supply: newSupply, + ledger: currentLedger, + }, + 'Key purchased via multi-buy' + ); + + results.push({ + creator: leg.creator, + amount: leg.amount, + total_cost: cost.toString(), + new_supply: newSupply, + }); + } + + logger.debug( + { + event: 'multi_buy_completed', + buyer, + leg_count: results.length, + total_cost: totalCostAllLegs.toString(), + ledger: currentLedger, + }, + 'MultiBuyCompleted' + ); + + return results; +}