Skip to content

Commit 4a3f52e

Browse files
authored
Merge pull request #154 from nice-bills/feat/contributor-pledge-limit
feat: add per-contributor pledge limit per campaign
2 parents f53f533 + 1d94812 commit 4a3f52e

7 files changed

Lines changed: 103 additions & 27 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Request body:
100100
- `assetCode`
101101
- `targetAmount`
102102
- `deadline`
103+
- `maxPerContributor` (optional): Maximum total pledge amount a single contributor can contribute to this campaign. If not set, no per-contributor limit applies. Can also be set globally via `DEFAULT_MAX_PER_CONTRIBUTOR` env variable.
103104

104105
### `POST /api/campaigns/:id/pledges`
105106
- Add a pledge to a live campaign

backend/src/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const config = {
3131
contractId: process.env.CONTRACT_ID ?? "",
3232
sorobanNetworkPassphrase:
3333
process.env.SOROBAN_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015",
34+
defaultMaxPerContributor: parseInteger(process.env.DEFAULT_MAX_PER_CONTRIBUTOR, 0),
3435
};
3536

36-
export const walletIntegrationReady = Boolean(config.contractId && config.sorobanRpcUrl);
37+
export const walletIntegrationReady = Boolean(config.contractId && config.sorobanRpcUrl);

backend/src/index.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ import {
3939
} from "./validation/schemas";
4040
import { logError, logInfo, logRequest } from "./logger";
4141

42+
type RequestWithId = Request & { requestId?: string };
43+
type CampaignListItem = ReturnType<typeof import("./services/campaignStore").getCampaign> & { progress: ReturnType<typeof import("./services/campaignStore").calculateProgress> };
44+
4245
export const app = express();
4346

4447
interface RequestWithId extends Request {
@@ -302,13 +305,20 @@ app.post("/api/campaigns", (req: Request, res: Response) => {
302305
const parsedBody = createCampaignPayloadSchema.safeParse(req.body);
303306
if (!parsedBody.success) {
304307
sendValidationError(parsedBody.error.issues);
308+
return;
305309
}
306310

307311
if (parsedBody.data.deadline <= Math.floor(Date.now() / 1000)) {
308312
throw new AppError("deadline must be in the future.", 400, "INVALID_DEADLINE");
309313
}
310314

311-
const campaign = createCampaign(parsedBody.data);
315+
const campaignInput = {
316+
...parsedBody.data,
317+
maxPerContributor:
318+
parsedBody.data.maxPerContributor ?? (config.defaultMaxPerContributor > 0 ? config.defaultMaxPerContributor : undefined),
319+
};
320+
321+
const campaign = createCampaign(campaignInput);
312322
res.status(201).json({ data: { ...campaign, progress: calculateProgress(campaign) } });
313323
});
314324

backend/src/services/campaignStore.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@ let getPledges: CampaignStoreModule["getPledges"];
2424
let getGlobalStats: CampaignStoreModule["getGlobalStats"];
2525
let getDb: DbModule["getDb"];
2626
let getCampaignHistory: EventHistoryModule["getCampaignHistory"];
27+
let addPledge: CampaignStoreModule["addPledge"];
2728

2829
const CREATOR = `G${"A".repeat(55)}`;
2930
const CONTRIBUTOR = `G${"B".repeat(55)}`;
31+
const CONTRIBUTOR2 = `G${"C".repeat(55)}`;
3032
const TX_HASH = "a".repeat(64);
3133

3234
beforeAll(async () => {
@@ -40,7 +42,7 @@ beforeAll(async () => {
4042
reconcileOnChainPledge,
4143
getCampaign,
4244
getPledges,
43-
getGlobalStats,
45+
4446
} = await import("./campaignStore"));
4547
({ getDb } = await import("./db"));
4648
({ getCampaignHistory } = await import("./eventHistory"));
@@ -163,3 +165,4 @@ describe("on-chain pledge reconciliation", () => {
163165
});
164166
});
165167

168+

backend/src/services/campaignStore.ts

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface CampaignInput {
1414
imageUrl?: string;
1515
externalLink?: string;
1616
};
17+
maxPerContributor?: number;
1718
}
1819

1920
export interface PledgeInput {
@@ -42,6 +43,7 @@ export interface CampaignRecord {
4243
imageUrl?: string;
4344
externalLink?: string;
4445
};
46+
maxPerContributor?: number;
4547
}
4648

