Lightning payment primitives that work anywhere. A farrier shoes working animals for the road. This kit shoes your payment paths.
Reach for farrier-kit when an app needs to read and verify Lightning payment data
without running a node. Decode a BOLT-11 invoice, resolve a Lightning Address to
an invoice for an exact amount, and prove a payment settled. It runs in the
browser and in Node from one codebase, depends only on
@noble/hashes, and ships
language-neutral conformance vectors so a Kotlin or Swift port
can verify payments the same way.
It is not a wallet and not a node client. It holds no keys and moves no money. It decodes, resolves, and verifies.
| Import | What it does |
|---|---|
farrier-kit/bolt11 |
Decode a BOLT-11 invoice: payment hash, amount, description, expiry, network. Checksum-verified, without pulling in a node library to read two fields. |
farrier-kit/preimage |
payment_hash = SHA-256(preimage): generate, hash, verify in constant time. |
farrier-kit/lnurl |
Lightning Address to invoice (LUD-06/16), LUD-21 verify, capability probing. The fetch is SSRF-guarded and the invoice you get back is checked for amount, network, expiry, and description-hash. |
farrier-kit/http |
fetchJson with a hard timeout, a response-size cap, and a redirect default that does not bounce you inward. |
farrier-kit/node |
Node-only. A DNS-pinned fetch for resolving untrusted LNURL hosts on a server: it resolves once, refuses any private answer, and pins the socket to the approved address so a rebinding race cannot swap in an internal one. |
npm install farrier-kitNode 18 or newer. Dual ESM and CJS, with TypeScript types. No configuration and no build step in the consumer.
These are the constraints the library holds itself to, and CI enforces the first two.
- One codebase for the browser and Node. Nothing imports from
node:. CI greps for it and bundles the output withesbuild --platform=browserto prove it.@noble/hashesis the only runtime dependency. - Dual ESM and CJS. Use
importorrequire, with any bundler. fetchis a parameter you can pass in, never an ambient assumption.- Amounts are explicit. Millisatoshis are
bigint.amountSatsis set only when the amount divides exactly, and flooring is its own named call (msatsToSatsFloor), so a sub-satoshi remainder can never disappear on you. - Checked against independents. Every invoice fixture is cross-validated against
light-bolt11-decoderand the BOLT-11 spec vector, preimage hashing against a second SHA-256, and the whole verifiable surface is pinned by conformance vectors. - Safe by default. The LNURL path refuses non-HTTPS, credentials, private IPs, redirects, and oversized bodies without being asked.
resolveLnurlPay is a pipeline. Every network hop is SSRF-guarded, and the
invoice the payee returns is decoded and checked against what you asked for before
you ever see it.
flowchart TD
A["resolveLnurlPay({ address, amountSats })"] --> G1{"guard: HTTPS,<br/>public host,<br/>no private IP"}
G1 -->|ok| M["GET /.well-known/lnurlp/name"]
G1 -->|violation| X(("throw<br/>LnurlError"))
M --> C{"payRequest?<br/>amount in range?"}
C -->|no| X
C -->|yes| B["build callback URL<br/>+ amount, + comment or NIP-57 zap"]
B --> G2{"guard callback URL"}
G2 -->|violation| X
G2 -->|ok| I["GET callback, read bolt11 invoice"]
I --> D["decodeBolt11(invoice)"]
D --> V{"amount == request?<br/>network == expected?<br/>not expired?<br/>description_hash matches?"}
V -->|any fails| X
V -->|all pass| R(["{ bolt11, paymentHashHex, verifyUrl }"])
The settlement lifecycle is non-custodial. The operator advertises and verifies, and the rider pays the payee directly.
sequenceDiagram
participant Rider
participant farrier as farrier-kit
participant Payee as Payee LN service
Rider->>farrier: resolveLnurlPay(address, amount)
farrier->>Payee: LNURL-pay (guarded)
Payee-->>farrier: bolt11 invoice
farrier-->>Rider: verified invoice + paymentHashHex
Rider->>Payee: pay bolt11 from own wallet
Payee-->>Rider: preimage (on settlement)
Rider->>farrier: verifyPreimage(preimage, paymentHashHex)
farrier-->>Rider: true, proven, no third party
Note over Rider,Payee: or verifyLud21(verifyUrl) when the rider never sees the preimage
import { decodeBolt11, verifyInvoiceCommitment } from 'farrier-kit/bolt11'
const inv = decodeBolt11('lnbc2500u1p...')
inv.paymentHashHex // '0001…0102'
inv.amountMsats // 250000000n (bigint, null when amountless)
inv.amountSats // 250000 (null when not a whole satoshi)
inv.network // 'bc' | 'tb' | 'tbs' | 'bcrt' | 'sb'
inv.expirySeconds // 60 (spec default 3600 when absent)
// Pre-payment check. The payment_hash on its own is not enough: the payee picks
// the preimage, so they can mint a second invoice with the same hash for any
// amount. Pass expectedMsats, and rely on the default mainnet network, when money
// is about to move.
const check = verifyInvoiceCommitment({
bolt11,
paymentHash: expectedHash,
expectedMsats: 250000000n,
})
if (!check.ok) throw new Error(check.reason)decodeBolt11 throws a Bolt11Error with a machine-readable code on anything
that is not a checksum-valid invoice carrying a payment hash. When "not an
invoice" is expected input, use tryDecodeBolt11, which returns null, or the
single-field helpers bolt11PaymentHash and bolt11AmountMsats.
import { generatePreimage, computePaymentHash, verifyPreimage } from 'farrier-kit/preimage'
const preimage = generatePreimage()
const paymentHash = computePaymentHash(preimage)
// ... the invoice settles and the counterparty reveals the preimage ...
verifyPreimage(revealed, paymentHash) // constant-time true or falseThe signatures line up with escrow-kit, so that library can adopt these by re-export.
import { resolveLnurlPay, verifyLud21 } from 'farrier-kit/lnurl'
// Lightning Address to a bolt11 invoice for an exact amount. The invoice has been
// decoded and checked: the amount equals the request, mainnet by default, not
// expired, and for zaps the description_hash commits to the zap request.
const paid = await resolveLnurlPay({ address: 'alice@wallet.example.com', amountSats: 21000 })
paid.bolt11 // hand to any wallet
paid.paymentHashHex // verify the revealed preimage against this
paid.verifyUrl // LUD-21, origin-bound to the callback, when offered
// LUD-21 settlement check. verified is true only when a returned preimage
// cryptographically matches. settled on its own is the service's word, not proof.
const status = await verifyLud21({ verifyUrl: paid.verifyUrl, paymentHashHex: paid.paymentHashHex })import { fetchJson } from 'farrier-kit/http'
const body = await fetchJson('https://example.com/.well-known/lnurlp/alice', {
timeoutMs: 5000, // default 8000
maxBytes: 262144, // default 10 MB, metered and aborted mid-flight
fetchImpl: myFetch,// optional, defaults to globalThis.fetch
})fetchJson sets redirect to 'manual' by default. For a payments util that is
the safe choice, since a public host should not be able to 3xx-bounce a request
inward. Pass redirect: 'follow' if you want redirects.
Node-only. createPinnedFetch returns a fetch you pass as fetchImpl to
resolveLnurlPay, verifyLud21 or createCapabilityProbe. It is the server-side
answer to DNS rebinding (see the SSRF section for why a URL check alone is not
enough).
import { createPinnedFetch } from 'farrier-kit/node'
import { resolveLnurlPay } from 'farrier-kit/lnurl'
const pinnedFetch = createPinnedFetch() // rejects any private/reserved answer
const invoice = await resolveLnurlPay({
address: 'alice@example.com', // untrusted, payee-controlled
amountSats: 1000,
fetchImpl: pinnedFetch, // metadata, callback and verify URLs all pinned
})It resolves the hostname once, rejects the request if any answer is private,
loopback, link-local, reserved, documentation-only or multicast, and connects the
socket to the one approved address by overriding its DNS lookup, so there is no
second resolution to race. The TLS SNI, certificate check and HTTP Host header
stay on the original hostname. It never follows redirects. Pass allowPrivate: true only for local development against regtest or localhost.
resolveLnurlPay fetches from payee-controlled domains, so it is an SSRF surface.
The built-in guard rejects an HTTPS violation, credentials in the URL,
localhost, .local and .internal hosts (trailing dots included), and any IP
literal in a private or reserved range. That covers IPv4 and every IPv6 form the
URL parser normalises to: mapped ::ffff:*, NAT64, 6to4, Teredo, link-local,
site-local, ULA, and multicast.
Here is what the core cannot do on its own. It cannot stop a hostname that
resolves to a private address, such as an attacker A-record pointing at
10.0.0.5, or a DNS-rebinding race. Browsers cannot resolve DNS at all, so this
pinning is a server-only job; a browser should never resolve an untrusted address.
On a server, use createPinnedFetch from farrier-kit/node:
import { createPinnedFetch } from 'farrier-kit/node'
import { resolveLnurlPay } from 'farrier-kit/lnurl'
await resolveLnurlPay({ address, amountSats, fetchImpl: createPinnedFetch() })Why not just a urlGuard that resolves DNS and checks the address? Because that
is a check, and the fetch that follows is a separate connection that resolves DNS
again. Between the two lookups the answer can change: public when you check,
private when you connect. That gap is DNS rebinding, and a check-then-fetch cannot
close it. createPinnedFetch does, by being the connection: it validates the
address it is about to use and pins the socket to it.
The urlGuard hook still exists for an extra synchronous check on every URL (an
allowlist, say). It runs after the built-in checks, but it does not replace
createPinnedFetch for rebinding.
The pure, deterministic surface is pinned by language-neutral vectors in
vectors/, so a native port can be checked byte-for-byte against the
same contract the TypeScript reference passes. See
CONFORMANCE.md. This is how the Android and GrapheneOS clients
on the roadmap verify payments the same way as the browser build. The vectors ship
in the npm package, and CI regenerates them from independent oracles and fails on
any drift.
farrier-kit had a full independent security review before its first release: three
reviewers plus a Codex cross-check. The SSRF guard, the invoice amount and network
gating, and the response caps were hardened as a result, each with a regression
test. Cryptography is @noble/hashes,
not hand-rolled. It has not had a paid third-party audit, so treat it accordingly
for high-value custody. Report issues via
GitHub.
Most Lightning JS libraries either only decode an invoice, or are wallet and payment toolkits that resolve a Lightning Address and hand you an invoice to pay. farrier-kit sits in a different spot. It is the read-and-verify layer you run before payment.
| Library | bolt11 decode | LNURL resolve | invoice gating¹ | SSRF guard | preimage / LUD-21 verify | sends / zaps | browser + Node | core deps |
|---|---|---|---|---|---|---|---|---|
| farrier-kit | ✓ | ✓ | ✓ all four | ✓ | ✓ | ✗ | ✓ | @noble only |
| light-bolt11-decoder | ✓ | ✗ | ✗ | – | ✗ | ✗ | ✓ | @scure/base |
| bolt11 (bitcoinjs) | ✓ +encode/sign | ✗ | ✗ | – | ✗ | ✗ | ~ native secp | heavy |
| @getalby/lightning-tools | ✓ | ✓ | ✗ | ✗ | ✓ | ✓ zaps/NWC/L402 | ✓ | zero-install (bundled) |
| lnurl-pay | ✓ | ✓ | ~ amount + desc-hash | ✗ (accepts localhost) |
✓ | ✗ | ✓ | browserify tree |
¹ invoice gating means checking the invoice you got back against what you asked for: amount, network, expiry, and description-hash.
What is actually different here. As far as we can tell, farrier-kit is the only JS
or TS library that guards LNURL and Lightning-Address resolution against SSRF.
Alby fetches the callback unchecked, and lnurl-pay's URL check accepts
localhost. It is also the only one that gates the resolved invoice on all four
of amount, network, expiry, and description-hash. lnurl-pay already checks amount
and description-hash, which is good prior art, but not network or expiry, and does
not guard the fetch. Alby checks none of the four. The value is the combination:
guarded resolution, the full four-way gate, and preimage verify, in the browser
and Node from an @noble-only core.
Where the others win, and where you should use them. If you need to send payments, zap, or reach a wallet, @getalby/lightning-tools is the one to beat. It is actively maintained, pulls no install dependencies since it bundles its own tree, and covers WebLN, NWC, NIP-57 zaps, boostagram, and L402, none of which farrier-kit will ever do. If you only need to decode, light-bolt11-decoder is smaller and more battle-tested, and Alby bundles it anyway. If you need to create or sign invoices, use bolt11. farrier-kit takes the narrow, security-critical slice those leave open: safely verifying what someone handed you.
Shipped so far: the v1.0 core (/bolt11, /preimage, /lnurl, /http), an
independent security review, and language-neutral conformance vectors. Next up is
a Kotlin port checked against the vectors, so verification is identical on native
mobile, then /nwc (a NIP-47 client plus a wallet-service harness),
/nostr-crypto, /fiat, and /handles. New modules land as additive minor
releases.
See ROADMAP.md for the full plan and RELEASING.md for how releases are cut.
For issues and feature requests, see GitHub Issues.
If farrier-kit is useful to you, a tip is always welcome:
- Lightning:
profusemeat89@walletofsatoshi.com - Nostr zaps:
npub1mgvlrnf5hm9yf0n5mf9nqmvarhvxkc6remu5ec3vf8r0txqkuk7su0e7q2
MIT