npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

thunder-bridge

v0.7.0

Published

JavaScript client for the Thunder Bridge Lightning donation proxy. REST API + WebSocket with PoW anti-spam.

Readme

thunder-bridge

Accept Lightning donations and relay sats to any Lightning wallet. Hook up automations: trigger functions on your web app, ping a server, control IoT devices.

REST API + WebSocket + PoW anti-spam + QR codes + webhook verification. Works in Node.js, Bun, Deno, browsers, and edge runtimes. Webhook verification requires node:crypto (Node.js, Bun, Deno).

Quick start

import { ThunderBridge } from 'thunder-bridge';

const gw = new ThunderBridge('https://thunder-bridge.agora.gripe');

// Create a donation
const { id, bolt11 } = await gw.createDonation({
  destination: '[email protected]',
  amountSat: 100,
});

// Wait for fulfillment via WebSocket
const status = await gw.waitForFulfillment(id, {
  onStatusChange(s) { console.log('status:', s); },
});

console.log(status); // "success" | "failed" | "expired"

API

new ThunderBridge(gatewayUrl, options?)

const gw = new ThunderBridge('https://thunder-bridge.agora.gripe');

// Verification is strict by default. Opt into a named risk with `allow`:
const lenient = new ThunderBridge('https://gateway.example', {
  allow: {
    custodialFallback: true,    // accept custody (gateway holds funds first)
    unverifiedRecipient: true,  // proceed when the address did not resolve at all
    unboundRecipient: true,     // proceed when only the provider, not the account, was confirmed
  },
});

| Option | Type | Description | |--------|------|-------------| | autoVerify | boolean? | Verify the gateway's invoice automatically and throw on a cheat. Default true | | allow | AllowPolicy? | Per-risk opt-outs from the strict default (all false). See above | | allowUnverifiedRecipient | boolean? | Deprecated, use allow.unverifiedRecipient |


gw.createDonation(params)

Create a new donation. Automatically solves a PoW challenge if required.

const { id, bolt11, invoiceHash, forwardingMode } = await gw.createDonation({
  destination: '[email protected]', // Lightning Address
  amountSat: 100,              // amount in satoshis
  webhookUrl: 'https://myapp.com/hook',    // optional
  webhookSecret: 'my-hmac-secret',         // optional HMAC-SHA256 signing key
});

The trustless path is demanded by default. Set allow.custodialFallback: true on the client to accept custody instead.

| Param | Type | Description | |-------|------|-------------| | destination | string | Lightning Address (user@domain). BOLT12 offers are not currently accepted by the gateway | | amountSat | number | Amount in satoshis (min 1) | | webhookUrl | string? | URL to POST on successful donation relay | | webhookSecret | string? | HMAC-SHA256 key for signing webhook payloads (use HTTPS) | | requireTrustless | boolean? | Deprecated (the trustless path is now demanded by default). Use the client-level allow.custodialFallback. Still overrides the policy per call | | maxDonorFeeSat | number? | Max sat the donor invoice may exceed the donation by during auto-verify. Default 1% of the donation (min 2 sat), a hard ceiling against extreme fee inflation. Set smaller to tighten | | amountKind | "recipient_receives" \| "donor_pays"? | Who covers the routing fee. recipient_receives (default): the recipient is paid the full amountSat and the fee is added on top of the donor invoice. donor_pays: the donor is charged exactly amountSat and the recipient nets it minus the fee. verifyTrustless checks the matching leg |

Returns { id, bolt11, invoiceHash, amountSat, expiresAt, forwardingMode, recipientInvoice, amountKind }. forwardingMode is "trustless" or "custodial".

Automatic verification (on by default). Before returning, createDonation checks the gateway's invoice against your destination and amountSat, and waitForFulfillment checks the settled preimage. A proven cheat (skim, overcharge, chain swap, recipient swap, faked settlement) throws GatewayCheatError with code naming the failed check, so the only input you supply is the destination. The checks are listed under verifyTrustless below. Disable with autoVerify: false and verify by hand.