4749
export interface CampaignProgress {
@@ -90,6 +92,7 @@ interface CampaignRow {
9092
claimed_at: number | null;
9193
deleted_at: number | null;
9294
metadata_json: string | null;
95+
max_per_contributor: number | null;
9396
}
9497

9598
interface PledgeRow {
@@ -136,6 +139,7 @@ function rowToCampaign(row: CampaignRow): CampaignRecord {
136139
claimedAt: row.claimed_at ?? undefined,
137140
deletedAt: row.deleted_at ?? undefined,
138141
metadata: row.metadata_json ? JSON.parse(row.metadata_json) : undefined,
142+
maxPerContributor: row.max_per_contributor ?? undefined,
139143
};
140144
}
141145

@@ -180,6 +184,19 @@ function getPledgeByTransactionHash(transactionHash: string): PledgeRecord | und
180184
return row ? rowToPledge(row) : undefined;
181185
}
182186

187+
function getContributorPledgedTotal(campaignId: string, contributor: string): number {
188+
const db = getDb();
189+
const row = db
190+
.prepare(
191+
`SELECT COALESCE(SUM(amount), 0) AS total
192+
FROM pledges
193+
WHERE campaign_id = ? AND contributor = ? AND refunded_at IS NULL`,
194+
)
195+
.get(campaignId, contributor) as { total: number };
196+
197+
return row.total;
198+
}
199+
183200
export function initCampaignStore(): void {
184201
initDb();
185202
}
@@ -256,8 +273,8 @@ export function listCampaigns(options?: ListCampaignsOptions): ListCampaignsResu
256273
if (options?.searchQuery && options.searchQuery.trim()) {
257274
const searchTerm = `%${options.searchQuery.trim().toLowerCase()}%`;
258275
whereClauses.push(`(
259-
LOWER(id) LIKE ? OR
260-
LOWER(title) LIKE ? OR
276+
LOWER(id) LIKE ? OR
277+
LOWER(title) LIKE ? OR
261278
LOWER(creator) LIKE ?
262279
)`);
263280
params.push(searchTerm, searchTerm, searchTerm);
@@ -374,18 +391,20 @@ export function createCampaign(input: CampaignInput): CampaignRecord {
374391
deadline: input.deadline,
375392
createdAt: now,
376393
metadata: input.metadata,
394+
maxPerContributor: input.maxPerContributor,
377395
};
378396

379397
db.prepare(
380398
`INSERT INTO campaigns (
381-
id, creator, title, description, asset_code, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json
399+
id, creator, title, description, asset_code, target_amount, pledged_amount, deadline, created_at, claimed_at, metadata_json, max_per_contributor
382400
) VALUES (
383-
@id, @creator, @title, @description, @assetCode, @targetAmount, @pledgedAmount, @deadline, @createdAt, @claimedAt, @metadataJson
401+
@id, @creator, @title, @description, @assetCode, @targetAmount, @pledgedAmount, @deadline, @createdAt, @claimedAt, @metadataJson, @maxPerContributor
384402
)`,
385403
).run({
386404
...campaign,
387405
claimedAt: null,
388406
metadataJson: campaign.metadata ? JSON.stringify(campaign.metadata) : null,
407+
maxPerContributor: campaign.maxPerContributor ?? null,
389408
});
390409

391410
recordEvent(
@@ -406,6 +425,25 @@ export function createCampaign(input: CampaignInput): CampaignRecord {
406425
return campaign;
407426
}
408427

428+
function checkContributorLimit(
429+
campaign: CampaignRecord,
430+
contributor: string,
431+
amount: number,
432+
): void {
433+
if (campaign.maxPerContributor === undefined) {
434+
return;
435+
}
436+
437+
const alreadyPledged = getContributorPledgedTotal(campaign.id, contributor);
438+
if (alreadyPledged + amount > campaign.maxPerContributor) {
439+
throw toServiceError(
440+
`Contributor limit exceeded. Max: ${campaign.maxPerContributor}, Already pledged: ${alreadyPledged}, Attempted: ${amount}`,
441+
400,
442+
"CONTRIBUTOR_LIMIT_EXCEEDED",
443+
);
444+
}
445+
}
446+
409447
export function addPledge(campaignId: string, input: PledgeInput): CampaignRecord {
410448
const db = getDb();
411449
const campaign = getCampaign(campaignId);
@@ -422,6 +460,8 @@ export function addPledge(campaignId: string, input: PledgeInput): CampaignRecor
422460
);
423461
}
424462

463+
checkContributorLimit(campaign, input.contributor, input.amount);
464+
425465
const createdAt = nowInSeconds();
426466
const roundedAmount = round(input.amount);
427467
const nextPledgedAmount = round(campaign.pledgedAmount + roundedAmount);
@@ -489,6 +529,8 @@ export function reconcileOnChainPledge(
489529
);
490530
}
491531

532+
checkContributorLimit(campaign, input.contributor, input.amount);
533+
492534
const db = getDb();
493535
const createdAt = input.confirmedAt ?? nowInSeconds();
494536
const roundedAmount = round(input.amount);
@@ -600,7 +642,6 @@ function reconcileOnChainClaim(
600642
);
601643
}
602644

