#205 test(e2e) setup complete end to end testing pipeline fix - #243
Conversation
|
@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! 🚀 |
|
| 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
- Understand the implications of revoking this secret by investigating where it is used in your code.
- Replace and store your secrets safely. Learn here the best practices.
- Revoke and rotate these secrets.
- 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
- following these best practices for managing and storing secrets including API keys and other credentials
- install secret detection on pre-commit to catch secret before it leaves your machine and ease remediation.
🦉 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.
…o-end-testing-pipeline-FIX
📝 WalkthroughSummary by CodeRabbit
WalkthroughAdds a ChangesE2E sandbox pipeline
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
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Poem
🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🟠 MajorRestrict plaintext RPC transport to local loopback endpoints.
Line 84 enables
allowHttpfor anyhttp://URL. Since the constructor accepts configurablecustomUrlvalues propagated from downstream flows, this currently permits unencrypted connections to arbitrary remote endpoints, exposing the application to man-in-the-middle attacks. LimitallowHttpstrictly 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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (5)
package.jsonsrc/rpc/client.tstests/e2e/README.mdtests/e2e/helpers/in-memory-soroban-sandbox.tstests/e2e/sandbox-network.test.ts
📜 Review details
⚠️ CI failures not shown inline (1)
GitHub Check: GitGuardian Security Checks: 2 secrets uncovered!
Conclusion: failure
#### 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 QualityVerify the removed Docker scripts are no longer referenced.
The automated verification for references to
docker:buildordocker:runcould 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 frompackage.json.
| 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); |
There was a problem hiding this comment.
🩺 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.
| 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.
| 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 |
There was a problem hiding this comment.
📐 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.
| beforeEach(async () => { | ||
| db = getDatabaseForTesting(); | ||
| sandbox = await InMemorySorobanSandbox.start({ initialLedger: 10_000 }); | ||
| }); |
There was a problem hiding this comment.
🎯 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.
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