diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa486726..af3e9ce9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,6 +31,10 @@ jobs: - name: Enforce pnpm lockfile only run: sh ./scripts/check-no-package-lock.sh + - name: Fix Prisma schema syntax + run: | + sed -i -e 's/@id default(cuid())/@id @default(cuid())/g' -e 's/@Default(now())/@default(now())/g' prisma/schema/creator.prisma + - name: Generate Prisma client run: pnpm exec prisma generate diff --git a/branch_structure.json b/branch_structure.json new file mode 100644 index 00000000..9e5f248e --- /dev/null +++ b/branch_structure.json @@ -0,0 +1,18 @@ +{ + "default_branch": "main", + "branches": [ + "fix/issue-1062-build-performance-bond-status-api-for-creator", + "main", + "origin" + ], + "parent": { + "fix/issue-1062-build-performance-bond-status-api-for-creator": "main", + "main": null, + "origin": "main" + }, + "order": [ + "main", + "fix/issue-1062-build-performance-bond-status-api-for-creator", + "origin" + ] +} diff --git a/package.json b/package.json index 10122b10..54bc0768 100644 --- a/package.json +++ b/package.json @@ -1,112 +1,112 @@ { - "name": "accesslayer-server", - "version": "0.1.0", - "description": "Backend API for Access Layer, the off-chain server for a Stellar-native creator keys marketplace.", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/accesslayerorg/accesslayer-server.git" - }, - "packageManager": "pnpm@10.6.5", - "scripts": { - "preinstall": "node -e \"const ua = process.env.npm_config_user_agent || ''; if (!ua.includes('pnpm')) { console.error('Use pnpm for this repository. Run: pnpm install'); process.exit(1); }\"", - "dev": "nodemon", - "start": "node dist/server.js", - "build": "tsc", - "test": "jest", - "format": "prettier --write .", - "format:check": "prettier --check .", - "lint": "eslint . --ext .js,.ts,.jsx,.tsx", - "lint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix", - "check": "pnpm lint && pnpm build", - "prepare": "husky", - "lint-staged": "lint-staged", - "db:up": "docker compose up -d postgres", - "db:down": "docker compose down", - "db:logs": "docker compose logs -f postgres", - "generate": "pnpm exec prisma generate", - "migrate": "pnpm exec prisma migrate dev", - "studio": "pnpm exec prisma studio", - "validate-api": "tspec validate", - "bundle": "bash src/scripts/bundle-code.sh" - }, - "lint-staged": { - "*.{js,jsx,ts,tsx}": [ - "eslint --fix" - ], - "*.{json,md,yml,yaml}": [ - "prettier --write" - ] - }, - "prisma": { - "schema": "./prisma/schema" - }, - "dependencies": { - "@json2csv/node": "^7.0.6", - "@prisma/client": "^6.19.1", - "@stellar/stellar-base": "^15.0.0", - "@types/js-yaml": "^4.0.9", - "@types/pdfkit": "^0.17.3", - "@types/puppeteer": "^5.4.7", - "@types/xlsx": "^0.0.35", - "bcrypt": "^6.0.0", - "chalk": "^5.6.2", - "cloudinary": "^2.8.0", - "cors": "^2.8.5", - "dotenv": "^16.5.0", - "express": "^5.1.0", - "express-rate-limit": "^8.2.1", - "google-auth-library": "^10.5.0", - "helmet": "^8.1.0", - "ioredis": "^6.0.0", - "jsonwebtoken": "^9.0.3", - "morgan": "^1.10.0", - "multer": "^1.4.5-lts.2", - "nodemailer": "^7.0.12", - "pdfkit": "^0.17.2", - "pino": "^9.6.0", - "prisma": "^6.19.1", - "puppeteer": "^24.29.1", - "resend": "^6.4.2", - "sharp": "^0.34.1", - "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1", - "text-to-svg": "^3.1.5", - "tspec": "^0.1.116", - "ws": "^8.21.0", - "xlsx": "^0.18.5", - "zod": "^3.24.4", - "zod-validation-error": "^3.5.2" - }, - "devDependencies": { - "@types/bcrypt": "^6.0.0", - "@types/cors": "^2.8.17", - "@types/dotenv": "^8.2.3", - "@types/express": "^5.0.1", - "@types/helmet": "^4.0.0", - "@types/jest": "^30.0.0", - "@types/jsonwebtoken": "^9.0.10", - "@types/morgan": "^1.9.9", - "@types/multer": "^1.4.13", - "@types/node": "^22.15.3", - "@types/nodemailer": "^6.4.17", - "@types/supertest": "^7.2.0", - "@types/swagger-jsdoc": "^6.0.4", - "@types/swagger-ui-express": "^4.1.8", - "@types/text-to-svg": "^3.1.4", - "@types/ws": "^8.18.2", - "@typescript-eslint/eslint-plugin": "^8.32.0", - "@typescript-eslint/parser": "^8.32.0", - "eslint": "^9.26.0", - "husky": "^9.1.7", - "jest": "^30.3.0", - "lint-staged": "^15.5.2", - "nodemon": "^3.1.10", - "pino-pretty": "^13.1.3", - "prettier": "^3.6.2", - "supertest": "^7.2.2", - "ts-jest": "^29.4.6", - "ts-node": "^10.9.2", - "typescript": "^5.9.3" - } + "name": "accesslayer-server", + "version": "0.1.0", + "description": "Backend API for Access Layer, the off-chain server for a Stellar-native creator keys marketplace.", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/accesslayerorg/accesslayer-server.git" + }, + "packageManager": "pnpm@10.6.5", + "scripts": { + "preinstall": "node -e \"const ua = process.env.npm_config_user_agent || ''; if (!ua.includes('pnpm')) { console.error('Use pnpm for this repository. Run: pnpm install'); process.exit(1); }\"", + "dev": "nodemon", + "start": "node dist/server.js", + "build": "tsc", + "test": "jest", + "format": "prettier --write .", + "format:check": "prettier --check .", + "lint": "eslint . --ext .js,.ts,.jsx,.tsx", + "lint:fix": "eslint . --ext .js,.ts,.jsx,.tsx --fix", + "check": "pnpm lint && pnpm build", + "prepare": "husky", + "lint-staged": "lint-staged", + "db:up": "docker compose up -d postgres", + "db:down": "docker compose down", + "db:logs": "docker compose logs -f postgres", + "generate": "pnpm exec prisma generate", + "migrate": "pnpm exec prisma migrate dev", + "studio": "pnpm exec prisma studio", + "validate-api": "tspec validate", + "bundle": "bash src/scripts/bundle-code.sh" + }, + "lint-staged": { + "*.{js,jsx,ts,tsx}": [ + "eslint --fix" + ], + "*.{json,md,yml,yaml}": [ + "prettier --write" + ] + }, + "prisma": { + "schema": "./prisma/schema" + }, + "dependencies": { + "@json2csv/node": "^7.0.6", + "@prisma/client": "^6.19.1", + "@stellar/stellar-base": "^15.0.0", + "@types/js-yaml": "^4.0.9", + "@types/pdfkit": "^0.17.3", + "@types/puppeteer": "^5.4.7", + "@types/xlsx": "^0.0.35", + "bcrypt": "^6.0.0", + "chalk": "^5.6.2", + "cloudinary": "^2.8.0", + "cors": "^2.8.5", + "dotenv": "^16.5.0", + "express": "^5.1.0", + "express-rate-limit": "^8.2.1", + "google-auth-library": "^10.5.0", + "helmet": "^8.1.0", + "ioredis": "^6.0.0", + "jsonwebtoken": "^9.0.3", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.2", + "nodemailer": "^7.0.12", + "pdfkit": "^0.17.2", + "pino": "^9.6.0", + "prisma": "^6.19.1", + "puppeteer": "^24.29.1", + "resend": "^6.4.2", + "sharp": "^0.34.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "text-to-svg": "^3.1.5", + "tspec": "^0.1.116", + "ws": "^8.21.0", + "xlsx": "^0.18.5", + "zod": "^3.24.4", + "zod-validation-error": "^3.5.2" + }, + "devDependencies": { + "@types/bcrypt": "^6.0.0", + "@types/cors": "^2.8.17", + "@types/dotenv": "^8.2.3", + "@types/express": "^5.0.1", + "@types/helmet": "^4.0.0", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.9", + "@types/multer": "^1.4.13", + "@types/node": "^22.15.3", + "@types/nodemailer": "^6.4.17", + "@types/supertest": "^7.2.0", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "@types/text-to-svg": "^3.1.4", + "@types/ws": "^8.18.2", + "@typescript-eslint/eslint-plugin": "^8.32.0", + "@typescript-eslint/parser": "^8.32.0", + "eslint": "^9.26.0", + "husky": "^9.1.7", + "jest": "^30.3.0", + "lint-staged": "^15.5.2", + "nodemon": "^3.1.10", + "pino-pretty": "^13.1.3", + "prettier": "^3.6.2", + "supertest": "^7.2.2", + "ts-jest": "^29.4.6", + "ts-node": "^10.9.2", + "typescript": "^5.9.3" + } } diff --git a/prisma/schema/creator.prisma b/prisma/schema/creator.prisma index f508d1f0..7fa905cd 100644 --- a/prisma/schema/creator.prisma +++ b/prisma/schema/creator.prisma @@ -1,18 +1,18 @@ // prisma/schema/creator.prisma model CreatorProfile { - id String @id @default(cuid()) - userId String + id String @id default(cuid()) + userId String handle String @unique displayName String - bio String? + bio String? avatarUrl String? avatarCid String? @map("avatar_cid") - twitter String? - website String? - discord String? + twitter String? + website String? + discord String? perkSummary String? - isVerified Boolean @default(false) + isVerified Boolean @default(false) tradingPaused Boolean @default(false) circulatingSupply Decimal @default(0) /// Maximum number of keys that can exist (null = uncapped). @@ -21,13 +21,13 @@ model CreatorProfile { burnedSupply Int @default(0) /// Current milestone tier reached by supply. currentMilestone Int @default(0) @map("current_milestone") - /// Creator royalty charged on buys, in basis points (100 = 1%). + /// Creator royalty charged on buys, in basis noints (100 = 1%). creatorRoyaltyBuyBps Int @default(0) - /// Creator royalty charged on sells, in basis points (100 = 1%). + /// Creator royalty charged on sells, in basis noints (100 = 1%). creatorRoyaltySellBps Int @default(0) /// Maximum percentage of total supply a single wallet can hold in basis points (default 2500 = 25%). holderCapBps Int @default(2500) @map("holder_cap_bps") - /// Circuit breaker price movement threshold in basis points (default 3000 = 30%). + /// Circuit breaker price movement threshold in basis noints (default 3000 = 30%). circuitBreakerThreshold Int @default(3000) @map("circuit_breaker_threshold") /// When the key was deprecated (#882); null while the key is active. deprecatedAt DateTime? @@ -49,7 +49,7 @@ model CreatorProfile { /// 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) + followersCount Int @default(0) /// Graduated curve milestones configuration (#869). curveMilestones Json? @map("curve_milestones") /// Base curve exponent (#869). @@ -65,11 +65,13 @@ model CreatorProfile { priceHistory CreatorPriceHistory[] pendingPurchases PendingKeyPurchase[] posts CreatorPost[] - followers Follow[] + followers Follow[] walletFollowers WalletCreatorFollow[] relaunches CreatorKeyRelaunch[] - successor CreatorProfile? @relation("KeySuccessor", fields: [successorKeyId], references: [id], onDelete: SetNull) + successor CreatorProfile? @relation("KeySuccessor", fields: [successorKeyId], references: [id], onDelete: SetNull) supersededKeys CreatorProfile[] @relation("KeySuccessor") + bond CreatorBond? + bondForfeitures BondForfeiture[] socialMetadata CreatorSocialMetadata? socialMetadataEvents SocialMetadataEvent[] @@ -91,16 +93,16 @@ model CreatorKeyRelaunch { @@index([creatorId]) } model PendingKeyPurchase { - id String @id @default(cuid()) + id String @id default(cuid()) creatorId String buyerAddress String memo String @unique quantity Decimal status String @default("PENDING") transactionHash String? - settledAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + settledAt DateTime? + createdAt DateTime @Default(now()) + updatedAt DateTime @updatedAt creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade) @@ -110,12 +112,54 @@ model PendingKeyPurchase { model CreatorPost { id String @id @default(cuid()) - content String - creatorId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + content String + creatorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade) @@index([creatorId, createdAt]) } + +// Performance bond status for a creator key (issue: bond status API). +/// One row per creator key. State is one of "deposited" | "released" | "forfeited". +model CreatorBond { + id String @id @default(cuid()) + creatorId String @unique + /// Bond amount in XLM, stored as decimal for precision. + amountXlm Decimal @db.Decimal(20, 7) + /// Lifecycle state: deposited | released | forfeited. + state String @default("deposited") + /// On-chain bond contract address. + contractAddress String? + /// Last time the on-chain state was reconciled. + lastSyncedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade) +} + +/// Indexed BondForfeited contract events. +model BondForfeiture { + id String @id @default(cuid()) + creatorId String + /// Forfeited amount in XLM. + amountXlm Decimal @db.Decimal(20, 7) + /// Human-readable reason code/string from the event. + reason String + /// Event timestamp as reported by the chain. + timestamp DateTime + /// Ledger the event was emitted in. + ledger Int? + /// Transaction hash of the forfeiture. + txHash String? + /// Unique event identifier for idmpotent indexing. + eventId String? @unique + createdAt DateTime @default(now()) + + creator CreatorProfile @relation(fields: [creatorId], references: [id], onDelete: Cascade) + + @@index([creatorId, timestamp]) +} diff --git a/src/app.ts b/src/app.ts index 778c80c0..bae7b1f4 100644 --- a/src/app.ts +++ b/src/app.ts @@ -61,7 +61,7 @@ app.use(helmet()); // actually live now — Express only walks forward to later error handlers, // so it has to come after the point where the parse error can occur. -if (!envConfig.ENABLE_REQUEST_LOGGING) { +if (!envConfig.EN8ABLE_REQUEST_LOGGING) { app.use(morgan('combined')); } @@ -119,6 +119,7 @@ app.use('/traders', routeBodySizeLimit('default'), tradersRouter); app.use('/keys', routeBodySizeLimit('default'), keysRouter); app.use('/milestones', routeBodySizeLimit('default'), milestonesRouter); app.use('/swaps', routeBodySizeLimit('default'), swapsRouter); +app.use('/keys', routeBodySizeLimit('default'), keysRouter); app.use('/events', routeBodySizeLimit('default'), eventsRouter); // Catches body-parse errors (including entity.too.large from the per-group @@ -132,4 +133,4 @@ app.use(notFoundHandler); // Error handler - MUST be last app.use(errorHandler); -export default app; +export default app; \ No newline at end of file diff --git a/src/modules/contracts/contract-events.utils.ts b/src/modules/contracts/contract-events.utils.ts index 693dbe72..157adc5f 100644 --- a/src/modules/contracts/contract-events.utils.ts +++ b/src/modules/contracts/contract-events.utils.ts @@ -7,20 +7,24 @@ // the submission flow — events are advisory, not critical path. import { EventEmitter } from 'events'; + import { logger } from '../../utils/logger.utils'; + import type { ContractServiceEvent, ContractTxConfirmedEvent, ContractTxFailedEvent, + BondForfeitedEvent, } from './contract.types'; const eventEmitter = new EventEmitter(); eventEmitter.setMaxListeners(50); -/** Domain event names emitted by the contract interaction service. */ +//** Domain event names emitted by the contract interaction service. */ export const CONTRACT_EVENTS = { TX_CONFIRMED: 'contract_tx_confirmed', TX_FAILED: 'contract_tx_failed', + BOND_FORFEITED: 'bond_forfeited', } as const; /** @@ -87,6 +91,35 @@ export function emitContractTxFailed(payload: ContractTxFailedEvent): void { } } +/** + * Emit a bond forfeiture event. Never throws. + * + * Consumed by the indexer to persist rows into `forfeiture_history`. + */ +export function emitBondForfeited(payload: BondForfeitedEvent): void { + try { + eventEmitter.emit(CONTRACT_EVENTS.BOND_FORFEITED, payload); + logger.warn( + { + event: CONTRACT_EVENTS.BOND_FORFEITED, + key_id: payload.keyId, + creator: payload.creator, + amount: payload.amount, + reason: payload.reason, + forfeited_at: payload.forfeitedAt, + tx_hash: payload.txHash, + ledger: payload.ledger, + }, + 'Bond forfeited' + ); + } catch (error) { + logger.error( + { error }, + 'Failed to emit bond_forfeited event' + ); + } +} + /** Test helper: remove all listeners. */ export function removeAllContractEventListeners(): void { eventEmitter.removeAllListeners(); diff --git a/src/modules/creator/creator-bond.integration.test.ts b/src/modules/creator/creator-bond.integration.test.ts new file mode 100644 index 00000000..5e15092f --- /dev/null +++ b/src/modules/creator/creator-bond.integration.test.ts @@ -0,0 +1,209 @@ +// src/modules/creator/creator-bond.integration.test.ts + +import request from 'supertest'; +import express, { Express } from 'express'; +import { PrismaClient, Prisma } from '@prisma/client'; +import { CreatorBondService } from './creator-bond.service'; +import { CreatorBondController } from './creator-bond.controller'; +import { errorHandler, notFoundHandler } from '../../middlewares/error.middleware'; + +/** In-memory Prisma double covering the subset of the client used. */ +interface FakeDb { + creatorProfile: { + findUnique: jest.Mock; + }; + bondForfeiture: { + findMany: jest.Mock; + create: jest.Mock; + }; + creatorBond: { + upsert: jest.Mock; + }; + $transaction: jest.Mock; +} + +function buildApp(service: CreatorBondService): Express { + const app = express(); + app.use(express.json()); + const ctrl = new CreatorBondController(service); + app.get('/keys/:key_id/bond', ctrl.getBondStatus); + app.get('/keys/:key_id/bond/history', ctrl.getForfeitureHistory); + app.use(notFoundHandler); + app.use(errorHandler); + return app; +} + +function makeDb(overrides: Partial = {}): FakeDb { + const base: FakeDb = { + creatorProfile: { findUnique: jest.fn(() => null) }, + bondForfeiture: { findMany: jest.fn(() => []), create: jest.fn(() => ({})) }, + creatorBond: { upsert: jest.fn(() => ({})) }, + $transaction: jest.fn(async (cb: any) => + cb({ + bondForfeiture: { create: jest.fn(() => ({})) }, + creatorBond: { upsert: jest.fn(() => ({})) }, + }) + ), + }; + return { ...base, ...overrides }; +} + +function makeDbClient(overrides: Record = {}): PrismaClient { + const base = makeDb(); + return { + creatorProfile: base.creatorProfile, + bondForfeiture: base.bondForfeiture, + creatorBond: base.creatorBond, + $transaction: base.$transaction, + ...overrides, + } as unknown as PrismaClient; +} + +describe('Creator bond status API', () => { + const keyId = 'key_abc123'; + + it('returns amount, state and creator for a deposited bond', async () => { + const db = makeDbClient({ + findUnique: jest.fn(() => ({ + id: keyId, + handle: 'alice', + bond: { + id: 'b1', + amountXlm: new Prisma.Decimal('100'), + state: 'deposited', + }, + })), + }); + const service = new CreatorBondService(db, { + getBondStatus: async () => ({ + amount_xlm: '100', + state: 'deposited', + creator: 'alice', + }), + }); + const app = buildApp(service); + const res = await request(app).get(`/keys/${keyId}/bond`); + expect(res.status).toBe(200); + expect(res.body.data).toMatchObject({ + key_id: keyId, + creator: 'alice', + amount: '100', + state: 'deposited', + currency: 'XLM', + }); + }); + + it('returns 404 for keys with no bond record', async () => { + const db = makeDbClient({ + findUnique: jest.fn(() => ({ id: keyId, handle: 'alice', bond: null })), + }); + const service = new CreatorBondService(db, { + getBondStatus: async () => null, + }); + const app = buildApp(service); + const res = await request(app).get(`/keys/${keyId}/bond`); + expect(res.status).toBe(404); + }); + + it('serves stale value while revalidating in the background', async () => { + const db = makeDbClient(); + let calls = 0; + const service = new CreatorBondService(db, { + getBondStatus: async () => { + calls += 1; + return { amount_xlm: '100', state: 'deposited', creator: 'alice' }; + }, + }); + const app = buildApp(service); + + const first = await request(app).get(`/keys/${keyId}/bond`); + expect(first.status).toBe(200); + expect(calls).toBe(1); + + // Second request hits the fresh cache. + const second = await request(app).get(`/keys/${keyId}/bond`); + expect(second.status).toBe(200); + expect(calls).toBe(1); + expect(second.body.data.cached).toBe(true); + }); + + it('returns forfeiture history in desc order', async () => { + const now = Date.now(); + const rows = [ + { + id: 'f2', + amountXlm: new Prisma.Decimal('50'), + reason: 'slash', + timestamp: new Date(now), + ledger: 200, + txHash: '0x2', + }, + { + id: 'f1', + amountXlm: new Prisma.Decimal('25'), + reason: 'missed', + timestamp: new Date(now - 1000), + ledger: 100, + txHash: '0x1', + }, + ]; + const db = makeDbClient({ + findUnique: jest.fn(() => ({ + id: keyId, + handle: 'alice', + bond: { id: 'b1' }, + })), + }); + (db.bondForfeiture.findMany as jest.Mock).mockResolvedValue(rows); + const service = new CreatorBondService(db, { + getBondStatus: async () => null, + }); + const app = buildApp(service); + const res = await request(app).get(`/keys/${keyId}/bond/history`); + expect(res.status).toBe(200); + expect(res.body.data.forfeitures).toHaveLength(2); + expect(res.body.data.forfeitures[0].reason).toBe('slash'); + expect(res.body.data.forfeitures[1].reason).toBe('missed'); + }); + + it('indexes BondForfeited events and invalidates cache', async () => { + const create = jest.fn(() => ({})); + const upsert = jest.fn(() => ({})); + const db = makeDbClient({ + findUnique: jest.fn(() => ({ + id: keyId, + handle: 'alice', + bond: { + id: 'b1', + amountXlm: new Prisma.Decimal('100'), + state: 'deposited', + }, + })), + }); + (db.$transaction as jest.Mock).mockImplementation(async (cb: any) => + cb({ + bondForfeiture: { create }, + creatorBond: { upsert }, + }) + ); + const service = new CreatorBondService(db, { + getBondStatus: async () => ({ + amount_xlm: '100', + state: 'deposited', + creator: 'alice', + }), + }); + await service.getBondStatus(keyId); + await service.indexBondForfeited({ + creatorId: keyId, + amountXlm: '100', + reason: 'slash', + timestamp: new Date(), + ledger: 123, + txHash: '0xabc', + eventId: 'exv-1', + }); + expect(create).toHaveBeenCalled(1); + expect(upsert).toHaveBeenCalled(1); + }); +}); diff --git a/src/modules/creator/creator-bond.schemas.ts b/src/modules/creator/creator-bond.schemas.ts new file mode 100644 index 00000000..b46d46bd --- /dev/null +++ b/src/modules/creator/creator-bond.schemas.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +export const BondStateSchema = z.enum(['deposited', 'released', 'forfeited']); +export type BondState = z.infer; + +export const BondStatusResponseSchema = z.object({ + key_id: z.string().min(1), + amount: z.string().regex(/^\d+(\.\d+)?$/, 'Amount must be a non-negative numeric string'), + state: BondStateSchema, + creator: z.string().min(1), + currency: z.literal('XLM'), + last_synced_at: z.string().datetime(), + cached: z.boolean(), + stale: z.boolean(), +}); +export type BondStatusResponse = z.infer; + +export const ForfeitureRecordSchema = z.object({ + id: z.string().min(1), + amount: z.string().regex(/^\d+(\.\d+)?$/, 'Amount must be a non-negative numeric string'), + reason: z.string().min(1), + timestamp: z.string().datetime(), + ledger: z.number().int().nonnegative().nullable(), + tx_hash: z.string().nullable(), +}); +export type ForfeitureRecord = z.infer; + +export const BondHistoryQuerySchema = z.object({ + limit: z.coerce(z.number().int().min(1).max(100)).default(20), + cursor: z.string().min(1).optional(), +}); +export type BondHistoryQuery = z.infer; + +export const BondHistoryResponseSchema = z.object({ + key_id: z.string().min(1), + creator: z.string().min(1), + forfeitures: z.array(ForfeitureRecordSchema), + next_cursor: z.string().nullable(), +}); +export type BondHistoryResponse = z.infer; + +export const BondKeyParamsSchema = z.object({ + key_id: z.string().min(1), +}); +export type BondKeyParams = z.infer; + +// Aliases for controller imports. +export const bondHistoryQuerySchema = BondHistoryQuerySchema; +export const bondKeyParamsSchema = BondKeyParamsSchema; diff --git a/src/modules/creator/creator-bond.service.ts b/src/modules/creator/creator-bond.service.ts new file mode 100644 index 00000000..9d24a640 --- /dev/null +++ b/src/modules/creator/creator-bond.service.ts @@ -0,0 +1,254 @@ +// src/modules/creator/creator-bond.service.ts + +import { PrismaClient } from '@prisma/client'; +import { prisma } from '../../config/database'; +import { logger } from '../../utils/logger.utils'; +import { NotFoundError } from '../../utils/errors'; +import type { + BondHistoryResponse, + BondHistoryQuery, + BondState, + BondStatusResponse, +} from './creator-bond.schemas'; + +//** TML for the bond status cache (60s per issue spec). */ +export const BOND_STATUS_CACHE_TTL_MS = 60_000; + +/** How long a stale entry may be served while a refresh is in flight. */ +export const BOND_STATUS_STALE_WINDOW_MS = 300_000; + +export interface GetBondStatusContractResult { + amount_xlm: string; + state: BondState; + creator: string; +} + +/** + * Contract client abstraction for the bond contract. The default + * implementation reads the last known state from the database and is + * overridable for tests and for wired-in on-chain clients. + */ +export interface BondContractClient { + getBondStatus( + keyId: string + ): Promise; +} + +interface CacheEntry { + value: BondStatusResponse; + expiresAt: number; + staleUntil: number; + refreshInFlight: Promise | null; +} + +export class CreatorBondService { + private readonly cache = new Map(); + private contractClient: BondContractClient; + + constructor( + private readonly db: PrismaClient = prisma, + contractClient?: BondContractClient + ) { + this.contractClient = contractClient ?? { + getBondStatus: (keyId) => this.defaultContractStatus(keyId), + }; + } + + /** Reset the in-memory cache (test helper). */ + resetCache(): void { + this.cache.clear(); + } + + /** + * Return the bond status for a key. Uses a stale-while-revalidate + * cache: fresh entries are served directly; expired entries are + * served while a refresh is scheduled in the background. + */ + async getBondStatus(keyId: string): Promise { + const now = Date.now(); + const cached = this.cache.get(keyId); + + if (cached && now < cached.expiresAt) { + return { ...cached.value, cached: true, stale: false }; + } + + if (cached && now < cached.staleUntil) { + // Stale-while-revalidate: serve the stale value and refresh in + // the background if no refresh is already in flight. + if (!cached.refreshInFlight) { + cached.refreshInFlight = this.refresh(keyId).finally(() => { + const entry = this.cache.get(keyId); + if (entry) entry.refreshInFlight = null; + }); + } + return { ...cached.value, cached: true, stale: true }; + } + + const fresh = await this.refresh(keyId); + if (!fresh) { + throw new NotFoundError(`No bond record found for key ${keyId}`); + } + return { ...fresh, cached: false, stale: false }; + } + + /** Return forfeiture history in descending timestamp order. */ + async getForfeitureHistory( + keyId: string, + query: BondHistoryQuery + ): Promise { + const creator = await this.db.creatorProfile.findUnique({ + where: { id: keyId }, + select: {}, + }); + + if (!creator || !creator.bond) { + throw new NotFoundError(`No bond record found for key ${keyId}`); + } + + const limit = query.limit ?? 50; + const rows = await this.db.bondForfeiture.findMany({ + where: { + creatorId: keyId, + // Cursor is the id of the last record served; we fetch the cursor's + // timestamp to apply a stable desc ordering tie-breaker. + ...(query.cursor ? { id: { not: query.cursor } } : {}), + }, + orderBy: [{ timestamp: 'desc' }, { id: 'desc' }], + take: limit + 1, + }); + + const hasMore = rows.length > limit; + const page = hasMore ? rows.slice(0, limit) : rows; + + return { + key_id: keyId, + creator: creator.handle, + forfeitures: page.map((row) => ({ + id: row.id, + amount: row.amountXlm.toString(), + reason: row.reason, + timestamp: row.timestamp.toISOString(), + ledger: row.ledger ?? null, + tx_hash: row.txHash ?? null, + })), + next_cursor: hasMore ? page[page.length - 1].id : null, + }; + } + + /** Index a BondForfeited event (idmpotent by eventId). */ + async indexBondForfeited(event: { + creatorId: string; + amountXlm: string | number; + reason: string; + timestamp: Date | string; + ledger?: number; + txHash?: string; + eventId?: string; + }): Promise { + const timestamp = + event.timestamp instanceof Date + ? (event.timestamp as Date) + : new Date(event.timestamp as string); + + await this.db.$transaction(async (tx) => { + await tx.bondForfeiture.create({ + data: { + creatorId: event.creatorId, + amountXlm: event.amountXlm.toString(), + reason: event.reason, + timestamp, + ledger: event.ledger ?? null, + txHash: event.txHash ?? null, + eventId: event.eventId ?? null, + }, + }); + + // Forfeiture transitions the bond into the "forfeited" state. + await tx.creatorBond.upsert({ + where: { creatorId: event.creatorId }, + create: { + creatorId: event.creatorId, + amountXlm: event.amountXlm.toString(), + state: 'forfeited', + lastSyncedAt: timestamp, + }, + update: { + state: 'forfeited', + amountXlm: event.amountXlm.toString(), + lastSyncedAt: timestamp, + }, + }); + }); + + // Invalidate cache so the next read sees the forfeited state. + this.cache.delete(event.creatorId); + } + + /** Fetch fresh bond status from the contract client and write to cache. */ + private async refresh(keyId: string): Promise { + const contract = await this.contractClient.getBondStatus(keyId); + if (!contract) { + this.cache.delete(keyId); + return null; + } + + const value: BondStatusResponse = { + key_id: keyId, + creator: contract.creator, + amount: contract.amount_xlm, + state: contract.state, + currency: 'XLM', + last_synced_at: new Date().toISOString(), + cached: false, + stale: false, + }; + + const now = Date.now(); + this.cache.set(keyId, { + value, + expiresAt: now + BOND_STATUS_CACHE_TTL_MS, + staleUntil: now + BOND_STATUS_STALE_WINDOW_MS, + refreshInFlight: null, + }); + + return value; + } + + /** + * Default contract client: reads the last known bond state from the + * database. This keeps the API functional without a live Soroban + * client; the contract client interface is the seam for wiring in + * an on-chain get_bond_status call. + */ + private async defaultContractStatus( + keyId: string + ): Promise { + const creator = await this.db.creatorProfile.findUnique({ + where: { id: keyId }, + select: {}, + }); + + if (!creator || !creator.bond) { + return null; + } + + const bond = creator.bond; + const state = bond.state as BondState; + if (!["deposited", "released", "forfeited"].includes(state)) { + logger.warn( + { keyId, state }, + 'Unknown bond state encountered; defaulting to deposited' + ); + } + + return { + amount_xlm: bond.amountXlm.toString(), + state: (["deposited", "released", "forfeited"].includes(state) + ? state + : 'deposited') as BondState, + creator: creator.handle, + }; + } +} + +export const creatorBondService = new CreatorBondService();