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
9 changes: 5 additions & 4 deletions docs/api-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,11 @@ events.

- Referral tier reference: [`docs/referral-tiers.md`](./referral-tiers.md).

| Method | Path | Description |
| :----- | :-------------------------- | :----------------------------------------------------------------------- |
| `GET` | `/referrals/tier` | Current tier, cumulative volume, next threshold and reward rate (bps). |
| `GET` | `/referrals/tier/history` | Cursor-paginated tier upgrade history for a wallet. |
| Method | Path | Description |
| :----- | :------------------------ | :--------------------------------------------------------------------- |
| `GET` | `/referrals/tier` | Current tier, cumulative volume, next threshold and reward rate (bps). |
| `GET` | `/referrals/tier/history` | Cursor-paginated tier upgrade history for a wallet. |

Wallet-scoped referral reward earnings indexed from `ReferralRewardDistributed`
events.

Expand Down
66 changes: 33 additions & 33 deletions docs/referral-tiers.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ Tiers are defined in `src/modules/referrals/referral-tier.constants.ts`. A
wallet's tier is a function of the cumulative volume of referrals it has
driven; the reward rate (basis points) is applied to referred volume.

| Tier | Minimum cumulative volume (XLM) | Reward rate |
| :--- | :------------------------------ | :-------------- |
| 1 | 0 | 500 bps (5%) |
| 2 | 1,000 | 750 bps (7.5%) |
| 3 | 10,000 | 1000 bps (10%) |
| Tier | Minimum cumulative volume (XLM) | Reward rate |
| :--- | :------------------------------ | :------------- |
| 1 | 0 | 500 bps (5%) |
| 2 | 1,000 | 750 bps (7.5%) |
| 3 | 10,000 | 1000 bps (10%) |

Tier 1 is the entry tier, so every wallet always has a tier. Once a wallet is
at the top tier, `nextThreshold` and `distanceToNextThreshold` are `null`.
Expand All @@ -29,28 +29,28 @@ Tier state is stored in two tables (`prisma/schema/referral.prisma`):

Current tier per wallet — one row per referrer.

| Field | Type | Notes |
| :----------------- | :-------------- | :--------------------------------------- |
| `walletAddress` | `String` | Unique referrer wallet. |
| `tier` | `Int` | Highest tier reached (1, 2 or 3). |
| `cumulativeVolume` | `Decimal(20,7)` | Cumulative referred volume in XLM. |
| `lastUpgradedAt` | `DateTime?` | Timestamp of the most recent upgrade. |
| `updatedAt` | `DateTime` | Last mutation time. |
| Field | Type | Notes |
| :----------------- | :-------------- | :------------------------------------ |
| `walletAddress` | `String` | Unique referrer wallet. |
| `tier` | `Int` | Highest tier reached (1, 2 or 3). |
| `cumulativeVolume` | `Decimal(20,7)` | Cumulative referred volume in XLM. |
| `lastUpgradedAt` | `DateTime?` | Timestamp of the most recent upgrade. |
| `updatedAt` | `DateTime` | Last mutation time. |

### `referral_tier_events` (`ReferralTierEvent`)

Append-only upgrade log — one row per `ReferralTierUpgraded` event.

| Field | Type | Notes |
| :----------------- | :-------------- | :--------------------------------------- |
| `walletAddress` | `String` | Referrer that upgraded. |
| `previousTier` | `Int` | Tier before the upgrade. |
| `newTier` | `Int` | Tier after the upgrade. |
| `cumulativeVolume` | `Decimal(20,7)` | Volume at the time of the upgrade. |
| `txHash` | `String` | Transaction hash. |
| `eventIndex` | `Int` | Event index within the transaction. |
| `ledger` | `Int` | Ledger the event was observed in. |
| `occurredAt` | `DateTime` | On-chain event timestamp. |
| Field | Type | Notes |
| :----------------- | :-------------- | :---------------------------------- |
| `walletAddress` | `String` | Referrer that upgraded. |
| `previousTier` | `Int` | Tier before the upgrade. |
| `newTier` | `Int` | Tier after the upgrade. |
| `cumulativeVolume` | `Decimal(20,7)` | Volume at the time of the upgrade. |
| `txHash` | `String` | Transaction hash. |
| `eventIndex` | `Int` | Event index within the transaction. |
| `ledger` | `Int` | Ledger the event was observed in. |
| `occurredAt` | `DateTime` | On-chain event timestamp. |

