diff --git a/docs/badge-minting.md b/docs/badge-minting.md index 8e78afdb..465c7ab8 100644 --- a/docs/badge-minting.md +++ b/docs/badge-minting.md @@ -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 @@ -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 | diff --git a/prisma/schema/badge-mint.prisma b/prisma/schema/badge-mint.prisma index 5e6e62bb..ade7fa3f 100644 --- a/prisma/schema/badge-mint.prisma +++ b/prisma/schema/badge-mint.prisma @@ -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") +} diff --git a/prisma/schema/migrations/20261021140000_add_wallet_badges/migration.sql b/prisma/schema/migrations/20261021140000_add_wallet_badges/migration.sql new file mode 100644 index 00000000..359adc75 --- /dev/null +++ b/prisma/schema/migrations/20261021140000_add_wallet_badges/migration.sql @@ -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"); diff --git a/src/modules/badges/badge-read.service.test.ts b/src/modules/badges/badge-read.service.test.ts new file mode 100644 index 00000000..28f30676 --- /dev/null +++ b/src/modules/badges/badge-read.service.test.ts @@ -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(); + }); +}); diff --git a/src/modules/badges/badge-read.service.ts b/src/modules/badges/badge-read.service.ts new file mode 100644 index 00000000..5a49e5bf --- /dev/null +++ b/src/modules/badges/badge-read.service.ts @@ -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 = { + 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 { + const key = collectionCacheKey(wallet); + const cached = await cacheGetJson(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 { + const key = summaryCacheKey(wallet); + const cached = await cacheGetJson(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 { + await cacheInvalidate(collectionCacheKey(wallet), summaryCacheKey(wallet)); +} diff --git a/src/modules/indexer/badge-minted-indexer.service.test.ts b/src/modules/indexer/badge-minted-indexer.service.test.ts new file mode 100644 index 00000000..b6a9c1ac --- /dev/null +++ b/src/modules/indexer/badge-minted-indexer.service.test.ts @@ -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(); + }); +}); diff --git a/src/modules/indexer/badge-minted-indexer.service.ts b/src/modules/indexer/badge-minted-indexer.service.ts new file mode 100644 index 00000000..db4c4890 --- /dev/null +++ b/src/modules/indexer/badge-minted-indexer.service.ts @@ -0,0 +1,99 @@ +import { z } from 'zod'; +import { prisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import { + IndexerChainEvent, + processIndexerChainEvents, +} from '../../utils/indexer-event-processor.utils'; +import { + chainPositionSchema, + eventIdentifierSchema, + eventOccurredAt, +} from '../../utils/indexer-event-fields.utils'; +import { isBadgeType } from '../badges/badge.types'; +import { invalidateWalletBadgeCache } from '../badges/badge-read.service'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; + +export const BADGE_MINTED_EVENT_TYPES = new Set([ + 'BadgeMinted', + 'BADGE_MINTED', +]); + +const badgeMintedPayloadSchema = z.object({ + wallet: eventIdentifierSchema, + badgeType: eventIdentifierSchema, +}); + +export async function processBadgeMintedEvents( + events: IndexerChainEvent[] +): Promise { + await processIndexerChainEvents(events, async event => { + if (!BADGE_MINTED_EVENT_TYPES.has(event.eventType)) return; + + const position = chainPositionSchema.safeParse(event); + const payload = badgeMintedPayloadSchema.safeParse({ + wallet: + event.wallet ?? + event.recipient ?? + event.to ?? + event.owner ?? + event.actor, + badgeType: event.badgeType ?? event.badge_type ?? event.type, + }); + const wallet = payload.success + ? StellarAddressSchema.safeParse(payload.data.wallet) + : null; + const badgeType = + payload.success && isBadgeType(payload.data.badgeType) + ? payload.data.badgeType + : null; + const rawTimestamp = + event.mintedAt ?? event.timestamp ?? event.ledgerClosedAt; + + if ( + !position.success || + !payload.success || + !wallet?.success || + !badgeType || + rawTimestamp === undefined || + rawTimestamp === null + ) { + logger.error( + { + eventId: `${event.txHash}:${event.eventIndex}`, + eventType: event.eventType, + }, + 'BadgeMinted event is missing valid chain position, wallet, type, or timestamp' + ); + throw new Error('Invalid BadgeMinted event payload'); + } + + let mintedAt: Date; + try { + mintedAt = eventOccurredAt({ ...event, timestamp: rawTimestamp }); + } catch (error) { + logger.error( + { + eventId: `${event.txHash}:${event.eventIndex}`, + error: error instanceof Error ? error.message : String(error), + }, + 'BadgeMinted event has an invalid timestamp' + ); + throw error; + } + + await prisma.walletBadge.createMany({ + data: [ + { + wallet: wallet.data, + badgeType, + mintedAt, + ...position.data, + }, + ], + skipDuplicates: true, + }); + + await invalidateWalletBadgeCache(wallet.data); + }); +} diff --git a/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts b/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts index 54f2969e..1911fe68 100644 --- a/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts +++ b/src/modules/indexer/indexer-pipeline-dispatch.integration.test.ts @@ -10,6 +10,7 @@ import { import { processDividendEvents } from './dividend-indexer.service'; import { processRevenueShareEvents } from './revenue-share-indexer.service'; import { processGovernanceProposalEvents } from './governance-proposal-indexer.service'; +import { processBadgeMintedEvents } from './badge-minted-indexer.service'; import { upsertPriceSnapshot } from './price-snapshot.service'; import { prisma } from '../../utils/prisma.utils'; import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils'; @@ -35,6 +36,10 @@ jest.mock('./lp-indexer.service', () => { jest.mock('./governance-proposal-indexer.service', () => ({ processGovernanceProposalEvents: jest.fn(), })); +jest.mock('./badge-minted-indexer.service', () => ({ + BADGE_MINTED_EVENT_TYPES: new Set(['BadgeMinted', 'BADGE_MINTED']), + processBadgeMintedEvents: jest.fn(), +})); jest.mock('../ownership/ownership.service', () => ({ updateOwnership: jest.fn(), @@ -87,6 +92,7 @@ const mockSocialHandler = processSocialMetadataEvents as jest.Mock; const mockDividendHandler = processDividendEvents as jest.Mock; const mockRevenueShareHandler = processRevenueShareEvents as jest.Mock; const mockGovernanceHandler = processGovernanceProposalEvents as jest.Mock; +const mockBadgeMintHandler = processBadgeMintedEvents as jest.Mock; const mockUpsertPriceSnapshot = upsertPriceSnapshot as jest.Mock; const mockPrisma = prisma as unknown as { activity: { create: jest.Mock }; @@ -201,6 +207,39 @@ describe('processIndexerEvents', () => { expect(mockPrisma.indexedLedger.upsert).toHaveBeenCalledTimes(1); }); + it('routes BadgeMinted events before advancing the shared cursor', async () => { + const event: IndexerChainEvent = { + txHash: 'tx-badge', + eventIndex: 1, + eventType: 'BadgeMinted', + ledger: 720, + }; + + await processIndexerEvents([event]); + + expect(mockBadgeMintHandler).toHaveBeenCalledWith([event]); + expect(mockPrisma.indexedLedger.upsert).toHaveBeenCalledTimes(1); + }); + + it('does not advance the cursor when BadgeMinted projection fails', async () => { + mockBadgeMintHandler.mockRejectedValueOnce( + new Error('wallet badge write failed') + ); + + await expect( + processIndexerEvents([ + { + txHash: 'tx-badge-failed', + eventIndex: 1, + eventType: 'BadgeMinted', + ledger: 721, + }, + ]) + ).rejects.toThrow('wallet badge write failed'); + + expect(mockPrisma.indexedLedger.upsert).not.toHaveBeenCalled(); + }); + it('routes creator revenue distribution and claim events before advancing the cursor', async () => { const distributionEvent = socialEvent({ txHash: 'tx-revenue-distributed', diff --git a/src/modules/indexer/indexer-pipeline.service.ts b/src/modules/indexer/indexer-pipeline.service.ts index 99f53198..5ba3176c 100644 --- a/src/modules/indexer/indexer-pipeline.service.ts +++ b/src/modules/indexer/indexer-pipeline.service.ts @@ -600,6 +600,15 @@ export async function processIndexerEvents( await processHealthScoreEvents(healthScoreEvents); } + const { BADGE_MINTED_EVENT_TYPES, processBadgeMintedEvents } = + await import('./badge-minted-indexer.service'); + const badgeMintedEvents = events.filter(event => + BADGE_MINTED_EVENT_TYPES.has(event.eventType) + ); + if (badgeMintedEvents.length > 0) { + await processBadgeMintedEvents(badgeMintedEvents); + } + if (options.advanceCursor !== false) await advanceIndexerCursorForBatch(events); } diff --git a/src/modules/wallets/wallet-badges.controllers.ts b/src/modules/wallets/wallet-badges.controllers.ts new file mode 100644 index 00000000..26bd7c6c --- /dev/null +++ b/src/modules/wallets/wallet-badges.controllers.ts @@ -0,0 +1,61 @@ +import { Request, Response, NextFunction } from 'express'; +import { z } from 'zod'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; +import { + getWalletBadgeCollection, + getWalletBadgeSummary, +} from '../badges/badge-read.service'; +import { + sendSuccess, + sendValidationError, +} from '../../utils/api-response.utils'; + +const WalletBadgesParamsSchema = z.object({ + wallet: StellarAddressSchema, +}); + +async function parseWallet( + req: Request, + res: Response +): Promise { + const parsed = WalletBadgesParamsSchema.safeParse(req.params); + if (parsed.success) return parsed.data.wallet; + + sendValidationError( + res, + 'Invalid wallet address', + parsed.error.issues.map(issue => ({ + field: 'wallet', + message: issue.message, + })) + ); + return null; +} + +export async function httpGetWalletBadges( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const wallet = await parseWallet(req, res); + if (!wallet) return; + sendSuccess(res, await getWalletBadgeCollection(wallet)); + } catch (error) { + next(error); + } +} + +export async function httpGetWalletBadgeSummary( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const wallet = await parseWallet(req, res); + if (!wallet) return; + sendSuccess(res, await getWalletBadgeSummary(wallet)); + } catch (error) { + next(error); + } +} diff --git a/src/modules/wallets/wallets.routes.ts b/src/modules/wallets/wallets.routes.ts index 4d620c77..dcbc608f 100644 --- a/src/modules/wallets/wallets.routes.ts +++ b/src/modules/wallets/wallets.routes.ts @@ -3,6 +3,10 @@ import { httpGetWalletActivity } from "./wallet-activity.controllers"; import { httpGetWalletHoldings } from "./wallet-holdings.controllers"; import { httpGetWalletFollowing } from "./wallet-following.controllers"; import { httpGetWalletFeed } from "./wallet-feed.controllers"; +import { + httpGetWalletBadges, + httpGetWalletBadgeSummary, +} from "./wallet-badges.controllers"; import { cacheControl } from "../../middlewares/cache-control.middleware"; import { ACTIVITY_FEED_CACHE_PRESET } from "../../constants/activity-feed-cache.constants"; import { requireWalletParamMatch } from "../../middlewares/jwt-auth.middleware"; @@ -10,6 +14,20 @@ import { jwtAuth } from "../../middlewares/jwt.middleware"; const walletsRouter = Router(); +/** + * GET /api/v1/wallets/:wallet/badges + * + * Returns all earned and locked badge types for a Stellar wallet. + */ +walletsRouter.get("/:wallet/badges", httpGetWalletBadges); + +/** + * GET /api/v1/wallets/:wallet/badges/summary + * + * Returns earned badge counts for every badge type. + */ +walletsRouter.get("/:wallet/badges/summary", httpGetWalletBadgeSummary); + /** * GET /api/v1/wallets/:address/activity *