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 51e00ee..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 { @@ -1780,6 +1790,8 @@ export { STELLAR_MAX_ASSET_CODE_LEN, deduplicateRequest, createRateLimiter, + withRateLimit, + SorokitRateLimitError, validateContractMethodSpec, isValidContractMethodSpec, validateSorobanType, @@ -1801,6 +1813,9 @@ export type { SanitizeInputOptions, RateLimiterOptions, RateLimiter, + AsyncMethod, + MethodRateLimitConfig, + RateLimitDecoratorConfig, ContractMethodParamSpec, ContractMethodSpecInput, ContractMethodValidationData, 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); + }); +}); 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; + } +}