From e587d8a528b54ca5992a4fb5145bb63aeadd464c Mon Sep 17 00:00:00 2001 From: CNduka001 Date: Wed, 21 Oct 2026 00:52:23 +0100 Subject: [PATCH] feat(badges): add key NFT badge minting trigger service (#1148) Awards key NFT badges automatically from qualifying on-chain activity, routed through the centralised Soroban contract service so retry, transaction tracking, and resolution events apply uniformly. - Triggers: first swap (FIRST_BUYER), whale swap (WHALE_HOLDER), governance vote (GOVERNANCE_VOTER), 365d hold (DIAMOND_HANDS), top-10 leaderboard (TOP_TRADER) - Deduplication guard via unique (wallet, badgeType) on badge_mint_log - Retry on transient mint failures; user-kind failures are terminal - Every attempt persisted to badge_mint_log for audit - Config thresholds + docs; mint_badge added to ContractOperation --- .env.example | 10 + README.md | 1 + docs/badge-minting.md | 99 +++++++ prisma/schema/badge-mint.prisma | 39 +++ .../migration.sql | 23 ++ src/config.schema.ts | 13 + .../__tests__/badge-mint.service.test.ts | 209 +++++++++++++++ .../__tests__/badge-trigger.service.test.ts | 214 +++++++++++++++ src/modules/badges/badge-mint.builder.ts | 73 ++++++ src/modules/badges/badge-mint.client.ts | 75 ++++++ src/modules/badges/badge-mint.service.ts | 243 ++++++++++++++++++ src/modules/badges/badge-trigger.service.ts | 133 ++++++++++ src/modules/badges/badge.constants.ts | 52 ++++ src/modules/badges/badge.types.ts | 122 +++++++++ src/modules/badges/index.ts | 24 ++ src/modules/contracts/contract.types.ts | 4 +- 16 files changed, 1333 insertions(+), 1 deletion(-) create mode 100644 docs/badge-minting.md create mode 100644 prisma/schema/badge-mint.prisma create mode 100644 prisma/schema/migrations/20261021100000_add_badge_mint_log/migration.sql create mode 100644 src/modules/badges/__tests__/badge-mint.service.test.ts create mode 100644 src/modules/badges/__tests__/badge-trigger.service.test.ts create mode 100644 src/modules/badges/badge-mint.builder.ts create mode 100644 src/modules/badges/badge-mint.client.ts create mode 100644 src/modules/badges/badge-mint.service.ts create mode 100644 src/modules/badges/badge-trigger.service.ts create mode 100644 src/modules/badges/badge.constants.ts create mode 100644 src/modules/badges/badge.types.ts create mode 100644 src/modules/badges/index.ts diff --git a/.env.example b/.env.example index 046d8716..b733f5ef 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.md b/README.md index 7e6f64dd..6c6ad863 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/badge-minting.md b/docs/badge-minting.md new file mode 100644 index 00000000..8e78afdb --- /dev/null +++ b/docs/badge-minting.md @@ -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. | diff --git a/prisma/schema/badge-mint.prisma b/prisma/schema/badge-mint.prisma new file mode 100644 index 00000000..5e6e62bb --- /dev/null +++ b/prisma/schema/badge-mint.prisma @@ -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") +} diff --git a/prisma/schema/migrations/20261021100000_add_badge_mint_log/migration.sql b/prisma/schema/migrations/20261021100000_add_badge_mint_log/migration.sql new file mode 100644 index 00000000..8b63558b --- /dev/null +++ b/prisma/schema/migrations/20261021100000_add_badge_mint_log/migration.sql @@ -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"); diff --git a/src/config.schema.ts b/src/config.schema.ts index 93881d16..80b316b8 100644 --- a/src/config.schema.ts +++ b/src/config.schema.ts @@ -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') { diff --git a/src/modules/badges/__tests__/badge-mint.service.test.ts b/src/modules/badges/__tests__/badge-mint.service.test.ts new file mode 100644 index 00000000..e16b7181 --- /dev/null +++ b/src/modules/badges/__tests__/badge-mint.service.test.ts @@ -0,0 +1,209 @@ +// src/modules/badges/__tests__/badge-mint.service.test.ts + +jest.mock('../../../config', () => ({ + envConfig: { + BADGE_WHALE_THRESHOLD_XLM: 10000, + BADGE_HOLD_MILESTONE_DAYS: 365, + BADGE_TOP_TRADER_MAX_RANK: 10, + BADGE_MINT_MAX_ATTEMPTS: 3, + }, +})); + +jest.mock('../../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +import { mintBadge } from '../badge-mint.service'; +import type { BadgeMintContractClient } from '../badge.types'; + +describe('Badge Mint Service', () => { + const now = new Date('2026-10-21T12:00:00.000Z'); + + const mockPrisma = { + badgeMintLog: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + }; + + let mockContractClient: BadgeMintContractClient; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.badgeMintLog.findUnique.mockResolvedValue(null); + mockPrisma.badgeMintLog.upsert.mockResolvedValue({}); + mockContractClient = { + mintBadge: jest.fn().mockResolvedValue({ txHash: 'tx-abc' }), + }; + }); + + const deps = (overrides = {}) => ({ + prisma: mockPrisma as any, + contractClient: mockContractClient, + now: () => now, + delay: jest.fn().mockResolvedValue(undefined), + baseDelayMs: 1, + ...overrides, + }); + + it('mints a badge and records it as MINTED in badge_mint_log', async () => { + const result = await mintBadge( + 'GWALLET1', + 'FIRST_BUYER', + 'first_swap', + deps() + ); + + expect(result).toEqual({ + wallet: 'GWALLET1', + badgeType: 'FIRST_BUYER', + status: 'MINTED', + txHash: 'tx-abc', + attempts: 1, + }); + expect(mockContractClient.mintBadge).toHaveBeenCalledTimes(1); + expect(mockContractClient.mintBadge).toHaveBeenCalledWith( + 'GWALLET1', + 'FIRST_BUYER' + ); + expect(mockPrisma.badgeMintLog.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + wallet_badgeType: { + wallet: 'GWALLET1', + badgeType: 'FIRST_BUYER', + }, + }, + create: expect.objectContaining({ + status: 'MINTED', + txHash: 'tx-abc', + triggerEvent: 'first_swap', + attempts: 1, + mintedAt: now, + }), + }) + ); + }); + + it('deduplicates: skips minting when the badge is already MINTED', async () => { + mockPrisma.badgeMintLog.findUnique.mockResolvedValueOnce({ + status: 'MINTED', + txHash: 'tx-existing', + attempts: 1, + }); + + const result = await mintBadge( + 'GWALLET1', + 'WHALE_HOLDER', + 'whale_swap', + deps() + ); + + expect(result).toEqual({ + wallet: 'GWALLET1', + badgeType: 'WHALE_HOLDER', + status: 'SKIPPED', + reason: 'already_earned', + txHash: 'tx-existing', + attempts: 1, + }); + // No contract call and no new log write for an already-earned badge. + expect(mockContractClient.mintBadge).not.toHaveBeenCalled(); + expect(mockPrisma.badgeMintLog.upsert).not.toHaveBeenCalled(); + }); + + it('retries transient mint failures and succeeds on a later attempt', async () => { + mockContractClient.mintBadge = jest + .fn() + .mockRejectedValueOnce( + Object.assign(new Error('RPC timeout'), { kind: 'transient' }) + ) + .mockRejectedValueOnce(new Error('connection reset')) // unknown => transient + .mockResolvedValueOnce({ txHash: 'tx-after-retry' }); + + const delay = jest.fn().mockResolvedValue(undefined); + const result = await mintBadge( + 'GWALLET2', + 'FIRST_BUYER', + 'first_swap', + deps({ delay }) + ); + + expect(result.status).toBe('MINTED'); + expect(result.txHash).toBe('tx-after-retry'); + expect(result.attempts).toBe(3); + expect(mockContractClient.mintBadge).toHaveBeenCalledTimes(3); + expect(delay).toHaveBeenCalledTimes(2); // backoff between the 3 attempts + }); + + it('gives up after exhausting attempts on persistent transient failures', async () => { + mockContractClient.mintBadge = jest + .fn() + .mockRejectedValue( + Object.assign(new Error('node unavailable'), { kind: 'transient' }) + ); + + const result = await mintBadge( + 'GWALLET3', + 'FIRST_BUYER', + 'first_swap', + deps({ maxAttempts: 3 }) + ); + + expect(result.status).toBe('FAILED'); + expect(result.attempts).toBe(3); + expect(result.error).toBe('node unavailable'); + expect(mockContractClient.mintBadge).toHaveBeenCalledTimes(3); + expect(mockPrisma.badgeMintLog.upsert).toHaveBeenCalledWith( + expect.objectContaining({ + create: expect.objectContaining({ + status: 'FAILED', + error: 'node unavailable', + attempts: 3, + }), + }) + ); + }); + + it('does not retry user-kind failures (terminal immediately)', async () => { + mockContractClient.mintBadge = jest + .fn() + .mockRejectedValue( + Object.assign(new Error('unauthorized'), { kind: 'user' }) + ); + + const result = await mintBadge( + 'GWALLET4', + 'FIRST_BUYER', + 'first_swap', + deps() + ); + + expect(result.status).toBe('FAILED'); + expect(result.attempts).toBe(1); + expect(mockContractClient.mintBadge).toHaveBeenCalledTimes(1); + }); + + it('re-attempts a badge whose previous mint FAILED (not deduped)', async () => { + mockPrisma.badgeMintLog.findUnique.mockResolvedValueOnce({ + status: 'FAILED', + txHash: null, + attempts: 2, + }); + + const result = await mintBadge( + 'GWALLET5', + 'GOVERNANCE_VOTER', + 'vote_cast', + deps() + ); + + expect(result.status).toBe('MINTED'); + expect(mockContractClient.mintBadge).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/badges/__tests__/badge-trigger.service.test.ts b/src/modules/badges/__tests__/badge-trigger.service.test.ts new file mode 100644 index 00000000..3212b9c5 --- /dev/null +++ b/src/modules/badges/__tests__/badge-trigger.service.test.ts @@ -0,0 +1,214 @@ +// src/modules/badges/__tests__/badge-trigger.service.test.ts + +jest.mock('../../../config', () => ({ + envConfig: { + BADGE_WHALE_THRESHOLD_XLM: 10000, + BADGE_HOLD_MILESTONE_DAYS: 365, + BADGE_TOP_TRADER_MAX_RANK: 10, + BADGE_MINT_MAX_ATTEMPTS: 3, + }, +})); + +jest.mock('../../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +import { + evaluateEvent, + processQualifyingEvent, +} from '../badge-trigger.service'; +import type { BadgeMintContractClient } from '../badge.types'; + +describe('Badge Trigger Service', () => { + describe('evaluateEvent (qualification rules)', () => { + it('first swap earns FIRST_BUYER', () => { + const awards = evaluateEvent({ + kind: 'swap', + wallet: 'G1', + amount: 5, + isFirstSwap: true, + }); + expect(awards.map(a => a.badgeType)).toEqual(['FIRST_BUYER']); + expect(awards[0].triggerEvent).toBe('first_swap'); + }); + + it('whale-sized swap earns WHALE_HOLDER', () => { + const awards = evaluateEvent({ + kind: 'swap', + wallet: 'G1', + amount: 10000, + isFirstSwap: false, + }); + expect(awards.map(a => a.badgeType)).toEqual(['WHALE_HOLDER']); + }); + + it('a first swap that is also whale-sized earns both badges', () => { + const awards = evaluateEvent({ + kind: 'swap', + wallet: 'G1', + amount: 25000, + isFirstSwap: true, + }); + expect(awards.map(a => a.badgeType)).toEqual([ + 'FIRST_BUYER', + 'WHALE_HOLDER', + ]); + }); + + it('a small non-first swap earns nothing', () => { + const awards = evaluateEvent({ + kind: 'swap', + wallet: 'G1', + amount: 9999, + isFirstSwap: false, + }); + expect(awards).toEqual([]); + }); + + it('a vote earns GOVERNANCE_VOTER', () => { + const awards = evaluateEvent({ + kind: 'vote', + wallet: 'G1', + proposalId: 'prop-1', + }); + expect(awards.map(a => a.badgeType)).toEqual(['GOVERNANCE_VOTER']); + }); + + it('a 365d hold earns DIAMOND_HANDS, a shorter hold does not', () => { + expect( + evaluateEvent({ + kind: 'hold', + wallet: 'G1', + holdDurationDays: 365, + }).map(a => a.badgeType) + ).toEqual(['DIAMOND_HANDS']); + + expect( + evaluateEvent({ + kind: 'hold', + wallet: 'G1', + holdDurationDays: 364, + }) + ).toEqual([]); + }); + + it('a top-10 leaderboard entry earns TOP_TRADER, rank 11 does not', () => { + expect( + evaluateEvent({ kind: 'leaderboard', wallet: 'G1', rank: 10 }).map( + a => a.badgeType + ) + ).toEqual(['TOP_TRADER']); + + expect( + evaluateEvent({ kind: 'leaderboard', wallet: 'G1', rank: 11 }) + ).toEqual([]); + }); + }); + + describe('processQualifyingEvent (mint dispatch)', () => { + const mockPrisma = { + badgeMintLog: { + findUnique: jest.fn(), + upsert: jest.fn(), + }, + }; + let mockContractClient: BadgeMintContractClient; + + beforeEach(() => { + jest.clearAllMocks(); + mockPrisma.badgeMintLog.findUnique.mockResolvedValue(null); + mockPrisma.badgeMintLog.upsert.mockResolvedValue({}); + mockContractClient = { + mintBadge: jest.fn().mockResolvedValue({ txHash: 'tx-1' }), + }; + }); + + const deps = () => ({ + prisma: mockPrisma as any, + contractClient: mockContractClient, + delay: jest.fn().mockResolvedValue(undefined), + baseDelayMs: 1, + }); + + it('first swap triggers a FIRST_BUYER mint', async () => { + const result = await processQualifyingEvent( + { kind: 'swap', wallet: 'GBUYER', amount: 5, isFirstSwap: true }, + deps() + ); + + expect(result.awards).toHaveLength(1); + expect(result.awards[0]).toMatchObject({ + badgeType: 'FIRST_BUYER', + status: 'MINTED', + }); + expect(mockContractClient.mintBadge).toHaveBeenCalledWith( + 'GBUYER', + 'FIRST_BUYER' + ); + }); + + it('whale swap triggers a WHALE_HOLDER mint', async () => { + const result = await processQualifyingEvent( + { + kind: 'swap', + wallet: 'GWHALE', + amount: 50000, + isFirstSwap: false, + }, + deps() + ); + + expect(result.awards.map(a => a.badgeType)).toEqual(['WHALE_HOLDER']); + expect(mockContractClient.mintBadge).toHaveBeenCalledWith( + 'GWHALE', + 'WHALE_HOLDER' + ); + }); + + it('governance vote triggers a GOVERNANCE_VOTER mint', async () => { + const result = await processQualifyingEvent( + { kind: 'vote', wallet: 'GVOTER', proposalId: 'p-9' }, + deps() + ); + + expect(result.awards.map(a => a.badgeType)).toEqual([ + 'GOVERNANCE_VOTER', + ]); + expect(mockContractClient.mintBadge).toHaveBeenCalledWith( + 'GVOTER', + 'GOVERNANCE_VOTER' + ); + }); + + it('does nothing for a non-qualifying event', async () => { + const result = await processQualifyingEvent( + { kind: 'leaderboard', wallet: 'GX', rank: 99 }, + deps() + ); + + expect(result.awards).toEqual([]); + expect(mockContractClient.mintBadge).not.toHaveBeenCalled(); + }); + + it('deduplicates an already-earned badge on reprocessing', async () => { + mockPrisma.badgeMintLog.findUnique.mockResolvedValueOnce({ + status: 'MINTED', + txHash: 'tx-old', + attempts: 1, + }); + + const result = await processQualifyingEvent( + { kind: 'vote', wallet: 'GVOTER', proposalId: 'p-9' }, + deps() + ); + + expect(result.awards[0].status).toBe('SKIPPED'); + expect(mockContractClient.mintBadge).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/badges/badge-mint.builder.ts b/src/modules/badges/badge-mint.builder.ts new file mode 100644 index 00000000..94c35224 --- /dev/null +++ b/src/modules/badges/badge-mint.builder.ts @@ -0,0 +1,73 @@ +// src/modules/badges/badge-mint.builder.ts +// Builds and signs the `mint_badge(wallet, badge_type)` Soroban transaction. + +import { + Account, + Address, + Contract, + Keypair, + Networks, + TransactionBuilder, + xdr, +} from '@stellar/stellar-base'; +import { envConfig } from '../../config'; +import type { BadgeType } from './badge.types'; + +/** Raised when the badge contract or minter key is not configured. */ +export class BadgeContractUnavailableError extends Error { + constructor(message = 'Badge minting is not configured') { + super(message); + this.name = 'BadgeContractUnavailableError'; + } +} + +function networkPassphrase(): string { + return envConfig.STELLAR_NETWORK === 'mainnet' + ? Networks.PUBLIC + : Networks.TESTNET; +} + +/** + * Build a signed Soroban transaction that invokes + * `mint_badge(recipient: Address, badge_type: Symbol)` on the configured badge + * contract, signed by the server's badge minter keypair. + * + * @throws {BadgeContractUnavailableError} when BADGE_CONTRACT_ID or + * BADGE_MINTER_SECRET is missing. + */ +export function buildMintBadgeTransaction( + wallet: string, + badgeType: BadgeType +): string { + const contractId = envConfig.BADGE_CONTRACT_ID; + const minterSecret = envConfig.BADGE_MINTER_SECRET; + if (!contractId || !minterSecret) { + throw new BadgeContractUnavailableError( + 'BADGE_CONTRACT_ID and BADGE_MINTER_SECRET must be set to mint badges' + ); + } + + const minter = Keypair.fromSecret(minterSecret); + const contract = new Contract(contractId); + const recipient = new Address(wallet); + + const op = contract.call( + 'mint_badge', + recipient.toScVal(), + xdr.ScVal.scvSymbol(badgeType) + ); + + // Sequence 0 placeholder: the submission pipeline / RPC assigns the real + // sequence. Mirrors the unsigned-builder pattern used elsewhere in the repo. + const tx = new TransactionBuilder(new Account(minter.publicKey(), '0'), { + fee: '100', + networkPassphrase: networkPassphrase(), + }) + .addOperation(op) + .setTimeout(300) + .build(); + + tx.sign(minter); + + return tx.toXDR(); +} diff --git a/src/modules/badges/badge-mint.client.ts b/src/modules/badges/badge-mint.client.ts new file mode 100644 index 00000000..73474507 --- /dev/null +++ b/src/modules/badges/badge-mint.client.ts @@ -0,0 +1,75 @@ +// src/modules/badges/badge-mint.client.ts +// Default on-chain client that mints badges through the centralised Soroban +// contract interaction service (#899). Keeping minting behind that pipeline +// means submission retries, transaction tracking, and resolution events are +// applied uniformly, exactly like every other contract call the server makes. + +import { logger } from '../../utils/logger.utils'; +import type { ContractErrorKind } from '../contracts/contract.types'; +import { buildMintBadgeTransaction } from './badge-mint.builder'; +import type { BadgeMintContractClient, BadgeType } from './badge.types'; + +/** + * Failure returned by the minting pipeline. `kind` lets the service decide + * whether another attempt could succeed (`transient`) or not (`user`). + */ +export class BadgeMintSubmitError extends Error { + constructor( + message: string, + public readonly kind: ContractErrorKind + ) { + super(message); + this.name = 'BadgeMintSubmitError'; + } +} + +/** Production client: builds, signs, and submits the mint_badge call. */ +export class DefaultBadgeMintContractClient implements BadgeMintContractClient { + async mintBadge( + wallet: string, + badgeType: BadgeType + ): Promise<{ txHash?: string }> { + logger.info( + { wallet, badgeType, operation: 'mint_badge' }, + 'Submitting mint_badge contract call' + ); + + const transactionXdr = buildMintBadgeTransaction(wallet, badgeType); + + // Imported lazily to mirror invoice-market.service and avoid a static + // import cycle with the contracts module. + const { submitContractCall } = + await import('../contracts/contract.service'); + + const result = await submitContractCall( + { + operation: 'mint_badge', + submitterWallet: wallet, + transactionXdr, + }, + {} + ); + + if (result.status === 'CONFIRMED') { + return { txHash: result.txHash }; + } + + // A FAILED resolution still returns (rather than throws); surface it as a + // typed error so the service's retry loop can classify it. + throw new BadgeMintSubmitError(result.lastError, result.errorKind); + } + + async mint_badge( + wallet: string, + badge_type: BadgeType + ): Promise<{ txHash?: string }> { + return this.mintBadge(wallet, badge_type); + } +} + +export function makeDefaultBadgeMintContractClient(): BadgeMintContractClient { + return new DefaultBadgeMintContractClient(); +} + +export const defaultBadgeMintContractClient = + makeDefaultBadgeMintContractClient(); diff --git a/src/modules/badges/badge-mint.service.ts b/src/modules/badges/badge-mint.service.ts new file mode 100644 index 00000000..e092591a --- /dev/null +++ b/src/modules/badges/badge-mint.service.ts @@ -0,0 +1,243 @@ +// src/modules/badges/badge-mint.service.ts +// Core badge minting with deduplication, audit logging, and retry (#1148). +// +// `mintBadge` is the single entry point for awarding a badge: +// +// 1. Deduplication guard — a wallet already holding a badge type is never +// re-minted (the badge_mint_log unique (wallet, badgeType) constraint +// backs this even under concurrency). +// 2. Mint via the injected contract client, retrying transient failures up +// to BADGE_MINT_MAX_ATTEMPTS. User-kind failures are terminal. +// 3. Every attempt's outcome is persisted to badge_mint_log for audit. +// +// Prisma, the contract client, the clock, and the delay are all injectable so +// the whole flow is unit-testable without a database or Soroban RPC. + +import type { Prisma } from '@prisma/client'; +import { prisma as defaultPrisma } from '../../utils/prisma.utils'; +import { logger } from '../../utils/logger.utils'; +import type { ContractErrorKind } from '../contracts/contract.types'; +import { defaultBadgeMintContractClient } from './badge-mint.client'; +import { mintMaxAttempts } from './badge.constants'; +import type { + BadgeMintContractClient, + BadgeMintResult, + BadgeTriggerEvent, + BadgeType, +} from './badge.types'; + +export interface BadgeMintDeps { + prisma?: typeof defaultPrisma; + contractClient?: BadgeMintContractClient; + now?: () => Date; + /** Max mint attempts before a transient failure is treated as terminal. */ + maxAttempts?: number; + /** Delay between retries; injectable so tests run instantly. */ + delay?: (ms: number) => Promise; + /** Base backoff delay in ms (linear per attempt). */ + baseDelayMs?: number; +} + +const defaultDelay = (ms: number): Promise => + new Promise(resolve => setTimeout(resolve, ms)); + +/** Classify a thrown mint error. Unknown errors are treated as transient. */ +function classifyMintError(error: unknown): ContractErrorKind { + const kind = (error as { kind?: unknown })?.kind; + return kind === 'user' ? 'user' : 'transient'; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Award a single badge to a wallet, deduplicating against prior mints and + * retrying transient on-chain failures. + */ +export async function mintBadge( + wallet: string, + badgeType: BadgeType, + triggerEvent: BadgeTriggerEvent, + deps?: BadgeMintDeps, + metadata?: Record +): Promise { + const prisma = deps?.prisma ?? defaultPrisma; + const contractClient = + deps?.contractClient ?? defaultBadgeMintContractClient; + const getNow = deps?.now ?? (() => new Date()); + const maxAttempts = Math.max(1, deps?.maxAttempts ?? mintMaxAttempts()); + const delay = deps?.delay ?? defaultDelay; + const baseDelayMs = deps?.baseDelayMs ?? 1000; + + // ── 1. Deduplication guard ─────────────────────────────────────────── + const existing = await prisma.badgeMintLog.findUnique({ + where: { wallet_badgeType: { wallet, badgeType } }, + select: { status: true, txHash: true, attempts: true }, + }); + + if (existing?.status === 'MINTED') { + logger.info( + { wallet, badgeType }, + 'Badge already minted for wallet; skipping (dedup)' + ); + return { + wallet, + badgeType, + status: 'SKIPPED', + reason: 'already_earned', + txHash: existing.txHash ?? undefined, + attempts: existing.attempts, + }; + } + + const callMint = + contractClient.mint_badge?.bind(contractClient) ?? + contractClient.mintBadge.bind(contractClient); + + // ── 2. Mint with retry on transient failures ───────────────────────── + let attempts = 0; + let lastError = ''; + + while (attempts < maxAttempts) { + attempts += 1; + try { + const result = await callMint(wallet, badgeType); + const txHash = result?.txHash ?? null; + const mintedAt = getNow(); + + await persistOutcome(prisma, { + wallet, + badgeType, + triggerEvent, + status: 'MINTED', + txHash, + attempts, + error: null, + mintedAt, + metadata, + }); + + logger.info( + { wallet, badgeType, triggerEvent, txHash, attempts }, + 'Badge minted successfully' + ); + + return { + wallet, + badgeType, + status: 'MINTED', + txHash: txHash ?? undefined, + attempts, + }; + } catch (error) { + lastError = errorMessage(error); + const kind = classifyMintError(error); + + logger.warn( + { + wallet, + badgeType, + triggerEvent, + attempt: attempts, + max_attempts: maxAttempts, + error_kind: kind, + error: lastError, + }, + 'Badge mint attempt failed' + ); + + // User-kind failures cannot be fixed by retrying. + if (kind === 'user') break; + + // Back off before another transient retry (not after the last one). + if (attempts < maxAttempts) { + await delay(baseDelayMs * attempts); + } + } + } + + await persistOutcome(prisma, { + wallet, + badgeType, + triggerEvent, + status: 'FAILED', + txHash: null, + attempts, + error: lastError, + mintedAt: null, + metadata, + }); + + return { + wallet, + badgeType, + status: 'FAILED', + attempts, + error: lastError, + }; +} + +/** + * Upsert the badge_mint_log row for an attempt outcome. The unique + * (wallet, badgeType) constraint keeps the audit trail to one row per award. + */ +async function persistOutcome( + prisma: typeof defaultPrisma, + row: { + wallet: string; + badgeType: BadgeType; + triggerEvent: BadgeTriggerEvent; + status: 'MINTED' | 'FAILED'; + txHash: string | null; + attempts: number; + error: string | null; + mintedAt: Date | null; + metadata?: Record; + } +): Promise { + try { + const metadata = + row.metadata === undefined + ? undefined + : (row.metadata as Prisma.InputJsonValue); + await prisma.badgeMintLog.upsert({ + where: { + wallet_badgeType: { + wallet: row.wallet, + badgeType: row.badgeType, + }, + }, + create: { + wallet: row.wallet, + badgeType: row.badgeType, + triggerEvent: row.triggerEvent, + status: row.status, + txHash: row.txHash, + attempts: row.attempts, + error: row.error, + mintedAt: row.mintedAt, + ...(metadata === undefined ? {} : { metadata }), + }, + update: { + status: row.status, + triggerEvent: row.triggerEvent, + txHash: row.txHash, + attempts: row.attempts, + error: row.error, + mintedAt: row.mintedAt, + ...(metadata === undefined ? {} : { metadata }), + }, + }); + } catch (error) { + // Logging must never break the award path (mirrors the contract service). + logger.error( + { + wallet: row.wallet, + badgeType: row.badgeType, + error: errorMessage(error), + }, + 'Failed to persist badge mint log entry' + ); + } +} diff --git a/src/modules/badges/badge-trigger.service.ts b/src/modules/badges/badge-trigger.service.ts new file mode 100644 index 00000000..1468fede --- /dev/null +++ b/src/modules/badges/badge-trigger.service.ts @@ -0,0 +1,133 @@ +// src/modules/badges/badge-trigger.service.ts +// Badge trigger service (#1148): turns qualifying on-chain events into badge +// awards and routes them through the deduplicating mint service. +// +// `evaluateEvent` is pure — it decides which badges an event earns — so the +// qualification rules are trivially unit-testable. `processQualifyingEvent` +// wires that decision to `mintBadge`. Indexer / webhook consumers call +// `processQualifyingEvent` as qualifying activity is observed. + +import { logger } from '../../utils/logger.utils'; +import { + holdMilestoneDays, + topTraderMaxRank, + whaleThresholdXlm, +} from './badge.constants'; +import { mintBadge, type BadgeMintDeps } from './badge-mint.service'; +import type { + BadgeAward, + ProcessEventResult, + QualifyingEvent, +} from './badge.types'; + +/** + * Pure qualification check: given a qualifying event, return the badge awards + * it earns. A single swap can earn both FIRST_BUYER and WHALE_HOLDER. + */ +export function evaluateEvent(event: QualifyingEvent): BadgeAward[] { + switch (event.kind) { + case 'swap': { + const awards: BadgeAward[] = []; + if (event.isFirstSwap) { + awards.push({ + wallet: event.wallet, + badgeType: 'FIRST_BUYER', + triggerEvent: 'first_swap', + metadata: { amount: event.amount, txHash: event.txHash }, + }); + } + if (event.amount >= whaleThresholdXlm()) { + awards.push({ + wallet: event.wallet, + badgeType: 'WHALE_HOLDER', + triggerEvent: 'whale_swap', + metadata: { + amount: event.amount, + threshold: whaleThresholdXlm(), + txHash: event.txHash, + }, + }); + } + return awards; + } + case 'vote': + return [ + { + wallet: event.wallet, + badgeType: 'GOVERNANCE_VOTER', + triggerEvent: 'vote_cast', + metadata: { proposalId: event.proposalId, txHash: event.txHash }, + }, + ]; + case 'hold': + if (event.holdDurationDays >= holdMilestoneDays()) { + return [ + { + wallet: event.wallet, + badgeType: 'DIAMOND_HANDS', + triggerEvent: 'hold_milestone', + metadata: { + holdDurationDays: event.holdDurationDays, + milestoneDays: holdMilestoneDays(), + txHash: event.txHash, + }, + }, + ]; + } + return []; + case 'leaderboard': + if (event.rank >= 1 && event.rank <= topTraderMaxRank()) { + return [ + { + wallet: event.wallet, + badgeType: 'TOP_TRADER', + triggerEvent: 'leaderboard_entry', + metadata: { + rank: event.rank, + maxRank: topTraderMaxRank(), + txHash: event.txHash, + }, + }, + ]; + } + return []; + default: { + // Exhaustiveness guard: a new event kind must be handled above. + const _never: never = event; + return _never; + } + } +} + +/** + * Process a qualifying on-chain event: evaluate the badges it earns and mint + * each one (deduplicated). Minting one badge never blocks the others. + */ +export async function processQualifyingEvent( + event: QualifyingEvent, + deps?: BadgeMintDeps +): Promise { + const awards = evaluateEvent(event); + + if (awards.length === 0) { + logger.debug( + { kind: event.kind, wallet: event.wallet }, + 'Qualifying event earned no badges' + ); + return { awards: [] }; + } + + const results = await Promise.all( + awards.map(award => + mintBadge( + award.wallet, + award.badgeType, + award.triggerEvent, + deps, + award.metadata + ) + ) + ); + + return { awards: results }; +} diff --git a/src/modules/badges/badge.constants.ts b/src/modules/badges/badge.constants.ts new file mode 100644 index 00000000..b5766e50 --- /dev/null +++ b/src/modules/badges/badge.constants.ts @@ -0,0 +1,52 @@ +// src/modules/badges/badge.constants.ts +// Thresholds and defaults for the badge minting trigger service (#1148). + +import { envConfig } from '../../config'; +import type { BadgeType, BadgeTriggerEvent } from './badge.types'; + +/** + * Minimum single-swap size (in XLM) that qualifies a wallet for the + * WHALE_HOLDER badge. Overridable via BADGE_WHALE_THRESHOLD_XLM. + */ +export function whaleThresholdXlm(): number { + return envConfig.BADGE_WHALE_THRESHOLD_XLM; +} + +/** + * Continuous hold duration (in days) that qualifies a wallet for the + * DIAMOND_HANDS long-term hold badge. Overridable via BADGE_HOLD_MILESTONE_DAYS. + */ +export function holdMilestoneDays(): number { + return envConfig.BADGE_HOLD_MILESTONE_DAYS; +} + +/** + * Highest leaderboard rank (inclusive, 1-based) that qualifies a wallet for + * the TOP_TRADER badge. Overridable via BADGE_TOP_TRADER_MAX_RANK. + */ +export function topTraderMaxRank(): number { + return envConfig.BADGE_TOP_TRADER_MAX_RANK; +} + +/** Maximum mint attempts for a single badge before giving up (transient). */ +export function mintMaxAttempts(): number { + return envConfig.BADGE_MINT_MAX_ATTEMPTS; +} + +/** Human-readable description per badge type (for logs / future API). */ +export const BADGE_DESCRIPTIONS: Record = { + FIRST_BUYER: 'Awarded on a wallet’s first key purchase', + WHALE_HOLDER: 'Awarded for a swap above the whale threshold', + GOVERNANCE_VOTER: 'Awarded for casting a governance vote', + DIAMOND_HANDS: 'Awarded for a 365-day continuous hold', + TOP_TRADER: 'Awarded for entering the top-trade leaderboard', +}; + +/** The trigger event that is the canonical source for each badge type. */ +export const BADGE_TRIGGER: Record = { + FIRST_BUYER: 'first_swap', + WHALE_HOLDER: 'whale_swap', + GOVERNANCE_VOTER: 'vote_cast', + DIAMOND_HANDS: 'hold_milestone', + TOP_TRADER: 'leaderboard_entry', +}; diff --git a/src/modules/badges/badge.types.ts b/src/modules/badges/badge.types.ts new file mode 100644 index 00000000..34108edc --- /dev/null +++ b/src/modules/badges/badge.types.ts @@ -0,0 +1,122 @@ +// src/modules/badges/badge.types.ts +// Shared types for the key NFT badge minting trigger service (#1148). + +/** Badge kinds the trigger service can award. */ +export type BadgeType = + | 'FIRST_BUYER' + | 'WHALE_HOLDER' + | 'GOVERNANCE_VOTER' + | 'DIAMOND_HANDS' + | 'TOP_TRADER'; + +export const BADGE_TYPES: BadgeType[] = [ + 'FIRST_BUYER', + 'WHALE_HOLDER', + 'GOVERNANCE_VOTER', + 'DIAMOND_HANDS', + 'TOP_TRADER', +]; + +export function isBadgeType(value: string): value is BadgeType { + return BADGE_TYPES.includes(value as BadgeType); +} + +/** Qualifying on-chain event that triggered an award (stored for audit). */ +export type BadgeTriggerEvent = + | 'first_swap' + | 'whale_swap' + | 'vote_cast' + | 'hold_milestone' + | 'leaderboard_entry'; + +/** Lifecycle status of a badge mint log row. */ +export type BadgeMintStatus = 'PENDING' | 'MINTED' | 'FAILED'; + +// ── Qualifying events consumed by the trigger service ────────────────────── + +/** A swap/first-buy. Can qualify for FIRST_BUYER and/or WHALE_HOLDER. */ +export interface SwapQualifyingEvent { + kind: 'swap'; + wallet: string; + /** Trade size in XLM; compared against the whale threshold. */ + amount: number; + /** Whether this is the wallet's first-ever swap (from the indexer). */ + isFirstSwap: boolean; + txHash?: string; +} + +/** A governance vote cast. Qualifies for GOVERNANCE_VOTER. */ +export interface VoteQualifyingEvent { + kind: 'vote'; + wallet: string; + proposalId: string; + txHash?: string; +} + +/** A long-term hold milestone. Qualifies for DIAMOND_HANDS at 365d. */ +export interface HoldQualifyingEvent { + kind: 'hold'; + wallet: string; + /** Continuous hold duration in days. */ + holdDurationDays: number; + txHash?: string; +} + +/** A leaderboard entry. Qualifies for TOP_TRADER when inside the top rank. */ +export interface LeaderboardQualifyingEvent { + kind: 'leaderboard'; + wallet: string; + /** 1-based leaderboard rank. */ + rank: number; + txHash?: string; +} + +export type QualifyingEvent = + | SwapQualifyingEvent + | VoteQualifyingEvent + | HoldQualifyingEvent + | LeaderboardQualifyingEvent; + +/** A single badge award derived from a qualifying event. */ +export interface BadgeAward { + wallet: string; + badgeType: BadgeType; + triggerEvent: BadgeTriggerEvent; + /** Qualifying context persisted alongside the mint log row. */ + metadata?: Record; +} + +/** + * On-chain client that mints a badge for a wallet. Injectable so the service + * can be unit-tested without a Soroban RPC. Mirrors the campaign contract + * client's dual camelCase/snake_case surface. + */ +export interface BadgeMintContractClient { + mintBadge( + wallet: string, + badgeType: BadgeType + ): Promise<{ txHash?: string }>; + mint_badge?( + wallet: string, + badge_type: BadgeType + ): Promise<{ txHash?: string }>; +} + +/** Terminal outcome of a single `mintBadge` attempt sequence. */ +export interface BadgeMintResult { + wallet: string; + badgeType: BadgeType; + /** MINTED on success, SKIPPED when already earned, FAILED otherwise. */ + status: 'MINTED' | 'SKIPPED' | 'FAILED'; + txHash?: string; + attempts: number; + /** Reason a mint was skipped (e.g. already earned). */ + reason?: string; + /** Error message when status is FAILED. */ + error?: string; +} + +/** Aggregate outcome of processing one qualifying event. */ +export interface ProcessEventResult { + awards: BadgeMintResult[]; +} diff --git a/src/modules/badges/index.ts b/src/modules/badges/index.ts new file mode 100644 index 00000000..5971e896 --- /dev/null +++ b/src/modules/badges/index.ts @@ -0,0 +1,24 @@ +// src/modules/badges/index.ts +// Public surface of the key NFT badge minting trigger service (#1148). + +export * from './badge.types'; +export { + BADGE_DESCRIPTIONS, + BADGE_TRIGGER, + whaleThresholdXlm, + holdMilestoneDays, + topTraderMaxRank, + mintMaxAttempts, +} from './badge.constants'; +export { mintBadge, type BadgeMintDeps } from './badge-mint.service'; +export { evaluateEvent, processQualifyingEvent } from './badge-trigger.service'; +export { + DefaultBadgeMintContractClient, + makeDefaultBadgeMintContractClient, + defaultBadgeMintContractClient, + BadgeMintSubmitError, +} from './badge-mint.client'; +export { + buildMintBadgeTransaction, + BadgeContractUnavailableError, +} from './badge-mint.builder'; diff --git a/src/modules/contracts/contract.types.ts b/src/modules/contracts/contract.types.ts index 335e1422..95fc19a5 100644 --- a/src/modules/contracts/contract.types.ts +++ b/src/modules/contracts/contract.types.ts @@ -12,7 +12,8 @@ export type ContractOperation = | 'stake' | 'governance' | 'claim' - | 'qualify_wallet'; + | 'qualify_wallet' + | 'mint_badge'; export const CONTRACT_OPERATIONS: ContractOperation[] = [ 'buy', @@ -21,6 +22,7 @@ export const CONTRACT_OPERATIONS: ContractOperation[] = [ 'governance', 'claim', 'qualify_wallet', + 'mint_badge', ]; export function isContractOperation(value: string): value is ContractOperation {