Skip to content

Commit ab90186

Browse files
authored
Merge pull request #755 from dumbdevss/feature/similar-campaigns-endpoint
Fix #564: Prevent concurrent pledges from exceeding maxPerContributor…
2 parents 58e391a + ba80c9e commit ab90186

2 files changed

Lines changed: 153 additions & 63 deletions

File tree

backend/src/pledgesEndpoint.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ let getCampaignWithProgress: CampaignStoreModule['getCampaignWithProgress'];
2020
let listCampaignPledges: CampaignStoreModule['listCampaignPledges'];
2121
let initCampaignStore: CampaignStoreModule['initCampaignStore'];
2222
let getPledges: CampaignStoreModule['getPledges'];
23+
let getContributorPledgedTotal: CampaignStoreModule['getContributorPledgedTotal'];
2324
let getDb: DbModule['getDb'];
2425
let parsePledgeListPaginationQuery: ValidationModule['parsePledgeListPaginationQuery'];
2526

@@ -52,6 +53,7 @@ beforeAll(async () => {
5253
listCampaignPledges,
5354
initCampaignStore,
5455
getPledges,
56+
getContributorPledgedTotal,
5557
} = await import('./services/campaignStore'));
5658
({ getDb } = await import('./services/db'));
5759
({ parsePledgeListPaginationQuery } = await import('./validation/schemas'));
@@ -143,3 +145,60 @@ describe('pledge pagination query parsing', () => {
143145
);
144146
});
145147
});
148+
149+
describe('concurrent pledge race condition', () => {
150+
it('prevents concurrent pledges from exceeding maxPerContributor limit', async () => {
151+
const campaign = createCampaign({
152+
creator: CREATOR,
153+
title: 'Campaign with contributor limit',
154+
description: 'A campaign to test concurrent pledge limits.',
155+
assetCode: 'USDC',
156+
targetAmount: 1000,
157+
deadline: nowInSeconds() + 86400,
158+
maxPerContributor: 50,
159+
});
160+
161+
const contributor = CONTRIBUTOR_A;
162+
const pledgeAmount = 10;
163+
const concurrentPledges = 10;
164+
165+
// Create 10 concurrent pledges from the same contributor using setTimeout to simulate true concurrency
166+
const pledgePromises = Array.from({ length: concurrentPledges }, (_, i) =>
167+
new Promise((resolve, reject) => {
168+
// Use setImmediate to allow the event loop to interleave operations
169+
setImmediate(() => {
170+
try {
171+
resolve(addPledge(campaign.id, { contributor, amount: pledgeAmount }));
172+
} catch (error) {
173+
reject(error);
174+
}
175+
});
176+
})
177+
);
178+
179+
const results = await Promise.allSettled(pledgePromises);
180+
181+
// Count successful and failed pledges
182+
const successful = results.filter((r) => r.status === 'fulfilled').length;
183+
const failed = results.filter((r) => r.status === 'rejected').length;
184+
185+
// With maxPerContributor of 50 and pledge amount of 10, only 5 pledges should succeed
186+
expect(successful).toBe(5);
187+
expect(failed).toBe(5);
188+
189+
// Verify the final pledged amount does not exceed the limit
190+
const finalCampaign = getCampaignWithProgress(campaign.id);
191+
const contributorTotal = getContributorPledgedTotal(campaign.id, contributor);
192+
expect(contributorTotal).toBeLessThanOrEqual(50);
193+
expect(contributorTotal).toBeGreaterThan(0);
194+
195+
// Verify failed pledges returned 400 error
196+
const rejectedResults = results.filter((r) => r.status === 'rejected');
197+
rejectedResults.forEach((result) => {
198+
if (result.status === 'rejected') {
199+
expect(result.reason).toHaveProperty('statusCode', 400);
200+
expect(result.reason).toHaveProperty('code', 'MAX_PER_CONTRIBUTOR_EXCEEDED');
201+
}
202+
});
203+
});
204+
});

backend/src/services/campaignStore.ts

