Skip to content

#205 test(e2e) setup complete end to end testing pipeline fix - #243

Merged
AbdulmalikAlayande merged 3 commits into
TegoLabs:mainfrom
onakijames-droid:#205-test(e2e)--setup-complete-end-to-end-testing-pipeline-FIX
Jun 26, 2026
Merged

AbdulmalikAlayande merged 3 commits into
TegoLabs:mainfrom
onakijames-droid:#205-test(e2e)--setup-complete-end-to-end-testing-pipeline-FIX

Conversation

@onakijames-droid

Copy link
Copy Markdown
Contributor

Findings
The repository had many unit/integration tests but lacked a real tests/e2e/ suite.
The code could not connect to local HTTP sandbox RPC endpoints because allowHttp was not enabled.
Real extension/restore transaction building could fail because SorobanDataBuilder was referenced incorrectly from rpc.
getCurrentLedger() was brittle when a sandbox RPC did not support getLatestLedger() correctly.
Fix Features Added
New E2E Framework
Created:
tests/e2e/
with:
tests/e2e/README.md
tests/e2e/helpers/in-memory-soroban-sandbox.ts
tests/e2e/sandbox-network.test.ts
E2E Lifecycle Covered
The new E2E test verifies:
local sandbox starts automatically
synthetic contract is deployed
Sorokeep watches the contract
TTL drops below threshold
monitor detects threshold crossing
auto-extension submits simulated ExtendFootprintTTLOp
TTL recovers
alert is resolved
extension history is recorded
RPC Client Fixes
Updated:
src/rpc/client.ts
Fixes include:
local HTTP RPC support
direct SorobanDataBuilder import
fallback from getLatestLedger() to getHealth()
New npm Script
Updated:
package.json
Added:
"test:e2e": "vitest run tests/e2e"
CLOSE #205

@drips-wave

drips-wave Bot commented Jun 26, 2026

Copy link
Copy Markdown

@onakijames-droid Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@gitguardian

gitguardian Bot commented Jun 26, 2026

Copy link
Copy Markdown

⚠️ GitGuardian has uncovered 2 secrets following the scan of your pull request.

Please consider investigating the findings and remediating the incidents. Failure to do so may lead to compromising the associated services or software components.

Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.

🔎 Detected hardcoded secrets in your pull request
GitGuardian id GitGuardian status Secret Commit Filename
- - Generic High Entropy Secret 7efd589 tests/commands/channels.test.ts View secret
- - Generic High Entropy Secret 617e5cd tests/rpc/client.test.ts View secret
🛠 Guidelines to remediate hardcoded secrets
  1. Understand the implications of revoking this secret by investigating where it is used in your code.
  2. Replace and store your secrets safely. Learn here the best practices.
  3. Revoke and rotate these secrets.
  4. If possible, rewrite git history. Rewriting git history is not a trivial act. You might completely break other contributing developers' workflow and you risk accidentally deleting legitimate data.

To avoid such incidents in the future consider


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

@coderabbitai

coderabbitai Bot commented Jun 26, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added a new end-to-end test command for running the E2E suite.
    • Introduced a local sandbox environment for testing contract and ledger behavior.
  • Bug Fixes

    • Improved RPC client handling for HTTP connections and ledger lookups, making fallback behavior more reliable.
  • Tests

    • Added coverage for TTL extension and alert resolution flows in the sandbox network scenario.

Walkthrough

Adds a test:e2e script, updates the Stellar RPC client for HTTP sandbox URLs and fallback ledger lookup, introduces an in-memory Soroban sandbox with JSON-RPC TTL handling, and adds an E2E test that exercises contract extension and alert resolution.

Changes

E2E sandbox pipeline

