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 f8d36b1..18e6b65 100644 --- a/src/oracle/oracle.service.ts +++ b/src/oracle/oracle.service.ts @@ -346,7 +346,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 @@ -356,16 +366,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 2c5a187..bbbed9d 100644 --- a/src/stellar/stellar.service.ts +++ b/src/stellar/stellar.service.ts @@ -63,6 +63,24 @@ export class StellarService { private readonly _horizonUrl: string; private readonly _rpcUrl: string; + // #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") ?? @@ -137,6 +155,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;