// Core Lightning REST backend — ported from Zeus's `backends/CLNRest.ts` together with // `backends/CoreLightningRequestHandler.ts`, which holds all of the actual shaping logic (balances, // channels, peers, chain transactions) that CLNRest.ts delegates to. // // CLNRest is POST-only: every command is `POST /v1/` with a JSON body (`{}` when the command // takes no arguments) and a `Rune: ` header. There are no GET endpoints and no path parameters. // // UNITS. CLN reports millisatoshis two ways depending on the endpoint and on the node's deprecated-API // setting: a bare JSON number (`1234`) or a suffixed string (`"1234msat"`). Zeus assumes the number form // everywhere — `opArray[i].amount_msat / 1000` in CoreLightningRequestHandler — which yields NaN against a // node still emitting the string form. `parseMsat` below accepts both. // // INTENTIONAL BEHAVIOURAL DIFFERENCES FROM ZEUS // // 1. No Tor, no in-flight call de-duplication, no `trusty:`-style blanket TLS bypass — see the same three // notes in `lnd.ts`. Self-signed certs are accepted only when the owner opts in per node. // 2. `payLightningInvoice` no longer sends `amount_msat: Number(data.amt && data.amt * 1000)`. When `amt` // is undefined that expression is `0`, and CLN rejects an explicit zero amount on an invoice that // already carries one. We omit the field unless an amount was actually requested. // 3. `feeLimitMsat` is honoured via `pay`'s `maxfee`. Zeus declares `supportsCustomFeeLimit = () => false` // for CLN and only ever sends `maxfeepercent`; both forms are supported here. // 4. `signMessage` returns the **zbase** signature. CLN's signmessage returns `signature` (hex), // `recid` and `zbase`, but its own `checkmessage` only accepts zbase — returning the hex form would // produce a signature this backend could not verify. // 5. Channel states are reported rather than dropped. Zeus's `listPeerChannels` filters ONCHAIN, CLOSED // and CHANNELD_AWAITING_LOCKIN out of the list entirely because its flat model has nowhere to put // them; `Channel.status` does, so awaiting-lockin surfaces as 'pending-open'. // 6. Chain-transaction confirmations are `tip - height + 1`. Zeus computes `tip - height`, which reports // a transaction in the tip block as having zero confirmations. // 7. `getInvoices` recovers each invoice's creation time by reading the 35-bit timestamp straight out of // the BOLT11 data part. Zeus does the same thing via `Bolt11Utils.decode`; listinvoices genuinely has // no creation-time column, only `paid_at` and `expires_at`. // 8. `customPreimages` is NOT declared, matching Zeus's `supportsCustomPreimages = () => false`, even // though CLN's `invoice` command does accept a `preimage` argument. // 9. `accounts` is NOT declared — `supportsAccounts = () => false` in Zeus. CLN has no account concept. // 10. `NewAddressRequest.peek` is ignored: `newaddr` always derives a fresh address. // 11. `SendCoinsRequest.rbf` and `.label` are ignored — `withdraw` exposes neither. // 12. BOLT12 offer management (`listoffers`, `offer`, `disableoffer`, `fetchinvoice`, `invoicerequest`, // `sendinvoice`) and the askrene/getroutes/sendpay routing surface are not ported: `WalletBackend` // has no methods for them. The `offers` capability is still declared, since the node supports them. // // DEPENDENCIES ON CLN PLUGINS. `getInvoices`, `getPayments` and `getTransactions` go through the **sql** // plugin, exactly as Zeus does; `getTransactions` additionally reads the **bookkeeper**'s // `bkpr_accountevents` table. Both plugins ship with CLN and are enabled by default, but a node that has // disabled either will fail those three calls and no others. import * as bitcoin from 'bitcoinjs-lib'; import { BackendError, type AddressType, type Balances, type BackendKind, type BitcoinNetwork, type Capability, type Channel, type CreateInvoiceRequest, type DecodedInvoice, type FeeEstimates, type Invoice, type InvoiceState, type KeysendRequest, type NewAddressRequest, type NodeInfo, type OnchainTx, type PayInvoiceRequest, type Payment, type PaymentStatus, type Peer, type SendCoinsRequest, type SendCoinsResult, type SignMessageResult, type Utxo, type VerifyMessageResult, } from '../types'; import { BaseBackend, httpJson } from './base'; // ── upstream wire types ────────────────────────────────────────────────────────────────────────── // // Field names are lightningd's own, verbatim. `Msat` marks every field that may arrive as either a number // or a `"…msat"` string — see the UNITS note above. type Msat = number | string; type ClnGetInfo = { id?: string; alias?: string; version?: string; blockheight?: number; network?: string; fees_collected_msat?: Msat; warning_bitcoind_sync?: string; warning_lightningd_sync?: string; }; type ClnFundsOutput = { txid?: string; output?: number; amount_msat?: Msat; scriptpubkey?: string; address?: string; status?: string; blockheight?: number; reserved?: boolean; }; type ClnFundsChannel = { peer_id?: string; our_amount_msat?: Msat; amount_msat?: Msat; connected?: boolean; state?: string; }; type ClnListFunds = { outputs?: ClnFundsOutput[]; channels?: ClnFundsChannel[] }; type ClnPeerChannel = { peer_id?: string; peer_connected?: boolean; state?: string; channel_id?: string; short_channel_id?: string; funding_txid?: string; funding_outnum?: number; total_msat?: Msat; to_us_msat?: Msat; private?: boolean; }; type ClnPeer = { id?: string; connected?: boolean; num_channels?: number; netaddr?: string[] }; type ClnNode = { nodeid?: string; alias?: string }; type ClnTxOutput = { index?: number; amount_msat?: Msat; scriptPubKey?: string }; type ClnTransaction = { hash?: string; rawtx?: string; blockheight?: number; txindex?: number; outputs?: ClnTxOutput[]; }; /** The sql plugin answers with positional rows in the order the SELECT names its columns. */ type ClnSqlResult = { rows?: SqlCell[][] }; type ClnFeerateEstimate = { blockcount?: number; feerate?: number; smoothed_feerate?: number }; type ClnFeerates = { perkb?: { min_acceptable?: number; max_acceptable?: number; floor?: number; opening?: number; estimates?: ClnFeerateEstimate[]; }; }; type ClnNewAddr = { bech32?: string; p2tr?: string }; type ClnWithdraw = { tx?: string; txid?: string; psbt?: string }; type ClnInvoiceResponse = { bolt11?: string; payment_hash?: string; payment_secret?: string; expires_at?: number; created_index?: number; }; type ClnListInvoice = { label?: string; bolt11?: string; bolt12?: string; payment_hash?: string; amount_msat?: Msat; status?: string; amount_received_msat?: Msat; paid_at?: number; payment_preimage?: string; description?: string; expires_at?: number; }; type ClnDecoded = { type?: string; valid?: boolean; created_at?: number; expiry?: number; payee?: string; amount_msat?: Msat; payment_hash?: string; description?: string; min_final_cltv_expiry?: number; features?: string; routes?: unknown[]; }; type ClnPayResult = { payment_preimage?: string; payment_hash?: string; created_at?: number; parts?: number; amount_msat?: Msat; amount_sent_msat?: Msat; status?: string; destination?: string; }; type ClnSignMessage = { signature?: string; recid?: number; zbase?: string }; type ClnCheckMessage = { verified?: boolean; pubkey?: string }; // ── helpers ────────────────────────────────────────────────────────────────────────────────────── /** Accepts `1234`, `"1234"` and `"1234msat"`; returns a decimal msat string, never a number. */ function parseMsat(value: Msat | null | undefined): string | null { if (value == null) return null; if (typeof value === 'number') return Number.isFinite(value) ? Math.round(value).toString() : null; const match = /^(\d+)\s*(msat)?$/i.exec(value.trim()); return match?.[1] ?? null; } /** msat (either form) → whole satoshis, truncated the way CLN's own sat-denominated views truncate. */ function msatToSats(value: Msat | null | undefined): number { const msat = parseMsat(value); return msat == null ? 0 : Math.floor(Number(msat) / 1000); } function toNumber(value: unknown): number { const n = Number(value); return Number.isFinite(n) ? n : 0; } /** * Caller-supplied msat decimal string → the JSON number CLN's API expects. * * CLN takes amounts as JSON numbers, so a msat value above 2^53 would silently lose precision on the * way out — a request to pay 1 msat more than MAX_SAFE_INTEGER would be rounded rather than rejected. * The amounts involved are absurd (2^53 msat ≈ 90,000 BTC) but the failure is silent and the input is * caller-controlled, so it is checked rather than assumed. Mirrors toMsatNumber() in nwc.ts. */ function msatToApiNumber(msat: string, field: string): number { let value: bigint; try { value = BigInt(msat); } catch { throw new BackendError(`${field} must be a decimal msat string, got "${msat}"`, 400, 'BAD_AMOUNT'); } if (value < 0n || value > BigInt(Number.MAX_SAFE_INTEGER)) { throw new BackendError(`${field}=${msat}msat is out of range`, 400, 'BAD_AMOUNT'); } return Number(value); } function toNetwork(network: string | undefined): BitcoinNetwork { switch (network) { case 'testnet': case 'testnet4': return 'testnet'; case 'signet': return 'signet'; case 'regtest': return 'regtest'; default: return 'bitcoin'; } } const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'; /** * BOLT11 puts a 35-bit creation timestamp in the first seven characters of the data part, immediately * after the separator (the LAST '1' in the string, per the spec — the human-readable part may contain * one). Reading it directly avoids pulling in a full bech32 decoder just for `listinvoices`, which has no * creation-time column at all. */ function bolt11Timestamp(bolt11: string | null | undefined): number | null { if (!bolt11) return null; const invoice = bolt11.toLowerCase(); const separator = invoice.lastIndexOf('1'); if (separator < 0) return null; const data = invoice.slice(separator + 1); if (data.length < 7) return null; let timestamp = 0; for (let i = 0; i < 7; i++) { const char = data[i]; // indexOf('') would answer 0, so the empty case has to be rejected before the lookup. if (!char) return null; const value = BECH32_CHARSET.indexOf(char); if (value < 0) return null; timestamp = timestamp * 32 + value; } return timestamp; } // ── sql plugin cells ───────────────────────────────────────────────────────────────────────────── // // The sql plugin returns positional rows, so every read is an index into an array of loosely-typed cells. // These three readers keep that in one place instead of scattering casts through the mappers. type SqlCell = string | number | boolean | null | undefined; function cellStr(cell: SqlCell): string | null { return typeof cell === 'string' && cell.length > 0 ? cell : null; } function cellNum(cell: SqlCell): number | null { if (cell == null || typeof cell === 'boolean') return null; const n = Number(cell); return Number.isFinite(n) ? n : null; } function cellMsat(cell: SqlCell): Msat | undefined { return typeof cell === 'number' || typeof cell === 'string' ? cell : undefined; } /** * The sql plugin takes a whole SQL string, not bound parameters, so `limit` is the one caller-supplied * value that reaches it. Force it to a bounded integer before it is ever interpolated. */ function clampLimit(limit: number | undefined, fallback: number): number { const n = Math.floor(Number(limit)); if (!Number.isFinite(n) || n <= 0) return fallback; return Math.min(n, 5000); } /** * Address type by prefix. `listfunds` reports the address but not its kind, and the prefix is an exact * discriminator across every network CLN runs on, so no lookup is needed. */ function addressType(address: string | undefined): AddressType | null { if (!address) return null; const addr = address.toLowerCase(); const bech32Prefix = /^(bc1|tb1|bcrt1)/.exec(addr); if (bech32Prefix) return addr[bech32Prefix[0].length] === 'p' ? 'p2tr' : 'p2wpkh'; if (addr.startsWith('3') || addr.startsWith('2')) return 'p2sh-p2wpkh'; if (addr.startsWith('1') || addr.startsWith('m') || addr.startsWith('n')) return 'p2pkh'; return null; } /** bitcoinjs has no signet parameter set; signet reuses testnet's version bytes and 'tb' hrp. */ function bitcoinjsNetwork(network: BitcoinNetwork): bitcoin.Network { switch (network) { case 'regtest': return bitcoin.networks.regtest; case 'testnet': case 'signet': return bitcoin.networks.testnet; default: return bitcoin.networks.bitcoin; } } function toPaymentStatus(status: string | undefined): PaymentStatus { switch (status) { case 'complete': return 'succeeded'; case 'failed': return 'failed'; default: return 'pending'; } } /** * CLN's channel state machine → the coarse status in `Channel`. Zeus never models this; it filters the * terminal states out and treats everything else as open. */ function toChannelStatus(state: string | undefined): string { switch (state) { case 'CHANNELD_NORMAL': return 'open'; case 'OPENINGD': case 'CHANNELD_AWAITING_LOCKIN': case 'DUALOPEND_OPEN_INIT': case 'DUALOPEND_AWAITING_LOCKIN': case 'DUALOPEND_OPEN_COMMITTED': case 'DUALOPEND_OPEN_COMMIT_READY': return 'pending-open'; case 'CHANNELD_SHUTTING_DOWN': case 'CLOSINGD_SIGEXCHANGE': case 'CLOSINGD_COMPLETE': return 'pending-close'; case 'AWAITING_UNILATERAL': case 'FUNDING_SPEND_SEEN': case 'ONCHAIN': return 'force-closing'; case 'CLOSED': return 'closed'; default: return state ?? 'open'; } } /** Column indices for the two SQL projections below — positional rows are otherwise unreadable. */ const INVOICE_COL = { label: 0, bolt11: 1, bolt12: 2, paymentHash: 3, amountMsat: 4, status: 5, amountReceivedMsat: 6, paidAt: 7, preimage: 8, description: 9, expiresAt: 10, } as const; const PAYMENT_COL = { paymentHash: 0, groupId: 1, status: 2, destination: 3, createdAt: 4, description: 5, bolt11: 6, bolt12: 7, amountSentMsat: 8, amountMsat: 9, preimage: 10, } as const; const ACCOUNT_EVENT_COL = { account: 0, tag: 1, outpoint: 2, creditMsat: 3, debitMsat: 4, timestamp: 5, blockheight: 6, } as const; /** TLV record carrying a free-text note alongside a keysend payment. */ const KEYSEND_MESSAGE_RECORD = '34349334'; export type ClnRestConfig = { /** Full base URL including scheme and port, e.g. `https://192.168.1.5:3010`. */ url: string; /** The rune authorising this connection. Sent as the `Rune` header. */ rune: string; allowSelfSigned?: boolean; }; // ── the backend ────────────────────────────────────────────────────────────────────────────────── export class ClnRestBackend extends BaseBackend { readonly kind: BackendKind = 'cln-rest'; protected readonly capabilities: ReadonlySet = new Set([ 'onchainReceive', 'onchainSend', 'coinControl', // Zeus: supportsCoinControl / supportsChannelCoinControl 'sweep', // `withdraw … satoshi: "all"` 'lightningReceive', 'lightningSend', 'keysend', 'offers', // Zeus: supportsOffers / supportsListingOffers / supportsBolt12Address 'channels', 'peers', 'routing', 'signMessage', // Deliberately absent: psbt, bumpFee (supportsBumpFee = false), accounts (supportsAccounts = false), // customPreimages (supportsCustomPreimages = false). ]); constructor(private readonly config: ClnRestConfig) { super(); } // ── transport ────────────────────────────────────────────────────────────────────────────────── /** Every CLNRest command is a POST with a JSON body; commands without arguments still need `{}`. */ private call(command: string, body: Record = {}, timeoutMs?: number): Promise { return httpJson({ base: this.config.url, path: `/v1/${command}`, method: 'POST', body, headers: { Rune: this.config.rune }, allowSelfSigned: this.config.allowSelfSigned, timeoutMs, }); } private sql(query: string): Promise { return this.call('sql', { query }); } /** * Resolve node aliases in one pass. Zeus issues a `listnodes` per channel and per peer inside * `Promise.all` and lets a single failure reject the whole list; we de-duplicate by pubkey and swallow * individual lookups, because a missing alias must not cost you the channel list. */ private async resolveAliases(pubkeys: string[]): Promise> { const unique = [...new Set(pubkeys.filter(Boolean))]; const entries = await Promise.all( unique.map(async (id): Promise<[string, string] | null> => { try { const res = await this.call<{ nodes?: ClnNode[] }>('listnodes', { id }); const alias = res.nodes?.[0]?.alias; return alias ? [id, alias] : null; } catch { return null; } }), ); return new Map(entries.filter((entry): entry is [string, string] => entry !== null)); } // ── node / balances ──────────────────────────────────────────────────────────────────────────── override async getInfo(): Promise { const info = await this.call('getinfo'); return { kind: this.kind, pubkey: info.id ?? null, alias: info.alias ?? null, version: info.version ?? null, network: toNetwork(info.network), blockHeight: info.blockheight ?? null, // CLN reports lag by ADDING a warning field; their absence is the only "synced" signal there is. synced: !info.warning_bitcoind_sync && !info.warning_lightningd_sync, }; } override async getBalances(): Promise { // One listfunds call answers both halves — Zeus makes two, one via getBalance and one via // getOffchainBalance, each re-fetching the identical payload. const funds = await this.call('listfunds'); let onchainConfirmed = 0; let onchainUnconfirmed = 0; for (const output of funds.outputs ?? []) { // 'spent' and 'immature' outputs are also listed; neither is spendable balance. if (output.status === 'confirmed') onchainConfirmed += msatToSats(output.amount_msat); else if (output.status === 'unconfirmed') onchainUnconfirmed += msatToSats(output.amount_msat); } let lightningBalance = 0; let lightningInbound = 0; for (const channel of funds.channels ?? []) { // Zeus counts only connected CHANNELD_NORMAL channels towards the spendable balance; funds in // disconnected or still-opening channels are real but not usable right now. if (channel.state !== 'CHANNELD_NORMAL' || channel.connected !== true) continue; const ours = Number(parseMsat(channel.our_amount_msat) ?? '0'); const total = Number(parseMsat(channel.amount_msat) ?? '0'); lightningBalance += Math.floor(ours / 1000); lightningInbound += Math.floor((total - ours) / 1000); } // Core Lightning reserves coins itself; Officer freezing is the route's overlay, so 0 here. return { onchainConfirmed, onchainUnconfirmed, onchainFrozen: 0, lightningBalance, lightningInbound }; } // ── on-chain ─────────────────────────────────────────────────────────────────────────────────── /** * Port of `CoreLightningRequestHandler.getChainTransactions`. * * CLN has no single "wallet transactions" command. `listtransactions` knows every transaction the node * has seen but not what it MEANT; the bookkeeper's `bkpr_accountevents` knows the meaning (deposit to * the wallet vs. payment out to an external account) but is keyed by outpoint. So the two are joined on * txid, and any transaction that is really a channel open or close is excluded — otherwise a channel * funding would show up as an ordinary withdrawal. */ override async getTransactions(opts?: { limit?: number }): Promise { const limit = clampLimit(opts?.limit, 150); const query = 'SELECT account, tag, outpoint, credit_msat, debit_msat, timestamp, blockheight ' + "FROM bkpr_accountevents WHERE (tag='deposit' OR tag='to_them' OR tag='channel_open' " + `OR tag='channel_close') ORDER BY timestamp DESC LIMIT ${limit}`; // Zeus uses allSettled here so a missing bookkeeper or sql plugin degrades instead of failing; only // getinfo is load-bearing (it supplies the chain tip and the network for address decoding). const [eventsResult, txsResult, infoResult] = await Promise.allSettled([ this.sql(query), this.call<{ transactions?: ClnTransaction[] }>('listtransactions'), this.call('getinfo'), ]); if (infoResult.status !== 'fulfilled') return []; const tip = infoResult.value.blockheight ?? 0; const network = bitcoinjsNetwork(toNetwork(infoResult.value.network)); const rows = eventsResult.status === 'fulfilled' ? (eventsResult.value.rows ?? []) : []; const transactions = txsResult.status === 'fulfilled' ? (txsResult.value.transactions ?? []) : []; const txidOf = (row: SqlCell[]): string | null => { const outpoint = cellStr(row[ACCOUNT_EVENT_COL.outpoint]); return outpoint ? (outpoint.split(':')[0] ?? null) : null; }; const deposits = rows.filter((row) => row[ACCOUNT_EVENT_COL.tag] === 'deposit'); const walletDeposits = deposits.filter((row) => row[ACCOUNT_EVENT_COL.account] === 'wallet'); const externalDeposits = deposits.filter((row) => row[ACCOUNT_EVENT_COL.account] === 'external'); const isChannelChange = (txid: string): boolean => rows.some( (row) => txidOf(row) === txid && (row[ACCOUNT_EVENT_COL.tag] === 'channel_open' || row[ACCOUNT_EVENT_COL.tag] === 'channel_close'), ); const result: OnchainTx[] = []; for (const tx of transactions) { const txid = tx.hash ?? ''; if (!txid || isChannelChange(txid)) continue; // A deposit booked against the 'external' account is money leaving us; against 'wallet', arriving. const outgoing = externalDeposits.find((row) => txidOf(row) === txid); const incoming = outgoing ? undefined : walletDeposits.find((row) => txidOf(row) === txid); const row = outgoing ?? incoming; if (!row) continue; const credit = Number(parseMsat(cellMsat(row[ACCOUNT_EVENT_COL.creditMsat])) ?? '0'); const amount = Math.floor((outgoing ? -Math.abs(credit) : credit) / 1000); const height = cellNum(row[ACCOUNT_EVENT_COL.blockheight]) || null; const destAddresses: string[] = []; for (const output of tx.outputs ?? []) { if (!output.scriptPubKey) continue; try { destAddresses.push(bitcoin.address.fromOutputScript(Buffer.from(output.scriptPubKey, 'hex'), network)); } catch { // Non-standard or unrecognised script — CLN reports it, we simply have no address for it. } } result.push({ txid, amount, // bkpr records the fee as its own accountevent tag, not on the deposit row. feeSats: null, blockHeight: height, timestamp: cellNum(row[ACCOUNT_EVENT_COL.timestamp]) || null, confirmations: height ? Math.max(0, tip - height + 1) : 0, label: null, destAddresses, rawHex: tx.rawtx ?? null, }); } return result.sort((a, b) => (b.timestamp ?? 0) - (a.timestamp ?? 0)); } override async getNewAddress(req?: NewAddressRequest): Promise<{ address: string; type: AddressType }> { const type: AddressType = req?.type ?? 'p2wpkh'; // CLN dropped p2sh-segwit issuance in v23.05 and has never issued legacy p2pkh // (Zeus: supportsNestedSegWit = () => false). const addresstype = type === 'p2wpkh' ? 'bech32' : type === 'p2tr' ? 'p2tr' : null; if (!addresstype) { throw new BackendError(`core lightning cannot derive a ${type} address`, 400, 'UNSUPPORTED_ADDRESS_TYPE'); } const res = await this.call('newaddr', { addresstype }); // newaddr keys the response by the address type it produced, not by a generic `address` field. const address = addresstype === 'p2tr' ? res.p2tr : res.bech32; if (!address) throw new BackendError('core lightning returned no address', 502); return { address, type }; } override async getUtxos(): Promise { const [funds, info] = await Promise.all([this.call('listfunds'), this.call('getinfo')]); const tip = info.blockheight ?? 0; return (funds.outputs ?? []) .filter((output) => output.status === 'confirmed' || output.status === 'unconfirmed') .map((output) => ({ txid: output.txid ?? '', vout: output.output ?? 0, amountSats: msatToSats(output.amount_msat), address: output.address ?? '', addressType: addressType(output.address), confirmations: output.blockheight ? Math.max(0, tip - output.blockheight + 1) : 0, // CLN's listfunds does not expose the derivation path for its own outputs. derivationPath: null, // `reserved` is CLN's exclusion flag: an output held for an in-flight psbt or channel open. frozen: output.reserved === true, })); } override async estimateFees(): Promise { const res = await this.call('feerates', { style: 'perkb' }); const perkb = res.perkb; const estimates = perkb?.estimates ?? []; // perkb is sat per 1000 vbytes; the interface wants sat/vB, and never below the 1 sat/vB relay floor. const toVbyte = (perKb: number | undefined): number => Math.max(1, Math.ceil(toNumber(perKb) / 1000)); /** Cheapest estimate that still confirms within `blocks`, falling back to the slowest we have. */ const forTarget = (blocks: number): number | undefined => { const eligible = estimates.filter((e) => (e.blockcount ?? 0) <= blocks && e.feerate != null); const best = eligible.length > 0 ? eligible[eligible.length - 1] : estimates[estimates.length - 1]; return best?.feerate ?? perkb?.opening; }; return { fastestFee: toVbyte(forTarget(2)), halfHourFee: toVbyte(forTarget(3)), hourFee: toVbyte(forTarget(6)), economyFee: toVbyte(forTarget(144)), minimumFee: toVbyte(perkb?.floor ?? perkb?.min_acceptable), }; } override async sendCoins(req: SendCoinsRequest): Promise { const body: Record = { destination: req.address, // CLN takes a feerate with an explicit unit; sat/vB × 1000 is the perkb form. feerate: `${Math.round(req.satPerVbyte * 1000)}perkb`, satoshi: req.sendAll ? 'all' : (req.amountSats ?? 0), }; // CLN's utxos are plain `txid:vout` strings, unlike LND's structured outpoints. if (req.outpoints?.length) body.utxos = req.outpoints; if (req.spendUnconfirmed) body.minconf = 0; const res = await this.call('withdraw', body); if (!res.txid) throw new BackendError('core lightning returned no txid', 502); // `withdraw` returns the signed transaction but not the fee it chose. return { txid: res.txid, feeSats: 0, rawHex: res.tx ?? null }; } // ── lightning ────────────────────────────────────────────────────────────────────────────────── private toInvoice(inv: ClnListInvoice): Invoice { const expiresAt = inv.expires_at ?? null; let state: InvoiceState; switch (inv.status) { case 'paid': state = 'settled'; break; case 'expired': state = 'expired'; break; default: state = expiresAt != null && expiresAt * 1000 < Date.now() ? 'expired' : 'open'; } const bolt11 = inv.bolt11 ?? ''; return { paymentHash: inv.payment_hash ?? '', // A BOLT12-only invoice has no bolt11; fall back to the offer-issued string so the row is still // addressable rather than blank. bolt11: bolt11 || (inv.bolt12 ?? ''), amountMsat: parseMsat(inv.amount_msat), amountPaidMsat: parseMsat(inv.amount_received_msat), memo: inv.description ? inv.description : null, state, // See difference 7: listinvoices has no creation-time column. createdAt: bolt11Timestamp(bolt11) ?? inv.paid_at ?? 0, expiresAt, settledAt: inv.paid_at ?? null, preimage: inv.payment_preimage ?? null, // CLN does not flag keysend-created invoices, and has no AMP at all (Zeus: supportsAMP = false). isKeysend: false, isAmp: false, }; } override async getInvoices(opts?: { limit?: number }): Promise { const limit = clampLimit(opts?.limit, 150); const res = await this.sql( 'SELECT label, bolt11, bolt12, payment_hash, amount_msat, status, amount_received_msat, ' + `paid_at, payment_preimage, description, expires_at FROM invoices ORDER BY created_index DESC LIMIT ${limit};`, ); return (res.rows ?? []).map((row) => this.toInvoice({ label: cellStr(row[INVOICE_COL.label]) ?? undefined, bolt11: cellStr(row[INVOICE_COL.bolt11]) ?? undefined, bolt12: cellStr(row[INVOICE_COL.bolt12]) ?? undefined, payment_hash: cellStr(row[INVOICE_COL.paymentHash]) ?? undefined, amount_msat: cellMsat(row[INVOICE_COL.amountMsat]), status: cellStr(row[INVOICE_COL.status]) ?? undefined, amount_received_msat: cellMsat(row[INVOICE_COL.amountReceivedMsat]), paid_at: cellNum(row[INVOICE_COL.paidAt]) ?? undefined, payment_preimage: cellStr(row[INVOICE_COL.preimage]) ?? undefined, description: cellStr(row[INVOICE_COL.description]) ?? undefined, expires_at: cellNum(row[INVOICE_COL.expiresAt]) ?? undefined, }), ); } override async createInvoice(req: CreateInvoiceRequest): Promise { if (req.preimage) this.notSupported('custom preimages'); if (req.isAmp) this.notSupported('AMP invoices'); const res = await this.call('invoice', { description: req.memo ?? '', // `label` is CLN's primary key for an invoice and must be unique per node. label: `officer.${Date.now()}.${Math.floor(Math.random() * 1_000_000)}`, // The literal string 'any' is how CLN expresses a zero-amount (donation) invoice. amount_msat: req.amountMsat ? msatToApiNumber(req.amountMsat, 'amountMsat') : 'any', expiry: req.expirySeconds, // Zeus hardcodes this to true so an invoice is payable through unannounced channels — without it a // node whose inbound liquidity is all private is unreachable. exposeprivatechannels: req.private ?? true, }); const now = Math.floor(Date.now() / 1000); return { paymentHash: res.payment_hash ?? '', bolt11: res.bolt11 ?? '', amountMsat: req.amountMsat ?? null, amountPaidMsat: null, memo: req.memo ?? null, state: 'open', createdAt: bolt11Timestamp(res.bolt11) ?? now, expiresAt: res.expires_at ?? (req.expirySeconds != null ? now + req.expirySeconds : null), settledAt: null, preimage: null, isKeysend: false, isAmp: false, }; } override async lookupInvoice(paymentHash: string): Promise { const res = await this.call<{ invoices?: ClnListInvoice[] }>('listinvoices', { payment_hash: paymentHash }); const invoice = res.invoices?.[0]; return invoice ? this.toInvoice(invoice) : null; } override async decodeInvoice(bolt11: string): Promise { // `decode` handles bolt11 and bolt12 alike but arrives with the offers plugin; `decodepay` is the // classic bolt11-only command and is always present. Try the general one, fall back to the old one. let decoded: ClnDecoded; try { decoded = await this.call('decode', { string: bolt11 }); if (decoded.valid === false) throw new BackendError('invoice failed to decode', 400); } catch (err) { if (err instanceof BackendError && err.status === 400) throw err; decoded = await this.call('decodepay', { bolt11 }); } return { bolt11, paymentHash: decoded.payment_hash ?? '', amountMsat: parseMsat(decoded.amount_msat), description: decoded.description ? decoded.description : null, destination: decoded.payee ?? '', timestamp: decoded.created_at ?? 0, expiry: decoded.expiry ?? 0, cltvExpiry: decoded.min_final_cltv_expiry ?? null, routeHints: (decoded.routes ?? []).length > 0, // CLN hands back a raw hex feature BITMAP where LND names each feature; expand it to the indices of // the set bits so the shape still matches and nothing is invented. features: featureBits(decoded.features), }; } override async getPayments(opts?: { limit?: number }): Promise { const limit = clampLimit(opts?.limit, 150); // Grouping by (payment_hash, groupid) collapses the parts of an MPP payment back into one row; the // per-part rows in `sendpays` would otherwise each look like a separate payment. const res = await this.sql( 'select sp.payment_hash, sp.groupid, min(sp.status) as status, min(sp.destination) as destination, ' + 'min(sp.created_at) as created_at, min(sp.description) as description, min(sp.bolt11) as bolt11, ' + "min(sp.bolt12) as bolt12, sum(case when sp.status = 'complete' then sp.amount_sent_msat else null end) " + "as amount_sent_msat, sum(case when sp.status = 'complete' then sp.amount_msat else 0 end) as amount_msat, " + `max(sp.payment_preimage) as preimage from sendpays sp group by sp.payment_hash, sp.groupid ` + `order by created_index desc limit ${limit}`, ); return (res.rows ?? []).map((row) => { const amountMsat = parseMsat(cellMsat(row[PAYMENT_COL.amountMsat])) ?? '0'; const sentMsat = parseMsat(cellMsat(row[PAYMENT_COL.amountSentMsat])) ?? amountMsat; // The routing fee is the difference between what left us and what reached the payee. const fee = BigInt(sentMsat) - BigInt(amountMsat); return { paymentHash: cellStr(row[PAYMENT_COL.paymentHash]) ?? '', preimage: cellStr(row[PAYMENT_COL.preimage]), amountMsat, feeMsat: (fee > 0n ? fee : 0n).toString(), status: toPaymentStatus(cellStr(row[PAYMENT_COL.status]) ?? undefined), createdAt: cellNum(row[PAYMENT_COL.createdAt]) ?? 0, destination: cellStr(row[PAYMENT_COL.destination]), memo: cellStr(row[PAYMENT_COL.description]), // sendpays records no failure text; a failed attempt only carries a status. failureReason: null, }; }); } private toPayment(res: ClnPayResult, fallbackDestination: string | null, memo: string | null): Payment { const amountMsat = parseMsat(res.amount_msat) ?? '0'; const sentMsat = parseMsat(res.amount_sent_msat) ?? amountMsat; const fee = BigInt(sentMsat) - BigInt(amountMsat); return { paymentHash: res.payment_hash ?? '', preimage: res.payment_preimage ?? null, amountMsat, feeMsat: (fee > 0n ? fee : 0n).toString(), status: toPaymentStatus(res.status), // created_at is a float in CLN (sub-second resolution); the interface is unix seconds. createdAt: Math.floor(toNumber(res.created_at)), destination: res.destination ?? fallbackDestination, memo, failureReason: null, }; } override async payInvoice(req: PayInvoiceRequest): Promise { if (req.feeLimitMsat && req.feeLimitPercent != null) { throw new BackendError('feeLimitMsat and feeLimitPercent are mutually exclusive', 400); } const timeoutSeconds = req.timeoutSeconds ?? 60; const body: Record = { bolt11: req.bolt11, retry_for: timeoutSeconds }; // Only send an amount when one was actually requested — see difference 2. if (req.amountMsat) body.amount_msat = msatToApiNumber(req.amountMsat, 'amountMsat'); if (req.feeLimitMsat) body.maxfee = msatToApiNumber(req.feeLimitMsat, 'feeLimitMsat'); else if (req.feeLimitPercent != null) body.maxfeepercent = req.feeLimitPercent; // `retry_for` bounds CLN's own retrying; give the socket a little more than that. const res = await this.call('pay', body, (timeoutSeconds + 10) * 1000); return this.toPayment(res, null, null); } override async sendKeysend(req: KeysendRequest): Promise { const timeoutSeconds = 60; const body: Record = { destination: req.destination, amount_msat: msatToApiNumber(req.amountMsat, 'amountMsat'), retry_for: timeoutSeconds, }; if (req.feeLimitMsat) body.maxfee = msatToApiNumber(req.feeLimitMsat, 'feeLimitMsat'); // `extratlvs` values are hex-encoded by CLN's convention, not base64 as in LND's custom records. if (req.message) { body.extratlvs = { [KEYSEND_MESSAGE_RECORD]: Buffer.from(req.message, 'utf8').toString('hex') }; } const res = await this.call('keysend', body, (timeoutSeconds + 10) * 1000); return this.toPayment(res, req.destination, req.message ?? null); } // ── channels / peers ─────────────────────────────────────────────────────────────────────────── override async getChannels(): Promise { const res = await this.call<{ channels?: ClnPeerChannel[] }>('listpeerchannels'); const channels = res.channels ?? []; const aliases = await this.resolveAliases(channels.map((chan) => chan.peer_id ?? '')); return channels .filter((chan) => chan.state !== 'CLOSED' && chan.state !== 'ONCHAIN') .map((chan) => { const total = Number(parseMsat(chan.total_msat) ?? '0'); const ours = Number(parseMsat(chan.to_us_msat) ?? '0'); return { // short_channel_id is the network-visible id but is absent until the funding confirms. channelId: chan.short_channel_id ?? chan.channel_id ?? '', channelPoint: chan.funding_txid ? `${chan.funding_txid}:${chan.funding_outnum ?? 0}` : null, remotePubkey: chan.peer_id ?? '', remoteAlias: aliases.get(chan.peer_id ?? '') ?? null, capacitySats: Math.floor(total / 1000), localBalanceSats: Math.floor(ours / 1000), remoteBalanceSats: Math.floor((total - ours) / 1000), active: chan.peer_connected === true && chan.state === 'CHANNELD_NORMAL', // CLN's `private` is the inverse of the gossip announcement; unset means announced. private: chan.private === true, status: toChannelStatus(chan.state), }; }); } override async getPeers(): Promise { const res = await this.call<{ peers?: ClnPeer[] }>('listpeers'); const peers = res.peers ?? []; const aliases = await this.resolveAliases(peers.map((peer) => peer.id ?? '')); return peers.map((peer) => ({ pubkey: peer.id ?? '', address: peer.netaddr?.[0] ?? '', alias: aliases.get(peer.id ?? '') ?? null, // listpeers does not report which side dialled; LND's `inbound` has no CLN equivalent. inbound: false, })); } // ── signing ──────────────────────────────────────────────────────────────────────────────────── override async signMessage(message: string): Promise { const res = await this.call('signmessage', { message }); // zbase, not the hex `signature` — see difference 4. if (!res.zbase) throw new BackendError('core lightning returned no signature', 502); return { signature: res.zbase }; } override async verifyMessage(message: string, signature: string): Promise { const res = await this.call('checkmessage', { message, zbase: signature }); return { valid: res.verified === true, pubkey: res.pubkey ?? null }; } } /** Hex feature bitmap → the indices of its set bits, least-significant bit first. */ function featureBits(hex: string | undefined): string[] { if (!hex) return []; const bytes = Buffer.from(hex, 'hex'); const bits: string[] = []; // The bitmap is big-endian: the LAST byte holds bits 0-7. for (let i = 0; i < bytes.length; i++) { const byte = bytes[bytes.length - 1 - i] ?? 0; for (let bit = 0; bit < 8; bit++) { if (byte & (1 << bit)) bits.push(String(i * 8 + bit)); } } return bits; }