Layer / File(s) Summary
E2E command and RPC client
package.json, src/rpc/client.ts
Adds test:e2e, removes docker:build and docker:run, enables HTTP RPC URLs, falls back from getLatestLedger(), and instantiates SorobanDataBuilder directly in extension and restore paths.
Sandbox state and startup
tests/e2e/README.md, tests/e2e/helpers/in-memory-soroban-sandbox.ts
Defines sandbox deployment types, in-memory ledger and transaction state, startup and shutdown, contract deployment, ledger advancement, TTL inspection, and RPC URL publication.
Sandbox JSON-RPC and ledger reads
tests/e2e/README.md, tests/e2e/helpers/in-memory-soroban-sandbox.ts
Implements JSON-RPC request parsing and dispatch, getLedgerEntries, account entry synthesis, and request/response helpers.
Transaction simulation and submission
tests/e2e/README.md, tests/e2e/helpers/in-memory-soroban-sandbox.ts
Implements Soroban transaction parsing, simulation, footprint TTL extension, transaction polling, and success XDR builders.
TTL lifecycle scenario
tests/e2e/sandbox-network.test.ts
Bootstraps the database and sandbox, deploys a TTL-limited contract, configures watching and extension policy, advances ledgers, runs monitor and extension cycles, and asserts TTL and alert state changes.

Sequence Diagram(s)

sequenceDiagram
  participant Test as "tests/e2e/sandbox-network.test.ts"
  participant Sandbox as InMemorySorobanSandbox
  participant Handle as handleRequest
  participant Read as getLedgerEntries
  participant Submit as sendTransaction
  participant Poll as getTransaction

  Test->>Sandbox: start() / deployTestContract()
  Test->>Handle: JSON-RPC getLedgerEntries
  Handle->>Read: ledger key XDRs
  Read-->>Handle: live entries
  Test->>Handle: JSON-RPC sendTransaction
  Handle->>Submit: extendFootprintTtl envelope XDR
  Submit-->>Handle: PENDING hash
  Test->>Handle: JSON-RPC getTransaction
  Handle->>Poll: sha256 hash
  Poll-->>Handle: SUCCESS result/meta XDR
  Handle-->>Test: JSON-RPC response
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 I hopped through ledgers, bright and keen,
With Soroban scripts in test-run sheen.
A sandbox burrow lit the way,
TTLs leapt far by end of day,
And every alert hopped home to stay.

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The removal of docker:build and docker:run is not part of the stated E2E pipeline requirements. Keep the docker script removals separate or justify them explicitly if they are needed for the new E2E workflow.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific and matches the main change: setting up an end-to-end testing pipeline.
Description check ✅ Passed The description clearly describes the E2E framework, client fixes, and new test script in this PR.
Linked Issues check ✅ Passed The PR adds the requested E2E workspace, automated sandbox flow, recovery test, and supporting RPC/script changes.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/rpc/client.ts (1)

78-84: 🔒 Security & Privacy | 🟠 Major

Restrict plaintext RPC transport to local loopback endpoints.

Line 84 enables allowHttp for any http:// URL. Since the constructor accepts configurable customUrl values propagated from downstream flows, this currently permits unencrypted connections to arbitrary remote endpoints, exposing the application to man-in-the-middle attacks. Limit allowHttp strictly to loopback addresses (127.0.0.1, localhost, ::1) and reject remote HTTP URLs.

Proposed fix
     constructor(network: string, customUrl?: string) {
         this.network = network;
         const url = customUrl ?? RPC_URLS[network];
         if (!url) {
             throw new Error(`Unknown network "${network}". Use "testnet", "mainnet", or provide a custom URL.`);
         }
-        this.server = new rpc.Server(url, { allowHttp: url.startsWith("http://") });
+        const parsed = new URL(url);
+        const isLoopbackHttp = parsed.protocol === "http:"
+            && ["127.0.0.1", "localhost", "::1"].includes(parsed.hostname);
+        if (parsed.protocol === "http:" && !isLoopbackHttp) {
+            throw new Error("Plain HTTP RPC is only supported for local sandbox endpoints.");
+        }
+        this.server = new rpc.Server(url, { allowHttp: isLoopbackHttp });
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/rpc/client.ts` around lines 78 - 84, The RPC client constructor currently
enables plaintext transport for any http:// URL, which should be restricted to
local loopback only. Update the logic in the constructor of RpcClient so
allowHttp is set only when the resolved URL points to 127.0.0.1, localhost, or
::1, and reject any other http:// customUrl values before creating the
rpc.Server. Keep the existing unknown-network validation, but ensure remote HTTP
endpoints cannot pass through this path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/e2e/helpers/in-memory-soroban-sandbox.ts`:
- Around line 117-133: `handleRequest()` currently awaits `readBody()` before
any error handling, while the server callback in `listen()` drops the returned
promise with `void`, so aborted or failing request streams can leave responses
hanging and trigger unhandled rejections. Wrap the request body read in
`handleRequest()` with its own try/catch (or move the `readBody()` call inside
the existing JSON-RPC error handling path) and ensure failures always write an
error response via `writeJson`, so the `createServer` callback never leaves a
rejected promise unobserved.

In `@tests/e2e/README.md`:
- Around line 1-349: This file is a copied helper implementation, not a README,
so replace the InMemorySorobanSandbox/fundedSandboxKeypair content with real
Markdown workspace documentation. Add a brief overview of the E2E workspace and
point readers to the existing helper in
tests/e2e/helpers/in-memory-soroban-sandbox.ts instead of duplicating its
implementation here. Keep this file as docs only so the sandbox logic has a
single source of truth.

In `@tests/e2e/sandbox-network.test.ts`:
- Around line 20-23: The E2E setup in beforeEach is starting
InMemorySorobanSandbox directly, which bypasses the auto-start path this suite
should validate. Update the test setup to exercise the same production startup
flow used by the sandbox discovery/launch logic instead of constructing the
helper yourself, and keep getDatabaseForTesting plus the existing test
assertions wired against that path.

---

Outside diff comments:
In `@src/rpc/client.ts`:
- Around line 78-84: The RPC client constructor currently enables plaintext
transport for any http:// URL, which should be restricted to local loopback
only. Update the logic in the constructor of RpcClient so allowHttp is set only
when the resolved URL points to 127.0.0.1, localhost, or ::1, and reject any
other http:// customUrl values before creating the rpc.Server. Keep the existing
unknown-network validation, but ensure remote HTTP endpoints cannot pass through
this path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e66652a4-a220-47ba-848a-16a19742b200

📥 Commits

Reviewing files that changed from the base of the PR and between f87c0cb and e429bd8.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (5)
  • package.json
  • src/rpc/client.ts
  • tests/e2e/README.md
  • tests/e2e/helpers/in-memory-soroban-sandbox.ts
  • tests/e2e/sandbox-network.test.ts
📜 Review details
⚠️ CI failures not shown inline (1)

GitHub Check: GitGuardian Security Checks: 2 secrets uncovered!

Conclusion: failure

View job details

#### 2 secrets were uncovered from the scan of 48 commits in your pull request. ❌
Please have a look to GitGuardian findings and remediate in order to secure your code.
Since your pull request originates from a forked repository, GitGuardian is not able to associate the secrets uncovered with secret incidents on your GitGuardian dashboard.
Skipping this check run and merging your pull request will create secret incidents on your GitGuardian dashboard.
### 🔎 Detected hardcoded secrets in your pull request
-   Pull request `#243`: `#205-test(e2e)--setup-complete-end-to-end-testing-pipeline-FIX` 👉 `main` (3 commits)
-   Pull request `#242`: `main` 👉 `main` (2 commits)
-   Pull request `#241`: `feat/mcp-list-watched-contracts` 👉 `main` (1 commit)
-   Pull request `#239`: `test/db-migrations-coverage` 👉 `main` (2 commits)
-   Pull request `#234`: `main` 👉 `main` (3 commits)
-   Pull request `#230`: `main` 👉 `main` (2 commits)
-   Pull request `#229`: `feat/bad-sequence-recovery` 👉 `main` (2 commits)
-   Pull request `#228`: `feat/library-exports` 👉 `main` (2 commits)
-   Pull request `#227`: `feat/systemd-service` 👉 `main` (2 commits)
-   Pull request `#225`: `feat/channel-account-pool` 👉 `main` (2 commits)
-   Pull request `#224`: `feat/channels-command` 👉 `main` (2 commits)
-   Pull request `#223`: `cursor/get-extension-costs-mcp-tool-1f25` 👉 `main` (2 commits)
-   Pull request `#222`: `docs/contributing-guide-and-adrs` 👉 `main` (2 commits)
-   Pull request `#221`: `jay` 👉 `main` (2 commits)
-   Pull request `#220`: `feat/core-contract-introspection` 👉 `main` (4 commits)
-   Pull request `#218`: `feat/daemon-introspection-rescan` 👉 `main` (3 commits)
-   Pull request `#217`: `completed` 👉 `main` (2 commits)
-   Pull request `#216`: `feat/fee-bump-sponsorship` 👉 `main` (2 commits)
-   Pull request `#215`: `main` 👉 `main` (2 commits)
-   Pull request `#214`: `done` 👉 `main` (2 commits)
-   Pull request `#211`: `Cost` 👉 `main` (3 commits)
-   Pull request `#210`: `feature/cost-anomaly-detec...
🧰 Additional context used
🪛 markdownlint-cli2 (0.22.1)
tests/e2e/README.md

[warning] 1-1: First line in a file should be a top-level heading

(MD041, first-line-heading, first-line-h1)


[warning] 349-349: Files should end with a single newline character

(MD047, single-trailing-newline)

🔇 Additional comments (1)
package.json (1)

15-16: 📐 Maintainability & Code Quality

Verify the removed Docker scripts are no longer referenced.

The automated verification for references to docker:build or docker:run could not execute because the sandbox lacks authenticated access to the repository. Consequently, it cannot determine if documentation or CI workflows still rely on these removed scripts.

Manually inspect files like README.md, .github/workflows/*, and other guides to ensure no stale references remain after removing these scripts from package.json.

Comment on lines +117 to +133
private async listen(): Promise<void> {
this.server = createServer((request, response) => {
void this.handleRequest(request, response);
});

await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
const address = this.server.address() as AddressInfo;
this.rpcUrl = `http://127.0.0.1:${address.port}`;
}