Strict when identity cannot be confirmed. For a Lightning address the SDK resolves the address off the gateway and binds the recipient invoice to it by LUD-06 description_hash, which tells apart two accounts on one shared custodial node (Wallet of Satoshi, Alby, ...) where a node id alone cannot. If it cannot confirm the account, createDonation refuses with UnverifiedRecipientError: an unbound address (no description_hash, or generic metadata) unless allow.unboundRecipient, an unresolvable one (provider down, multi-node) unless allow.unverifiedRecipient. A proven substitution always throws GatewayCheatError, which no flag relaxes.

Run the SDK where the gateway cannot tamper with it (your backend, or a frontend you host). If the gateway serves the page or the SDK, it controls the verifier and the checks are theater. See docs/trust/ for the residuals no client code removes.

A custodial donation (the gateway's fallback when the trustless path cannot serve it) is refused by default: the SDK demands trustless and throws GatewayCheatError (code: "custodial_fallback"). Custody is honest, not theft, but the bedrock refund guarantee does not cover it. Opt in with allow.custodialFallback: true (verification then reports "custodial" and does not throw), or read forwardingMode on the result and decide.

import {
  ThunderBridge,
  GatewayCheatError,
  UnverifiedRecipientError,
} from 'thunder-bridge';

const gw = new ThunderBridge('https://gateway.example');
try {
  const donation = await gw.createDonation({ destination, amountSat });
  // safe to show donation.bolt11 to the payer
} catch (e) {
  if (e instanceof GatewayCheatError) {
    // the gateway demonstrably cheated. e.code: hash_mismatch | amount_skim | amount_overcharge | ...
  } else if (e instanceof UnverifiedRecipientError) {
    // identity could not be confirmed to the account. Use a provider that binds
    // by description_hash, or set allow.unboundRecipient /
    // allow.unverifiedRecipient to accept the risk.
  }
}

See verifying trustlessness and the trust vault in docs/trust/.


gw.createDonationForInvoice(recipientInvoice, options?)

Forward to a recipient invoice you supply, the bring-your-own-invoice path. You resolve the recipient yourself (see resolveInvoice below, or decode any BOLT11 you already hold) and hand the gateway a finished invoice instead of an address. The gateway then chooses nothing about the recipient, it relays to the exact invoice, so its trust drops to zero on who gets paid and you trust it for nothing beyond relaying. This is the strongest trust posture and works for any wallet, because it rests on no provider signal, only on the donor holding the choice.

import { resolveInvoice } from 'thunder-bridge';

const invoice = await resolveInvoice('[email protected]', 100);
const donation = await gw.createDonationForInvoice(invoice);
// safe to show donation.bolt11 to the payer

| Param | Type | Description | |-------|------|-------------| | recipientInvoice | string | A BOLT11 invoice carrying a whole-sat amount, the destination the gateway must pay | | options.maxDonorFeeSat | number? | Max sat the donor invoice may exceed the recipient amount by. Default 1% (min 2 sat) | | options.webhookUrl | string? | URL to POST on successful relay | | options.webhookSecret | string? | HMAC-SHA256 key for signing webhook payloads |

Returns the same CreateDonationResult as createDonation. The trustless path is always required (a custodial relay would defeat the donor's reason for choosing the invoice), so a custodial response throws GatewayCheatError (code: "custodial_fallback"). Verification confirms the gateway's donor invoice reuses your invoice's payment hash, checked against your own copy and not the gateway's echo, so settling it can only pay your recipient. There is no identity step, you already chose the recipient. A mismatched hash, amount, or chain throws GatewayCheatError.

resolveInvoice(address, amountSat) fetches a recipient invoice off the gateway over LNURL, returning null when resolution is unavailable (a CORS-blocked browser, a provider down, a malformed address). Server-side it is unrestricted, in a browser it is subject to the provider's CORS policy. See the BYOI design for the flow and the never-at-a-loss economics on the operator side.


gw.waitForFulfillment(donationId, options?)

Subscribe to donation status updates via WebSocket. Resolves when the donation reaches a terminal status (success, failed, or expired).

const status = await gw.waitForFulfillment(id, {
  onStatusChange(s) { console.log(s); },
  timeoutMs: 600_000, // 10 minutes
});

gw.getDonation(id)

Fetch a single donation by ID. Rate-limited to 60 req/min per IP.

const donation = await gw.getDonation('550e8400-...');
console.log(donation.status, donation.amount_sat);
console.log(donation.forwarding_mode);          // "trustless" | "custodial"
// On success, donation.preimage proves payout: sha256(preimage) === invoice_hash.

verifyTrustless(donation)

Verify a donation forwarded trustlessly, without trusting the gateway (when you run this SDK yourself, not a copy the gateway serves). Decodes both the donor invoice you pay and the recipient invoice locally and confirms they share a payment hash, so the gateway cannot settle your payment without paying the recipient. It checks the amounts against the fee mode (amount_kind). One leg is pinned to the donation exactly and the other may differ only by a fee allowance, so the gateway cannot skim the recipient or overcharge the donor beyond the fee. In the default recipient_receives the recipient invoice equals the donation, with no skim. In donor_pays the donor invoice equals the donation and the recipient nets it minus the bounded fee. It also confirms both invoices are on the same chain, no testnet hash reuse. Once the donation succeeds it also confirms sha256(preimage) equals that hash.

It accepts the createDonation result too, so you can gate before showing the invoice to a payer: only display it when the verdict is "verified".

import { verifyTrustless } from 'thunder-bridge/lowlevel';

const created = await gw.createDonation({ destination, amountSat });
// `created` carries the amount you requested, so the hash, amount, and (once
// settled) preimage are all checked with no extra argument:
if (verifyTrustless(created) !== 'verified') {
  throw new Error('gateway returned an untrustworthy invoice, not showing it');
}
// safe to display created.bolt11 to the payer

// after settlement, the same check also proves payout:
const status = verifyTrustless(await gw.getDonation(created.id));
// "verified" | "custodial" | "hash_mismatch" | "amount_skim" | "amount_overcharge"
//   | "amount_mismatch" | "network_mismatch" | "preimage_mismatch"
//   | "recipient_mismatch" | "recipient_undecodable"

To also catch a gateway forwarding to a different recipient than intended, pin the recipient's node public key (obtained out of band from the recipient) and pass it. The recipient invoice's payee node id is recovered from its signature and compared, so a substituted recipient returns "recipient_mismatch" and you refuse to show the invoice:

const RECIPIENT_NODE_ID = '03e7156a...'; // pinned, from the recipient directly
if (verifyTrustless(created, { expectedRecipientNodeId: RECIPIENT_NODE_ID }) !== 'verified') {
  throw new Error('recipient substitution or untrustworthy invoice, not showing it');
}

"hash_mismatch", "amount_skim", "amount_overcharge", "amount_mismatch", "network_mismatch", and "preimage_mismatch" mean the gateway's claim did not hold. "recipient_undecodable" means the recipient invoice could not be decoded (for example a lno1... or garbage value): the SDK money checks cannot run, so treat it as unverified and do not show the invoice.

A Lightning address is anchored by its DNS and TLS resolution plus the LUD-06 description_hash bind, which distinguishes accounts on one shared custodial node. BOLT12 offers, whose identity is cryptographic and DNS-free, are not currently accepted by the gateway. See verifying trustlessness.


verifyBolt12Donation(donation, offer) (BOLT12 recipients)

Not currently usable through this gateway. BOLT12 offer forwarding is disabled, so the gateway rejects a lno1... destination at createDonation and there is no BOLT12 donation to verify. This entry point ships for when BOLT12 is re-enabled. It needs the optional boltz-bolt12 peer dependency (npm install boltz-bolt12).

When usable, it parses the recipient invoice (rejecting a forged/tampered signature), confirms the invoice's node id equals the offer's issuer key, confirms the payment hash matches the donor invoice, and checks the amounts against the fee mode (amount_kind) just like the BOLT11 path, one leg pinned to the donation and the other within the fee allowance. Unlike the BOLT11 path it does not check the chain (a BOLT12 invoice does not expose it here).


gw.estimateFees(amountSat, amountKind?)

Estimate relay fees for a given amount. Rate-limited to 60 req/min per IP. Pass amountKind ("recipient_receives" default, or "donor_pays") to preview the mode you will use: it shifts the fee onto the matching leg.

const fees = await gw.estimateFees(1_000);
console.log(`Donor pays: ${fees.donor_pays_sat} sat`);
console.log(`Recipient receives: ${fees.relay_estimate_sat} sat`);
console.log(`Total fee: ${fees.total_fee_estimate_sat} sat`);

Triggers (IoT)

Triggers are Lightning-powered buttons. Someone donates, your device reacts. Each trigger has a fixed price and a Lightning address for QR codes.

Create a trigger

const trigger = await gw.createTrigger({
  priceSat: 100,                          // fixed price
  name: 'Fountain',                       // optional display name
  forwardTo: '[email protected]',      // optional: relay donation to this address
  invoiceExpirySecs: 86400,               // optional: invoice valid for 1 day
});

console.log(trigger.ln_address);          // "[email protected]"
console.log(trigger.hash);               // "a1b2c3" (use for WS + stats)

Triggers without forwardTo are signal-only: the IoT device gets notified, funds stay in the node.

Listen for donations (continuous stream)

const cleanup = gw.streamTrigger(trigger.hash, {
  onDonation(d) {
    console.log(`Donated ${d.amount_sat} sats, signal: ${d.signal_only}`);
    // activate fountain, ring bell, unlock door, ...
  },
  onConnect() { console.log('Connected'); },
  reconnect: true,       // auto-reconnect on disconnect (default)
  reconnectDelayMs: 3000,
});

// Later: cleanup() to disconnect

On connect, the last 10 successful donations are replayed so the device can catch up after a restart.

Wait for a single donation

const donation = await gw.waitForTrigger(trigger.hash, {
  timeoutMs: 300_000,
});
console.log(donation.amount_sat, donation.signal_only);

Get trigger stats

const stats = await gw.getTriggerStats(trigger.hash);
console.log(`${stats.total_payments} donations, last at ${stats.last_payment_at}`);

Generate QR code from Lightning address

import { invoiceToSvg } from 'thunder-bridge';
import { invoiceToDataUrl } from 'thunder-bridge/lowlevel';

const svg = invoiceToSvg(trigger.ln_address);
const dataUrl = invoiceToDataUrl(trigger.ln_address);

Donation event format

{
  "event": "donation",
  "amount_sat": 100,
  "signal_only": false,
  "timestamp": 1744382400
}

signal_only is true when the amount is too small to relay (fee exceeds amount). timestamp is Unix seconds.

Anti-spam

  • One active invoice per trigger at a time (reused until fulfilled or expired)
  • Unused triggers (zero donations) are auto-deleted after 7 days
  • PoW challenge required after too many requests from one IP

Webhooks

When you provide webhookUrl on donation creation, the server POSTs a JSON payload to that URL immediately after a successful relay. If delivery fails, it retries with increasing delays: 10s, 1m, 2m, 5m, 10m, 30m, 1h, then hourly up to 1 week.

Payload

{
  "event": "donation.success",
  "donation": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "destination": "[email protected]",
    "amount_received_sat": 100,
    "amount_sent_sat": 95,
    "network_fee_sat": 5,
    "status": "success",
    "error_message": null,
    "created_at": "2025-03-28T10:30:00Z",
    "updated_at": "2025-03-28T10:31:00Z"
  },
  "signal_only": false
}

Headers

| Header | Description | |--------|-------------| | Content-Type | application/json | | X-Event-Type | donation.success | | X-Signature-256 | HMAC-SHA256 hex signature (only if webhookSecret was provided) |

Verifying signatures

The client exports helpers to verify and parse webhook payloads:

parseWebhookRequest(request, secret) / Fetch API (Hono, Next.js, SvelteKit, Remix, Deno, Bun)

import { parseWebhookRequest } from 'thunder-bridge';

// Hono
app.post('/webhook', async (c) => {
  const payload = await parseWebhookRequest(c.req.raw, MY_SECRET);
  if (!payload) return c.text('Bad signature', 401);
  console.log('Donation succeeded:', payload.donation.id);
  return c.text('OK');
});
// Next.js app router (app/api/webhook/route.ts)
import { parseWebhookRequest } from 'thunder-bridge';

export async function POST(req: Request) {
  const payload = await parseWebhookRequest(req, process.env.WEBHOOK_SECRET!);
  if (!payload) return new Response('Bad signature', { status: 401 });
  // payload.donation is fully typed
  return new Response('OK');
}

parseWebhook(body, signature, secret) / Express / Fastify / raw body

import { parseWebhook } from 'thunder-bridge/lowlevel';

// Express (with express.raw() middleware on this route)
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
  const sig = req.headers['x-signature-256'] as string;
  const payload = await parseWebhook(req.body, sig, MY_SECRET);
  if (!payload) return res.status(401).send('Bad signature');
  console.log(payload.donation.id);
  res.sendStatus(200);
});

