From 20e89ad80bf76b04ed59cc23cb52da98f0b7563d Mon Sep 17 00:00:00 2001 From: AbelOsaretin Date: Sat, 20 Jun 2026 22:05:55 +0100 Subject: [PATCH 1/3] fix: wrap processRewardClaim DB updates in a transaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The processRewardClaim function had two independent DB calls (marking rewardClaimed and incrementing credits) without a transaction wrapper. If the process crashed or the second update failed after the first succeeded, the user would permanently lose the reward or face a double-spend scenario. Wrapped both operations in a single db.transaction() call to ensure atomicity — either both updates succeed or neither does. --- src/modules/rewards/reward.service.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index 63e852e..0602abe 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -80,17 +80,19 @@ export async function processRewardClaim( throw err; } - await db - .update(quizSubmissions) - .set({ rewardClaimed: true, txHash }) - .where(eq(quizSubmissions.id, submissionId)); - - await db - .update(users) - .set({ - credits: sql`${users.credits} + ${REWARD_AMOUNT}`, - }) - .where(eq(users.id, userId)); + await db.transaction(async (tx) => { + await tx + .update(quizSubmissions) + .set({ rewardClaimed: true, txHash }) + .where(eq(quizSubmissions.id, submissionId)); + + await tx + .update(users) + .set({ + credits: sql`${users.credits} + ${REWARD_AMOUNT}`, + }) + .where(eq(users.id, userId)); + }); return true; } From 4d1e3e5e672c1a1351dc7adac9fc5292d9e67e15 Mon Sep 17 00:00:00 2001 From: AbelOsaretin Date: Sat, 20 Jun 2026 22:15:36 +0100 Subject: [PATCH 2/3] test: add unit tests for processRewardClaim Add comprehensive unit tests for the processRewardClaim function covering: - Return true when submission does not exist - Return true when reward is already claimed - Return true when quiz does not exist - Return true when user does not exist - Successfully process claim with transaction wrapping - Throw error when on-chain transaction fails --- .../services/process-reward-claim.test.ts | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 tests/unit/services/process-reward-claim.test.ts diff --git a/tests/unit/services/process-reward-claim.test.ts b/tests/unit/services/process-reward-claim.test.ts new file mode 100644 index 0000000..86078af --- /dev/null +++ b/tests/unit/services/process-reward-claim.test.ts @@ -0,0 +1,227 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("@stellar/stellar-sdk", () => ({ + default: { + Address: { + fromString: vi.fn().mockReturnValue({ + toScVal: vi.fn().mockReturnValue("mock-sc-val"), + }), + }, + nativeToScVal: vi.fn().mockReturnValue("mock-native-val"), + }, +})); + +vi.mock("../../../src/config/database.js", () => { + const mockDb = { + select: vi.fn(), + from: vi.fn(), + where: vi.fn(), + update: vi.fn(), + insert: vi.fn(), + transaction: vi.fn(), + }; + return { db: mockDb }; +}); + +vi.mock("../../../src/stellar/transactions.js", () => ({ + invokeContract: vi.fn().mockResolvedValue("tx-hash-123"), +})); + +vi.mock("../../../src/stellar/signatures.js", () => ({ + createQuizProof: vi.fn().mockReturnValue({ signature: "base64sig" }), +})); + +vi.mock("../../../src/stellar/resilience.js", () => ({ + isCircuitBreakerError: vi.fn().mockReturnValue(false), +})); + +vi.mock("../../../src/config/index.js", () => ({ + config: { + STELLAR_REWARD_CONTRACT_ID: "test-reward-contract", + }, +})); + +vi.mock("../../../src/utils/logger.js", () => ({ + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); + +vi.mock("../../../src/services/retry-queue.js", () => ({ + enqueueReward: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("../../../src/audit/index.js", () => ({ + auditLog: vi.fn(), +})); + +vi.mock("../../../src/metrics/index.js", () => ({ + rewardClaimsTotal: { inc: vi.fn() }, + stellarTxDurationSeconds: { observe: vi.fn() }, +})); + +import { db } from "../../../src/config/database.js"; +import { processRewardClaim } from "../../../src/modules/rewards/reward.service.js"; +import { invokeContract } from "../../../src/stellar/transactions.js"; + +const mockDb = vi.mocked(db); + +function makeThenable(result: any[]) { + const obj: any = {}; + obj.then = (resolve: Function, reject: Function) => + Promise.resolve(result).then(resolve, reject); + obj.select = vi.fn().mockReturnValue(obj); + obj.from = vi.fn().mockReturnValue(obj); + obj.where = vi.fn().mockReturnValue(obj); + obj.update = vi.fn().mockReturnValue(obj); + obj.set = vi.fn().mockReturnValue(obj); + return obj; +} + +describe("processRewardClaim", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return true when submission does not exist", async () => { + const submissionChain = makeThenable([]); + + mockDb.select.mockReturnValue(submissionChain); + + const result = await processRewardClaim("sub-1", "user-1", 5); + expect(result).toBe(true); + }); + + it("should return true when reward is already claimed", async () => { + const submissionChain = makeThenable([ + { + id: "sub-1", + userId: "user-1", + score: 5, + rewardClaimed: true, + quizId: "quiz-1", + }, + ]); + + mockDb.select.mockReturnValue(submissionChain); + + const result = await processRewardClaim("sub-1", "user-1", 5); + expect(result).toBe(true); + }); + + it("should return true when quiz does not exist", async () => { + const submissionChain = makeThenable([ + { + id: "sub-1", + userId: "user-1", + score: 5, + rewardClaimed: false, + quizId: "quiz-1", + }, + ]); + const quizChain = makeThenable([]); + + mockDb.select + .mockReturnValueOnce(submissionChain) + .mockReturnValueOnce(quizChain); + + const result = await processRewardClaim("sub-1", "user-1", 5); + expect(result).toBe(true); + }); + + it("should return true when user does not exist", async () => { + const submissionChain = makeThenable([ + { + id: "sub-1", + userId: "user-1", + score: 5, + rewardClaimed: false, + quizId: "quiz-1", + }, + ]); + const quizChain = makeThenable([{ id: "quiz-1", courseId: "course-1" }]); + const userChain = makeThenable([]); + + mockDb.select + .mockReturnValueOnce(submissionChain) + .mockReturnValueOnce(quizChain) + .mockReturnValueOnce(userChain); + + const result = await processRewardClaim("sub-1", "user-1", 5); + expect(result).toBe(true); + }); + + it("should successfully process claim and update DB in transaction", async () => { + const submissionChain = makeThenable([ + { + id: "sub-1", + userId: "user-1", + score: 5, + rewardClaimed: false, + quizId: "quiz-1", + }, + ]); + const quizChain = makeThenable([{ id: "quiz-1", courseId: "course-1" }]); + const userChain = makeThenable([ + { + id: "user-1", + stellarAddress: + "GALICE0000000000000000000000000000000000000000000000000000000", + }, + ]); + + mockDb.select + .mockReturnValueOnce(submissionChain) + .mockReturnValueOnce(quizChain) + .mockReturnValueOnce(userChain); + + mockDb.transaction.mockImplementation(async (fn: Function) => { + const tx: any = {}; + tx.update = vi.fn().mockReturnValue({ + set: vi.fn().mockReturnValue({ + where: vi.fn().mockResolvedValue(undefined), + }), + }); + return fn(tx); + }); + + const result = await processRewardClaim("sub-1", "user-1", 5); + + expect(result).toBe(true); + expect(mockDb.transaction).toHaveBeenCalledTimes(1); + expect(invokeContract).toHaveBeenCalledWith( + "test-reward-contract", + "claim_reward", + expect.any(Array) + ); + }); + + it("should throw when on-chain transaction fails", async () => { + const submissionChain = makeThenable([ + { + id: "sub-1", + userId: "user-1", + score: 5, + rewardClaimed: false, + quizId: "quiz-1", + }, + ]); + const quizChain = makeThenable([{ id: "quiz-1", courseId: "course-1" }]); + const userChain = makeThenable([ + { + id: "user-1", + stellarAddress: + "GALICE0000000000000000000000000000000000000000000000000000000", + }, + ]); + + mockDb.select + .mockReturnValueOnce(submissionChain) + .mockReturnValueOnce(quizChain) + .mockReturnValueOnce(userChain); + + vi.mocked(invokeContract).mockRejectedValue(new Error("Stellar error")); + + await expect( + processRewardClaim("sub-1", "user-1", 5) + ).rejects.toThrow("Stellar error"); + }); +}); From 0e1382690d6f4ad9bc45207d746aa501c91ae72e Mon Sep 17 00:00:00 2001 From: AbelOsaretin Date: Sat, 20 Jun 2026 22:38:17 +0100 Subject: [PATCH 3/3] ci: re-trigger CI