Development of Lightning Telegram Bot
The Lightning Network is not just "fast Bitcoin." It is a separate protocol with its own payment channel model, routing algorithms, and specific types of attacks. A Telegram bot on top of Lightning is a UI layer over this infrastructure, and an error in integration costs users real money.
Typical request: "build a bot so users can receive and send satoshi." Behind this lies the choice of node architecture, custodial storage model, channel liquidity management, and failed payment handling logic.
Architecture: Custodial vs Non-Custodial
This is the first and most important choice, determining everything else.
Custodial Model
The bot holds a single Lightning node, users are just database records with balances. Payments between bot users are off-chain operations within your database, without real Lightning transactions.
Advantages: no routing problems, instant internal transfers, simple implementation. Disadvantages: you are custodian, requires license in most jurisdictions, users trust you — like an exchange, just less regulated.
Architecture:
Telegram Bot → Node.js service → PostgreSQL (balances) → LND/CLN node (for external payments)
Internal transfer is a database transaction. External withdrawal is a real Lightning invoice through the node. Deposit is generating invoice through the node, monitoring payment.
Non-Custodial via LSP
Lightning Service Provider (LSP) manages user's channels. User holds own keys, LSP provides liquidity. LSPS0-LSPS2 protocols standardize this interaction.
Implementation is more complex: need to integrate with LSP API (Breez SDK, LDK-node), manage channel open/close, explain channel concept to users. For Telegram bot this is usually excessive.
Practical choice for most projects: custodial model with transparent user communication that the bot is custodial.
Lightning Node: LND vs Core Lightning
LND (Lightning Network Daemon)
Go, developed by Lightning Labs. gRPC API with good documentation. Most popular for integrations — more SDKs and examples.
import { AuthenticatedLnd } from "lightning";
import { createInvoice, payViaRoutes, getWalletInfo } from "lightning";
// Creating invoice for deposit
const { request, id } = await createInvoice({
lnd,
tokens: 10000, // satoshi
description: `Deposit for user ${userId}`,
expires_at: new Date(Date.now() + 3600 * 1000).toISOString(),
});
The lightning package (npm) is a typed wrapper over LND gRPC. Much more convenient than raw gRPC.
Core Lightning (CLN)
C, developed by Blockstream. More modular architecture via plugin system. JSON-RPC API. Smaller ecosystem, but more performant on large channel count.
For most Telegram bots with up to 10k users — no difference. Choose by stack familiarity and documentation availability.
Managing Channel Liquidity
The main operational problem of Lightning bot — liquidity. A Lightning channel has inbound capacity (how much you can receive) and outbound capacity (how much you can send). After opening a channel all liquidity is on your side — you can send but cannot receive.
For a bot accepting user deposits, inbound liquidity is needed:
Buying inbound liquidity — services like Bitrefill Thor, Lightning Pool (LND), or manual arrangements with routing nodes. You pay the provider, they open a channel to you with balance on their side.
Circular rebalancing — if balance shifted (many outbound operations), conduct circular payment through the network: send via A → B → C → yourself. Pay routing fee, but rebalance channel.
// Monitoring channel balance
const channels = await getChannels({ lnd });
for (const channel of channels.channels) {
const localRatio = channel.local_balance / channel.capacity;
if (localRatio < 0.2) {
// Low outbound — need rebalance
await alertOps(`Channel ${channel.id}: low outbound liquidity`);
}
if (localRatio > 0.8) {
// Low inbound — cannot accept payments
await alertOps(`Channel ${channel.id}: low inbound liquidity`);
}
}
For production bot you need automatic rebalancing or integration with liquidity service.
Payment Processing in Bot
Webhook vs Polling for Telegram
Webhook is preferable: lower latency, no limit on request frequency. Requires public HTTPS endpoint. For production — webhook is mandatory.
import { Telegraf } from "telegraf";
const bot = new Telegraf(BOT_TOKEN);
bot.command("deposit", async (ctx) => {
const userId = ctx.from.id.toString();
const invoice = await createDepositInvoice(userId, 0); // any amount
await ctx.reply(
`Your Lightning invoice for deposit:\n\n\`${invoice.request}\`\n\nValid 1 hour.`,
{ parse_mode: "Markdown" }
);
});
Monitoring Incoming Payments
LND provides subscribeToInvoices — stream that notifies on each settlement:
const sub = subscribeToInvoices({ lnd });
sub.on("invoice_updated", async (invoice) => {
if (!invoice.is_confirmed) return;
const userId = await getUserByInvoiceId(invoice.id);
if (!userId) return;
await db.transaction(async (trx) => {
await trx("users")
.where({ id: userId })
.increment("balance_sats", invoice.received);
await trx("transactions").insert({
user_id: userId,
type: "deposit",
amount_sats: invoice.received,
lightning_id: invoice.id,
confirmed_at: new Date(),
});
});
await bot.telegram.sendMessage(userId,
`Received ${invoice.received} sat. Balance updated.`
);
});
Important: operation must be idempotent — if worker crashed and restarted, reprocessing the same invoice must not credit twice. lightning_id with UNIQUE constraint — simple protection.
Sending Payments (Withdrawal)
User inserts invoice, bot pays it:
async function processWithdrawal(userId: string, invoiceStr: string) {
const decoded = await decodePaymentRequest({ lnd, request: invoiceStr });
// Checks
if (decoded.tokens > user.balance_sats) throw new Error("Insufficient funds");
if (decoded.expires_at < new Date()) throw new Error("Invoice expired");
// Reserve balance BEFORE sending
await db("users")
.where({ id: userId })
.decrement("balance_sats", decoded.tokens);
try {
const payment = await pay({ lnd, request: invoiceStr });
// Success, record transaction
await recordWithdrawal(userId, decoded.tokens, payment.id);
} catch (err) {
// Payment failed — return balance
await db("users")
.where({ id: userId })
.increment("balance_sats", decoded.tokens);
throw new Error(`Payment failed: ${err.message}`);
}
}
Order of operations is critical: first reserve, then send. Otherwise — double spend on parallel requests. Return on error — mandatory.
Specific Attacks on Lightning Bots
Invoice replay: user sends same invoice twice. Defense — store all processed payment hashes, check before processing.
Amount mismatch on deposits: user created invoice for 1000 sat, someone sent 999 sat (partial amount). LND by default accepts any amount if invoice has no tokens. Always create amount-less invoice or explicitly specify amount and check received.
Timing attack on withdrawal: parallel withdrawal requests simultaneously read balance and both see sufficient funds. Defense — optimistic lock via UPDATE users SET balance = balance - X WHERE balance >= X AND id = Y, check affected rows.
Stack and Deployment
- Node: LND 0.18.x on separate server/VPS, Bitcoin full node or Neutrino (light client)
- Backend: Node.js + TypeScript + Fastify
- Database: PostgreSQL, tables: users, invoices, transactions, channels_log
- Monitoring: Grafana + LND Prometheus exporter, alerts on channel offline, low liquidity
- Backups: SCB (Static Channel Backups) automatically after each channel change — this is mandatory, without it on node crash user funds are lost
For running Lightning node you need to reserve for channel deposit: minimum 0.1 BTC for small bot, 0.5–1 BTC for production with normal liquidity.
Development Timeline
MVP with custodial model, deposit/withdraw, basic p2p transfer — 3–4 weeks. Production with auto-rebalancing, liquidity monitoring, multi-channel management, full transaction audit — 8–12 weeks.







