From c7a7775d173ede8d54598da52109fa14605e8b71 Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Sun, 21 Jun 2026 13:52:55 +0100 Subject: [PATCH 1/5] Implement Stellar sequence number management --- package-lock.json | 15 ++++ package.json | 2 + src/modules/rewards/reward.service.ts | 54 ++++++++--- src/stellar/sequence-cache.ts | 31 +++++++ src/stellar/transactions.ts | 116 ++++++++++++++++-------- src/utils/account-lock.ts | 13 +++ src/utils/errors.ts | 2 +- tests/unit/stellar-sequence.test.ts | 125 ++++++++++++++++++++++++++ 8 files changed, 306 insertions(+), 52 deletions(-) create mode 100644 src/stellar/sequence-cache.ts create mode 100644 src/utils/account-lock.ts create mode 100644 tests/unit/stellar-sequence.test.ts diff --git a/package-lock.json b/package-lock.json index dcbc2d8..bf36cfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/semantic-conventions": "^1.41.1", "@stellar/stellar-sdk": "^13.3.0", + "async-lock": "^1.4.1", "cockatiel": "^4.0.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.38.3", @@ -33,6 +34,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@types/async-lock": "^1.4.2", "@types/node": "^22.10.5", "@types/pg": "^8.11.10", "@types/sanitize-html": "^2.16.1", @@ -2650,6 +2652,13 @@ "node": ">=18.0.0" } }, + "node_modules/@types/async-lock": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@types/async-lock/-/async-lock-1.4.2.tgz", + "integrity": "sha512-HlZ6Dcr205BmNhwkdXqrg2vkFMN2PluI7Lgr8In3B3wE5PiQHhjRqtW/lGdVU9gw+sM0JcIDx2AN+cW8oSWIcw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3276,6 +3285,12 @@ "node": ">=12" } }, + "node_modules/async-lock": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/async-lock/-/async-lock-1.4.1.tgz", + "integrity": "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==", + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", diff --git a/package.json b/package.json index b75b343..ef2d916 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@opentelemetry/sdk-node": "^0.219.0", "@opentelemetry/semantic-conventions": "^1.41.1", "@stellar/stellar-sdk": "^13.3.0", + "async-lock": "^1.4.1", "cockatiel": "^4.0.0", "dotenv": "^16.4.7", "drizzle-orm": "^0.38.3", @@ -42,6 +43,7 @@ "zod": "^3.24.1" }, "devDependencies": { + "@types/async-lock": "^1.4.2", "@types/node": "^22.10.5", "@types/pg": "^8.11.10", "@types/sanitize-html": "^2.16.1", diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index 0602abe..5161df6 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -6,9 +6,10 @@ import { courses, users, } from "../../database/schema.js"; -import { NotFoundError, ForbiddenError, ConflictError } from "../../utils/errors.js"; +import { NotFoundError, ForbiddenError, ConflictError, StellarError } from "../../utils/errors.js"; import { withLock } from "../../utils/lock.js"; import { invokeContract } from "../../stellar/transactions.js"; +import { stellarClient } from "../../stellar/client.js"; import { createQuizProof } from "../../stellar/signatures.js"; import { isCircuitBreakerError } from "../../stellar/resilience.js"; import { config } from "../../config/index.js"; @@ -77,7 +78,21 @@ export async function processRewardClaim( { method: "claim_reward", status: "error" }, Number(process.hrtime.bigint() - txStart) / 1e9 ); - throw err; + if (err instanceof StellarError && (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))) { + let accountSeq = "unknown"; + try { + const account = await stellarClient.getAccount(user.stellarAddress); + accountSeq = account.sequence; + } catch (e) {} + + logger.warn( + { submissionId, accountSeq }, + "bad_seq after invoke — the tx might actually succeed on-chain" + ); + txHash = "pending_indexer_confirmation"; + } else { + throw err; + } } await db.transaction(async (tx) => { @@ -144,16 +159,17 @@ export class RewardService { const proof = createQuizProof(userId, submission.quizId, submission.score); + const [user] = await tx + .select() + .from(users) + .where(eq(users.id, userId)); + + if (!user) { + throw new NotFoundError("User"); + } + let txHash: string | null = null; try { - const [user] = await tx - .select() - .from(users) - .where(eq(users.id, userId)); - - if (!user) { - throw new NotFoundError("User"); - } txHash = await invokeContract( config.STELLAR_REWARD_CONTRACT_ID, @@ -184,8 +200,22 @@ export class RewardService { }; } - logger.error({ err, submissionId }, "On-chain reward claim failed"); - throw new Error("Failed to process on-chain reward"); + if (err instanceof StellarError && (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))) { + let accountSeq = "unknown"; + try { + const account = await stellarClient.getAccount(user.stellarAddress); + accountSeq = account.sequence; + } catch (e) {} + + logger.warn( + { submissionId, accountSeq }, + "bad_seq after invoke — the tx might actually succeed on-chain" + ); + txHash = "pending_indexer_confirmation"; + } else { + logger.error({ err, submissionId }, "On-chain reward claim failed"); + throw new Error("Failed to process on-chain reward"); + } } await tx diff --git a/src/stellar/sequence-cache.ts b/src/stellar/sequence-cache.ts new file mode 100644 index 0000000..6595831 --- /dev/null +++ b/src/stellar/sequence-cache.ts @@ -0,0 +1,31 @@ +import { stellarClient } from "./client.js"; + +export class SequenceCache { + private localSeq: Map = new Map(); + + async getNextSequence(accountId: string): Promise { + // 1. Check local in-memory cache first + const local = this.localSeq.get(accountId); + if (local !== undefined) { + const next = local + 1n; + this.localSeq.set(accountId, next); + return next.toString(); + } + + // 2. Load from Horizon + const account = await stellarClient.getAccount(accountId); + const seq = BigInt(account.sequence); + this.localSeq.set(accountId, seq); + return seq.toString(); + } + + invalidate(accountId: string): void { + this.localSeq.delete(accountId); + } + + resetTo(accountId: string, seq: bigint): void { + this.localSeq.set(accountId, seq); + } +} + +export const sequenceCache = new SequenceCache(); diff --git a/src/stellar/transactions.ts b/src/stellar/transactions.ts index d506650..f91743f 100644 --- a/src/stellar/transactions.ts +++ b/src/stellar/transactions.ts @@ -9,6 +9,11 @@ import { stellarClient } from "./client.js"; import { logger } from "../utils/logger.js"; import { StellarError } from "../utils/errors.js"; +import { sequenceCache } from "./sequence-cache.js"; +import { withAccountLock } from "../utils/account-lock.js"; + +const MAX_SEQ_RETRIES = 3; + /** * Build and submit a Soroban contract invocation transaction. */ @@ -19,33 +24,50 @@ export async function invokeContract( signer?: StellarSdk.Keypair ): Promise { const keypair = signer ?? getPlatformKeypair(); - const account = await stellarClient.getAccount(keypair.publicKey()); - const contract = new StellarSdk.Contract(contractId); - const tx = new StellarSdk.TransactionBuilder(account, { - fee: StellarSdk.BASE_FEE, - networkPassphrase: getNetworkPassphrase(), - }) - .addOperation(contract.call(method, ...args)) - .setTimeout(60) - .build(); + return withAccountLock(keypair.publicKey(), async () => { + const contract = new StellarSdk.Contract(contractId); + + for (let attempt = 0; attempt < MAX_SEQ_RETRIES; attempt++) { + try { + const seqNum = await sequenceCache.getNextSequence(keypair.publicKey()); + const account = new StellarSdk.Account(keypair.publicKey(), seqNum); - tx.sign(keypair); + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(60) + .build(); - // Simulate first to avoid submitting doomed txs - const soroban = getSorobanServer(); - const simResult = await soroban.simulateTransaction(tx); - if (StellarSdk.rpc.Api.isSimulationError(simResult)) { - logger.error({ error: simResult.error }, "Simulation failed"); - throw new StellarError(`Simulation failed: ${simResult.error}`); - } + tx.sign(keypair); - // Prepare the transaction with the simulation results - const preparedTx = StellarSdk.rpc.assembleTransaction(tx, simResult).build(); - preparedTx.sign(keypair); + // Simulate first to avoid submitting doomed txs + const soroban = getSorobanServer(); + const simResult = await soroban.simulateTransaction(tx); + if (StellarSdk.rpc.Api.isSimulationError(simResult)) { + logger.error({ error: simResult.error }, "Simulation failed"); + throw new StellarError(`Simulation failed: ${simResult.error}`); + } - const result = await stellarClient.submitTransaction(preparedTx); - return result.hash; + // Prepare the transaction with the simulation results + const preparedTx = StellarSdk.rpc.assembleTransaction(tx, simResult).build(); + preparedTx.sign(keypair); + + 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"))) { + sequenceCache.invalidate(keypair.publicKey()); + logger.warn({ attempt, err }, "Sequence number conflict, retrying with fresh sequence"); + continue; + } + throw err; + } + } + throw new StellarError(`Failed after ${MAX_SEQ_RETRIES} attempts due to sequence conflicts`); + }); } /** @@ -57,23 +79,39 @@ export async function sendPayment( signer?: StellarSdk.Keypair ): Promise { const keypair = signer ?? getPlatformKeypair(); - const account = await stellarClient.getAccount(keypair.publicKey()); - const tx = new StellarSdk.TransactionBuilder(account, { - fee: StellarSdk.BASE_FEE, - networkPassphrase: getNetworkPassphrase(), - }) - .addOperation( - StellarSdk.Operation.payment({ - destination, - asset: StellarSdk.Asset.native(), - amount, - }) - ) - .setTimeout(60) - .build(); + return withAccountLock(keypair.publicKey(), async () => { + for (let attempt = 0; attempt < MAX_SEQ_RETRIES; attempt++) { + try { + const seqNum = await sequenceCache.getNextSequence(keypair.publicKey()); + const account = new StellarSdk.Account(keypair.publicKey(), seqNum); + + const tx = new StellarSdk.TransactionBuilder(account, { + fee: StellarSdk.BASE_FEE, + networkPassphrase: getNetworkPassphrase(), + }) + .addOperation( + StellarSdk.Operation.payment({ + destination, + asset: StellarSdk.Asset.native(), + amount, + }) + ) + .setTimeout(60) + .build(); - tx.sign(keypair); - const result = await stellarClient.submitTransaction(tx); - return result.hash; + tx.sign(keypair); + const result = await stellarClient.submitTransaction(tx); + return result.hash; + } catch (err: any) { + if (err instanceof StellarError && (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))) { + sequenceCache.invalidate(keypair.publicKey()); + logger.warn({ attempt, err }, "Sequence number conflict, retrying with fresh sequence"); + continue; + } + throw err; + } + } + throw new StellarError(`Failed after ${MAX_SEQ_RETRIES} attempts due to sequence conflicts`); + }); } diff --git a/src/utils/account-lock.ts b/src/utils/account-lock.ts new file mode 100644 index 0000000..6550bf2 --- /dev/null +++ b/src/utils/account-lock.ts @@ -0,0 +1,13 @@ +import AsyncLock from "async-lock"; + +const accountLock = new AsyncLock({ + timeout: 10_000, + maxPending: 50, +}); + +export function withAccountLock( + accountId: string, + fn: () => Promise +): Promise { + return accountLock.acquire(`account:${accountId}`, fn); +} diff --git a/src/utils/errors.ts b/src/utils/errors.ts index 43be37e..d48f432 100644 --- a/src/utils/errors.ts +++ b/src/utils/errors.ts @@ -13,7 +13,7 @@ export class AppError extends Error { this.statusCode = statusCode; this.code = code; this.isOperational = isOperational; - Object.setPrototypeOf(this, AppError.prototype); + Object.setPrototypeOf(this, new.target.prototype); } } diff --git a/tests/unit/stellar-sequence.test.ts b/tests/unit/stellar-sequence.test.ts new file mode 100644 index 0000000..71d1f8f --- /dev/null +++ b/tests/unit/stellar-sequence.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { sequenceCache } from "../../src/stellar/sequence-cache.js"; +import { stellarClient } from "../../src/stellar/client.js"; +import { withAccountLock } from "../../src/utils/account-lock.js"; +import { StellarError } from "../../src/utils/errors.js"; + +// Mock the external client so we don't hit real Horizon +vi.mock("../../src/stellar/client.js", () => ({ + stellarClient: { + getAccount: vi.fn(), + submitTransaction: vi.fn(), + }, +})); + +describe("Stellar Sequence Number Management", () => { + const accountId = "GBZ5...TEST"; + + beforeEach(() => { + vi.resetAllMocks(); + sequenceCache.invalidate(accountId); + }); + + it("loads sequence from Horizon on first call and caches it", async () => { + vi.mocked(stellarClient.getAccount).mockResolvedValueOnce({ sequence: "41" } as any); + + const seq1 = await sequenceCache.getNextSequence(accountId); + expect(seq1).toBe("41"); + + const seq2 = await sequenceCache.getNextSequence(accountId); + expect(seq2).toBe("42"); // 41 + 1 + + expect(stellarClient.getAccount).toHaveBeenCalledTimes(1); + }); + + it("handles 10 concurrent transactions (account lock + monotonic sequence)", async () => { + // 1. Mock Horizon returning a stale sequence of 100 + vi.mocked(stellarClient.getAccount).mockResolvedValue({ sequence: "100" } as any); + + let activeOperations = 0; + let maxConcurrent = 0; + + const runTx = async (index: number) => { + return withAccountLock(accountId, async () => { + // Track concurrency to verify lock serialization + activeOperations++; + maxConcurrent = Math.max(maxConcurrent, activeOperations); + + // Wait a tiny bit to make concurrency overlaps likely if lock didn't work + await new Promise((r) => setTimeout(r, 10)); + + const seq = await sequenceCache.getNextSequence(accountId); + + activeOperations--; + return seq; + }); + }; + + // Fire 10 transactions concurrently + const promises = Array.from({ length: 10 }, (_, i) => runTx(i)); + const sequences = await Promise.all(promises); + + // Verify all 10 succeeded + expect(sequences.length).toBe(10); + + // Verify sequences are monotonic (100, 101, ..., 109) + expect(sequences).toEqual([ + "100", + "101", + "102", + "103", + "104", + "105", + "106", + "107", + "108", + "109", + ]); + + // Verify account lock serialization: max concurrent should be exactly 1 + expect(maxConcurrent).toBe(1); + + // Verify Horizon was only hit once + expect(stellarClient.getAccount).toHaveBeenCalledTimes(1); + }); + + it("retries on bad_seq and invalidates cache", async () => { + // Mock Horizon returning 100 on first call, 105 on second call + vi.mocked(stellarClient.getAccount) + .mockResolvedValueOnce({ sequence: "100" } as any) + .mockResolvedValueOnce({ sequence: "105" } as any); + + // Simulate an API flow that uses the retry loop mechanism + let attempt = 0; + const simulateTxSubmit = async () => { + return withAccountLock(accountId, async () => { + for (let i = 0; i < 3; i++) { + try { + attempt++; + const seq = await sequenceCache.getNextSequence(accountId); + + if (attempt === 1) { + // Simulate submitting with bad sequence + throw new StellarError("tx failed: [\"tx_bad_seq\"]"); + } + + return seq; // Success + } catch (err: any) { + if (err instanceof StellarError && err.message.includes("bad_seq")) { + sequenceCache.invalidate(accountId); + continue; + } + throw err; + } + } + }); + }; + + const finalSeq = await simulateTxSubmit(); + + // First attempt got 100, failed with bad_seq, cache invalidated. + // Second attempt hit Horizon, got 105, returned 105. + expect(finalSeq).toBe("105"); + expect(stellarClient.getAccount).toHaveBeenCalledTimes(2); + }); +}); From 3eab6d13f8987868603e5a6b9fc83ca83b53a7ec Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Wed, 24 Jun 2026 13:43:15 +0100 Subject: [PATCH 2/5] fix: address PR review feedback - Fix empty catch block lint errors in reward.service.ts - Extract duplicated bad_seq handling into handleBadSeqError() helper - Fix sequence cache to consistently return next sequence number - Update tests to match corrected sequence cache behavior Resolves CI lint failures and code quality issues --- src/modules/rewards/reward.service.ts | 47 ++++++++++++++------------- src/stellar/sequence-cache.ts | 6 ++-- tests/unit/stellar-sequence.test.ts | 14 ++++---- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index 1f44ff2..a359d44 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -30,6 +30,29 @@ import { cacheGet, cacheSet, cacheDel, cacheKey } from "../../cache/index.js"; const REWARD_AMOUNT = 10; // credits per passed quiz +/** + * Helper function to handle bad_seq errors from Stellar transactions. + * When a bad_seq error occurs, it attempts to fetch the current account sequence + * for debugging purposes. The transaction may still succeed on-chain despite the error. + * @returns txHash set to "pending_indexer_confirmation" to indicate uncertain state + */ +async function handleBadSeqError(submissionId: string, stellarAddress: string): Promise { + let accountSeq = "unknown"; + try { + const account = await stellarClient.getAccount(stellarAddress); + accountSeq = account.sequence; + } catch { + // Intentionally swallow error: sequence fetch is for debugging only + // If Horizon is unavailable, we still want to mark the transaction as pending + } + + logger.warn( + { submissionId, accountSeq }, + "bad_seq after invoke — the tx might actually succeed on-chain" + ); + return "pending_indexer_confirmation"; +} + /** * Shared reward claim execution logic. * Used by both the direct claim path and the background retry processor. @@ -84,17 +107,7 @@ export async function processRewardClaim( Number(process.hrtime.bigint() - txStart) / 1e9, ); if (err instanceof StellarError && (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))) { - let accountSeq = "unknown"; - try { - const account = await stellarClient.getAccount(user.stellarAddress); - accountSeq = account.sequence; - } catch (e) {} - - logger.warn( - { submissionId, accountSeq }, - "bad_seq after invoke — the tx might actually succeed on-chain" - ); - txHash = "pending_indexer_confirmation"; + txHash = await handleBadSeqError(submissionId, user.stellarAddress); } else { throw err; } @@ -224,17 +237,7 @@ export class RewardService { } if (err instanceof StellarError && (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))) { - let accountSeq = "unknown"; - try { - const account = await stellarClient.getAccount(user.stellarAddress); - accountSeq = account.sequence; - } catch (e) {} - - logger.warn( - { submissionId, accountSeq }, - "bad_seq after invoke — the tx might actually succeed on-chain" - ); - txHash = "pending_indexer_confirmation"; + txHash = await handleBadSeqError(submissionId, user.stellarAddress); } else { logger.error({ err, submissionId }, "On-chain reward claim failed"); throw new Error("Failed to process on-chain reward"); diff --git a/src/stellar/sequence-cache.ts b/src/stellar/sequence-cache.ts index 6595831..74efa2b 100644 --- a/src/stellar/sequence-cache.ts +++ b/src/stellar/sequence-cache.ts @@ -15,8 +15,10 @@ export class SequenceCache { // 2. Load from Horizon const account = await stellarClient.getAccount(accountId); const seq = BigInt(account.sequence); - this.localSeq.set(accountId, seq); - return seq.toString(); + const next = seq + 1n; + this.localSeq.set(accountId, next); + // Return next sequence number (seq + 1) on first call for consistency + return next.toString(); } invalidate(accountId: string): void { diff --git a/tests/unit/stellar-sequence.test.ts b/tests/unit/stellar-sequence.test.ts index 71d1f8f..5dfa2c3 100644 --- a/tests/unit/stellar-sequence.test.ts +++ b/tests/unit/stellar-sequence.test.ts @@ -24,10 +24,10 @@ describe("Stellar Sequence Number Management", () => { vi.mocked(stellarClient.getAccount).mockResolvedValueOnce({ sequence: "41" } as any); const seq1 = await sequenceCache.getNextSequence(accountId); - expect(seq1).toBe("41"); + expect(seq1).toBe("42"); // First call returns seq + 1 (41 + 1 = 42) const seq2 = await sequenceCache.getNextSequence(accountId); - expect(seq2).toBe("42"); // 41 + 1 + expect(seq2).toBe("43"); // Second call returns 42 + 1 = 43 expect(stellarClient.getAccount).toHaveBeenCalledTimes(1); }); @@ -62,9 +62,8 @@ describe("Stellar Sequence Number Management", () => { // Verify all 10 succeeded expect(sequences.length).toBe(10); - // Verify sequences are monotonic (100, 101, ..., 109) + // Verify sequences are monotonic (101, 102, ..., 110) expect(sequences).toEqual([ - "100", "101", "102", "103", @@ -74,6 +73,7 @@ describe("Stellar Sequence Number Management", () => { "107", "108", "109", + "110", ]); // Verify account lock serialization: max concurrent should be exactly 1 @@ -117,9 +117,9 @@ describe("Stellar Sequence Number Management", () => { const finalSeq = await simulateTxSubmit(); - // First attempt got 100, failed with bad_seq, cache invalidated. - // Second attempt hit Horizon, got 105, returned 105. - expect(finalSeq).toBe("105"); + // First attempt got 101 (100 + 1), failed with bad_seq, cache invalidated. + // Second attempt hit Horizon, got 105, returned 106 (105 + 1). + expect(finalSeq).toBe("106"); expect(stellarClient.getAccount).toHaveBeenCalledTimes(2); }); }); From 5dd66d16ab72e52a55ece2c33b993708e88e1c8a Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Wed, 24 Jun 2026 22:11:10 +0100 Subject: [PATCH 3/5] fix: add StellarError import and fix TypeScript type errors - Import StellarError from utils/errors - Add explicit type annotation (err: unknown) to catch blocks - Split compound condition for proper TypeScript type narrowing --- src/modules/rewards/reward.service.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/modules/rewards/reward.service.ts b/src/modules/rewards/reward.service.ts index a359d44..8bac9e8 100644 --- a/src/modules/rewards/reward.service.ts +++ b/src/modules/rewards/reward.service.ts @@ -10,6 +10,7 @@ import { NotFoundError, ForbiddenError, ConflictError, + StellarError, } from "../../utils/errors.js"; import { withLock } from "../../utils/lock.js"; import { invokeContract } from "../../stellar/transactions.js"; @@ -101,12 +102,14 @@ export async function processRewardClaim( { method: "claim_reward", status: "success" }, Number(process.hrtime.bigint() - txStart) / 1e9, ); - } catch (err) { + } 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") || err.message.includes("tx_bad_seq"))) { + 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; @@ -206,7 +209,7 @@ export class RewardService { StellarSdk.nativeToScVal(Buffer.from(proof.signature, "base64")), ], ); - } catch (err) { + } catch (err: unknown) { if (err instanceof NotFoundError) throw err; if (isCircuitBreakerError(err)) { @@ -236,7 +239,9 @@ export class RewardService { }; } - if (err instanceof StellarError && (err.message.includes("bad_seq") || err.message.includes("tx_bad_seq"))) { + 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 { logger.error({ err, submissionId }, "On-chain reward claim failed"); From 457de43eba8ea324e476dc122cef3ce0df7061af Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Thu, 25 Jun 2026 23:23:57 +0100 Subject: [PATCH 4/5] test: fix sep10-auth test assertion for error message Update test to expect 'Invalid signature' instead of 'Signature verification failed' to match the actual error thrown by the auth service. --- tests/unit/services/sep10-auth.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/services/sep10-auth.test.ts b/tests/unit/services/sep10-auth.test.ts index 7a4ee98..8f9b690 100644 --- a/tests/unit/services/sep10-auth.test.ts +++ b/tests/unit/services/sep10-auth.test.ts @@ -177,7 +177,7 @@ describe("AuthService - SEP-10 Verification", () => { await expect( authService.verifyChallenge(stellarAddress, signedXdr) - ).rejects.toThrow("Signature verification failed"); + ).rejects.toThrow("Invalid signature"); }); it("should accept valid signed challenge and create new user", async () => { From e65fd50736b69ca2c144eafd97f38b38c36e193a Mon Sep 17 00:00:00 2001 From: Joeloo1 Date: Sat, 27 Jun 2026 12:44:37 +0100 Subject: [PATCH 5/5] fix: update test mocks to properly import stellar-sdk - Use importActual to preserve Horizon and rpc exports - Add missing STELLAR_HORIZON_URL and STELLAR_SOROBAN_RPC_URL to config mocks - Fixes concurrent-safety and process-reward-claim test failures --- tests/unit/services/concurrent-safety.test.ts | 25 ++++++++++++------- .../services/process-reward-claim.test.ts | 25 ++++++++++++------- 2 files changed, 32 insertions(+), 18 deletions(-) diff --git a/tests/unit/services/concurrent-safety.test.ts b/tests/unit/services/concurrent-safety.test.ts index 0220743..7fea0bb 100644 --- a/tests/unit/services/concurrent-safety.test.ts +++ b/tests/unit/services/concurrent-safety.test.ts @@ -1,15 +1,20 @@ 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"), - }), +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + default: { + ...actual.default, + Address: { + fromString: vi.fn().mockReturnValue({ + toScVal: vi.fn().mockReturnValue("mock-sc-val"), + }), + }, + nativeToScVal: vi.fn().mockReturnValue("mock-native-val"), }, - nativeToScVal: vi.fn().mockReturnValue("mock-native-val"), - }, -})); + }; +}); vi.mock("../../../src/config/database.js", () => { const mockDb = { @@ -40,6 +45,8 @@ vi.mock("../../../src/config/index.js", () => ({ config: { STELLAR_REWARD_CONTRACT_ID: "test-reward-contract", STELLAR_CREDENTIAL_CONTRACT_ID: "test-credential-contract", + STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org", + STELLAR_SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org", }, })); diff --git a/tests/unit/services/process-reward-claim.test.ts b/tests/unit/services/process-reward-claim.test.ts index 86078af..067dbb8 100644 --- a/tests/unit/services/process-reward-claim.test.ts +++ b/tests/unit/services/process-reward-claim.test.ts @@ -1,15 +1,20 @@ 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"), - }), +vi.mock("@stellar/stellar-sdk", async () => { + const actual = await vi.importActual("@stellar/stellar-sdk"); + return { + ...actual, + default: { + ...actual.default, + Address: { + fromString: vi.fn().mockReturnValue({ + toScVal: vi.fn().mockReturnValue("mock-sc-val"), + }), + }, + nativeToScVal: vi.fn().mockReturnValue("mock-native-val"), }, - nativeToScVal: vi.fn().mockReturnValue("mock-native-val"), - }, -})); + }; +}); vi.mock("../../../src/config/database.js", () => { const mockDb = { @@ -38,6 +43,8 @@ vi.mock("../../../src/stellar/resilience.js", () => ({ vi.mock("../../../src/config/index.js", () => ({ config: { STELLAR_REWARD_CONTRACT_ID: "test-reward-contract", + STELLAR_HORIZON_URL: "https://horizon-testnet.stellar.org", + STELLAR_SOROBAN_RPC_URL: "https://soroban-testnet.stellar.org", }, }));