diff --git a/cli/src/__tests__/e2e-testnet.test.ts b/cli/src/__tests__/e2e-testnet.test.ts new file mode 100644 index 0000000..6524ee7 --- /dev/null +++ b/cli/src/__tests__/e2e-testnet.test.ts @@ -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 + ); +}); diff --git a/cli/src/__tests__/mocks.ts b/cli/src/__tests__/mocks.ts index 89570d8..737e7e4 100644 --- a/cli/src/__tests__/mocks.ts +++ b/cli/src/__tests__/mocks.ts @@ -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)); diff --git a/cli/src/__tests__/smoke.test.ts b/cli/src/__tests__/smoke.test.ts index 640b14d..8525fb8 100644 --- a/cli/src/__tests__/smoke.test.ts +++ b/cli/src/__tests__/smoke.test.ts @@ -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"); + }); + }); }); diff --git a/cli/src/__tests__/soroban-tx.test.ts b/cli/src/__tests__/soroban-tx.test.ts new file mode 100644 index 0000000..976b5c1 --- /dev/null +++ b/cli/src/__tests__/soroban-tx.test.ts @@ -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 { + 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" }); + }); +}); diff --git a/cli/src/__tests__/upgrade.test.ts b/cli/src/__tests__/upgrade.test.ts index 78338d6..82a6b78 100644 --- a/cli/src/__tests__/upgrade.test.ts +++ b/cli/src/__tests__/upgrade.test.ts @@ -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", () => { diff --git a/cli/src/commands/smoke-test.ts b/cli/src/commands/smoke-test.ts index 11da527..f7608f4 100644 --- a/cli/src/commands/smoke-test.ts +++ b/cli/src/commands/smoke-test.ts @@ -8,6 +8,22 @@ import { rpc as SorobanRpcNs, } from "@stellar/stellar-sdk"; import { addNetworkOptions } from "../network.js"; +import { prepareSignAndSubmit, type PrepareSignSubmitResult } from "../utils/soroban-tx.js"; + +function describeSubmitOutcome(outcome: PrepareSignSubmitResult): string { + switch (outcome.outcome) { + case "simulation_failed": + return `simulation failed: ${outcome.error}`; + case "submission_failed": + return `submission failed: ${outcome.error}`; + case "failed_on_ledger": + return `transaction ${outcome.hash} failed on-ledger`; + case "timed_out": + return `timed out waiting for confirmation of ${outcome.hash} (last status: ${outcome.lastStatus})`; + case "confirmed": + return `confirmed (${outcome.hash})`; + } +} export interface SmokeTestOptions { contractId: string; @@ -116,14 +132,17 @@ export async function runSmokeTest( .setTimeout(30) .build(); - const mintResult = await server.sendTransaction(mintTx); - if (mintResult.status === "ERROR") { + const mintOutcome = await prepareSignAndSubmit(server, mintTx, sourceKeypair, { + deadline: startTime + timeout, + }); + if (mintOutcome.outcome !== "confirmed") { return { success: false, sequence, - message: `Mint failed: ${JSON.stringify(mintResult.errorResult)}`, + message: `Mint failed: ${describeSubmitOutcome(mintOutcome)}`, }; } + const mintHash = mintOutcome.hash; sequence.push("mint_ok"); // 4. Check balance after mint @@ -161,14 +180,17 @@ export async function runSmokeTest( .setTimeout(30) .build(); - const transferResult = await server.sendTransaction(transferTx); - if (transferResult.status === "ERROR") { + const transferOutcome = await prepareSignAndSubmit(server, transferTx, sourceKeypair, { + deadline: startTime + timeout, + }); + if (transferOutcome.outcome !== "confirmed") { return { success: false, sequence, - message: `Transfer failed: ${JSON.stringify(transferResult.errorResult)}`, + message: `Transfer failed: ${describeSubmitOutcome(transferOutcome)}`, }; } + const transferHash = transferOutcome.hash; sequence.push("transfer_ok"); // 7. Final balance check @@ -190,8 +212,8 @@ export async function runSmokeTest( message: `Smoke test passed: minted ${amount}, transferred to ${recipientAddress.slice(0, 8)}…`, details: { balanceBefore, - transferHash: transferResult.hash, - mintHash: mintResult.hash, + transferHash, + mintHash, }, }; } catch (err) { diff --git a/cli/src/commands/upgrade.ts b/cli/src/commands/upgrade.ts index ff5f7d5..dfd92ae 100644 --- a/cli/src/commands/upgrade.ts +++ b/cli/src/commands/upgrade.ts @@ -8,6 +8,7 @@ import { rpc as SorobanRpcNs, } from "@stellar/stellar-sdk"; import { addNetworkOptions } from "../network.js"; +import { prepareSignAndSubmit } from "../utils/soroban-tx.js"; export interface FeeEstimate { baseFee: string; @@ -33,6 +34,7 @@ export interface UpgradeOptions { proposalId?: string; dryRun?: boolean; estimate?: boolean; + timeout?: number; } export function createUpgradeCommand(): Command { @@ -50,6 +52,11 @@ export function createUpgradeCommand(): Command { "--estimate", "Dry-run to estimate total fee cost without submitting", false + ) + .option( + "--timeout ", + "Timeout in milliseconds to wait for on-chain confirmation", + "30000" ); addNetworkOptions(cmd); @@ -146,22 +153,48 @@ export async function runUpgrade(opts: UpgradeOptions): Promise { }; } - // 6. Submit on-chain - const result = await server.sendTransaction(tx); + // 6. Submit on-chain (simulate + assemble fee/footprint, sign, submit, await confirmation) + const timeout = Number(opts.timeout) || 30000; + const outcome = await prepareSignAndSubmit(server, tx, sourceKeypair, { + deadline: Date.now() + timeout, + }); - if (result.status === "ERROR") { - return { - success: false, - message: `Transaction submission failed: ${JSON.stringify(result.errorResult)}`, - }; + switch (outcome.outcome) { + case "simulation_failed": + return { + success: false, + wasmHash, + message: `Simulation failed: ${outcome.error}`, + }; + case "submission_failed": + return { + success: false, + wasmHash, + txHash: outcome.hash, + message: `Transaction submission failed: ${outcome.error}`, + }; + case "failed_on_ledger": + return { + success: false, + wasmHash, + txHash: outcome.hash, + message: `Upgrade transaction failed on-ledger. Hash: ${outcome.hash}`, + }; + case "timed_out": + return { + success: false, + wasmHash, + txHash: outcome.hash, + message: `Timed out after ${timeout}ms waiting for confirmation. Hash: ${outcome.hash} (last status: ${outcome.lastStatus})`, + }; + case "confirmed": + return { + success: true, + txHash: outcome.hash, + wasmHash, + message: `Upgrade transaction submitted. Hash: ${outcome.hash}`, + }; } - - return { - success: true, - txHash: result.hash, - wasmHash, - message: `Upgrade transaction submitted. Hash: ${result.hash}`, - }; } catch (err) { return { success: false, diff --git a/cli/src/utils/soroban-tx.ts b/cli/src/utils/soroban-tx.ts new file mode 100644 index 0000000..0dcc2d2 --- /dev/null +++ b/cli/src/utils/soroban-tx.ts @@ -0,0 +1,95 @@ +import type { Keypair } from "@stellar/stellar-sdk"; + +/** + * Minimal surface of `SorobanRpc.Server` needed to prepare, sign, submit, and + * confirm a contract-invoking transaction. Kept narrow so tests can supply a + * lightweight mock instead of a real RPC client. + */ +export interface SorobanSubmitServer { + prepareTransaction(tx: any): Promise; + sendTransaction(tx: any): Promise<{ status: string; hash: string; errorResult?: unknown }>; + getTransaction(hash: string): Promise<{ status: string }>; +} + +export interface PrepareSignSubmitOptions { + /** Interval between confirmation polls, in ms. Default 1000. */ + pollIntervalMs?: number; + /** Absolute `Date.now()`-based deadline; polling stops once reached. */ + deadline: number; +} + +export type PrepareSignSubmitResult = + | { outcome: "simulation_failed"; error: string } + | { outcome: "submission_failed"; hash?: string; error: string } + | { outcome: "confirmed"; hash: string } + | { outcome: "failed_on_ledger"; hash: string } + | { outcome: "timed_out"; hash: string; lastStatus: string }; + +/** + * Simulates a raw transaction to attach the resource fee and footprint Soroban + * requires, signs the assembled transaction, submits it, and polls until the + * network reaches a terminal status or `deadline` passes. + * + * A hand-built `fee: "100"` and an unsigned transaction only ever "work" + * against mocks: real Soroban RPC nodes reject unsigned submissions and + * reject invocations whose fee doesn't cover the simulated resource cost. + */ +export async function prepareSignAndSubmit( + server: SorobanSubmitServer, + tx: any, + signer: Keypair, + opts: PrepareSignSubmitOptions +): Promise { + let prepared; + try { + prepared = await server.prepareTransaction(tx); + } catch (err) { + return { + outcome: "simulation_failed", + error: err instanceof Error ? err.message : String(err), + }; + } + + prepared.sign(signer); + + const sendResult = await server.sendTransaction(prepared); + if (sendResult.status === "ERROR") { + return { + outcome: "submission_failed", + hash: sendResult.hash, + error: JSON.stringify(sendResult.errorResult), + }; + } + + return pollForConfirmation(server, sendResult.hash, opts); +} + +/** + * Polls `getTransaction` until the network reports a terminal status + * (SUCCESS/FAILED) or `deadline` passes. Real testnet confirmation typically + * takes several ledgers (~5-15s), unlike the instant status a mock returns. + */ +export async function pollForConfirmation( + server: Pick, + hash: string, + opts: PrepareSignSubmitOptions +): Promise { + const pollIntervalMs = opts.pollIntervalMs ?? 1000; + + for (;;) { + const response = await server.getTransaction(hash); + + if (response.status === "SUCCESS") { + return { outcome: "confirmed", hash }; + } + if (response.status === "FAILED") { + return { outcome: "failed_on_ledger", hash }; + } + + if (Date.now() >= opts.deadline) { + return { outcome: "timed_out", hash, lastStatus: response.status }; + } + + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } +}