From 6ad895102fb72ac2a0c1b7e9a19ed242300db04c Mon Sep 17 00:00:00 2001 From: brightcli-stack Date: Sun, 18 Oct 2026 01:01:16 +0100 Subject: [PATCH] fix: policy re-check at claim time, oracle key max length, query cache, sequence serialization (#725-#728) - #725: Re-check policy status immediately before claim creation and contract invocation. Reject cancelled and expired policies at claim time, not just at request start. - #726: Add MAX_ORACLE_KEY_LENGTH (128 chars) to oracle key validation. Reject oversized keys before regex matching to prevent DB bloat and DoS. - #727: Add 30s TTL cache for getLatestReading() to avoid repeated identical queries. Prisma already uses prepared statements; the cache eliminates redundant round-trips for hot keys. - #728: Add per-account promise-chain mutex to serialize concurrent transaction submissions, preventing sequence number conflicts (TRANSACTION_BAD_SEQ). InvokeContract now acquires the lock before building and submitting transactions. --- src/claims/claims.service.ts | 21 +++++++++++++++++++ src/oracle/oracle-key-format.ts | 7 +++++++ src/oracle/oracle.service.ts | 36 ++++++++++++++++++++++++--------- src/stellar/stellar.service.ts | 30 +++++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 9 deletions(-) diff --git a/src/claims/claims.service.ts b/src/claims/claims.service.ts index f745da9..f4d77d6 100644 --- a/src/claims/claims.service.ts +++ b/src/claims/claims.service.ts @@ -569,6 +569,27 @@ this.statusEvents.emitPolicyStatusChange(policyId, PolicyStatus.ACTIVE); ); } + // #725 — Re-check policy status immediately before creating the claim + // and invoking the contract. The earlier checks used a snapshot from + // when the request started; the policy may have been expired or + // cancelled since then. + const freshPolicy = await this.prisma.policy.findUnique({ where: { id: policyId } }); + if (!freshPolicy) { + throw new NotFoundException({ message: `Policy ${policyId} not found`, errorCode: ErrorCode.CLAIM_POLICY_NOT_FOUND }); + } + if (freshPolicy.status === PolicyStatus.CANCELLED) { + throw new ConflictException({ message: `Policy ${policyId} was cancelled`, errorCode: ErrorCode.CLAIM_POLICY_NOT_ACTIVE }); + } + if (freshPolicy.status !== PolicyStatus.ACTIVE) { + throw new ConflictException({ message: `Policy ${policyId} is not active (status: ${freshPolicy.status})`, errorCode: ErrorCode.CLAIM_POLICY_NOT_ACTIVE }); + } + if (new Date() > freshPolicy.endTime) { + throw new ConflictException({ + message: `Policy ${policyId} coverage period ended at ${freshPolicy.endTime.toISOString()}`, + errorCode: ErrorCode.CLAIM_POLICY_EXPIRED, + }); + } + let claim: Awaited>; try { claim = await this.prisma.claim.create({ diff --git a/src/oracle/oracle-key-format.ts b/src/oracle/oracle-key-format.ts index a1f3665..d88bd06 100644 --- a/src/oracle/oracle-key-format.ts +++ b/src/oracle/oracle-key-format.ts @@ -17,6 +17,9 @@ const RAINFALL_OR_TEMPERATURE_KEY_RE = /^(?:rainfall|temperature):(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?):20\d{2}-(?:0[1-9]|1[0-2])$/; const FLIGHT_KEY_RE = /^flight:[A-Z0-9]+:20\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/; +/** Maximum oracle key length to prevent DB bloat and oversized-key DoS (#726). */ +export const MAX_ORACLE_KEY_LENGTH = 128; + /** * Range-checks the lat/lng embedded in a rainfall/temperature key in addition * to the format regex, which only checks that they're signed decimals — same @@ -24,6 +27,10 @@ const FLIGHT_KEY_RE = /^flight:[A-Z0-9]+:20\d{2}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12 * src/policy/dto/oracle-key-coordinates.validator.ts. */ export function isValidOracleKeyFormat(key: string): boolean { + // Reject oversized keys before regex matching (#726). + if (typeof key !== 'string' || key.length > MAX_ORACLE_KEY_LENGTH) { + return false; + } const coordMatch = RAINFALL_OR_TEMPERATURE_KEY_RE.exec(key); if (coordMatch) { const lat = parseFloat(coordMatch[1]); diff --git a/src/oracle/oracle.service.ts b/src/oracle/oracle.service.ts index c9281ad..2b1aec4 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -334,7 +334,17 @@ export class OracleService { } /** Get the latest reading for a given oracle key from the database. */ + // #727 — cache recent reads to avoid repeated identical queries. + private readonly readingCache = new Map(); + private static readonly READING_CACHE_TTL_MS = 30_000; + async getLatestReading(key: string): Promise { + // Check cache first (#727) + const cached = this.readingCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.value; + } + // #265 — submittedAt alone has no guaranteed tiebreak on ties (plausible // under bulk/backfill writes or fast successive submissions), and this // result directly drives claim payout decisions. id is a stable secondary @@ -344,16 +354,24 @@ export class OracleService { orderBy: [{ submittedAt: "desc" }, { id: "desc" }], }); - if (!record) return null; + const result = record + ? { + dataType: record.dataType, + key: record.key, + value: record.value.toString(), + confidence: record.confidence, + timestamp: Math.floor(record.submittedAt.getTime() / 1000), + source: record.source, + } + : null; - return { - dataType: record.dataType, - key: record.key, - value: record.value.toString(), - confidence: record.confidence, - timestamp: Math.floor(record.submittedAt.getTime() / 1000), - source: record.source, - }; + // Cache the result (#727) + this.readingCache.set(key, { + value: result, + expiresAt: Date.now() + OracleService.READING_CACHE_TTL_MS, + }); + + return result; } private async executeWithRateLimitBackoff( diff --git a/src/stellar/stellar.service.ts b/src/stellar/stellar.service.ts index 3406ff9..df8740e 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -59,6 +59,24 @@ export class StellarService { private readonly network: string; private readonly keeperKeypair: Keypair; + // #728 — per-account transaction serialization. Concurrent invocations from + // the same account would race on sequence numbers and fail with + // TRANSACTION_BAD_SEQ. A simple promise-chain mutex serializes them. + private readonly accountLocks = new Map>(); + + private async withAccountLock(publicKey: string, fn: () => Promise): Promise { + const prev = this.accountLocks.get(publicKey) ?? Promise.resolve(); + let release: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + this.accountLocks.set(publicKey, prev.then(() => gate)); + await prev; + try { + return await fn(); + } finally { + release!(); + } + } + constructor(private readonly config: ConfigService) { const rpcUrl = config.get("STELLAR_RPC_URL") ?? @@ -130,6 +148,18 @@ export class StellarService { ); } const signer = signerKeypair ?? this.keeperKeypair; + // #728 — serialize per-account to prevent sequence number conflicts. + return this.withAccountLock(signer.publicKey(), () => + this.doInvokeContract(contractId, method, args, signer), + ); + } + + private async doInvokeContract( + contractId: string, + method: string, + args: xdr.ScVal[], + signer: Keypair, + ): Promise { const contract = new Contract(contractId); const MAX_ATTEMPTS = 3; let lastError: Error | null = null;