Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/constants/notifications.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function makeHolder(
key_count: key_balance,
share_percent: 0,
rank: index,
stakedQuantity: 0,
liquidQuantity: key_balance,
...overrides,
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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 = {
Expand All @@ -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)', () => {
Expand Down
2 changes: 2 additions & 0 deletions src/modules/creators/creator-holders.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ function makeHolder(
key_count: key_balance,
share_percent: 0,
rank: index,
stakedQuantity: 0,
liquidQuantity: key_balance,
...overrides,
};
}
Expand Down
94 changes: 70 additions & 24 deletions src/modules/creators/creator-holders.service.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<number> {
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;
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions src/modules/keys/key-auction.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// src/modules/keys/key-auction.service.test.ts
const redisStore = new Map<string, string>();

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);
});
});
106 changes: 106 additions & 0 deletions src/modules/keys/key-auction.service.ts
Original file line number Diff line number Diff line change
@@ -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<KeyAuction> {
const cacheKey = REDIS_KEYS.keyAuction(keyId);

const cached = await cacheGetJson<KeyAuction>(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<KeyAuction | null> {
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<void> {
await cacheInvalidate(REDIS_KEYS.keyAuction(keyId));
}
Loading
Loading