Skip to content

Commit 872f719

Browse files
authored
Merge pull request Stellar-IndigoPay#278 from scarface-dev1/feat/idempotency-key-donation-recording-148
Feat/idempotency key donation recording 118
2 parents a095b7c + d2235cf commit 872f719

35 files changed

Lines changed: 1668 additions & 969 deletions

.github/workflows/contracts.yml

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
run: cargo build --workspace --target wasm32v1-none --release
4444

4545
fuzz:
46-
name: Fuzz Tests (10k iterations)
46+
name: Fuzz Tests (256 cases each)
4747
runs-on: ubuntu-latest
4848
timeout-minutes: 20
4949
defaults:
@@ -64,10 +64,89 @@ jobs:
6464
with:
6565
workspaces: contracts
6666

67-
- name: Run fuzz tests (1.5k iterations)
67+
- name: Run fuzz tests
6868
run: cargo test --features testutils -- fuzz
6969
env:
70-
FUZZ_ITERATIONS: 1500
70+
FUZZ_ITERATIONS: 256
71+
72+
deep-fuzz:
73+
name: Deep Fuzz (2k cases each -- nightly)
74+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
75+
runs-on: ubuntu-latest
76+
timeout-minutes: 120
77+
defaults:
78+
run:
79+
working-directory: contracts
80+
steps:
81+
- uses: actions/checkout@v4
82+
83+
- name: Install Rust toolchain
84+
uses: dtolnay/rust-toolchain@stable
85+
with:
86+
toolchain: 1.91.0
87+
targets: wasm32v1-none
88+
components: rustfmt, clippy
89+
90+
- name: Cache Rust dependencies
91+
uses: Swatinem/rust-cache@v2
92+
with:
93+
workspaces: contracts
94+
95+
deep-fuzz:
96+
name: Deep Fuzz (2k cases each -- nightly)
97+
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
98+
runs-on: ubuntu-latest
99+
timeout-minutes: 120
100+
defaults:
101+
run:
102+
working-directory: contracts
103+
steps:
104+
- uses: actions/checkout@v4
105+
106+
- name: Install Rust toolchain
107+
uses: dtolnay/rust-toolchain@stable
108+
with:
109+
toolchain: 1.91.0
110+
targets: wasm32v1-none
111+
components: rustfmt, clippy
112+
113+
- name: Cache Rust dependencies
114+
uses: Swatinem/rust-cache@v2
115+
with:
116+
workspaces: contracts
117+
118+
- name: Run deep fuzz tests
119+
run: cargo test --features testutils -- fuzz
120+
env:
121+
FUZZ_ITERATIONS: 2000
122+
123+
deep-fuzz-pr:
124+
name: Deep Fuzz (500 cases each -- PR)
125+
if: github.event_name == 'pull_request'
126+
runs-on: ubuntu-latest
127+
timeout-minutes: 30
128+
defaults:
129+
run:
130+
working-directory: contracts
131+
steps:
132+
- uses: actions/checkout@v4
133+
134+
- name: Install Rust toolchain
135+
uses: dtolnay/rust-toolchain@stable
136+
with:
137+
toolchain: 1.91.0
138+
targets: wasm32v1-none
139+
components: rustfmt, clippy
140+
141+
- name: Cache Rust dependencies
142+
uses: Swatinem/rust-cache@v2
143+
with:
144+
workspaces: contracts
145+
146+
- name: Run deep fuzz tests
147+
run: cargo test --features testutils -- fuzz
148+
env:
149+
FUZZ_ITERATIONS: 500
71150

72151
coverage:
73152
name: Coverage (tarpaulin)
@@ -94,7 +173,7 @@ jobs:
94173
workspaces: contracts
95174

96175
- name: Run coverage
97-
run: cargo tarpaulin --config indigopay-contract/.tarpaulin.toml --out Html --out Lcov
176+
run: cargo tarpaulin --config indigopay-contract/.tarpaulin.toml --features testutils --out Html --out Lcov -- --skip fuzz
98177

99178
- name: Upload coverage report
100179
uses: actions/upload-artifact@v4