private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method !== "POST") {
this.writeJson(response, 405, { error: "Only POST is supported" });
return;
}

const body = await this.readBody(request);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Catch request-stream failures before dropping the promise.

readBody() can reject on aborted/erroring requests, but it's awaited before the JSON-RPC try/catch, and the server callback discards the resulting promise with void. That leaves the response hanging and can surface as an unhandled rejection that flakes the E2E suite.

Proposed fix
     private async listen(): Promise<void> {
         this.server = createServer((request, response) => {
-            void this.handleRequest(request, response);
+            void this.handleRequest(request, response).catch((error) => {
+                if (!response.headersSent) {
+                    this.writeJson(response, 500, {
+                        error: error instanceof Error ? error.message : String(error),
+                    });
+                    return;
+                }
+                response.destroy(error instanceof Error ? error : new Error(String(error)));
+            });
         });
-        const body = await this.readBody(request);
-        let payload: { id?: unknown; method?: string; params?: any };
         try {
+            const body = await this.readBody(request);
+            let payload: { id?: unknown; method?: string; params?: any };
             payload = JSON.parse(body);
-        } catch {
-            this.writeJson(response, 400, { error: "Invalid JSON" });
+            const result = this.dispatch(payload.method, payload.params ?? {});
+            this.writeJson(response, 200, { jsonrpc: "2.0", id: payload.id ?? 1, result });
+        } catch (error) {
+            this.writeJson(response, 400, {
+                error: error instanceof Error ? error.message : "Invalid request",
+            });
             return;
         }
-
-        try {
-            const result = this.dispatch(payload.method, payload.params ?? {});
-            this.writeJson(response, 200, { jsonrpc: "2.0", id: payload.id ?? 1, result });
-        } catch (error) {
-            this.writeJson(response, 200, {
-                jsonrpc: "2.0",
-                id: payload.id ?? 1,
-                error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
-            });
-        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
private async listen(): Promise<void> {
this.server = createServer((request, response) => {
void this.handleRequest(request, response);
});
await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
const address = this.server.address() as AddressInfo;
this.rpcUrl = `http://127.0.0.1:${address.port}`;
}
private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method !== "POST") {
this.writeJson(response, 405, { error: "Only POST is supported" });
return;
}
const body = await this.readBody(request);
private async listen(): Promise<void> {
this.server = createServer((request, response) => {
void this.handleRequest(request, response).catch((error) => {
if (!response.headersSent) {
this.writeJson(response, 500, {
error: error instanceof Error ? error.message : String(error),
});
return;
}
response.destroy(error instanceof Error ? error : new Error(String(error)));
});
});
await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
const address = this.server.address() as AddressInfo;
this.rpcUrl = `http://127.0.0.1:${address.port}`;
}
private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method !== "POST") {
this.writeJson(response, 405, { error: "Only POST is supported" });
return;
}
try {
const body = await this.readBody(request);
let payload: { id?: unknown; method?: string; params?: any };
payload = JSON.parse(body);
const result = this.dispatch(payload.method, payload.params ?? {});
this.writeJson(response, 200, { jsonrpc: "2.0", id: payload.id ?? 1, result });
} catch (error) {
this.writeJson(response, 400, {
error: error instanceof Error ? error.message : "Invalid request",
});
return;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/helpers/in-memory-soroban-sandbox.ts` around lines 117 - 133,
`handleRequest()` currently awaits `readBody()` before any error handling, while
the server callback in `listen()` drops the returned promise with `void`, so
aborted or failing request streams can leave responses hanging and trigger
unhandled rejections. Wrap the request body read in `handleRequest()` with its
own try/catch (or move the `readBody()` call inside the existing JSON-RPC error
handling path) and ensure failures always write an error response via
`writeJson`, so the `createServer` callback never leaves a rejected promise
unobserved.

Comment thread tests/e2e/README.md
Comment on lines +1 to +349
import { createHash } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { AddressInfo } from "node:net";
import {
Contract,
Keypair,
StrKey,
xdr,
} from "@stellar/stellar-sdk";

const SANDBOX_PASSPHRASE = "Sorokeep E2E Sandbox Network ; June 2026";

interface SandboxEntry {
key: xdr.LedgerKey;
val: xdr.LedgerEntryData;
liveUntilLedgerSeq: number;
lastModifiedLedgerSeq: number;
}

interface SubmittedTransaction {
hash: string;
ledger: number;
envelopeXdr: string;
}

export interface SandboxDeployment {
contractId: string;
wasmHashHex: string;
instanceKeyXdr: string;
wasmKeyXdr: string;
}

export class InMemorySorobanSandbox {
private server: Server | undefined;
private entries = new Map<string, SandboxEntry>();
private submittedTransactions = new Map<string, SubmittedTransaction>();

latestLedger: number;
rpcUrl = "";

private constructor(initialLedger: number) {
this.latestLedger = initialLedger;
}

static async start(options?: { initialLedger?: number }): Promise<InMemorySorobanSandbox> {
const sandbox = new InMemorySorobanSandbox(options?.initialLedger ?? 1000);
await sandbox.listen();
return sandbox;
}

async stop(): Promise<void> {
if (!this.server) return;
await new Promise<void>((resolve, reject) => {
this.server!.close((err) => err ? reject(err) : resolve());
});
this.server = undefined;
}

deployTestContract(options?: { ttlLedgers?: number; contractSeedByte?: number }): SandboxDeployment {
const ttlLedgers = options?.ttlLedgers ?? 6;
const seed = options?.contractSeedByte ?? 1;
const contractId = StrKey.encodeContract(Buffer.alloc(32, seed));
const contract = new Contract(contractId);
const instanceKey = contract.getFootprint();
const instanceKeyData = instanceKey.contractData();
const wasmHash = Buffer.alloc(32, seed + 1);

const instance = new xdr.ScContractInstance({
executable: xdr.ContractExecutable.contractExecutableWasm(wasmHash),
storage: null,
});
const instanceData = xdr.LedgerEntryData.contractData(new xdr.ContractDataEntry({
ext: new xdr.ExtensionPoint(0),
contract: instanceKeyData.contract(),
key: instanceKeyData.key(),
durability: xdr.ContractDataDurability.persistent(),
val: xdr.ScVal.scvContractInstance(instance),
}));

const wasmKey = xdr.LedgerKey.contractCode(new xdr.LedgerKeyContractCode({ hash: wasmHash }));
const wasmData = xdr.LedgerEntryData.contractCode(new xdr.ContractCodeEntry({
ext: new xdr.ContractCodeEntryExt(0),
hash: wasmHash,
code: Buffer.from("0061736d01000000", "hex"),
}));

this.putEntry(instanceKey, instanceData, ttlLedgers);
this.putEntry(wasmKey, wasmData, ttlLedgers + 1);

return {
contractId,
wasmHashHex: wasmHash.toString("hex"),
instanceKeyXdr: instanceKey.toXDR("base64"),
wasmKeyXdr: wasmKey.toXDR("base64"),
};
}

advanceLedgers(count: number): void {
if (count < 0) throw new Error("Cannot move sandbox ledger backwards");
this.latestLedger += count;
}

remainingTtl(entryKeyXdr: string): number | undefined {
const entry = this.entries.get(entryKeyXdr);
return entry ? entry.liveUntilLedgerSeq - this.latestLedger : undefined;
}

private putEntry(key: xdr.LedgerKey, val: xdr.LedgerEntryData, ttlLedgers: number): void {
this.entries.set(key.toXDR("base64"), {
key,
val,
liveUntilLedgerSeq: this.latestLedger + ttlLedgers,
lastModifiedLedgerSeq: this.latestLedger,
});
}

private async listen(): Promise<void> {
this.server = createServer((request, response) => {
void this.handleRequest(request, response);
});

await new Promise<void>((resolve) => this.server!.listen(0, "127.0.0.1", resolve));
const address = this.server.address() as AddressInfo;
this.rpcUrl = `http://127.0.0.1:${address.port}`;
}