603-
// Idempotency: if already claimed with this tx hash, return current state
604645
if (campaign.claimedAt) {
605646
return campaign;
606647
}
@@ -724,4 +765,4 @@ export function refundContributor(
724765
campaign: getCampaign(campaignId)!,
725766
refundedAmount,
726767
};
727-
}
768+
}

backend/src/services/db.ts

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -69,27 +69,28 @@ export function checkDbHealth(): {
6969
function migrate(database: SQLiteDatabase): void {
7070
database.exec(`
7171
CREATE TABLE IF NOT EXISTS campaigns (
72-
id TEXT PRIMARY KEY,
73-
creator TEXT NOT NULL,
74-
title TEXT NOT NULL,
75-
description TEXT NOT NULL,
76-
asset_code TEXT NOT NULL,
77-
target_amount REAL NOT NULL,
78-
pledged_amount REAL NOT NULL DEFAULT 0,
79-
deadline INTEGER NOT NULL,
80-
created_at INTEGER NOT NULL,
81-
claimed_at INTEGER,
82-
metadata_json TEXT
72+
id TEXT PRIMARY KEY,
73+
creator TEXT NOT NULL,
74+
title TEXT NOT NULL,
75+
description TEXT NOT NULL,
76+
asset_code TEXT NOT NULL,
77+
target_amount REAL NOT NULL,
78+
pledged_amount REAL NOT NULL DEFAULT 0,
79+
deadline INTEGER NOT NULL,
80+
created_at INTEGER NOT NULL,
81+
claimed_at INTEGER,
82+
metadata_json TEXT,
83+
max_per_contributor INTEGER
8384
);
8485
8586
CREATE TABLE IF NOT EXISTS pledges (
8687
id INTEGER PRIMARY KEY AUTOINCREMENT,
8788
campaign_id TEXT NOT NULL,
88-
contributor TEXT NOT NULL,
89-
amount REAL NOT NULL,
90-
created_at INTEGER NOT NULL,
91-
refunded_at INTEGER,
92-
transaction_hash TEXT,
89+
contributor TEXT NOT NULL,
90+
amount REAL NOT NULL,
91+
created_at INTEGER NOT NULL,
92+
refunded_at INTEGER,
93+
transaction_hash TEXT,
9394
FOREIGN KEY (campaign_id) REFERENCES campaigns(id)
9495
);
9596
@@ -133,10 +134,21 @@ function migrate(database: SQLiteDatabase): void {
133134
// Column already exists, ignore error.
134135
}
135136

137+
const campaignColumns = database
138+
.prepare(`PRAGMA table_info(campaigns)`)
139+
.all() as Array<{ name: string }>;
140+
141+
const hasMaxPerContributor = campaignColumns.some(
142+
(column) => column.name === "max_per_contributor",
143+
);
144+
if (!hasMaxPerContributor) {
145+
database.exec(`ALTER TABLE campaigns ADD COLUMN max_per_contributor INTEGER`);
146+
}
147+
136148
database.exec(`
137149
CREATE INDEX IF NOT EXISTS idx_campaign_events_tx_hash
138150
ON campaign_events(json_extract(blockchain_metadata, '$.txHash'));
139151
CREATE INDEX IF NOT EXISTS idx_campaign_events_ledger
140152
ON campaign_events(json_extract(blockchain_metadata, '$.ledgerNumber'));
141153
`);
142-
}
154+
}

backend/src/validation/schemas.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ export const positiveAmountSchema = z.coerce
3333
.finite("Amount must be a valid number.")
3434
.positive("Amount must be greater than zero.");
3535

36+
export const optionalPositiveIntSchema = z.coerce
37+
.number()
38+
.finite("Value must be a valid number.")
39+
.int("Value must be an integer.")
40+
.nonnegative("Value must be non-negative.")
41+
.optional();
42+
3643
export const unixTimestampSchema = z.coerce
3744
.number()
3845
.int("deadline must be a valid UNIX timestamp in seconds.")
@@ -55,6 +62,7 @@ export const createCampaignPayloadSchema = z.object({
5562
externalLink: z.string().url().optional(),
5663
})
5764
.optional(),
65+
maxPerContributor: optionalPositiveIntSchema,
5866
});
5967

6068
export const createPledgePayloadSchema = z.object({
@@ -190,4 +198,4 @@ export function zodIssuesToErrorMessage(issues: z.ZodIssue[]): string {
190198
return zodIssuesToValidationIssues(issues)
191199
.map(({ field, message }) => `${field}: ${message}`)
192200
.join("; ");
193-
}
201+
}

0 commit comments

Comments
 (0)