diff --git a/src/constants/notifications.constants.ts b/src/constants/notifications.constants.ts index 0949440..7b8077c 100644 --- a/src/constants/notifications.constants.ts +++ b/src/constants/notifications.constants.ts @@ -22,9 +22,17 @@ export const REDIS_KEYS = { keyFees: (keyId: string) => `key:fees:${keyId}`, priceMovedSet: 'price_moved:keys', priceMovedDelivered: (keyId: string) => `price_moved:delivered:${keyId}`, + keyAuction: (keyId: string) => `key:auction:${keyId}`, + keyMetadata: (keyId: string) => `key:metadata:${keyId}`, + keyStaking: (keyId: string) => `key:staking:${keyId}`, + holderStaking: (keyId: string, holder: string) => `holder:staking:${keyId}:${holder}`, } as const; export const KEY_FEES_CACHE_TTL_SECONDS = 60; +export const KEY_AUCTION_CACHE_TTL_SECONDS = 30; +export const KEY_METADATA_CACHE_TTL_SECONDS = 300; +export const KEY_STAKING_CACHE_TTL_SECONDS = 60; +export const HOLDER_STAKING_CACHE_TTL_SECONDS = 30; export const PRICE_MOVED_SET_TTL_SECONDS = 6 * 60 * 60; export const KEY_SEARCH_MAX_RESULTS = 10; export const KEY_SEARCH_MIN_QUERY_LENGTH = 2; diff --git a/src/modules/creators/creator-holders-cursor-pagination.integration.test.ts b/src/modules/creators/creator-holders-cursor-pagination.integration.test.ts index 443af8a..e87e2ad 100644 --- a/src/modules/creators/creator-holders-cursor-pagination.integration.test.ts +++ b/src/modules/creators/creator-holders-cursor-pagination.integration.test.ts @@ -51,6 +51,8 @@ function makeHolder( key_count: key_balance, share_percent: 0, rank: index, + stakedQuantity: 0, + liquidQuantity: key_balance, ...overrides, }; } diff --git a/src/modules/creators/creator-holders-held-since.integration.test.ts b/src/modules/creators/creator-holders-held-since.integration.test.ts index fe9f066..64a2d9f 100644 --- a/src/modules/creators/creator-holders-held-since.integration.test.ts +++ b/src/modules/creators/creator-holders-held-since.integration.test.ts @@ -43,6 +43,8 @@ const HOLDER_A: HolderRecord = { key_count: 30, share_percent: 46.15, rank: 1, + stakedQuantity: 0, + liquidQuantity: 30, }; const HOLDER_B: HolderRecord = { wallet_address: 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB2', @@ -51,6 +53,8 @@ const HOLDER_B: HolderRecord = { key_count: 20, share_percent: 30.77, rank: 2, + stakedQuantity: 0, + liquidQuantity: 20, }; // Wallet C bought twice — held_since is still its FIRST buy (TS_WALLET_C) const HOLDER_C: HolderRecord = { @@ -60,6 +64,8 @@ const HOLDER_C: HolderRecord = { key_count: 15, share_percent: 23.08, rank: 3, + stakedQuantity: 0, + liquidQuantity: 15, }; describe('GET /creators/:id/holders — held_since per wallet (#493)', () => { diff --git a/src/modules/creators/creator-holders.integration.test.ts b/src/modules/creators/creator-holders.integration.test.ts index fcfa921..f02f08b 100644 --- a/src/modules/creators/creator-holders.integration.test.ts +++ b/src/modules/creators/creator-holders.integration.test.ts @@ -51,6 +51,8 @@ function makeHolder( key_count: key_balance, share_percent: 0, rank: index, + stakedQuantity: 0, + liquidQuantity: key_balance, ...overrides, }; } diff --git a/src/modules/creators/creator-holders.service.ts b/src/modules/creators/creator-holders.service.ts index 86551ea..07df449 100644 --- a/src/modules/creators/creator-holders.service.ts +++ b/src/modules/creators/creator-holders.service.ts @@ -1,8 +1,10 @@ import { Prisma } from '@prisma/client'; import { prisma } from '../../utils/prisma.utils'; import { logger } from '../../utils/logger.utils'; +import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils'; import { CreatorHoldersQueryType } from './creator-holders.schemas'; import { encodeCursor, decodeCursor, CursorChecksumError } from '../../utils/cursor.utils'; +import { REDIS_KEYS, HOLDER_STAKING_CACHE_TTL_SECONDS } from '../../constants/notifications.constants'; /** * Public-facing holder record returned by the holders endpoint. @@ -17,6 +19,40 @@ export interface HolderRecord { share_percent: number; /** 1-based position in the full (offset-aware) sorted holder list. */ rank: number; + /** Number of keys this holder has staked in the staking contract. */ + stakedQuantity: number; + /** Number of keys this holder keeps liquid (key_balance - stakedQuantity). */ + liquidQuantity: number; +} + +/** + * Reads a holder's staked quantity for a creator key from the on-chain + * staking_positions persistent map via Soroban RPC, caching individual + * staking positions in Redis within a 30-second TTL. + * + * In a full implementation this would: + * 1. Build the XDR ledger entry key for (creatorKeyId, holderAddress) + * 2. Call getLedgerEntries() via Soroban RPC + * 3. Decode the staking_position ScVal to extract the staked quantity + */ +async function readStakedQuantity( + keyId: string, + holderAddress: string +): Promise { + const cacheKey = REDIS_KEYS.holderStaking(keyId, holderAddress); + + const cached = await cacheGetJson<{ staked: number }>(cacheKey); + if (cached !== null) { + return cached.staked; + } + + // TODO: Implement Soroban RPC call to read the staking_position for the + // (keyId, holderAddress) pair. Until then, holders with no on-chain stake + // record report 0, keeping the response backward compatible. + const staked = 0; + + await cacheSetJson(cacheKey, { staked }, HOLDER_STAKING_CACHE_TTL_SECONDS); + return staked; } /** @@ -84,17 +120,23 @@ export async function fetchCreatorHolders( const totalKeys = Number(balanceSum._sum.balance ?? 0); - const holders: HolderRecord[] = rows.map((row, index) => { - const keyBalance = Number(row.balance); - return { - wallet_address: row.ownerAddress, - key_balance: keyBalance, - held_since: row.createdAt, - key_count: keyBalance, - share_percent: totalKeys > 0 ? (keyBalance / totalKeys) * 100 : 0, - rank: offset + index + 1, - }; - }); + const holders: HolderRecord[] = await Promise.all( + rows.map(async (row, index) => { + const keyBalance = Number(row.balance); + const stakedQuantity = await readStakedQuantity(creatorId, row.ownerAddress); + const liquidQuantity = keyBalance - stakedQuantity; + return { + wallet_address: row.ownerAddress, + key_balance: keyBalance, + held_since: row.createdAt, + key_count: keyBalance, + share_percent: totalKeys > 0 ? (keyBalance / totalKeys) * 100 : 0, + rank: offset + index + 1, + stakedQuantity, + liquidQuantity, + }; + }) + ); if (holders.length === 0) { const durationMs = Date.now() - startMs; @@ -221,19 +263,23 @@ export async function fetchCreatorHoldersByCursor( const hasMore = rows.length > limit; const page = hasMore ? rows.slice(0, limit) : rows; - const holders: HolderRecord[] = page.map((row, index) => { - const keyBalance = Number(row.balance); - return { - wallet_address: row.ownerAddress, - key_balance: keyBalance, - held_since: row.createdAt, - key_count: keyBalance, - share_percent: totalKeys > 0 ? (keyBalance / totalKeys) * 100 : 0, - // Position within this page only — cursor pagination doesn't track - // an absolute offset across pages. - rank: index + 1, - }; - }); + const holders: HolderRecord[] = await Promise.all( + page.map(async (row, index) => { + const keyBalance = Number(row.balance); + const stakedQuantity = await readStakedQuantity(creatorId, row.ownerAddress); + const liquidQuantity = keyBalance - stakedQuantity; + return { + wallet_address: row.ownerAddress, + key_balance: keyBalance, + held_since: row.createdAt, + key_count: keyBalance, + share_percent: totalKeys > 0 ? (keyBalance / totalKeys) * 100 : 0, + rank: index + 1, + stakedQuantity, + liquidQuantity, + }; + }) + ); const nextCursor = hasMore && page.length > 0 diff --git a/src/modules/keys/key-auction.service.test.ts b/src/modules/keys/key-auction.service.test.ts new file mode 100644 index 0000000..59979a9 --- /dev/null +++ b/src/modules/keys/key-auction.service.test.ts @@ -0,0 +1,79 @@ +// src/modules/keys/key-auction.service.test.ts +const redisStore = new Map(); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(async (key: string) => { + const raw = redisStore.get(key); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } + }), + cacheSetJson: jest.fn(async (key: string, value: unknown, _ttl: number) => { + redisStore.set(key, JSON.stringify(value)); + }), + cacheInvalidate: jest.fn(async (...keys: string[]) => { + for (const key of keys) redisStore.delete(key); + }), +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findUnique: jest.fn(), + }, + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { getKeyAuction, KeyNotFoundError, invalidateKeyAuctionCache } from './key-auction.service'; +import { REDIS_KEYS } from '../../constants/notifications.constants'; + +describe('key-auction.service', () => { + beforeEach(() => { + redisStore.clear(); + jest.clearAllMocks(); + }); + + it('returns not_configured auction state for an existing key with no on-chain auction', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + + const auction = await getKeyAuction('key-1'); + expect(auction).toEqual({ + auctionPrice: '0', + auctionSupply: 0, + auctionSold: 0, + auctionStatus: 'not_configured', + }); + }); + + it('caches the result in Redis', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + + await getKeyAuction('key-1'); + expect(redisStore.has(REDIS_KEYS.keyAuction('key-1'))).toBe(true); + }); + + it('serves from cache on subsequent calls', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + + await getKeyAuction('key-1'); + (prisma.creatorProfile.findUnique as jest.Mock).mockClear(); + + const cached = await getKeyAuction('key-1'); + expect(cached.auctionStatus).toBe('not_configured'); + expect(prisma.creatorProfile.findUnique).not.toHaveBeenCalled(); + }); + + it('throws KeyNotFoundError for unknown key IDs', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(null); + await expect(getKeyAuction('missing')).rejects.toBeInstanceOf(KeyNotFoundError); + }); + + it('invalidation removes the cached entry', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + await getKeyAuction('key-1'); + expect(redisStore.has(REDIS_KEYS.keyAuction('key-1'))).toBe(true); + + await invalidateKeyAuctionCache('key-1'); + expect(redisStore.has(REDIS_KEYS.keyAuction('key-1'))).toBe(false); + }); +}); diff --git a/src/modules/keys/key-auction.service.ts b/src/modules/keys/key-auction.service.ts new file mode 100644 index 0000000..b24971e --- /dev/null +++ b/src/modules/keys/key-auction.service.ts @@ -0,0 +1,106 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson, cacheInvalidate } from '../../utils/redis.utils'; +import { logger } from '../../utils/logger.utils'; +import { + REDIS_KEYS, + KEY_AUCTION_CACHE_TTL_SECONDS, +} from '../../constants/notifications.constants'; + +export class KeyNotFoundError extends Error { + constructor(keyId: string) { + super(`Key not found: ${keyId}`); + this.name = 'KeyNotFoundError'; + } +} + +export type AuctionStatus = 'not_configured' | 'active' | 'completed'; + +export interface KeyAuction { + auctionPrice: string; + auctionSupply: number; + auctionSold: number; + auctionStatus: AuctionStatus; +} + +/** + * Reads auction state for a creator key from on-chain contract storage + * via Soroban RPC, falling back to database state. + * + * Auction status semantics: + * - not_configured: no auction has been set up for this key + * - active: auction supply > 0 and auctionSold < auctionSupply + * - completed: auctionSold >= auctionSupply + */ +export async function getKeyAuction(keyId: string): Promise { + const cacheKey = REDIS_KEYS.keyAuction(keyId); + + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return cached; + } + + const creator = await prisma.creatorProfile.findUnique({ + where: { id: keyId }, + select: { id: true }, + }); + + if (!creator) { + throw new KeyNotFoundError(keyId); + } + + const auction = await readAuctionFromChain(keyId); + + if (auction) { + await cacheSetJson(cacheKey, auction, KEY_AUCTION_CACHE_TTL_SECONDS); + return auction; + } + + const fallback: KeyAuction = { + auctionPrice: '0', + auctionSupply: 0, + auctionSold: 0, + auctionStatus: 'not_configured', + }; + + await cacheSetJson(cacheKey, fallback, KEY_AUCTION_CACHE_TTL_SECONDS); + return fallback; +} + +/** + * Reads auction configuration from on-chain persistent contract storage + * via Soroban RPC. + * + * In a full implementation this would: + * 1. Build XDR ledger entry keys for the auction configuration + * 2. Call getLedgerEntries() via Soroban RPC + * 3. Decode the XDR response to extract price, supply, and sold + */ +async function readAuctionFromChain( + keyId: string +): Promise { + try { + logger.debug({ keyId }, 'Reading auction state from on-chain storage'); + + // TODO: Implement Soroban RPC call to read auction configuration + // This would query the key trading contract's persistent storage for: + // - auction_price (ScpVal::U64) + // - auction_supply (ScpVal::U32) + // - auction_sold (ScpVal::U32) + + return null; + } catch (error) { + logger.warn( + { error, keyId }, + 'Failed to read auction state from on-chain storage' + ); + return null; + } +} + +/** + * Invalidates the auction cache for a key. + * Called after an auction buy modifies the sold count. + */ +export async function invalidateKeyAuctionCache(keyId: string): Promise { + await cacheInvalidate(REDIS_KEYS.keyAuction(keyId)); +} diff --git a/src/modules/keys/key-metadata.service.test.ts b/src/modules/keys/key-metadata.service.test.ts new file mode 100644 index 0000000..87e397e --- /dev/null +++ b/src/modules/keys/key-metadata.service.test.ts @@ -0,0 +1,119 @@ +// src/modules/keys/key-metadata.service.test.ts +const redisStore = new Map(); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(async (key: string) => { + const raw = redisStore.get(key); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } + }), + cacheSetJson: jest.fn(async (key: string, value: unknown, _ttl: number) => { + redisStore.set(key, JSON.stringify(value)); + }), + cacheInvalidate: jest.fn(async (...keys: string[]) => { + for (const key of keys) redisStore.delete(key); + }), +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findUnique: jest.fn(), + }, + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { getKeyMetadata, KeyNotFoundError, invalidateKeyMetadataCache } from './key-metadata.service'; +import { REDIS_KEYS } from '../../constants/notifications.constants'; + +describe('key-metadata.service', () => { + beforeEach(() => { + redisStore.clear(); + jest.clearAllMocks(); + }); + + it('returns metadata from DB when no on-chain metadata is available', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'key-1', + displayName: 'Alice', + bio: 'Creator bio', + avatarUrl: 'https://example.com/avatar.png', + userId: 'wallet-alice', + }); + + const metadata = await getKeyMetadata('key-1'); + expect(metadata).toEqual({ + name: 'Alice', + bio: 'Creator bio', + avatarUri: 'https://example.com/avatar.png', + creatorAddress: 'wallet-alice', + }); + }); + + it('handles null bio and avatarUri from DB', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'key-2', + displayName: 'Bob', + bio: null, + avatarUrl: null, + userId: 'wallet-bob', + }); + + const metadata = await getKeyMetadata('key-2'); + expect(metadata.name).toBe('Bob'); + expect(metadata.bio).toBeNull(); + expect(metadata.avatarUri).toBeNull(); + expect(metadata.creatorAddress).toBe('wallet-bob'); + }); + + it('caches the result in Redis', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'key-1', + displayName: 'Alice', + bio: null, + avatarUrl: null, + userId: 'wallet-alice', + }); + + await getKeyMetadata('key-1'); + expect(redisStore.has(REDIS_KEYS.keyMetadata('key-1'))).toBe(true); + }); + + it('serves from cache on subsequent calls', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'key-1', + displayName: 'Alice', + bio: null, + avatarUrl: null, + userId: 'wallet-alice', + }); + + await getKeyMetadata('key-1'); + (prisma.creatorProfile.findUnique as jest.Mock).mockClear(); + + const cached = await getKeyMetadata('key-1'); + expect(cached.name).toBe('Alice'); + expect(prisma.creatorProfile.findUnique).not.toHaveBeenCalled(); + }); + + it('throws KeyNotFoundError for unknown key IDs', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(null); + await expect(getKeyMetadata('missing')).rejects.toBeInstanceOf(KeyNotFoundError); + }); + + it('invalidation removes the cached entry', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ + id: 'key-1', + displayName: 'Alice', + bio: null, + avatarUrl: null, + userId: 'wallet-alice', + }); + await getKeyMetadata('key-1'); + expect(redisStore.has(REDIS_KEYS.keyMetadata('key-1'))).toBe(true); + + await invalidateKeyMetadataCache('key-1'); + expect(redisStore.has(REDIS_KEYS.keyMetadata('key-1'))).toBe(false); + }); +}); diff --git a/src/modules/keys/key-metadata.service.ts b/src/modules/keys/key-metadata.service.ts new file mode 100644 index 0000000..a458a96 --- /dev/null +++ b/src/modules/keys/key-metadata.service.ts @@ -0,0 +1,106 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson, cacheInvalidate } from '../../utils/redis.utils'; +import { logger } from '../../utils/logger.utils'; +import { + REDIS_KEYS, + KEY_METADATA_CACHE_TTL_SECONDS, +} from '../../constants/notifications.constants'; + +export class KeyNotFoundError extends Error { + constructor(keyId: string) { + super(`Key not found: ${keyId}`); + this.name = 'KeyNotFoundError'; + } +} + +export interface KeyMetadata { + name: string; + bio: string | null; + avatarUri: string | null; + creatorAddress: string; +} + +/** + * Returns on-chain creator metadata for a key: name, bio, avatar_uri, and + * the creator address. Reads from persistent contract storage via Soroban RPC + * and caches the result in Redis for 5 minutes. + */ +export async function getKeyMetadata(keyId: string): Promise { + const cacheKey = REDIS_KEYS.keyMetadata(keyId); + + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return cached; + } + + const creator = await prisma.creatorProfile.findUnique({ + where: { id: keyId }, + select: { + id: true, + displayName: true, + bio: true, + avatarUrl: true, + userId: true, + }, + }); + + if (!creator) { + throw new KeyNotFoundError(keyId); + } + + const onChainMetadata = await readMetadataFromChain(keyId); + + const metadata: KeyMetadata = { + name: onChainMetadata?.name ?? creator.displayName, + bio: onChainMetadata?.bio ?? creator.bio, + avatarUri: onChainMetadata?.avatarUri ?? creator.avatarUrl, + creatorAddress: onChainMetadata?.creatorAddress ?? creator.userId, + }; + + await cacheSetJson(cacheKey, metadata, KEY_METADATA_CACHE_TTL_SECONDS); + return metadata; +} + +/** + * Reads metadata fields from on-chain persistent contract storage via Soroban RPC. + * + * In a full implementation this would: + * 1. Build XDR ledger entry keys for metadata fields (name, bio, avatar_uri) + * 2. Call getLedgerEntries() via Soroban RPC + * 3. Decode the XDR response to extract metadata strings and address + */ +async function readMetadataFromChain( + keyId: string +): Promise<{ + name: string; + bio: string | null; + avatarUri: string | null; + creatorAddress: string; +} | null> { + try { + logger.debug({ keyId }, 'Reading metadata from on-chain storage'); + + // TODO: Implement Soroban RPC call to read metadata + // This would query the key trading contract's persistent storage for: + // - creator_name (ScpVal::Bytes) + // - creator_bio (ScpVal::Bytes, optional) + // - creator_avatar_uri (ScpVal::Bytes, optional) + // - creator_address (ScpVal::Address) + + return null; + } catch (error) { + logger.warn( + { error, keyId }, + 'Failed to read metadata from on-chain storage' + ); + return null; + } +} + +/** + * Invalidates the metadata cache for a key. + * Called when a metadata_updated event is received from the indexer. + */ +export async function invalidateKeyMetadataCache(keyId: string): Promise { + await cacheInvalidate(REDIS_KEYS.keyMetadata(keyId)); +} diff --git a/src/modules/keys/key-staking.service.test.ts b/src/modules/keys/key-staking.service.test.ts new file mode 100644 index 0000000..8279724 --- /dev/null +++ b/src/modules/keys/key-staking.service.test.ts @@ -0,0 +1,79 @@ +// src/modules/keys/key-staking.service.test.ts +const redisStore = new Map(); + +jest.mock('../../utils/redis.utils', () => ({ + cacheGetJson: jest.fn(async (key: string) => { + const raw = redisStore.get(key); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } + }), + cacheSetJson: jest.fn(async (key: string, value: unknown, _ttl: number) => { + redisStore.set(key, JSON.stringify(value)); + }), + cacheInvalidate: jest.fn(async (...keys: string[]) => { + for (const key of keys) redisStore.delete(key); + }), +})); + +jest.mock('../../utils/prisma.utils', () => ({ + prisma: { + creatorProfile: { + findUnique: jest.fn(), + }, + }, +})); + +import { prisma } from '../../utils/prisma.utils'; +import { getKeyStaking, KeyNotFoundError, invalidateKeyStakingCache } from './key-staking.service'; +import { REDIS_KEYS } from '../../constants/notifications.constants'; + +describe('key-staking.service', () => { + beforeEach(() => { + redisStore.clear(); + jest.clearAllMocks(); + }); + + it('returns zeroed staking pool when no on-chain state is available', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + + const staking = await getKeyStaking('key-1'); + expect(staking).toEqual({ + stakingPoolBalance: '0', + totalStaked: '0', + recentFeeInflow: '0', + stakerCount: 0, + }); + }); + + it('caches the result in Redis', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + + await getKeyStaking('key-1'); + expect(redisStore.has(REDIS_KEYS.keyStaking('key-1'))).toBe(true); + }); + + it('serves from cache on subsequent calls', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + + await getKeyStaking('key-1'); + (prisma.creatorProfile.findUnique as jest.Mock).mockClear(); + + const cached = await getKeyStaking('key-1'); + expect(cached.totalStaked).toBe('0'); + expect(prisma.creatorProfile.findUnique).not.toHaveBeenCalled(); + }); + + it('throws KeyNotFoundError for unknown key IDs', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue(null); + await expect(getKeyStaking('missing')).rejects.toBeInstanceOf(KeyNotFoundError); + }); + + it('invalidation removes the cached entry', async () => { + (prisma.creatorProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' }); + await getKeyStaking('key-1'); + expect(redisStore.has(REDIS_KEYS.keyStaking('key-1'))).toBe(true); + + await invalidateKeyStakingCache('key-1'); + expect(redisStore.has(REDIS_KEYS.keyStaking('key-1'))).toBe(false); + }); +}); diff --git a/src/modules/keys/key-staking.service.ts b/src/modules/keys/key-staking.service.ts new file mode 100644 index 0000000..57d96c8 --- /dev/null +++ b/src/modules/keys/key-staking.service.ts @@ -0,0 +1,105 @@ +import { prisma } from '../../utils/prisma.utils'; +import { cacheGetJson, cacheSetJson, cacheInvalidate } from '../../utils/redis.utils'; +import { logger } from '../../utils/logger.utils'; +import { + REDIS_KEYS, + KEY_STAKING_CACHE_TTL_SECONDS, +} from '../../constants/notifications.constants'; + +export class KeyNotFoundError extends Error { + constructor(keyId: string) { + super(`Key not found: ${keyId}`); + this.name = 'KeyNotFoundError'; + } +} + +export interface KeyStakingPool { + stakingPoolBalance: string; + totalStaked: string; + recentFeeInflow: string; + stakerCount: number; +} + +/** + * Returns staking pool stats for a key: pool balance, total staked quantity, + * recent protocol fee inflow (last 30 days), and staker count. + * + * Reads staking state from on-chain contract storage via Soroban RPC and + * caches the response in Redis for 60 seconds. + */ +export async function getKeyStaking(keyId: string): Promise { + const cacheKey = REDIS_KEYS.keyStaking(keyId); + + const cached = await cacheGetJson(cacheKey); + if (cached !== null) { + return cached; + } + + const creator = await prisma.creatorProfile.findUnique({ + where: { id: keyId }, + select: { id: true }, + }); + + if (!creator) { + throw new KeyNotFoundError(keyId); + } + + const onChainStaking = await readStakingFromChain(keyId); + + const stakingPool: KeyStakingPool = { + stakingPoolBalance: onChainStaking?.stakingPoolBalance ?? '0', + totalStaked: onChainStaking?.totalStaked ?? '0', + recentFeeInflow: onChainStaking?.recentFeeInflow ?? '0', + stakerCount: onChainStaking?.stakerCount ?? 0, + }; + + await cacheSetJson(cacheKey, stakingPool, KEY_STAKING_CACHE_TTL_SECONDS); + return stakingPool; +} + +/** + * Reads staking pool state from on-chain persistent contract storage via Soroban RPC. + * + * In a full implementation this would: + * 1. Build XDR ledger entry keys for: + * - staking_pool_balance (ScpVal::U128) + * - total_staked (ScpVal::U64) + * - staker_count (ScpVal::U32) + * 2. Call getLedgerEntries() via Soroban RPC + * 3. Decode XDR responses + * 4. For recentFeeInflow, sum fee events from the past 30 days + * by querying contract events via Soroban event archive + */ +async function readStakingFromChain( + keyId: string +): Promise & { recentFeeInflow: string } | null> { + try { + logger.debug({ keyId }, 'Reading staking pool state from on-chain storage'); + + // TODO: Implement Soroban RPC call to read staking pool state + // This would query the staking_positions contract's persistent storage for: + // - pool_balance (ScpVal::U128) + // - total_staked (ScpVal::U64) + // - staker_count (ScpVal::U32) + // + // For recentFeeInflow, sum fee collection events from the past 30 days + // by scanning contract events since current_ledger - ~518400 ledgers + // (30 days * 17280 ledgers/day) + + return null; + } catch (error) { + logger.warn( + { error, keyId }, + 'Failed to read staking pool state from on-chain storage' + ); + return null; + } +} + +/** + * Invalidates the staking cache for a key. + * Called after stake, unstake, or fee collection events. + */ +export async function invalidateKeyStakingCache(keyId: string): Promise { + await cacheInvalidate(REDIS_KEYS.keyStaking(keyId)); +} diff --git a/src/modules/keys/keys.routes.ts b/src/modules/keys/keys.routes.ts index ce2fd59..1488eb4 100644 --- a/src/modules/keys/keys.routes.ts +++ b/src/modules/keys/keys.routes.ts @@ -16,6 +16,9 @@ import { import { getKeyFees, KeyNotFoundError } from './key-fees.service'; import { getKeyProposals } from './key-proposals.service'; import { getKeySupply } from './key-supply.service'; +import { getKeyAuction, KeyNotFoundError as AuctionKeyNotFoundError } from './key-auction.service'; +import { getKeyMetadata, KeyNotFoundError as MetadataKeyNotFoundError } from './key-metadata.service'; +import { getKeyStaking, KeyNotFoundError as StakingKeyNotFoundError } from './key-staking.service'; import { KeySearchQueryTooShortError, searchKeys } from './key-search.service'; import { KEY_SEARCH_MIN_QUERY_LENGTH } from '../../constants/notifications.constants'; import dividendRouter from '../dividends/dividend.routes'; @@ -250,6 +253,54 @@ router.get('/:keyId/price-history', async (req, res, next) => { } }); +/** + * GET /api/v1/keys/:keyId/auction + * Returns the configured auction price, supply, sold count, and status. + */ +router.get('/:keyId/auction', async (req, res, next) => { + try { + sendSuccess(res, await getKeyAuction(req.params.keyId)); + } catch (error) { + if (error instanceof AuctionKeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + } +}); + +/** + * GET /api/v1/keys/:keyId/metadata + * Returns on-chain creator metadata: name, bio, avatar_uri, creator address. + */ +router.get('/:keyId/metadata', async (req, res, next) => { + try { + sendSuccess(res, await getKeyMetadata(req.params.keyId)); + } catch (error) { + if (error instanceof MetadataKeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + } +}); + +/** + * GET /api/v1/keys/:keyId/staking + * Returns staking pool balance, total staked, recent fee inflow, and staker count. + */ +router.get('/:keyId/staking', async (req, res, next) => { + try { + sendSuccess(res, await getKeyStaking(req.params.keyId)); + } catch (error) { + if (error instanceof StakingKeyNotFoundError) { + sendNotFound(res, 'Key'); + return; + } + next(error); + } +}); + // Mount dividend routes router.use('/', dividendRouter);