From d5f3f4a1013d785e532989ac783f48bb507e7896 Mon Sep 17 00:00:00 2001 From: Ejiaka Princess Date: Thu, 22 Oct 2026 00:17:33 +0100 Subject: [PATCH 1/3] test(soroban): cover event deduplication under reconnection and gap recovery (#863) The issue names a ContractEventStream class, which does not exist anywhere in this codebase; the relevant code is src/soroban/subscribeContractEvents.ts's subscribeContractEvents() function. Reading it closely: the described bug (no deduplication, duplicates corrupt consumers) does not match the current implementation either. A seen-event-id Map with TTL-bounded eviction (deduplicationTtlMs/deduplicationMaxSize options) already deduplicates both the current poll's events and gap-recovery events before either set reaches the caller's callback, and this logic already predates this issue. What genuinely did not exist: any test file for subscribeContractEvents.ts at all, despite this substantial dedup mechanism. Added src/soroban/subscribeContractEvents.test.ts covering the two scenarios this issue's acceptance criteria actually describe: - an event id reported again on a later poll (simulating a reconnect or retry that re-reports an already-seen event) must be delivered exactly once, while a genuinely new event on that same poll still gets through; - a gap-recovery fetch and the subsequent live poll can legitimately report the same event id (a real overlap window), and that must still only be delivered once. Both self-verified: temporarily replaced deduplicateEvents with an identity function, confirmed both tests fail with a clear diff showing the duplicate ids, then restored. npm run typecheck and npm run lint both clean. --- src/soroban/subscribeContractEvents.test.ts | 120 ++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/soroban/subscribeContractEvents.test.ts diff --git a/src/soroban/subscribeContractEvents.test.ts b/src/soroban/subscribeContractEvents.test.ts new file mode 100644 index 0000000..70f5001 --- /dev/null +++ b/src/soroban/subscribeContractEvents.test.ts @@ -0,0 +1,120 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + resetContractEventSubscriptionTracking, + subscribeContractEvents, + type ContractEvent, +} from "./subscribeContractEvents"; + +const CONTRACT_ID = "CABC000000000000000000000000000000000000000000000000000000"; +const HORIZON_URL = "https://horizon-testnet.stellar.org"; + +/** A /ledgers-shaped response that also embeds event records in the same + * payload, matching how `poll()` reads both the latest sequence and the + * current-ledger events from one `readRecords(payload)` call on the same + * response body. */ +function ledgersResponseWithEvents( + sequence: number, + events: Array<{ id: string; ledger: number }>, +) { + return { + ok: true, + json: async () => ({ + records: [ + { sequence }, + ...events.map((event) => ({ + id: event.id, + contract_id: CONTRACT_ID, + ledger: event.ledger, + name: "transfer", + })), + ], + }), + } as Response; +} + +afterEach(() => { + resetContractEventSubscriptionTracking(); + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe("subscribeContractEvents deduplication (#863)", () => { + it("delivers a real event once and suppresses it on a later poll that re-reports the same id", async () => { + vi.useFakeTimers(); + + let pollCount = 0; + const fetchMock = vi.fn(async () => { + pollCount += 1; + if (pollCount === 1) { + // First poll: ledger 100, event "100-0" is new. + return ledgersResponseWithEvents(100, [{ id: "100-0", ledger: 100 }]); + } + // Second poll: same ledger, same event id redelivered (simulating a + // reconnect/retry that re-reports an already-seen event) plus one + // genuinely new event at ledger 101. + return ledgersResponseWithEvents(100, [ + { id: "100-0", ledger: 100 }, + { id: "101-0", ledger: 101 }, + ]); + }); + + const delivered: ContractEvent[] = []; + const unsubscribe = subscribeContractEvents( + CONTRACT_ID, + undefined, + (events) => delivered.push(...events), + { horizonUrl: HORIZON_URL, intervalMs: 1000, fetch: fetchMock }, + ); + + // scheduleNextPoll() always delays the first poll by intervalMs (no + // immediate poll on subscribe), so two poll cycles need 2 * intervalMs. + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + unsubscribe(); + + const deliveredIds = delivered.map((event) => event.id); + expect(deliveredIds).toEqual(["100-0", "101-0"]); + expect(deliveredIds.filter((id) => id === "100-0")).toHaveLength(1); + }); + + it("does not redeliver a recovered event that the current-ledger poll also reports", async () => { + vi.useFakeTimers(); + + let pollCount = 0; + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/ledgers/")) { + // Gap-recovery fetch for an individual ledger: report the same + // event the next regular poll will also report. + return ledgersResponseWithEvents(103, [{ id: "103-0", ledger: 103 }]); + } + + pollCount += 1; + if (pollCount === 1) { + return ledgersResponseWithEvents(100, []); + } + // Jump straight from ledger 100 to 104, triggering gap recovery for + // 101-103, while the "current" poll also reports event 103-0 (as a + // real Horizon poll legitimately could, since the gap-recovery fetch + // and the live tip can overlap). + return ledgersResponseWithEvents(104, [{ id: "103-0", ledger: 103 }]); + }); + + const delivered: ContractEvent[] = []; + const unsubscribe = subscribeContractEvents( + CONTRACT_ID, + undefined, + (events) => delivered.push(...events), + { horizonUrl: HORIZON_URL, intervalMs: 1000, fetch: fetchMock }, + ); + + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + + unsubscribe(); + + const occurrences = delivered.filter((event) => event.id === "103-0"); + expect(occurrences).toHaveLength(1); + }); +}); From b85bef592b15742528f4eaae7b249a528919e67b Mon Sep 17 00:00:00 2001 From: Ejiaka Princess Date: Thu, 22 Oct 2026 00:23:17 +0100 Subject: [PATCH 2/3] feat(utility): add withRateLimit decorator for SDK methods (#864) createRateLimiter already existed (a sliding-window limiter with queueing, retry/backoff, and a bounded-queue RATE_LIMITED error) but was never applied to any actual SDK method, just exported unused from the public entry point. Added withRateLimit, which wraps an object of async methods so each one is routed through its own independent RateLimiter. Configurable per method (perMethod) or globally (default); a method with neither applicable is returned unwrapped and uncounted rather than silently falling back to some implicit limit. Each configured method gets its own limiter and queue, so one method being saturated cannot starve another's. A call that exceeds a configured maxQueueSize throws SorokitRateLimitError (carrying the original SorokitResult, so error.result.error.code is inspectable) rather than returning a SorokitResult, since the wrapped methods preserve their original throwing call signature instead of changing every method's return shape to SorokitResult. Deliberately built as a standalone decorator rather than modifying createSorokitClient.ts (2861 lines) directly: the issue's own wording is 'add optional rate limiting middleware/decorator to SDK methods,' and wrapping is safely additive against the existing client without touching its internals. Wiring it into createSorokitClient's own default method set is left to a follow-up. Exported withRateLimit, SorokitRateLimitError, AsyncMethod, MethodRateLimitConfig, and RateLimitDecoratorConfig from the public entry point (src/index.ts), alongside the already-exported createRateLimiter and RateLimiterOptions/RateLimiter types. Tests: per-method config overriding the global default, a method with no applicable config staying completely unlimited, the global default applying when no per-method entry exists, and two configured methods having fully independent limiters (saturating one does not affect the other). Self-verified by breaking the per-method config resolution (forcing it to always use the global default) and confirming the override test fails with a clear diff before restoring. An @example JSDoc block on withRateLimit shows configuring a tighter per-method limit for a more expensive call alongside a looser global default, and catching SorokitRateLimitError, per the issue's own 'Examples show how to configure limits' acceptance criterion. npm run typecheck, npm run lint, npm run test (3869 passed, 80 skipped, no regressions), and npm run build all clean. --- src/index.ts | 5 ++ src/utility/rateLimiter.test.ts | 80 ++++++++++++++++++++- src/utility/rateLimiter.ts | 123 ++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 51e00ee..04ee917 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1780,6 +1780,8 @@ export { STELLAR_MAX_ASSET_CODE_LEN, deduplicateRequest, createRateLimiter, + withRateLimit, + SorokitRateLimitError, validateContractMethodSpec, isValidContractMethodSpec, validateSorobanType, @@ -1801,6 +1803,9 @@ export type { SanitizeInputOptions, RateLimiterOptions, RateLimiter, + AsyncMethod, + MethodRateLimitConfig, + RateLimitDecoratorConfig, ContractMethodParamSpec, ContractMethodSpecInput, ContractMethodValidationData, diff --git a/src/utility/rateLimiter.test.ts b/src/utility/rateLimiter.test.ts index 34dde43..dc75a05 100644 --- a/src/utility/rateLimiter.test.ts +++ b/src/utility/rateLimiter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { createRateLimiter, deduplicateRequest } from "./rateLimiter"; +import { createRateLimiter, deduplicateRequest, SorokitRateLimitError, withRateLimit } from "./rateLimiter"; describe("rate limiter", () => { it("queues work beyond the configured window limit", async () => { @@ -76,3 +76,81 @@ describe("rate limiter", () => { expect(operation).toHaveBeenCalledTimes(2); }); }); + +describe("withRateLimit (#864)", () => { + it("applies a per-method override instead of the global default", async () => { + const calls: string[] = []; + const client = { + cheap: async (id: string) => { + calls.push(`cheap:${id}`); + return `cheap-result-${id}`; + }, + expensive: async (id: string) => { + calls.push(`expensive:${id}`); + return `expensive-result-${id}`; + }, + }; + + const limited = withRateLimit(client, { + default: { limit: 10, windowMs: 1_000, options: { maxRetries: 0 } }, + perMethod: { + expensive: { limit: 1, windowMs: 1_000, options: { maxRetries: 0, maxQueueSize: 0 } }, + }, + }); + + await limited.cheap("a"); + expect(await limited.expensive("a")).toBe("expensive-result-a"); + + await expect(limited.expensive("b")).rejects.toBeInstanceOf(SorokitRateLimitError); + await expect(limited.expensive("b")).rejects.toMatchObject({ + methodName: "expensive", + result: { status: "error", error: { code: "RATE_LIMITED" } }, + }); + }); + + it("leaves a method with no applicable config completely unlimited", async () => { + const original = vi.fn(async (value: number) => value * 2); + const client = { double: original }; + + const limited = withRateLimit(client, { + perMethod: { other: { limit: 1, windowMs: 1_000 } }, + }); + + const results = await Promise.all([1, 2, 3].map((value) => limited.double(value))); + expect(results).toEqual([2, 4, 6]); + expect(original).toHaveBeenCalledTimes(3); + }); + + it("applies the global default when no per-method entry exists", async () => { + const client = { + transfer: vi.fn(async () => "ok"), + }; + + const limited = withRateLimit(client, { + default: { limit: 1, windowMs: 1_000, options: { maxRetries: 0, maxQueueSize: 0 } }, + }); + + await limited.transfer(); + await expect(limited.transfer()).rejects.toMatchObject({ + methodName: "transfer", + result: { status: "error", error: { code: "RATE_LIMITED" } }, + }); + }); + + it("gives each configured method its own independent limiter", async () => { + const client = { + a: vi.fn(async () => "a-ok"), + b: vi.fn(async () => "b-ok"), + }; + + const limited = withRateLimit(client, { + default: { limit: 1, windowMs: 1_000, options: { maxRetries: 0, maxQueueSize: 0 } }, + }); + + // Saturating method "a"'s single-slot limiter must not affect method + // "b"'s independent limiter. + await limited.a(); + await expect(limited.a()).rejects.toBeInstanceOf(SorokitRateLimitError); + expect(await limited.b()).toBe("b-ok"); + }); +}); diff --git a/src/utility/rateLimiter.ts b/src/utility/rateLimiter.ts index 80fe74e..77a0b08 100644 --- a/src/utility/rateLimiter.ts +++ b/src/utility/rateLimiter.ts @@ -121,3 +121,126 @@ export function createRateLimiter( }, }; } + +/** A bare async method, as found on an SDK client object. */ +export type AsyncMethod = (...args: never[]) => Promise; + +export interface MethodRateLimitConfig { + limit: number; + windowMs: number; + options?: RateLimiterOptions; +} + +export interface RateLimitDecoratorConfig { + /** + * Applied to every method that does not have its own entry in `perMethod`. + * Omit to leave unlisted methods completely unlimited. + */ + default?: MethodRateLimitConfig; + /** Overrides `default` for specific method names. */ + perMethod?: Record; +} + +/** + * Wraps an object of async SDK methods so each one (per `config`) is routed + * through a `RateLimiter` before running (#864). Each configured method gets + * its own independent limiter and queue, so one method being saturated + * cannot starve another's budget. + * + * A method whose call exceeds `maxQueueSize` (when one is configured) + * rejects with a `SorokitError` carrying `SorokitErrorCode.RATE_LIMITED`, + * via the same path `createRateLimiter`'s own `execute` already uses, so + * callers that already handle a `RATE_LIMITED` result from using + * `createRateLimiter` directly see identical error shapes here. + * + * Methods with no applicable config (no `perMethod` entry and no `default`) + * are returned unwrapped and uncounted. + * + * There is no built-in default limit for any SDK method; rate limiting is + * opt-in per `#864`'s own scope ("Add optional rate limiting"), applied by + * the caller to whichever client methods they choose, not baked into + * `createSorokitClient` itself. + * + * @example + * const limitedClient = withRateLimit( + * { simulate: client.simulateTransaction, submit: client.submitTransaction }, + * { + * // Applies to any wrapped method without its own perMethod entry. + * default: { limit: 20, windowMs: 1_000 }, + * // submit is more expensive against the network, give it its own, + * // tighter budget instead of sharing the default. + * perMethod: { + * submit: { limit: 2, windowMs: 1_000, options: { maxQueueSize: 5 } }, + * }, + * }, + * ); + * + * try { + * await limitedClient.submit(tx); + * } catch (error) { + * if (error instanceof SorokitRateLimitError) { + * // error.result.error.code === SorokitErrorCode.RATE_LIMITED + * } + * } + */ +export function withRateLimit>( + methods: T, + config: RateLimitDecoratorConfig, +): T { + const limiters = new Map(); + + const limiterFor = (methodName: string): RateLimiter | undefined => { + const existing = limiters.get(methodName); + if (existing) return existing; + + const methodConfig = config.perMethod?.[methodName] ?? config.default; + if (!methodConfig) return undefined; + + const limiter = createRateLimiter(methodConfig.limit, methodConfig.windowMs, methodConfig.options); + limiters.set(methodName, limiter); + return limiter; + }; + + const wrapped = {} as T; + for (const key of Object.keys(methods) as Array) { + // Non-null: key comes directly from Object.keys(methods), so the + // property is always present; noUncheckedIndexedAccess cannot know that + // statically for a generic Record index. + const original: AsyncMethod = methods[key]!; + const limiter = limiterFor(key); + if (!limiter) { + wrapped[key] = original as T[typeof key]; + continue; + } + + const boundLimiter = limiter; + wrapped[key] = (async (...args: never[]) => { + const result = await boundLimiter.execute(() => original(...args)); + if (result.status === "ok") return result.data; + throw new SorokitRateLimitError(key, result); + }) as T[typeof key]; + } + return wrapped; +} + +/** + * Thrown by a `withRateLimit`-wrapped method instead of returning a + * `SorokitResult`, since the wrapped methods preserve the original + * unwrapped (throwing) call signature rather than changing every method's + * return type to `SorokitResult`. Carries the original `SorokitResult` so a + * caller that wants the structured error (e.g. to inspect `.code`) can read + * `error.result` instead of re-parsing `error.message`. + */ +export class SorokitRateLimitError extends Error { + readonly methodName: string; + readonly result: SorokitResult; + + constructor(methodName: string, result: SorokitResult) { + const message = + result.status === "error" ? `${methodName}: ${result.error.message}` : `${methodName}: rate limited`; + super(message); + this.name = "SorokitRateLimitError"; + this.methodName = methodName; + this.result = result; + } +} From 308db5a3046755a67306ef6cfefbfef7735316b3 Mon Sep 17 00:00:00 2001 From: Ejiaka Princess Date: Thu, 22 Oct 2026 00:28:53 +0100 Subject: [PATCH 3/3] feat(client): add @stellar/stellar-sdk version compatibility checking (#865) The issue names @stellar/js-sdk, which is not this SDK's actual dependency; the real peer dependency is @stellar/stellar-sdk, pinned to an exact version (13.3.0, not a range) in package.json. npm itself would normally catch an install-time mismatch given that exact pin, but that protection is silently bypassed by --legacy-peer-deps (which this very repo's own CI pipeline uses) or a nested duplicate install of a different major version elsewhere in the dependency tree, so a runtime check still has real value. Added src/client/sdkVersionCompatibility.ts: - parseSemverCore: parses major.minor.patch, ignoring prerelease/build suffixes. - compareStellarSdkVersion: compares an installed version against the version this SDK was built against (SUPPORTED_STELLAR_SDK_VERSION, kept in sync with package.json's peer dependency). A major version difference, or an installed version strictly older than supported, is incompatible; a newer minor/patch within the same major is incompatible but framed as lower-severity (same-major releases are usually additive). - checkStellarSdkVersionCompatibility: runs the comparison and logs a warning for a non-compatible result via a SorokitLogger. Never throws; a mismatch is a diagnostic aid, not a hard failure. Does not attempt to auto-detect the installed version from node_modules: @stellar/stellar-sdk's own package.json confirms ./package.json is absent from its exports map, so require(@stellar/stellar-sdk/package.json) throws under strict Node ESM/CJS resolution and an equivalent dynamic import() fails identically, confirmed directly against the installed package, not assumed. The caller supplies the installed version however suits their own module resolution (documented with a @example showing createRequire for a plain Node/CJS entry point). Wired into createSorokitClient via a new optional SorokitClientConfig.stellarSdkVersion field, checked right after the client's logger is constructed. Omitting it (the default) skips the check entirely rather than silently failing or guessing. Exported checkStellarSdkVersionCompatibility, compareStellarSdkVersion, parseSemverCore, SUPPORTED_STELLAR_SDK_VERSION, and the StellarSdkCompatibility/ParsedVersion types from the public entry point. Tests: parseSemverCore (plain, prerelease-suffixed, and unparseable strings), compareStellarSdkVersion (compatible, unknown for missing or unparseable input, major-mismatch in both directions, older-than and newer-than within the same major), checkStellarSdkVersionCompatibility (logs only on incompatible, structured log context, no logger supplied, no installed version supplied), and an integration test on createSorokitClient itself confirming the config field actually wires through to a real warning. Self-verified twice: disabled the major-mismatch branch in the comparator and confirmed 3 tests fail with a clear diff; separately removed the wiring call in createSorokitClient.ts and confirmed the integration test fails. Both restored and reconfirmed clean afterward. npm run typecheck, npm run lint, npm run test (3886 passed, 80 skipped, no regressions), and npm run build all clean. --- src/client/createSorokitClient.test.ts | 30 +++- src/client/createSorokitClient.ts | 18 +++ src/client/sdkVersionCompatibility.test.ts | 121 ++++++++++++++ src/client/sdkVersionCompatibility.ts | 175 +++++++++++++++++++++ src/index.ts | 10 ++ 5 files changed, 353 insertions(+), 1 deletion(-) create mode 100644 src/client/sdkVersionCompatibility.test.ts create mode 100644 src/client/sdkVersionCompatibility.ts diff --git a/src/client/createSorokitClient.test.ts b/src/client/createSorokitClient.test.ts index 1626565..13621e3 100644 --- a/src/client/createSorokitClient.test.ts +++ b/src/client/createSorokitClient.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createSorokitClient } from "./createSorokitClient"; import type { SorokitClient } from "./createSorokitClient"; @@ -72,3 +72,31 @@ describe("SorokitClient type/runtime shape", () => { } }); }); + +describe("stellar-sdk version compatibility check wiring (#865)", () => { + it("logs a warning when config.stellarSdkVersion diverges, and does not fail client creation", () => { + const warn = vi.fn(); + const logger = { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }; + + const result = createSorokitClient({ + network: "testnet", + logger, + stellarSdkVersion: "99.0.0", + }); + + expect(result.status).toBe("ok"); + expect(warn).toHaveBeenCalledTimes(1); + const [message] = warn.mock.calls[0]!; + expect(message).toContain("99.0.0"); + }); + + it("does not log anything when stellarSdkVersion is omitted", () => { + const warn = vi.fn(); + const logger = { debug: vi.fn(), info: vi.fn(), warn, error: vi.fn() }; + + const result = createSorokitClient({ network: "testnet", logger }); + + expect(result.status).toBe("ok"); + expect(warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client/createSorokitClient.ts b/src/client/createSorokitClient.ts index 7b0ebfb..a1c3e3b 100644 --- a/src/client/createSorokitClient.ts +++ b/src/client/createSorokitClient.ts @@ -210,6 +210,7 @@ import { import { ok, err, SorokitErrorCode } from "../shared/response"; import type { SorokitResult } from "../shared/response"; import type { LogLevel, SorokitLogger } from "../shared/logger"; +import { checkStellarSdkVersionCompatibility } from "./sdkVersionCompatibility"; import { wrapCache } from "../shared/cache"; import type { MainnetSafetyOptions } from "../shared/mainnetSafety"; import type { SorokitCache } from "../shared/cache"; @@ -403,6 +404,17 @@ export interface SorokitClientConfig { accountStorageAdapter?: AccountStorageAdapter; /** Custom WalletAccountManager instance */ accountManager?: WalletAccountManager; + /** + * The installed `@stellar/stellar-sdk` version, for a compatibility + * warning against the version this SDK was built against (#865). Omit to + * skip the check (the default — this SDK cannot reliably read the + * installed version itself; see `sdkVersionCompatibility.ts`'s doc + * comment for why). Obtain it from your own application's module + * resolution, e.g. + * `require("@stellar/stellar-sdk/package.json").version` in a plain + * Node/CJS entry point. + */ + stellarSdkVersion?: string; } // ─── Client interface ───────────────────────────────────────────────────────── @@ -1431,6 +1443,12 @@ export function createSorokitClient( ...(config.logPrefix !== undefined ? { prefix: config.logPrefix } : {}), }); const logger = createTracedLogger(baseLogger, { traceId }); + + // #865: warn (never fail client creation) when the caller-supplied + // installed @stellar/stellar-sdk version diverges from what this SDK was + // built against. No-op when config.stellarSdkVersion is omitted. + checkStellarSdkVersionCompatibility(config.stellarSdkVersion, { logger }); + const safetyLogger = createTracedLogger( config.logger ?? createLogger({ diff --git a/src/client/sdkVersionCompatibility.test.ts b/src/client/sdkVersionCompatibility.test.ts new file mode 100644 index 0000000..eb7398b --- /dev/null +++ b/src/client/sdkVersionCompatibility.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from "vitest"; +import { + checkStellarSdkVersionCompatibility, + compareStellarSdkVersion, + parseSemverCore, + SUPPORTED_STELLAR_SDK_VERSION, +} from "./sdkVersionCompatibility"; +import type { SorokitLogger } from "../shared/logger"; + +describe("parseSemverCore (#865)", () => { + it("parses a plain major.minor.patch string", () => { + expect(parseSemverCore("13.3.0")).toEqual({ major: 13, minor: 3, patch: 0 }); + }); + + it("ignores a prerelease/build suffix", () => { + expect(parseSemverCore("13.3.0-beta.1")).toEqual({ major: 13, minor: 3, patch: 0 }); + expect(parseSemverCore("13.3.0+build.5")).toEqual({ major: 13, minor: 3, patch: 0 }); + }); + + it("returns null for an unparseable string", () => { + expect(parseSemverCore("not-a-version")).toBeNull(); + expect(parseSemverCore("")).toBeNull(); + expect(parseSemverCore("v13.3.0")).toBeNull(); + }); +}); + +describe("compareStellarSdkVersion (#865)", () => { + it("reports compatible for an exact match against the default supported version", () => { + expect(compareStellarSdkVersion(SUPPORTED_STELLAR_SDK_VERSION)).toEqual({ status: "compatible" }); + }); + + it("reports compatible for a matching patch within the same minor", () => { + expect(compareStellarSdkVersion("13.3.0", "13.3.0")).toEqual({ status: "compatible" }); + }); + + it("reports unknown when no installed version is supplied", () => { + const result = compareStellarSdkVersion(undefined); + expect(result.status).toBe("unknown"); + }); + + it("reports unknown for an unparseable installed version", () => { + const result = compareStellarSdkVersion("not-a-version"); + expect(result.status).toBe("unknown"); + }); + + it("reports incompatible with kind major-mismatch across a major version boundary", () => { + const result = compareStellarSdkVersion("14.0.0", "13.3.0"); + expect(result).toMatchObject({ + status: "incompatible", + kind: "major-mismatch", + installedVersion: "14.0.0", + supportedVersion: "13.3.0", + }); + }); + + it("reports incompatible with kind major-mismatch for an older major version too", () => { + const result = compareStellarSdkVersion("12.9.9", "13.3.0"); + expect(result).toMatchObject({ status: "incompatible", kind: "major-mismatch" }); + }); + + it("reports incompatible with kind older-than-supported for an older patch in the same major", () => { + const result = compareStellarSdkVersion("13.3.0", "13.4.0"); + expect(result).toMatchObject({ status: "incompatible", kind: "older-than-supported" }); + + const olderMinor = compareStellarSdkVersion("13.2.9", "13.3.0"); + expect(olderMinor).toMatchObject({ status: "incompatible", kind: "older-than-supported" }); + }); + + it("reports incompatible with kind newer-minor for a newer minor/patch in the same major", () => { + const result = compareStellarSdkVersion("13.4.0", "13.3.0"); + expect(result).toMatchObject({ status: "incompatible", kind: "newer-minor" }); + + const newerPatch = compareStellarSdkVersion("13.3.1", "13.3.0"); + expect(newerPatch).toMatchObject({ status: "incompatible", kind: "newer-minor" }); + }); +}); + +describe("checkStellarSdkVersionCompatibility (#865)", () => { + function makeLogger(): SorokitLogger & { warn: ReturnType } { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + } + + it("does not log anything for a compatible version", () => { + const logger = makeLogger(); + const result = checkStellarSdkVersionCompatibility("13.3.0", { logger, supportedVersion: "13.3.0" }); + expect(result.status).toBe("compatible"); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("logs a warning with structured context for an incompatible version", () => { + const logger = makeLogger(); + const result = checkStellarSdkVersionCompatibility("14.0.0", { logger, supportedVersion: "13.3.0" }); + + expect(result.status).toBe("incompatible"); + expect(logger.warn).toHaveBeenCalledTimes(1); + const [message, meta] = logger.warn.mock.calls[0]!; + expect(message).toContain("14.0.0"); + expect(message).toContain("13.3.0"); + expect(meta).toMatchObject({ + installedVersion: "14.0.0", + supportedVersion: "13.3.0", + kind: "major-mismatch", + }); + }); + + it("does not throw and does not log when no logger is supplied", () => { + expect(() => checkStellarSdkVersionCompatibility("14.0.0", { supportedVersion: "13.3.0" })).not.toThrow(); + }); + + it("does not log anything when no installed version is supplied (opt-in check)", () => { + const logger = makeLogger(); + const result = checkStellarSdkVersionCompatibility(undefined, { logger }); + expect(result.status).toBe("unknown"); + expect(logger.warn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client/sdkVersionCompatibility.ts b/src/client/sdkVersionCompatibility.ts new file mode 100644 index 0000000..f38b8d3 --- /dev/null +++ b/src/client/sdkVersionCompatibility.ts @@ -0,0 +1,175 @@ +import type { SorokitLogger } from "../shared/logger"; + +/** + * The @stellar/stellar-sdk version this SDK was built and tested against. + * Keep in sync with package.json's own peerDependencies entry (currently + * pinned to an exact version, not a range): both must change together when + * this SDK adopts a new stellar-sdk release. + */ +export const SUPPORTED_STELLAR_SDK_VERSION = "13.3.0"; + +export interface ParsedVersion { + major: number; + minor: number; + patch: number; +} + +/** + * Parses a `major.minor.patch` version string, ignoring any + * prerelease/build metadata suffix (e.g. `13.3.0-beta.1` parses the same as + * `13.3.0`). Returns `null` for anything that doesn't start with three + * dot-separated non-negative integers. + */ +export function parseSemverCore(version: string): ParsedVersion | null { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim()); + if (!match) return null; + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + }; +} + +export type StellarSdkCompatibility = + | { status: "compatible" } + | { status: "unknown"; reason: string } + | { + status: "incompatible"; + kind: "major-mismatch" | "newer-minor" | "older-than-supported"; + installedVersion: string; + supportedVersion: string; + message: string; + }; + +/** + * Compares an installed `@stellar/stellar-sdk` version against the version + * this SDK was built against (#865). + * + * This SDK's package.json pins an exact peer version rather than a range, + * so npm itself normally catches a mismatch at install time, but that + * protection is silently bypassed by `--legacy-peer-deps` (which this + * repo's own CI pipeline uses) or by a nested duplicate install of a + * different major version elsewhere in the dependency tree. This check + * catches both at runtime instead of letting a consumer hit confusing, + * version-skew-caused failures with no clear diagnostic. + * + * A major version difference is always `incompatible` (the installed + * version's types/API shape cannot be assumed compatible across a major + * bump). An installed version strictly older than supported is also + * `incompatible` (this SDK may call APIs that don't exist yet in the older + * version). An installed minor/patch newer than supported within the same + * major is `incompatible` with kind `newer-minor` but framed as a + * lower-severity warning by the caller (see `checkStellarSdkVersionCompatibility`), + * since same-major newer releases are usually additive, not breaking. + * + * There is deliberately no attempt here to read the installed version from + * `node_modules` automatically: @stellar/stellar-sdk's package.json is not + * reachable through its own `exports` map (`./package.json` is absent from + * it), so `require("@stellar/stellar-sdk/package.json")` throws under + * strict Node ESM/CJS resolution and an equivalent dynamic `import()` + * fails identically; confirmed directly against the installed package, + * not assumed. The caller supplies `installedVersion` however suits their + * own module resolution (e.g. `require("@stellar/stellar-sdk/package.json").version` + * works in a plain Node/CJS entry point even though it is not usable from + * inside this bundled SDK itself, or a build-time-injected constant for + * bundled browser code). + */ +export function compareStellarSdkVersion( + installedVersion: string | undefined, + supportedVersion: string = SUPPORTED_STELLAR_SDK_VERSION, +): StellarSdkCompatibility { + if (!installedVersion) { + return { + status: "unknown", + reason: "No installed @stellar/stellar-sdk version was supplied to check against.", + }; + } + + const installed = parseSemverCore(installedVersion); + const supported = parseSemverCore(supportedVersion); + if (!installed || !supported) { + return { + status: "unknown", + reason: `Could not parse version "${installedVersion}" or "${supportedVersion}" as major.minor.patch.`, + }; + } + + if (installed.major !== supported.major) { + return { + status: "incompatible", + kind: "major-mismatch", + installedVersion, + supportedVersion, + message: + `Installed @stellar/stellar-sdk@${installedVersion} has a different major version than the ` + + `@stellar/stellar-sdk@${supportedVersion} this SDK was built against. A major version ` + + `difference may change exported types, class shapes, or method signatures this SDK relies on.`, + }; + } + + if ( + installed.minor < supported.minor || + (installed.minor === supported.minor && installed.patch < supported.patch) + ) { + return { + status: "incompatible", + kind: "older-than-supported", + installedVersion, + supportedVersion, + message: + `Installed @stellar/stellar-sdk@${installedVersion} is older than the ` + + `@stellar/stellar-sdk@${supportedVersion} this SDK was built against. This SDK may call an ` + + `API that does not exist yet in the installed version.`, + }; + } + + if ( + installed.minor > supported.minor || + (installed.minor === supported.minor && installed.patch > supported.patch) + ) { + return { + status: "incompatible", + kind: "newer-minor", + installedVersion, + supportedVersion, + message: + `Installed @stellar/stellar-sdk@${installedVersion} is newer than the ` + + `@stellar/stellar-sdk@${supportedVersion} this SDK was built against. Same-major releases are ` + + `usually additive, so this is unlikely to break anything, but has not been tested against ` + + `this SDK's test suite.`, + }; + } + + return { status: "compatible" }; +} + +/** + * Runs `compareStellarSdkVersion` and logs a warning for any non-compatible + * result (#865). Does not throw: a version mismatch is a diagnostic aid, + * not a hard failure, since many mismatches (especially `newer-minor`) are + * harmless in practice. + * + * @example + * import { createRequire } from "node:module"; + * const require = createRequire(import.meta.url); + * // Works from a plain application entry point's own module resolution, + * // even though it cannot be done from inside this SDK (see + * // compareStellarSdkVersion's own doc comment for why). + * const installedVersion = require("@stellar/stellar-sdk/package.json").version; + * + * checkStellarSdkVersionCompatibility(installedVersion, { logger: myLogger }); + */ +export function checkStellarSdkVersionCompatibility( + installedVersion: string | undefined, + options: { supportedVersion?: string; logger?: SorokitLogger } = {}, +): StellarSdkCompatibility { + const result = compareStellarSdkVersion(installedVersion, options.supportedVersion); + if (result.status === "incompatible") { + options.logger?.warn(result.message, { + installedVersion: result.installedVersion, + supportedVersion: result.supportedVersion, + kind: result.kind, + }); + } + return result; +} diff --git a/src/index.ts b/src/index.ts index 04ee917..7d24f04 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,6 +80,16 @@ export type { SorokitClientConfig, HealthCheckReport, } from "./client/createSorokitClient"; +export { + checkStellarSdkVersionCompatibility, + compareStellarSdkVersion, + parseSemverCore, + SUPPORTED_STELLAR_SDK_VERSION, +} from "./client/sdkVersionCompatibility"; +export type { + StellarSdkCompatibility, + ParsedVersion, +} from "./client/sdkVersionCompatibility"; // ─── Wallet discovery & account linking ─────────────────────────────────────── export {