diff --git a/prisma/schema/curve-update.prisma b/prisma/schema/curve-update.prisma new file mode 100644 index 00000000..af437b01 --- /dev/null +++ b/prisma/schema/curve-update.prisma @@ -0,0 +1,25 @@ +/// Timelocked bonding-curve parameter update proposal indexed from Soroban. +model CurveUpdate { + id String @id @default(cuid()) + keyId String + proposalId String + status String @default("pending") // pending | completed | cancelled + proposedParams Json + eta DateTime + proposedAt DateTime + completedAt DateTime? + outcome String? + proposedTxHash String + proposedEventIndex Int + executedTxHash String? + executedEventIndex Int? + ledger Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([keyId, proposalId]) + @@unique([proposedTxHash, proposedEventIndex]) + @@unique([executedTxHash, executedEventIndex]) + @@index([keyId, status, eta]) + @@map("curve_updates") +} diff --git a/prisma/schema/key-launch-auction.prisma b/prisma/schema/key-launch-auction.prisma new file mode 100644 index 00000000..8e87fdc2 --- /dev/null +++ b/prisma/schema/key-launch-auction.prisma @@ -0,0 +1,47 @@ +/// On-chain launch auction current state. +model KeyLaunchAuction { + id String @id @default(cuid()) + auctionId String @unique + keyId String @unique + status String @default("active") + reserve Decimal @db.Decimal(36, 7) + highestBid Decimal? @db.Decimal(36, 7) + highestBidder String? + clearingPrice Decimal? @db.Decimal(36, 7) + startsAt DateTime + endsAt DateTime + settledAt DateTime? + createdTxHash String + createdEventIndex Int + settledTxHash String? + settledEventIndex Int? + ledger Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + bids KeyLaunchAuctionBid[] + + @@unique([createdTxHash, createdEventIndex]) + @@unique([settledTxHash, settledEventIndex]) + @@index([keyId, status]) + @@map("key_auctions") +} + +/// Immutable bid history for a launch auction. +model KeyLaunchAuctionBid { + id String @id @default(cuid()) + auctionId String + bidder String + amount Decimal @db.Decimal(36, 7) + placedAt DateTime + txHash String + eventIndex Int + ledger Int + createdAt DateTime @default(now()) + + auction KeyLaunchAuction @relation(fields: [auctionId], references: [auctionId], onDelete: Cascade) + + @@unique([txHash, eventIndex]) + @@index([auctionId, placedAt(sort: Desc)]) + @@map("key_auction_bids") +} diff --git a/prisma/schema/key-retirement.prisma b/prisma/schema/key-retirement.prisma new file mode 100644 index 00000000..b2adbf82 --- /dev/null +++ b/prisma/schema/key-retirement.prisma @@ -0,0 +1,19 @@ +/// Append-only retirement/deprecation lifecycle event log. +model KeyRetirement { + id String @id @default(cuid()) + keyId String + eventType String + status String + buybackPrice Decimal? @db.Decimal(36, 7) + noticeExpiry DateTime? + reason String? + occurredAt DateTime + txHash String + eventIndex Int + ledger Int + createdAt DateTime @default(now()) + + @@unique([txHash, eventIndex]) + @@index([keyId, occurredAt(sort: Desc)]) + @@map("key_retirement") +} diff --git a/prisma/schema/migrations/20261019100000_add_curve_updates/migration.sql b/prisma/schema/migrations/20261019100000_add_curve_updates/migration.sql new file mode 100644 index 00000000..411614d7 --- /dev/null +++ b/prisma/schema/migrations/20261019100000_add_curve_updates/migration.sql @@ -0,0 +1,24 @@ +CREATE TABLE "curve_updates" ( + "id" TEXT NOT NULL, + "keyId" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "proposedParams" JSONB NOT NULL, + "eta" TIMESTAMP(3) NOT NULL, + "proposedAt" TIMESTAMP(3) NOT NULL, + "completedAt" TIMESTAMP(3), + "outcome" TEXT, + "proposedTxHash" TEXT NOT NULL, + "proposedEventIndex" INTEGER NOT NULL, + "executedTxHash" TEXT, + "executedEventIndex" INTEGER, + "ledger" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "curve_updates_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "curve_updates_keyId_proposalId_key" ON "curve_updates"("keyId", "proposalId"); +CREATE UNIQUE INDEX "curve_updates_proposedTxHash_proposedEventIndex_key" ON "curve_updates"("proposedTxHash", "proposedEventIndex"); +CREATE UNIQUE INDEX "curve_updates_executedTxHash_executedEventIndex_key" ON "curve_updates"("executedTxHash", "executedEventIndex"); +CREATE INDEX "curve_updates_keyId_status_eta_idx" ON "curve_updates"("keyId", "status", "eta"); diff --git a/prisma/schema/migrations/20261019110000_add_milestone_claims/migration.sql b/prisma/schema/migrations/20261019110000_add_milestone_claims/migration.sql new file mode 100644 index 00000000..53ff6dfa --- /dev/null +++ b/prisma/schema/migrations/20261019110000_add_milestone_claims/migration.sql @@ -0,0 +1,17 @@ +CREATE TABLE "milestone_claims" ( + "id" TEXT NOT NULL, + "keyId" TEXT NOT NULL, + "wallet" TEXT NOT NULL, + "milestoneDays" INTEGER NOT NULL, + "rewardAmount" DECIMAL(36,7), + "claimedAt" TIMESTAMP(3) NOT NULL, + "txHash" TEXT NOT NULL, + "eventIndex" INTEGER NOT NULL, + "ledger" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "milestone_claims_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "milestone_claims_txHash_eventIndex_key" ON "milestone_claims"("txHash", "eventIndex"); +CREATE UNIQUE INDEX "milestone_claims_keyId_wallet_milestoneDays_key" ON "milestone_claims"("keyId", "wallet", "milestoneDays"); +CREATE INDEX "milestone_claims_wallet_keyId_idx" ON "milestone_claims"("wallet", "keyId"); diff --git a/prisma/schema/migrations/20261019120000_add_key_retirement/migration.sql b/prisma/schema/migrations/20261019120000_add_key_retirement/migration.sql new file mode 100644 index 00000000..a867b934 --- /dev/null +++ b/prisma/schema/migrations/20261019120000_add_key_retirement/migration.sql @@ -0,0 +1,18 @@ +CREATE TABLE "key_retirement" ( + "id" TEXT NOT NULL, + "keyId" TEXT NOT NULL, + "eventType" TEXT NOT NULL, + "status" TEXT NOT NULL, + "buybackPrice" DECIMAL(36,7), + "noticeExpiry" TIMESTAMP(3), + "reason" TEXT, + "occurredAt" TIMESTAMP(3) NOT NULL, + "txHash" TEXT NOT NULL, + "eventIndex" INTEGER NOT NULL, + "ledger" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "key_retirement_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "key_retirement_txHash_eventIndex_key" ON "key_retirement"("txHash", "eventIndex"); +CREATE INDEX "key_retirement_keyId_occurredAt_idx" ON "key_retirement"("keyId", "occurredAt" DESC); diff --git a/prisma/schema/migrations/20261019130000_add_key_launch_auctions/migration.sql b/prisma/schema/migrations/20261019130000_add_key_launch_auctions/migration.sql new file mode 100644 index 00000000..d138d9ce --- /dev/null +++ b/prisma/schema/migrations/20261019130000_add_key_launch_auctions/migration.sql @@ -0,0 +1,43 @@ +CREATE TABLE "key_auctions" ( + "id" TEXT NOT NULL, + "auctionId" TEXT NOT NULL, + "keyId" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "reserve" DECIMAL(36,7) NOT NULL, + "highestBid" DECIMAL(36,7), + "highestBidder" TEXT, + "clearingPrice" DECIMAL(36,7), + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3) NOT NULL, + "settledAt" TIMESTAMP(3), + "createdTxHash" TEXT NOT NULL, + "createdEventIndex" INTEGER NOT NULL, + "settledTxHash" TEXT, + "settledEventIndex" INTEGER, + "ledger" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "key_auctions_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "key_auction_bids" ( + "id" TEXT NOT NULL, + "auctionId" TEXT NOT NULL, + "bidder" TEXT NOT NULL, + "amount" DECIMAL(36,7) NOT NULL, + "placedAt" TIMESTAMP(3) NOT NULL, + "txHash" TEXT NOT NULL, + "eventIndex" INTEGER NOT NULL, + "ledger" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "key_auction_bids_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "key_auctions_auctionId_key" ON "key_auctions"("auctionId"); +CREATE UNIQUE INDEX "key_auctions_keyId_key" ON "key_auctions"("keyId"); +CREATE UNIQUE INDEX "key_auctions_createdTxHash_createdEventIndex_key" ON "key_auctions"("createdTxHash", "createdEventIndex"); +CREATE UNIQUE INDEX "key_auctions_settledTxHash_settledEventIndex_key" ON "key_auctions"("settledTxHash", "settledEventIndex"); +CREATE INDEX "key_auctions_keyId_status_idx" ON "key_auctions"("keyId", "status"); +CREATE UNIQUE INDEX "key_auction_bids_txHash_eventIndex_key" ON "key_auction_bids"("txHash", "eventIndex"); +CREATE INDEX "key_auction_bids_auctionId_placedAt_idx" ON "key_auction_bids"("auctionId", "placedAt" DESC); +ALTER TABLE "key_auction_bids" ADD CONSTRAINT "key_auction_bids_auctionId_fkey" FOREIGN KEY ("auctionId") REFERENCES "key_auctions"("auctionId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema/milestone-claim.prisma b/prisma/schema/milestone-claim.prisma new file mode 100644 index 00000000..dd8e5df7 --- /dev/null +++ b/prisma/schema/milestone-claim.prisma @@ -0,0 +1,18 @@ +/// Holder tenure milestone reward claimed on-chain. +model MilestoneClaim { + id String @id @default(cuid()) + keyId String + wallet String + milestoneDays Int + rewardAmount Decimal? @db.Decimal(36, 7) + claimedAt DateTime + txHash String + eventIndex Int + ledger Int + createdAt DateTime @default(now()) + + @@unique([txHash, eventIndex]) + @@unique([keyId, wallet, milestoneDays]) + @@index([wallet, keyId]) + @@map("milestone_claims") +} diff --git a/src/app.ts b/src/app.ts index 07d6f8e5..8cd37b8b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,7 @@ import tradersRouter from './modules/traders/traders.routes'; import nonceRouter from './modules/nonce/nonce.routes'; import swapsRouter from './modules/swaps/swaps.routes'; import keysRouter from './modules/keys/keys.routes'; +import milestonesRouter from './modules/keys/milestones.routes'; import internalRouter from './modules/admin/sequencer.routes'; import eventsRouter from './modules/events/events.routes'; import { corsMiddleware } from './middlewares/cors.middleware'; @@ -116,6 +117,7 @@ app.use('/staking', routeBodySizeLimit('default'), stakingRouter); app.use('/trades', routeBodySizeLimit('default'), tradesRouter); 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('/events', routeBodySizeLimit('default'), eventsRouter); diff --git a/src/modules/index.ts b/src/modules/index.ts index f89d2696..23825c10 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -56,6 +56,7 @@ import tradersRouter from './traders/traders.routes'; import nonceRouter from './nonce/nonce.routes'; import swapsRouter from './swaps/swaps.routes'; import eventsRouter from './events/events.routes'; +import milestonesRouter from './keys/milestones.routes'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; @@ -141,5 +142,6 @@ router.use('/trades', routeBodySizeLimit('default'), tradesRouter); router.use('/traders', routeBodySizeLimit('default'), tradersRouter); router.use('/nonce', routeBodySizeLimit('default'), nonceRouter); router.use('/swaps', routeBodySizeLimit('default'), swapsRouter); +router.use('/milestones', routeBodySizeLimit('default'), milestonesRouter); export default router; diff --git a/src/modules/indexer/curve-update-indexer.service.ts b/src/modules/indexer/curve-update-indexer.service.ts new file mode 100644 index 00000000..99ede2b2 --- /dev/null +++ b/src/modules/indexer/curve-update-indexer.service.ts @@ -0,0 +1,127 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; +import { logger } from '../../utils/logger.utils'; +import { + IndexerChainEvent, + processIndexerChainEvents, +} from '../../utils/indexer-event-processor.utils'; +import { curveUpdateCacheKeys } from '../keys/curve-updates.service'; + +export const CURVE_UPDATE_EVENT_TYPES = new Set([ + 'CurveUpdateProposed', + 'CURVE_UPDATE_PROPOSED', + 'CurveUpdateExecuted', + 'CURVE_UPDATE_EXECUTED', +]); + +const asDate = (value: unknown, fallback = new Date()): Date => { + if ( + typeof value === 'number' || + (typeof value === 'string' && /^\d+$/.test(value)) + ) { + const n = Number(value); + const date = new Date(n < 1e12 ? n * 1000 : n); + if (!Number.isNaN(date.getTime())) return date; + } + const date = value ? new Date(String(value)) : fallback; + return Number.isNaN(date.getTime()) ? fallback : date; +}; + +export async function processCurveUpdateEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!CURVE_UPDATE_EVENT_TYPES.has(event.eventType)) return; + const keyId = String(event.keyId ?? event.creatorId ?? '').trim(); + const proposalId = String( + event.proposalId ?? event.updateId ?? '' + ).trim(); + if (!keyId || !proposalId) + throw new Error( + `Invalid ${event.eventType}: keyId and proposalId are required` + ); + + if ( + event.eventType === 'CurveUpdateProposed' || + event.eventType === 'CURVE_UPDATE_PROPOSED' + ) { + const eta = asDate( + event.eta ?? event.executeAfter ?? event.executionTime + ); + const proposedAt = asDate( + event.proposedAt ?? event.occurredAt ?? event.timestamp + ); + const proposedParams = + event.proposedParams ?? event.params ?? event.parameters; + if (!proposedParams || typeof proposedParams !== 'object') + throw new Error( + `Invalid ${event.eventType}: proposed params are required` + ); + await prisma.curveUpdate.upsert({ + where: { keyId_proposalId: { keyId, proposalId } }, + create: { + keyId, + proposalId, + proposedParams, + eta, + proposedAt, + proposedTxHash: event.txHash, + proposedEventIndex: event.eventIndex, + ledger: Number(event.ledger ?? 0), + }, + update: {}, + }); + } else { + const outcome = String( + event.outcome ?? + event.result ?? + (event.cancelled ? 'cancelled' : 'executed') + ).toLowerCase(); + const existing = await prisma.curveUpdate.findUnique({ + where: { keyId_proposalId: { keyId, proposalId } }, + select: { executedTxHash: true, executedEventIndex: true }, + }); + if (!existing) { + throw new Error( + `${event.eventType} references unknown proposal ${keyId}/${proposalId}` + ); + } + if ( + existing.executedTxHash === event.txHash && + existing.executedEventIndex === event.eventIndex + ) { + await cacheInvalidate( + curveUpdateCacheKeys.pending(keyId), + curveUpdateCacheKeys.history(keyId) + ); + return; + } + await prisma.curveUpdate.update({ + where: { keyId_proposalId: { keyId, proposalId } }, + data: { + status: + outcome === 'cancelled' || + outcome === 'canceled' || + event.success === false + ? 'cancelled' + : 'completed', + outcome, + completedAt: asDate( + event.executedAt ?? event.occurredAt ?? event.timestamp + ), + executedTxHash: event.txHash, + executedEventIndex: event.eventIndex, + ledger: Number(event.ledger ?? 0), + }, + }); + } + await cacheInvalidate( + curveUpdateCacheKeys.pending(keyId), + curveUpdateCacheKeys.history(keyId) + ); + logger.info( + { keyId, proposalId, eventType: event.eventType }, + 'Curve update event indexed' + ); + }); +} diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index 1019c64f..be3f74ab 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -427,6 +427,44 @@ export async function processIndexerEvents( await processGovernanceProposalEvents(governanceEvents); } + const { CURVE_UPDATE_EVENT_TYPES, processCurveUpdateEvents } = + await import('./curve-update-indexer.service'); + const curveUpdateEvents = events.filter(event => + CURVE_UPDATE_EVENT_TYPES.has(event.eventType) + ); + if (curveUpdateEvents.length > 0) { + await processCurveUpdateEvents(curveUpdateEvents); + } + + const { + MILESTONE_REWARD_CLAIMED_EVENT_TYPES, + processMilestoneRewardClaimedEvents, + } = await import('./milestone-claim-indexer.service'); + const milestoneClaimEvents = events.filter(event => + MILESTONE_REWARD_CLAIMED_EVENT_TYPES.has(event.eventType) + ); + if (milestoneClaimEvents.length > 0) { + await processMilestoneRewardClaimedEvents(milestoneClaimEvents); + } + + const { KEY_RETIREMENT_EVENT_TYPES, processKeyRetirementEvents } = + await import('./key-retirement-indexer.service'); + const retirementEvents = events.filter(event => + KEY_RETIREMENT_EVENT_TYPES.has(event.eventType) + ); + if (retirementEvents.length > 0) { + await processKeyRetirementEvents(retirementEvents); + } + + const { KEY_LAUNCH_AUCTION_EVENT_TYPES, processKeyLaunchAuctionEvents } = + await import('./key-launch-auction-indexer.service'); + const launchAuctionEvents = events.filter(event => + KEY_LAUNCH_AUCTION_EVENT_TYPES.has(event.eventType) + ); + if (launchAuctionEvents.length > 0) { + await processKeyLaunchAuctionEvents(launchAuctionEvents); + } + if (tradeEvents.length > 0) { await processTradeEvents(tradeEvents, { advanceCursor: false }); } diff --git a/src/modules/indexer/key-launch-auction-indexer.service.ts b/src/modules/indexer/key-launch-auction-indexer.service.ts new file mode 100644 index 00000000..d88f90fd --- /dev/null +++ b/src/modules/indexer/key-launch-auction-indexer.service.ts @@ -0,0 +1,173 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; +import { + IndexerChainEvent, + processIndexerChainEvents, +} from '../../utils/indexer-event-processor.utils'; +import { keyLaunchAuctionCacheKeys } from '../keys/key-launch-auction.service'; + +export const KEY_LAUNCH_AUCTION_EVENT_TYPES = new Set([ + 'AuctionCreated', + 'AUCTION_CREATED', + 'BidPlaced', + 'BID_PLACED', + 'AuctionSettled', + 'AUCTION_SETTLED', +]); + +function auctionDate(value: unknown, fallback = new Date()): Date { + const numeric = Number(value); + if ( + value != null && + value !== '' && + Number.isFinite(numeric) && + /^\d+$/.test(String(value)) + ) { + const result = new Date(numeric < 1e12 ? numeric * 1000 : numeric); + if (!Number.isNaN(result.getTime())) return result; + } + const result = value ? new Date(String(value)) : fallback; + return Number.isNaN(result.getTime()) ? fallback : result; +} + +export async function processKeyLaunchAuctionEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!KEY_LAUNCH_AUCTION_EVENT_TYPES.has(event.eventType)) return; + let keyId = String(event.keyId ?? event.creatorId ?? '').trim(); + const auctionId = String(event.auctionId ?? keyId).trim(); + if (!auctionId) + throw new Error(`Invalid ${event.eventType}: auctionId is required`); + const isCreated = + event.eventType === 'AuctionCreated' || + event.eventType === 'AUCTION_CREATED'; + const isBid = + event.eventType === 'BidPlaced' || event.eventType === 'BID_PLACED'; + + if (!isCreated && !keyId) { + const auction = await prisma.keyLaunchAuction.findUnique({ + where: { auctionId }, + select: { keyId: true }, + }); + if (!auction) { + throw new Error( + `${event.eventType} references unknown auction ${auctionId}` + ); + } + keyId = auction.keyId; + } + if (!keyId) + throw new Error(`Invalid ${event.eventType}: keyId is required`); + + if (isCreated) { + const startsAt = auctionDate( + event.startsAt ?? event.startTime ?? event.occurredAt + ); + await prisma.keyLaunchAuction.upsert({ + where: { auctionId }, + create: { + auctionId, + keyId, + status: 'active', + reserve: String(event.reserve ?? event.reservePrice ?? 0), + startsAt, + endsAt: auctionDate(event.endsAt ?? event.endTime, startsAt), + createdTxHash: event.txHash, + createdEventIndex: event.eventIndex, + ledger: Number(event.ledger ?? 0), + }, + update: {}, + }); + } else if (isBid) { + const bidder = String( + event.bidder ?? event.wallet ?? event.actor ?? '' + ).trim(); + const amount = String(event.amount ?? event.bidAmount ?? ''); + if (!bidder || !amount || !Number.isFinite(Number(amount))) + throw new Error( + `Invalid ${event.eventType}: bidder and amount are required` + ); + await prisma.$transaction(async tx => { + await tx.keyLaunchAuctionBid.upsert({ + where: { + txHash_eventIndex: { + txHash: event.txHash, + eventIndex: event.eventIndex, + }, + }, + create: { + auctionId, + bidder, + amount, + placedAt: auctionDate( + event.placedAt ?? event.occurredAt ?? event.timestamp + ), + txHash: event.txHash, + eventIndex: event.eventIndex, + ledger: Number(event.ledger ?? 0), + }, + update: {}, + }); + const current = await tx.keyLaunchAuction.findUnique({ + where: { auctionId }, + select: { highestBid: true }, + }); + if (!current) + throw new Error( + `BidPlaced references unknown auction ${auctionId}` + ); + if ( + current.highestBid == null || + Number(amount) > Number(current.highestBid) + ) { + await tx.keyLaunchAuction.update({ + where: { auctionId }, + data: { + highestBid: amount, + highestBidder: bidder, + ledger: Number(event.ledger ?? 0), + }, + }); + } + }); + } else { + const existing = await prisma.keyLaunchAuction.findUnique({ + where: { auctionId }, + select: { settledTxHash: true, settledEventIndex: true }, + }); + if (!existing) { + throw new Error( + `${event.eventType} references unknown auction ${auctionId}` + ); + } + if ( + existing.settledTxHash === event.txHash && + existing.settledEventIndex === event.eventIndex + ) { + await cacheInvalidate( + keyLaunchAuctionCacheKeys.state(keyId), + keyLaunchAuctionCacheKeys.bids(keyId) + ); + return; + } + await prisma.keyLaunchAuction.update({ + where: { auctionId }, + data: { + status: 'settled', + clearingPrice: String(event.clearingPrice ?? event.price ?? 0), + settledAt: auctionDate( + event.settledAt ?? event.occurredAt ?? event.timestamp + ), + settledTxHash: event.txHash, + settledEventIndex: event.eventIndex, + ledger: Number(event.ledger ?? 0), + }, + }); + } + await cacheInvalidate( + keyLaunchAuctionCacheKeys.state(keyId), + keyLaunchAuctionCacheKeys.bids(keyId) + ); + }); +} diff --git a/src/modules/indexer/key-retirement-indexer.service.ts b/src/modules/indexer/key-retirement-indexer.service.ts new file mode 100644 index 00000000..045eedd0 --- /dev/null +++ b/src/modules/indexer/key-retirement-indexer.service.ts @@ -0,0 +1,83 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; +import { + IndexerChainEvent, + processIndexerChainEvents, +} from '../../utils/indexer-event-processor.utils'; +import { keyRetirementCacheKey } from '../keys/key-retirement.service'; + +export const KEY_RETIREMENT_EVENT_TYPES = new Set([ + 'RetirementProposed', + 'RETIREMENT_PROPOSED', + 'RetirementExecuted', + 'RETIREMENT_EXECUTED', + 'DeprecationExecuted', + 'DEPRECATION_EXECUTED', +]); + +function eventDate(value: unknown): Date { + if ( + typeof value === 'number' || + (typeof value === 'string' && /^\d+$/.test(value)) + ) { + const n = Number(value); + const result = new Date(n < 1e12 ? n * 1000 : n); + if (!Number.isNaN(result.getTime())) return result; + } + const result = value ? new Date(String(value)) : new Date(); + return Number.isNaN(result.getTime()) ? new Date() : result; +} + +export async function processKeyRetirementEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!KEY_RETIREMENT_EVENT_TYPES.has(event.eventType)) return; + const keyId = String(event.keyId ?? event.creatorId ?? '').trim(); + if (!keyId) + throw new Error(`Invalid ${event.eventType}: keyId is required`); + const proposed = + event.eventType === 'RetirementProposed' || + event.eventType === 'RETIREMENT_PROPOSED'; + const deprecated = + event.eventType === 'DeprecationExecuted' || + event.eventType === 'DEPRECATION_EXECUTED'; + await prisma.keyRetirement.upsert({ + where: { + txHash_eventIndex: { + txHash: event.txHash, + eventIndex: event.eventIndex, + }, + }, + create: { + keyId, + eventType: event.eventType, + status: proposed + ? 'proposed' + : deprecated + ? 'deprecated' + : 'retired', + buybackPrice: + event.buybackPrice == null + ? undefined + : String(event.buybackPrice), + noticeExpiry: + (event.noticeExpiry ?? event.noticeExpiresAt) + ? eventDate(event.noticeExpiry ?? event.noticeExpiresAt) + : undefined, + reason: event.reason == null ? undefined : String(event.reason), + occurredAt: eventDate( + event.occurredAt ?? + event.executedAt ?? + event.proposedAt ?? + event.timestamp + ), + txHash: event.txHash, + eventIndex: event.eventIndex, + ledger: Number(event.ledger ?? 0), + }, + update: {}, + }); + await cacheInvalidate(keyRetirementCacheKey(keyId)); + }); +} diff --git a/src/modules/indexer/milestone-claim-indexer.service.ts b/src/modules/indexer/milestone-claim-indexer.service.ts new file mode 100644 index 00000000..6442917d --- /dev/null +++ b/src/modules/indexer/milestone-claim-indexer.service.ts @@ -0,0 +1,80 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheInvalidate } from '../../utils/redis.utils'; +import { + IndexerChainEvent, + processIndexerChainEvents, +} from '../../utils/indexer-event-processor.utils'; +import { holderMilestoneCacheKeys } from '../keys/holder-milestones.service'; + +export const MILESTONE_REWARD_CLAIMED_EVENT_TYPES = new Set([ + 'MilestoneRewardClaimed', + 'MILESTONE_REWARD_CLAIMED', +]); + +function dateFrom(value: unknown): Date { + const n = Number(value); + const date = + value != null && value !== '' + ? new Date( + Number.isFinite(n) && /^\d+$/.test(String(value)) + ? n < 1e12 + ? n * 1000 + : n + : String(value) + ) + : new Date(); + return Number.isNaN(date.getTime()) ? new Date() : date; +} + +export async function processMilestoneRewardClaimedEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!MILESTONE_REWARD_CLAIMED_EVENT_TYPES.has(event.eventType)) return; + const keyId = String(event.keyId ?? event.creatorId ?? '').trim(); + const wallet = String( + event.wallet ?? event.holder ?? event.actor ?? '' + ).trim(); + const milestoneDays = Number( + event.milestoneDays ?? event.milestone ?? event.days + ); + if ( + !keyId || + !wallet || + !Number.isInteger(milestoneDays) || + milestoneDays < 0 + ) { + throw new Error( + `Invalid ${event.eventType}: keyId, wallet and milestoneDays are required` + ); + } + await prisma.milestoneClaim.upsert({ + where: { + txHash_eventIndex: { + txHash: event.txHash, + eventIndex: event.eventIndex, + }, + }, + create: { + keyId, + wallet, + milestoneDays, + rewardAmount: + event.rewardAmount == null + ? undefined + : String(event.rewardAmount), + claimedAt: dateFrom( + event.claimedAt ?? event.occurredAt ?? event.timestamp + ), + txHash: event.txHash, + eventIndex: event.eventIndex, + ledger: Number(event.ledger ?? 0), + }, + update: {}, + }); + await cacheInvalidate( + holderMilestoneCacheKeys.key(keyId, wallet), + holderMilestoneCacheKeys.wallet(wallet) + ); + }); +} diff --git a/src/modules/keys/curve-updates.service.ts b/src/modules/keys/curve-updates.service.ts new file mode 100644 index 00000000..cc526897 --- /dev/null +++ b/src/modules/keys/curve-updates.service.ts @@ -0,0 +1,89 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; + +export const CURVE_UPDATE_CACHE_TTL_SECONDS = 60; +export const curveUpdateCacheKeys = { + pending: (keyId: string) => `keys:curve-updates:${keyId}:pending`, + history: (keyId: string) => `keys:curve-updates:${keyId}:history`, +}; + +type CurveUpdateRow = { + proposalId: string; + status: string; + proposedParams: unknown; + eta: Date; + proposedAt: Date; + completedAt: Date | null; + outcome: string | null; +}; + +function serialize(row: CurveUpdateRow, now: Date) { + return { + proposal_id: row.proposalId, + status: row.status, + proposed_params: row.proposedParams, + eta: row.eta.toISOString(), + eta_seconds_remaining: Math.max( + 0, + Math.ceil((row.eta.getTime() - now.getTime()) / 1000) + ), + proposed_at: row.proposedAt.toISOString(), + completed_at: row.completedAt?.toISOString() ?? null, + outcome: row.outcome, + }; +} + +export async function getPendingCurveUpdates(keyId: string, now = new Date()) { + const cacheKey = curveUpdateCacheKeys.pending(keyId); + // Cache stable DB fields only. Countdown is deliberately recomputed below. + let rows = await cacheGetJson(cacheKey); + if (!rows) { + rows = (await prisma.curveUpdate.findMany({ + where: { keyId, status: 'pending' }, + orderBy: [{ eta: 'asc' }, { proposedAt: 'asc' }], + select: { + proposalId: true, + status: true, + proposedParams: true, + eta: true, + proposedAt: true, + completedAt: true, + outcome: true, + }, + })) as CurveUpdateRow[]; + await cacheSetJson(cacheKey, rows, CURVE_UPDATE_CACHE_TTL_SECONDS); + } + return rows.map(row => + serialize( + { + ...row, + eta: new Date(row.eta), + proposedAt: new Date(row.proposedAt), + completedAt: row.completedAt ? new Date(row.completedAt) : null, + }, + now + ) + ); +} + +export async function getCurveUpdateHistory(keyId: string) { + const cacheKey = curveUpdateCacheKeys.history(keyId); + const cached = await cacheGetJson[]>(cacheKey); + if (cached) return cached; + const rows = (await prisma.curveUpdate.findMany({ + where: { keyId, status: { in: ['completed', 'cancelled'] } }, + orderBy: { completedAt: 'desc' }, + select: { + proposalId: true, + status: true, + proposedParams: true, + eta: true, + proposedAt: true, + completedAt: true, + outcome: true, + }, + })) as CurveUpdateRow[]; + const result = rows.map(row => serialize(row, new Date())); + await cacheSetJson(cacheKey, result, CURVE_UPDATE_CACHE_TTL_SECONDS); + return result; +} diff --git a/src/modules/keys/holder-milestones.service.ts b/src/modules/keys/holder-milestones.service.ts new file mode 100644 index 00000000..37b809fa --- /dev/null +++ b/src/modules/keys/holder-milestones.service.ts @@ -0,0 +1,111 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; + +export const HOLDER_MILESTONE_CACHE_TTL_SECONDS = 60; +export const HOLDER_MILESTONE_DAYS = [30, 90, 180, 365] as const; +export const holderMilestoneCacheKeys = { + key: (keyId: string, wallet: string) => `keys:milestones:${keyId}:${wallet}`, + wallet: (wallet: string) => `milestones:${wallet}`, +}; + +type Holding = { creatorId: string; createdAt: Date }; +type Claim = { + keyId: string; + milestoneDays: number; + claimedAt: Date; + rewardAmount: unknown; +}; + +function buildStatus(holding: Holding, claims: Claim[], now: Date) { + const firstPurchaseAt = new Date(holding.createdAt); + const daysHeld = Math.max( + 0, + Math.floor((now.getTime() - firstPurchaseAt.getTime()) / 86_400_000) + ); + const byDay = new Map(claims.map(claim => [claim.milestoneDays, claim])); + const milestones = HOLDER_MILESTONE_DAYS.map(days => { + const claim = byDay.get(days); + return { + days, + claimed: Boolean(claim), + eligible: daysHeld >= days, + days_remaining: Math.max(0, days - daysHeld), + claimed_at: claim ? new Date(claim.claimedAt).toISOString() : null, + reward_amount: + claim?.rewardAmount == null ? null : String(claim.rewardAmount), + }; + }); + return { + key_id: holding.creatorId, + first_purchase_at: firstPurchaseAt.toISOString(), + days_held: daysHeld, + milestones, + next_eligible_milestone: milestones.find(item => !item.claimed) ?? null, + }; +} + +async function loadWalletData(wallet: string, keyId?: string) { + const holdings = (await prisma.keyOwnership.findMany({ + where: { + ownerAddress: wallet, + balance: { gt: 0 }, + ...(keyId ? { creatorId: keyId } : {}), + }, + select: { creatorId: true, createdAt: true }, + orderBy: { creatorId: 'asc' }, + })) as Holding[]; + const claims = (await prisma.milestoneClaim.findMany({ + where: { wallet, ...(keyId ? { keyId } : {}) }, + select: { + keyId: true, + milestoneDays: true, + claimedAt: true, + rewardAmount: true, + }, + })) as Claim[]; + return { holdings, claims }; +} + +export async function getKeyHolderMilestones( + keyId: string, + wallet: string, + now = new Date() +) { + const cacheKey = holderMilestoneCacheKeys.key(keyId, wallet); + let data = + await cacheGetJson>>(cacheKey); + if (!data) { + data = await loadWalletData(wallet, keyId); + await cacheSetJson(cacheKey, data, HOLDER_MILESTONE_CACHE_TTL_SECONDS); + } + const holding = data.holdings[0]; + if (!holding) + return { + key_id: keyId, + wallet, + days_held: 0, + milestones: [], + next_eligible_milestone: null, + }; + return { wallet, ...buildStatus(holding, data.claims, now) }; +} + +export async function getWalletMilestones(wallet: string, now = new Date()) { + const cacheKey = holderMilestoneCacheKeys.wallet(wallet); + let data = + await cacheGetJson>>(cacheKey); + if (!data) { + data = await loadWalletData(wallet); + await cacheSetJson(cacheKey, data, HOLDER_MILESTONE_CACHE_TTL_SECONDS); + } + return { + wallet, + keys: data.holdings.map(holding => + buildStatus( + holding, + data!.claims.filter(claim => claim.keyId === holding.creatorId), + now + ) + ), + }; +} diff --git a/src/modules/keys/key-launch-auction.service.ts b/src/modules/keys/key-launch-auction.service.ts new file mode 100644 index 00000000..3c8e566d --- /dev/null +++ b/src/modules/keys/key-launch-auction.service.ts @@ -0,0 +1,98 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; + +export const ACTIVE_AUCTION_CACHE_TTL_SECONDS = 10; +export const SETTLED_AUCTION_CACHE_TTL_SECONDS = 300; +export const keyLaunchAuctionCacheKeys = { + state: (keyId: string) => `keys:launch-auction:${keyId}`, + bids: (keyId: string) => `keys:launch-auction:${keyId}:bids:*`, +}; + +type AuctionRow = { + auctionId: string; + keyId: string; + status: string; + reserve: unknown; + highestBid: unknown; + highestBidder: string | null; + clearingPrice: unknown; + startsAt: Date | string; + endsAt: Date | string; + settledAt: Date | string | null; +}; + +function formatAuction(row: AuctionRow, now: Date) { + const endsAt = new Date(row.endsAt); + return { + auction_id: row.auctionId, + key_id: row.keyId, + status: row.status, + highest_bid: row.highestBid == null ? null : String(row.highestBid), + highest_bidder: row.highestBidder, + reserve: String(row.reserve), + starts_at: new Date(row.startsAt).toISOString(), + ends_at: endsAt.toISOString(), + countdown_seconds: + row.status === 'active' + ? Math.max(0, Math.ceil((endsAt.getTime() - now.getTime()) / 1000)) + : 0, + clearing_price: + row.clearingPrice == null ? null : String(row.clearingPrice), + settled_at: row.settledAt ? new Date(row.settledAt).toISOString() : null, + }; +} + +export async function getKeyLaunchAuction(keyId: string, now = new Date()) { + const cacheKey = keyLaunchAuctionCacheKeys.state(keyId); + let row = await cacheGetJson(cacheKey); + if (!row) { + row = (await prisma.keyLaunchAuction.findUnique({ + where: { keyId }, + })) as AuctionRow | null; + if (!row) return null; + await cacheSetJson( + cacheKey, + row, + row.status === 'active' + ? ACTIVE_AUCTION_CACHE_TTL_SECONDS + : SETTLED_AUCTION_CACHE_TTL_SECONDS + ); + } + return formatAuction(row, now); +} + +export async function getKeyLaunchAuctionBids( + keyId: string, + page = 1, + limit = 20 +) { + const auction = await prisma.keyLaunchAuction.findUnique({ + where: { keyId }, + select: { auctionId: true }, + }); + if (!auction) return null; + const [rows, total] = await Promise.all([ + prisma.keyLaunchAuctionBid.findMany({ + where: { auctionId: auction.auctionId }, + orderBy: [ + { amount: 'desc' }, + { placedAt: 'desc' }, + { eventIndex: 'desc' }, + ], + skip: (page - 1) * limit, + take: limit, + }), + prisma.keyLaunchAuctionBid.count({ + where: { auctionId: auction.auctionId }, + }), + ]); + return { + bids: rows.map(row => ({ + bidder: row.bidder, + amount: String(row.amount), + placed_at: row.placedAt.toISOString(), + tx_hash: row.txHash, + })), + pagination: { page, limit, total, total_pages: Math.ceil(total / limit) }, + }; +} diff --git a/src/modules/keys/key-retirement.service.ts b/src/modules/keys/key-retirement.service.ts new file mode 100644 index 00000000..49671b6f --- /dev/null +++ b/src/modules/keys/key-retirement.service.ts @@ -0,0 +1,98 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; + +export const KEY_RETIREMENT_CACHE_TTL_SECONDS = 60; +export const keyRetirementCacheKey = (keyId: string) => + `keys:retirement:${keyId}`; + +type RetirementRow = { + eventType: string; + status: string; + buybackPrice: unknown; + noticeExpiry: Date | string | null; + reason: string | null; + occurredAt: Date | string; + txHash: string; + eventIndex: number; + ledger: number; +}; + +async function getRows(keyId: string): Promise { + const cacheKey = keyRetirementCacheKey(keyId); + const cached = await cacheGetJson(cacheKey); + if (cached) return cached; + const rows = (await prisma.keyRetirement.findMany({ + where: { keyId }, + orderBy: [{ occurredAt: 'asc' }, { eventIndex: 'asc' }], + select: { + eventType: true, + status: true, + buybackPrice: true, + noticeExpiry: true, + reason: true, + occurredAt: true, + txHash: true, + eventIndex: true, + ledger: true, + }, + })) as RetirementRow[]; + await cacheSetJson(cacheKey, rows, KEY_RETIREMENT_CACHE_TTL_SECONDS); + return rows; +} + +export async function getKeyRetirement(keyId: string, now = new Date()) { + const rows = await getRows(keyId); + if (rows.length === 0) { + return { + key_id: keyId, + status: 'none', + buyback_price: null, + notice_expiry: null, + notice_seconds_remaining: 0, + reason: null, + }; + } + const state = rows.reduce( + (current, row) => ({ + status: row.status, + buybackPrice: row.buybackPrice ?? current.buybackPrice, + noticeExpiry: row.noticeExpiry ?? current.noticeExpiry, + reason: row.reason ?? current.reason, + }), + { + status: 'none', + buybackPrice: null as unknown, + noticeExpiry: null as Date | string | null, + reason: null as string | null, + } + ); + const expiry = state.noticeExpiry ? new Date(state.noticeExpiry) : null; + return { + key_id: keyId, + status: state.status, + buyback_price: + state.buybackPrice == null ? null : String(state.buybackPrice), + notice_expiry: expiry?.toISOString() ?? null, + notice_seconds_remaining: expiry + ? Math.max(0, Math.ceil((expiry.getTime() - now.getTime()) / 1000)) + : 0, + reason: state.reason, + }; +} + +export async function getKeyRetirementHistory(keyId: string) { + const rows = await getRows(keyId); + return [...rows].reverse().map(row => ({ + event_type: row.eventType, + status: row.status, + buyback_price: row.buybackPrice == null ? null : String(row.buybackPrice), + notice_expiry: row.noticeExpiry + ? new Date(row.noticeExpiry).toISOString() + : null, + reason: row.reason, + occurred_at: new Date(row.occurredAt).toISOString(), + tx_hash: row.txHash, + event_index: row.eventIndex, + ledger: row.ledger, + })); +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index 0977d98f..56b4fae3 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -128,6 +128,10 @@ import { calculateEffectiveWeight, } from '../staking/staking.service'; import { getKeyCurveMilestones } from './key-milestones.service'; +import { getPendingCurveUpdates, getCurveUpdateHistory } from './curve-updates.service'; +import { getKeyHolderMilestones } from './holder-milestones.service'; +import { getKeyRetirement, getKeyRetirementHistory } from './key-retirement.service'; +import { getKeyLaunchAuction, getKeyLaunchAuctionBids } from './key-launch-auction.service'; import { getSunsetWatchList } from './key-sunset-watch.service'; import { getKeyPaymentAssetAnalytics, @@ -243,6 +247,11 @@ const keyPriceQuerySchema = z.object({ .default(1), }); +const launchAuctionBidsQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(20), +}); + const router = Router(); const registerKeyBodySchema = z.preprocess( @@ -872,6 +881,84 @@ router.get('/:keyId/curve-config', async (req, res, next) => { } }); +router.get('/:keyId/curve-updates/pending', async (req, res, next) => { + try { + sendSuccess(res, await getPendingCurveUpdates(String(req.params.keyId))); + } catch (error) { + next(error); + } +}); + +router.get('/:keyId/curve-updates/history', async (req, res, next) => { + try { + sendSuccess(res, await getCurveUpdateHistory(String(req.params.keyId))); + } catch (error) { + next(error); + } +}); + +router.get('/:keyId/milestones/:wallet', async (req, res, next) => { + try { + sendSuccess( + res, + await getKeyHolderMilestones( + String(req.params.keyId), + String(req.params.wallet) + ) + ); + } catch (error) { + next(error); + } +}); + +router.get('/:keyId/retirement', async (req, res, next) => { + try { + sendSuccess(res, await getKeyRetirement(String(req.params.keyId))); + } catch (error) { + next(error); + } +}); + +router.get('/:keyId/retirement/history', async (req, res, next) => { + try { + sendSuccess(res, await getKeyRetirementHistory(String(req.params.keyId))); + } catch (error) { + next(error); + } +}); + +router.get('/:keyId/auction', async (req, res, next) => { + try { + const result = await getKeyLaunchAuction(String(req.params.keyId)); + if (!result) return sendNotFound(res, 'Auction'); + sendSuccess(res, result); + } catch (error) { + next(error); + } +}); + +router.get('/:keyId/auction/bids', async (req, res, next) => { + const parsed = launchAuctionBidsQuerySchema.safeParse(req.query); + if (!parsed.success) { + return sendValidationError( + res, + 'Invalid pagination', + zodIssuesToDetails(parsed.error.issues) + ); + } + try { + const result = await getKeyLaunchAuctionBids( + String(req.params.keyId), + parsed.data.page, + parsed.data.limit + ); + if (!result) return sendNotFound(res, 'Auction'); + sendSuccess(res, result); + } catch (error) { + next(error); + } +}); + // ── GET /:keyId/twap (#1082) ────────────────────────────────── // TWAP from the indexed TWAPUpdated contract events (twap_snapshots table), // windows 1h|4h|24h, 60s cache invalidated per key by the indexer. diff --git a/src/modules/keys/milestones.routes.ts b/src/modules/keys/milestones.routes.ts new file mode 100644 index 00000000..656d09c7 --- /dev/null +++ b/src/modules/keys/milestones.routes.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { sendSuccess } from '../../utils/api-response.utils'; +import { getWalletMilestones } from './holder-milestones.service'; + +const router = Router(); +router.get('/:wallet', async (req, res, next) => { + try { + sendSuccess(res, await getWalletMilestones(String(req.params.wallet))); + } catch (error) { + next(error); + } +}); +export default router;