Skip to content

Add layered integration test framework with simulated and real-node tiers - #314

Open
carlaKC wants to merge 4 commits into
bitcoin-dev-project:mainfrom
carlaKC:integration-tests
Open

carlaKC wants to merge 4 commits into
bitcoin-dev-project:mainfrom
carlaKC:integration-tests

Conversation

@carlaKC

@carlaKC carlaKC commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Adds an integration test framework covering sim-ln's node backends, payment modalities and configuration surface, structured as independent layers:

  • Environment layer (integration-tests/src/env/): provisions a network and emits its part of the sim.json config. Two providers: a deterministic in-process simulated graph, and a dockerized heterogeneous regtest network (bitcoind + one node each of LND, CLN, Eclair and ldk-server in a ring of announced channels, via testcontainers). Knows nothing about payments.
  • Scenario layer (src/scenario.rs): describes activity (defined/random) and config style (alias vs pubkey references, scalar vs [min, max] values). Knows nothing about provisioning.
  • Runner + assertions (src/runner.rs, src/asserts.rs): assembles real config files, drives them through the same public entry points the sim-cli binary uses, and asserts over parsing results, dispatched payments and the results CSV.

Test tiers

Simulated (tests/sim_matrix.rs, runs in the existing test CI job, ~0.2s on virtual time): the full cross-product of node reference styles and value shapes for defined activity, random activity with/without exclusions, seeded determinism, count- and time-bounded runs, untagged connector inference, and negative validation cases asserting specific errors.

