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: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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;
103 changes: 103 additions & 0 deletions src/modules/trading/multi-buy.controllers.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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<bigint> {
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<number> {
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<string, number> = {
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);
}
};
8 changes: 8 additions & 0 deletions src/modules/trading/multi-buy.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
38 changes: 38 additions & 0 deletions src/modules/trading/multi-buy.schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof MultiBuyLegSchema>;
export type MultiBuyRequest = z.infer<typeof MultiBuyRequestSchema>;

export interface MultiBuyResult {
creator: string;
amount: number;
total_cost: string;
new_supply: number;
}
162 changes: 162 additions & 0 deletions src/modules/trading/multi-buy.service.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>;
} = {}) {
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');
}
});
});
Loading
Loading