private async handleRequest(request: IncomingMessage, response: ServerResponse): Promise<void> {
if (request.method !== "POST") {
this.writeJson(response, 405, { error: "Only POST is supported" });
return;
}

const body = await this.readBody(request);
let payload: { id?: unknown; method?: string; params?: any };
try {
payload = JSON.parse(body);
} catch {
this.writeJson(response, 400, { error: "Invalid JSON" });
return;
}

try {
const result = this.dispatch(payload.method, payload.params ?? {});
this.writeJson(response, 200, { jsonrpc: "2.0", id: payload.id ?? 1, result });
} catch (error) {
this.writeJson(response, 200, {
jsonrpc: "2.0",
id: payload.id ?? 1,
error: { code: -32000, message: error instanceof Error ? error.message : String(error) },
});
}
}

private dispatch(method: string | undefined, params: any): unknown {
switch (method) {
case "getHealth":
return {
status: "healthy",
latestLedger: this.latestLedger,
oldestLedger: Math.max(1, this.latestLedger - 1000),
ledgerRetentionWindow: 1000,
};
case "getNetwork":
return {
passphrase: SANDBOX_PASSPHRASE,
protocolVersion: "23",
};
case "getLedgerEntries":
return this.getLedgerEntries(params.keys ?? []);
case "simulateTransaction":
return this.simulateTransaction(params.transaction);
case "sendTransaction":
return this.sendTransaction(params.transaction);
case "getTransaction":
return this.getTransaction(params.hash);
default:
throw new Error(`Unsupported sandbox RPC method: ${method}`);
}
}

