Skip to content
Merged
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
32 changes: 23 additions & 9 deletions docs/badge-minting.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,15 +21,17 @@ A single swap can earn both `FIRST_BUYER` and `WHALE_HOLDER`.

## Components

| File | Responsibility |
| --------------------------------------------- | -------------------------------------------------------------- |
| `src/modules/badges/badge-trigger.service.ts` | `evaluateEvent` (pure rules) + `processQualifyingEvent`. |
| `src/modules/badges/badge-mint.service.ts` | `mintBadge`: dedup guard → mint w/ retry → audit log. |
| `src/modules/badges/badge-mint.client.ts` | Default client: submits `mint_badge` via the contract service. |
| `src/modules/badges/badge-mint.builder.ts` | Builds + signs the `mint_badge(wallet, badge_type)` envelope. |
| `src/modules/badges/badge.constants.ts` | Thresholds (whale/hold/rank) and attempt limits. |
| `src/modules/badges/badge.types.ts` | Badge types, event shapes, client/result interfaces. |
| `prisma/schema/badge-mint.prisma` | `BadgeMintLog` audit table. |
| File | Responsibility |
| ----------------------------------------------------- | -------------------------------------------------------------- |
| `src/modules/badges/badge-trigger.service.ts` | `evaluateEvent` (pure rules) + `processQualifyingEvent`. |
| `src/modules/badges/badge-mint.service.ts` | `mintBadge`: dedup guard → mint w/ retry → audit log. |
| `src/modules/badges/badge-mint.client.ts` | Default client: submits `mint_badge` via the contract service. |
| `src/modules/badges/badge-mint.builder.ts` | Builds + signs the `mint_badge(wallet, badge_type)` envelope. |
| `src/modules/badges/badge.constants.ts` | Thresholds (whale/hold/rank) and attempt limits. |
| `src/modules/badges/badge.types.ts` | Badge types, event shapes, client/result interfaces. |
| `src/modules/indexer/badge-minted-indexer.service.ts` | Indexes confirmed `BadgeMinted` events into `wallet_badges`. |
| `src/modules/badges/badge-read.service.ts` | Wallet badge collection/summary with five-minute cache. |
| `prisma/schema/badge-mint.prisma` | `BadgeMintLog` audit table. |

## Flow