backend/src/db/schema.sql

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,3 +279,17 @@ CREATE INDEX IF NOT EXISTS verification_requests_status_idx
279279
ON verification_requests (status, submitted_at DESC);
280280
CREATE INDEX IF NOT EXISTS verification_requests_wallet_idx
281281
ON verification_requests (wallet_address);
282+
283+
-- idempotency_keys: stores the cached HTTP response for each Idempotency-Key
284+
-- header value sent by clients to POST /api/donations. Rows older than 24 h
285+
-- are pruned by the idempotencyCleanup cron service so that retried requests
286+
-- within the same window always receive the same response body and status code.
287+
CREATE TABLE IF NOT EXISTS idempotency_keys (
288+
key TEXT PRIMARY KEY,
289+
response_status INTEGER NOT NULL,
290+
response_body JSONB NOT NULL,
291+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
292+
);
293+
294+
CREATE INDEX IF NOT EXISTS idempotency_keys_created_at_idx
295+
ON idempotency_keys (created_at);

backend/src/routes/donations.js

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const { v4: uuid } = require("uuid");
99
const { z } = require("zod");
1010
const logger = require("../logger");
1111
const pool = require("../db/pool");
12+
const { AppError } = require("../errors");
1213
const { createRateLimiter } = require("../middleware/rateLimiter");
1314
const { validate } = require("../middleware/validate");
1415
const idempotencyMiddleware = require("../middleware/idempotency");
@@ -21,18 +22,33 @@ const { mapDonationRow } = require("../services/store");
2122
const { enqueueProfileUpdate } = require("../services/profileQueue");
2223
const { enqueuePushNotification } = require("../services/pushQueue");
2324
const { server } = require("../services/stellar");
24-
const { AppError } = require("../errors");
2525
const donationLimiter = createRateLimiter(10, 1); // 10 requests per minute
2626

2727
// Local EventEmitter used by both the POST /api/donations handler and the
2828
// GET /api/donations/stream SSE endpoint to broadcast new donations in
2929
// real time without going through Socket.IO.
3030
const donationEvents = new EventEmitter();
3131

32+
function validateKey(k) {
33+
if (!k || !/^G[A-Z0-9]{55}$/.test(k)) {
34+
throw new AppError("INVALID_ADDRESS");
35+
}
36+
}
37+
38+
function validateTxHash(h) {
39+
if (!h || !/^[a-fA-F0-9]{64}$/.test(h)) {
40+
throw new AppError("INVALID_TX_HASH");
41+
}
42+
}
3243