Real nodes (tests/real_nodes.rs, #[ignore], new CI job / make integration-real, ~8 min): one shared network, scenarios run sequentially — defined keysends across every directed ring edge (each connector proves it sends and receives cross-implementation, with receipts checked against each destination node's own books), a multi-hop route, alias-referenced activities, and random activity.

Error resistance

Every real-node startup step polls with capped exponential backoff and names the node/step in its failure. Container logs are dumped on startup or scenario failure. Readiness keeps mining while waiting for channels because some implementations (ldk-node) broadcast funding transactions asynchronously after the open call returns.

Notes

  • ldk-server has no published image: the harness (and CI, with a docker layer cache) builds one from the upstream repo at the rev the ldk-server-client dependency already pins; SIMLN_LDK_SERVER_IMAGE overrides.
  • ACINQ ships no versioned eclair tags past 0.8.0, so eclair is pinned by digest; the image is built from the post-release dev commit and needs -Declair.allow-unsafe-startup=true (a static dev-build guard, nothing dynamic).
  • testcontainers is pinned to =0.25.0, the last release whose MSRV fits the repo's Rust 1.85 toolchain; serde_with/time are pinned in Cargo.lock for the same reason.
  • Count-bounded assertions tolerate the loss of the final payment record per activity: meeting a payment count triggers shutdown in the same instant as the last dispatch, and the results consumer's select! prefers the shutdown listener over draining pending results. Possibly worth an upstream fix (drain the channel before exiting) — happy to file separately.

Both tiers run green locally on macOS (Apple Silicon, Docker Desktop; eclair runs under Rosetta since its image is amd64-only).

🤖 Generated with Claude Code

carlaKC and others added 4 commits August 4, 2026 10:27
…ario layers

Adds a workspace crate housing an integration test framework built from
independent layers: an environment layer that provisions a network and
emits its partial sim.json config, a scenario layer that describes
payment activity and config style, a runner that assembles config files
and drives them through the same public entry points the sim-cli binary
uses, and shared assertions over the observable output. Simulated
networks run on virtual time so time-bounded scenarios complete
instantly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Covers defined activity across the cross-product of node reference
styles (alias/pubkey) and value shapes (scalar/range), random activity
with and without exclusions, seeded determinism, count- and time-bounded
runs, connector implementation inference from untagged config, and
negative validation cases.

Note that count-bounded assertions tolerate the loss of the final
payment record: meeting a payment count shuts the simulation down in the
same instant as the last dispatch, and the results consumer prefers the
shutdown signal over draining pending results.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a container-based environment provider that brings up a
heterogeneous regtest network: bitcoind plus one node each of LND, CLN,
Eclair and ldk-server (built from the upstream repository at the same
rev the client dependency pins), connected in a ring of announced
channels. All startup steps poll with capped exponential backoff, and
container logs are dumped when a node fails to come up. Because some
implementations broadcast funding transactions asynchronously after the
open call returns, readiness polling keeps mining blocks until every
channel is active rather than confirming once.

The real-node test runs scenarios sequentially against one shared
network: defined keysends across every directed ring edge with receipts
verified against each destination's own books, a multi-hop route, alias
references resolved from real node configuration, and random activity.
It is marked ignored so it only runs when requested explicitly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The simulated tier already runs under the existing build job's cargo
test invocation; the new CI job covers the real-node tier, building the
ldk-server image from the rev pinned in Cargo.lock with a docker layer
cache so repeat runs reuse it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@only1dreamgene only1dreamgene 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.

A few notes from going through the new integration-tests crate: two unchecked u64 subtractions that can panic on edge-case inputs, a non-idempotent retry pattern around channel-open RPCs that could double-open channels on a lost response, and some smaller flakiness/efficiency items inline.

peer_host: &str,
peer_port: u16,
) -> anyhow::Result<()> {
with_backoff("cln open channel", Backoff::slow(), || async {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

open_channel wraps the whole connect_peer + fund_channel sequence in with_backoff, but channel-open RPCs aren't idempotent. If the response is lost or times out after the node already accepted the request (funding tx broadcast), a retry issues a second channel-open to the same peer. Same pattern in lnd.rs's open_channel_sync, eclair.rs's open, and ldk_server.rs's open_channel. Might be worth splitting the connect step (safe to retry) from the actual open call, or checking for an existing/pending channel before retrying.

.copied()
.unwrap_or(0);
assert!(
(count - 1..=count).contains(&recorded),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

count - 1 underflows if count == 0 (u64). Not hit by current call sites, but a future caller asserting zero payments between excluded pairs would panic here instead of getting a clear assertion failure. count.saturating_sub(1) would be safer.

// Each ring destination's own node reports the keysends it received. The final payment of
// the run may still be settling (or its record lost to shutdown), hence the range.
for (_, dest) in RING {
let received = network.settled_keysend_count(dest).await? - receipts_before[dest];

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

settled_keysend_count(dest).await? - receipts_before[dest] can underflow-panic if the post-scenario count is ever lower than the pre-scenario snapshot (e.g. a paginated list_payments race, or a restart mid-test). A saturating_sub plus a descriptive message would fail more usefully than a raw overflow panic.

match op().await {
Ok(t) => return Ok(t),
Err(e) => {
if start.elapsed() + delay > backoff.timeout {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This check aborts before making the next attempt whenever elapsed + delay > timeout, even when the retry itself would still land within budget (e.g. elapsed=59s of a 60s timeout with delay=5s — aborts here even though the next poll would happen well before 60s). Could produce flaky 'not ready' failures for nodes that would've answered on the very next poll. Might be worth checking elapsed >= timeout after attempting, or clamping the final delay to the remaining budget instead of bailing early.


/// The rev the workspace's ldk-server-client dependency pins; the server is built from the same
/// rev so client and server always match.
const LDK_SERVER_REV: &str = "8163f4fe139368613959bf4f10b19ee6a5b9b4ab";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This is a second hardcoded copy of the ldk-server rev, separate from the git rev pinned for ldk-server-client in integration-tests/Cargo.toml. The CI workflow already derives its rev by grepping Cargo.lock rather than hardcoding it again — might be worth doing the same here so a future dependency bump can't silently build a server image at a different commit than the client expects.


// The four nodes are independent of each other until channels open: start them in
// parallel since (particularly with cold image pulls) startup dominates runtime.
let (lnd, cln, eclair, ldk) = tokio::try_join!(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If one of these four futures errors while the others are still mid-initialization (containers already up, credentials being extracted), try_join! drops the still-running futures immediately. Cleanup then relies entirely on Drop/the testcontainers reaper rather than each harness's own dump_logs/error-context path, so a fast-failing node can leave the still-starting ones without a diagnostic log dump. A join_all with explicit error aggregation would let every harness reach its own failure/cleanup path.

let count = 3u64;

let mut receipts_before = [0u64; 4];
for (i, receipts) in receipts_before.iter_mut().enumerate() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These four settled_keysend_count calls are awaited sequentially. They're independent RPCs to four different nodes (and Eclair's JVM-under-emulation responses are already the slow one) — a tokio::try_join! here would cut this baseline-read step down to the slowest single call instead of the sum of all four.

@carlaKC

carlaKC commented Sep 18, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review @only1dreamgene !

This is an entirely-clauded idea that I haven't even read the code for, just thought it would be a good idea to throw up and deal with ✨ sometime ✨ but I never got around to it.

Would you be interested in taking over this PR and addressing your own review? It's definitely something I'd like to get over the finish line.

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.

2 participants