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
10 changes: 10 additions & 0 deletions src/database/migrations/0020_quiz_generated_for_cascade.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Drop existing constraint
ALTER TABLE quizzes
DROP CONSTRAINT IF EXISTS quizzes_generated_for_users_id_fk;

-- Recreate with CASCADE
ALTER TABLE quizzes
ADD CONSTRAINT quizzes_generated_for_users_id_fk
FOREIGN KEY (generated_for)
REFERENCES users(id)
ON DELETE CASCADE;
4 changes: 3 additions & 1 deletion src/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,9 @@ export const quizzes = pgTable(
.references(() => courses.id, { onDelete: "cascade" }),
moduleId: varchar("module_id", { length: 100 }).notNull(),
questions: jsonb("questions").notNull(),
generatedFor: uuid("generated_for").references(() => users.id),
generatedFor: uuid("generated_for").references(() => users.id, {
onDelete: "cascade",
}),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
Expand Down
30 changes: 26 additions & 4 deletions src/modules/rewards/reward.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,28 @@ const REWARD_AMOUNT = 10; // credits per passed quiz
const PENDING_REWARDS_TTL_SECONDS = 10; // #327 — near-real-time
const PENDING_CONFIRMATION_ETA_SECONDS = 300; // reconcile job runs every 5 min

/**
* Detects if an error is a bad sequence error from Stellar.
* Uses multiple detection methods for robustness across SDK versions.
*/
function isBadSeqError(err: StellarError): boolean {
// Primary detection: string matching (backwards compatible)
if (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq")) {
return true;
}

// Robust detection: check Horizon response structure
const response = (err as any)?.response;
if (response?.status === 400) {
const resultCodes = response?.data?.extras?.result_codes;
if (resultCodes?.transaction === "tx_bad_seq") {
return true;
}
}

return false;
}

export async function selectSubmissionForUpdate(
tx: Parameters<typeof db.transaction>[0] extends (arg: infer T) => any ? T : never,
submissionId: string,
Expand Down Expand Up @@ -122,7 +144,7 @@ async function _executeStellarRewardClaim(claimData: RewardClaimData): Promise<s
);
if (
err instanceof StellarError &&
(err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))
isBadSeqError(err)
) {
return handleBadSeqError(claimData.submissionId, claimData.stellarAddress);
}
Expand Down Expand Up @@ -229,7 +251,7 @@ export async function processRewardClaim(
await _applyRewardToDb(submissionId, userId, txHash);

return true;
}).then(async (result) => {
}, 90_000).then(async (result) => {
if (result) {
await cacheDel(cacheKey("user", "progress", userId));
await cacheDel(cacheKey("user", "profile", userId));
Expand Down Expand Up @@ -418,7 +440,7 @@ export class RewardService {
queued: false,
message: `Successfully claimed ${REWARD_AMOUNT} credits`,
};
});
}, 90_000);
}

/**
Expand Down Expand Up @@ -482,7 +504,7 @@ export class RewardService {
}));

const result = { history, total: totalResult?.value ?? 0 };
await cacheSet(cacheKeyString, result, 30);
await cacheSet(cacheKeyString, result, 300);

return result;
}
Expand Down
24 changes: 23 additions & 1 deletion src/stellar/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,28 @@ import { withAccountLock } from "../utils/account-lock.js";

const MAX_SEQ_RETRIES = 3;

/**
* Detects if an error is a bad sequence error from Stellar.
* Uses multiple detection methods for robustness across SDK versions.
*/
function isBadSeqError(err: StellarError): boolean {
// Primary detection: string matching (backwards compatible)
if (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq")) {
return true;
}

// Robust detection: check Horizon response structure
const response = (err as any)?.response;
if (response?.status === 400) {
const resultCodes = response?.data?.extras?.result_codes;
if (resultCodes?.transaction === "tx_bad_seq") {
return true;
}
}

return false;
}

/**
* Build and submit a Soroban contract invocation transaction.
*/
Expand Down Expand Up @@ -81,7 +103,7 @@ export async function invokeContract(
const result = await stellarClient.submitTransaction(preparedTx);
return result.hash;
} catch (err: any) {
if (err instanceof StellarError && (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))) {
if (err instanceof StellarError && isBadSeqError(err)) {
await sequenceCache.invalidate(keypair.publicKey());
logger.warn({ attempt, err }, "Sequence number conflict, retrying with fresh sequence");
continue;
Expand Down
2 changes: 1 addition & 1 deletion src/utils/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { logger } from "./logger.js";
export async function withLock<T>(
key: string,
fn: () => Promise<T>,
ttlMs: number = 30_000
ttlMs: number = 60_000
): Promise<T> {
const lockKey = `lock:${key}`;
const lockValue = crypto.randomUUID();
Expand Down
Loading