3344
/**
3445
* Record a donation after an on-chain transaction is observed.
3546
*
47+
* Supports an optional `Idempotency-Key` request header (UUID v4). When
48+
* supplied, the server stores the response and replays it on duplicate
49+
* requests within a 24-hour window, preventing double-recording of the same
50+
* donation.
51+
*
3652
* @route POST /api/donations
3753
* @param {import('express').Request} req - Express request containing the donation payload.
3854
* @param {import('express').Response} res - Express response object.
@@ -65,7 +81,7 @@ async function recordDonation(req, res, next) {
6581
throw new AppError("INVALID_TX_HASH");
6682
}
6783

68-
if (!client) client = await pool.connect();
84+
client = await pool.connect();
6985

7086
const projectResult = await client.query(
7187
"SELECT id FROM projects WHERE id = $1",
@@ -80,10 +96,7 @@ async function recordDonation(req, res, next) {
8096
currency === "XLM" ? (amountXLM ?? amount) : amount,
8197
);
8298
if (isNaN(parsedAmount) || parsedAmount <= 0) {
83-
throw new AppError("VALIDATION_ERROR", {
84-
field: "amount",
85-
detail: "Invalid amount",
86-
});
99+
throw new AppError("VALIDATION_ERROR", { field: "amount" });
87100
}
88101

89102
// Deduplicate by tx hash
@@ -110,9 +123,7 @@ async function recordDonation(req, res, next) {
110123
throw new AppError("TX_NOT_FOUND");
111124
}
112125
if (!onChainTx || onChainTx.successful !== true) {
113-
throw new AppError("TX_FAILED", {
114-
detail: "Transaction not confirmed on Stellar",
115-
});
126+
throw new AppError("TX_FAILED");
116127
}
117128

118129
await client.query("BEGIN");
@@ -129,7 +140,7 @@ async function recordDonation(req, res, next) {
129140
uuid(),
130141
projectId,
131142
donorAddress,
132-
currency === "XLM" ? parsedAmount : (convertedAmountXLM || null),
143+
currency === "XLM" ? parsedAmount : (convertedAmountXLM ? parseFloat(convertedAmountXLM) : null),
133144
parsedAmount,
134145
currency,
135146
message?.trim().slice(0, 100) || null,
@@ -144,14 +155,14 @@ async function recordDonation(req, res, next) {
144155
id: uuid(),
145156
project_id: projectId,
146157
donor_address: donorAddress,
147-
amount_xlm: currency === "XLM" ? parsedAmount : (convertedAmountXLM || null),
158+
amount_xlm: currency === "XLM" ? parsedAmount : (convertedAmountXLM ? parseFloat(convertedAmountXLM) : null),
148159
amount: parsedAmount,
149160
currency,
150161
message: message?.trim().slice(0, 100) || null,
151162
transaction_hash: transactionHash,
152163
source_asset: sourceAsset || null,
153164
conversion_path: conversionPath || null,
154-
converted_amount_xlm: convertedAmountXLM || null,
165+
converted_amount_xlm: convertedAmountXLM ? parseFloat(convertedAmountXLM) : null,
155166
created_at: new Date().toISOString(),
156167
};
157168

@@ -416,10 +427,7 @@ router.get("/:id", async (req, res, next) => {
416427
id,
417428
)
418429
) {
419-
throw new AppError("VALIDATION_ERROR", {
420-
field: "id",
421-
detail: "Invalid donation ID",
422-
});
430+
throw new AppError("VALIDATION_ERROR", { field: "id", message: "Invalid donation ID" });
423431
}
424432

425433
const USDC_TO_XLM_RATE = parseFloat(process.env.USDC_TO_XLM_RATE || "8.0");

backend/src/routes/donations.test.js

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,12 @@ jest.mock("../services/profileQueue", () => ({
1616
enqueueProfileUpdate: jest.fn().mockResolvedValue(undefined),
1717
}));
1818

19-
jest.mock("../services/pushQueue", () => ({
20-
enqueuePushNotification: jest.fn().mockResolvedValue(undefined),
21-
}));
22-
2319
const { server } = require("../services/stellar");
2420
const pool = require("../db/pool");
2521
const { computeBadges } = require("../services/store");
2622
const { enqueueProfileUpdate } = require("../services/profileQueue");
27-
const { recordDonation } = require("./donations");
2823
const { AppError } = require("../errors");
24+
const { recordDonation } = require("./donations");
2925

3026
function makePublicKey(char = "A") {
3127
return `G${char.repeat(55)}`;
@@ -73,17 +69,20 @@ function createMockResponse() {
7369
};
7470
}
7571

76-
async function invokeRecordDonation(body, headers = {}) {
77-
const req = { body, headers };
72+
const STATUS_FALLBACK_CODE = { 400: "VALIDATION_ERROR", 404: "NOT_FOUND", 409: "DUPLICATE_DONATION", 413: "FILE_TOO_LARGE", 422: "SCHEMA_VALIDATION_ERROR", 429: "RATE_LIMITED" };
73+
74+
async function invokeRecordDonation(body) {
75+
const req = { body };
7876
const res = createMockResponse();
7977
const next = jest.fn((err) => {
8078
if (err) {
8179
if (err instanceof AppError) {
8280
res.status(err.status).json(err.toJSON());
81+
} else if (err.status && err.status < 500) {
82+
const code = STATUS_FALLBACK_CODE[err.status] || "VALIDATION_ERROR";
83+
res.status(err.status).json({ error: { code, message: err.message } });
8384
} else {
84-
res
85-
.status(err.status || 500)
86-
.json({ error: err.message || "Internal server error" });
85+
res.status(500).json({ error: { code: "INTERNAL_ERROR", message: "Internal server error" } });
8786
}
8887
}
8988
});
@@ -221,7 +220,7 @@ describe("POST /api/donations", () => {
221220

222221
expect(next).toHaveBeenCalledTimes(1);
223222
expect(res.statusCode).toBe(404);
224-
expect(res.body.error.code).toBe("PROJECT_NOT_FOUND");
223+
expect(res.body.error).toEqual({ code: "PROJECT_NOT_FOUND", message: "Project not found" });
225224
expect(client.release).toHaveBeenCalledTimes(1);
226225
});
227226

@@ -235,7 +234,7 @@ describe("POST /api/donations", () => {
235234

236235
expect(next).toHaveBeenCalledTimes(1);
237236
expect(res.statusCode).toBe(400);
238-
expect(res.body.error.code).toBe("INVALID_ADDRESS");
237+
expect(res.body.error).toEqual({ code: "INVALID_ADDRESS", message: "Invalid Stellar address" });
239238
expect(pool.connect).not.toHaveBeenCalled();
240239
});
241240

@@ -249,7 +248,7 @@ describe("POST /api/donations", () => {
249248

250249
expect(next).toHaveBeenCalledTimes(1);
251250
expect(res.statusCode).toBe(400);
252-
expect(res.body.error.code).toBe("INVALID_TX_HASH");
251+
expect(res.body.error).toEqual({ code: "INVALID_TX_HASH", message: "Invalid transaction hash" });
253252
expect(pool.connect).not.toHaveBeenCalled();
254253
});
255254

@@ -332,7 +331,7 @@ describe("POST /api/donations", () => {
332331

333332
test("calculates badges from cumulative donations across multiple requests", async () => {
334333
const donorAddress = makePublicKey("F");
335-
createMockClient(
334+
void createMockClient(
336335
queryResult([{ id: "project-3" }]), // SELECT project
337336
queryResult([]), // dedup check
338337
queryResult(), // BEGIN
@@ -382,7 +381,7 @@ describe("POST /api/donations", () => {
382381

383382
expect(next).toHaveBeenCalledTimes(1);
384383
expect(res.statusCode).toBe(400);
385-
expect(res.body.error.code).toBe("TX_FAILED");
384+
expect(res.body.error).toEqual({ code: "TX_FAILED", message: "Transaction failed on Stellar" });
386385
// No DB write transaction should have been opened.
387386
expect(client.query).not.toHaveBeenCalledWith("BEGIN");
388387
expect(client.release).toHaveBeenCalledTimes(1);
@@ -404,7 +403,7 @@ describe("POST /api/donations", () => {
404403

405404
expect(next).toHaveBeenCalledTimes(1);
406405
expect(res.statusCode).toBe(400);
407-
expect(res.body.error.code).toBe("TX_NOT_FOUND");
406+
expect(res.body.error).toEqual({ code: "TX_NOT_FOUND", message: "Transaction not found on Stellar" });
408407
expect(client.query).not.toHaveBeenCalledWith("BEGIN");
409408
expect(client.release).toHaveBeenCalledTimes(1);
410409
});
@@ -510,7 +509,7 @@ describe("profile upsert on first donation", () => {
510509
created_at: "2026-03-29T10:00:00.000Z",
511510
};
512511

513-
createMockClient(
512+
void createMockClient(
514513
queryResult([{ id: "project-p" }]),
515514
queryResult([]),
516515
queryResult(),
@@ -549,7 +548,7 @@ describe("profile upsert on first donation", () => {
549548
created_at: "2026-03-29T10:00:00.000Z",
550549
};
551550

552-
createMockClient(
551+
void createMockClient(
553552
queryResult([{ id: "project-q" }]),
554553
queryResult([]),
555554
queryResult(),
@@ -596,7 +595,7 @@ describe("profile upsert on first donation", () => {
596595
created_at: "2026-03-29T10:00:00.000Z",
597596
};
598597

599-
createMockClient(
598+
void createMockClient(
600599
queryResult([{ id: "project-r" }]),
601600
queryResult([]),
602601
queryResult(),

backend/src/server.js

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,7 @@ const { AppError } = require("./errors");
5959
const { startTurretsServer } = require("./services/turrets");
6060
const { start: startSummaryQueue } = require("./services/summaryQueue");
6161
const { start: startProfileQueue } = require("./services/profileQueue");
62-
const {
63-
start: startWebhookQueue,
62+
const { start: startWebhookQueue,
6463
stop: stopWebhookQueue,
6564
} = require("./services/webhookQueue");
6665
const { start: startPushQueue } = require("./services/pushQueue");
@@ -563,7 +562,7 @@ async function startServer() {
563562
const sorobanEvents = require("./services/sorobanEventService");
564563
if (typeof sorobanEvents.stop === "function") await sorobanEvents.stop();
565564
} catch {
566-
// Service may already be stopped; swallow.
565+
// Service may already be stopped or not loaded; swallow.
567566
}
568567
});
569568

0 commit comments

Comments
 (0)