A complete walkthrough: from an empty cargo new to a node that opens a channel, receives and sends BOLT11 payments, serves a BOLT12 offer, and pushes sats with keysend — every step run against a real local Lightning network on your own machine.
Adapted and expanded from “Getting Started with LDK Node: Building a Lightning Node in Rust” by Rita Anene (Btrust). This version fills in the setup, funding, and debugging steps a first-timer hits between the code blocks.
By the end you will have two things running at once, and keeping them straight is most of the battle:
ldk-node library. It joins that fake world as a fourth participant.Everything happens on regtest. Regtest is a private Bitcoin network where you decide when blocks get mined, by clicking a button. Coins on it are worthless and can’t leave your machine. That’s the point: you can make every mistake there is, for free.
ldk-node and not LDKLDK — the Lightning Development Kit — is a toolkit of parts. It hands you control over peer connections, key handling, storage, channel configuration, and routing. That control is exactly right when you’re building something unusual, and exhausting when you just want to receive a payment.
ldk-node is a layer on top that makes those decisions for you: BDK for the on-chain wallet, a choice of chain backends, filesystem or SQLite persistence, sensible channel defaults. Smaller API, fewer decisions, still a real Lightning node. If you outgrow it, raw LDK is still underneath.
| Thing | Why | Check it |
|---|---|---|
| Rust | The language you’ll write the node in | rustc --version |
| Docker Desktop | Polar runs every fake node as a container. Nothing works without it | docker ps |
| Polar | The GUI that starts and wires up the fake network | — |
Grab Polar from its releases page, picking the build that matches your platform. Start Docker Desktop before you open Polar, and leave it running — Polar doesn’t run nodes itself, it drives Docker containers, and if Docker isn’t up, network creation fails with errors that never mention Docker.
No Lightning implementation experience is assumed. You should be comfortable enough with Rust to read a match statement, and know roughly what a Lightning channel is for.
Install Rust with the official one-liner from rustup.rs, then reload your shell so cargo is on PATH:
source $HOME/.cargo/env
rustc --version
cargo --version
In Polar, click Create Network. Give it a name. You choose how many nodes of each implementation to include. Create three Lightning nodes and one Bitcoin Core backend:
alice bob carol
(LND) (Core Lightning) (Eclair)
│ │ │
└───────────────┼───────────────┘
│
backend1
(Bitcoin Core · regtest)
▲
│
your node (ldk-node)
Each Lightning node talks to the same Bitcoin Core for chain data, and the Lightning nodes peer with each other. Your Rust node needs both: RPC to Bitcoin Core for chain data, and a peer connection for channels.
The three implementations aren’t interchangeable, and the differences matter later:
| Node | Implementation | Good for | Onion messages? |
|---|---|---|---|
alice |
LND | The general-purpose peer. Best tooling, easiest CLI | No |
bob |
Core Lightning | Required for BOLT12 offers | Yes |
carol |
Eclair | A third peer, useful for multi-hop routing tests | Yes |
Why this matters later. BOLT12 offers are delivered over onion messages, and LND does not support them. If your only peer is alice, offer creation fails with a confusing error. Make sure your network includes a Core Lightning node — that’s what
bobis here for.
Click a node in Polar and the right-hand panel opens with three tabs. Knowing which tab holds what saves a lot of hunting:
| Tab | Holds | You use it to |
|---|---|---|
| Info | Pubkey, alias, balance, block height, channel count | Copy the node’s pubkey — its permanent identity |
| Connect | gRPC host, REST host, TLS cert and macaroon file paths | Talk to that node as its operator, from your own code |
| Actions | Deposit funds, open channel, create invoice, pay invoice | Drive the node by hand while testing |
⚠️ The address trap. The Connect tab’s gRPC host (e.g.
127.0.0.1:10001) is not the node’s Lightning listening address. It’s the admin API. The listening address — thepubkey@host:portthat other nodes dial to open channels — is a different thing on a different port, and Polar doesn’t show it in the panel at all.
To get a node’s true listening address, ask the node. Right-click alice on the canvas → Launch Terminal, then:
lncli getinfo
Read the uris field. You’ll get something like:
"uris": [
"022a5a1a...d52a9a@172.18.0.4:9735"
]
⚠️ This address won’t work from your Rust code.
172.18.0.4is an address inside Docker’s private network. It’s reachable from bob’s container and carol’s container. It is not reachable from your Mac, and your Rust program runs on your Mac. You need the host-published port that Docker mapped instead — the pubkey stays the same either way.
Get the real mapping straight from Docker. The left side of each -> is the port on your machine; the right side is the port inside the container:
docker ps --format '{{.Names}}\t{{.Ports}}' | grep polar
A typical first network maps out like this. Yours will differ — read your own output rather than copying these numbers:
| Node | p2p (from your Mac) | Admin API |
|---|---|---|
alice |
127.0.0.1:9735 |
10001 (gRPC) |
bob |
127.0.0.1:9836 |
11002 (gRPC) |
carol |
127.0.0.1:9937 |
8283 (REST) |
backend1 |
— | 18443 (Bitcoin RPC) |
So the address you’ll actually put in your Rust code is 127.0.0.1:9735 — the pubkey from getinfo, paired with the host port from docker ps.
cargo new ldk_node_example
cd ldk_node_example
[package]
name = "ldk_node_example"
version = "0.1.0"
edition = "2021"
[dependencies]
ldk-node = "0.7"
tokio = { version = "1", features = ["full"] }
Run cargo build now, before writing any code. ldk-node pulls in a large dependency tree and compiles a lot of cryptography — the first build takes several minutes and it’s better to discover that now than in the middle of debugging. Later builds are fast.
Two things worth adding to
.gitignore. Your node writes its state — including its wallet seed — into a data directory next to your project. Add both/targetand your node’s data directory. On regtest the seed is worthless, but the habit of never committing one is worth forming now.
Every code block below goes into src/main.rs, in order. By the end they form one complete file.
Before any node logic, add two printing functions. You’ll call them after every meaningful action, so you can watch the node’s state change instead of guessing at it. This is the difference between learning Lightning and cargo-culting it.
use ldk_node::payment::{PaymentDirection, PaymentStatus};
fn print_channels(node: &ldk_node::Node) {
let channels = node.list_channels();
if channels.is_empty() {
println!("[channels] none");
return;
}
for c in channels {
println!(
"[channel] id: {} | peer: {} | capacity: {} sats | ready: {}",
c.channel_id,
c.counterparty_node_id,
c.channel_value_sats,
c.is_channel_ready,
);
}
}
fn print_payments(node: &ldk_node::Node) {
let payments = node.list_payments();
if payments.is_empty() {
println!("[payments] none");
return;
}
for p in payments {
let direction = match p.direction {
PaymentDirection::Inbound => "INBOUND ",
PaymentDirection::Outbound => "OUTBOUND",
};
let status = match p.status {
PaymentStatus::Pending => "pending",
PaymentStatus::Succeeded => "succeeded",
PaymentStatus::Failed => "failed",
};
println!(
"[payment] {} | {:?} | {} | {} msats | id: {:?}",
direction,
p.kind,
status,
p.amount_msat.unwrap_or(0),
p.id,
);
}
}
Sats and millisats. Lightning counts in millisatoshis — thousandths of a satoshi. Channel capacities are given in sats; payment amounts are given in msats. So
100_000capacity is 100,000 sats, while50_000_000pushed is 50,000 sats. Mixing these up by a factor of 1,000 is the single most common beginner bug in this whole guide. Read every amount twice.
The entry point is Builder. You configure it, call a build method, and get back a Node that owns everything from then on.
Your node needs somewhere to get blockchain data. Polar’s Bitcoin Core node is right there, so use it. Click backend1 in Polar and open its info panel to see the RPC host, port, username, and password. Polar’s defaults are polaruser / polarpass on port 18443.
use ldk_node::Builder;
use ldk_node::bitcoin::Network;
fn build_node() -> ldk_node::Node {
let mut builder = Builder::new();
builder.set_network(Network::Regtest);
builder.set_chain_source_bitcoind_rpc(
"127.0.0.1".to_string(), // RPC host from Polar
18443, // RPC port from Polar
"polaruser".to_string(), // RPC username from Polar
"polarpass".to_string(), // RPC password from Polar
);
builder.set_gossip_source_p2p();
builder.build_with_fs_store().unwrap()
}
Network — why Regtest. This must match what your chain source is running, or the node refuses to start. Polar uses regtest by default. Working against Mutinynet or another Signet instead? Switch to Network::Signet and use set_chain_source_esplora() with a public endpoint, since you won’t have RPC credentials for someone else’s node.
Gossip — why P2P and not Rapid Gossip Sync. Gossip is how a node learns the network’s shape so it can route payments. P2P gossip means crawling peers for it. Rapid Gossip Sync downloads a compressed snapshot from a server instead — much faster on the real network, which has tens of thousands of channels. Your regtest network has three nodes and no snapshot server, so P2P is the only sensible choice here.
Entropy — the shortcut this example takes. Entropy seeds the wallet, which determines your node’s identity and controls its funds. This example lets the filesystem store handle it. In a real application you’d generate a BIP39 mnemonic once, persist it safely, and load it on every start. Worth knowing now: if you delete the data directory, you get a brand-new node with a new pubkey and no funds. That’s occasionally exactly what you want while testing.
Persistence — build_with_fs_store(). Writes node state to flat files next to your project. Fine for learning. Production alternatives are build_with_sqlite_store(), build_with_postgres_store(), and build_with_vss_store() for remote-backed storage. The API above them is identical, so this choice is easy to revisit.
use std::sync::Arc;
fn start_node(node: Arc<ldk_node::Node>) {
node.start().unwrap();
let node_id = node.node_id();
let funding_address = node.onchain_payment().new_address().unwrap();
println!("Node ID: {}", node_id);
println!("Funding address: {}", funding_address);
println!("\n[channels at startup]");
print_channels(&node);
println!("\n[payments at startup]");
print_payments(&node);
}
node.start() launches the background threads that sync the chain, maintain peer connections, and deliver events. When it returns, the node is live. node_id() is your node’s public key — its permanent identity on the network. new_address() derives a fresh regtest address from the BDK wallet inside your node.
#[tokio::main]
async fn main() {
let node = Arc::new(build_node());
start_node(Arc::clone(&node));
println!("\nFund the address above in Polar, then press Enter...");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
}
Run it with cargo run. You should see a node ID, a regtest address, and two empty lists. That’s a working Lightning node — it just has no money and no friends yet.
⚠️ Read this before you try to open a channel. Your node is the one opening the channel, so your node pays the channel capacity plus on-chain fees. Funding alice in Polar does nothing for you. You must send regtest coins to the funding address your program just printed.
Two things have to be true before that send will work:
backend1, once the above is satisfied.The easy route: in Polar, click Quick Mine repeatedly until the height counter passes 100. Then click backend1 → Send Coins, paste your funding address, and send something comfortable like 1000000 sats (0.01 BTC). Mine ~6 more blocks so the transaction confirms.
Or do it in one shot from the terminal:
# Adjust the container name to match your network
BTC="docker exec polar-n1-backend1 bitcoin-cli -regtest \
-rpcuser=polaruser -rpcpassword=polarpass"
# 1. Mine 101 blocks so coinbase outputs mature
$BTC -generate 101
# 2. Send 0.01 BTC to the address your program printed
$BTC sendtoaddress "<YOUR_FUNDING_ADDRESS>" 0.01
# 3. Confirm it
$BTC -generate 6
Restart cargo run and your node should now report an on-chain balance. If it doesn’t, give it a few seconds — chain sync isn’t instant — and check that the address you funded matches the one printed this run.
Now combine the two values you dug out in step 01: alice’s pubkey from lncli getinfo, and her host-published port from docker ps.
use ldk_node::lightning::ln::msgs::SocketAddress;
use ldk_node::bitcoin::secp256k1::PublicKey;
use std::str::FromStr;
fn open_channel(node: Arc<ldk_node::Node>) {
// Pubkey from `lncli getinfo` on alice
let peer_pubkey = PublicKey::from_str("<ALICE_PUBKEY>").unwrap();
// Host-published port from `docker ps` — NOT the 172.x Docker IP
let peer_addr = SocketAddress::from_str("127.0.0.1:9735").unwrap();
node.open_channel(
peer_pubkey,
peer_addr,
100_000, // capacity in SATS
Some(50_000_000), // push amount in MSATS (= 50,000 sats)
None, // channel config; None = defaults
).unwrap();
println!("Channel open request sent.");
println!("\n[channels after open request]");
print_channels(&node);
}
When a channel opens, all the money starts on the opener’s side — yours. That means you can pay alice, but alice cannot pay you, because she has no balance in the channel to send. Developers hit this constantly: they open a channel, generate an invoice, try to pay it from the other node, and get an opaque routing failure.
The push amount fixes it. It hands part of the balance to your peer at the moment the channel opens. Pushing 50,000 sats of a 100,000-sat channel splits it evenly, so payments work in both directions immediately. On mainnet you would think hard before giving money away like this; on regtest it just makes testing possible.
ready: falseis expected. Afteropen_channel()returns, the funding transaction has been broadcast but not confirmed, soprint_channelsshowsready: false. The channel becomes usable once the funding transaction confirms — which on regtest means you have to mine the blocks. Click Quick Mine ~6 times and watch for theChannelReadyevent.
This is the piece that makes ldk-node click. A Lightning node is fundamentally asynchronous: you request things, and outcomes arrive later, in their own time. The event loop is where those outcomes show up.
Crucially, the event loop is purely reactive. It never initiates anything. Sending payments, generating invoices, responding to users — all of that lives elsewhere, driven by your application. The loop just handles what the node surfaces. Structuring it this way from the start means the code you write on regtest is shaped like the code you’d ship.
use ldk_node::Event;
async fn run_event_loop(node: Arc<ldk_node::Node>) {
loop {
let event = node.next_event_async().await;
match event {
Event::ChannelReady { channel_id, counterparty_node_id, .. } => {
println!("\nChannel ready: {} with peer {:?}",
channel_id, counterparty_node_id);
print_channels(&node);
node.event_handled().unwrap();
}
Event::PaymentReceived { payment_id, amount_msat, .. } => {
println!("\nPayment received: {} msats (id: {:?})",
amount_msat, payment_id);
// Persist the payment to your database BEFORE calling
// event_handled(). Once acknowledged, the node moves on
// and will not re-deliver this event after a restart.
print_payments(&node);
node.event_handled().unwrap();
}
Event::PaymentSuccessful { payment_id, fee_paid_msat, .. } => {
println!("\nPayment succeeded (id: {:?}, fee: {:?} msats)",
payment_id, fee_paid_msat);
print_payments(&node);
node.event_handled().unwrap();
}
Event::PaymentFailed { payment_id, reason, .. } => {
println!("\nPayment failed (id: {:?}, reason: {:?})",
payment_id, reason);
// `reason` distinguishes routing failures from expiry,
// insufficient liquidity, and so on — use it to give
// users a real error message, not "payment failed".
print_payments(&node);
node.event_handled().unwrap();
}
Event::ChannelClosed { channel_id, reason, .. } => {
println!("\nChannel closed: {} ({:?})", channel_id, reason);
print_channels(&node);
node.event_handled().unwrap();
}
_ => { node.event_handled().unwrap(); }
}
}
}
⚠️ The one mistake that silently breaks everything. You must call
node.event_handled()in every single branch, including the_ =>wildcard. The node will not deliver the next event until you acknowledge the current one. Miss it in one branch, and the moment that event type arrives your loop stops forever — no error, no panic, no output. It just goes quiet, and you spend an hour looking in the wrong place.
The acknowledgement is deliberate, not ceremony. It’s what makes events survive a crash: the node keeps redelivering an event until you confirm you’ve dealt with it. Which is why, in a real application, persisting the payment comes before acknowledging it. Acknowledge first and crash second, and you’ve lost a payment record you already told the node to forget.
BOLT11 is the classic Lightning invoice — the long lnbcrt1... string. It encodes one amount, one payment hash, and an expiry. It’s meant to be used once.
use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description};
fn create_invoice(node: Arc<ldk_node::Node>) {
let invoice_description = Bolt11InvoiceDescription::Direct(
Description::new(String::from("coffee")).unwrap()
).into();
let invoice = node
.bolt11_payment()
.receive(
10_000, // amount in MSATS (= 10 sats)
&invoice_description,
3600, // expiry in seconds
)
.unwrap();
println!("\nInvoice generated. Pay this from alice in Polar:");
println!("{}", invoice);
}
Copy that string, open alice’s Actions tab in Polar, and use Pay Invoice. Within a second or two, PaymentReceived prints from your event loop. That’s your first inbound Lightning payment.
bolt11_payment() has more than receive(): receive_variable_amount() lets the payer choose the amount, and receive_for_hash() creates hold invoices you release manually — the building block for escrow and swaps.
use ldk_node::lightning_invoice::Bolt11Invoice;
fn send_payment(node: Arc<ldk_node::Node>, invoice_str: &str) {
let invoice = match Bolt11Invoice::from_str(invoice_str) {
Ok(i) => i,
Err(e) => {
eprintln!("\nInvalid invoice ({e:?}). Skipping.");
return;
}
};
match node.bolt11_payment().send(&invoice, None) {
Ok(payment_id) => {
println!("\nPayment sent (id: {})", payment_id);
println!("Waiting for result in event loop...");
}
Err(e) => eprintln!("\nFailed to send ({e:?})."),
}
}
Note what send() does not return: a success. It returns a payment_id, meaning “this is now in flight.” The actual outcome arrives later as PaymentSuccessful or PaymentFailed in the event loop, carrying that same id. Store the id if you need to tie the result back to whatever prompted the payment.
Pathfinding, fee estimation, and retries are all handled inside ldk-node. You don’t route manually.
An offer is BOLT12’s answer to the invoice, and it fixes the invoice’s biggest limitation: reuse. A BOLT11 invoice is one amount, one payment, then it’s spent. An offer is a long-lived string you can put on a website, a business card, or a donate button. Each payer fetches a fresh invoice from it behind the scenes.
⚠️ This is where LND-only setups fail. That “fetches a fresh invoice behind the scenes” step travels over onion messages, and your node needs a peer that can relay them. LND does not support onion messages. If alice is your only peer,
create_offerfails and the error won’t obviously say why. Connect to bob (Core Lightning) as well — or open your channel to bob instead of alice — before trying this section.
use ldk_node::lightning::offers::offer::Offer;
fn create_offer(node: Arc<ldk_node::Node>) {
match node.bolt12_payment().receive(10_000, "coffee", None, None) {
Ok(offer) => {
println!("\nOffer created. Share this with the payer:");
println!("{}", offer);
}
Err(e) => eprintln!(
"\nCould not create offer ({e:?}). Is your node connected \
to an onion-message-capable peer (Core Lightning)?"
),
}
}
fn send_bolt12_payment(node: Arc<ldk_node::Node>, offer_str: &str) {
let offer = match Offer::from_str(offer_str) {
Ok(o) => o,
Err(e) => {
eprintln!("\nInvalid offer ({e:?}). Skipping.");
return;
}
};
match node.bolt12_payment().send(&offer, None, None, None) {
Ok(payment_id) => {
println!("\nBOLT12 payment sent (id: {})", payment_id);
println!("Waiting for result in event loop...");
}
Err(e) => eprintln!("\nFailed to send BOLT12 payment ({e:?})."),
}
}
Outcomes arrive exactly as with BOLT11 — PaymentSuccessful or PaymentFailed in the event loop.
Expectation setting. Paying an offer end-to-end through Polar’s UI isn’t fully supported yet, so this is the one section you may not be able to complete round-trip in the GUI.
send_bolt12_paymentis correct and works against any BOLT12-capable payer; you can also drive bob’s Core Lightning CLI directly withfetchinvoiceandpayif you want to see it work.
Sometimes there’s no invoice and no time to ask for one. Keysend pushes sats straight to a node’s public key — no invoice, no prior coordination. It’s what powers streaming sats, podcast value splits, and tipping.
fn send_spontaneous_payment(
node: Arc<ldk_node::Node>,
recipient_pubkey: PublicKey,
) {
let payment_id = node
.spontaneous_payment()
.send(5_000, recipient_pubkey, None) // 5,000 msats = 5 sats
.unwrap();
println!("\nSpontaneous payment sent (id: {})", payment_id);
println!("Waiting for result in event loop...");
}
The tradeoff is worth understanding before you build on it. In a normal payment, the recipient generates the payment hash, so a completed payment proves both that they asked for it and that they received it. Keysend skips that: the sender makes up the preimage. You lose the cryptographic receipt in both directions — the recipient can’t prove they were owed the payment, and you can’t prove they agreed to accept it. For tips that’s irrelevant. For anything that settles a debt, it isn’t.
The recipient also has to have keysend enabled. It’s on by default in most implementations, but it’s a setting, and a failing keysend to an otherwise-healthy node is usually this.
The final main() mirrors how a real application is structured: the event loop runs on its own task, reacting, while the main flow initiates actions in sequence. Here the “application layer” is you, pressing Enter.
#[tokio::main]
async fn main() {
let node = Arc::new(build_node());
start_node(Arc::clone(&node));
println!("\nFund the address above in Polar, then press Enter...");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let peer_pubkey = PublicKey::from_str("<ALICE_PUBKEY>").unwrap();
open_channel(Arc::clone(&node));
// Spawn the event loop so it processes events independently
// of whatever main() is blocking on.
tokio::spawn(run_event_loop(Arc::clone(&node)));
println!("\nMine ~6 blocks in Polar to confirm the channel.");
println!("Press Enter once you see ChannelReady above...");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
// 1 — receive a BOLT11 payment
create_invoice(Arc::clone(&node));
println!("\nPay it from alice in Polar, then press Enter...");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
// 2 — send a BOLT11 payment
println!("\nCreate an invoice on alice. Paste it here:");
let mut invoice_input = String::new();
std::io::stdin().read_line(&mut invoice_input).unwrap();
send_payment(Arc::clone(&node), invoice_input.trim());
// 3 — create a BOLT12 offer (needs a Core Lightning peer)
println!("\nPress Enter to create a BOLT12 offer...");
std::io::stdin().read_line(&mut String::new()).unwrap();
create_offer(Arc::clone(&node));
// 4 — pay a BOLT12 offer
println!("\nPaste an offer from bob (or press Enter to skip):");
let mut offer_input = String::new();
std::io::stdin().read_line(&mut offer_input).unwrap();
if !offer_input.trim().is_empty() {
send_bolt12_payment(Arc::clone(&node), offer_input.trim());
}
// 5 — keysend, no invoice at all
println!("\nPress Enter to send a spontaneous payment...");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
send_spontaneous_payment(Arc::clone(&node), peer_pubkey);
// Give the event loop time to report the last outcomes
println!("\nWaiting for remaining events...");
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
}
Why the sleep at the end. When
main()returns, the process exits and the spawned event loop dies with it — including any payment result still in flight. The sleep is a crude way to let the last events land. A real application never exits here; it runs until it’s shut down.
Every one of these has cost someone an afternoon.
| Symptom | Cause | Fix |
|---|---|---|
Connection refused |
Docker or Polar isn’t running, or the network is stopped | Start Docker Desktop, then click Start on the network in Polar |
| RPC auth error on startup | Wrong Bitcoin Core credentials or port | Check backend1’s info panel; the port is usually 18443 |
InsufficientFunds on open_channel |
Your node has no on-chain coins — funding alice doesn’t fund you | Send regtest coins to your funding address (step 05) |
Channel opens but ChannelReady never fires |
The funding transaction is unconfirmed | Mine ~6 blocks with Quick Mine |
| Peer connection times out | You used the 172.x.x.x Docker IP from getinfo |
Use 127.0.0.1 plus the host port from docker ps |
| Event loop goes silent | A branch is missing event_handled() |
Add it to every branch, wildcard included |
| BOLT12 offer creation fails | No onion-message-capable peer | Connect to bob (Core Lightning), not just alice |
| Inbound payment fails, outbound works | All channel liquidity is on your side | Reopen with a push amount, or spend some out first |
| Node ID changed after a restart | The data directory was deleted or moved | Expected. Refund it and reopen channels, or persist a mnemonic |
| Nothing works and state looks corrupt | Mismatched node and network state after edits | Delete the data dir, and in Polar delete and recreate the network |
When you’re properly stuck, the fastest reset is a clean slate on both sides: stop your program, delete its data directory, and in Polar delete the network and create a fresh one. Nothing on regtest is worth preserving, and a stale channel state against a rebuilt chain will waste more time than starting over.
When PaymentFailed fires, reason is a PaymentFailureReason that distinguishes a routing failure from an expired invoice from a rejected payment. Print it. “Payment failed” tells you nothing; “no route found” tells you to check liquidity, and “invoice expired” tells you to stop leaving the terminal open over lunch.
Embed it in a backend. A node running as a long-lived process inside your service gives you Lightning payments with no third-party provider in the path. Payments arrive through the event loop rather than webhooks, which means no HTTPS endpoint to expose, no signature verification, and no retry semantics to reimplement — but also no one else holding the keys.
Look at the LSPS protocols. ldk-node has first-class support for LSPS0, LSPS1, and LSPS2 — the open standards for Lightning Service Providers. If you want channels opened on demand for your users, that’s the path, and multi-LSP support means discovering provider capabilities automatically rather than hardcoding one.
Take it mobile. UniFFI bindings wrap the same Rust core for Swift, Kotlin, and Python. The Builder pattern, the event loop, and the payment APIs behave identically, so everything here transfers directly to an iOS or Android app.
| Source & issues | https://github.com/lightningdevkit/ldk-node |
| Full API docs | https://docs.rs/ldk-node |
| Polar releases | https://github.com/jamaljsr/polar/releases |
| Community | LDK Discord — #ldk-dev and #ldk-help |
The API is small enough to hold in your head, the event model maps cleanly onto how the protocol actually works, and raw LDK is still there when you need to reach past the defaults. If Lightning has felt like too much protocol to learn before writing a line of code, this is the right place to start from.
Adapted from “Getting Started with LDK Node: Building a Lightning Node in Rust” by Rita Anene, published by Btrust. This version expands the original with macOS installation steps, Polar network setup, node funding, host-vs-container networking, and a troubleshooting reference — the parts that sit between the code blocks.
All amounts and addresses shown are from a local regtest network. Nothing here involves real bitcoin.