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
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -172,3 +172,13 @@ CIRCUIT_BREAKER_ROLLING_WINDOW_SECONDS=60
# CIRCUIT_DATABASE_COOLDOWN_SECONDS=30
# Internal network authentication key for /internal/health/circuits endpoint
# INTERNAL_SERVICE_KEY=

# Key NFT badge minting trigger service (#1148)
# Minting stays disabled until BOTH of these are set.
BADGE_CONTRACT_ID=
BADGE_MINTER_SECRET=
# Qualification thresholds (optional; shown with their defaults)
# BADGE_WHALE_THRESHOLD_XLM=10000
# BADGE_HOLD_MILESTONE_DAYS=365
# BADGE_TOP_TRADER_MAX_RANK=10
# BADGE_MINT_MAX_ATTEMPTS=3
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ The server is responsible for:
- access checks for gated off-chain content
- trade webhooks for external integrations
- a centralised Soroban contract interaction service that wraps all on-chain submissions with retry, error classification, transaction tracking, and resolution events ([Soroban Contract Service Reference](./docs/soroban-contract-service.md))
- automatic key NFT badge awards triggered by qualifying on-chain activity (first buy, whale swap, governance vote, long-term hold, top trade) ([Badge Minting Trigger Service](./docs/badge-minting.md))

See [Backend Domain Model and Endpoint Boundaries](./docs/architecture/domain-boundaries.md) for a technical overview, [Creator Data Model Reference](./docs/architecture/creator-data-model.md) for creator field definitions, [API Versioning](./docs/api-versioning.md) for details on schema versioning, [API Timeout Configuration](./docs/api-timeouts.md) for timeout defaults, [Rate Limiting Configuration](./docs/rate-limiting.md) for rate limit defaults and guidelines, and [Trade Webhooks Reference](./docs/webhooks.md) for webhook payload and delivery behavior.

Expand Down
99 changes: 99 additions & 0 deletions docs/badge-minting.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Key NFT Badge Minting Trigger Service

