Skip to content

Add Getting Started with LDK Node guide - #315

Open
Camillarhi wants to merge 1 commit into
lightningdevkit:mainfrom
Camillarhi:docs/getting-started-with-ldk-node
Open

Add Getting Started with LDK Node guide#315
Camillarhi wants to merge 1 commit into
lightningdevkit:mainfrom
Camillarhi:docs/getting-started-with-ldk-node

Conversation

@Camillarhi

@Camillarhi Camillarhi commented Jun 12, 2026

Copy link
Copy Markdown

Adds a guide for building a Lightning node with ldk-node in Rust, covering node setup, channel management, BOLT11/BOLT12 payments, and spontaneous payments against a local Polar regtest network.

Adds:

  • docs/getting-started-with-ldk-node.md
  • sidebar/config entries in docs/.vitepress/config.mts and docs/.vuepress/config.js
  • footer link in docs/.vitepress/theme/components/SiteFooter.vue

🤖 Kotlin code examples generated with Claude Code

@netlify

netlify Bot commented Jun 12, 2026

Copy link
Copy Markdown

Deploy Preview for lightningdevkit ready!

Name Link
🔨 Latest commit 765b70b
🔍 Latest deploy log https://app.netlify.com/projects/lightningdevkit/deploys/6a63cdc8289f74000894d5f5
😎 Deploy Preview https://deploy-preview-315--lightningdevkit.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@Camillarhi
Camillarhi force-pushed the docs/getting-started-with-ldk-node branch from 5ca6ec4 to 16a8ba1 Compare June 13, 2026 02:08

@ConorOkus ConorOkus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Automated code review (Claude Code) — 3 inline comments below on the guide's code samples, plus one issue that can't be anchored inline:

PR body promises a footer link that isn't in the diff. The description says "footer link in docs/.vitepress/theme/components/SiteFooter.vue", but the diff doesn't touch that file and it has no getting-started entry. The footer entry that was added lives in the legacy docs/.vuepress/config.js, which the live VitePress build doesn't read (the build workflow publishes docs/.vitepress/dist). Either add { text: 'Getting Started with LDK Node', link: '/getting-started-with-ldk-node' } to the Docs column in SiteFooter.vue, or amend the PR description.

Verdict: ready with fixes — structure, flow, and sidebar wiring are sound; the three inline items are small, localized edits to the code samples.

Comment thread docs/getting-started-with-ldk-node.md Outdated
Comment thread docs/getting-started-with-ldk-node.md Outdated
Comment thread docs/getting-started-with-ldk-node.md Outdated
@Camillarhi
Camillarhi force-pushed the docs/getting-started-with-ldk-node branch from 16a8ba1 to 004377c Compare July 24, 2026 10:31
@ConorOkus

Copy link
Copy Markdown
Contributor

One remaining issue from review, in the persistence guidance:

docs/getting-started-with-ldk-node.md line 310 recommends build_with_sqlite_store() and build_with_postgres_store(), but neither method exists on ldk-node 0.7's Builder — the available options are build() (SQLite is the default backend), build_with_fs_store(), build_with_store(), and the build_with_vss_store* variants. There's no Postgres persistence support.

Suggested rewrite for line 310:

On persistence: build_with_fs_store() persists node state to the filesystem. For production, you would likely prefer the default build() (which uses SQLite) or build_with_vss_store() depending on your storage requirements.

Line 17 repeats the claim ("State is persisted to SQLite, Postgres or the filesystem") — Postgres should be dropped there too.

Everything else from the earlier review round looks resolved in 004377c — thanks!

