A self-custodial Bitcoin wallet library for JavaScript/TypeScript with a full Lightning Network implementation. Beignet implements the Lightning protocol and channel state machine in TypeScript rather than wrapping LND, CLN or LDK: it speaks BOLT 8 over a real TCP socket and runs its own BOLT 2 state machine.
Two layers, one mnemonic:
- On-chain wallet: HD keys, address generation, UTXO tracking, transaction building, PSBT/hardware signing, multisig, watch-only, Electrum connectivity.
- Lightning: channel lifecycle, onion-routed payments, BOLT 11 invoices, BOLT 12 offers, gossip and pathfinding, anchors, splicing, taproot channels, watchtower client. Interop-tested against LND, Core Lightning and Eclair on regtest.
Requires Node.js 18+. MIT licensed.
Jump to: Install · Examples · On-chain wallet · Lightning · Daemon & CLI · Protocol layer · Tests · Status & limitations
npm install beignet # or: yarn add beignetSmallest thing that works. net and tls are injected so the same code runs on Node and React Native:
import net from 'net';
import tls from 'tls';
import { Wallet, generateMnemonic } from 'beignet';
const result = await Wallet.create({
mnemonic: generateMnemonic(),
electrumOptions: { net, tls }
});
if (result.isErr()) throw result.error;
const wallet = result.value;
console.log(await wallet.getAddress());
console.log(wallet.getBalance());From here: the on-chain wallet for sending, PSBTs, multisig and watch-only, or Lightning for channels and payments.
The fastest way to understand the whole system is to run the two REPL examples against a live wallet and a live node. Both are checked-in TypeScript you can read and edit.
git clone git@github.com:coreyphillips/beignet.git && cd beignet
npm installnpm run exampleCreates a mainnet wallet (a fresh mnemonic unless you pass one), syncs it against a public Electrum server, prints the balance and a receive address, then drops you at a > prompt with the wallet bound to wallet. Type help() for the command list.
> wallet.getBalance()
> await wallet.getAddress()
> await wallet.refreshWallet()
> await wallet.send({ address: 'bc1q...', amount: 10000, satsPerByte: 2 })State persists as JSON under example/walletData/. Pass a mnemonic as the first argument to reuse a wallet: npm run example -- "abandon abandon ... about".
npm run example:lightningBoots a real Lightning node (BeignetNode) with an auto-created wallet, storage and funding provider, waits for it to become operational, prints info/balance/health, and drops you at a beignet> prompt with the node bound to node. Type help() for the command list. Top-level await works.
beignet> await node.getNewAddress() // fund this on-chain, then:
beignet> await node.connectAndOpenChannel(pubkey, host, port, 200000)
beignet> node.createInvoice(1000, 'coffee').bolt11
beignet> await node.payInvoice('lnbc...')
beignet> node.getLiquiditySnapshot()The flags you will actually reach for (everything after --):
| Flag | Effect |
|---|---|
mainnet | testnet | regtest |
Network, as a bare positional arg (default mainnet) |
<12 or 24 words> |
Reuse a mnemonic, as bare positional args (default generates one) |
--electrum-host <h> --electrum-port <p> |
Point at your own Electrum server |
--alias <name> |
Node alias in node_announcement |
# named regtest node against a local Electrum server
npm run example:lightning -- regtest --electrum-host 127.0.0.1 --electrum-port 60001 --alias mynodeTor, full-graph gossip, the low-level LightningNode variant and the non-interactive payment-API walkthrough have flags too: see the flag reference.
Node state lives in a SQLite DB under ~/.beignet/data/<hash-of-mnemonic>/ (the --low-level example uses example/lightningData/node.db).
Both examples run straight off the TypeScript sources through ts-node, so no build step is needed. Use npm run build when you want the compiled dist/.
→ example/REPL_TESTING.md is a copy-pasteable walkthrough of the whole lifecycle in the REPL: funding, peers, channels, invoices, payments, keysend, offers, splicing, closing, backup.
| Import | Contains | Use when |
|---|---|---|
beignet |
Wallet, generateMnemonic, types |
You want the on-chain wallet |
beignet/cli |
BeignetNode, startDaemon, error helpers |
You want Lightning. Sats-denominated, string IDs, structured errors |
beignet/lightning |
Namespaced protocol modules (node, channel, onion, ...) |
You need the raw BOLT layer: bigint msat, Buffer IDs, wire messages |
import net from 'net';
import tls from 'tls';
import { Wallet, generateMnemonic } from 'beignet';
const res = await Wallet.create({
mnemonic: generateMnemonic(),
electrumOptions: { net, tls } // required: inject the socket implementations
});
if (res.isErr()) throw res.error;
const wallet = res.value;
const address = await wallet.getAddress();
const balance = wallet.getBalance();
await wallet.send({ address: 'bc1q...', amount: 50_000, satsPerByte: 2 });
await wallet.sendMany({ txs: [{ address: 'bc1q...', amount: 1000 }] });
await wallet.refreshWallet();
const utxos = wallet.listUtxos();
const history = await wallet.getAddressHistory('bc1q...');Every fallible call returns a Result<T>: check isErr() before reading .value. Amounts are always satoshis.
Options worth knowing on Wallet.create: network (EAvailableNetworks.mainnet | testnet | regtest | signet), addressType (p2wpkh default, p2sh-p2wpkh, p2pkh, p2tr), passphrase, account, storage, logger, coinSelectPreference, feeEstimationSource, gapLimitOptions.
Custom Electrum servers, failover, fee sources, BIP21
import { EAvailableNetworks, EProtocol, Wallet } from 'beignet';
const res = await Wallet.create({
mnemonic,
network: EAvailableNetworks.mainnet,
feeEstimationSource: 'electrum', // 'electrum' | 'http' | 'auto' (default)
electrumOptions: {
net,
tls,
servers: [
{ host: 'bitcoin.lu.ke', ssl: 50002, tcp: 50001, protocol: EProtocol.ssl },
{ host: 'mempool.space', ssl: 60602, tcp: 60601, protocol: EProtocol.ssl }
]
}
});- Failover: with multiple servers the wallet rotates through them in order on connect/reconnect failure, then through hardcoded fallback peers for the network, with a per-server cooldown so dead servers are not hammered. Inspect
wallet.electrum.currentServerandwallet.electrum.rotationCount. - Fee source:
'electrum'queries only the connected server viablockchain.estimatefee, so fee lookups never leak to mempool.space/blocktank over clearnet.'auto'prefers Electrum and falls back to HTTP. All remote rates are clamped to 5000 sat/vB. - Networks: mainnet, testnet, regtest and signet work end to end (wallet, Electrum, CLI/daemon
--network signet, Lightning chain hash andtbsinvoice prefix). Signet shares testnet address formats and coin type 1. - BIP21:
encodeBip21({ address, amountSats?, label?, message? })builds abitcoin:URI.
Watch-only wallets (account xpub)
Built from an account-level extended public key instead of a mnemonic. The key is assumed to sit at m/purpose'/coin'/account' (e.g. m/84'/0'/0'), so addresses derive as xpub/0/i and xpub/1/i. SLIP-132 version bytes are normalized: zpub/vpub implies p2wpkh, ypub/upub implies p2sh-p2wpkh, a plain xpub/tpub uses addressType (default p2wpkh). One account xpub yields exactly one address type, so a watch-only wallet monitors only that type.
const res = await Wallet.createWatchOnly({
xpub: 'zpub6r...',
network: EAvailableNetworks.mainnet,
electrumOptions: { net, tls }
});
if (res.isErr()) return;
const watchOnly = res.value;
await watchOnly.getAddress(); // works
watchOnly.getBalance(); // works
const send = await watchOnly.send({ address: 'bc1q...', amount: 1000 });
// send.isErr() === true, message: 'watch-only wallet cannot sign'The full read-only surface works: address generation, gap-limit scanning, Electrum refresh, balances, history, UTXOs, fee estimates, address subscriptions. Anything needing private keys (send/sendMax/sendMany/sweepPrivateKey/getPrivateKey) fails with the typed WatchOnlySigningError (code: 'WATCH_ONLY_CANNOT_SIGN'). Library-only for now: the HTTP daemon always runs with a mnemonic.
Hardware wallets and external signers (PSBT)
buildPsbt runs the normal setup (coin selection, change, fee) but stops before signing, returning a base64 PSBT populated with what a hardware signer needs: witnessUtxo (or nonWitnessUtxo for legacy p2pkh), redeemScript for p2sh-p2wpkh, tapInternalKey plus tapBip32Derivation for p2tr, and bip32Derivation on every wallet input. Works on full and watch-only wallets.
// 1. Build (never touches private keys)
const build = await wallet.buildPsbt({ address: 'bc1q...', amount: 50_000, satsPerByte: 4 });
if (build.isErr()) return;
const { psbtBase64, fee, vsizeEstimate } = build.value;
// 2. Sign externally (hardware wallet, HWI, another machine)
const signedBase64 = await myHardwareWallet.signPsbt(psbtBase64);
// 3. Import: validates a signature on EVERY input, finalizes, does NOT broadcast
const imported = wallet.importSignedPsbt(signedBase64);
if (imported.isErr()) return; // missing/invalid signatures are rejected loudly
const { txHex, txid } = imported.value;
// 4. Broadcast when ready
await wallet.broadcastTransaction(txHex);
// Multi-party: merge partially signed copies of the same PSBT
const combined = wallet.combinePsbts([copyA, copyB]);For watch-only wallets the true master fingerprint is unknowable from an account xpub, so the xpub's parent fingerprint is used: signers should locate keys by derivation path.
Also on the daemon (POST /psbt/build, /psbt/import-signed, /psbt/combine) and the CLI (beignet psbt build|import-signed|combine).
Multisig (P2WSH sortedmulti)
Wallet.createMultisig creates a descriptor-based sorted-multisig wallet, wsh(sortedmulti(threshold, key1, key2, ...)): the interoperable standard used by Bitcoin Core, Sparrow and Specter. Derivation follows BIP 48 script type 2 (m/48'/coin'/account'/2', receive /0/*, change /1/*) and keys are BIP 67 ordered at every index, so any wallet built from the same account xpubs produces identical addresses regardless of cosigner order.
Cosigners are account-level extended public keys (xpub/tpub, or SLIP-132 Zpub/Vpub, normalized automatically). With a mnemonic, this wallet IS one of the cosigners: its BIP 48 account xpub is derived and included automatically (pass ourXpub to assert it; a mismatch is rejected). Omit the mnemonic for a watch-only coordinator.
Spending is PSBT-only. send/sendMany/sendMax fail with MultisigSpendError (code: 'MULTISIG_REQUIRES_PSBT').
// 1. Each cosigner builds the same quorum from the others' BIP 48 account xpubs.
const a = await Wallet.createMultisig({
threshold: 2,
mnemonic: mnemonicA, // we are one cosigner; our xpub is added automatically
cosigners: [xpubB, xpubC],
network: EAvailableNetworks.mainnet,
electrumOptions: { net, tls }
});
const b = await Wallet.createMultisig({ threshold: 2, mnemonic: mnemonicB, cosigners: [xpubA, xpubC], /* ... */ });
// An optional watch-only coordinator holds no keys at all.
const c = await Wallet.createMultisig({ threshold: 2, cosigners: [xpubA, xpubB, xpubC], /* ... */ });
if (a.isErr() || b.isErr() || c.isErr()) return;
const [walletA, walletB, coordinator] = [a.value, b.value, c.value];
// 2. Fund it: every instance derives the same addresses.
const deposit = await walletA.getAddress();
// 3. Build the unsigned PSBT (any instance, coordinator included).
const built = await walletA.buildPsbt({ address: 'bc1q...', amount: 50_000, satsPerByte: 4 });
if (built.isErr()) return;
const unsigned = built.value.psbtBase64;
// 4. Each cosigner signs their own copy (nothing finalizes below threshold).
const signedA = walletA.signPsbtWithOurKey(unsigned);
const signedB = walletB.signPsbtWithOurKey(unsigned);
if (signedA.isErr() || signedB.isErr()) return;
// 5. Combine, finalize at threshold, broadcast.
const combined = coordinator.combinePsbts([signedA.value, signedB.value]);
if (combined.isErr()) return;
const finalized = coordinator.importSignedPsbt(combined.value); // 2-of-3 met
if (finalized.isErr()) return;
await coordinator.broadcastTransaction(finalized.value.txHex);
// Below threshold it fails loudly:
// 'Input 0 is below the multisig threshold: have 1 signature(s), need 2.'
// Interop: import into Bitcoin Core / Sparrow / Specter.
coordinator.exportDescriptors();
// wsh(sortedmulti(2,[fp/48h/0h/0h/2h]xpub.../0/*,[fp]xpub.../0/*,...))#checksumbuildPsbt attaches the witnessScript and one bip32Derivation per cosigner to every input. importSignedPsbt counts VALID partial signatures per input against the witnessScript threshold and refuses to finalize below it. Library-only for now: the daemon wallet stays single-sig.
Encrypted storage and leveled logging
The wallet persists through the host-injected TStorage interface (storage: { getData, setData }), and values are handed over as-is, so by default they are stored in plaintext. Persisted data is addresses, indexes, UTXOs, transactions, balance and fee estimates: no private keys and no mnemonic are ever written, so exposure is a privacy concern (full wallet history), not fund loss.
Wrap any TStorage with createEncryptedStorage to encrypt at rest with AES-256-GCM under an HKDF-derived key from the seed. Pre-existing plaintext values pass through unchanged and migrate lazily as they are rewritten.
import * as bip39 from 'bip39';
import { createConsoleLogger, createEncryptedStorage, Wallet } from 'beignet';
const seed = bip39.mnemonicToSeedSync(mnemonic);
const wallet = await Wallet.create({
mnemonic,
storage: createEncryptedStorage({ getData, setData }, seed),
logger: createConsoleLogger('warn'), // only warn + error reach the console
electrumOptions: { net, tls }
});Diagnostics flow through a small injectable ILogger (debug/info/warn/error, each (message, meta?)), with filtering debug < info < warn < error plus 'silent'. This is separate from the Lightning node's persisted structured action log (getActionLog).
Wallet.create({ logger })defaults tocreateConsoleLogger('info'), preserving historical console output.disableMessagesis independent: it only gatesonMessagecallbacks.LightningNodedefaults tonoopLogger(silent). Every action-log entry is also mirrored tologger.debug('category:action', data).BeignetNode.create({ logger, logLevel })forwards passing entries to the logger (in addition to the'log'event) and injects it into the underlyingWalletandLightningNode.- Daemon:
beignet start --log-level <debug|info|warn|error|silent>(orBEIGNET_LOG_LEVEL, orlogLevelin~/.beignet/config.json) prints to stderr. Unset keeps the daemon silent; stdout stays reserved for command output.
Beignet is under active development. Evaluate it on regtest, signet, or with small amounts you can afford to lose. Read Status & limitations before putting meaningful mainnet funds behind it: this is a self-custodial Lightning implementation, and channel funds are only as safe as the node watching them.
BeignetNode from beignet/cli is the recommended API: it wraps the protocol layer with satoshi amounts, string channel IDs and structured error codes.
import { BeignetNode, isRetryableError } from 'beignet/cli';
// Creates the wallet, storage and funding provider for you
const node = await BeignetNode.create({
mnemonic: 'abandon abandon ... about',
network: 'regtest',
electrumHost: '127.0.0.1',
electrumPort: 60001
});
node.getInfo(); // { nodeId, network, alias, ... }
node.getHealth(); // { status: 'ready', peers, channels, ... }
node.isReady(); // true once the node has active channels
const inv = node.createInvoice(1000, 'coffee');
console.log(inv.bolt11);
try {
const payment = await node.payInvoice('lnbcrt10n1...');
console.log(payment.status); // 'COMPLETED'
} catch (err) {
if (isRetryableError(err)) {
// transient: no route, timeout. Safe to retry
} else {
// permanent: invalid invoice, expired. Do not retry
}
}
node.listChannels();
node.listPayments();
node.listInvoices();
await node.destroy();Events: node:ready, channel:ready, channel:closed, channel:resolved, peer:connect, peer:disconnect, peer:error, payment:sent, payment:received, node:error, log.
Useful variants: payInvoiceSafe (never throws), payInvoiceWithRetry({ maxRetries, backoffMs, maxFeeSats }), sendPaymentAsync (returns the hash immediately), connectAndOpenChannel, openChannelAndWait, sendKeysend, createOffer/payOffer, spliceIn/spliceOut, backup, gracefulShutdown.
→ docs/AI_AGENT_GUIDE.md covers deployment in depth: channel strategy, liquidity management, monitoring and Prometheus metrics, pre-flight validation, safety rails, retry/backoff patterns, idempotency keys, spend limits, drain mode, backup and recovery, mainnet checklist.
Built-in advisors, not usually found in a Lightning library:
// Channel balance analysis with actionable recommendations
const liquidity = node.getLiquiditySnapshot();
console.log('Outbound:', liquidity.outboundLiquidityPct + '%');
for (const rec of liquidity.recommendations) {
console.log(`[${rec.priority}] ${rec.type}: ${rec.reason}`);
}
node.getChannelSuggestions(3); // graph-based peer suggestions for opens
node.getFeeSnapshot(); // on-chain fee trend: OPEN_NOW / WAIT / NEUTRAL
node.estimatePayment(bolt11); // success probability + estimated fee, pre-send
node.getMainnetReadiness(); // 12-check weighted readiness reportAdvisor execution: circular rebalancing and fee auto-tuning
The advisor can act, not just recommend. Both features are off by default.
// One-shot circular rebalance: self-payment out over `from` and back in over `to`.
// Aborts WITHOUT paying if the route fee exceeds maxFeeSats.
await node.rebalanceChannel(fromChannelId, toChannelId, 50_000, /* maxFeeSats */ 50);
node.getAdvisorRecommendations(); // read-only: analyze() + rebalancePlan[]
await node.executeRebalances(/* budgetSatsPerDay */ 500);Automatic modes, opt-in via BeignetNodeOptions / INodeConfig:
const node = await BeignetNode.create({
mnemonic,
// Periodically executes the rebalance plan. Routing fees spent on rebalances
// are capped per UTC day and the running spend is persisted, so restarts
// never overspend the same day. Resets at midnight UTC.
autoRebalance: { enabled: true, budgetSatsPerDay: 500, minImbalancePct: 20 },
// Every intervalMs (default 6h) nudges each channel's proportional fee:
// +25% when outbound is depleted (<20% local) but still forwarding, -25% when
// the channel saw no forwards in the window, clamped to [floorPpm, ceilPpm].
// One adjustment per channel per interval.
autoTuneFees: { enabled: true, floorPpm: 1, ceilPpm: 5_000 }
});Daemon: POST /rebalance, GET /advisor/recommendations, POST /advisor/execute-rebalances.
CLI: beignet rebalance <from> <to> <sats> --max-fee <sats>, beignet advisor recommendations, beignet advisor execute-rebalances [--budget <sats>].
Watchtowers (altruist client)
Penalty enforcement normally needs this node's chain monitor to be online: if a counterparty broadcasts a revoked commitment while you are offline, nobody sweeps the breach. The watchtower client closes that gap. At every revocation it builds an encrypted justice kit (the revoked commitment's breach hint plus a pre-signed to_local penalty) and ships it to remote towers over BOLT 8. When a tower later sees the breach on chain it decrypts the kit and broadcasts the penalty for you.
const node = await BeignetNode.create({
mnemonic,
watchtowers: ['03abc...@tower.example.com:9911'] // off when empty
});- Altruist only. Sessions use
reward = 0. There is no server mode: beignet is a tower client, not a tower. - LND-tower compatible. Implements LND's
wtwireprotocol (Init/CreateSession/StateUpdate/DeleteSession, message types 600-607) and the version-0 justice blob (XChaCha20-Poly1305, breach hintSHA256(txid)[:16], keySHA256(txid‖txid)), so it works with existing public LND altruist towers. - Legacy + anchor channels. The to_local revocation penalty (the fund-critical punishment) is packed for both. Taproot channels are not yet backed up.
- Durable. Per-tower session state and the un-acked backlog are persisted (encrypted at rest) and drained with exponential backoff on reconnect. An un-acked update is never dropped silently.
Daemon: GET /watchtowers, POST /watchtower/add, DELETE /watchtower/remove.
CLI: beignet watchtower list|add <pubkey@host:port>|remove <uri>, daemon flag --watchtower (repeatable) or BEIGNET_WATCHTOWERS.
The same node runs as an HTTP/SSE daemon for language-agnostic integrations, driven by a JSON CLI.
# 1. Generate a mnemonic + ~/.beignet/config.json
npx beignet init --network regtest
# 2. Start the daemon (add --daemon to background it)
BEIGNET_ELECTRUM_HOST=127.0.0.1 BEIGNET_ELECTRUM_PORT=60001 BEIGNET_ELECTRUM_TLS=false \
npx beignet start --network regtest --api-token mytoken
# 3. Drive it with the CLI (thin HTTP client, JSON out)
npx beignet info --pretty
npx beignet address
npx beignet channel connect-and-open <pubkey> <host> <port> 200000
npx beignet invoice create 1000 "coffee"
npx beignet invoice pay <bolt11>Electrum and most other settings come from ~/.beignet/config.json or the environment (BEIGNET_MNEMONIC, BEIGNET_ELECTRUM_HOST, BEIGNET_ELECTRUM_PORT, BEIGNET_NETWORK, ...). Run npx beignet help for the full command and flag list.
Or over HTTP directly:
curl -X POST http://localhost:2112/invoice/create -H 'Authorization: Bearer mytoken' \
-H 'Content-Type: application/json' -d '{"amountSats": 1000, "description": "coffee"}'
curl -X POST http://localhost:2112/invoice/pay -H 'Authorization: Bearer mytoken' \
-H 'Content-Type: application/json' -d '{"bolt11": "lnbcrt10n1..."}'
curl -N http://localhost:2112/events -H 'Authorization: Bearer mytoken' # SSE stream
curl http://localhost:2112/ready # load-balancer probe- Responses are
{ "ok": true, "result": {...} }or{ "ok": false, "error": { "code": "...", "message": "..." } }. - Full spec at
GET /openapi.json. GET /health,/ready,/openapi.jsonand/metricsare auth-exempt; everything else requires the bearer token when one is configured. Auth is off unless you setapiTokenorapiKeys(named keys withreadonly/invoice/adminscopes), so configure a token before exposing the daemon anywhere. It binds127.0.0.1by default.- Embed it instead of shelling out:
import { startDaemon } from 'beignet/cli'.
Fast-Forward Offline Receive (spec: github.com/coreyphillips/ffor, Variant D) lets a
wallet receive while offline through a settlement peer that holds a pre-signed
voucher book. The daemon exposes the receiver's lifecycle under /ffor/*
(/ffor/epoch/start, /ffor/invoice, /ffor/epoch/close, /ffor/preimage,
/ffor/witness/provision, /ffor/issuer/offer, /ffor/issuer/provision,
/ffor/recover, /ffor/enforce, /ffor/epochs, /ffor/epoch) and three roles a
node can run for others, each an explicit opt-in switched on with an exact
true:
| Env | Role |
|---|---|
BEIGNET_FFOR_SETTLE |
Answer ff_init as a settlement peer. BEIGNET_FFOR_MAX_BUDGET_MSAT, BEIGNET_FFOR_MAX_EPOCH_BLOCKS, BEIGNET_FFOR_FEE_BASE_MSAT and BEIGNET_FFOR_FEE_PPM bound what it accepts. Off by default: an epoch locks the whole budget of this node's liquidity. GET /ffor/settlements lists them. |
BEIGNET_FFOR_WITNESS |
Store a receiver-encrypted record of every delegated preimage this node relays before propagating the fulfil (a receipt witness). BEIGNET_FFOR_WITNESS_MAX_MAILBOXES and BEIGNET_FFOR_WITNESS_MAX_BYTES cap it. GET /ffor/witness/status. |
BEIGNET_FFOR_ISSUER |
Answer BOLT 12 invoice requests for offers a receiver delegated to this node, one fixed-amount slot per invoice. Needs the witness. GET /ffor/issuer/status. |
The SSE stream carries ffor:state, ffor:settled, ffor:delegated-failed,
ffor:enforce and the witness and issuer events.
A beignet node can serve swaps to any Lightning peer in both directions.
Reverse (issue #737): the peer pays a hold invoice, this node funds a P2WSH
contract the peer claims on chain with its preimage, and the claim settles
the hold. Submarine (issue #743): the peer locks coins in a P2WSH contract,
this node pays the peer's own invoice under an absolute HTLC expiry ceiling,
and the preimage that payment reveals claims the coins. Each direction is an
explicit opt-in switched on with an exact true, because it puts this node's
own funds at risk for peers:
| Env | Role |
|---|---|
BEIGNET_SWAPS |
Serve reverse swaps. BEIGNET_SWAP_FLAT_FEE_SAT and BEIGNET_SWAP_FEE_PPM price them; BEIGNET_SWAP_MIN_SAT, BEIGNET_SWAP_MAX_SAT, BEIGNET_SWAP_MAX_EXPOSURE_SAT and BEIGNET_SWAP_MAX_CONCURRENT cap what is at risk; BEIGNET_SWAP_REFUND_DELTA_BLOCKS, BEIGNET_SWAP_FUNDING_CONFS and BEIGNET_SWAP_RESOLUTION_CONFS set the timing. GET /swaps/status, GET /swaps, POST /swaps/cancel. |
BEIGNET_SWAP_SUBMARINE |
With BEIGNET_SWAPS, also serve submarine swaps (on-chain to Lightning): a peer locks coins in a contract, this node pays the peer's invoice under an absolute HTLC expiry ceiling and claims the coins with the preimage. BEIGNET_SWAP_CLAIM_SAFETY_BLOCKS, BEIGNET_SWAP_PAYMENT_MAX_FEE_PPM, BEIGNET_SWAP_CLAIM_BUMP_INTERVAL_BLOCKS and BEIGNET_SWAP_SUBMARINE_REFUND_DELTA_BLOCKS set the direction's margins; the fee and exposure caps above apply to both. |
The reverse provider funds only against the complete committed MPP set of
the hold invoice, settles the hold the moment a claim reveals the preimage
(mempool included), and cancels the hold only after its own refund has
confirmed to policy depth; never because the refund height passed. The
submarine provider pays only once the peer's funding has confirmed to policy
depth and been re-verified unspent immediately before the dispatch, binds
every HTLC of the payment to refundHeight minus its claim margins, judges
the payment by the node's own HTLC view (never by a wall clock or a failed
record while an HTLC is out), and persists its claim before broadcasting it.
The SSE stream carries swap:created through swap:settled, swap:refunded
and swap:exposed for the reverse direction and swap:funding-seen,
swap:paying, swap:preimage, swap:claim-broadcast,
swap:claim-confirmed and swap:payment-failed for the submarine one.
Use this only if you need the BOLT layer directly: bigint msat, Buffer IDs, raw wire messages. beignet/lightning exports namespaces, not flat symbols.
import net from 'net';
import tls from 'tls';
import { Wallet, generateMnemonic } from 'beignet';
import { invoice, node as ln, wallet as lnWallet } from 'beignet/lightning';
const mnemonic = generateMnemonic();
// 1. On-chain wallet (the same mnemonic funds both layers)
const res = await Wallet.create({ mnemonic, electrumOptions: { net, tls } });
if (res.isErr()) throw res.error;
// 2. Lightning node with auto-funding from the wallet
const node = ln.LightningNode.fromMnemonic(mnemonic, {
network: invoice.Network.REGTEST,
enableNetworking: true,
fundingProvider: new lnWallet.WalletFundingProvider(res.value)
});
// 3. Connect and open: fully automatic with a funding provider
await node.connectPeer('03...pubkey', '127.0.0.1', 9735);
node.openChannel('03...pubkey', 100_000n);
// 4. Invoice and payment
node.createInvoice({ amountMsat: 50_000n, description: 'coffee' });
node.sendPayment(invoiceString);
// 5. Events. Channel-scoped events carry an object, not a bare id
node.on('channel:ready', ({ channelId }) => console.log(channelId.toString('hex')));
node.on('payment:received', (p) => console.log(p.amountMsat, 'msat'));
node.on('node:error', (err) => console.error(`[${err.code}]`, err.message));Without a fundingProvider, build the funding transaction yourself and call node.createFunding(channel, fundingTxid, outputIndex, signature) after openChannel.
LightningNode High-level API (EventEmitter)
├── ChannelManager Multiplexes messages to Channel instances
│ └── Channel BOLT 2 state machine (returns ChannelAction[])
├── PeerManager TCP connections + Noise_XK encrypted transport
│ └── Peer Per-connection BOLT 8 handshake + message framing
├── NetworkGraph BOLT 7 gossip topology + Dijkstra pathfinding
├── InvoiceManager BOLT 11 encode/decode/sign
├── ChainMonitor BOLT 5 force-close detection + sweep
└── FundingProvider? Auto-builds + broadcasts funding txs (via Wallet)
Key design principle: Channel is fully transport-agnostic. Every method returns a ChannelAction[] (send message, broadcast tx, watch output, ...) that ChannelManager maps to real transport or chain operations, which makes the state machine testable without network I/O.
→ src/lightning/README.md documents the protocol layer in detail: data flow, events reference, typed payment errors, channel lifecycle, zero-conf, anchors, dual funding, splicing, offers, onion messages, forwarding, chain monitoring.
BOLT coverage
| BOLT | Specification | Implemented |
|---|---|---|
| 1 | Base Protocol | Peer messaging, init, error, ping/pong, feature negotiation, peer storage |
| 2 | Channel Management | Full state machine: open, fund, normal operation, shutdown, close, reestablish; v2 dual-funded opens (interactive-tx), splicing, quiescence |
| 3 | Transactions | Commitment txs, HTLC scripts, funding scripts, anchor outputs, fee calculation; simple taproot channels (MuSig2 funding, Schnorr HTLC sigs) |
| 4 | Onion Routing | Sphinx encryption, TLV hop payloads, payment_secret, failure codes, route blinding, onion messages |
| 5 | On-Chain | Force-close detection, HTLC sweep, output resolution, chain monitoring, wallet-funded anchor fee bumping (commitment CPFP + zero-fee HTLC fee-attach) |
| 7 | Gossip | Channel/node announcements, network graph, Dijkstra routing, gossip sync, Rapid Gossip Sync |
| 8 | Transport | Noise_XK handshake, encrypted transport, key rotation |
| 9 | Features | DATA_LOSS_PROTECT, STATIC_REMOTE_KEY, PAYMENT_SECRET, TLV_ONION, BASIC_MPP, CHANNEL_TYPE, GOSSIP_QUERIES, ANCHORS_ZERO_FEE_HTLC_TX (default), ROUTE_BLINDING, ONION_MESSAGES, QUIESCE, SCID_ALIAS, ZERO_CONF, KEYSEND, OPTION_TAPROOT, OPTION_WILL_FUND |
| 10 | DNS Bootstrap | Seed resolution for discovering initial peers |
| 11 | Invoices | Encode, decode, sign, verify, amount formatting, hold invoices |
| 12 | Offers | Offer encode/decode, invoice_request/invoice over onion messages, receive-side settlement, async payment offers |
| bLIP-51 | Liquidity Ads | lease_rates/request_funds/will_fund negotiation, lease fee accounting, CLTV-locked lessor to_local, advisor lease quoting |
Module reference (src/lightning/)
| Module | Description |
|---|---|
crypto/ |
ChaCha20-Poly1305 AEAD, ECDH, HKDF, MuSig2 (BIP 327) for taproot channels |
message/ |
Wire encode/decode for all channel, gossip and control messages |
features/ |
Feature flag bitmap management (BOLT 9) |
transport/ |
Noise_XK handshake, transport cipher, TCP/WebSocket peer connections, PeerManager |
keys/ |
HD derivation, per-commitment secrets (shachain), signing, wallet keys |
script/ |
Funding 2-of-2 multisig, commitment outputs, HTLC scripts, revocation, anchors, taproot scripts |
channel/ |
Channel state machine, ChannelManager, commitment builder, actions, validation, liquidity ads |
chain/ |
ChainMonitor, ChainWatcher, output resolver, closing tx, sweep tx, Electrum backend |
invoice/ |
BOLT 11 encoding/decoding, bech32 words, signature verification |
gossip/ |
NetworkGraph, Dijkstra pathfinding, gossip sync state machine, SCID encoding |
onion/ |
Sphinx crypto, packet construction/processing, hop payloads, failures, blinded paths |
onion-message/ |
Onion message construction/processing (carries BOLT 12 and async-payment messages) |
offer/ |
BOLT 12 offers: encode/decode, OfferManager invoice_request/invoice flows |
async-payments/ |
Hold invoices and AsyncPaymentManager (LSP held-forward, release_held_htlc, wake) |
ffor/ |
FFOR Variant D offline receive: signed epoch lifecycle, voucher book, transcript hashes, delegated settlement arithmetic |
interactive-tx/ |
Interactive transaction construction for v2 dual-funded opens and splicing |
watchtower/ |
Altruist watchtower client: wtwire protocol, justice blobs, tower sessions |
backup/ |
Static channel backup (SCB) export/import |
recovery/ |
Safety transition layer: atomic persistence, the durable outbound-message outbox, the opt-in hash-chained recovery journal, and the peer_storage Recovery Capsule |
liquidity/ |
JIT channel receive (LSP role): intercept SCIDs, held HTLCs, zero-conf open or splice, then forward; the opening fee is skimmed off the delivery for wallets that accept it, or charged to the sender through the invoice hint (hop mode) for wallets that cannot settle a short HTLC |
direct-funding/ |
Third-party direct funding: the signed payment request envelope, sealed frames, protocol messages, outstanding-request store, the transport registry with its direct-peer, onion and blind-relay lanes, the receiver engine that turns a payer's offered UTXO into channel funding, and the payer engine that verifies and signs it |
swaps/ |
Swaps: the P2WSH HTLC contract, claim/refund transactions, preimage extraction, admission policies, the durable swap ledger, the chain resolver, the wire protocol, and the swap provider engines (reverse: Lightning to on-chain; submarine: on-chain to Lightning) |
l402/ |
L402 (Lightning HTTP 402) client: challenge parsing, macaroon reading, paid credentials |
node/ |
LightningNode orchestrator, the main protocol-layer entry point |
wallet/ |
WalletFundingProvider, adapts the on-chain Wallet for auto-funded opens |
bootstrap/ |
DNS seed resolution for discovering initial peers |
advisor/ |
Liquidity, fee and channel-suggestion advisors |
storage/ |
SQLite persistence backend, channel state serialization |
validation/ |
Input validation shared across modules |
beignet/lightning re-exports each of these as a namespace (crypto, message, node, ...). async-payments and watchtower are reachable via their source paths.
npm run test:local # test:lightning + test:cli at once; no infrastructure needed
npm run test:lightning # 6200+ Lightning unit tests (parallel), no infrastructure needed
npm run test:cli # 1350+ CLI + daemon unit tests (parallel), no infrastructure needed
npm run test:conformance # 250+ official BOLT vector cases (subset of test:lightning)
npm run test:chaos # recovery kill matrices (parallel), split out of test:lightning
npm run test:sigkill # process-level SIGKILL chaos matrix (builds dist first)
npm run test:integration # daemon/Electrum integration (needs an Electrum server)
npm run test:interop # 190+ cases vs LND/CLN/Eclair (needs Docker)
npm run test:interop:ffor # FFOR Variant D chain gates on regtest (needs only the bitcoind container)
npm run test:all # Lightning + CLI + interop (needs Docker + Electrum)Counts are floors, not snapshots. Run the suites for exact numbers.
test:local is the fast inner loop. test:lightning and test:cli are
independent processes and neither saturates the machine alone, so running them at
once beats running them in sequence. Measured on 8 cores: 195.6s in sequence
before this existed, 106.1s in sequence once test:cli gained --parallel, and
77.1s together. It prints a per-suite summary and, on
failure, the failing suite's output.
test:chaos is deliberately not in that bundle. Its cases carry real wall-clock
budgets that do not care how loaded the box is (chaosWait defaults to 15s, the
quorum barrier to 20s), and sharing the machine ate one of them: running all
three concurrently failed with chaosWait timed out with the victim alive while
the same suite passes 30/30 on its own. Run it separately. See
scripts/run-suites.js for the worker split and why more workers is not better.
The on-chain wallet suites live in tests/*.test.ts and connect to live public
Electrum servers, so they need network access and can fail on a server outage
rather than on your change. Each script runs npm run build first:
npm run test:wallet # also test:transaction, test:electrum, test:storage,
# test:derivation, test:receive, test:boost
npm test # everything: build, on-chain, Lightning, CLI, interopThe on-chain files without a dedicated script (multisig, PSBT, watch-only, descriptors, signet and others) run through mocha directly:
npx mocha --exit -r ts-node/register 'tests/multisig.test.ts'test:conformance runs the official BOLT test vectors (BOLT 1 bigsize/TLV, BOLT 3 commitments and anchors and per-commitment secrets, BOLT 4 onion/route-blinding/onion-errors, BOLT 7 extended queries, BOLT 8 transport, BOLT 11 invoices, BOLT 12 offers/signatures) under tests/lightning/conformance/.
Interop testing against real implementations
The interop suite drives beignet against real nodes on Bitcoin regtest.
docker compose -f docker/docker-compose.yml up -d # wait ~30s for nodes to sync
npm run test:interopServices in docker/docker-compose.yml:
| Service | Image | Ports |
|---|---|---|
| bitcoind | Bitcoin Core 31.0 (regtest) | RPC 43782, ZMQ 28334/28335/28336 |
| lnd | lightninglabs/lnd:v0.20.0-beta |
P2P 9735, REST 8081 |
| cln | elementsproject/lightningd:v26.06.1 |
CLNRest 3010 |
| eclair | 0.14.1, built locally from the release zip (docker/eclair/Dockerfile) |
HTTP API 8082 |
| electrs | getumbrel/electrs:v0.10.10 |
Electrum 60001 |
Covered per implementation: BOLT 8 handshake and BOLT 1 init/feature negotiation, disconnect/reconnect and ping/pong survival, channel open in both directions, bidirectional payments and payment_secret validation, MPP, SCID aliases, cooperative close, reestablish, gossip sync, inbound connections, anchor channels, anchor force-close with wallet-funded CPFP and HTLC-timeout fee-attach, and crash recovery. Beyond the shared matrix: taproot channel lifecycle vs LND (open, pay both directions, reestablish, coop and force close, penalty, SCB recovery), splice matrix and lease/liquidity-ads flows vs CLN, simple_close vs Eclair, blinded-path payments, and the watchtower client vs an LND tower.
Interop tests are excluded from npm run test:lightning.
Beignet is under active development. Known gaps and caveats:
| Feature | Status | Detail |
|---|---|---|
| Mainnet battle-testing | Limited | Interop-tested on regtest, with some flows validated live on mainnet. Exercise caution with large balances. |
| Watchtowers | Client only (altruist) | Punishes breaches while you are offline via remote LND altruist towers. Legacy + anchor channels only: taproot channels are not backed up. No server mode. |
| LSP / LSPS protocols | Not implemented | No automated inbound liquidity via LSPS0/1/2. Liquidity ads (bLIP-51) cover negotiated leases; otherwise open channels manually. |
| Trampoline routing | Not implemented | All route computation is local. |
| BOLT 12 offers | Newer | Offers, invoice_request/invoice over onion messages and receive-side settlement work, but the surface is less battle-tested than BOLT 11. Prefer BOLT 11 in production. |
| Async payments | LSP-dependent | Hold invoices plus AsyncPaymentManager let an offline receiver be paid, but the receiver's LSP must run the held-forward/wake flow. |
| Simple taproot channels | Experimental | Full lifecycle validated against LND v0.20 on regtest, but the feature bit is still in staging upstream. Not recommended for mainnet balances. |
| Splicing / dual funding | Partial | Splice-out and splice-in validated live against CLN; v2 dual-funded opens implemented both as initiator and acceptor. CLN-initiated splices, repeat splices and multi-UTXO splice-ins are untested. |
| Mobile background | Limited | Works on React Native but has no background sync or push-notification support. |
Recommended safeguards in production:
- Cap exposure with
maxPaymentSatsanddailySpendLimitSats. - Call
validatePayment()before every send. - Set
backupPathfor automated database backups, and keep an SCB (beignet backup scb). - Pass multiple
electrumServersfor connection redundancy. - Configure watchtowers so breaches are punished while you are offline.
- Monitor
node:errorevents and the/healthendpoint. - Start with small channels and increase gradually.
react-native-tcp-socket is a drop-in replacement for net and tls:
{
"react-native": {
"net": "react-native-tcp-socket",
"tls": "react-native-tcp-socket"
}
}| Document | Contents |
|---|---|
| example/REPL_TESTING.md | Copy-pasteable REPL walkthrough of the full node lifecycle |
| docs/AI_AGENT_GUIDE.md | Deployment, monitoring, safety rails, HTTP daemon patterns |
| src/lightning/README.md | Protocol-layer reference and usage guide |
| docs/ROADMAP.md | Feature roadmap and progress |
| docs/RECOVERY-PROTOCOL.md | Proposed replicated state-continuity design |
| API reference | Generated typedoc (HTML) |
Open an issue, or reach out on Telegram.
MIT