private getLedgerEntries(keyXdrs: string[]): unknown {
const entries = keyXdrs.flatMap((keyXdr) => {
const key = xdr.LedgerKey.fromXDR(keyXdr, "base64");

if (key.switch().name === "account") {
return [this.accountLedgerEntry(key)];
}

const entry = this.entries.get(keyXdr);
if (!entry || entry.liveUntilLedgerSeq <= this.latestLedger) return [];

return [{
key: entry.key.toXDR("base64"),
xdr: entry.val.toXDR("base64"),
lastModifiedLedgerSeq: entry.lastModifiedLedgerSeq,
liveUntilLedgerSeq: entry.liveUntilLedgerSeq,
}];
});

return { latestLedger: this.latestLedger, entries };
}

private accountLedgerEntry(key: xdr.LedgerKey): unknown {
const accountId = key.account().accountId();
const account = new xdr.AccountEntry({
accountId,
balance: xdr.Int64.fromString("100000000000"),
seqNum: xdr.Int64.fromString("123456789") as any,
numSubEntries: 0,
inflationDest: null,
flags: 0,
homeDomain: "",
thresholds: Buffer.from([1, 1, 1, 1]),
signers: [],
ext: new xdr.AccountEntryExt(0),
});

return {
key: key.toXDR("base64"),
xdr: xdr.LedgerEntryData.account(account).toXDR("base64"),
lastModifiedLedgerSeq: this.latestLedger,
liveUntilLedgerSeq: this.latestLedger + 1_000_000,
};
}