@Camillarhi
Camillarhi force-pushed the docs/getting-started-with-ldk-node branch from 004377c to de79bfe Compare July 24, 2026 20:36
@Camillarhi
Camillarhi requested a review from ConorOkus July 24, 2026 20:36
@Camillarhi
Camillarhi force-pushed the docs/getting-started-with-ldk-node branch from de79bfe to b71b3d3 Compare July 24, 2026 20:37
Walkthrough for building a Lightning node with ldk-node in Rust and kotlin
covering node setup, channel management, BOLT11 and BOLT12 payments, and
spontaneous payments using Polar for a local regtest environment.
@Camillarhi
Camillarhi force-pushed the docs/getting-started-with-ldk-node branch from b71b3d3 to 765b70b Compare July 24, 2026 20:40
@Camillarhi

Copy link
Copy Markdown
Author

One remaining issue from review, in the persistence guidance:

docs/getting-started-with-ldk-node.md line 310 recommends build_with_sqlite_store() and build_with_postgres_store(), but neither method exists on ldk-node 0.7's Builder — the available options are build() (SQLite is the default backend), build_with_fs_store(), build_with_store(), and the build_with_vss_store* variants. There's no Postgres persistence support.

Suggested rewrite for line 310:

On persistence: build_with_fs_store() persists node state to the filesystem. For production, you would likely prefer the default build() (which uses SQLite) or build_with_vss_store() depending on your storage requirements.

Line 17 repeats the claim ("State is persisted to SQLite, Postgres or the filesystem") — Postgres should be dropped there too.

Everything else from the earlier review round looks resolved in 004377c — thanks!

Thanks. This has been updated

@ConorOkus

Copy link
Copy Markdown
Contributor

To avoid confusion, I think it's worth changing the title to "Building on mobile with LDK Node"

@Camillarhi

Copy link
Copy Markdown
Author

To avoid confusion, I think it's worth changing the title to "Building on mobile with LDK Node"

I see where you're coming from, but I'd lean away from a mobile-specific title. The article builds against a local Polar regtest setup, and the Kotlin examples use JVM with Gradle and a terminal flow, not Android. A mobile dev could adapt it, but they wouldn't get the mobile-specific setup like Android project setup or the .aar dependency. I think something like 'Getting Started with LDK Node' fits the content better, but open to other ideas.

@ConorOkus ConorOkus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for this — it's a genuinely useful guide and clearly the product of actually running the thing. I checked every API call in it against the ldk-node 0.7.0 rustdoc and the v0.7.0 UniFFI definitions, and the signatures, arities and units all check out in both languages. The three comments from the last round are all addressed too.

Most of what's below isn't about the API usage — it's about what the guide tells the reader. Since this is official docs, readers will copy both the code and the prose into nodes that hold real funds, so I've weighted it that way.

The things I'd most like to see before merge:

  1. set_storage_dir_path is never called, so the wallet seed and every channel monitor land in /tmp/ldk_node. That's the one with actual funds risk.
  2. The BOLT12 walkthrough can't be completed as written — no onion-message peer is ever connected, and then the flow asks the reader to pay the offer in Polar, which the page itself says Polar can't do. Worth settling the intended Polar topology once; that answers both.
  3. Two statements are factually inverted — the push-amount liquidity direction, and what actually happens when you skip event_handled().

The rest are smaller. Nothing here is a reason not to land it — happy to re-review whenever.


On entropy: This example uses filesystem-derived entropy for brevity. In a real application, you would generate a BIP39 mnemonic once, persist it, and reload it on every subsequent start. Generating fresh entropy each run gives you a different node identity and wallet every time, which is not what you want outside of throwaway tests.

On persistence: `build_with_fs_store()` persists node state to the filesystem. For production, you would likely prefer `build()` (which defaults to SQLite) or `build_with_vss_store()` depending on your storage requirements. A Postgres backend (`build_with_postgres_store()`) will be available in the next release.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Worth fixing before merge — this one has funds risk.

ldk-node 0.7 defines DEFAULT_STORAGE_DIR_PATH = "/tmp/ldk_node" and derives entropy from a keys_seed file under that same directory. The guide never calls set_storage_dir_path, so with the code exactly as shown, the wallet seed and every channel monitor live in /tmp — which plenty of systems clear on reboot. Losing the monitors means you can't punish a counterparty who broadcasts a revoked state; losing keys_seed means losing the wallet.

