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
26 changes: 25 additions & 1 deletion docs/api-endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,9 @@ Cached for 60 seconds.
"success": true,
"data": {
"total_balance": "1200000000",
"per_source_breakdown": [{ "source": "trading", "balance": "1500000000" }],
"per_source_breakdown": [
{ "source": "trading", "balance": "1500000000" }
],
"last_updated": "2026-10-21T10:00:00.000Z"
}
}
Expand Down Expand Up @@ -804,6 +806,28 @@ serialised as strings. Response is cached in Redis for 10 seconds.
- `400 Bad Request` — `quantity` is missing, non-integer, ≤ 0, or > 1 000 000
- `404 Not Found` — key does not exist

### Community Polls

Polls are indexed from `PollCreated`, `PollVoteCast`, and `PollClosed` contract
events. Results report vote counts and summed vote weight per option. Responses
are cached for 30 seconds and invalidated when a poll event for the key is
indexed.

#### GET /keys/:keyId/polls

Lists all active and closed polls for a key with expiry, total votes, total
voting weight, and per-option weight tallies.

#### GET /keys/:keyId/polls/:pollId

Returns poll details and per-option vote counts and weights in `optionResults`.

#### GET /keys/:keyId/polls/:pollId/votes/:wallet

Returns whether the wallet voted and, if so, its selected option and indexed
vote weight. Invalid Stellar wallet addresses return `400`; unknown keys or
polls return `404`.

---

## Common Headers
Expand Down
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")
}
34 changes: 34 additions & 0 deletions prisma/schema/community-poll.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
model KeyPoll {
id String @id @default(cuid())
keyId String
pollId String
question String
options Json
expiresAt DateTime
status String @default("active")
closedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([keyId, pollId])
@@index([keyId, status])
@@index([keyId, expiresAt])
@@map("key_polls")
}

model KeyPollVote {
id String @id @default(cuid())
keyId String
pollId String
wallet String
optionIndex Int
weight Decimal @db.Decimal(30, 7)
txHash String
eventIndex Int
castAt DateTime @default(now())

@@unique([keyId, pollId, wallet])
@@unique([txHash, eventIndex])
@@index([keyId, pollId, optionIndex])
@@map("poll_votes")
}
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");
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
CREATE TABLE "key_polls" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"pollId" TEXT NOT NULL,
"question" TEXT NOT NULL,
"options" JSONB NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"status" TEXT NOT NULL DEFAULT 'active',
"closedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

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

CREATE UNIQUE INDEX "key_polls_keyId_pollId_key"
ON "key_polls"("keyId", "pollId");
CREATE INDEX "key_polls_keyId_status_idx"
ON "key_polls"("keyId", "status");
CREATE INDEX "key_polls_keyId_expiresAt_idx"
ON "key_polls"("keyId", "expiresAt");

CREATE TABLE "poll_votes" (
"id" TEXT NOT NULL,
"keyId" TEXT NOT NULL,
"pollId" TEXT NOT NULL,
"wallet" TEXT NOT NULL,
"optionIndex" INTEGER NOT NULL,
"weight" DECIMAL(30,7) NOT NULL,
"txHash" TEXT NOT NULL,
"eventIndex" INTEGER NOT NULL,
"castAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

CREATE UNIQUE INDEX "poll_votes_keyId_pollId_wallet_key"
ON "poll_votes"("keyId", "pollId", "wallet");
CREATE UNIQUE INDEX "poll_votes_txHash_eventIndex_key"
ON "poll_votes"("txHash", "eventIndex");
CREATE INDEX "poll_votes_keyId_pollId_optionIndex_idx"
ON "poll_votes"("keyId", "pollId", "optionIndex");
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();
});
});
Loading
Loading