If you've spent time in the Bitcoin developer space, you've probably heard of LDK (the Lightning Development Kit). It's a fully modular Rust library that lets you build a Lightning node exactly the way you want. It's powerful and deeply configurable, but honestly a little intimidating if you're just trying to get something running.I contribute to ldk-node, and what drew me to it is how clear its purpose is: you shouldn't need to understand every layer of the Lightning protocol just to send a payment. LDK Node is LDK with the hard decisions already made for you. It has a small API surface, sensible defaults, and enough structure to be useful in production.This article walks you through building a working Lightning node from scratch. By the end, you'll have a node that connects to a peer, opens a channel, handles the channel-ready event, receives a BOLT11 payment, sends one, creates a BOLT12 offer and pays one, and sends a spontaneous payment without an invoice. At each step, as you run the code, you'll see exactly what the node returns, building a real mental model of what's happening under the hood.No prior Lightning implementation experience required. Rust familiarity and a working knowledge of Bitcoin and Lightning are enough to follow along.What LDK Node is, and what it’s notLDK (the underlying library) exposes a vast array of methods. This level of control is ideal for building systems that require custom peer management, exotic channel configurations, or strict key handling. However, it’s not the right tool when you simply need to embed a node in an app.LDK Node reduces the API surface by making concrete choices on your behalf. BDK (the Bitcoin Dev Kit) handles the on-chain wallet, while chain data comes from Esplora, Electrum, or Bitcoin Core RPC. State is persisted to SQLite, Postgres or the filesystem. Gossip is sourced from Lightning's P2P network or Rapid Gossip Sync, and entropy is derived from raw bytes or a BIP39 mnemonic.You trade some configurability for speed of iteration. Those defaults cover most real use cases, and if you eventually need more control, the underlying LDK is still accessible.It’s written in Rust and ships with UniFFI-based bindings for Swift, Kotlin, and Python if you are targeting mobile.PrerequisitesBefore writing any code, you need Rust installed. If you don't have it yet, install it with rustup:curl --proto '=https' --tlsv1.2 -sSf | shFollow the on-screen instructions, then reload your shell environment:source ~/.cargo/envVerify the installation worked:rustc --versioncargo --versionFor a fully interactive experience with real payments between two nodes, you'll also need Polar. Polar is a desktop app that lets you spin up a local Lightning network with one click. If you don't have it installed, grab it from the Polar website to get two nodes running. The article uses Node A (the ldk-node we build) and Node B (a Polar-managed node) to demonstrate every payment direction.Note: LDK Server is being added to Polar (PR #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.With that in place, create a new binary project:cargo new ldk_node_examplecd ldk_node_exampleCargo generates this structure:ldk_node_example/├── Cargo.toml└── src/ └── main.rsOpen src/main.rs and you'll find a default "Hello, world!" program. Everything that follows replaces it.Project setupAdd ldk-node and tokio to your Cargo.toml:[dependencies]ldk-node = "0.7"tokio = { version = "1", features = ["full"] }The full src/main.rs will be built up function by function through the article. Each section adds one function, and main() calls them in order. By the end, you'll have a single file that compiles and runs as a complete working node.Helper functions: printing node stateBefore writing any node logic, add two helper functions at the top of src/main.rs. These get called after each meaningful action so you can see exactly what the node knows at that point: channels, payment directions, statuses, and amounts all printed in one place.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, ); }}print_channels shows every open or pending channel with its capacity and whether it's ready to route payments. print_payments shows every payment the node has seen, with direction, kind (bolt11, spontaneous, or onchain), status, and amount. You'll see both grow as the article progresses.Building the nodeThe entry point into ldk-node is Builder. You configure it, call build(), and get back a Node that manages everything from that point forward.This article uses Bitcoin Core RPC as the chain source, which is what Polar runs under the hood. Open Polar, click on the Bitcoin Core node, and find the RPC credentials in the node settings panel. You'll need the host, port, username, and password.Add this function to src/main.rs: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()}On network: Polar runs a local Regtest network by default, so Network::Regtest is the right choice. Working against Mutinynet or another Signet instead? Swap this to Network::Signet and use an Esplora endpoint with set_chain_source_esplora(). The full list of chain source and store options is in the ldk-node docs.On gossip: On a local Regtest network with Polar, P2P gossip is the natural choice. Rapid Gossip Sync is better suited for connecting to the public Lightning network where bootstrapping from a snapshot is faster than crawling peers.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_with_sqlite_store(), build_with_postgres_store(), or build_with_vss_store() depending on your storage requirements.Starting the nodeAdd a start_node function:use std::sync::Arc;fn start_node(node: Arc) { 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 handle chain sync, peer connections, and event delivery. After that call returns, the node is live.node_id() is your node's public key, the identity other peers use to find and connect to you.onchain_payment().new_address() derives a fresh address from the BDK wallet. In Polar, send some Regtest BTC to this address using the Bitcoin Core node before moving on.Now add main() and run it:#[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 with cargo run. You should see your node ID, a Regtest address, and empty channel and payment lists.Opening a channelWith funds on-chain, add an open_channel function. You'll need Node B's pubkey and listening address from Polar. Click on Node B in the Polar interface and find them in the node info panel.use ldk_node::lightning::ln::msgs::SocketAddress;use ldk_node::bitcoin::secp256k1::PublicKey;use std::str::FromStr;fn open_channel(node: Arc) { let peer_pubkey = PublicKey::from_str("NODE_B_PUBKEY_FROM_POLAR").unwrap(); let peer_addr = SocketAddress::from_str("127.0.0.1:NODE_B_PORT").unwrap(); node.open_channel( peer_pubkey, peer_addr, 100_000, // channel capacity in satoshis Some(50_000_000), // push amount in millisatoshis (50,000 sats to Node B) None, // channel config: None uses defaults ).unwrap(); println!("Channel open request sent."); println!("\n[channels after open request]"); print_channels(&node);}The push amount is worth pausing on. It’s the balance you hand to the peer the moment the channel opens. This gives Node B inbound capacity toward Node A from the start, meaning Node B can pay Node A immediately. Without it, all the liquidity sits on Node A's side and Node B can't route anything back until Node A has spent some first. This trips up a lot of developers building their first integrations.After open_channel() returns, the funding transaction has been broadcast but not yet confirmed. print_channels will show the channel with ready: false. That is expected. The channel becomes usable once the funding transaction confirms, which the event loop will signal.The event loopIn a real application, the event loop is purely reactive. It handles what the node surfaces and nothing else. Payment initiation, invoice generation, user interaction, all of that happens elsewhere, driven by API calls or user input. The event loop just responds.This is exactly how the code is structured here. The event loop runs on its own spawned task, always free to process events. main() drives the interactive flow separately, prompting you at each step.Add the event loop function:use ldk_node::Event;async fn run_event_loop(node: Arc) { 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 ); println!("\n[channels after ChannelReady]"); 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 the event will not be re-delivered after a restart. println!("\n[payments after receive]"); 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 ); println!("\n[payments after success]"); print_payments(&node); node.event_handled().unwrap(); } Event::PaymentFailed { payment_id, reason, .. } => { println!( "\nPayment failed (id: {:?}, reason: {:?})", payment_id, reason ); // reason is a PaymentFailureReason that tells you whether // the failure was a routing problem, invoice expiry, and so on. // Useful for surfacing meaningful error messages to users. println!("\n[payments after failure]"); 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(); } } }}Always call node.event_handled().unwrap() 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.Receiving a BOLT11 paymentAdd a create_invoice function:use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description};fn create_invoice(node: Arc) { let invoice_description = Bolt11InvoiceDescription::Direct(Description::new(String::from("coffee")).unwrap()).into(); let invoice = node .bolt11_payment() .receive( 10_000, // amount in millisatoshis (10 sats) &invoice_description, 3600, // expiry in seconds ) .unwrap(); println!("\nNode A invoice generated. Take this to Polar and pay it from Node B:"); println!("{}", invoice);}The amount is in millisatoshis throughout. ldk-node works at millisat precision for everything payment-related.bolt11_payment() has more methods than just receive(). You can create variable-amount invoices with receive_variable_amount(), hold invoices pending manual release with receive_for_hash(), and more. The full API is at docs.rs/ldk-node.Sending a BOLT11 paymentAdd a send_payment function:use ldk_node::lightning_invoice::Bolt11Invoice;fn send_payment(node: Arc, invoice_str: &str) { let invoice = match Bolt11Invoice::from_str(invoice_str) { Ok(i) => i, Err(e) => { eprintln!("\nInvalid invoice string ({e:?}). Skipping payment."); return; } }; match node.bolt11_payment().send(&invoice, None) { Ok(payment_id) => { println!("\nPayment sent to Node B (id: {})", payment_id); println!("Waiting for result in event loop..."); } Err(e) => eprintln!("\nFailed to send payment ({e:?})."), }}ldk-node handles pathfinding, fee estimation, and retry logic internally. The payment_id returned here matches what you'll see in the PaymentSuccessful or PaymentFailed event, so store it if you need to correlate the outcome back to this call.BOLT12 payments (offers)BOLT12 introduces a different payment primitive called an offer. Unlike a BOLT11 invoice, an offer is reusable and does not expire by default. The payer fetches a fresh invoice from the offer each time they want to pay, so you share it once and it keeps working.Add a create_offer function:use ldk_node::lightning::offers::offer::Offer;fn create_offer(node: Arc) { 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 BOLT12 offer ({e:?}). Make sure your node is connected to an onion-message-capable peer." ), }}To pay an offer, add a send_bolt12_payment function:fn send_bolt12_payment(node: Arc, offer_str: &str) { let offer = match Offer::from_str(offer_str) { Ok(o) => o, Err(e) => { eprintln!("\nInvalid offer string ({e:?}). Skipping BOLT12 payment."); 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:?})."), }}The outcome arrives the same way as BOLT11, as a PaymentSuccessful or PaymentFailed event in the event loop. The full BOLT12 API including variable amount offers and offers with descriptions is at docs.rs/ldk-node.Note: Paying a BOLT12 offer end to end through Polar's UI is not currently supported. The send_bolt12_payment function is correct and will work against any compatible BOLT12 payer outside of this regtest setup.Spontaneous payments (keysend)Not every payment needs an invoice. Keysend lets you push sats directly to a node's public key without the recipient generating anything first. It’s useful for tipping, streaming payments, or any flow where waiting for an invoice adds unnecessary friction.Add a send_spontaneous_payment function:fn send_spontaneous_payment(node: Arc, recipient_pubkey: PublicKey) { let payment_id = node .spontaneous_payment() .send(5_000, recipient_pubkey, None) .unwrap(); println!( "\nSpontaneous payment sent to Node B (id: {})", payment_id ); println!("Waiting for result in event loop...");}One thing to keep in mind: keysend payments are not protected by a payment hash the recipient generates, which changes the trust model slightly compared to BOLT11. The recipient can't prove they were expecting the payment, and you can't prove they agreed to receive it. For most spontaneous use cases, this does not matter, but it's worth knowing before building a product on top of it.Putting it all together in main()Now update main() to wire everything together. The event loop runs on its own spawned task so it's always free to process events. The interactive flow runs sequentially in main(), prompting you at each step. This mirrors how a real application works: the event loop reacts, the application layer initiates.#[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 node_b_pubkey = PublicKey::from_str("NODE_B_PUBKEY_FROM_POLAR").unwrap(); open_channel(Arc::clone(&node)); // Spawn the event loop on its own task so it runs independently. // It will print events as they arrive without blocking main(). tokio::spawn(run_event_loop(Arc::clone(&node))); println!("\nMine blocks in Polar to confirm the channel."); println!("Press Enter once you see ChannelReady printed above..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); // Step 1: Generate an invoice for Node B to pay (inbound payment) create_invoice(Arc::clone(&node)); println!("\nPay the invoice from Node B in Polar, then press Enter when done..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); // PaymentReceived will have already fired and printed on the event loop task. // Step 2: Send an outbound BOLT11 payment to Node B println!("\nGenerate an invoice on Node B in Polar."); println!("Paste it here, then press Enter:"); let mut invoice_input = String::new(); std::io::stdin().read_line(&mut invoice_input).unwrap(); send_payment(Arc::clone(&node), invoice_input.trim()); // Step 3: Create a BOLT12 offer for Node B to pay 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..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); // Step 4: Send a BOLT12 payment to Node B println!("Press enter to send a BOLT12 payment to Node B..."); std::io::stdin().read_line(&mut String::new()).unwrap(); println!("\nGenerate an offer on Node B in Polar."); println!("Paste it here, then press Enter:"); let mut offer_input = String::new(); std::io::stdin().read_line(&mut offer_input).unwrap(); send_bolt12_payment(Arc::clone(&node), offer_input.trim()); // Step 5: Send a spontaneous payment to Node B, no invoice needed println!("Press Enter to send a spontaneous payment to Node B (no invoice needed)..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); send_spontaneous_payment(Arc::clone(&node), node_b_pubkey); // 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;}Run with cargo run. After funding and opening the channel, mine blocks in Polar to confirm it. You'll see ChannelReady print from the event loop task. From that point, each payment step is driven by your input, and the event loop prints the result as soon as the node delivers it.\ \ \ \ \ \ The complete main.rsHere is the full file for reference. Every function is in place, main() drives the interactive flow, and the event loop handles everything the node surfaces on its own task.use ldk_node::Builder;use ldk_node::Event;use ldk_node::bitcoin::Network;use ldk_node::bitcoin::secp256k1::PublicKey;use ldk_node::lightning::ln::msgs::SocketAddress;use ldk_node::lightning::offers::offer::Offer;use ldk_node::lightning_invoice::Bolt11Invoice;use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description};use ldk_node::payment::{PaymentDirection, PaymentStatus};use std::str::FromStr;use std::sync::Arc;// --- Helpers ---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, ); }}// --- Node setup ---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(), 18443, "polaruser".to_string(), "polarpass".to_string(), ); builder.set_gossip_source_p2p(); builder.build_with_fs_store().unwrap()}fn start_node(node: Arc) { node.start().unwrap(); println!("Node ID: {}", node.node_id()); println!("Funding address: {}", node.onchain_payment().new_address().unwrap()); println!("\n[channels at startup]"); print_channels(&node); println!("\n[payments at startup]"); print_payments(&node);}// --- Channel ---fn open_channel(node: Arc) { let peer_pubkey = PublicKey::from_str("NODE_B_PUBKEY_FROM_POLAR").unwrap(); let peer_addr = SocketAddress::from_str("127.0.0.1:NODE_B_PORT").unwrap(); node.open_channel(peer_pubkey, peer_addr, 100_000, Some(50_000_000), None).unwrap(); println!("Channel open request sent."); println!("\n[channels after open request]"); print_channels(&node);}// --- Payments ---fn create_invoice(node: Arc) { let invoice_description = Bolt11InvoiceDescription::Direct(Description::new(String::from("coffee")).unwrap()).into(); let invoice = node .bolt11_payment() .receive(10_000, &invoice_description, 3600) .unwrap(); println!("\nNode A invoice generated. Pay this from Node B in Polar:"); println!("{}", invoice);}fn send_payment(node: Arc, invoice_str: &str) { let invoice = match Bolt11Invoice::from_str(invoice_str) { Ok(i) => i, Err(e) => { eprintln!("\nInvalid invoice string ({e:?}). Skipping payment."); return; } }; match node.bolt11_payment().send(&invoice, None) { Ok(payment_id) => { println!("\nPayment sent to Node B (id: {})", payment_id); println!("Waiting for result in event loop..."); } Err(e) => eprintln!("\nFailed to send payment ({e:?})."), }}fn create_offer(node: Arc) { 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 BOLT12 offer ({e:?}). Make sure your node is connected to an onion-message-capable peer." ), }}fn send_bolt12_payment(node: Arc, offer_str: &str) { let offer = match Offer::from_str(offer_str) { Ok(o) => o, Err(e) => { eprintln!("\nInvalid offer string ({e:?}). Skipping BOLT12 payment."); 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:?})."), }}fn send_spontaneous_payment(node: Arc, recipient_pubkey: PublicKey) { let payment_id = node .spontaneous_payment() .send(5_000, recipient_pubkey, None) .unwrap(); println!("\nSpontaneous payment sent to Node B (id: {})", payment_id); println!("Waiting for result in event loop...");}// --- Event loop ---async fn run_event_loop(node: Arc) { 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 ); println!("\n[channels after ChannelReady]"); print_channels(&node); node.event_handled().unwrap(); } Event::PaymentReceived { payment_id, amount_msat, .. } => { println!( "\nPayment received: {} msats (id: {:?})", amount_msat, payment_id ); println!("\n[payments after receive]"); 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 ); println!("\n[payments after success]"); print_payments(&node); node.event_handled().unwrap(); } Event::PaymentFailed { payment_id, reason, .. } => { println!( "\nPayment failed (id: {:?}, reason: {:?})", payment_id, reason ); println!("\n[payments after failure]"); 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(); } } }}// --- Entry point ---#[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 node_b_pubkey = PublicKey::from_str("NODE_B_PUBKEY_FROM_POLAR").unwrap(); open_channel(Arc::clone(&node)); tokio::spawn(run_event_loop(Arc::clone(&node))); println!("\nMine blocks in Polar to confirm the channel."); println!("Press Enter once you see ChannelReady printed above..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); create_invoice(Arc::clone(&node)); println!("\nPay the invoice from Node B in Polar, then press Enter when done..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); println!("\nGenerate an invoice on Node B in Polar."); println!("Paste it here, then press Enter:"); let mut invoice_input = String::new(); std::io::stdin().read_line(&mut invoice_input).unwrap(); send_payment(Arc::clone(&node), invoice_input.trim()); 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..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); println!("Press enter to send a BOLT12 payment to Node B..."); std::io::stdin().read_line(&mut String::new()).unwrap(); println!("\nGenerate an offer on Node B in Polar."); println!("Paste it here, then press Enter:"); let mut offer_input = String::new(); std::io::stdin().read_line(&mut offer_input).unwrap(); send_bolt12_payment(Arc::clone(&node), offer_input.trim()); println!("Press Enter to send a spontaneous payment to Node B (no invoice needed)..."); let mut input = String::new(); std::io::stdin().read_line(&mut input).unwrap(); send_spontaneous_payment(Arc::clone(&node), node_b_pubkey); println!("\nWaiting for remaining events..."); tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;}What you can build from hereOnce the basics are working, there are a few directions worth exploring.Embedding a node in a backend service gives you Lightning payments without routing through a third-party provider. The node runs as a long-lived process alongside your application, and payments arrive through the event loop rather than webhooks.LDK Node has first-class support for the LSPS protocols: LSPS0, LSPS1, and LSPS2, the open standards for Lightning Service Providers. Building a service that opens channels on demand for users goes through those protocols. Work in ldk-node on multi-LSP configurations makes it possible to connect to more than one LSP and discover their capabilities automatically via LSPS0 protocol negotiation.The Swift and Kotlin bindings wrap the same Rust core, so the event loop, the Builder pattern, and the payment APIs behave identically on mobile. The mental model transfers directly.Where to go nextThe ldk-node GitHub repository has the source, the CHANGELOG, and the issue tracker. The PR history is genuinely useful for understanding why design decisions were made.The API docs on docs.rs cover the full surface, including things this article did not get into: on-chain sends, channel configuration, and other chain source options.The LDK Discord is active. The #ldk-dev channel is where contributors and users discuss implementation questions, and the maintainers are responsive. If you run into issues following this article, #ldk-help is the right place to ask.LDK Node makes Lightning approachable without hiding what’s actually happening. The API is small enough to hold in your head, the event model maps cleanly to how the protocol works under the hood, and the escape hatch to raw LDK is there when you need it. If the protocol has felt too complex to build on before, this is the right starting point.\