Reference for the automatic badge award pipeline (#1148).

The service watches for qualifying on-chain activity and mints the matching key
NFT badge for the wallet. All minting is routed through the centralised
[Soroban Contract Service](./soroban-contract-service.md) so submission retries,
transaction tracking, and resolution events apply uniformly.

## Badges and triggers

| Badge | Qualifying event (`triggerEvent`) | Rule |
| ------------------ | --------------------------------- | ---------------------------------------------------- |
| `FIRST_BUYER` | `first_swap` | The wallet's first swap. |
| `WHALE_HOLDER` | `whale_swap` | A single swap ≥ `BADGE_WHALE_THRESHOLD_XLM`. |
| `GOVERNANCE_VOTER` | `vote_cast` | The wallet cast a governance vote. |
| `DIAMOND_HANDS` | `hold_milestone` | Continuous hold ≥ `BADGE_HOLD_MILESTONE_DAYS` (365). |
| `TOP_TRADER` | `leaderboard_entry` | Leaderboard rank ≤ `BADGE_TOP_TRADER_MAX_RANK` (10). |

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. |

## Flow

```
processQualifyingEvent(event)
│
▼
evaluateEvent(event) ──► [] no badges earned ──► done
│
▼ one or more BadgeAward
mintBadge(wallet, badgeType, triggerEvent)
│
├─ 1. Dedup: badge already MINTED for (wallet, badgeType)? ─► SKIPPED
│
├─ 2. Mint via contract client
│ transient failure ─► retry up to BADGE_MINT_MAX_ATTEMPTS
│ user failure ─► terminal, no retry
│
└─ 3. Upsert badge_mint_log (MINTED | FAILED), attempts + error recorded
```

## Deduplication

`badge_mint_log` has a unique `(wallet, badgeType)` constraint — one row per
award. `mintBadge` short-circuits to `SKIPPED` when a row is already `MINTED`, so
re-processing the same (or an overlapping) event never re-mints. A row left
`FAILED` by an exhausted retry is eligible to be re-attempted on the next event.

## Retry

Minting inherits the contract service's submission retries; on top of that,
`mintBadge` retries the whole mint on **transient** outcomes (RPC down,
timeouts, unconfirmed) up to `BADGE_MINT_MAX_ATTEMPTS` with a linear backoff.
**User** outcomes (e.g. `unauthorized`, `reverted`) are terminal and not
retried. The final attempt count and last error are persisted on the log row.

## Integration

Indexer / webhook consumers call `processQualifyingEvent` as activity is
observed:

```ts
import { processQualifyingEvent } from '../modules/badges';

// On an observed swap:
await processQualifyingEvent({
kind: 'swap',
wallet,
amount,
isFirstSwap,
txHash,
});

// On a governance vote:
await processQualifyingEvent({ kind: 'vote', wallet, proposalId, txHash });
```

## Configuration

| Variable | Default | Description |
| --------------------------- | ------- | ----------------------------------------------------------- |
| `BADGE_CONTRACT_ID` | — | Deployed badge contract id. Minting is disabled unless set. |
| `BADGE_MINTER_SECRET` | — | Server keypair secret that signs mints. Required to mint. |
| `BADGE_WHALE_THRESHOLD_XLM` | `10000` | Single-swap size that earns `WHALE_HOLDER`. |
| `BADGE_HOLD_MILESTONE_DAYS` | `365` | Hold duration that earns `DIAMOND_HANDS`. |
| `BADGE_TOP_TRADER_MAX_RANK` | `10` | Highest rank that earns `TOP_TRADER`. |
| `BADGE_MINT_MAX_ATTEMPTS` | `3` | Max mint attempts before a transient failure is terminal. |
39 changes: 39 additions & 0 deletions prisma/schema/badge-mint.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// prisma/schema/badge-mint.prisma
// Key NFT badge minting trigger service (#1148).

/// Audit log of every badge mint the trigger service attempts. One row per
/// (wallet, badgeType): the unique constraint is the deduplication guard that
/// prevents a wallet from being awarded the same badge type more than once.
/// Rows move PENDING -> MINTED on a confirmed on-chain mint, or PENDING ->
/// FAILED when minting is ultimately rejected (a FAILED row may be retried).
model BadgeMintLog {
id String @id @default(cuid())
/// Wallet the badge is awarded to.
wallet String
/// Badge kind: FIRST_BUYER | WHALE_HOLDER | GOVERNANCE_VOTER | DIAMOND_HANDS
/// | TOP_TRADER.
badgeType String
/// PENDING | MINTED | FAILED
status String @default("PENDING")
/// Qualifying event that triggered the award: first_swap | whale_swap |
/// vote_cast | hold_milestone | leaderboard_entry.
triggerEvent String
/// Soroban transaction hash of the confirmed mint (null until minted).
txHash String?
/// Number of mint attempts made by the retry loop (0 until the first try).
attempts Int @default(0)
/// Last error message seen while minting (null once minted).
error String?
/// Qualifying context captured from the event (amount, rank, duration, ...).
metadata Json?
/// When the mint confirmed on-chain (null until minted).
mintedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([wallet, badgeType])
@@index([wallet])
@@index([badgeType])
@@index([status])
@@map("badge_mint_log")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- CreateTable
CREATE TABLE "badge_mint_log" (
"id" TEXT NOT NULL,
"wallet" TEXT NOT NULL,
"badgeType" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"triggerEvent" TEXT NOT NULL,
"txHash" TEXT,
"attempts" INTEGER NOT NULL DEFAULT 0,
"error" TEXT,
"metadata" JSONB,
"mintedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

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

-- CreateIndex
CREATE UNIQUE INDEX "badge_mint_log_wallet_badgeType_key" ON "badge_mint_log"("wallet", "badgeType");
CREATE INDEX "badge_mint_log_wallet_idx" ON "badge_mint_log"("wallet");
CREATE INDEX "badge_mint_log_badgeType_idx" ON "badge_mint_log"("badgeType");
CREATE INDEX "badge_mint_log_status_idx" ON "badge_mint_log"("status");
13 changes: 13 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,19 @@ export const envSchema = z
.int()
.positive()
.default(5),

// Key NFT badge minting trigger service (#1148). Minting is disabled
// until both the contract id and the server minter secret are set.
BADGE_CONTRACT_ID: optionalNonEmptyString,
BADGE_MINTER_SECRET: optionalNonEmptyString,
// Single-swap size (XLM) that earns the WHALE_HOLDER badge.
BADGE_WHALE_THRESHOLD_XLM: z.coerce.number().positive().default(10000),
// Continuous hold (days) that earns the DIAMOND_HANDS badge.
BADGE_HOLD_MILESTONE_DAYS: z.coerce.number().int().positive().default(365),
// Highest leaderboard rank (inclusive) that earns the TOP_TRADER badge.
BADGE_TOP_TRADER_MAX_RANK: z.coerce.number().int().positive().default(10),
// Max mint attempts before a transient failure is treated as terminal.
BADGE_MINT_MAX_ATTEMPTS: z.coerce.number().int().positive().default(3),
})
.superRefine((data, ctx) => {
if (data.MODE === 'production' && data.STELLAR_NETWORK === 'testnet') {
Expand Down
Loading
Loading