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
60 changes: 60 additions & 0 deletions prisma/schema/auto-invest.prisma
Original file line number Diff line number Diff line change
@@ -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")
}
4 changes: 4 additions & 0 deletions prisma/schema/creator.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ model CreatorProfile {
sunsetFlaggedAt 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)
/// Graduated curve milestones configuration (#869).
Expand Down
Original file line number Diff line number Diff line change
@@ -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");
254 changes: 254 additions & 0 deletions src/modules/auto-invest/auto-invest.controllers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
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();
(deleteAutoInvestRule as jest.Mock).mockResolvedValue(true);

await httpDeleteAutoInvestRule(req, res);

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();
(deleteAutoInvestRule as jest.Mock).mockResolvedValue(false);

await httpDeleteAutoInvestRule(req, res);

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);
});
});
});
Loading
Loading