private simulateTransaction(transactionXdr: string): unknown {
const sorobanData = this.sorobanDataFromTransaction(transactionXdr);
return {
id: "1",
latestLedger: this.latestLedger,
transactionData: sorobanData.toXDR("base64"),
minResourceFee: "100",
events: [],
results: [],
};
}

private sendTransaction(transactionXdr: string): unknown {
const envelope = xdr.TransactionEnvelope.fromXDR(transactionXdr, "base64");
const tx = envelope.v1().tx();
const operation = tx.operations()[0];
if (!operation) throw new Error("Sandbox only supports single-operation transactions");

if (operation.body().switch().name === "extendFootprintTtl") {
const extendTo = operation.body().extendFootprintTtlOp().extendTo();
const sorobanData = tx.ext().value();
if (!sorobanData || typeof (sorobanData as any).resources !== "function") {
throw new Error("Missing Soroban transaction data");
}
const footprint = (sorobanData as xdr.SorobanTransactionData).resources().footprint();
for (const key of footprint.readOnly()) {
const keyXdr = key.toXDR("base64");
const entry = this.entries.get(keyXdr);
if (!entry || entry.liveUntilLedgerSeq <= this.latestLedger) {
throw new Error(`Cannot extend missing or archived entry ${keyXdr}`);
}
entry.liveUntilLedgerSeq = this.latestLedger + extendTo;
entry.lastModifiedLedgerSeq = this.latestLedger;
}
} else if (operation.body().switch().name !== "restoreFootprint") {
throw new Error(`Unsupported sandbox operation: ${operation.body().switch().name}`);
}

this.latestLedger += 1;
const hash = createHash("sha256").update(transactionXdr).digest("hex");
this.submittedTransactions.set(hash, { hash, ledger: this.latestLedger, envelopeXdr: transactionXdr });

return {
status: "PENDING",
hash,
latestLedger: this.latestLedger,
latestLedgerCloseTime: Date.now(),
};
}

