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
65 changes: 65 additions & 0 deletions cli/src/__tests__/e2e-testnet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, it, expect } from "vitest";
import { runSmokeTest } from "../commands/smoke-test.js";
import { NETWORK_PRESETS } from "../network.js";

/**
* Live E2E: exercises the CLI's deployment flow against the real Stellar
* Testnet (#708). This talks to a public RPC node and needs a funded testnet
* account plus a deployed token contract it administers, so it is opt-in and
* skipped by default — a normal `npm test` (and CI) never depends on live
* network availability or funded credentials.
*
* To run locally:
* RUN_E2E_TESTNET=true \
* E2E_TESTNET_SECRET=S... \
* E2E_TOKEN_CONTRACT_ID=C... \
* npm test -- e2e-testnet
*/
const enabled = process.env.RUN_E2E_TESTNET === "true";
const secret = process.env.E2E_TESTNET_SECRET;
const contractId = process.env.E2E_TOKEN_CONTRACT_ID;

describe.skipIf(!enabled || !secret || !contractId)("CLI E2E: Testnet deployment flow (#708)", () => {
const testnet = NETWORK_PRESETS.testnet;
const rpcUrl = process.env.E2E_TESTNET_RPC_URL || testnet.rpcUrl;
const networkPassphrase = process.env.E2E_TESTNET_PASSPHRASE || testnet.networkPassphrase;

it(
"mints and transfers against a live Testnet contract, confirming within a generous budget",
async () => {
const result = await runSmokeTest({
contractId: contractId as string,
rpcUrl,
networkPassphrase,
source: secret as string,
amount: "1",
timeout: 60000,
});

expect(result.success, result.message).toBe(true);
expect(result.sequence).toEqual(
expect.arrayContaining(["mint_ok", "transfer_ok", "final_balance_ok"])
);
expect(result.details?.mintHash).toBeDefined();
expect(result.details?.transferHash).toBeDefined();
},
90000
);

it(
"reports a timeout instead of hanging when given a budget real network latency can't meet",
async () => {
const result = await runSmokeTest({
contractId: contractId as string,
rpcUrl,
networkPassphrase,
source: secret as string,
amount: "1",
timeout: 1,
});

expect(result.success).toBe(false);
},
30000
);
});
20 changes: 19 additions & 1 deletion cli/src/__tests__/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,36 @@ export interface MockServerOptions {
failMethods?: string[];
latency?: number;
simulationError?: string;
/** Number of `getTransaction` polls that report NOT_FOUND before the terminal status, simulating real confirmation latency. */
pendingPollsBeforeSuccess?: number;
/** Terminal status returned once `pendingPollsBeforeSuccess` polls have elapsed. Default SUCCESS. */
finalTransactionStatus?: "SUCCESS" | "FAILED";
}