`(txHash, eventIndex)` is unique, so replaying the same chain event never
records a duplicate upgrade.
Expand Down Expand Up @@ -80,10 +80,10 @@ Event shape:
interface ReferralTierUpgradedEvent {
eventType: 'REFERRAL_TIER_UPGRADED';
walletAddress: string;
previousTier: number; // 0 for a wallet's first tier
newTier: number; // 1 | 2 | 3
previousTier: number; // 0 for a wallet's first tier
newTier: number; // 1 | 2 | 3
cumulativeVolume: string; // XLM, string for precision
upgradedAt: string; // ISO timestamp
upgradedAt: string; // ISO timestamp
ledger: number;
txHash: string;
eventIndex: number;
Expand All @@ -101,9 +101,9 @@ returns `401`.
Current tier and the metadata needed to render progress towards the next tier.
Cached in Redis for 5 minutes per wallet (invalidated when an upgrade lands).

| Query param | Type | Notes |
| :---------- | :----- | :---------------------------------------- |
| `wallet` | string | Required. Referrer wallet address. |
| Query param | Type | Notes |
| :---------- | :----- | :--------------------------------- |
| `wallet` | string | Required. Referrer wallet address. |

```json
{
Expand All @@ -124,11 +124,11 @@ Cached in Redis for 5 minutes per wallet (invalidated when an upgrade lands).

Cursor-paginated upgrade log, newest first.

| Query param | Type | Default | Notes |
| :---------- | :------ | :------ | :------------------------------------- |
| `wallet` | string | – | Required. Referrer wallet address. |
| `limit` | integer | `20` | 1–100. |
| `cursor` | string | – | Opaque cursor from the previous page. |
| Query param | Type | Default | Notes |
| :---------- | :------ | :------ | :------------------------------------ |
| `wallet` | string | – | Required. Referrer wallet address. |
| `limit` | integer | `20` | 1–100. |
| `cursor` | string | – | Opaque cursor from the previous page. |

```json
{
Expand Down
21 changes: 21 additions & 0 deletions prisma/schema/key-fee-history.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// prisma/schema/key-fee-history.prisma
// Fee tier change history indexed from FeeAdjusted contract events.

model KeyFeeHistory {
id String @id @default(cuid())
keyId String @map("key_id")
feeBps Int @map("fee_bps")
previousFeeBps Int? @map("previous_fee_bps")
volumeBracket String? @map("volume_bracket")
rolling24hVolume String? @map("rolling_24h_volume")
ledger Int
txHash String @map("tx_hash")
eventIndex Int @map("event_index")
timestamp DateTime
createdAt DateTime @default(now()) @map("created_at")

@@unique([txHash, eventIndex])
@@index([keyId, timestamp(sort: Desc)])
@@index([keyId])
@@map("key_fee_history")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
-- Fee tier change history indexed from FeeAdjusted contract events.
CREATE TABLE "key_fee_history" (
"id" TEXT NOT NULL,
"key_id" TEXT NOT NULL,
"fee_bps" INTEGER NOT NULL,
"previous_fee_bps" INTEGER,
"volume_bracket" TEXT,
"rolling_24h_volume" TEXT,
"ledger" INTEGER NOT NULL,
"tx_hash" TEXT NOT NULL,
"event_index" INTEGER NOT NULL,
"timestamp" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

-- CreateIndex
CREATE UNIQUE INDEX "key_fee_history_tx_hash_event_index_key" ON "key_fee_history"("tx_hash", "event_index");

-- CreateIndex
CREATE INDEX "key_fee_history_key_id_timestamp_idx" ON "key_fee_history"("key_id", "timestamp" DESC);

-- CreateIndex
CREATE INDEX "key_fee_history_key_id_idx" ON "key_fee_history"("key_id");
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ import { errorHandler, notFoundHandler } from './middlewares/error.middleware';
import router from './modules/index';
import stakingRouter from './modules/staking/staking.routes';
import statusRouter from './modules/status/status.routes';
import keysRouter from './modules/keys/keys.routes';
import tradesRouter from './modules/trades/trades.routes';
import tradersRouter from './modules/traders/traders.routes';
import nonceRouter from './modules/nonce/nonce.routes';
import swapsRouter from './modules/swaps/swaps.routes';
import keysRouter from './modules/keys/keys.routes';
import milestonesRouter from './modules/keys/milestones.routes';
import internalRouter from './modules/admin/sequencer.routes';
import eventsRouter from './modules/events/events.routes';
Expand Down
3 changes: 3 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,9 @@ export const envSchema = z
.positive()
.default(300),

// Dynamic fee contract (#1055)
DYNAMIC_FEE_CONTRACT_ID: optionalNonEmptyString,

// Nonce registry contract (#1039): deployed contract id holding the
// on-chain single-use nonce registry, and short TTL for cached nonces.
NONCE_REGISTRY_CONTRACT_ID: optionalNonEmptyString,
Expand Down
132 changes: 132 additions & 0 deletions src/modules/indexer/key-fee-indexer.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// src/modules/indexer/key-fee-indexer.service.test.ts

jest.mock('../../utils/prisma.utils', () => ({
prisma: {
creatorProfile: {
findFirst: jest.fn(),
},
keyFeeHistory: {
upsert: jest.fn(),
},
},
}));

jest.mock('../keys/key-swap-fee.service', () => ({
invalidateKeyFeeCache: jest.fn(),
}));

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

import { prisma } from '../../utils/prisma.utils';
import { invalidateKeyFeeCache } from '../keys/key-swap-fee.service';
import {
processFeeAdjustedEvents,
FeeAdjustedChainEvent,
} from './key-fee-indexer.service';

const mockPrisma = prisma as unknown as {
creatorProfile: { findFirst: jest.Mock };
keyFeeHistory: { upsert: jest.Mock };
};
const mockInvalidateCache = invalidateKeyFeeCache as jest.Mock;

describe('key-fee-indexer.service', () => {
const KEY_ID = 'key-uuid-1';

beforeEach(() => {
jest.clearAllMocks();
mockPrisma.creatorProfile.findFirst.mockResolvedValue({
id: KEY_ID,
});
mockPrisma.keyFeeHistory.upsert.mockResolvedValue({
id: 'fee-hist-1',
});
});

it('indexes FeeAdjusted event and invalidates cache for affected key', async () => {
const event: FeeAdjustedChainEvent = {
eventType: 'FeeAdjusted',
keyId: KEY_ID,
feeBps: 300,
previousFeeBps: 400,
volumeBracket: 'TIER_3',
rolling24hVolume: '5500000000',
ledger: 105,
txHash: '0x123456',
eventIndex: 1,
timestamp: '2026-10-17T12:00:00Z',
};

await processFeeAdjustedEvents([event]);

expect(mockPrisma.creatorProfile.findFirst).toHaveBeenCalledWith({
where: { OR: [{ id: KEY_ID }, { handle: KEY_ID }] },
select: { id: true },
});

expect(mockPrisma.keyFeeHistory.upsert).toHaveBeenCalledWith({
where: {
txHash_eventIndex: {
txHash: '0x123456',
eventIndex: 1,
},
},
create: {
keyId: KEY_ID,
feeBps: 300,
previousFeeBps: 400,
volumeBracket: 'TIER_3',
rolling24hVolume: '5500000000',
ledger: 105,
txHash: '0x123456',
eventIndex: 1,
timestamp: new Date('2026-10-17T12:00:00Z'),
},
update: {
feeBps: 300,
previousFeeBps: 400,
volumeBracket: 'TIER_3',
rolling24hVolume: '5500000000',
timestamp: new Date('2026-10-17T12:00:00Z'),
},
});

expect(mockInvalidateCache).toHaveBeenCalledWith(KEY_ID);
});

it('skips non-FeeAdjusted events', async () => {
await processFeeAdjustedEvents([
{
eventType: 'OTHER_EVENT' as any,
txHash: '0xabc',
eventIndex: 0,
} as any,
]);

expect(mockPrisma.keyFeeHistory.upsert).not.toHaveBeenCalled();
expect(mockInvalidateCache).not.toHaveBeenCalled();
});

it('skips events missing required fields', async () => {
await processFeeAdjustedEvents([
{
eventType: 'FeeAdjusted',
keyId: KEY_ID,
// missing feeBps
txHash: '0xabc',
eventIndex: 0,
ledger: 100,
} as any,
]);

expect(mockPrisma.keyFeeHistory.upsert).not.toHaveBeenCalled();
expect(mockInvalidateCache).not.toHaveBeenCalled();
});
});
Loading
Loading