Expand Down Expand Up @@ -87,6 +89,18 @@ await processQualifyingEvent({
await processQualifyingEvent({ kind: 'vote', wallet, proposalId, txHash });
```

Confirmed `BadgeMinted` events are independently indexed into `wallet_badges`
by the shared indexer pipeline. The cursor advances only after this projection
is persisted. Wallet badge reads are available at:

- `GET /api/v1/wallets/:wallet/badges` — earned badges and the remaining locked
badge types, with mint timestamps for earned badges.
- `GET /api/v1/wallets/:wallet/badges/summary` — total earned and a count for
each of the five badge types.

Both read responses are cached for five minutes. A newly indexed badge mint
invalidates both cached responses for that wallet.

## Configuration

| Variable | Default | Description |
Expand Down
16 changes: 16 additions & 0 deletions prisma/schema/badge-mint.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,19 @@ model BadgeMintLog {
@@index([status])
@@map("badge_mint_log")
}

/// On-chain badge mints indexed by their original chain position.
model WalletBadge {
id String @id @default(cuid())
wallet String
badgeType String
mintedAt DateTime
txHash String
eventIndex Int
ledger Int

@@unique([wallet, badgeType])
@@unique([txHash, eventIndex])
@@index([wallet, mintedAt])
@@map("wallet_badges")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
CREATE TABLE "wallet_badges" (
"id" TEXT NOT NULL,
"wallet" TEXT NOT NULL,
"badgeType" TEXT NOT NULL,
"mintedAt" TIMESTAMP(3) NOT NULL,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"ledger" INTEGER NOT NULL,

CONSTRAINT "wallet_badges_pkey" PRIMARY KEY ("id")
);

CREATE UNIQUE INDEX "wallet_badges_wallet_badgeType_key"
ON "wallet_badges"("wallet", "badgeType");
CREATE UNIQUE INDEX "wallet_badges_txHash_eventIndex_key"
ON "wallet_badges"("txHash", "eventIndex");
CREATE INDEX "wallet_badges_wallet_mintedAt_idx"
ON "wallet_badges"("wallet", "mintedAt");
98 changes: 98 additions & 0 deletions src/modules/badges/badge-read.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import {
getWalletBadgeCollection,
getWalletBadgeSummary,
WALLET_BADGES_CACHE_TTL_SECONDS,
} from './badge-read.service';
import { prisma } from '../../utils/prisma.utils';
import { cacheGetJson, cacheSetJson } from '../../utils/redis.utils';

jest.mock('../../utils/redis.utils', () => ({
cacheGetJson: jest.fn().mockResolvedValue(null),
cacheSetJson: jest.fn().mockResolvedValue(undefined),
cacheInvalidate: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
walletBadge: {
findMany: jest.fn(),
groupBy: jest.fn(),
},
},
}));

const wallet = `G${'A'.repeat(55)}`;

beforeEach(() => {
jest.clearAllMocks();
(cacheGetJson as jest.Mock).mockResolvedValue(null);
(cacheSetJson as jest.Mock).mockResolvedValue(undefined);
});

describe('wallet badge read models', () => {
it('returns earned badges and computes all unearned types as locked', async () => {
(prisma.walletBadge.findMany as jest.Mock).mockResolvedValue([
{
badgeType: 'FIRST_BUYER',
mintedAt: new Date('2026-10-20T12:00:00.000Z'),
},
{
badgeType: 'TOP_TRADER',
mintedAt: new Date('2026-10-21T12:00:00.000Z'),
},
]);

const result = await getWalletBadgeCollection(wallet);

expect(result.earned).toEqual([
{
type: 'FIRST_BUYER',
name: 'First Buyer',
mintedAt: '2026-10-20T12:00:00.000Z',
},
{
type: 'TOP_TRADER',
name: 'Top Trader',
mintedAt: '2026-10-21T12:00:00.000Z',
},
]);
expect(result.locked.map(badge => badge.type)).toEqual([
'WHALE_HOLDER',
'GOVERNANCE_VOTER',
'DIAMOND_HANDS',
]);
expect(result.earned.length + result.locked.length).toBe(5);
expect(result.totalEarned).toBe(2);
expect(cacheSetJson).toHaveBeenCalledWith(
`wallet-badges:v1:${wallet}:collection`,
result,
WALLET_BADGES_CACHE_TTL_SECONDS
);
});

it('includes a count for each badge type, including zero-count types', async () => {
(prisma.walletBadge.groupBy as jest.Mock).mockResolvedValue([
{ badgeType: 'FIRST_BUYER', _count: { _all: 2 } },
{ badgeType: 'TOP_TRADER', _count: { _all: 1 } },
]);

const result = await getWalletBadgeSummary(wallet);

expect(result.totalEarned).toBe(3);
expect(result.byType).toHaveLength(5);
expect(result.byType.map(badge => badge.count)).toEqual([2, 0, 0, 0, 1]);
expect(cacheSetJson).toHaveBeenCalledWith(
`wallet-badges:v1:${wallet}:summary`,
result,
WALLET_BADGES_CACHE_TTL_SECONDS
);
});

it('serves cached collection data without querying the database', async () => {
const cached = { earned: [], locked: [], totalEarned: 0 };
(cacheGetJson as jest.Mock).mockResolvedValue(cached);

await expect(getWalletBadgeCollection(wallet)).resolves.toBe(cached);
expect(prisma.walletBadge.findMany).not.toHaveBeenCalled();
});
});
106 changes: 106 additions & 0 deletions src/modules/badges/badge-read.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { BADGE_TYPES, type BadgeType } from './badge.types';
import { prisma } from '../../utils/prisma.utils';
import {
cacheGetJson,
cacheInvalidate,
cacheSetJson,
} from '../../utils/redis.utils';

export const WALLET_BADGES_CACHE_TTL_SECONDS = 5 * 60;

const BADGE_NAMES: Record<BadgeType, string> = {
FIRST_BUYER: 'First Buyer',
WHALE_HOLDER: 'Whale Holder',
GOVERNANCE_VOTER: 'Governance Voter',
DIAMOND_HANDS: 'Diamond Hands',
TOP_TRADER: 'Top Trader',
};

export interface WalletBadgeItem {
type: BadgeType;
name: string;
mintedAt: string | null;
}

export interface WalletBadgeCollection {
earned: WalletBadgeItem[];
locked: WalletBadgeItem[];
totalEarned: number;
}

export interface WalletBadgeSummary {
totalEarned: number;
byType: Array<{ type: BadgeType; name: string; count: number }>;
}

const collectionCacheKey = (wallet: string): string =>
`wallet-badges:v1:${wallet}:collection`;
const summaryCacheKey = (wallet: string): string =>
`wallet-badges:v1:${wallet}:summary`;

export async function getWalletBadgeCollection(
wallet: string
): Promise<WalletBadgeCollection> {
const key = collectionCacheKey(wallet);
const cached = await cacheGetJson<WalletBadgeCollection>(key);
if (cached) return cached;

const rows = await prisma.walletBadge.findMany({
where: { wallet },
select: { badgeType: true, mintedAt: true },
orderBy: { mintedAt: 'desc' },
});
const earnedTypes = new Set(rows.map(row => row.badgeType));
const mintedAtByType = new Map(
rows.map(row => [row.badgeType, row.mintedAt.toISOString()])
);

const earned: WalletBadgeItem[] = [];
const locked: WalletBadgeItem[] = [];
for (const type of BADGE_TYPES) {
const item = {
type,
name: BADGE_NAMES[type],
mintedAt: mintedAtByType.get(type) ?? null,
};
if (earnedTypes.has(type)) earned.push(item);
else locked.push(item);
}

const result = { earned, locked, totalEarned: earned.length };
await cacheSetJson(key, result, WALLET_BADGES_CACHE_TTL_SECONDS);
return result;
}

export async function getWalletBadgeSummary(
wallet: string
): Promise<WalletBadgeSummary> {
const key = summaryCacheKey(wallet);
const cached = await cacheGetJson<WalletBadgeSummary>(key);
if (cached) return cached;

const rows = await prisma.walletBadge.groupBy({
by: ['badgeType'],
where: { wallet },
_count: { _all: true },
});
const counts = new Map(rows.map(row => [row.badgeType, row._count._all]));
const byType = BADGE_TYPES.map(type => ({
type,
name: BADGE_NAMES[type],
count: counts.get(type) ?? 0,
}));
const result = {
totalEarned: byType.reduce((total, badge) => total + badge.count, 0),
byType,
};

await cacheSetJson(key, result, WALLET_BADGES_CACHE_TTL_SECONDS);
return result;
}

export async function invalidateWalletBadgeCache(
wallet: string
): Promise<void> {
await cacheInvalidate(collectionCacheKey(wallet), summaryCacheKey(wallet));
}
79 changes: 79 additions & 0 deletions src/modules/indexer/badge-minted-indexer.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { processBadgeMintedEvents } from './badge-minted-indexer.service';
import { prisma } from '../../utils/prisma.utils';
import { cacheInvalidate } from '../../utils/redis.utils';

jest.mock('../../utils/logger.utils', () => ({
logger: {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
},
}));

jest.mock('../../utils/redis.utils', () => ({
cacheInvalidate: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
walletBadge: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
},
},
}));

const wallet = `G${'A'.repeat(55)}`;
const mintedAt = new Date('2026-10-20T12:00:00.000Z');
const event = {
txHash: 'tx-badge-mint',
eventIndex: 3,
ledger: 800,
timestamp: mintedAt.getTime() / 1000,
eventType: 'BadgeMinted',
wallet,
badge_type: 'FIRST_BUYER',
};

beforeEach(() => {
jest.clearAllMocks();
});

describe('processBadgeMintedEvents', () => {
it('persists each confirmed mint with its chain timestamp and invalidates wallet caches', async () => {
await processBadgeMintedEvents([event]);

expect(prisma.walletBadge.createMany).toHaveBeenCalledWith({
data: [
{
wallet,
badgeType: 'FIRST_BUYER',
mintedAt,
txHash: 'tx-badge-mint',
eventIndex: 3,
ledger: 800,
},
],
skipDuplicates: true,
});
expect(cacheInvalidate).toHaveBeenCalledWith(
'wallet-badges:v1:' + wallet + ':collection',
'wallet-badges:v1:' + wallet + ':summary'
);
});

it('rejects malformed mint events so the indexer cursor cannot pass them', async () => {
await expect(
processBadgeMintedEvents([{ ...event, badge_type: 'UNKNOWN' }])
).rejects.toThrow('Invalid BadgeMinted event payload');

expect(prisma.walletBadge.createMany).not.toHaveBeenCalled();
expect(cacheInvalidate).not.toHaveBeenCalled();
});

it('ignores unrelated event types', async () => {
await processBadgeMintedEvents([{ ...event, eventType: 'KEY_BOUGHT' }]);

expect(prisma.walletBadge.createMany).not.toHaveBeenCalled();
});
});
Loading
Loading