export function createMockServer(options: MockServerOptions = {}) {
let pollCount = 0;

return {
getAccount: vi.fn(async (publicKey: string) => createMockAccount(publicKey)),
prepareTransaction: vi.fn(async (tx: any) => {
if (options.latency) await new Promise((r) => setTimeout(r, options.latency));
if (options.simulationError) {
throw new Error(options.simulationError);
}
return tx;
}),
sendTransaction: vi.fn(async () => {
if (options.latency) await new Promise((r) => setTimeout(r, options.latency));
return { status: "PENDING", hash: `mock_hash_${Date.now()}` };
}),
getTransaction: vi.fn(async (txHash: string) => {
if (options.latency) await new Promise((r) => setTimeout(r, options.latency));
return { status: "SUCCESS", hash: txHash, resultXdr: "AAAAAAA=" };
const pendingPolls = options.pendingPollsBeforeSuccess ?? 0;
if (pollCount < pendingPolls) {
pollCount++;
return { status: "NOT_FOUND", hash: txHash };
}
return { status: options.finalTransactionStatus ?? "SUCCESS", hash: txHash, resultXdr: "AAAAAAA=" };
}),
simulateTransaction: vi.fn(async () => {
if (options.latency) await new Promise((r) => setTimeout(r, options.latency));
Expand Down
54 changes: 54 additions & 0 deletions cli/src/__tests__/smoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,58 @@ describe("Smoke Test Command (#704, #706)", () => {
expect(result.success).toBe(true);
});
});

describe("network fees and latency (#708)", () => {
it("signs the mint and transfer transactions before submitting them", async () => {
const opts = baseOpts();
const result = await runSmokeTest(opts);
const { TransactionBuilder } = await import("@stellar/stellar-sdk");
// runSmokeTest builds 5 transactions per call (balance, mint, balance, transfer, balance);
// mocks aren't cleared between tests, so take only this call's slice of the mock history.
const thisCallResults = vi.mocked(TransactionBuilder).mock.results.slice(-5);
const builtTxs = thisCallResults.map((r) => r.value.build());
const signedCount = builtTxs.filter((tx) => tx.sign.mock.calls.length > 0).length;
expect(result.success).toBe(true);
// mint and transfer each simulate+sign+submit; the 3 read-only balance checks never sign
expect(signedCount).toBe(2);
});

it("fails the mint step (without submitting) when simulation/fee assembly fails", async () => {
const opts = baseOpts();
const { rpc: SorobanRpcNs } = await import("@stellar/stellar-sdk");
vi.mocked(SorobanRpcNs.Server).mockImplementationOnce(
() => createMockServer({ simulationError: "resource limit exceeded" }) as any
);

const result = await runSmokeTest(opts);
expect(result.success).toBe(false);
expect(result.message).toContain("Mint failed");
expect(result.message).toContain("resource limit exceeded");
});

it("tolerates real confirmation latency by polling past transient NOT_FOUND status", async () => {
const opts = baseOpts();
const { rpc: SorobanRpcNs } = await import("@stellar/stellar-sdk");
vi.mocked(SorobanRpcNs.Server).mockImplementationOnce(
() => createMockServer({ pendingPollsBeforeSuccess: 2 }) as any
);

const result = await runSmokeTest(opts);
expect(result.success).toBe(true);
expect(result.sequence).toContain("mint_ok");
});

it("reports a timeout instead of hanging when confirmation never arrives within the budget", async () => {
const opts = baseOpts();
opts.timeout = "5";
const { rpc: SorobanRpcNs } = await import("@stellar/stellar-sdk");
vi.mocked(SorobanRpcNs.Server).mockImplementationOnce(
() => createMockServer({ pendingPollsBeforeSuccess: 1000 }) as any
);

const result = await runSmokeTest(opts);
expect(result.success).toBe(false);
expect(result.message).toContain("timed out");
});
});
});
113 changes: 113 additions & 0 deletions cli/src/__tests__/soroban-tx.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi } from "vitest";
import {
prepareSignAndSubmit,
pollForConfirmation,
type SorobanSubmitServer,
} from "../utils/soroban-tx.js";

function createServer(overrides: Partial<SorobanSubmitServer> = {}): SorobanSubmitServer {
return {
prepareTransaction: vi.fn(async (tx: any) => tx),
sendTransaction: vi.fn(async () => ({ status: "PENDING", hash: "hash1" })),
getTransaction: vi.fn(async (hash: string) => ({ status: "SUCCESS", hash })),
...overrides,
};
}

function fakeTx() {
return { sign: vi.fn() };
}

const signer = {} as any;

describe("prepareSignAndSubmit (#708)", () => {
it("simulates, assembles fee/footprint, signs, submits, and confirms on the happy path", async () => {
const tx = fakeTx();
const server = createServer();

const result = await prepareSignAndSubmit(server, tx, signer, { deadline: Date.now() + 5000 });

expect(server.prepareTransaction).toHaveBeenCalledWith(tx);
expect(tx.sign).toHaveBeenCalledWith(signer);
expect(server.sendTransaction).toHaveBeenCalledWith(tx);
expect(result).toEqual({ outcome: "confirmed", hash: "hash1" });
});

it("never signs or submits an unsigned transaction when simulation fails", async () => {
const tx = fakeTx();
const server = createServer({
prepareTransaction: vi.fn(async () => {
throw new Error("simulation host error: budget exceeded");
}),
});

const result = await prepareSignAndSubmit(server, tx, signer, { deadline: Date.now() + 5000 });

expect(tx.sign).not.toHaveBeenCalled();
expect(server.sendTransaction).not.toHaveBeenCalled();
expect(result).toEqual({
outcome: "simulation_failed",
error: "simulation host error: budget exceeded",
});
});

it("reports submission_failed when the RPC rejects the signed transaction", async () => {
const tx = fakeTx();
const server = createServer({
sendTransaction: vi.fn(async () => ({
status: "ERROR",
hash: "hash1",
errorResult: { code: "txInsufficientFee" },
})),
});

const result = await prepareSignAndSubmit(server, tx, signer, { deadline: Date.now() + 5000 });

expect(result.outcome).toBe("submission_failed");
expect((result as any).error).toContain("txInsufficientFee");
});
});