That also makes the note just above (line 308) read backwards: it says generating fresh entropy each run "gives you a different node identity and wallet every time," but what actually happens is the opposite — identity persists across restarts until /tmp is cleared, and then it's gone for good.

Suggested change — add to build_node / buildNode and both complete listings:

builder.set_storage_dir_path("./ldk_node_data".to_string());
builder.setStorageDirPath("./ldk_node_data")

and reword both notes to say where state actually goes.


Note: [LDK Server](https://github.com/lightningdevkit/ldk-server) is being added to Polar ([PR #1374](https://github.com/jamaljsr/polar/pull/1374)). Once merged, you will be able to use it directly as your local node backend, since LDK Server is essentially ldk-node with an RPC interface. It will also work as an onion-message-capable peer for BOLT12 offer creation.

Note: To create BOLT12 offers, your ldk-node needs to connect to an onion-message-capable peer. In Polar, this means adding a CLN node to your network. LND does not currently support onion messages, so offer creation will fail if CLN is not present.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This prerequisite is correct, but nothing in the guide ever satisfies it. The code only opens a channel to Node B and never calls node.connect(...), so a reader who follows along lands in create_offer's error branch with no way forward beyond the message "make sure your node is connected."

Two ways out, depending on what your Polar setup actually looked like:

  • If Node B was the CLN node, say so here — then the channel peer is also the onion-message peer and it just works.
  • If CLN was a separate node, add the connect before create_offer:
node.connect(cln_pubkey, cln_addr, true).unwrap();
node.connect(clnPubkey, clnAddr, true)

Related to the comment on line 1247 — one decision about the topology resolves both.

println!("Press enter to create a BOLT12 offer for Node B to pay (inbound payment)...");
std::io::stdin().read_line(&mut String::new()).unwrap();
create_offer(Arc::clone(&node));
println!("\nPay the offer from Node B in Polar, then press Enter when done...");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This contradicts the page's own note at line 778 ("Paying a BOLT12 offer end to end through Polar's UI is not currently supported"), which the screenshot caption at line 975 repeats. A reader gets here, is told to pay the offer in Polar, and can't. Same prompt in the Kotlin listing at line 1494, and in the incremental listings at 871 / 930.

Simplest fix is to end the BOLT12 receive demo at offer creation and say plainly that paying it back needs a payer outside this setup — which is what the note already says. Alternatively, drop in a CLN command-line pay flow if you've run one.

let payment_id = node
.spontaneous_payment()
.send(5_000, recipient_pubkey, None)
.unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

send_spontaneous_payment is the only payment function in the guide that panics on failure — send_payment (665), create_offer (708) and send_bolt12_payment (749) all match Ok/Err, and the Kotlin twin of this exact function try/catches.

It's also the most likely one to fail: it's the last step, and by then most of the local balance has moved. An unroutable keysend aborts the whole program instead of printing why.

match node.spontaneous_payment().send(5_000, recipient_pubkey, None) {
    Ok(payment_id) => {
        println!("\nSpontaneous payment sent to Node B (id: {})", payment_id);
        println!("Waiting for result in event loop...");
    }
    Err(e) => eprintln!("\nFailed to send spontaneous payment ({e:?})."),
}

Same change needed in the complete listing at line 1147.


// Keep main alive so the event loop task can finish processing.
println!("\nWaiting for remaining events...");
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

stop() never appears anywhere in the guide, and both programs end on a fixed ten-second sleep.

Two consequences worth teaching instead:

  • A payment still retrying at ten seconds loses its event loop when main returns — Rust drops the runtime, Kotlin kills its daemon thread. The reader sees "Payment sent" and never finds out whether it succeeded, which undercuts the whole event-loop narrative the guide builds up.
  • On the JVM there's no Drop safety net, so peers aren't disconnected and state isn't flushed on exit.

Waiting for the terminal PaymentSuccessful / PaymentFailed and then calling node.stop() and joining the loop would be both more correct and a better thing for readers to copy than sleep(10). Same in the Kotlin listing at 1509.


:::

Always call `event_handled()` after every branch, including the wildcard. The node blocks on delivering the next event until you acknowledge the current one. Missing it in any branch means the event loop silently stalls the moment that event type arrives.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The instruction is right, but the failure mode described isn't what happens.

In ldk-node 0.7, next_event returns queue.front().cloned() and only event_handled() pops the queue. So skipping the acknowledgement doesn't stall the loop — it re-delivers the same event immediately, and the loop spins, re-running that branch's side effects. A reader debugging this will be watching for a hang and instead seeing repeated output, which is a confusing place to send them.

Suggested: "...Until you acknowledge an event, the node keeps returning that same event — the loop re-enters the branch immediately and spins, re-running its side effects. Keep handler bodies idempotent, and never skip the acknowledgement."

node.event_handled().unwrap();
}

