From 7be28dc9dba5d62b23a8fb5636a5105286910ab3 Mon Sep 17 00:00:00 2001 From: Mosas2000 Date: Fri, 28 Aug 2026 12:54:13 +0100 Subject: [PATCH 1/4] fix(sdk): distinguish empty/absent values from transport errors in read paths --- packages/sdk/src/__tests__/read.test.ts | 36 +++++- packages/sdk/src/client.ts | 148 +++++++++++++++++++++--- packages/sdk/src/errors.ts | 19 +++ 3 files changed, 189 insertions(+), 14 deletions(-) diff --git a/packages/sdk/src/__tests__/read.test.ts b/packages/sdk/src/__tests__/read.test.ts index cd1d4856..bbb430d7 100644 --- a/packages/sdk/src/__tests__/read.test.ts +++ b/packages/sdk/src/__tests__/read.test.ts @@ -273,14 +273,48 @@ describe("LinkoraClient read methods", () => { }); }); - describe("error mapping", () => { + describe("error mapping and discriminated read result (Issue #1359)", () => { it("throws mapped error for non-NotFound errors", async () => { simError("unauthorized action"); await expect(client.getPostCount()).rejects.toThrow("Unauthorized"); }); + + it("distinguishes absent vs network error for getDmKey", async () => { + simError("HostError: Error(Storage, MissingValue)"); + expect(await client.getDmKey("GUSER")).toBeNull(); + + simError("connection refused ECONNREFUSED"); + await expect(client.getDmKey("GUSER")).rejects.toThrow("NetworkError"); + }); + + it("distinguishes absent vs network error for getTreasury", async () => { + simError("HostError: Error(Storage, MissingValue)"); + expect(await client.getTreasury()).toBeNull(); + + simError("fetch failed / timeout"); + await expect(client.getTreasury()).rejects.toThrow("NetworkError"); + }); + + it("returns discriminated ReadResult with executeReadResult", async () => { + success("GTREASURY"); + const res1 = await client.executeReadResult(() => client.getTreasury()); + expect(res1).toEqual({ ok: true, value: "GTREASURY" }); + + notFound(); + const res2 = await client.executeReadResult(() => client.getProfile("GUSER")); + expect(res2).toEqual({ ok: true, value: null, absent: true }); + + simError("connection refused ECONNREFUSED"); + const res3 = await client.executeReadResult(() => client.getDmKey("GUSER")); + expect(res3.ok).toBe(false); + if (!res3.ok) { + expect(res3.error.code).toBe("NETWORK_ERROR"); + } + }); }); }); + describe("contract address validation", () => { it("accepts valid contract addresses (C...)", () => { const contractId = "CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM"; diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index b8d33c96..013dd23e 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -20,7 +20,11 @@ import { ValidationError, InvalidInputError, NetworkError, + LinkoraError, + VersionMismatchError, + ReadResult, } from "./errors.js"; +import { ClassicAccountClient, ClassicBalance } from "./classic.js"; import { GovParameter } from "./generated/types.js"; import type { GovProposal } from "./generated/types.js"; import { ConnectionHealthMonitor, HealthCheckConfig, ConnectionStatusCallback } from "./health.js"; @@ -211,6 +215,7 @@ export interface SetProfileWithNewTokenParams { * error handling, and type conversions (e.g. bigint ↔ number). */ export class LinkoraClient extends GeneratedLinkoraClient { + public readonly classic: ClassicAccountClient; private tokenFactoryId?: string; private readonly _rpcUrl: string; private readonly _networkPassphrase: string; @@ -219,6 +224,7 @@ export class LinkoraClient extends GeneratedLinkoraClient { private readonly _timeoutMs: number; private readonly _allowHttp: boolean; private readonly _horizonUrl?: string; + private readonly _rpcServer: rpc.Server; constructor(config: ClientConfig) { super({ @@ -235,14 +241,27 @@ export class LinkoraClient extends GeneratedLinkoraClient { this._allowHttp = resolveAllowHttp({ rpcUrl: config.rpcUrl, allowHttp: config.allowHttp }); this._horizonUrl = config.horizonUrl; + this._rpcServer = new rpc.Server(this._rpcUrl, { allowHttp: this._allowHttp }); + + this.classic = new ClassicAccountClient({ + networkPassphrase: this._networkPassphrase, + horizonUrl: this._horizonUrl, + timeoutMs: this._timeoutMs, + }); + const { autoStart, ...healthCfg } = config.healthCheck ?? {}; - this._healthMonitor = new ConnectionHealthMonitor(this._rpcUrl, healthCfg); + this._healthMonitor = new ConnectionHealthMonitor(this._rpcUrl, healthCfg, this._rpcServer); if (autoStart) this._healthMonitor.start(); } - /** Build an RPC server handle honoring the insecure-HTTP setting. */ + /** Get the shared client-wide RPC server instance. */ + public get rpcServer(): rpc.Server { + return this._rpcServer; + } + + /** Return the client-wide RPC server handle. */ private createRpcServer(): rpc.Server { - return new rpc.Server(this._rpcUrl, { allowHttp: this._allowHttp }); + return this._rpcServer; } /** @@ -674,22 +693,124 @@ export class LinkoraClient extends GeneratedLinkoraClient { return super.getLikeCount(BigInt(postId)); } + /** + * Execute a read function and wrap the outcome into a discriminated ReadResult. + * Callers can distinguish valid values, missing/empty data, and transport errors. + */ + async executeReadResult(fn: () => Promise): Promise> { + try { + const value = await fn(); + if (value === null) { + return { ok: true, value: null, absent: true }; + } + return { ok: true, value }; + } catch (error: unknown) { + const linkoraErr = error instanceof LinkoraError ? error : mapError(error); + if (linkoraErr instanceof NotFoundError) { + return { ok: true, value: null, absent: true }; + } + return { ok: false, error: linkoraErr }; + } + } + + /** + * Batch multiple contract read/simulation operations into a single RPC roundtrip. + * + * @param ops Array of contract operations specifying function method and ScVal arguments. + * @returns Array of ScVal return values (or null for empty results). + */ + async batchSimulate( + ops: Array<{ contractId?: string; method: string; args: xdr.ScVal[] }> + ): Promise> { + if (ops.length === 0) return []; + + const tempSource = Keypair.random(); + const tempAccount = new Account(tempSource.publicKey(), "0"); + const tempBuilder = new TransactionBuilder(tempAccount, { + fee: "100", + networkPassphrase: this._networkPassphrase, + }); + + for (const opDef of ops) { + const targetContractId = opDef.contractId ?? this._contractId; + const contract = new Contract(targetContractId); + tempBuilder.addOperation(contract.call(opDef.method, ...opDef.args)); + } + + const tempTx = tempBuilder.setTimeout(DEFAULT_TIMEOUT).build(); + const simulationResult = await this._rpcServer.simulateTransaction(tempTx); + + if (isSimulationError(simulationResult)) { + throw mapError(simulationResult.error); + } + + if (!isSimulationSuccess(simulationResult) || !simulationResult.result) { + return ops.map(() => null); + } + + const results = simulationResult.result ?? []; + return ops.map((_, i) => { + const entry = (results as unknown as Array<{ retval?: xdr.ScVal }>)[i]; + return entry?.retval ?? null; + }); + } + + /** + * Read the contract version or capability marker from the connected contract. + * Returns the version string if supported by the contract, or "unknown". + */ + async getContractVersion(): Promise { + try { + const retval = await this.simulateCallOnContract(this._contractId, "version"); + if (!retval) return "unknown"; + return (scValToNative(retval) as string) ?? "unknown"; + } catch { + return "unknown"; + } + } + + /** + * Verify that the contract version matches the expected capability version. + * + * @param expectedVersion The required contract version. + * @throws {VersionMismatchError} If the deployed contract version does not match. + */ + async verifyContractVersion(expectedVersion: string): Promise { + const actualVersion = await this.getContractVersion(); + if (actualVersion !== "unknown" && actualVersion !== expectedVersion) { + throw new VersionMismatchError( + `Contract version mismatch: expected "${expectedVersion}", but deployed contract returned "${actualVersion}".`, + { expected: expectedVersion, actual: actualVersion } + ); + } + return true; + } + + /** + * Fetch classic Stellar account balances (native XLM and tokens) via Horizon. + */ + getClassicAccountBalances(address: string): Promise { + return this.classic.getAccountBalances(address); + } + + /** + * Fetch non-native asset trustlines for a classic account via Horizon. + */ + getClassicAccountTrustlines(address: string): Promise { + return this.classic.getAccountTrustlines(address); + } + /** * Get the current treasury address where protocol fees are sent. * * @returns The treasury Stellar public key, or null if not set. - * - * @example - * ```ts - * const treasury = await client.getTreasury(); - * console.log(`Treasury address: ${treasury}`); - * ``` */ async getTreasury(): Promise { try { return await super.getTreasury(); - } catch { - return null; + } catch (e) { + if (e instanceof NotFoundError) return null; + throw e; } } @@ -756,8 +877,9 @@ export class LinkoraClient extends GeneratedLinkoraClient { async getDmKey(address: string): Promise { try { return await super.getDmKey(address); - } catch { - return null; + } catch (e) { + if (e instanceof NotFoundError) return null; + throw e; } } diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index ac096b41..f1f0d395 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -148,6 +148,25 @@ export class CircuitBreakerError extends LinkoraError { } } +/** + * Thrown when the deployed contract version or capability marker does not match SDK expectation. + */ +export class VersionMismatchError extends LinkoraError { + constructor(message: string, details?: Record, originalError?: unknown) { + super(message, "VERSION_MISMATCH", details, originalError); + } +} + +/** + * Discriminated result type for on-chain read operations. + * Callers can explicitly distinguish valid data, genuinely empty/absent data, and errors. + */ +export type ReadResult = + | { ok: true; value: T; absent?: false } + | { ok: true; value: null; absent: true } + | { ok: false; error: LinkoraError }; + + // ── Contract error codes ────────────────────────────────────────────────────── export enum ContractErrorCode { From 521f2979edf0f7cc38daa1ae39a1fa19d6e680d9 Mon Sep 17 00:00:00 2001 From: Mosas2000 Date: Fri, 28 Aug 2026 12:54:16 +0100 Subject: [PATCH 2/4] feat(sdk): reuse single rpc.Server instance and expose batchSimulate helper --- packages/sdk/src/__tests__/batch.test.ts | 55 ++++++++++++++++++++++++ packages/sdk/src/health.ts | 12 ++---- 2 files changed, 59 insertions(+), 8 deletions(-) create mode 100644 packages/sdk/src/__tests__/batch.test.ts diff --git a/packages/sdk/src/__tests__/batch.test.ts b/packages/sdk/src/__tests__/batch.test.ts new file mode 100644 index 00000000..5ca9c3bd --- /dev/null +++ b/packages/sdk/src/__tests__/batch.test.ts @@ -0,0 +1,55 @@ +import { LinkoraClient } from "../client.js"; +import * as rpc from "@stellar/stellar-sdk/rpc"; + +const mockSimulate = jest.fn(); + +jest.mock("@stellar/stellar-sdk/rpc", () => { + const original = jest.requireActual("@stellar/stellar-sdk/rpc"); + return { + ...original, + Server: jest.fn().mockImplementation(() => ({ + simulateTransaction: mockSimulate, + })), + }; +}); + +describe("Issue #1360: SDK request batching and single rpc.Server reuse", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("reuses a single rpc.Server instance across calls", () => { + const client = new LinkoraClient({ + contractId: "CDUMMY", + rpcUrl: "https://rpc.example.com", + }); + + const server1 = client.rpcServer; + const server2 = client.rpcServer; + + expect(server1).toBe(server2); + expect(rpc.Server).toHaveBeenCalledTimes(1); + }); + + it("executes batchSimulate in a single multi-op simulation RPC roundtrip", async () => { + const client = new LinkoraClient({ + contractId: "CDUMMY", + rpcUrl: "https://rpc.example.com", + }); + + mockSimulate.mockResolvedValueOnce({ + result: [ + { retval: { _type: "scval", _val: "val1" } }, + { retval: { _type: "scval", _val: "val2" } }, + ], + }); + + const results = await client.batchSimulate([ + { method: "get_profile", args: [] }, + { method: "get_post", args: [] }, + ]); + + expect(mockSimulate).toHaveBeenCalledTimes(1); + expect(results.length).toBe(2); + }); +}); diff --git a/packages/sdk/src/health.ts b/packages/sdk/src/health.ts index 0137d53e..2f06d62c 100644 --- a/packages/sdk/src/health.ts +++ b/packages/sdk/src/health.ts @@ -56,6 +56,7 @@ export class ConnectionHealthMonitor { private readonly backoffMs: number; private readonly maxBackoffMs: number; private readonly pingTimeoutMs: number; + private readonly server: rpc.Server; private status: ConnectionStatus = "disconnected"; private listeners: ConnectionStatusCallback[] = []; @@ -64,12 +65,13 @@ export class ConnectionHealthMonitor { private hasChecked = false; private retryMetrics: RetryMetrics = emptyRetryMetrics(); - constructor(rpcUrl: string, config: HealthCheckConfig = {}) { + constructor(rpcUrl: string, config: HealthCheckConfig = {}, server?: rpc.Server) { this.rpcUrl = rpcUrl; this.intervalMs = config.intervalMs ?? 30_000; this.backoffMs = config.backoffMs ?? 1_000; this.maxBackoffMs = config.maxBackoffMs ?? 30_000; this.pingTimeoutMs = config.pingTimeoutMs ?? 10_000; + this.server = server ?? new rpc.Server(this.rpcUrl, { allowHttp: false }); } /** @@ -93,14 +95,8 @@ export class ConnectionHealthMonitor { /** Perform a single health check ping against the RPC endpoint. */ async healthCheck(): Promise { try { - // Insecure HTTP is disabled by default (safe-by-default). A health check - // against a plaintext endpoint will simply report disconnected unless the - // endpoint was explicitly opted-in when constructing the client. - const server = new rpc.Server(this.rpcUrl, { - allowHttp: false, - }); const result = await withTimeout( - server.getLatestLedger(), + this.server.getLatestLedger(), this.pingTimeoutMs, `Health check timed out after ${this.pingTimeoutMs}ms` ); From 209773aeb6612bb8946034c97d4ed340f63b103e Mon Sep 17 00:00:00 2001 From: Mosas2000 Date: Fri, 28 Aug 2026 12:54:19 +0100 Subject: [PATCH 3/4] feat(sdk): add classic account Horizon surface and route web balance queries --- apps/web/src/hooks/useTokenBalances.ts | 10 +- packages/sdk/src/__tests__/classic.test.ts | 70 ++++++++++++++ packages/sdk/src/classic.ts | 105 +++++++++++++++++++++ packages/sdk/src/index.ts | 2 + 4 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/src/__tests__/classic.test.ts create mode 100644 packages/sdk/src/classic.ts diff --git a/apps/web/src/hooks/useTokenBalances.ts b/apps/web/src/hooks/useTokenBalances.ts index e6a773b1..c72f4bdd 100644 --- a/apps/web/src/hooks/useTokenBalances.ts +++ b/apps/web/src/hooks/useTokenBalances.ts @@ -1,14 +1,15 @@ "use client"; import { useEffect, useState, useCallback, useRef } from "react"; +import { ClassicAccountClient } from "@linkora/sdk"; -const HORIZON_TESTNET = "https://horizon-testnet.stellar.org"; const CACHE_TTL_MS = 30_000; +const classicClient = new ClassicAccountClient(); export interface TokenBalance { asset_type: string; - asset_code: string; - asset_issuer: string; + asset_code?: string; + asset_issuer?: string; balance: string; limit?: string; } @@ -67,7 +68,8 @@ export function useTokenBalances(address: string | null) { setError(null); try { - const res = await fetch(`${HORIZON_TESTNET}/accounts/${address}`, { + const horizonUrl = classicClient.getHorizonUrl(); + const res = await fetch(`${horizonUrl}/accounts/${address}`, { signal: controller.signal, }); diff --git a/packages/sdk/src/__tests__/classic.test.ts b/packages/sdk/src/__tests__/classic.test.ts new file mode 100644 index 00000000..7fe3e15b --- /dev/null +++ b/packages/sdk/src/__tests__/classic.test.ts @@ -0,0 +1,70 @@ +import { ClassicAccountClient, resolveHorizonUrl } from "../classic.js"; +import { ValidationError, NetworkError } from "../errors.js"; + +describe("Issue #1361: Classic Account & Horizon helpers", () => { + describe("resolveHorizonUrl", () => { + it("resolves testnet URL for test network passphrase", () => { + const url = resolveHorizonUrl("Test SDF Network ; September 2015"); + expect(url).toBe("https://horizon-testnet.stellar.org"); + }); + + it("resolves pubnet URL for global stellar passphrase", () => { + const url = resolveHorizonUrl("Public Global Stellar Network ; September 2015"); + expect(url).toBe("https://horizon.stellar.org"); + }); + + it("uses explicitly provided horizonUrl", () => { + const url = resolveHorizonUrl("Custom Passphrase", "https://custom-horizon.com"); + expect(url).toBe("https://custom-horizon.com"); + }); + + it("throws ValidationError when custom network has no horizonUrl", () => { + expect(() => resolveHorizonUrl("Unknown Custom Passphrase")).toThrow(ValidationError); + }); + }); + + describe("ClassicAccountClient", () => { + it("fetches account balances and formats native XLM", async () => { + const client = new ClassicAccountClient({ horizonUrl: "https://horizon-testnet.stellar.org" }); + + const mockFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + id: "GUSER", + account_id: "GUSER", + sequence: "100", + balances: [ + { asset_type: "native", balance: "150.5" }, + { asset_type: "credit_alphanum4", asset_code: "USDC", asset_issuer: "GISSUER", balance: "10.0" }, + ], + }), + }); + global.fetch = mockFetch as unknown as typeof fetch; + + const balances = await client.getAccountBalances("GUSER"); + expect(balances.length).toBe(2); + expect(balances[0]).toEqual({ + asset_type: "native", + asset_code: "XLM", + asset_issuer: "", + balance: "150.5", + }); + + const trustlines = await client.getAccountTrustlines("GUSER"); + expect(trustlines.length).toBe(1); + expect(trustlines[0].asset_code).toBe("USDC"); + }); + + it("throws NetworkError on Horizon HTTP failure", async () => { + const client = new ClassicAccountClient({ horizonUrl: "https://horizon-testnet.stellar.org" }); + + const mockFetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + }); + global.fetch = mockFetch as unknown as typeof fetch; + + await expect(client.getAccount("GUSER")).rejects.toThrow(NetworkError); + }); + }); +}); diff --git a/packages/sdk/src/classic.ts b/packages/sdk/src/classic.ts new file mode 100644 index 00000000..44ff7615 --- /dev/null +++ b/packages/sdk/src/classic.ts @@ -0,0 +1,105 @@ +import { fetchWithTimeout } from "./utils/fetch.js"; +import { NetworkError, ValidationError } from "./errors.js"; + +export interface ClassicBalance { + asset_type: string; + asset_code?: string; + asset_issuer?: string; + balance: string; + limit?: string; +} + +export interface ClassicAccountInfo { + id: string; + account_id: string; + sequence: string; + balances: ClassicBalance[]; +} + +export interface ClassicAccountConfig { + networkPassphrase?: string; + horizonUrl?: string; + timeoutMs?: number; +} + +const DEFAULT_NETWORK = "Test SDF Network ; September 2015"; + +/** + * Resolve the appropriate Horizon URL based on network passphrase and config options. + */ +export function resolveHorizonUrl(networkPassphrase?: string, horizonUrl?: string): string { + if (horizonUrl) return horizonUrl; + const passphrase = networkPassphrase || DEFAULT_NETWORK; + if (passphrase.includes("Test")) { + return "https://horizon-testnet.stellar.org"; + } + if (passphrase === "Public Global Stellar Network ; September 2015") { + return "https://horizon.stellar.org"; + } + throw new ValidationError( + `Cannot determine Horizon URL for custom network passphrase: "${passphrase}". Please provide horizonUrl in ClientConfig.`, + { networkPassphrase: passphrase } + ); +} + +/** + * Documented SDK client surface for Stellar classic-account operations (Horizon API). + * Handles account sequence fetching, XLM native balances, and asset trustlines. + */ +export class ClassicAccountClient { + private readonly horizonUrl: string; + private readonly timeoutMs: number; + + constructor(config: ClassicAccountConfig = {}) { + this.horizonUrl = resolveHorizonUrl(config.networkPassphrase, config.horizonUrl); + this.timeoutMs = config.timeoutMs ?? 30_000; + } + + /** Get the configured Horizon URL endpoint. */ + getHorizonUrl(): string { + return this.horizonUrl; + } + + /** + * Fetch classic account details from Horizon. + * + * @param address Stellar public key of the account. + */ + async getAccount(address: string): Promise { + if (!address) throw new ValidationError("address is required for Horizon query."); + const url = `${this.horizonUrl}/accounts/${address}`; + const res = await fetchWithTimeout(url, undefined, this.timeoutMs); + if (!res.ok) { + throw new NetworkError( + `Failed to fetch account from Horizon (HTTP ${res.status}).`, + { status: res.status, address } + ); + } + return (await res.json()) as ClassicAccountInfo; + } + + /** + * Fetch all balances (native XLM + assets) for an account. + * + * @param address Stellar public key of the account. + */ + async getAccountBalances(address: string): Promise { + const account = await this.getAccount(address); + return (account.balances ?? []).map((b) => { + if (b.asset_type === "native" && !b.asset_code) { + return { ...b, asset_code: "XLM", asset_issuer: "" }; + } + return b; + }); + } + + /** + * Fetch non-native asset trustlines for an account. + * + * @param address Stellar public key of the account. + */ + async getAccountTrustlines(address: string): Promise { + const balances = await this.getAccountBalances(address); + return balances.filter((b) => b.asset_type !== "native"); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 6f759c8b..3a3df28f 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -17,3 +17,5 @@ export * from "./dm/index.js"; // TODO(#1045): Ensure DmService and LinkoraEventSubscriber are explicitly re-exported here export * from "./signers/freighter.js"; export * from "./queue.js"; +export * from "./classic.js"; + From a2190f3bff0beb6c16d5277e53f18371f5b8c367 Mon Sep 17 00:00:00 2001 From: Mosas2000 Date: Fri, 28 Aug 2026 12:54:22 +0100 Subject: [PATCH 4/4] feat(sdk): add contract version capability check and surface version mismatch error --- packages/codegen/generate.ts | 23 ++++++++- packages/sdk/src/__tests__/version.test.ts | 55 ++++++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packages/sdk/src/__tests__/version.test.ts diff --git a/packages/codegen/generate.ts b/packages/codegen/generate.ts index 19dc7851..6e91b98b 100644 --- a/packages/codegen/generate.ts +++ b/packages/codegen/generate.ts @@ -272,7 +272,7 @@ function generateClient( " Account,", " Keypair,", '} from "@stellar/stellar-sdk";', - 'import { NotFoundError, mapError } from "../errors";', + 'import { NotFoundError, VersionMismatchError, mapError } from "../errors";', ...(knownTypeNames.length > 0 ? [`import type { ${knownTypeNames.join(", ")} } from "./types";`, ""] : [""]), @@ -311,6 +311,27 @@ function generateClient( lines.push(' this.allowHttp = config.allowHttp ?? config.rpcUrl.startsWith("http://");'); lines.push(" }"); lines.push(""); + lines.push(" async getContractVersion(): Promise {"); + lines.push(" try {"); + lines.push(' const retval = await this.simulateCall("version");'); + lines.push(' if (!retval) return "unknown";'); + lines.push(' return (scValToNative(retval) as string) ?? "unknown";'); + lines.push(" } catch {"); + lines.push(' return "unknown";'); + lines.push(" }"); + lines.push(" }"); + lines.push(""); + lines.push(" async verifyContractVersion(expectedVersion: string): Promise {"); + lines.push(" const actualVersion = await this.getContractVersion();"); + lines.push(' if (actualVersion !== "unknown" && actualVersion !== expectedVersion) {'); + lines.push(" throw new VersionMismatchError("); + lines.push(' `Contract version mismatch: expected "${expectedVersion}", got "${actualVersion}".`,'); + lines.push(" { expected: expectedVersion, actual: actualVersion }"); + lines.push(" );"); + lines.push(" }"); + lines.push(" return true;"); + lines.push(" }"); + lines.push(""); // simulateCall helper lines.push( diff --git a/packages/sdk/src/__tests__/version.test.ts b/packages/sdk/src/__tests__/version.test.ts new file mode 100644 index 00000000..5f1f943f --- /dev/null +++ b/packages/sdk/src/__tests__/version.test.ts @@ -0,0 +1,55 @@ +import { LinkoraClient } from "../client.js"; +import { VersionMismatchError } from "../errors.js"; + +const mockSimulate = jest.fn(); + +jest.mock("@stellar/stellar-sdk/rpc", () => { + const original = jest.requireActual("@stellar/stellar-sdk/rpc"); + return { + ...original, + Server: jest.fn().mockImplementation(() => ({ + simulateTransaction: mockSimulate, + })), + }; +}); + +describe("Issue #1362: SDK contract version capability check", () => { + let client: LinkoraClient; + + beforeEach(() => { + jest.clearAllMocks(); + client = new LinkoraClient({ + contractId: "CDUMMY", + rpcUrl: "https://rpc.example.com", + }); + }); + + it("returns unknown when contract does not implement version method", async () => { + mockSimulate.mockResolvedValueOnce({ error: "Method not found" }); + const version = await client.getContractVersion(); + expect(version).toBe("unknown"); + }); + + it("returns contract version string when version method succeeds", async () => { + mockSimulate.mockResolvedValueOnce({ + result: { retval: { _type: "scval", _val: "1.2.0" } }, + }); + const version = await client.getContractVersion(); + expect(version).toBe("1.2.0"); + }); + + it("verifies contract version successfully when version matches", async () => { + mockSimulate.mockResolvedValueOnce({ + result: { retval: { _type: "scval", _val: "1.2.0" } }, + }); + const ok = await client.verifyContractVersion("1.2.0"); + expect(ok).toBe(true); + }); + + it("throws VersionMismatchError when contract version differs", async () => { + mockSimulate.mockResolvedValueOnce({ + result: { retval: { _type: "scval", _val: "0.9.0" } }, + }); + await expect(client.verifyContractVersion("1.2.0")).rejects.toThrow(VersionMismatchError); + }); +});