From 3f79f06e30137867f13da756d731776bb67d6b60 Mon Sep 17 00:00:00 2001 From: Zainab Wahab Date: Mon, 19 Oct 2026 11:47:47 +0100 Subject: [PATCH 1/2] feat: implement auto-invest rules and execution (#1031) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add auto-invest rule management API and background execution flow. - POST /auto-invest/rules — create a rule (category, minRating, maxPrice, maxPerKey) owned by the authenticated wallet (resolved from JWT, never client-supplied) - GET /auto-invest/rules?wallet= — list active rules for the authorized wallet - PATCH /auto-invest/rules/:id — partial update / toggle active state - DELETE /auto-invest/rules/:id — delete a rule after ownership check - Background listener on key_registered evaluates each new listing against eligible active rules (category, min rating, max price, maxPerKey) - Balance re-check via buyGateway before each purchase; insufficient balance skips without emitting a success event - Purchases reuse buyGateway.submitBuy; AutoInvestExecuted event emitted only after purchase success - Idempotency via unique (ruleId, listingId) constraint on AutoInvestExecution to prevent duplicate purchases from duplicate/concurrent events - New AutoInvestRule and AutoInvestExecution models + migration - category and rating fields added to CreatorProfile - Unit + integration tests for CRUD and execution flows --- prisma/schema/auto-invest.prisma | 60 ++ prisma/schema/creator.prisma | 4 + .../migration.sql | 46 ++ .../auto-invest.controllers.test.ts | 256 +++++++++ .../auto-invest/auto-invest.controllers.ts | 218 +++++++ src/modules/auto-invest/auto-invest.events.ts | 33 ++ src/modules/auto-invest/auto-invest.routes.ts | 61 ++ .../auto-invest/auto-invest.schemas.ts | 141 +++++ .../auto-invest/auto-invest.service.test.ts | 434 ++++++++++++++ .../auto-invest/auto-invest.service.ts | 533 ++++++++++++++++++ src/modules/index.ts | 2 + src/server.ts | 6 + 12 files changed, 1794 insertions(+) create mode 100644 prisma/schema/auto-invest.prisma create mode 100644 prisma/schema/migrations/20260926000000_add_auto_invest_rules/migration.sql create mode 100644 src/modules/auto-invest/auto-invest.controllers.test.ts create mode 100644 src/modules/auto-invest/auto-invest.controllers.ts create mode 100644 src/modules/auto-invest/auto-invest.events.ts create mode 100644 src/modules/auto-invest/auto-invest.routes.ts create mode 100644 src/modules/auto-invest/auto-invest.schemas.ts create mode 100644 src/modules/auto-invest/auto-invest.service.test.ts create mode 100644 src/modules/auto-invest/auto-invest.service.ts diff --git a/prisma/schema/auto-invest.prisma b/prisma/schema/auto-invest.prisma new file mode 100644 index 00000000..da6b478f --- /dev/null +++ b/prisma/schema/auto-invest.prisma @@ -0,0 +1,60 @@ +// prisma/schema/auto-invest.prisma +// Auto-invest rule management and execution schema for issue #1031. +// +// An AutoInvestRule lets a wallet automatically buy a newly listed creator key +// when it matches the rule's category, minimum rating, and maximum price. +// AutoInvestExecution records each execution attempt idempotently so the same +// rule can purchase a given listing at most once. + +/** + * A user-configured auto-invest rule, owned by a Stellar wallet. + * + * The rule is associated with the caller's wallet address resolved from the + * authenticated JWT; clients never supply a wallet identifier directly. + */ +model AutoInvestRule { + id String @id @default(cuid()) + /// Stellar wallet address that owns this rule (resolved from JWT, never + /// trusted from the client). + walletAddress String + /// Creator key category this rule matches (e.g. "music", "gaming"). + category String + /// Minimum key rating required to match (0.00-5.00). Null means no rating + /// floor; keys with no rating never match a rule that sets minRating. + minRating Decimal? @db.Decimal(3, 2) + /// Maximum price per key in XLM the rule will pay (7 decimal places). + maxPrice Decimal @db.Decimal(20, 7) + /// Maximum number of keys to purchase under this rule for a single listing. + maxPerKey Int @default(1) + /// Whether the rule is eligible for execution. Inactive rules are skipped. + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([walletAddress]) + @@index([walletAddress, isActive]) + @@map("auto_invest_rules") +} + +/** + * Record of a single auto-invest execution attempt for a rule + listing. + * + * The unique constraint on (ruleId, listingId) is the idempotency key that + * prevents duplicate purchases for the same rule and listing across retries + * and duplicate listing events. + */ +model AutoInvestExecution { + id String @id @default(cuid()) + ruleId String + /// Creator key / listing identifier (CreatorProfile.id) that was evaluated. + listingId String + status String @default("PENDING") // PENDING | EXECUTED | FAILED | SKIPPED_INSUFFICIENT_BALANCE + transactionHash String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([ruleId, listingId]) + @@index([ruleId]) + @@index([listingId]) + @@map("auto_invest_executions") +} diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index 29ff1e1a..9e0e8a1c 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -33,6 +33,10 @@ model CreatorProfile { buybackExpiresAt DateTime? /// Buy cooldown between purchases by the same wallet, in ledgers (0 = no cooldown). cooldownLedgers Int @default(0) + /// Key category used by auto-invest rules for matching (optional). + category String? + /// Key rating used by auto-invest rules for matching, 0.00-5.00 (optional). + rating Decimal? @db.Decimal(3, 2) perks Json? followersCount Int @default(0) createdAt DateTime @default(now()) diff --git a/prisma/schema/migrations/20260926000000_add_auto_invest_rules/migration.sql b/prisma/schema/migrations/20260926000000_add_auto_invest_rules/migration.sql new file mode 100644 index 00000000..dff552a2 --- /dev/null +++ b/prisma/schema/migrations/20260926000000_add_auto_invest_rules/migration.sql @@ -0,0 +1,46 @@ +-- Auto-invest rule management and execution (issue #1031). +-- +-- Adds category and rating to CreatorProfile so auto-invest rules can match +-- newly listed keys against category, minimum rating, and maximum price. +-- Creates the AutoInvestRule and AutoInvestExecution tables. The execution +-- table's unique (ruleId, listingId) constraint is the idempotency key that +-- prevents duplicate purchases for the same rule and listing. + +-- 1. Category and rating on creator keys. +ALTER TABLE "CreatorProfile" ADD COLUMN "category" TEXT; +ALTER TABLE "CreatorProfile" ADD COLUMN "rating" DECIMAL(3,2); + +-- 2. Auto-invest rules, owned by a Stellar wallet address. +CREATE TABLE "AutoInvestRule" ( + "id" TEXT NOT NULL, + "walletAddress" TEXT NOT NULL, + "category" TEXT NOT NULL, + "minRating" DECIMAL(3,2), + "maxPrice" DECIMAL(20,7) NOT NULL, + "maxPerKey" INTEGER NOT NULL DEFAULT 1, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutoInvestRule_pkey" PRIMARY KEY ("id") +); + +CREATE INDEX "AutoInvestRule_walletAddress_idx" ON "AutoInvestRule"("walletAddress"); +CREATE INDEX "AutoInvestRule_walletAddress_isActive_idx" ON "AutoInvestRule"("walletAddress", "isActive"); + +-- 3. Auto-invest execution records (idempotency key: ruleId + listingId). +CREATE TABLE "AutoInvestExecution" ( + "id" TEXT NOT NULL, + "ruleId" TEXT NOT NULL, + "listingId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'PENDING', + "transactionHash" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AutoInvestExecution_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "AutoInvestExecution_ruleId_listingId_key" ON "AutoInvestExecution"("ruleId", "listingId"); +CREATE INDEX "AutoInvestExecution_ruleId_idx" ON "AutoInvestExecution"("ruleId"); +CREATE INDEX "AutoInvestExecution_listingId_idx" ON "AutoInvestExecution"("listingId"); diff --git a/src/modules/auto-invest/auto-invest.controllers.test.ts b/src/modules/auto-invest/auto-invest.controllers.test.ts new file mode 100644 index 00000000..b4fe237b --- /dev/null +++ b/src/modules/auto-invest/auto-invest.controllers.test.ts @@ -0,0 +1,256 @@ +import { AuthenticatedRequest } from '../../middlewares/jwt-auth.middleware'; +import { + createAutoInvestRule, + deleteAutoInvestRule, + evaluateListing, + getAutoInvestRule, + listActiveAutoInvestRules, + updateAutoInvestRule, +} from './auto-invest.service'; +import { + httpCreateAutoInvestRule, + httpDeleteAutoInvestRule, + httpEvaluateListing, + httpListAutoInvestRules, + httpUpdateAutoInvestRule, +} from './auto-invest.controllers'; + +jest.mock('./auto-invest.service', () => ({ + createAutoInvestRule: jest.fn(), + deleteAutoInvestRule: jest.fn(), + evaluateListing: jest.fn(), + getAutoInvestRule: jest.fn(), + listActiveAutoInvestRules: jest.fn(), + updateAutoInvestRule: jest.fn(), +})); + +const WALLET = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASH'; + +function makeReq( + userWallet: string = WALLET, + overrides: { query?: any; body?: any; params?: any } = {} +): AuthenticatedRequest { + return { + user: { wallet: userWallet }, + query: overrides.query ?? {}, + body: overrides.body ?? {}, + params: overrides.params ?? {}, + } as unknown as AuthenticatedRequest; +} + +const OTHER_WALLET = 'G' + 'A'.repeat(54) + 'B'; +const RULE_ID = 'caaaaaaaaaaaaaaaaaaaaaaaa'; + +function makeRes() { + const res: any = { + statusCode: 200, + status: jest.fn(function (this: any, code: number) { + this.statusCode = code; + return this; + }), + json: jest.fn().mockReturnThis(), + }; + return res; +} + +describe('auto-invest controllers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('httpCreateAutoInvestRule', () => { + it('creates a rule and returns 201', async () => { + const req = makeReq(WALLET, { + body: { category: 'music', minRating: null, maxPrice: '5', maxPerKey: 1 }, + }); + const res = makeRes(); + const next = jest.fn(); + (createAutoInvestRule as jest.Mock).mockResolvedValue({ + id: 'caaaaaaaaaaaaaaaaaaaaaaaa', + walletAddress: WALLET, + category: 'music', + minRating: null, + maxPrice: '5', + maxPerKey: 1, + isActive: true, + }); + + await httpCreateAutoInvestRule(req, res, next); + + expect(createAutoInvestRule).toHaveBeenCalledWith(WALLET, { + category: 'music', + minRating: null, + maxPrice: '5', + maxPerKey: 1, + }); + expect(res.statusCode).toBe(201); + expect(next).not.toHaveBeenCalled(); + }); + + it('returns 400 on invalid fields', async () => { + const req = makeReq(WALLET, { body: { category: '', maxPrice: 'abc' } }); + const res = makeRes(); + const next = jest.fn(); + + await httpCreateAutoInvestRule(req, res, next); + + expect(res.statusCode).toBe(400); + expect(createAutoInvestRule).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + }); + + describe('httpListAutoInvestRules', () => { + it('lists active rules for the authorized wallet', async () => { + const req = makeReq(WALLET, { query: { wallet: WALLET } }); + const res = makeRes(); + const next = jest.fn(); + (listActiveAutoInvestRules as jest.Mock).mockResolvedValue([ + { id: 'caaaaaaaaaaaaaaaaaaaaaaaa', walletAddress: WALLET, isActive: true }, + ]); + + await httpListAutoInvestRules(req, res, next); + + expect(listActiveAutoInvestRules).toHaveBeenCalledWith(WALLET); + expect(res.statusCode).toBe(200); + expect(res.json).toHaveBeenCalled(); + }); + + it('rejects when the wallet param does not match the authenticated wallet', async () => { + const req = makeReq(WALLET, { query: { wallet: OTHER_WALLET } }); + const res = makeRes(); + const next = jest.fn(); + + await httpListAutoInvestRules(req, res, next); + + expect(res.statusCode).toBe(403); + expect(listActiveAutoInvestRules).not.toHaveBeenCalled(); + }); + + it('returns 400 when wallet query is missing', async () => { + const req = makeReq(WALLET, { query: {} }); + const res = makeRes(); + const next = jest.fn(); + + await httpListAutoInvestRules(req, res, next); + + expect(res.statusCode).toBe(400); + expect(listActiveAutoInvestRules).not.toHaveBeenCalled(); + }); + }); + + describe('httpUpdateAutoInvestRule', (): void => { + it('partially updates preserving unspecified values', async () => { + const req = makeReq(WALLET, { + params: { id: 'caaaaaaaaaaaaaaaaaaaaaaaa' }, + body: { isActive: false }, + }); + const res = makeRes(); + const next = jest.fn(); + (getAutoInvestRule as jest.Mock).mockResolvedValue({ + id: 'caaaaaaaaaaaaaaaaaaaaaaaa', + walletAddress: WALLET, + category: 'music', + minRating: null, + maxPrice: '5', + maxPerKey: 1, + isActive: true, + }); + (updateAutoInvestRule as jest.Mock).mockResolvedValue({ + id: 'caaaaaaaaaaaaaaaaaaaaaaaa', + walletAddress: WALLET, + category: 'music', + minRating: null, + maxPrice: '5', + maxPerKey: 1, + isActive: false, + }); + + await httpUpdateAutoInvestRule(req, res, next); + + expect(updateAutoInvestRule).toHaveBeenCalledWith('caaaaaaaaaaaaaaaaaaaaaaaa', WALLET, { + category: undefined, + minRating: null, + maxPrice: undefined, + maxPerKey: undefined, + isActive: false, + }); + expect(res.statusCode).toBe(200); + }); + + it('returns 404 for a rule owned by another wallet', async () => { + const req = makeReq(WALLET, { + params: { id: 'caaaaaaaaaaaaaaaaaaaaaaaa' }, + body: { isActive: false }, + }); + const res = makeRes(); + const next = jest.fn(); + (getAutoInvestRule as jest.Mock).mockResolvedValue(null); + + await httpUpdateAutoInvestRule(req, res, next); + + expect(res.statusCode).toBe(404); + expect(updateAutoInvestRule).not.toHaveBeenCalled(); + }); + + it('returns 404 for a nonexistent rule', async () => { + const req = makeReq(WALLET, { + params: { id: RULE_ID }, + body: { isActive: false }, + }); + const res = makeRes(); + const next = jest.fn(); + (getAutoInvestRule as jest.Mock).mockResolvedValue(null); + + await httpUpdateAutoInvestRule(req, res, next); + + expect(res.statusCode).toBe(404); + }); + }); + + describe('httpDeleteAutoInvestRule', () => { + it('deletes a rule owned by the wallet', async () => { + const req = makeReq(WALLET, { params: { id: 'caaaaaaaaaaaaaaaaaaaaaaaa' } }); + const res = makeRes(); + const next = jest.fn(); + (deleteAutoInvestRule as jest.Mock).mockResolvedValue(true); + + await httpDeleteAutoInvestRule(req, res, next); + + expect(deleteAutoInvestRule).toHaveBeenCalledWith('caaaaaaaaaaaaaaaaaaaaaaaa', WALLET); + expect(res.statusCode).toBe(200); + }); + + it('returns 404 for a rule not owned by the wallet', async () => { + const req = makeReq(WALLET, { params: { id: 'caaaaaaaaaaaaaaaaaaaaaaaa' } }); + const res = makeRes(); + const next = jest.fn(); + (deleteAutoInvestRule as jest.Mock).mockResolvedValue(false); + + await httpDeleteAutoInvestRule(req, res, next); + + expect(res.statusCode).toBe(404); + }); + }); + + describe('httpEvaluateListing', () => { + it('triggers evaluation and returns the result', async () => { + const req = makeReq(WALLET, { params: { listingId: 'creator-1' } }); + const res = makeRes(); + const next = jest.fn(); + (evaluateListing as jest.Mock).mockResolvedValue({ + listingId: 'creator-1', + evaluated: 1, + executed: 1, + skipped: 0, + failed: 0, + results: [], + }); + + await httpEvaluateListing(req, res, next); + + expect(evaluateListing).toHaveBeenCalledWith('creator-1'); + expect(res.statusCode).toBe(200); + }); + }); +}); diff --git a/src/modules/auto-invest/auto-invest.controllers.ts b/src/modules/auto-invest/auto-invest.controllers.ts new file mode 100644 index 00000000..50bad63a --- /dev/null +++ b/src/modules/auto-invest/auto-invest.controllers.ts @@ -0,0 +1,218 @@ +import type { NextFunction, Response } from 'express'; +import { AuthenticatedRequest } from '../../middlewares/jwt-auth.middleware'; +import { + sendSuccess, + sendNotFound, + sendForbidden, + sendValidationError, + zodIssuesToDetails, +} from '../../utils/api-response.utils'; +import { logger } from '../../utils/logger.utils'; +import { + createAutoInvestRule, + deleteAutoInvestRule, + evaluateListing, + getAutoInvestRule, + listActiveAutoInvestRules, + updateAutoInvestRule, +} from './auto-invest.service'; +import { + CreateAutoInvestRuleSchema, + UpdateAutoInvestRuleSchema, + AutoInvestRuleQuerySchema, + AutoInvestRuleParamsSchema, +} from './auto-invest.schemas'; + +/** + * Ensure the caller is authorized to act on the given wallet address. + * The wallet address is always resolved from the authenticated JWT; a + * client-supplied wallet identifier is never trusted without this check. + */ +function assertWalletOwnership( + req: AuthenticatedRequest, + res: Response, + walletAddress: string +): boolean { + if ( + !req.user || + req.user.wallet.toLowerCase() !== walletAddress.toLowerCase() + ) { + sendForbidden( + res, + 'Authenticated wallet does not own the requested wallet.' + ); + return false; + } + return true; +} + +/** + * POST /auto-invest/rules + * Create an auto-invest rule owned by the authenticated wallet. + */ +export async function httpCreateAutoInvestRule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise { + const walletAddress = req.user!.wallet; + + const parsed = CreateAutoInvestRuleSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid auto-invest rule request', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + try { + const rule = await createAutoInvestRule(walletAddress, parsed.data); + sendSuccess(res, rule, 201, 'Auto-invest rule created'); + } catch (error) { + logger.error({ error, walletAddress }, 'Create auto-invest rule failed'); + next(error); + } +} + +/** + * GET /auto-invest/rules?wallet=... + * List active auto-invest rules for the authenticated wallet. + */ +export async function httpListAutoInvestRules( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise { + const parsed = AutoInvestRuleQuerySchema.safeParse(req.query); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid query parameters', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const requestedWallet = parsed.data.wallet; + if (!assertWalletOwnership(req, res, requestedWallet)) return; + + try { + const rules = await listActiveAutoInvestRules(requestedWallet); + sendSuccess(res, rules); + } catch (error) { + logger.error( + { error, walletAddress: requestedWallet }, + 'List auto-invest rules failed' + ); + next(error); + } +} + +/** + * PATCH /auto-invest/rules/:id + * Partially update or toggle an auto-invest rule. + */ +export async function httpUpdateAutoInvestRule( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise { + const walletAddress = req.user!.wallet; + + const paramsParsed = AutoInvestRuleParamsSchema.safeParse(req.params); + if (!paramsParsed.success) { + sendValidationError( + res, + 'Invalid rule ID', + zodIssuesToDetails(paramsParsed.error.issues) + ); + return; + } + + const parsed = UpdateAutoInvestRuleSchema.safeParse(req.body); + if (!parsed.success) { + sendValidationError( + res, + 'Invalid auto-invest rule update', + zodIssuesToDetails(parsed.error.issues) + ); + return; + } + + const ruleId = paramsParsed.data.id; + + const existing = await getAutoInvestRule(ruleId, walletAddress); + if (!existing) { + sendNotFound(res, 'Auto-invest rule'); + return; + } + + try { + const rule = await updateAutoInvestRule(ruleId, walletAddress, { + category: parsed.data.category, + minRating: parsed.data.minRating ?? null, + maxPrice: parsed.data.maxPrice, + maxPerKey: parsed.data.maxPerKey, + isActive: parsed.data.isActive, + }); + sendSuccess(res, rule, 200, 'Auto-invest rule updated'); + } catch (error) { + logger.error( + { error, ruleId, walletAddress }, + 'Update auto-invest rule failed' + ); + next(error); + } +} + +/** + * DELETE /auto-invest/rules/:id + * Delete an auto-invest rule. + */ +export async function httpDeleteAutoInvestRule( + req: AuthenticatedRequest, + res: Response +): Promise { + const walletAddress = req.user!.wallet; + + const paramsParsed = AutoInvestRuleParamsSchema.safeParse(req.params); + if (!paramsParsed.success) { + sendValidationError( + res, + 'Invalid rule ID', + zodIssuesToDetails(paramsParsed.error.issues) + ); + return; + } + + const ruleId = paramsParsed.data.id; + + const deleted = await deleteAutoInvestRule(ruleId, walletAddress); + if (!deleted) { + sendNotFound(res, 'Auto-invest rule'); + return; + } + + sendSuccess(res, { id: ruleId }, 200, 'Auto-invest rule deleted'); +} + +/** + * POST /auto-invest/evaluate/:listingId + * (Operator) Trigger evaluation of a listing against auto-invest rules. + */ +export async function httpEvaluateListing( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +): Promise { + const listingId = String(req.params.listingId); + try { + const result = await evaluateListing(listingId); + sendSuccess(res, result, 200, 'Auto-invest listing evaluated'); + } catch (error) { + logger.error({ error, listingId }, 'Evaluate listing failed'); + next(error); + } +} diff --git a/src/modules/auto-invest/auto-invest.events.ts b/src/modules/auto-invest/auto-invest.events.ts new file mode 100644 index 00000000..d6ac241e --- /dev/null +++ b/src/modules/auto-invest/auto-invest.events.ts @@ -0,0 +1,33 @@ +import EventEmitter from 'events'; +import { logger } from '../../utils/logger.utils'; + +/** + * Payload published after a successful auto-invest execution. + * + * Contains only the identifiers needed to correlate the execution with its + * rule and listing, plus the actor wallet. No wallet secrets or credentials. + */ +export interface AutoInvestExecutedEventPayload { + event: 'auto_invest_executed'; + ruleId: string; + listingId: string; + walletAddress: string; + transactionHash?: string; + timestamp: string; +} + +export const autoInvestEventEmitter = new EventEmitter(); + +export function emitAutoInvestExecuted( + payload: Omit +): void { + const eventPayload: AutoInvestExecutedEventPayload = { + event: 'auto_invest_executed', + timestamp: new Date().toISOString(), + ...payload, + }; + + logger.info(eventPayload, 'Auto-invest execution event emitted'); + + autoInvestEventEmitter.emit('auto_invest_executed', eventPayload); +} diff --git a/src/modules/auto-invest/auto-invest.routes.ts b/src/modules/auto-invest/auto-invest.routes.ts new file mode 100644 index 00000000..c3979034 --- /dev/null +++ b/src/modules/auto-invest/auto-invest.routes.ts @@ -0,0 +1,61 @@ +import { Router } from 'express'; +import { + httpCreateAutoInvestRule, + httpDeleteAutoInvestRule, + httpEvaluateListing, + httpListAutoInvestRules, + httpUpdateAutoInvestRule, +} from './auto-invest.controllers'; +import { requireJwtAuth } from '../../middlewares/jwt-auth.middleware'; +import { validateBody } from '../../middlewares/validate-body.middleware'; +import { + CreateAutoInvestRuleSchema, + UpdateAutoInvestRuleSchema, +} from './auto-invest.schemas'; + +const router = Router(); + +// All auto-invest routes require an authenticated JWT session. The caller's +// wallet is resolved from the token (req.user.wallet) and used to scope rule +// ownership; clients never supply a wallet identifier directly. +router.use(requireJwtAuth); + +/** + * POST /auto-invest/rules + * Create an auto-invest rule owned by the authenticated wallet. + */ +router.post( + '/rules', + validateBody(CreateAutoInvestRuleSchema), + httpCreateAutoInvestRule +); + +/** + * GET /auto-invest/rules?wallet=... + * List active auto-invest rules for the authenticated wallet. + */ +router.get('/rules', httpListAutoInvestRules); + +/** + * PATCH /auto-invest/rules/:id + * Partially update or toggle an auto-invest rule. + */ +router.patch( + '/rules/:id', + validateBody(UpdateAutoInvestRuleSchema), + httpUpdateAutoInvestRule +); + +/** + * DELETE /auto-invest/rules/:id + * Delete an auto-invest rule. + */ +router.delete('/rules/:id', httpDeleteAutoInvestRule); + +/** + * POST /auto-invest/evaluate/:listingId + * Operator endpoint to trigger evaluation of a listing against rules. + */ +router.post('/evaluate/:listingId', httpEvaluateListing); + +export default router; diff --git a/src/modules/auto-invest/auto-invest.schemas.ts b/src/modules/auto-invest/auto-invest.schemas.ts new file mode 100644 index 00000000..21687286 --- /dev/null +++ b/src/modules/auto-invest/auto-invest.schemas.ts @@ -0,0 +1,141 @@ +import { z } from 'zod'; + +/** + * Coerce a numeric input (string or number) into a decimal string and reject + * non-finite / unsafe values. Mirrors the price handling used elsewhere in + * the codebase (amounts are strings to preserve precision). + */ +export const DecimalInput = z + .union([z.string(), z.number()]) + .transform(val => String(val).trim()) + .refine(val => val.length > 0, 'Value is required') + .refine(val => { + const n = Number(val); + return Number.isFinite(n); + }, 'Value must be a finite number'); + +/** A category label: non-empty, bounded length. */ +export const CategoryInput = z + .string() + .min(1, 'Category is required') + .max(100, 'Category must not exceed 100 characters') + .trim(); + +/** Minimum rating: optional, 0 to 5 inclusive. Null means no rating floor. */ +export const MinRatingInput = z + .union([z.string(), z.number(), z.null()]) + .optional() + .transform((val) => { + if (val === undefined || val === null || val === '') return null; + const n = Number(val); + if (!Number.isFinite(n) || n < 0 || n > 5) + throw new Error('minRating must be between 0 and 5'); + return n; + }); + +/** Maximum price per key in XLM: required, positive, 7 decimal places. */ +export const MaxPriceInput = DecimalInput.refine(val => { + const n = Number(val); + return Number.isFinite(n) && n > 0; +}, 'maxPrice must be a positive number'); + +/** Maximum keys to purchase per listing under one rule: optional, default 1. */ +export const MaxPerKeyInput = z + .union([z.string(), z.number()]) + .transform(val => { + const n = typeof val === 'number' ? val : Number(val); + if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1) { + throw new Error('maxPerKey must be a positive integer'); + } + return n; + }) + .optional() + .default(1); + +export const CreateAutoInvestRuleSchema = z.object({ + category: CategoryInput, + minRating: MinRatingInput, + maxPrice: MaxPriceInput, + maxPerKey: MaxPerKeyInput, +}); + +export type CreateAutoInvestRuleInput = z.infer< + typeof CreateAutoInvestRuleSchema +>; + +export const UpdateAutoInvestRuleSchema = + CreateAutoInvestRuleSchema.partial().extend({ + isActive: z.boolean().optional(), + }); + +export type UpdateAutoInvestRuleInput = z.infer< + typeof UpdateAutoInvestRuleSchema +>; + +export const AutoInvestRuleQuerySchema = z.object({ + wallet: z + .string() + .min(1, 'wallet query parameter is required') + .refine( + val => /^G[A-Z2-7]{55}$/.test(val), + 'wallet must be a valid Stellar address' + ), +}); + +export type AutoInvestRuleQuery = z.infer; + +export const AutoInvestRuleParamsSchema = z.object({ + id: z.string().cuid('Invalid rule ID format'), +}); + +export type AutoInvestRuleParams = z.infer; + +export interface AutoInvestRuleRow { + id: string; + walletAddress: string; + category: string; + minRating: string | null; + maxPrice: string; + maxPerKey: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +} + +/** Serializes a Prisma rule row (Decimal fields) into plain strings. */ +export function serializeRule(row: { + id: string; + walletAddress: string; + category: string; + minRating: unknown; + maxPrice: unknown; + maxPerKey: number; + isActive: boolean; + createdAt: Date; + updatedAt: Date; +}): AutoInvestRuleRow { + return { + id: row.id, + walletAddress: row.walletAddress, + category: row.category, + minRating: + row.minRating === null || row.minRating === undefined + ? null + : String(row.minRating), + maxPrice: String(row.maxPrice), + maxPerKey: row.maxPerKey, + isActive: row.isActive, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export interface AutoInvestExecutionRow { + id: string; + ruleId: string; + listingId: string; + status: string; + transactionHash: string | null; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/modules/auto-invest/auto-invest.service.test.ts b/src/modules/auto-invest/auto-invest.service.test.ts new file mode 100644 index 00000000..b7a27d34 --- /dev/null +++ b/src/modules/auto-invest/auto-invest.service.test.ts @@ -0,0 +1,434 @@ +import { prisma } from '../../utils/prisma.utils'; +import { buyGateway } from '../creator/buy.service'; +import { getKeyFees } from '../keys/key-fees.service'; +import { emitAutoInvestExecuted } from './auto-invest.events'; +import { + createAutoInvestRule, + deleteAutoInvestRule, + evaluateListing, + getAutoInvestRule, + listActiveAutoInvestRules, + updateAutoInvestRule, +} from './auto-invest.service'; + +jest.mock('../../utils/prisma.utils', () => { + const mockPrisma: any = { + autoInvestRule: { + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + autoInvestExecution: { + create: jest.fn(), + update: jest.fn(), + }, + creatorProfile: { + findUnique: jest.fn(), + }, + creatorPriceSnapshot: { + findUnique: jest.fn(), + }, + stellarWallet: { + findUnique: jest.fn(), + }, + $transaction: jest.fn(async (arg: any) => { + if (typeof arg === 'function') return arg(mockPrisma); + return Promise.all(arg); + }), + }; + return { prisma: mockPrisma }; +}); + +jest.mock('../creator/buy.service', () => ({ + buyGateway: { + getXlmBalance: jest.fn(), + submitBuy: jest.fn(), + }, +})); + +jest.mock('../keys/key-fees.service', () => ({ + getKeyFees: jest.fn(), + KeyNotFoundError: class KeyNotFoundError extends Error { + constructor(public keyId: string) { + super(`Key not found: ${keyId}`); + this.name = 'KeyNotFoundError'; + } + }, +})); + +jest.mock('./auto-invest.events', () => ({ + emitAutoInvestExecuted: jest.fn(), +})); + +const WALLET = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASH'; +const LISTING = 'creator-1'; +const RULE_ID = 'rule-1'; + +const baseRule = { + id: RULE_ID, + walletAddress: WALLET, + category: 'music', + minRating: null, + maxPrice: '5', + maxPerKey: 1, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('auto-invest service', () => { + beforeEach(() => { + jest.clearAllMocks(); + (prisma.autoInvestExecution.create as jest.Mock).mockResolvedValue({ + id: 'exec-1', + }); + (prisma.autoInvestExecution.update as jest.Mock).mockResolvedValue({}); + (getKeyFees as jest.Mock).mockResolvedValue({ + protocolFeeBps: 500, + creatorRoyaltyBuyBps: 0, + creatorRoyaltySellBps: 0, + }); + (buyGateway.getXlmBalance as jest.Mock).mockResolvedValue(100); + (buyGateway.submitBuy as jest.Mock).mockResolvedValue({ + transactionHash: 'tx-hash', + }); + }); + + describe('CRUD', () => { + it('creates a rule owned by the authenticated wallet', async () => { + (prisma.autoInvestRule.create as jest.Mock).mockResolvedValue(baseRule); + + const rule = await createAutoInvestRule(WALLET, { + category: 'music', + minRating: null, + maxPrice: '5', + maxPerKey: 1, + }); + + expect(prisma.autoInvestRule.create).toHaveBeenCalledWith({ + data: { + walletAddress: WALLET, + category: 'music', + minRating: null, + maxPrice: '5', + maxPerKey: 1, + isActive: true, + }, + }); + expect(rule.walletAddress).toBe(WALLET); + expect(rule.category).toBe('music'); + }); + + it('lists only active rules for the wallet', async () => { + const rules = [baseRule, { ...baseRule, isActive: false }]; + (prisma.autoInvestRule.findMany as jest.Mock).mockImplementation( + (args: any) => { + const filtered = rules.filter( + (r) => + r.walletAddress === args.where.walletAddress && + (args.where.isActive === undefined || + r.isActive === args.where.isActive) + ); + return Promise.resolve(filtered); + } + ); + + const result = await listActiveAutoInvestRules(WALLET); + + expect(prisma.autoInvestRule.findMany).toHaveBeenCalledWith({ + where: { walletAddress: WALLET, isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + expect(result).toHaveLength(1); + expect(result[0].isActive).toBe(true); + }); + + it('returns null for a rule owned by another wallet', async () => { + (prisma.autoInvestRule.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await getAutoInvestRule(RULE_ID, 'different-wallet'); + + expect(prisma.autoInvestRule.findFirst).toHaveBeenCalledWith({ + where: { id: RULE_ID, walletAddress: 'different-wallet' }, + }); + expect(result).toBeNull(); + }); + + it('updates only provided fields (partial update preserves others)', async () => { + const existing = { ...baseRule, category: 'music', maxPrice: '5' }; + (prisma.autoInvestRule.findFirst as jest.Mock).mockResolvedValue(existing); + const updated = { ...existing, maxPrice: '10' }; + (prisma.autoInvestRule.update as jest.Mock).mockResolvedValue(updated); + + const rule = await updateAutoInvestRule(RULE_ID, WALLET, { + maxPrice: '10', + }); + + expect(prisma.autoInvestRule.update).toHaveBeenCalledWith({ + where: { id: RULE_ID }, + data: { maxPrice: '10' }, + }); + expect(rule!.maxPrice).toBe('10'); + }); + + it('toggles a rule inactive', async () => { + const existing = { ...baseRule, isActive: true }; + (prisma.autoInvestRule.findFirst as jest.Mock).mockResolvedValue(existing); + const updated = { ...existing, isActive: false }; + (prisma.autoInvestRule.update as jest.Mock).mockResolvedValue(updated); + + const rule = await updateAutoInvestRule(RULE_ID, WALLET, { + isActive: false, + }); + + expect(rule!.isActive).toBe(false); + }); + + it('deletes a rule owned by the wallet', async () => { + (prisma.autoInvestRule.findFirst as jest.Mock).mockResolvedValue(baseRule); + (prisma.autoInvestRule.delete as jest.Mock).mockResolvedValue(baseRule); + + const deleted = await deleteAutoInvestRule(RULE_ID, WALLET); + + expect(deleted).toBe(true); + expect(prisma.autoInvestRule.delete).toHaveBeenCalledWith({ + where: { id: RULE_ID }, + }); + }); + + it('returns false when deleting a rule owned by another wallet', async () => { + (prisma.autoInvestRule.findFirst as jest.Mock).mockResolvedValue(null); + + const deleted = await deleteAutoInvestRule(RULE_ID, 'other-wallet'); + + expect(deleted).toBe(false); + expect(prisma.autoInvestRule.delete).not.toHaveBeenCalled(); + }); + + it('returns null for a nonexistent rule on update', async () => { + (prisma.autoInvestRule.findFirst as jest.Mock).mockResolvedValue(null); + + const result = await updateAutoInvestRule('nope', WALLET, { + isActive: false, + }); + + expect(result).toBeNull(); + }); + }); + + describe('evaluateListing', () => { + const key = { + id: LISTING, + category: 'music', + rating: 4.5, + circulatingSupply: 10, + }; + const priceSnapshot = { currentPrice: 10_000_000 }; // 1 XLM in stroops + + const matchingRule = { + ...baseRule, + category: 'music', + minRating: null, + maxPrice: '5', // 5 XLM + maxPerKey: 1, + }; + + async function setupListing() { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(key); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([ + matchingRule, + ]); + } + + it('executes a qualifying purchase and emits AutoInvestExecuted', async () => { + await setupListing(); + const result = await evaluateListing(LISTING); + + expect(buyGateway.submitBuy).toHaveBeenCalledWith({ + walletAddress: WALLET, + creatorId: LISTING, + quantity: 1, + }); + expect(emitAutoInvestExecuted).toHaveBeenCalledWith({ + ruleId: RULE_ID, + listingId: LISTING, + walletAddress: WALLET, + transactionHash: 'tx-hash', + }); + expect(result.executed).toBe(1); + expect(result.results[0].outcome).toBe('executed'); + }); + + it('skips when category does not match', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ + ...key, + category: 'art', + }); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([]); + + const result = await evaluateListing(LISTING); + expect(result.evaluated).toBe(0); + expect(result.executed).toBe(0); + }); + + it('skips when rating is below the minimum', async () => { + const highMin = { ...matchingRule, minRating: 4.6 }; + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(key); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([ + highMin, + ]); + + const result = await evaluateListing(LISTING); + expect(result.executed).toBe(0); + expect(result.results[0].outcome).toBe('ineligible'); + }); + + it('skips when rating is missing and minRating is set', async () => { + const highMin = { ...matchingRule, minRating: 1 }; + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ + ...key, + rating: null, + }); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([ + highMin, + ]); + + const result = await evaluateListing(LISTING); + expect(result.executed).toBe(0); + expect(result.results[0].outcome).toBe('ineligible'); + }); + + it('skips when price is above the maximum', async () => { + const lowMax = { ...matchingRule, maxPrice: '0.5' }; // 0.5 XLM < 1 XLM + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(key); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([ + lowMax, + ]); + + const result = await evaluateListing(LISTING); + expect(result.executed).toBe(0); + expect(result.results[0].outcome).toBe('ineligible'); + }); + + it('does not execute inactive rules', async () => { + const inactive = { ...matchingRule, isActive: false }; + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(key); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([ + inactive, + ]); + + const result = await evaluateListing(LISTING); + expect(result.executed).toBe(0); + }); + + it('skips on insufficient balance', async () => { + await setupListing(); + (buyGateway.getXlmBalance as jest.Mock).mockResolvedValue(0.001); // underfunded + + const result = await evaluateListing(LISTING); + + expect(result.executed).toBe(0); + expect(result.skipped).toBe(1); + expect(result.results[0].outcome).toBe('skipped'); + expect(emitAutoInvestExecuted).not.toHaveBeenCalled(); + }); + + it('does not emit a success event when purchase fails', async () => { + await setupListing(); + (buyGateway.submitBuy as jest.Mock).mockRejectedValue( + new Error('on-chain failure') + ); + + const result = await evaluateListing(LISTING); + + expect(result.executed).toBe(0); + expect(result.failed).toBe(1); + expect(emitAutoInvestExecuted).not.toHaveBeenCalled(); + }); + + it('evaluates multiple rules for the same wallet independently', async () => { + const ruleA = { ...matchingRule, id: 'rule-a', maxPrice: '5' }; + const ruleB = { ...matchingRule, id: 'rule-b', maxPrice: '5' }; + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(key); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([ + ruleA, + ruleB, + ]); + + const result = await evaluateListing(LISTING); + expect(result.executed).toBe(2); + expect(result.results.map((r) => r.ruleId).sort()).toEqual([ + 'rule-a', + 'rule-b', + ]); + }); + + it('lets one failing rule not block another', async () => { + const ruleA = { ...matchingRule, id: 'rule-a', maxPrice: '5' }; + const ruleB = { ...matchingRule, id: 'rule-b', maxPrice: '0.1' }; // price mismatch + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(key); + (prisma.creatorPriceSnapshot.findUnique as jest.Mock).mockResolvedValue( + priceSnapshot + ); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([ + ruleA, + ruleB, + ]); + + const result = await evaluateListing(LISTING); + expect(result.executed).toBe(1); + expect(result.results[0].outcome).toBe('executed'); + }); + + it('enforces maxPerKey quantity', async () => { + const rule = { ...matchingRule, maxPerKey: 3 }; + await setupListing(); + (prisma.autoInvestRule.findMany as jest.Mock).mockResolvedValue([rule]); + + await evaluateListing(LISTING); + + expect(buyGateway.submitBuy).toHaveBeenCalledWith({ + walletAddress: WALLET, + creatorId: LISTING, + quantity: 3, + }); + }); + + it('is idempotent: duplicate execution for same rule+listing is rejected', async () => { + await setupListing(); + const uniqueError: any = new Error('unique'); + uniqueError.code = 'P2002'; + (prisma.autoInvestExecution.create as jest.Mock).mockRejectedValueOnce( + uniqueError + ); + + const result = await evaluateListing(LISTING); + + expect(result.results[0].outcome).toBe('duplicate'); + expect(result.executed).toBe(0); + }); + }); +}); diff --git a/src/modules/auto-invest/auto-invest.service.ts b/src/modules/auto-invest/auto-invest.service.ts new file mode 100644 index 00000000..2b6fde99 --- /dev/null +++ b/src/modules/auto-invest/auto-invest.service.ts @@ -0,0 +1,533 @@ +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { computeBuyCost } from '../../utils/pricing.utils'; +import { buyGateway } from '../creator/buy.service'; +import { getKeyFees } from '../keys/key-fees.service'; +import { keyEventEmitter } from '../keys/key-registration.service'; +import { + AutoInvestRuleRow, + serializeRule, +} from './auto-invest.schemas'; +import { emitAutoInvestExecuted } from './auto-invest.events'; + +const STROOPS_PER_XLM = 10_000_000n; + +export class AutoInvestError extends Error { + constructor( + public readonly code: string, + message: string + ) { + super(message); + this.name = 'AutoInvestError'; + } +} + +export class AutoInvestKeyNotFoundError extends AutoInvestError { + constructor(listingId: string) { + super('key_not_found', `Key/listing not found: ${listingId}`); + } +} + +export class AutoInvestPriceUnavailableError extends AutoInvestError { + constructor(listingId: string) { + super( + 'price_unavailable', + `No price available for listing: ${listingId}` + ); + } +} + +function toStroops(xlm: string | number): bigint { + return BigInt(Math.floor(Number(xlm) * Number(STROOPS_PER_XLM))); +} + +/** + * Rule CRUD + */ + +/** + * Creates an auto-invest rule owned by the authenticated wallet. + * The wallet address is resolved from the authenticated session and is never + * accepted from the client body. + */ +export async function createAutoInvestRule( + walletAddress: string, + input: { + category: string; + minRating?: number | null | undefined; + maxPrice: string; + maxPerKey: number; + } +): Promise { + const rule = await prisma.autoInvestRule.create({ + data: { + walletAddress, + category: input.category, + minRating: input.minRating ?? null, + maxPrice: input.maxPrice, + maxPerKey: input.maxPerKey, + isActive: true, + }, + }); + return serializeRule(rule); +} + +/** + * Returns active auto-invest rules for a wallet. + */ +export async function listActiveAutoInvestRules( + walletAddress: string +): Promise { + const rules = await prisma.autoInvestRule.findMany({ + where: { + walletAddress, + isActive: true, + }, + orderBy: { createdAt: 'desc' }, + }); + return rules.map(serializeRule); +} + +/** + * Fetches a single rule, verifying wallet ownership. Returns null when the + * rule does not exist or does not belong to the wallet (no leakage). + */ +export async function getAutoInvestRule( + ruleId: string, + walletAddress: string +): Promise { + const rule = await prisma.autoInvestRule.findFirst({ + where: { id: ruleId, walletAddress }, + }); + if (!rule) return null; + return serializeRule(rule); +} + +/** + * Partially updates a rule. Only supplied fields are changed. Verifies wallet + * ownership. Returns null when the rule does not exist or is not owned. + */ +export async function updateAutoInvestRule( + ruleId: string, + walletAddress: string, + input: { + category?: string; + minRating?: number | null; + maxPrice?: string; + maxPerKey?: number; + isActive?: boolean; + } +): Promise { + const existing = await prisma.autoInvestRule.findFirst({ + where: { id: ruleId, walletAddress }, + }); + if (!existing) return null; + + const data: Record = {}; + if (input.category !== undefined) data.category = input.category; + if (input.minRating !== undefined) data.minRating = input.minRating ?? null; + if (input.maxPrice !== undefined) data.maxPrice = input.maxPrice; + if (input.maxPerKey !== undefined) data.maxPerKey = input.maxPerKey; + if (input.isActive !== undefined) data.isActive = input.isActive; + + const rule = await prisma.autoInvestRule.update({ + where: { id: ruleId }, + data, + }); + return serializeRule(rule); +} + +/** + * Deletes a rule after verifying wallet ownership. Returns false when the rule + * does not exist or is not owned by the wallet. + */ +export async function deleteAutoInvestRule( + ruleId: string, + walletAddress: string +): Promise { + const existing = await prisma.autoInvestRule.findFirst({ + where: { id: ruleId, walletAddress }, + }); + if (!existing) return false; + + await prisma.autoInvestRule.delete({ where: { id: ruleId } }); + return true; +} + +/** + * Execution + */ + +/** + * Evaluates a newly listed key against all eligible active auto-invest rules + * and executes qualifying purchases. Each rule is executed independently so a + * single rule's failure does not block other rules. + * + * Returns a summary of executions. + */ +export async function evaluateListing(listingId: string): Promise<{ + listingId: string; + evaluated: number; + executed: number; + skipped: number; + failed: number; + results: Array<{ + ruleId: string; + walletAddress: string; + outcome: 'executed' | 'skipped' | 'failed' | 'ineligible' | 'duplicate'; + reason?: string; + }>; +}> { + const results: Array<{ + ruleId: string; + walletAddress: string; + outcome: 'executed' | 'skipped' | 'failed' | 'ineligible' | 'duplicate'; + reason?: string; + }> = []; + + // 1. Load the key and its current price. + const key = await prisma.creatorProfile.findUnique({ + where: { id: listingId }, + select: { + id: true, + category: true, + rating: true, + circulatingSupply: true, + }, + }); + if (!key) { + logger.warn({ listingId }, 'Auto-invest: listing not found, skipping'); + return { + listingId, + evaluated: 0, + executed: 0, + skipped: 0, + failed: 0, + results, + }; + } + + // Keys without a category cannot match any rule (category is required on rules). + if (!key.category) { + logger.warn( + { listingId }, + 'Auto-invest: listing has no category, no rules can match' + ); + return { + listingId, + evaluated: 0, + executed: 0, + skipped: 0, + failed: 0, + results, + }; + } + + const priceSnapshot = await prisma.creatorPriceSnapshot.findUnique({ + where: { creatorId: listingId }, + select: { currentPrice: true }, + }); + if (!priceSnapshot) { + logger.warn( + { listingId }, + 'Auto-invest: no price snapshot for listing, skipping' + ); + return { + listingId, + evaluated: 0, + executed: 0, + skipped: 0, + failed: 0, + results, + }; + } + + const currentPriceStroops: bigint = BigInt( + priceSnapshot.currentPrice.toString() + ); + const currentSupply = Number(key.circulatingSupply ?? 0); + + // 2. Find eligible active rules matching the key's category. + const rules = await prisma.autoInvestRule.findMany({ + where: { + isActive: true, + category: key.category, + }, + orderBy: { createdAt: 'asc' }, + }); + + let evaluated = 0; + let executed = 0; + let skipped = 0; + let failed = 0; + + for (const rule of rules) { + evaluated++; + + // Re-verify the rule is still active immediately before execution. + if (!rule.isActive) { + results.push({ + ruleId: rule.id, + walletAddress: rule.walletAddress, + outcome: 'ineligible', + reason: 'rule is inactive', + }); + continue; + } + + // 3. Rating check: keys with no rating never match a minRating rule. + const ruleMinRating = rule.minRating ? Number(rule.minRating) : null; + const keyRating = key.rating ? Number(key.rating) : null; + if (ruleMinRating !== null) { + if (keyRating === null || keyRating < ruleMinRating) { + results.push({ + ruleId: rule.id, + walletAddress: rule.walletAddress, + outcome: 'ineligible', + reason: `key rating ${keyRating ?? 'missing'} below min ${ruleMinRating}`, + }); + continue; + } + } + + // 4. Price check. + const ruleMaxPriceStroops = toStroops(String(rule.maxPrice)); + if (currentPriceStroops > ruleMaxPriceStroops) { + results.push({ + ruleId: rule.id, + walletAddress: rule.walletAddress, + outcome: 'ineligible', + reason: `price ${currentPriceStroops} exceeds max ${ruleMaxPriceStroops}`, + }); + continue; + } + + const quantity = Math.max(1, rule.maxPerKey); + + try { + await executeRuleForListing(rule, listingId, quantity, currentSupply); + executed++; + results.push({ + ruleId: rule.id, + walletAddress: rule.walletAddress, + outcome: 'executed', + }); + } catch (error) { + if ( + error instanceof AutoInvestError && + error.code === 'insufficient_balance' + ) { + skipped++; + results.push({ + ruleId: rule.id, + walletAddress: rule.walletAddress, + outcome: 'skipped', + reason: error.message, + }); + continue; + } + if (error instanceof AutoInvestError && error.code === 'duplicate') { + results.push({ + ruleId: rule.id, + walletAddress: rule.walletAddress, + outcome: 'duplicate', + reason: 'execution already exists for this rule and listing', + }); + continue; + } + failed++; + logger.error( + { error, ruleId: rule.id, listingId }, + 'Auto-invest: rule execution failed' + ); + results.push({ + ruleId: rule.id, + walletAddress: rule.walletAddress, + outcome: 'failed', + reason: error instanceof Error ? error.message : 'unknown error', + }); + } + } + + logger.info( + { + listingId, + evaluated, + executed, + skipped, + failed, + ruleCount: rules.length, + }, + 'Auto-invest: listing evaluation complete' + ); + + return { listingId, evaluated, executed, skipped, failed, results }; +} + +/** + * Executes a single rule for a listing with idempotency and balance guards. + */ +export async function executeRuleForListing( + rule: { + id: string; + walletAddress: string; + maxPrice: unknown; + maxPerKey: number; + }, + listingId: string, + quantity: number, + currentSupply: number +): Promise { + const walletAddress = rule.walletAddress; + + // Idempotency: reserve the execution slot atomically. The unique constraint + // on (ruleId, listingId) guarantees only one worker can own this execution. + let execution; + try { + execution = await prisma.autoInvestExecution.create({ + data: { + ruleId: rule.id, + listingId, + status: 'PENDING', + }, + }); + } catch (error) { + // Unique constraint violation => a duplicate/concurrent execution already + // owns this rule+listing. Treat as a safe no-op. + throw new AutoInvestError( + 'duplicate', + `Execution already exists for rule ${rule.id} and listing ${listingId}` + ); + } + + try { + // Balance check (re-checked right before purchase). + const { protocolFeeBps } = await getKeyFees(listingId); + const estimatedCost = computeBuyCost( + currentSupply, + quantity, + protocolFeeBps + ); + + const balance = await buyGateway.getXlmBalance(walletAddress); + const balanceStroops = BigInt( + Math.floor(balance * Number(STROOPS_PER_XLM)) + ); + if (balanceStroops < estimatedCost) { + await prisma.autoInvestExecution.update({ + where: { id: execution.id }, + data: { status: 'SKIPPED_INSUFFICIENT_BALANCE' }, + }); + logger.warn( + { + ruleId: rule.id, + listingId, + walletAddress, + balanceStroops: balanceStroops.toString(), + estimatedCost: estimatedCost.toString(), + }, + 'Auto-invest: insufficient balance, skipping execution' + ); + throw new AutoInvestError( + 'insufficient_balance', + 'Insufficient wallet balance for purchase' + ); + } + + // Execute the purchase through the established purchase flow. + const result = await buyGateway.submitBuy({ + walletAddress, + creatorId: listingId, + quantity, + }); + + // Mark executed and publish the success event only after purchase success. + await prisma.autoInvestExecution.update({ + where: { id: execution.id }, + data: { + status: 'EXECUTED', + transactionHash: result.transactionHash ?? null, + }, + }); + + emitAutoInvestExecuted({ + ruleId: rule.id, + listingId, + walletAddress, + transactionHash: result.transactionHash ?? undefined, + }); + + logger.info( + { + ruleId: rule.id, + listingId, + walletAddress, + quantity, + transactionHash: result.transactionHash, + }, + 'Auto-invest: execution succeeded' + ); + } catch (error) { + if (error instanceof AutoInvestError) throw error; + // Purchase failure: record failure, do not emit a success event. + await prisma.autoInvestExecution + .update({ + where: { id: execution.id }, + data: { status: 'FAILED' }, + }) + .catch(updateErr => { + logger.error( + { error: updateErr, ruleId: rule.id, listingId }, + 'Auto-invest: failed to record execution failure' + ); + }); + throw error; + } +} + +/** + * Trigger: subscribe to new key listings and evaluate auto-invest rules. + * + * Wired to the existing `key_registered` internal event emitted by + * key-registration.service.ts when a new key listing is created. + */ +let autoInvestListenerStarted = false; + +export function startAutoInvestListener(): void { + if (autoInvestListenerStarted) return; + keyEventEmitter.on('key_registered', async payload => { + // The registration event carries the key address and the creator wallet. + // Resolve the creator profile (listing) owned by that wallet to obtain + // the listing id used for rule matching and execution. + try { + if (!payload.creatorWallet) return; + const wallet = await prisma.stellarWallet.findUnique({ + where: { address: payload.creatorWallet }, + select: { userId: true }, + }); + if (!wallet) return; + const creator = await prisma.creatorProfile.findFirst({ + where: { userId: wallet.userId }, + select: { id: true }, + orderBy: { createdAt: 'desc' }, + }); + if (!creator) return; + await evaluateListing(creator.id); + } catch (error) { + logger.error( + { error, payload }, + 'Auto-invest: key_registered listener failed' + ); + } + }); + autoInvestListenerStarted = true; + logger.info('Auto-invest listener started on key_registered event'); +} + +export function stopAutoInvestListener(): void { + // EventEmitter has no off-by-reference without the handler; clear flag so + // a restart re-subscribes. The process-level listener remains for the + // lifetime of the process (acceptable for this service-bound feature). + autoInvestListenerStarted = false; + logger.info('Auto-invest listener stopped'); +} diff --git a/src/modules/index.ts b/src/modules/index.ts index b98eeb54..8c32c58b 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -31,6 +31,7 @@ import vaultRouter from './staking/vault.routes'; import portfolioRouter from './portfolio/portfolio.routes'; import stakingRouter from './staking/staking.routes'; import sellersRouter from './sellers/sellers.routes'; +import autoInvestRouter from './auto-invest/auto-invest.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -84,5 +85,6 @@ router.use('/watchlist', routeBodySizeLimit('default'), watchlistRouter); router.use('/investor/watchlist', routeBodySizeLimit('default'), watchlistRouter); router.use('/staking', routeBodySizeLimit('default'), stakingRouter); router.use('/sellers', routeBodySizeLimit('default'), sellersRouter); +router.use('/auto-invest', routeBodySizeLimit('default'), autoInvestRouter); export default router; diff --git a/src/server.ts b/src/server.ts index 4fd0c5f9..ece4c614 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,6 +24,10 @@ import { startPriceHistoryCleanupJob, stopPriceHistoryCleanupJob, } from './jobs/price-history-cleanup.job'; +import { + startAutoInvestListener, + stopAutoInvestListener, +} from './modules/auto-invest/auto-invest.service'; import { connectRedis, disconnectRedis } from './utils/redis.utils'; import { broadcastServerClosing, closeAllConnections } from './utils/sse-fanout.utils'; import { buildStartupConfigSummary } from './utils/config-summary.utils'; @@ -77,6 +81,7 @@ async function startServer() { startDetectPriceMovementsJob(); startGovernanceSyncJob(); startPriceHistoryCleanupJob(); + startAutoInvestListener(); const server = app.listen(envConfig.PORT, () => { logger.info(`Server running on port ${envConfig.PORT}`); @@ -113,6 +118,7 @@ function createGracefulShutdownHandler(server: ReturnType) { stopDetectPriceMovementsJob(); stopGovernanceSyncJob(); stopPriceHistoryCleanupJob(); + stopAutoInvestListener(); await prisma.$disconnect(); logger.info('Database connection closed'); From 2f3cb22857cd82626af6c1d7744d59f003a4962e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 12:49:51 +0000 Subject: [PATCH 2/2] fix(auto-invest): resolve lint and type errors failing CI - Drop unused catch binding in executeRuleForListing (no-unused-vars) - Call httpDeleteAutoInvestRule with (req, res) in tests to match its two-argument signature (TS2554) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FUAqjMA1T6bJZA8hnhbCyE --- src/modules/auto-invest/auto-invest.controllers.test.ts | 6 ++---- src/modules/auto-invest/auto-invest.service.ts | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/modules/auto-invest/auto-invest.controllers.test.ts b/src/modules/auto-invest/auto-invest.controllers.test.ts index b4fe237b..cb75f23a 100644 --- a/src/modules/auto-invest/auto-invest.controllers.test.ts +++ b/src/modules/auto-invest/auto-invest.controllers.test.ts @@ -212,10 +212,9 @@ describe('auto-invest controllers', () => { it('deletes a rule owned by the wallet', async () => { const req = makeReq(WALLET, { params: { id: 'caaaaaaaaaaaaaaaaaaaaaaaa' } }); const res = makeRes(); - const next = jest.fn(); (deleteAutoInvestRule as jest.Mock).mockResolvedValue(true); - await httpDeleteAutoInvestRule(req, res, next); + await httpDeleteAutoInvestRule(req, res); expect(deleteAutoInvestRule).toHaveBeenCalledWith('caaaaaaaaaaaaaaaaaaaaaaaa', WALLET); expect(res.statusCode).toBe(200); @@ -224,10 +223,9 @@ describe('auto-invest controllers', () => { it('returns 404 for a rule not owned by the wallet', async () => { const req = makeReq(WALLET, { params: { id: 'caaaaaaaaaaaaaaaaaaaaaaaa' } }); const res = makeRes(); - const next = jest.fn(); (deleteAutoInvestRule as jest.Mock).mockResolvedValue(false); - await httpDeleteAutoInvestRule(req, res, next); + await httpDeleteAutoInvestRule(req, res); expect(res.statusCode).toBe(404); }); diff --git a/src/modules/auto-invest/auto-invest.service.ts b/src/modules/auto-invest/auto-invest.service.ts index 2b6fde99..b245f2ba 100644 --- a/src/modules/auto-invest/auto-invest.service.ts +++ b/src/modules/auto-invest/auto-invest.service.ts @@ -391,7 +391,7 @@ export async function executeRuleForListing( status: 'PENDING', }, }); - } catch (error) { + } catch { // Unique constraint violation => a duplicate/concurrent execution already // owns this rule+listing. Treat as a safe no-op. throw new AutoInvestError(