Lines changed: 94 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,7 @@ export function getPledgeByTransactionHash(transactionHash: string): PledgeRecor
194194
return row ? rowToPledge(row) : undefined;
195195
}
196196

197-
function getContributorPledgedTotal(campaignId: string, contributor: string): number {
197+
export function getContributorPledgedTotal(campaignId: string, contributor: string): number {
198198
const db = getDb();
199199
const row = db
200200
.prepare(
@@ -743,65 +743,83 @@ export function addPledge(campaignId: string, input: PledgeInput): CampaignRecor
743743
throw toServiceError('Campaign is no longer accepting pledges.', 400, 'INVALID_CAMPAIGN_STATE');
744744
}
745745

746-
checkContributorLimit(campaign, input.contributor, input.amount);
747-
748746
const createdAt = nowInSeconds();
749747
const roundedAmount = round(input.amount);
750-
const nextPledgedAmount = round(campaign.pledgedAmount + roundedAmount);
751-
if (nextPledgedAmount > campaign.targetAmount) {
752-
throw toServiceError(
753-
'Pledge exceeds campaign funding cap.',
754-
400,
755-
'CAMPAIGN_FUNDING_CAP_EXCEEDED',
756-
);
757-
}
758-
db.prepare(
759-
`INSERT INTO pledges (campaign_id, contributor, amount, asset_code, created_at, refunded_at, transaction_hash)
760-
VALUES (?, ?, ?, ?, ?, NULL, NULL)`,
761-
).run(campaignId, input.contributor, roundedAmount, assetCode, createdAt);
762748

763-
db.prepare(`UPDATE campaigns SET pledged_amount = pledged_amount + ? WHERE id = ?`).run(
764-
roundedAmount,
765-
campaignId,
766-
);
749+
db.transaction(() => {
750+
// Re-check contributor limit within transaction to prevent race conditions
751+
const existingPledged = getContributorPledgedTotal(campaignId, input.contributor);
752+
if (campaign.maxPerContributor !== undefined && campaign.maxPerContributor > 0) {
753+
if (existingPledged + roundedAmount > campaign.maxPerContributor) {
754+
throw toServiceError(
755+
'Pledge exceeds maximum allowed per contributor.',
756+
400,
757+
'MAX_PER_CONTRIBUTOR_EXCEEDED',
758+
);
759+
}
760+
}
767761

768-
recordEvent(
769-
campaignId,
770-
'pledged',
771-
createdAt,
772-
input.contributor,
773-
roundedAmount,
774-
{
775-
newTotalPledged: nextPledgedAmount,
776-
assetCode,
777-
source: 'backend-mvp',
778-
},
779-
{ source: 'local' } as BlockchainMetadata,
780-
);
762+
// Re-check campaign funding cap within transaction
763+
const currentPledgedAmount = db
764+
.prepare(`SELECT pledged_amount FROM campaigns WHERE id = ?`)
765+
.get(campaignId) as { pledged_amount: number };
766+
const nextPledgedAmount = round(currentPledgedAmount.pledged_amount + roundedAmount);
767+
if (nextPledgedAmount > campaign.targetAmount) {
768+
throw toServiceError(
769+
'Pledge exceeds campaign funding cap.',
770+
400,
771+
'CAMPAIGN_FUNDING_CAP_EXCEEDED',
772+
);
773+
}
781774

782-
// Check if contributor has reached their limit and record event
783-
if (
784-
campaign.maxPerContributor !== undefined &&
785-
campaign.maxPerContributor > 0
786-
) {
787-
const newContributorTotal = round(
788-
getContributorPledgedTotal(campaignId, input.contributor),
775+
db.prepare(
776+
`INSERT INTO pledges (campaign_id, contributor, amount, asset_code, created_at, refunded_at, transaction_hash)
777+
VALUES (?, ?, ?, ?, ?, NULL, NULL)`,
778+
).run(campaignId, input.contributor, roundedAmount, assetCode, createdAt);
779+
780+
db.prepare(`UPDATE campaigns SET pledged_amount = pledged_amount + ? WHERE id = ?`).run(
781+
roundedAmount,
782+
campaignId,
789783
);
790-
if (newContributorTotal >= campaign.maxPerContributor) {
791-
recordEvent(
792-
campaignId,
793-
"pledge_limit_reached",
794-
createdAt,
795-
input.contributor,
796-
newContributorTotal,
797-
{
798-
maxPerContributor: campaign.maxPerContributor,
799-
assetCode,
800-
},
801-
{ source: "local" } as BlockchainMetadata,
784+
785+
recordEvent(
786+
campaignId,
787+
'pledged',
788+
createdAt,
789+
input.contributor,
790+
roundedAmount,
791+
{
792+
newTotalPledged: nextPledgedAmount,
793+
assetCode,
794+
source: 'backend-mvp',
795+
},
796+
{ source: 'local' } as BlockchainMetadata,
797+
);
798+
799+
// Check if contributor has reached their limit and record event
800+
if (
801+
campaign.maxPerContributor !== undefined &&
802+
campaign.maxPerContributor > 0
803+
) {
804+
const newContributorTotal = round(
805+
getContributorPledgedTotal(campaignId, input.contributor),
802806
);
807+
if (newContributorTotal >= campaign.maxPerContributor) {
808+
recordEvent(
809+
campaignId,
810+
"pledge_limit_reached",
811+
createdAt,
812+
input.contributor,
813+
newContributorTotal,
814+
{
815+
maxPerContributor: campaign.maxPerContributor,
816+
assetCode,
817+
},
818+
{ source: "local" } as BlockchainMetadata,
819+
);
820+
}
803821
}
804-
}
822+
})();
805823

806824
return getCampaign(campaignId)!;
807825
}
@@ -850,23 +868,36 @@ export function reconcileOnChainPledge(
850868
throw toServiceError('Campaign is no longer accepting pledges.', 400, 'INVALID_CAMPAIGN_STATE');
851869
}
852870

853-
checkContributorLimit(campaign, input.contributor, input.amount);
854-
855871
const db = getDb();
856872
const createdAt = input.confirmedAt ?? nowInSeconds();
857873
const roundedAmount = round(input.amount);
858874
const assetCode = (input.assetCode || campaign.assetCode).toUpperCase();
859-
const nextPledgedAmount = round(campaign.pledgedAmount + roundedAmount);
860-
861-
if (nextPledgedAmount > campaign.targetAmount) {
862-
throw toServiceError(
863-
'Pledge exceeds campaign funding cap.',
864-
400,
865-
'CAMPAIGN_FUNDING_CAP_EXCEEDED',
866-
);
867-
}
868875

869876
const insertedNewPledge = db.transaction(() => {
877+
// Re-check contributor limit within transaction to prevent race conditions
878+
const existingPledged = getContributorPledgedTotal(campaignId, input.contributor);
879+
if (campaign.maxPerContributor !== undefined && campaign.maxPerContributor > 0) {
880+
if (existingPledged + roundedAmount > campaign.maxPerContributor) {
881+
throw toServiceError(
882+
'Pledge exceeds maximum allowed per contributor.',
883+
400,
884+
'MAX_PER_CONTRIBUTOR_EXCEEDED',
885+
);
886+
}
887+
}
888+
889+
// Re-check campaign funding cap within transaction
890+
const currentPledgedAmount = db
891+
.prepare(`SELECT pledged_amount FROM campaigns WHERE id = ?`)
892+
.get(campaignId) as { pledged_amount: number };
893+
const nextPledgedAmount = round(currentPledgedAmount.pledged_amount + roundedAmount);
894+
if (nextPledgedAmount > campaign.targetAmount) {
895+
throw toServiceError(
896+
'Pledge exceeds campaign funding cap.',
897+
400,
898+
'CAMPAIGN_FUNDING_CAP_EXCEEDED',
899+
);
900+
}
870901
const result = db
871902
.prepare(
872903
`INSERT OR IGNORE INTO pledges (

0 commit comments

Comments
 (0)