Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions apps/web/src/hooks/useTokenBalances.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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,
});

Expand Down
23 changes: 22 additions & 1 deletion packages/codegen/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";`, ""]
: [""]),
Expand Down Expand Up @@ -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<string> {");
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<boolean> {");
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(
Expand Down
55 changes: 55 additions & 0 deletions packages/sdk/src/__tests__/batch.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
70 changes: 70 additions & 0 deletions packages/sdk/src/__tests__/classic.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
36 changes: 35 additions & 1 deletion packages/sdk/src/__tests__/read.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
55 changes: 55 additions & 0 deletions packages/sdk/src/__tests__/version.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading