Skip to content
Merged
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
66 changes: 66 additions & 0 deletions src/soroban/resultDecoder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,72 @@ describe("contract result decoder", () => {
if (enumResult.status === "ok") expect(enumResult.data).toEqual({ type: "Some", value: 7n });
});

it("decodes the no-payload variant of an Option (#857)", () => {
const noneValue = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("None")]);
const result = decodeContractResult(noneValue, {
type: "enum",
variants: { Some: "i128", None: undefined },
});

expect(result.status).toBe("ok");
if (result.status === "ok") expect(result.data).toEqual({ type: "None" });
});

it("decodes both sides of a Result<Success, Failure> union with the discriminator intact (#857)", () => {
const schema = {
type: "enum" as const,
variants: {
Ok: { type: "struct" as const, fields: { amount: "i128" as const } },
Err: "u32" as const,
},
};

const okValue = xdr.ScVal.scvVec([
xdr.ScVal.scvSymbol("Ok"),
xdr.ScVal.scvMap([
new xdr.ScMapEntry({
key: xdr.ScVal.scvSymbol("amount"),
val: nativeToScVal(100n, { type: "i128" }),
}),
]),
]);
const okResult = decodeContractResult(okValue, schema);
expect(okResult.status).toBe("ok");
if (okResult.status === "ok") {
expect(okResult.data).toEqual({ type: "Ok", value: { amount: 100n } });
}

const errValue = xdr.ScVal.scvVec([
xdr.ScVal.scvSymbol("Err"),
xdr.ScVal.scvU32(4),
]);
const errResult = decodeContractResult(errValue, schema);
expect(errResult.status).toBe("ok");
if (errResult.status === "ok") {
expect(errResult.data).toEqual({ type: "Err", value: 4 });
}

// The two sides of the union decode to genuinely distinguishable shapes,
// not a flattened/ambiguous result that loses which arm was returned.
expect(okResult.status === "ok" && errResult.status === "ok").toBe(true);
});

it("rejects an unknown enum variant and a malformed enum vector rather than silently misdecoding (#857)", () => {
const unknownVariant = xdr.ScVal.scvVec([xdr.ScVal.scvSymbol("Pending")]);
const unknownResult = decodeContractResult(unknownVariant, {
type: "enum",
variants: { Ok: undefined, Err: "u32" },
});
expect(unknownResult.status).toBe("error");

const notAVec = nativeToScVal(5, { type: "u32" });
const malformedResult = decodeContractResult(notAVec, {
type: "enum",
variants: { Ok: undefined, Err: "u32" },
});
expect(malformedResult.status).toBe("error");
});

it("creates reusable decoders and returns errors for mismatches", () => {
const decoder = createDecoder("u32");
expect(decoder(xdr.ScVal.scvU32(9)).status).toBe("ok");
Expand Down
66 changes: 66 additions & 0 deletions src/tests/endpointFailover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ import {
import { mapHorizonError } from "../shared/horizonErrorMapper";
import { SorokitErrorCode } from "../shared/response";

function withFakeNow(startMs: number) {
const spy = vi.spyOn(Date, "now").mockReturnValue(startMs);
return {
advanceTo: (ms: number) => spy.mockReturnValue(ms),
restore: () => spy.mockRestore(),
};
}

describe("endpoint failover", () => {
it("retries a transient primary failure on the backup", async () => {
const pool = new EndpointPool([
Expand All @@ -24,6 +32,64 @@ describe("endpoint failover", () => {
expect(pool.getHealth()[0]?.healthy).toBe(false);
});

it("skips an endpoint that exceeded its failure threshold and prefers the healthy one (#855)", () => {
const pool = new EndpointPool(
["https://primary.example", "https://backup.example"],
{ failureThreshold: 2, recoveryIntervalMs: 60_000 },
);

pool.markFailure("https://primary.example");
pool.markFailure("https://primary.example");

const primaryHealth = pool
.getHealth()
.find((h) => h.endpoint === "https://primary.example");
expect(primaryHealth?.healthy).toBe(false);
expect(primaryHealth?.consecutiveFailures).toBe(2);

// With the primary unhealthy and inside its recovery window, `current`
// must select the still-healthy backup rather than cycling back to it.
expect(pool.current).toBe("https://backup.example");
expect(pool.current).toBe("https://backup.example");
});

it("recovers an unhealthy endpoint once its recovery window elapses (#855)", () => {
const clock = withFakeNow(1_000_000);
try {
const pool = new EndpointPool(
["https://primary.example", "https://backup.example"],
{ failureThreshold: 1, recoveryIntervalMs: 30_000 },
);

pool.markFailure("https://primary.example");
expect(
pool.getHealth().find((h) => h.endpoint === "https://primary.example")
?.healthy,
).toBe(false);

// Still inside the recovery window: the primary stays deprioritised.
clock.advanceTo(1_000_000 + 10_000);
expect(pool.current).toBe("https://backup.example");

// Recovery window has elapsed: the primary is eligible to be selected
// again even though its `healthy` flag hasn't been reset yet (recovery
// is a time-based retry opportunity, confirmed by a real request's
// markSuccess call, not an automatic flip).
clock.advanceTo(1_000_000 + 30_000);
const candidates = new Set<string>();
for (let i = 0; i < pool.endpoints.length; i += 1) candidates.add(pool.current);
expect(candidates.has("https://primary.example")).toBe(true);

pool.markSuccess("https://primary.example");
expect(
pool.getHealth().find((h) => h.endpoint === "https://primary.example")
?.healthy,
).toBe(true);
} finally {
clock.restore();
}
});

it("maps common Horizon response classes to stable SDK codes", () => {
expect(mapHorizonError({ status: 400, message: "tx_bad_auth" }).code)
.toBe(SorokitErrorCode.INVALID_TRANSACTION);
Expand Down
181 changes: 181 additions & 0 deletions src/tests/simulateTransactionCache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Tests for simulateTransaction's result caching (Issue #856).
*
* Verifies:
* - A repeated simulation of the same contract call (same contractId,
* method, and args) is served from cache with no second RPC call.
* - The cache key created by createSimulationCacheKey is scoped to the
* contract call identity, so a different method/args produces a miss.
* - The default TTL (5 minutes) and a custom ttlMs are both honored.
* - A simulation without a cache passed does not throw and does not cache.
*/

import {
Account,
BASE_FEE,
Contract,
Keypair,
StrKey,
TransactionBuilder,
nativeToScVal,
} from "@stellar/stellar-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createInMemoryCache } from "../shared/cache";
import { simulateTransaction } from "../soroban/simulateTransaction";

const { mockSimulateTransaction, mockIsSimulationSuccess, mockIsSimulationError } =
vi.hoisted(() => ({
mockSimulateTransaction: vi.fn(),
mockIsSimulationSuccess: vi.fn(),
mockIsSimulationError: vi.fn(),
}));

vi.mock("@stellar/stellar-sdk", async (importOriginal) => {
const actual = await importOriginal<typeof import("@stellar/stellar-sdk")>();
return {
...actual,
rpc: {
...actual.rpc,
Server: vi.fn().mockImplementation(() => ({
simulateTransaction: mockSimulateTransaction,
})),
Api: {
...actual.rpc.Api,
isSimulationError: mockIsSimulationError,
isSimulationSuccess: mockIsSimulationSuccess,
},
},
};
});

function randomContractId(): string {
return StrKey.encodeContract(Keypair.random().rawPublicKey());
}

const NETWORK_PASSPHRASE = "Test SDF Network ; September 2015";
const RPC_URL = "https://soroban-testnet.stellar.org";

function buildInvocationXdr(contractId: string, method: string, args: string[] = []) {
const source = Keypair.random();
const contract = new Contract(contractId);
const scArgs = args.map((a) => nativeToScVal(a, { type: "string" }));
const op = contract.call(method, ...scArgs);
const account = new Account(source.publicKey(), "1");
return new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(op)
.setTimeout(30)
.build()
.toXDR();
}

const MOCK_SUCCESS_RESULT = {
result: { retval: {} },
minResourceFee: "100",
transactionData: { build: () => ({ toXDR: () => "mock-resource-data" }) },
};

function setupMocks() {
mockSimulateTransaction.mockReset();
mockSimulateTransaction.mockResolvedValue(MOCK_SUCCESS_RESULT);
mockIsSimulationError.mockReset();
mockIsSimulationError.mockReturnValue(false);
mockIsSimulationSuccess.mockReset();
mockIsSimulationSuccess.mockReturnValue(true);
}

describe("simulateTransaction — cache behaviour (#856)", () => {
beforeEach(() => {
setupMocks();
});

it("caches a successful simulation — a repeated identical call is a cache hit with no second RPC call", async () => {
const cache = createInMemoryCache();
const contractId = randomContractId();
const xdr = buildInvocationXdr(contractId, "balance", ["GABC"]);

const first = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache });
expect(first.ok).toBe(true);

Check failure on line 100 in src/tests/simulateTransactionCache.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/tests/simulateTransactionCache.test.ts > simulateTransaction — cache behaviour (#856) > caches a successful simulation — a repeated identical call is a cache hit with no second RPC call

AssertionError: expected undefined to be true // Object.is equality - Expected: true + Received: undefined ❯ src/tests/simulateTransactionCache.test.ts:100:22
expect(mockSimulateTransaction).toHaveBeenCalledTimes(1);

const second = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache });
expect(second.ok).toBe(true);
expect(mockSimulateTransaction).toHaveBeenCalledTimes(1);
if (second.ok) {
expect(second.value).toEqual(first.ok ? first.value : undefined);
}
});

it("misses the cache for a different method on the same contract", async () => {
const cache = createInMemoryCache();
const contractId = randomContractId();
const balanceXdr = buildInvocationXdr(contractId, "balance", ["GABC"]);
const transferXdr = buildInvocationXdr(contractId, "transfer", ["GABC", "GXYZ", "10"]);

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, balanceXdr, { cache });
expect(mockSimulateTransaction).toHaveBeenCalledTimes(1);

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, transferXdr, { cache });
expect(mockSimulateTransaction).toHaveBeenCalledTimes(2);
});

it("misses the cache for the same method on a different contract", async () => {
const cache = createInMemoryCache();
const xdrA = buildInvocationXdr(randomContractId(), "balance", ["GABC"]);
const xdrB = buildInvocationXdr(randomContractId(), "balance", ["GABC"]);

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdrA, { cache });
expect(mockSimulateTransaction).toHaveBeenCalledTimes(1);

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdrB, { cache });
expect(mockSimulateTransaction).toHaveBeenCalledTimes(2);
});

it("uses the default TTL of 5 minutes when none is passed", async () => {
const cache = createInMemoryCache();
const setSpy = vi.spyOn(cache, "set");
const xdr = buildInvocationXdr(randomContractId(), "balance", ["GABC"]);

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache });

expect(setSpy).toHaveBeenCalledWith(expect.any(String), expect.anything(), 5 * 60 * 1000);
});

it("respects a custom ttlMs", async () => {
const cache = createInMemoryCache();
const setSpy = vi.spyOn(cache, "set");
const xdr = buildInvocationXdr(randomContractId(), "balance", ["GABC"]);

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache, ttlMs: 1000 });

expect(setSpy).toHaveBeenCalledWith(expect.any(String), expect.anything(), 1000);
});

it("does not throw and does not cache when no cache is passed", async () => {
const xdr = buildInvocationXdr(randomContractId(), "balance", ["GABC"]);

const result = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr);
expect(result.ok).toBe(true);

Check failure on line 160 in src/tests/simulateTransactionCache.test.ts

View workflow job for this annotation

GitHub Actions / Test

src/tests/simulateTransactionCache.test.ts > simulateTransaction — cache behaviour (#856) > does not throw and does not cache when no cache is passed

AssertionError: expected undefined to be true // Object.is equality - Expected: true + Received: undefined ❯ src/tests/simulateTransactionCache.test.ts:160:23

const second = await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr);
expect(second.ok).toBe(true);
// No cache supplied, so both calls must hit the RPC.
expect(mockSimulateTransaction).toHaveBeenCalledTimes(2);
});

it("invalidate() on the cache forces the next call to hit the RPC again", async () => {
const cache = createInMemoryCache();
const contractId = randomContractId();
const xdr = buildInvocationXdr(contractId, "balance", ["GABC"]);

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache });
expect(mockSimulateTransaction).toHaveBeenCalledTimes(1);

cache.clear();

await simulateTransaction(RPC_URL, NETWORK_PASSPHRASE, xdr, { cache });
expect(mockSimulateTransaction).toHaveBeenCalledTimes(2);
});
});
13 changes: 13 additions & 0 deletions src/transaction/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,19 @@ export {
formatTransactionsToJson,
} from "./exportTransactionHistory";
export { validateTransaction, validateTransactionBatch } from "./validateTransaction";
// ─── Transaction size tracking and early-warning (#858) ───────────────────────
export {
TransactionSizeTracker,
createTransactionSizeTracker,
MAX_OPERATIONS,
RECOMMENDED_MAX_OPERATIONS,
RECOMMENDED_MAX_BYTES,
} from "./transactionSizeTracker";
export type {
TransactionSizeWarning,
TransactionSizeSnapshot,
TransactionSizeTrackerOptions,
} from "./transactionSizeTracker";
export { validateTransactionOffline } from "./validateTransactionOffline";
export type { OfflineValidationIssue, OfflineValidationReport, OfflineValidationOptions } from "./validateTransactionOffline";
export {
Expand Down
Loading
Loading