Your First Lightning Node in Rust

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.


00 — What you are actually building

By the end you will have two things running at once, and keeping them straight is most of the battle:

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.

Why ldk-node and not LDK

LDK — 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.

What you need before you start

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

01 — Building the test network

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 bob is here for.

Finding the addresses your code needs

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 — the pubkey@host:port that 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.4 is 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.


02 — Setting up the Rust project

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 /target and 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.


03 — Two helpers that make the node legible

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_000 capacity is 100,000 sats, while 50_000_000 pushed 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.


04 — Building the node

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()
}

The four decisions you just made

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.


05 — Starting the node and funding it

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.

Funding: the step everyone gets wrong

⚠️ 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:

  1. Blocks must exist. A fresh Polar network sits at block height 1. Newly mined coins are coinbase outputs, and Bitcoin makes those unspendable for 100 blocks. So the chain needs to be well past 100 before anyone can send you anything.
  2. Someone must have spendable coins. That’s 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.


06 — Opening a channel

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);
}

The push amount, and why it saves you an hour

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: false is expected. After open_channel() returns, the funding transaction has been broadcast but not confirmed, so print_channels shows ready: 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 the ChannelReady event.


07 — The event loop

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.


08 — Receiving and sending a BOLT11 payment

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.

Receiving

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.

Sending

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.


09 — BOLT12 offers

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_offer fails 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_payment is correct and works against any BOLT12-capable payer; you can also drive bob’s Core Lightning CLI directly with fetchinvoice and pay if you want to see it work.


10 — Spontaneous payments (keysend)

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.


11 — Wiring it together

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.


12 — When it doesn’t work

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.

Reading a failure properly

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.


13 — Where to go from here

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.

Reference

   
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.