Skip to content
Merged
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
154 changes: 80 additions & 74 deletions src/modules/rewards/reward.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,85 +65,91 @@ export async function processRewardClaim(
userId: string,
score: number,
): Promise<boolean> {
const [submission] = await db
.select()
.from(quizSubmissions)
.where(eq(quizSubmissions.id, submissionId));

if (!submission || submission.rewardClaimed) {
return true;
}

const [quiz] = await db
.select()
.from(quizzes)
.where(eq(quizzes.id, submission.quizId));

if (!quiz) return true;
if (submission.score === null) return true;

const questions = quiz.questions as Array<unknown> | null;
if (!questions || questions.length === 0) return true;
const percentage = Math.round((score / questions.length) * 100);
if (percentage < PASSING_PERCENTAGE) {
return true;
}

const proof = createQuizProof(userId, submission.quizId, score);
return withLock(`reward:${submissionId}`, async () => {
return db.transaction(async (tx) => {
const [submission] = await tx
.select()
.from(quizSubmissions)
.where(eq(quizSubmissions.id, submissionId))
.for("update");

if (!submission || submission.rewardClaimed) {
return true;
}

const [quiz] = await tx
.select()
.from(quizzes)
.where(eq(quizzes.id, submission.quizId));

if (!quiz) return true;
if (submission.score === null) return true;

const questions = quiz.questions as Array<unknown> | null;
if (!questions || questions.length === 0) return true;
const percentage = Math.round((score / questions.length) * 100);
if (percentage < PASSING_PERCENTAGE) {
return true;
}

const proof = createQuizProof(userId, submission.quizId, score);

const [user] = await tx.select().from(users).where(eq(users.id, userId));

if (!user) return true;

const txStart = process.hrtime.bigint();
let txHash: string;
try {
txHash = await invokeContract(
config.STELLAR_REWARD_CONTRACT_ID,
"claim_reward",
[
StellarSdk.Address.fromString(user.stellarAddress).toScVal(),
StellarSdk.nativeToScVal(score, { type: "u32" }),
StellarSdk.nativeToScVal(Buffer.from(proof.signature, "base64")),
],
);
stellarTxDurationSeconds.observe(
{ method: "claim_reward", status: "success" },
Number(process.hrtime.bigint() - txStart) / 1e9,
);
} catch (err: unknown) {
stellarTxDurationSeconds.observe(
{ method: "claim_reward", status: "error" },
Number(process.hrtime.bigint() - txStart) / 1e9,
);
if (err instanceof StellarError && err.message.includes("bad_seq")) {
txHash = await handleBadSeqError(submissionId, user.stellarAddress);
} else if (err instanceof StellarError && err.message.includes("tx_bad_seq")) {
txHash = await handleBadSeqError(submissionId, user.stellarAddress);
} else {
throw err;
}
}

const [user] = await db.select().from(users).where(eq(users.id, userId));
await tx
.update(quizSubmissions)
.set({ rewardClaimed: true, txHash })
.where(eq(quizSubmissions.id, submissionId));

if (!user) return true;
await tx
.update(users)
.set({
credits: sql`${users.credits} + ${REWARD_AMOUNT}`,
})
.where(eq(users.id, userId));

const txStart = process.hrtime.bigint();
let txHash: string;
try {
txHash = await invokeContract(
config.STELLAR_REWARD_CONTRACT_ID,
"claim_reward",
[
StellarSdk.Address.fromString(user.stellarAddress).toScVal(),
StellarSdk.nativeToScVal(score, { type: "u32" }),
StellarSdk.nativeToScVal(Buffer.from(proof.signature, "base64")),
],
);
stellarTxDurationSeconds.observe(
{ method: "claim_reward", status: "success" },
Number(process.hrtime.bigint() - txStart) / 1e9,
);
} catch (err: unknown) {
stellarTxDurationSeconds.observe(
{ method: "claim_reward", status: "error" },
Number(process.hrtime.bigint() - txStart) / 1e9,
);
if (err instanceof StellarError && err.message.includes("bad_seq")) {
txHash = await handleBadSeqError(submissionId, user.stellarAddress);
} else if (err instanceof StellarError && err.message.includes("tx_bad_seq")) {
txHash = await handleBadSeqError(submissionId, user.stellarAddress);
} else {
throw err;
return true;
});
}).then(async (result) => {
if (result) {
await cacheDel(cacheKey("user", "progress", userId));
await cacheDel(cacheKey("user", "profile", userId));
await cacheDel(cacheKey("rewards", "history", 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 result;
});

await cacheDel(cacheKey("user", "progress", userId));
await cacheDel(cacheKey("user", "profile", userId));
await cacheDel(cacheKey("rewards", "history", userId));

return true;
}

export class RewardService {
Expand Down
147 changes: 50 additions & 97 deletions tests/unit/services/process-reward-claim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ vi.mock("../../../src/cache/index.js", () => ({
cacheMisses: { labels: vi.fn().mockReturnValue({ inc: vi.fn() }) },
}));

vi.mock("../../../src/utils/lock.js", () => ({
withLock: vi.fn(async (_key: string, fn: () => Promise<any>) => fn()),
}));

import { db } from "../../../src/config/database.js";
import { processRewardClaim } from "../../../src/modules/rewards/reward.service.js";
import { invokeContract } from "../../../src/stellar/transactions.js";
Expand All @@ -88,6 +92,7 @@ function makeThenable(result: any[]) {
obj.select = vi.fn().mockReturnValue(obj);
obj.from = vi.fn().mockReturnValue(obj);
obj.where = vi.fn().mockReturnValue(obj);
obj.for = vi.fn().mockReturnValue(obj);
obj.update = vi.fn().mockReturnValue(obj);
obj.set = vi.fn().mockReturnValue(obj);
return obj;
Expand All @@ -98,108 +103,73 @@ describe("processRewardClaim", () => {
vi.clearAllMocks();
});

it("should return true when submission does not exist", async () => {
const submissionChain = makeThenable([]);

mockDb.select.mockReturnValue(submissionChain);
function mockTxWithSelects(selectResults: any[][]) {
mockDb.transaction.mockImplementation(async (fn: Function) => {
const tx: any = {};
for (const result of selectResults) {
const chain = {
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
for: vi.fn().mockResolvedValue(result),
then: (r: Function) => Promise.resolve(result).then(r),
}),
then: (r: Function) => Promise.resolve(result).then(r),
}),
then: (r: Function) => Promise.resolve(result).then(r),
};
tx.select = tx.select
? tx.select.mockReturnValueOnce(chain)
: vi.fn().mockReturnValueOnce(chain);
}
tx.update = vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
});
return fn(tx);
});
}

it("should return true when submission does not exist", async () => {
mockTxWithSelects([[]]);
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",
},
mockTxWithSelects([
[{ 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",
},
mockTxWithSelects([
[{ 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",
},
mockTxWithSelects([
[{ id: "sub-1", userId: "user-1", score: 5, rewardClaimed: false, quizId: "quiz-1" }],
[{ id: "quiz-1", courseId: "course-1", questions: [{ id: "q1" }] }],
[],
]);
const quizChain = makeThenable([{ id: "quiz-1", courseId: "course-1", questions: [{ id: "q1" }] }]);
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", questions: [{ id: "q1" }] }]);
const userChain = makeThenable([
{
id: "user-1",
stellarAddress:
"GALICE0000000000000000000000000000000000000000000000000000000",
},
mockTxWithSelects([
[{ id: "sub-1", userId: "user-1", score: 5, rewardClaimed: false, quizId: "quiz-1" }],
[{ id: "quiz-1", courseId: "course-1", questions: [{ id: "q1" }] }],
[{ 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);
Expand All @@ -212,28 +182,11 @@ describe("processRewardClaim", () => {
});

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",
},
mockTxWithSelects([
[{ id: "sub-1", userId: "user-1", score: 5, rewardClaimed: false, quizId: "quiz-1" }],
[{ id: "quiz-1", courseId: "course-1", questions: [{ id: "q1" }] }],
[{ id: "user-1", stellarAddress: "GALICE0000000000000000000000000000000000000000000000000000000" }],
]);
const quizChain = makeThenable([{ id: "quiz-1", courseId: "course-1", questions: [{ id: "q1" }] }]);
const userChain = makeThenable([
{
id: "user-1",
stellarAddress:
"GALICE0000000000000000000000000000000000000000000000000000000",
},
]);

mockDb.select
.mockReturnValueOnce(submissionChain)
.mockReturnValueOnce(quizChain)
.mockReturnValueOnce(userChain);

vi.mocked(invokeContract).mockRejectedValue(new Error("Stellar error"));

Expand Down
Loading