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
7 changes: 7 additions & 0 deletions prisma/schema/creator.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ model CreatorProfile {
buybackPriceXlm Decimal? @db.Decimal(20, 7)
/// Buyback window expiry; buybacks are rejected with 410 after this time (#882).
buybackExpiresAt DateTime?
/// Reason for deprecation, set by an admin action or an indexed contract event.
reason String?
/// Successor key designated to replace this one on deprecation, if any.
successorKeyId String?
/// When the on-chain KeySunsetFlagged event was processed for this key (#931).
/// Null while the key has not been flagged for sunset.
sunsetFlaggedAt DateTime?
Expand All @@ -50,8 +54,11 @@ model CreatorProfile {
followers Follow[]
walletFollowers WalletCreatorFollow[]
relaunches CreatorKeyRelaunch[]
successor CreatorProfile? @relation("KeySuccessor", fields: [successorKeyId], references: [id], onDelete: SetNull)
supersededKeys CreatorProfile[] @relation("KeySuccessor")

@@index([userId])
@@index([successorKeyId])
}

model CreatorKeyRelaunch {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
ALTER TABLE "CreatorProfile"
ADD COLUMN "reason" TEXT,
ADD COLUMN "successorKeyId" TEXT;

CREATE INDEX "CreatorProfile_successorKeyId_idx" ON "CreatorProfile"("successorKeyId");

ALTER TABLE "CreatorProfile"
ADD CONSTRAINT "CreatorProfile_successorKeyId_fkey"
FOREIGN KEY ("successorKeyId") REFERENCES "CreatorProfile"("id") ON DELETE SET NULL ON UPDATE CASCADE;
2 changes: 2 additions & 0 deletions src/constants/creator-list-projection.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* - isVerified: Verification status badge
* - createdAt: Creator registration timestamp
* - updatedAt: Creator profile update timestamp
* - deprecatedAt: Deprecation timestamp (null while the key is active)
* - priceSnapshot: Nested price read model (currentPrice, price24hAgo, lastTradeAt)
*/
export const CREATOR_LIST_DEFAULT_SELECT = {
Expand All @@ -29,6 +30,7 @@ export const CREATOR_LIST_DEFAULT_SELECT = {
isVerified: true,
createdAt: true,
updatedAt: true,
deprecatedAt: true,
priceSnapshot: {
select: {
currentPrice: true,
Expand Down
1 change: 1 addition & 0 deletions src/constants/notifications.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const REDIS_KEYS = {
priceMovedSet: 'price_moved:keys',
priceMovedDelivered: (keyId: string) => `price_moved:delivered:${keyId}`,
keySunsetEvent: (eventId: string) => `key_sunset:dispatch:${eventId}`,
keyDeprecationEvent: (eventId: string) => `key_deprecation:dispatch:${eventId}`,
} as const;

/** Trips surfaced as circuit_breaker_tripped notifications, newest first (#987). */
Expand Down
2 changes: 2 additions & 0 deletions src/modules/creators/creator-list-field-picker.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const sampleItem: CreatorListItem = {
currentPrice: '1000',
price24hAgo: '900',
priceChange24h: 11.11,
deprecated: false,
};

describe('CREATOR_LIST_PUBLIC_FIELDS', () => {
Expand All @@ -30,6 +31,7 @@ describe('CREATOR_LIST_PUBLIC_FIELDS', () => {
'currentPrice',
'price24hAgo',
'priceChange24h',
'deprecated',
];
for (const field of expected) {
expect(CREATOR_LIST_PUBLIC_FIELDS).toContain(field);
Expand Down
1 change: 1 addition & 0 deletions src/modules/creators/creator-list-field-picker.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const CREATOR_LIST_PUBLIC_FIELDS = [
'currentPrice',
'price24hAgo',
'priceChange24h',
'deprecated',
] as const;

export type CreatorListPublicField =
Expand Down
11 changes: 11 additions & 0 deletions src/modules/creators/creator-list-item.mapper.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ describe('mapCreatorListItem()', () => {
currentPrice: null,
price24hAgo: null,
priceChange24h: null,
deprecated: false,
});
});

it('flags a deprecated key', async () => {
const input = createSeededCreatorFixture(2, {
deprecatedAt: new Date('2026-01-01T00:00:00.000Z'),
});

const result = await mapCreatorListItem(input);

expect(result.deprecated).toBe(true);
});
});
3 changes: 3 additions & 0 deletions src/modules/creators/creator-list-item.mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type CreatorListItem = {
price24hAgo: string | null;
/** Computed percentage change: ((current - 24h) / 24h) * 100. null when no baseline. */
priceChange24h: number | null;
/** True when the key has been deprecated (see GET /keys/:keyId/deprecation). */
deprecated: boolean;
};

type ExpectedFieldType = 'string' | 'boolean' | 'number' | 'Date';
Expand Down Expand Up @@ -134,5 +136,6 @@ export const mapCreatorListItem = async (
currentPrice: currentPrice !== null ? currentPrice.toString() : null,
price24hAgo: price24hAgo !== null ? price24hAgo.toString() : null,
priceChange24h,
deprecated: safeRead(creator, 'deprecatedAt', null) !== null,
};
};
117 changes: 117 additions & 0 deletions src/modules/indexer/key-deprecation-indexer.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// src/modules/indexer/key-deprecation-indexer.service.test.ts
const prismaMock = {
creatorProfile: {
findUnique: jest.fn(),
update: jest.fn(),
},
keyOwnership: {
findMany: jest.fn(),
},
activityLog: {
create: jest.fn(),
},
};

jest.mock('../../utils/prisma.utils', () => ({
prisma: prismaMock,
}));

jest.mock('../../utils/redis.utils', () => ({
getRedis: () => null,
}));

jest.mock('../creator/creator-dashboard.service', () => ({
invalidateCreatorDashboardCache: jest.fn().mockResolvedValue(undefined),
}));

import { processKeyDeprecationEvents } from './key-deprecation-indexer.service';
import { invalidateCreatorDashboardCache } from '../creator/creator-dashboard.service';
import { KeyDeprecationChainEvent } from './key-deprecation-indexer.service';

describe('processKeyDeprecationEvents', () => {
beforeEach(() => {
jest.clearAllMocks();
prismaMock.keyOwnership.findMany.mockResolvedValue([]);
});

function makeEvent(
overrides: Partial<KeyDeprecationChainEvent> = {}
): KeyDeprecationChainEvent {
return {
eventType: 'KEY_DEPRECATED',
txHash: 'tx-1',
eventIndex: 0,
ledger: 100,
creatorId: 'creator-1',
...overrides,
};
}

it('marks an active key deprecated, stores reason/successor, and invalidates cache', async () => {
prismaMock.creatorProfile.findUnique
.mockResolvedValueOnce({ id: 'creator-1', deprecatedAt: null }) // creator lookup
.mockResolvedValueOnce({ id: 'creator-2' }); // successor lookup

await processKeyDeprecationEvents([
makeEvent({ reason: 'sunset', successorKeyId: 'creator-2' }),
]);

expect(prismaMock.creatorProfile.update).toHaveBeenCalledWith({
where: { id: 'creator-1' },
data: expect.objectContaining({
reason: 'sunset',
successorKeyId: 'creator-2',
}),
});
expect(invalidateCreatorDashboardCache).toHaveBeenCalledWith(
'creator-1'
);
});

it('stores no successor when the referenced successor key does not exist', async () => {
prismaMock.creatorProfile.findUnique
.mockResolvedValueOnce({ id: 'creator-1', deprecatedAt: null })
.mockResolvedValueOnce(null);

await processKeyDeprecationEvents([
makeEvent({ successorKeyId: 'missing-key' }),
]);

expect(prismaMock.creatorProfile.update).toHaveBeenCalledWith({
where: { id: 'creator-1' },
data: expect.objectContaining({ successorKeyId: null }),
});
});

it('is idempotent: replaying an already-deprecated key skips the write', async () => {
prismaMock.creatorProfile.findUnique.mockResolvedValueOnce({
id: 'creator-1',
deprecatedAt: new Date('2026-01-01T00:00:00.000Z'),
});

await processKeyDeprecationEvents([makeEvent()]);

expect(prismaMock.creatorProfile.update).not.toHaveBeenCalled();
expect(invalidateCreatorDashboardCache).not.toHaveBeenCalled();
});

it('skips unknown creators without throwing', async () => {
prismaMock.creatorProfile.findUnique.mockResolvedValueOnce(null);

await processKeyDeprecationEvents([makeEvent()]);

expect(prismaMock.creatorProfile.update).not.toHaveBeenCalled();
});

it('ignores events of other types', async () => {
await processKeyDeprecationEvents([
{
eventType: 'WHITELIST_ADDED',
txHash: 'tx-2',
eventIndex: 0,
} as unknown as KeyDeprecationChainEvent,
]);

expect(prismaMock.creatorProfile.findUnique).not.toHaveBeenCalled();
});
});
106 changes: 106 additions & 0 deletions src/modules/indexer/key-deprecation-indexer.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// src/modules/indexer/key-deprecation-indexer.service.ts
// Indexes KEY_DEPRECATED events emitted by the key contract, mirroring
// on-chain deprecation onto CreatorProfile so GET /keys/:keyId/deprecation,
// the key list/search endpoints, and the notification feed reflect it.

import { prisma } from '../../utils/prisma.utils';
import { logger } from '../../utils/logger.utils';
import {
processIndexerChainEvents,
IndexerChainEvent,
getChainEventId,
} from '../../utils/indexer-event-processor.utils';
import { invalidateCreatorDashboardCache } from '../creator/creator-dashboard.service';
import { dispatchKeyDeprecationNotifications } from '../keys/key-deprecation.service';

/**
* Chain event emitted by the key contract when a creator's key is marked
* deprecated, optionally naming a successor key.
*/
export interface KeyDeprecationChainEvent extends IndexerChainEvent {
eventType: 'KEY_DEPRECATED';
/** Creator profile id (key id) the event applies to. */
creatorId: string;
/** Human-readable reason for the deprecation, if provided on-chain. */
reason?: string;
/** Creator profile id of the designated successor key, if any. */
successorKeyId?: string;
}

/**
* Applies KEY_DEPRECATED events to the database. Idempotent: once a key is
* already marked deprecated (whether by this indexer or the admin buyback
* flow), a replayed event is a no-op — no duplicate DB writes and no
* duplicate holder notifications.
*/
export async function processKeyDeprecationEvents(
events: IndexerChainEvent[]
): Promise<void> {
await processIndexerChainEvents(events, async event => {
if (event.eventType !== 'KEY_DEPRECATED') {
return;
}

const { creatorId, reason, successorKeyId } =
event as KeyDeprecationChainEvent;
if (!creatorId) {
logger.warn(
{ eventId: getChainEventId(event) },
'Skipping key deprecation event with missing creatorId'
);
return;
}

const creator = await prisma.creatorProfile.findUnique({
where: { id: creatorId },
select: { id: true, deprecatedAt: true },
});
if (!creator) {
logger.warn(
{ creatorId, txHash: event.txHash },
'Skipping key deprecation event for unknown creator'
);
return;
}

if (creator.deprecatedAt) {
// Already deprecated — replayed event or superseded by the admin
// buyback flow. Skip the write and the notification dispatch.
return;
}

let resolvedSuccessorKeyId: string | null = null;
if (successorKeyId) {
const successor = await prisma.creatorProfile.findUnique({
where: { id: successorKeyId },
select: { id: true },
});
if (successor) {
resolvedSuccessorKeyId = successor.id;
} else {
logger.warn(
{ creatorId, successorKeyId },
'Successor key referenced by deprecation event not found; storing without successor'
);
}
}

await prisma.creatorProfile.update({
where: { id: creator.id },
data: {
deprecatedAt: new Date(),
reason: reason ?? null,
successorKeyId: resolvedSuccessorKeyId,
},
});

await invalidateCreatorDashboardCache(creator.id);

await dispatchKeyDeprecationNotifications({
keyId: creator.id,
eventId: getChainEventId(event),
reason,
successorKeyId: resolvedSuccessorKeyId ?? undefined,
});
});
}
Loading
Loading