_ => { node.event_handled().unwrap(); }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Minor, but this interacts with the "always call event_handled(), including the wildcard" advice at line 593.

Event::PaymentClaimable exists in 0.7 and carries a claim_deadline, and it's emitted by the receive_for_hash hold-invoice flow the guide advertises at line 644. It has to be answered with claim_for_hash or fail_for_hash — acknowledging it via this wildcard and moving on means the payment just expires.

Nothing in the guide's own code path hits this, so it isn't a bug in what's shown. But a reader who takes both "here's the event loop" and "you can also use receive_for_hash()" and combines them silently loses payments. One sentence noting the catch-all is only safe for informational events would cover it.

let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();

let node_b_pubkey = PublicKey::from_str("NODE_B_PUBKEY_FROM_POLAR").unwrap();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

NODE_B_PUBKEY_FROM_POLAR appears twice per language in the complete listings — here and at line 1076 in Rust, and at 1345 / 1470 in Kotlin.

A reader who replaces the one inside open_channel and misses this one gets a panic in main before the channel even opens, which is a confusing first failure. Kotlin instead limps to the final keysend and fails there with an invalid identity.

Parsing it once in main and passing it into open_channel would leave a single place to edit.


Run with `cargo run` (Rust) or `gradle run` (Kotlin). You should see your node ID, a Regtest address, and empty channel and payment lists.

<img width="1033" height="300" alt="image" src="https://gist.github.com/user-attachments/assets/66d88c26-3219-45d1-b862-bae9b75fdbd5" />

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

These eight screenshots point at gist.github.com/user-attachments/..., which isn't part of the repo — if those attachments are removed or rate-limited, the tutorial loses all its visual confirmation with no local fallback.

Repo convention is committed assets: docs/public/img/ (as in #314's sequence diagrams) or docs/assets/ (as in introduction/architecture.md and probing.md). Worth noting CI won't catch a break here either — linkcheck runs with continue-on-error: true.

While moving them: each <img> currently has alt="image". The italic captions you've already written under each one would make excellent alt text.

children: [
{ text: 'Introduction', link: '/introduction/' },
{ text: 'Building a node with LDK', link: '/building-a-node-with-ldk/introduction/' },
{ text: 'Getting Started with LDK Node', link: '/getting-started-with-ldk-node/' },

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit, and honestly pre-existing rather than yours: the trailing slash makes this 404.

https://lightningdevkit.org/running-a-sample-ldk-node/  -> 404
https://lightningdevkit.org/running-a-sample-ldk-node   -> 200

Every entry in this footer list has the same trailing slash, so it's a site-wide thing, not something this PR introduced — but this adds one more instance. Your sidebar entry in config.mts uses the correct bare form already. Note ignoreDeadLinks: true means the build won't flag it.

Happy for this to be out of scope; just flagging since you're touching the file.

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