verifyWebhookSignature(body, signature, secret)

import { verifyWebhookSignature } from 'thunder-bridge/lowlevel';

const valid = await verifyWebhookSignature(rawBody, signature, secret);

All functions are async. All signature checks use constant-time comparison.


QR codes

Generate QR codes for any Lightning destination: BOLT11 invoices, BOLT12 offers, or Lightning addresses. Returns SVG strings, no canvas or browser APIs needed. Each input type is encoded per its own spec, since the rules differ.

import { invoiceToSvg } from 'thunder-bridge';
import { invoiceToDataUrl, encodeForQr } from 'thunder-bridge/lowlevel';

// BOLT11 invoice
const svg = invoiceToSvg(bolt11, { size: 300, color: '#1a1a2e' });

// BOLT12 offer
const offerQr = invoiceToSvg('lno1qgsq...');

// Lightning address (for triggers)
const triggerQr = invoiceToSvg('[email protected]');

// Data URL (for <img src="...">)
const dataUrl = invoiceToDataUrl(bolt11);

// If you want to drive your own QR library, get just the encoded payload:
const payload = encodeForQr('lno1qgsq...');  // -> "LNO1QGSQ..."

Encoding rules

| Input | Encoding | Reference | |---|---|---| | BOLT11 invoice (lnbc...) | LIGHTNING:LNBC... (uppercase) | BIP 21, BOLT 11 §QR | | BOLT12 offer (lno1...) | LNO1... (uppercase, no scheme) | BOLT 12 §encoding | | LN address (LUD-17) | LIGHTNING:user@domain (case preserved) | LUD-17 |

