diff --git a/src/database/migrations/0020_quiz_generated_for_cascade.sql b/src/database/migrations/0020_quiz_generated_for_cascade.sql new file mode 100644 index 0000000..b208b41 --- /dev/null +++ b/src/database/migrations/0020_quiz_generated_for_cascade.sql @@ -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; diff --git a/src/database/schema.ts b/src/database/schema.ts index 3ef8ee0..18f46d7 100644 --- a/src/database/schema.ts +++ b/src/database/schema.ts @@ -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(), diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index 8eec5b7..e74ccfd 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -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[0] extends (arg: infer T) => any ? T : never, submissionId: string, @@ -122,7 +144,7 @@ async function _executeStellarRewardClaim(claimData: RewardClaimData): Promise { + }, 90_000).then(async (result) => { if (result) { await cacheDel(cacheKey("user", "progress", userId)); await cacheDel(cacheKey("user", "profile", userId)); @@ -418,7 +440,7 @@ export class RewardService { queued: false, message: `Successfully claimed ${REWARD_AMOUNT} credits`, }; - }); + }, 90_000); } /** @@ -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; } diff --git a/src/stellar/transactions.ts b/src/stellar/transactions.ts index 0d16cb5..f439a92 100644 --- a/src/stellar/transactions.ts +++ b/src/stellar/transactions.ts @@ -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. */ @@ -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; diff --git a/src/utils/lock.ts b/src/utils/lock.ts index 2996f45..be58626 100644 --- a/src/utils/lock.ts +++ b/src/utils/lock.ts @@ -6,7 +6,7 @@ import { logger } from "./logger.js"; export async function withLock( key: string, fn: () => Promise, - ttlMs: number = 30_000 + ttlMs: number = 60_000 ): Promise { const lockKey = `lock:${key}`; const lockValue = crypto.randomUUID();