private getTransaction(hash: string): unknown {
const submitted = this.submittedTransactions.get(hash);
if (!submitted) {
return {
status: "NOT_FOUND",
txHash: hash,
latestLedger: this.latestLedger,
latestLedgerCloseTime: Date.now(),
oldestLedger: Math.max(1, this.latestLedger - 1000),
oldestLedgerCloseTime: Date.now(),
};
}

return {
status: "SUCCESS",
txHash: hash,
ledger: submitted.ledger,
latestLedger: this.latestLedger,
latestLedgerCloseTime: Date.now(),
oldestLedger: Math.max(1, this.latestLedger - 1000),
oldestLedgerCloseTime: Date.now(),
applicationOrder: 1,
feeBump: false,
envelopeXdr: submitted.envelopeXdr,
resultXdr: this.successResultXdr(),
resultMetaXdr: this.successMetaXdr(),
events: { contractEventsXdr: [], transactionEventsXdr: [] },
createdAt: new Date().toISOString(),
};
}

private sorobanDataFromTransaction(transactionXdr: string): xdr.SorobanTransactionData {
const envelope = xdr.TransactionEnvelope.fromXDR(transactionXdr, "base64");
const sorobanData = envelope.v1().tx().ext().value();
if (!sorobanData) throw new Error("Missing Soroban transaction data");
return sorobanData;
}

private successResultXdr(): string {
return new xdr.TransactionResult({
feeCharged: xdr.Int64.fromString("100"),
result: xdr.TransactionResultResult.txSuccess([]),
ext: new xdr.TransactionResultExt(0),
}).toXDR("base64");
}

private successMetaXdr(): string {
return new xdr.TransactionMeta(3, new xdr.TransactionMetaV3({
ext: new xdr.ExtensionPoint(0),
txChangesBefore: [],
operations: [],
txChangesAfter: [],
sorobanMeta: null,
})).toXDR("base64");
}

private readBody(request: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
request.on("error", reject);
});
}

private writeJson(response: ServerResponse, statusCode: number, payload: unknown): void {
response.writeHead(statusCode, { "content-type": "application/json" });
response.end(JSON.stringify(payload));
}
}

export function fundedSandboxKeypair(): Keypair {
return Keypair.random();
} No newline at end of file

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Replace this copied helper with an actual README.

tests/e2e/README.md currently contains the sandbox implementation verbatim instead of workspace documentation. That leaves the new E2E workspace without the README promised in this PR and creates a second copy of the helper that will drift from tests/e2e/helpers/in-memory-soroban-sandbox.ts. Replace this file with Markdown instructions and link to the helper instead.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 1-1: First line in a file should be a top-level heading

(MD041, first-line-heading, first-line-h1)


[warning] 349-349: Files should end with a single newline character

(MD047, single-trailing-newline)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/README.md` around lines 1 - 349, This file is a copied helper
implementation, not a README, so replace the
InMemorySorobanSandbox/fundedSandboxKeypair content with real Markdown workspace
documentation. Add a brief overview of the E2E workspace and point readers to
the existing helper in tests/e2e/helpers/in-memory-soroban-sandbox.ts instead of
duplicating its implementation here. Keep this file as docs only so the sandbox
logic has a single source of truth.

Comment on lines +20 to +23
beforeEach(async () => {
db = getDatabaseForTesting();
sandbox = await InMemorySorobanSandbox.start({ initialLedger: 10_000 });
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

This bypasses the auto-start behavior the E2E suite is supposed to prove.

Starting InMemorySorobanSandbox directly in beforeEach means the test only validates monitor/extension logic against an already-running RPC endpoint. It will not catch regressions in the code that is supposed to discover or start the local sandbox with zero manual setup, which is an explicit acceptance criterion for this PR. Drive the production startup path here instead of constructing the helper yourself.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/e2e/sandbox-network.test.ts` around lines 20 - 23, The E2E setup in
beforeEach is starting InMemorySorobanSandbox directly, which bypasses the
auto-start path this suite should validate. Update the test setup to exercise
the same production startup flow used by the sandbox discovery/launch logic
instead of constructing the helper yourself, and keep getDatabaseForTesting plus
the existing test assertions wired against that path.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

test(e2e): setup complete end-to-end testing pipeline

2 participants