describe("pollForConfirmation — real network latency (#708)", () => {
it("polls past transient NOT_FOUND responses until the network reports SUCCESS", async () => {
let calls = 0;
const server = {
getTransaction: vi.fn(async (hash: string) => {
calls++;
if (calls < 3) return { status: "NOT_FOUND", hash };
return { status: "SUCCESS", hash };
}),
};

const result = await pollForConfirmation(server, "hash1", {
deadline: Date.now() + 5000,
pollIntervalMs: 1,
});

expect(calls).toBe(3);
expect(result).toEqual({ outcome: "confirmed", hash: "hash1" });
});

it("reports failed_on_ledger when the network settles on FAILED", async () => {
const server = {
getTransaction: vi.fn(async (hash: string) => ({ status: "FAILED", hash })),
};

const result = await pollForConfirmation(server, "hash1", { deadline: Date.now() + 5000 });

expect(result).toEqual({ outcome: "failed_on_ledger", hash: "hash1" });
});

it("times out rather than polling forever when confirmation never arrives", async () => {
const server = {
getTransaction: vi.fn(async (hash: string) => ({ status: "NOT_FOUND", hash })),
};

const result = await pollForConfirmation(server, "hash1", {
deadline: Date.now() - 1,
pollIntervalMs: 1,
});

expect(result).toEqual({ outcome: "timed_out", hash: "hash1", lastStatus: "NOT_FOUND" });
});
});
56 changes: 56 additions & 0 deletions cli/src/__tests__/upgrade.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,62 @@ describe("Upgrade Command (#703, #706)", () => {
expect(result.wasmHash).toMatch(/^[0-9a-f]{64}$/);
expect(result.message).toContain("Upgrade transaction submitted");
});

it("signs the assembled transaction before submitting it (#708)", async () => {
const opts = baseOpts();
opts.dryRun = false;
const result = await runUpgrade(opts);
const { TransactionBuilder } = await import("@stellar/stellar-sdk");
// mocks aren't cleared between tests, so take only this call's (last) TransactionBuilder instance.
const results = vi.mocked(TransactionBuilder).mock.results;
const builtTx = results[results.length - 1].value.build();
expect(result.success).toBe(true);
expect(builtTx.sign).toHaveBeenCalled();
});

it("fails without submitting when simulation/fee assembly fails (#708)", async () => {
const opts = baseOpts();
opts.dryRun = false;

const { rpc: SorobanRpcNs } = await import("@stellar/stellar-sdk");
vi.mocked(SorobanRpcNs.Server).mockImplementationOnce(
() => createMockServer({ simulationError: "resource limit exceeded" }) as any
);

const result = await runUpgrade(opts);
expect(result.success).toBe(false);
expect(result.message).toContain("Simulation failed");
expect(result.message).toContain("resource limit exceeded");
});

it("tolerates real confirmation latency by polling past transient NOT_FOUND status (#708)", async () => {
const opts = baseOpts();
opts.dryRun = false;

const { rpc: SorobanRpcNs } = await import("@stellar/stellar-sdk");
vi.mocked(SorobanRpcNs.Server).mockImplementationOnce(
() => createMockServer({ pendingPollsBeforeSuccess: 2 }) as any
);

const result = await runUpgrade(opts);
expect(result.success).toBe(true);
expect(result.txHash).toBeDefined();
});

it("reports a timeout instead of hanging when confirmation never arrives within the budget (#708)", async () => {
const opts = baseOpts();
opts.dryRun = false;
opts.timeout = "5";

const { rpc: SorobanRpcNs } = await import("@stellar/stellar-sdk");
vi.mocked(SorobanRpcNs.Server).mockImplementationOnce(
() => createMockServer({ pendingPollsBeforeSuccess: 1000 }) as any
);

const result = await runUpgrade(opts);
expect(result.success).toBe(false);
expect(result.message).toContain("Timed out");
});
});

describe("error states", () => {
Expand Down
Loading
Loading