The lightning: URI scheme is BOLT11-specific. Wrapping a BOLT12 offer in LIGHTNING:LNO1... causes wallets that recognise the scheme to try parsing the body as BOLT11 and reject it as invalid. The unified URI form for offers is BIP 321's bitcoin:?lno=..., which this library does not generate.

Pass { withPrefix: false } to emit the raw input unchanged (debug only).


Utilities

Lightning address detection

Basic format checks for UI heuristics (icon switching, input hints). Full validation happens server-side.

import { detectDestinationType } from 'thunder-bridge';
import { isLnAddress, isBolt12Offer } from 'thunder-bridge/lowlevel';

isLnAddress('[email protected]');              // true
isBolt12Offer('lno1qgsqvgnwgcg35z6...');      // true
detectDestinationType('[email protected]');     // "lnAddress"

Proof of work (automatic)

The server requires proof-of-work when an IP creates too many open invoices (anti-spam). createDonation() handles this transparently, picking the fastest available strategy:

  1. node:crypto (native C++): Node.js, Bun, Deno
  2. Pure JS SHA-256: Cloudflare Workers, edge runtimes
  3. crypto.subtle (async, yielding): browsers

All types (Donation, DonationStatus, CreateDonationParams, CreateTriggerParams, Trigger, TriggerDonation, TriggerPublicStats, StreamTriggerOptions, WebhookPayload, etc.) are exported from the package root.

Lower-level primitives (verifyTrustless, classifyIdentity, preimageMatchesHash, resolveLnAddress, the bolt11* decoders, encodeForQr, invoiceToDataUrl, parseWebhook, verifyWebhookSignature, and the isBolt11Invoice/isBolt12Offer/isLnAddress detectors) and their types live under the thunder-bridge/lowlevel subpath, which needs a moduleResolution of node16, nodenext, or bundler.

Claude Code skill

If you build with Claude Code, the package ships a skill that teaches Claude the current SDK surface, naming conventions, and integration patterns. Install it once and Claude will use it automatically when you write thunder-bridge code.

# Install into the current project (./.claude/skills/)
npx thunder-bridge install-skill

# Or install globally for your user (~/.claude/skills/)
npx thunder-bridge install-skill --global

# Overwrite an existing copy
npx thunder-bridge install-skill --force

The CLI is bundled with the npm package, so no separate install step is needed. The skill source lives at node_modules/thunder-bridge/skills/thunder-bridge-sdk/SKILL.md if you want to inspect or vendor it.

License

MIT