Add Getting Started with LDK Node guide - #315
Conversation
✅ Deploy Preview for lightningdevkit ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
5ca6ec4 to
16a8ba1
Compare
ConorOkus
left a comment
There was a problem hiding this comment.
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.
16a8ba1 to
004377c
Compare
|
One remaining issue from review, in the persistence guidance:
Suggested rewrite for line 310:
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! |
004377c to
de79bfe
Compare
de79bfe to
b71b3d3
Compare
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.
b71b3d3 to
765b70b
Compare
Thanks. This has been updated |
|
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
left a comment
There was a problem hiding this comment.
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:
set_storage_dir_pathis never called, so the wallet seed and every channel monitor land in/tmp/ldk_node. That's the one with actual funds risk.- 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.
- 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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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..."); |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
mainreturns — 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
Dropsafety 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. |
There was a problem hiding this comment.
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(); } |
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
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" /> |
There was a problem hiding this comment.
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/' }, |
There was a problem hiding this comment.
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.
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.mdsidebar/config entries in docs/.vitepress/config.mts and docs/.vuepress/config.jsfooter link in docs/.vitepress/theme/components/SiteFooter.vue🤖 Kotlin code examples generated with Claude Code