Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ LOCKUP_DURATION_SECONDS=0
# aggregated into a pool per cycle and claimed proportionally to stake weight.
REVENUE_DISTRIBUTION_CYCLE_DAYS=7

# Referral programme (#910): share of a referred wallet's first trade paid to
# the referrer, in basis points (500 = 5%). Paid once per referee.
REFERRAL_REWARD_BPS=500

# 2-of-3 admin multisig quorum for key deprecation (#882).
# Comma-separated Stellar addresses of the three admin wallets.
ADMIN_MULTISIG_WALLETS=
Expand Down
5 changes: 5 additions & 0 deletions docs/ENVIRONMENT_VARIABLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ Complete reference for all server configuration environment variables.

## Protocol

| Variable | Type | Required | Default | Description |
| --------------------------------- | ------ | -------- | ------- | ------------------------------------------------------------------------------------------------- |
| `REVENUE_DISTRIBUTION_CYCLE_DAYS` | number | No | `7` | Length of each protocol revenue distribution cycle in days (#883) |
| `REFERRAL_REWARD_BPS` | number | No | `500` | Share of a referred wallet's first trade paid to the referrer, in basis points (500 = 5%). Paid once per referee (#910). |
| `ADMIN_MULTISIG_WALLETS` | string | No | _(unset)_ | Comma-separated Stellar addresses of the 2-of-3 admin quorum for key deprecation (#882). When unset, two distinct valid signatures are still required but no allowlist is enforced (development default). |
| Variable | Type | Required | Default | Description |
| -------------------------------------- | ------- | -------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `REVENUE_DISTRIBUTION_CYCLE_DAYS` | number | No | `7` | Length of each protocol revenue distribution cycle in days (#883) |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
-- Referral tracking and reward distribution (#910): referral codes issued per
-- wallet, the referee -> referrer relationship, and the referred-wallet column
-- on the referral fee ledger so earnings can be broken down per referral.

-- CreateTable
CREATE TABLE "ReferralCode" (
"id" TEXT NOT NULL,
"walletAddress" TEXT NOT NULL,
"code" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

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

-- CreateTable
CREATE TABLE "Referral" (
"id" TEXT NOT NULL,
"referrerAddress" TEXT NOT NULL,
"refereeAddress" TEXT NOT NULL,
"referralCode" TEXT NOT NULL,
"firstTradeAt" TIMESTAMP(3),
"firstTradeKeyId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

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

-- CreateIndex
CREATE UNIQUE INDEX "ReferralCode_walletAddress_key" ON "ReferralCode"("walletAddress");

-- CreateIndex
CREATE UNIQUE INDEX "ReferralCode_code_key" ON "ReferralCode"("code");

-- CreateIndex
CREATE INDEX "ReferralCode_walletAddress_idx" ON "ReferralCode"("walletAddress");

-- CreateIndex
CREATE UNIQUE INDEX "Referral_refereeAddress_key" ON "Referral"("refereeAddress");

-- CreateIndex
CREATE INDEX "Referral_referrerAddress_createdAt_idx" ON "Referral"("referrerAddress", "createdAt");

-- CreateIndex
CREATE INDEX "Referral_referrerAddress_idx" ON "Referral"("referrerAddress");

-- AlterTable
ALTER TABLE "ReferralEvent" ADD COLUMN "refereeAddress" TEXT;

-- CreateIndex
CREATE INDEX "ReferralEvent_refereeAddress_idx" ON "ReferralEvent"("refereeAddress");
53 changes: 53 additions & 0 deletions prisma/schema/referral.prisma
Original file line number Diff line number Diff line change
@@ -1,5 +1,53 @@
// prisma/schema/referral.prisma

/**
* Referral code issued once per wallet (#910). Referrers share this code so
* new wallets can register as their referees via
* `POST /referrals/register`. The code is generated on demand and is stable
* for the lifetime of the wallet.
*/
model ReferralCode {
id String @id @default(cuid())

// Wallet the code belongs to
walletAddress String @unique

// Public code shared by the referrer
code String @unique

createdAt DateTime @default(now())

@@index([walletAddress])
}

/// Links a referee wallet to the referrer that owns its referral code (#910).
/// A wallet can only ever be referred once (`refereeAddress` is unique), so a
/// second registration attempt is a 409 conflict.
model Referral {
id String @id @default(cuid())

// Referrer wallet that owns the referral code
referrerAddress String

// Referee wallet that registered with the referrer's code
refereeAddress String @unique

// Referral code used at registration time
referralCode String

// Set once the referee completes their first trade; null until then
firstTradeAt DateTime?

// Key (creator id) of the trade that triggered the first-trade reward
firstTradeKeyId String?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([referrerAddress, createdAt])
@@index([referrerAddress])
}

/**
* Referral fee earnings indexed from the on-chain `referral_fee_paid` event.
*
Expand All @@ -18,6 +66,10 @@ model ReferralEvent {
// Optional denormalised creator id for joins
creatorId String?

// Referred wallet whose trade generated the fee. Null for fees that were
// not attributed to a specific referee (e.g. raw on-chain events).
refereeAddress String?

// Fee amount in XLM
amount Decimal

Expand All @@ -29,4 +81,5 @@ model ReferralEvent {

@@index([walletAddress, createdAt])
@@index([keyId])
@@index([refereeAddress])
}
5 changes: 5 additions & 0 deletions src/config.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export const envSchema = z
.positive()
.default(7),

// Referral programme (#910): share of a referred wallet's first trade
// paid to the referrer, in basis points (500 = 5%). Paid once per
// referee, on their first trade only (see src/modules/referrals).
REFERRAL_REWARD_BPS: z.coerce.number().int().nonnegative().default(500),

// 2-of-3 admin multisig set for key deprecation (#882). Comma-separated
// Stellar addresses of the admin quorum. When unset, deprecation still
// requires two distinct valid admin signatures but no allowlist is
Expand Down
2 changes: 2 additions & 0 deletions src/modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import revenueRouter from './revenue/revenue.routes';
import stakerRouter from './revenue/staker-revenue.routes';
import vaultRouter from './staking/vault.routes';
import portfolioRouter from './portfolio/portfolio.routes';
import referralRouter from './referrals/referrals.routes';
import contractsRouter from './contracts/contract.routes';
import stakingRouter from './staking/staking.routes';
import sellersRouter from './sellers/sellers.routes';
Expand Down Expand Up @@ -81,6 +82,7 @@ router.use('/revenue', routeBodySizeLimit('default'), revenueRouter);
router.use('/staker', routeBodySizeLimit('default'), stakerRouter);
router.use('/staking', routeBodySizeLimit('default'), vaultRouter);
router.use('/portfolio', routeBodySizeLimit('default'), portfolioRouter);
router.use('/referrals', routeBodySizeLimit('default'), referralRouter);
router.use('/contracts', routeBodySizeLimit('default'), contractsRouter);
router.use('/watchlist', routeBodySizeLimit('default'), watchlistRouter);
router.use('/investor/watchlist', routeBodySizeLimit('default'), watchlistRouter);
Expand Down
45 changes: 35 additions & 10 deletions src/modules/indexer/indexer-pipeline.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import { logSellTransactionConfirmed } from '../../utils/sell-transaction-logger.utils';
import { persistCirculatingSupply } from './persist-circulating-supply.service';
import { invalidateVolumeLeaderboardCache } from '../creators/creator-leaderboard-volume.service';
import { recordFirstTradeReferralReward } from '../referrals/referrals.service';
import { invalidateCreatorPortfolioStatsCache } from '../creators/creator-portfolio.service';

/**
Expand Down Expand Up @@ -74,7 +75,41 @@
}
}

const { creatorId, actor, amount, price, feePaid, tradeAt, ledger } = event;

Check failure on line 78 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'ledger'.

Check failure on line 78 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'tradeAt'.

Check failure on line 78 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'feePaid'.

Check failure on line 78 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'price'.

Check failure on line 78 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'amount'.

Check failure on line 78 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'actor'.

Check failure on line 78 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'creatorId'.
const tradeQty = Number(amount);
let pricePerKeyXlm = 0;
try {
pricePerKeyXlm = Number(BigInt(price as any)) / 10_000_000;
} catch {
pricePerKeyXlm = Number(price as any);
}
if (!Number.isFinite(pricePerKeyXlm) || pricePerKeyXlm < 0) {
pricePerKeyXlm = 0;
}

// Run referral bookkeeping before the non-transactional trade writes so
// a failure can abort this event and let the indexer retry it.
// The conditional claim makes a successful referral write replay-safe.
try {
await recordFirstTradeReferralReward({
refereeAddress: actor,
keyId: creatorId,
tradeValueXlm: pricePerKeyXlm * tradeQty,
txHash: event.txHash,
eventIndex: event.eventIndex,
tradeAt: new Date(tradeAt),
});
} catch (error) {
logger.warn(
{
eventId: `${event.txHash}:${event.eventIndex}`,
error,
},
'Failed to record referral first trade reward'
);
throw error;
}
const { creatorId, actor, amount, price, feePaid, tradeAt, ledger } =

Check failure on line 112 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'amount'.

Check failure on line 112 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'actor'.

Check failure on line 112 in src/modules/indexer/indexer-pipeline.service.ts

View workflow job for this annotation

GitHub Actions / verify

Cannot redeclare block-scoped variable 'creatorId'.
event;

// 1. Create corresponding Activity record
Expand Down Expand Up @@ -106,16 +141,6 @@
// the pipeline.
// Event `price` is the unit (per-key) bonding-curve price in stroops,
// consistent with upsertPriceSnapshot below.
const tradeQty = Number(amount);
let pricePerKeyXlm = 0;
try {
pricePerKeyXlm = Number(BigInt(price as any)) / 10_000_000;
} catch {
pricePerKeyXlm = Number(price as any);
}
if (!Number.isFinite(pricePerKeyXlm) || pricePerKeyXlm < 0) {
pricePerKeyXlm = 0;
}
if (event.eventType === 'KEY_BOUGHT') {
await recordKeyPurchase(
actor,
Expand Down
146 changes: 146 additions & 0 deletions src/modules/indexer/referral-first-trade.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Wires the referral first-trade reward into the trade indexer (#910):
// - both KEY_BOUGHT and KEY_SOLD events trigger the first-trade check
// - a referral bookkeeping failure aborts the event before trade writes
//
// The reward logic itself is covered in ../referrals/referrals.service.test.ts.

jest.mock('../../utils/prisma.utils', () => {
const transactionClient = {
activity: { findMany: jest.fn(), create: jest.fn() },
creatorProfile: { findUnique: jest.fn(), update: jest.fn() },
};
return {
prisma: {
activity: { create: jest.fn() },
keyOwnership: {
findFirst: jest.fn(),
findUnique: jest.fn(),
upsert: jest.fn(),
aggregate: jest.fn(),
},
creatorPriceSnapshot: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() },
creatorPriceHistory: { create: jest.fn() },
creatorProfile: { findUnique: jest.fn() },
indexedLedger: { upsert: jest.fn() },
$transaction: jest.fn(
async (cb: (tx: typeof transactionClient) => Promise<unknown>) =>
cb(transactionClient)
),
},
};
});

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

jest.mock('../../utils/redis.utils', () => ({
getRedis: jest.fn(() => ({ del: jest.fn().mockResolvedValue(1) })),
}));

jest.mock('../referrals/referrals.service', () => ({
recordFirstTradeReferralReward: jest.fn().mockResolvedValue(false),
}));

import { processTradeEvents } from './indexer-pipeline.service';
import { prisma } from '../../utils/prisma.utils';
import { logger } from '../../utils/logger.utils';
import { recordFirstTradeReferralReward } from '../referrals/referrals.service';
import { IndexerChainEvent } from '../../utils/indexer-event-processor.utils';

const recordReward = recordFirstTradeReferralReward as jest.Mock;
const mockLogger = logger as unknown as { warn: jest.Mock };

const BUYER = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

function buyEvent(overrides: Partial<IndexerChainEvent> = {}): IndexerChainEvent {
return {
txHash: '0xhash-buy',
eventIndex: 0,
eventType: 'KEY_BOUGHT',
ledger: 20000,
creatorId: 'creator-abc',
actor: BUYER,
amount: 2,
// 1.5 XLM per key, in stroops
price: 15_000_000n,
feePaid: 10n,
tradeAt: '2026-09-05T12:00:00.000Z',
...overrides,
} as IndexerChainEvent;
}

describe('referral reward wiring in processTradeEvents', () => {
beforeEach(() => {
jest.clearAllMocks();
(prisma as any).keyOwnership.upsert.mockResolvedValue({ balance: 2 });
(prisma as any).keyOwnership.findFirst.mockResolvedValue(null);
(prisma as any).keyOwnership.findUnique.mockResolvedValue(null);
(prisma as any).keyOwnership.aggregate.mockResolvedValue({
_sum: { balance: 2 },
});
(prisma as any).indexedLedger.upsert.mockResolvedValue({});
(prisma as any).$transaction.mockImplementation(
async (cb: any) =>
cb({
activity: {
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn(),
},
creatorProfile: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn(),
},
})
);
});

it('records the referral reward with the total XLM value of the buy', async () => {
await processTradeEvents([buyEvent()]);

expect(recordReward).toHaveBeenCalledTimes(1);
expect(recordReward).toHaveBeenCalledWith({
refereeAddress: BUYER,
keyId: 'creator-abc',
// 1.5 XLM * 2 keys
tradeValueXlm: 3,
txHash: '0xhash-buy',
eventIndex: 0,
tradeAt: new Date('2026-09-05T12:00:00.000Z'),
});
});

it('records the referral reward check for sells as well as buys', async () => {
await processTradeEvents([
buyEvent({ eventType: 'KEY_SOLD', txHash: '0xhash-sell' }),
]);

expect(recordReward).toHaveBeenCalledWith(
expect.objectContaining({
refereeAddress: BUYER,
keyId: 'creator-abc',
tradeValueXlm: 3,
txHash: '0xhash-sell',
})
);
});

it('aborts the event before trade writes when referral bookkeeping fails', async () => {
recordReward.mockRejectedValueOnce(new Error('referral table locked'));

await expect(processTradeEvents([buyEvent()])).rejects.toThrow(
'referral table locked'
);

expect((prisma as any).activity.create).not.toHaveBeenCalled();
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.objectContaining({ eventId: '0xhash-buy:0' }),
'Failed to record referral first trade reward'
);
});
});
Loading
Loading