LNURL service that enables amountless Lightning receives for Arkade wallets, with optional SQLite persistence, LN Address (LUD-16) serving, and a React admin UI.
Wallet Service Payer
│ │ │
│── POST /lnurl/session ──▶│ │
│◀── SSE: session_created ─│ │
│ { sessionId, lnurl } │ │
│ │◀── GET /lnurl/:id ───────│
│ │── payRequest metadata ───▶│
│ │ │
│ │◀── GET /lnurl/:id/cb ────│
│ │ ?amount=50000 │
│◀── SSE: invoice_request ─│ (holds response) │
│ { amountMsat } │ │
│ │ │
│── POST /session/:id/ ───▶│ │
│ invoice { pr: "lnbc…" }│── { pr: "lnbc…" } ─────▶│
│ │ │
│── (close SSE) ──────────▶│ LNURL deactivated │
| Method | Path | Caller | Purpose |
|---|---|---|---|
| POST | /lnurl/session |
Wallet | Opens SSE stream; returns session_created with sessionId and lnurl |
| GET | /lnurl/:id |
Payer | LNURL-pay first call (LUD-06) — returns pay metadata |
| GET | /lnurl/:id/callback?amount=<msat> |
Payer | Requests invoice; notifies wallet via SSE |
| POST | /lnurl/session/:id/invoice |
Wallet | Wallet posts { pr: "<bolt11>" } to resolve the pending payer request |
| GET | /lnurl/verify/:paymentHash |
Payer | LUD-21 — poll settlement; returns { settled, preimage, pr } |
| POST | /lnurl/session/:id/settled |
Wallet | Wallet reports { preimage } once its invoice settled (feeds verify) |
| Method | Path | Caller | Purpose |
|---|---|---|---|
| GET | /.well-known/lnurlp/:username |
Payer | LUD-16 resolution — returns pay metadata; error if wallet is offline |
| GET | /.well-known/lnurlp/:username/callback |
Payer | Invoice callback for LN address payments |
| POST | /lnurl/address |
Wallet | Register/claim a Lightning address |
| GET | /lnurl/address |
Wallet | List own addresses (Authorization: Bearer <token>) |
| GET | /lnurl/domain |
Wallet | What the domain allows: allocation modes (self, random, admin, session) and username rules |
| PATCH | /lnurl/address/:handle |
Wallet | Give a nameless receiver a name in place; its LNURL keeps working (Authorization: Bearer <token>) |
| DELETE | /lnurl/address/:handle |
Wallet | Revoke own address (Authorization: Bearer <token>) |
| POST | /lnurl/address/:handle/arkade |
Wallet | Register Arkade receive identity for offline receive (Authorization: Bearer <token>) |
import { createServer } from "@arkade-os/lnurl";
const app = createServer({
port: 3000,
baseUrl: "https://lnurl.example.com",
minSendable: 1_000, // 1 sat in millisats
maxSendable: 100_000_000, // 100k sats in millisats
invoiceTimeoutMs: 30_000,
});
app.listen(3000);PORT=3000 \
BASE_URL=https://lnurl.example.com \
MIN_SENDABLE=1000 \
MAX_SENDABLE=100000000000 \
INVOICE_TIMEOUT_MS=30000 \
pnpm devdocker run -p 3000:3000 \
-e BASE_URL=https://lnurl.example.com \
-e DB_PATH=/data/lnurl.db \
-e TOKEN_ENCRYPTION_KEY=<32-byte-hex> \
-e BOOTSTRAP_DOMAIN=pay.example.com \
-v /host/data:/data \
ghcr.io/arklabshq/lnurl-server:latestThe image runs as the unprivileged node user, writes state only under /data, and checks /readyz. For the single-instance reference deployment and backup/restore procedure, see compose.production.yml and docs/operations.md. The admin port (3001) is not published in the example above.
-
Open session —
POST /lnurl/session. The response is an SSE stream. The first event issession_createdwith{ sessionId, lnurl, token }. Display the LNURL as a QR code. -
Listen for invoice requests — When the payer scans and selects an amount, the wallet receives an
invoice_requestevent with{ amountMsat, comment }. -
Create swap and reply — Use
@arkade-os/boltz-swapto create a reverse swap for the requested amount, thenPOST /lnurl/session/:id/invoicewith{ pr: "<bolt11>" }. -
Report settlement (LUD-21, optional) — After your invoice settles,
POST /lnurl/session/:id/settledwith{ preimage }(Bearer token). The server verifiessha256(preimage)matches the invoice's payment hash and flips theverifyURL tosettled: true, letting payers confirm the payment. Report while the session is still connected (or reconnect the reusable session first). -
Close session — When done, close the SSE connection. The LNURL is immediately deactivated.
| Event | Data | Description |
|---|---|---|
session_created |
{ sessionId, lnurl, token } |
Session is active, LNURL is ready to share |
invoice_request |
{ amountMsat, comment? } |
Payer requested an invoice for this amount |
error |
{ message } |
Something went wrong |
Without DB_PATH the service runs fully in-memory and behaves exactly as the original single-binary library — no database, no LN address provisioning, no admin UI.
Set DB_PATH to enable SQLite persistence:
DB_PATH=/data/lnurl.db \
TOKEN_ENCRYPTION_KEY=<32-byte-hex-or-base64> \
pnpm devThe database uses Node's built-in node:sqlite module (requires --experimental-sqlite, wired automatically via the NODE_OPTIONS env var in the npm scripts).
When DB_PATH is set, choose a private TOKEN_ENCRYPTION_KEY or explicitly set ALLOW_INSECURE_TOKEN_STORAGE=1. With a private key, wallet session tokens are stored with AES-256-GCM so a database dump alone cannot impersonate wallets. The insecure fallback makes no such confidentiality claim.
TOKEN_ENCRYPTION_KEY— 32-byte secret, encoded as hex (64 chars) or base64 (44 chars).
Generate:node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"ALLOW_INSECURE_TOKEN_STORAGE=1— explicit escape hatch that uses the source-known fallback key instead of requiringTOKEN_ENCRYPTION_KEY. It prevents accidental plaintext rows but provides no protection against anyone with the source and database. Use it only when that tradeoff is acceptable.
When persistence is enabled the service can serve Lightning addresses (user@domain.com). Resolution is host-based: the Host header of the incoming request selects the domain. Multi-domain setups are supported by routing different hostnames to the same server instance.
- Online —
/.well-known/lnurlp/:usernamereturns pay metadata and the callback triggers an invoice request to the wallet's live SSE session. - Offline — if the wallet's SSE session is not active the callback returns an LNURL error (
status: "ERROR").
Wallets register a Lightning address via POST /lnurl/address. The request body must include a token (the wallet's session token). The username and domain fields determine which address is assigned:
| Request | Behaviour |
|---|---|
username provided, domain allows "self" mode |
Wallet claims that specific username |
no username, domain allows "random" mode |
Server assigns a random username |
username + claimCode provided |
Wallet claims a pre-reserved address |
Constraints enforced per domain:
allocationModes— which of"self","random","admin","session"the domain permits."session"allows a receiver with no lightning address, reachable at/lnurl/<sessionId>.requireApiKey— if true, requests must include a validX-API-Keyheader.max_per_session— cap on active addresses per wallet session.username_min_len/username_max_len/username_pattern— username validation rules.- Blacklist — per-domain and global username blacklist enforced at registration time.
- Registration rate limit — per-IP limit controlled by
REGISTRATION_RATE_LIMIT(requests/min per IP).
Normally an LN address only resolves while the wallet's SSE session is connected. With offline receive, a wallet can receive while disconnected: the server quotes a solver-mediated swap over the Arkade intents corridor (lightning:BTC -> arkade:BTC) and hands the payer the solver's hold invoice. When the payer pays, the solver funds a VHTLC pinned to the user's Arkade address, and this server claims it on the user's behalf — constrained by covenant (enforcePayTo) to pay only that address, so neither the solver nor the claimer can redirect funds. See Self-claim, which is the intended configuration; a covclaimd daemon can claim the same leaf alongside it.
Enable it with the published registry for ARKADE_NETWORK (the default), an optional SOLVER_REGISTRY_URLS override, and/or SOLVER_CARDS_FILE, plus ARK_SERVER_URL — and COVCLAIMD_URL, unless OFFLINE_SELF_CLAIM=true (which omits the claim packet and claims without covclaimd). Discovery uses @arkade-os/solver-discovery, merges registry, startup-file, and admin-pasted cards, ranks compatible Lightning-receive markets, and fails over only before an invoice is accepted. No eligible card disables only offline receive; live wallet sessions still provide their own BOLT11 invoice. Pasted cards are managed in the admin UI's Solvers tab and activate immediately. A wallet then registers its receive identity on an owned address:
When
COVCLAIMD_URLis set, it must name the same covclaimd instance the solver reveals to. The protocol does not carry that choice: this server seals the preimage to the key its ownCOVCLAIMD_URLreports, while the solver reveals to whichever daemon its operator configured. Point them at different daemons and every offline receive funds, fails to claim, and refunds — covclaimd correctly refuses a packet it cannot decrypt, the solver retries that refusal until the refund deadline, and the only trace is in the solver operator's logs, not yours. Until arkade-os/intent-solver#46 moves the choice in band, confirm the pairing with whoever runs the solver. Omitted (self-claim mode), the RFQ carries noclaim_packetand there is nothing to pair.
curl -X POST https://pay.example.com/lnurl/address/alice/arkade \
-H "Authorization: Bearer <session-token>" \
-H "Content-Type: application/json" \
-d '{"arkadeAddress":"tark1...","claimPublicKey":"02..."}'After that, if a payer pays alice@pay.example.com while the wallet is offline, the server returns the solver's hold invoice and reports settlement through the LUD-21 verify URL (polled from the solver's RFQ status). The server never holds the user's keys or funds — it generates the swap preimage and hands it to covclaimd encrypted (sealed into the quote request, so it never touches the solver in plaintext).
The corridor client is the published
@arkade-os/swappackage. The wire contract is integration-tested against fake HTTP services and was live-probed against the public mutinynet solver (scripts/probe-solver.ts) — quote, hold-invoice binding, and covenant derivation all verified. A full funded swap (payer pays, covclaimd claims) remains the operator's pre-production check.
This is how the offline rail is meant to run, and .env.production.example sets it. covclaimd needs the sealed claim packet because it does not hold the preimage; this server generates it, so it can push the covenant's claim leaf itself with OFFLINE_SELF_CLAIM=true and OFFLINE_EMULATOR_URL set. COVCLAIMD_URL may then be omitted entirely: the RFQ carries no claim_packet (it is optional on the wire and the solver funds anyway) and the solver waits for this claim.
Leaving it off makes a third party the single point of failure for every receive — and @arkade-os/swap's own documentation states covclaimd cannot claim this covenant today, so a covclaimd-only deployment claims nothing at all: the lockup goes unclaimed, the solver reclaims it at refund_locktime, and the payer is refunded while the receiver gets neither the money nor a local trace of why.
It is on by default wherever
OFFLINE_EMULATOR_URLis set, soCOVCLAIMD_URLis no longer what makes the rail legal. Whether the rail runs at all is a separate question from who claims on it: an emulator is also the covenant rail's co-signer, so serving lightning swaps additionally requires something that only means that — solver cards, a registry override,COVCLAIMD_URL,SOLVER_RFQ_HTTP_URL,NOSTR_SECRET_KEY, or an explicitOFFLINE_SELF_CLAIM=true. A covenant-only deployment therefore keeps the swap rail off.
Which leaf, and what pins the destination. It spends the covenant's nonInteractiveClaim leaf — the same one covclaimd uses. That leaf is preimage + Arkade operator + an emulator key tweaked by enforcePayTo(receiverPkScript)``, where receiverPkScript is the user's registered Arkade address. Two things follow, and they are different claims:
- This server cannot redirect the payout. The leaf requires signatures from the Arkade operator and the emulator, and this server holds neither key. That follows from the tapscript alone.
- No pusher can redirect it either — but that one rests on the emulator, which derives its per-covenant key from
enforcePayTo(...)and, by policy, signs only after checking the spend satisfies it. That check is what makes "anyone can push this leaf" safe by design.
No key, and nothing taken from the user. The server needs no key of its own, so there is no new secret to hold or rotate. The user keeps their claimPublicKey in the covenant's receiver role and with it the collaborative claim and unilateralClaim leaves, exactly as with the flag off. This is an additional pusher, not a transfer of control. The server still holds the preimage — it always did — so enabling this adds no custody it did not already have.
The collaborative
claimleaf is deliberately not used. It is a barepreimage + receiver + operatormultisig with no output constraint, so a server holding the receiver key and the preimage could send a funded lockup anywhere. Spending it would make this server custodial for every in-flight swap.
OFFLINE_EMULATOR_URLmust name the emulator whose key is baked into the covenant. WithCOVCLAIMD_URLset that key comes from itsemulator_pub_key, so the two must agree — point them at different emulators and every push is refused, the same pairing hazard asCOVCLAIMD_URLitself. WithoutCOVCLAIMD_URLthe key comes straight fromOFFLINE_EMULATOR_URL, which must then be the solver's emulator (the covenant has to match the solver's derivation). Startup checks the pairing when both are set and logs a warning naming both keys if they disagree; it never refuses to start, and it stays silent when either service is unreachable, so a boot-time blip is not mistaken for a misconfiguration. An emulator that has rotated its key still counts as a match, since covenants built under the retired key remain satisfiable.
When covclaimd is configured it keeps working alongside this: both push the same leaf to the same destination, so they race harmlessly and the loser's push simply fails. The claim is skipped when the lockup is funded below the quote's to_amount, and is safe to retry. Accepted swaps persist the pinned solver, relay set, RFQ id, amount, and versioned VHTLC reconstruction fields atomically with the settlement, so status polling and self-claim resume after restart.
What triggers it. Each quote registers its lockup as an SDK contract, so the contract manager pushes an event the moment the solver funds it and the claim goes out then — not on the next poll. That matters because the claim is what makes the solver settle the payer's hold invoice, so a claim held for an interval is a payer watching a spinner for it. OFFLINE_POLL_INTERVAL_MS remains the safety net under a subscription that dropped or never started, and one settlement pass runs at startup so a lockup funded while the process was down is claimed at boot. A lockup whose registration failed is claimed by that poller alone: it queries the indexer by script and needs no contract row.
The arkade rail hands the payer an address, and the server is not in the payment path — so with a single static address, two payments arriving at once are indistinguishable. Nothing on the wire says which is which, and the watcher is left correlating on amount and arrival window. Two payments of the same amount cannot be told apart at all.
With OFFLINE_COVENANT_DESTINATIONS=true each callback derives its own address instead, and the script becomes the identifier. A VTXO there belongs to exactly one record, so attribution is exact rather than inferred.
The address is a three-leaf Taproot script:
| leaf | who can spend | what it is for |
|---|---|---|
condition(H(P)) + operator + covenant cosigner |
anyone holding P |
the sweep, pinned by the covenant |
| user + operator | the user, collaboratively | recovery without waiting |
| user alone, after a CSV delay | the user | recovery if the operator is gone |
Only the first leaf carries H(P), so a fresh preimage moves the address while the covenant bytes stay fixed. The preimage is not a secret — the covenant means whoever holds it can only pay the user's registered address — which is why the two recovery leaves are keyed to the user and need neither P nor this server.
A sweeper moves each funded destination on to the static address, so the user's wallet sees an ordinary arrival there and needs to know nothing about any of this. It costs one extra Arkade transaction per payment, and the rail gains a dependency on the emulator co-signing that sweep. If derivation fails at request time the callback falls back to the static address: that payment is ambiguous again, which beats refusing to be paid.
OFFLINE_COVENANT_RECOVERY_DELAY_SECONDS (default 86528) sets the CSV delay on the third leaf. It must be a multiple of 512: BIP68 encodes seconds in 512s units and rejects anything else, so 86400 — 24h exactly — is not a legal value and is refused at startup.
Once an address has a registered Arkade identity (see above), its LUD-06 payRequest advertises multiple rails — and one that also registered a boarding address advertises onchain alongside them:
"paymentOptions": [
{ "id": "lightning", "type": "lightning" },
{ "id": "arkade", "type": "arkade" },
{ "id": "onchain", "type": "onchain" }
]A payer selects one with ?paymentOption=<id> on the callback:
lightning(or omitted) — the existing BOLT11 flow (live wallet, or an offline reverse swap). Returnspr+verify.arkade— returns an Arkade address to pay directly, no swap.onchain— returns the owner's Arkade boarding address, for an ordinary on-chain payment.
Both destination rails answer in the same shape:
{ "status": "OK", "paymentOption": "arkade", "paymentDestination": "tark1..." }verify is present only when the destination identifies the payment, which means only on the arkade rail with OFFLINE_COVENANT_DESTINATIONS enabled — that mints a fresh covenant address per payment:
{ "status": "OK", "paymentOption": "arkade", "paymentDestination": "tark1...", "verify": "https://pay.example.com/lnurl/verify/<id>" }Without it, one static address is reused for every payment and settlement is correlated by amount and arrival window, so two concurrent payments of the same size cannot be told apart — a payer polling verify could be told someone else's had arrived. The record is still written, the watcher still settles it, and it still appears in the owner's history; the payer is simply not handed a URL whose answer the server cannot stand behind. The onchain rail omits verify for a stronger reason: nothing here watches Bitcoin, so it could only ever answer "not settled".
Where it is present, the verify URL reports the non-pr LUD-21 shape: { status, settled, paymentOption, paymentDestination, paymentReference }. Unknown or unavailable options return { "status": "ERROR", "reason": "Unsupported paymentOption" }.
The payer pays the destination directly, so settlement is observed, not reported. For the static Arkade address, a background watcher polls the Arkade indexer when
ARK_SERVER_URLis set and flipssettled(withpaymentReference= the Arkade txid) once a payment covering the agreed amount arrives; correlation is by address + amount + arrival time, and that fuzziness is inherent to reference-less address payments. A covenant destination needs no correlation — the script belongs to exactly one record — and settles from a contract event instead. Addresses without an Arkade identity stay pure LUD-06 (nopaymentOptions).
Every backend is one entry in the rail registry (src/rails.ts): interactive-lightning (live wallet session), offline-swap (solver-mediated bolt11->arkade-btc while offline), arkade (direct destination), covenant (per-payment destinations for the arkade rail), and onchain (the owner's boarding address — an ordinary on-chain payment, not a swap, which is why nothing here observes it). New rails — assets, stablecoin swaps, an onchain-BTC-to-arkade-BTC offline receive in the same family as offline-swap — slot in there with no plumbing changes.
Server-wide capability comes from configuration; per-address policy comes from the operator. GET /admin/api/rails lists what this process wired, each address carries its own disabled set (stored in SQLite, edited in the admin UI Addresses tab under Rails, or via PATCH /admin/api/addresses/{id}/rails), and the payRequest advertises only the rails that survive both. A disabled or unavailable rail never fails silently: its callbacks answer { "status": "ERROR" } naming the rail, while the process keeps serving the rest.
Optional: denominate amounts in units other than millisatoshis (USD, USDT, …). This server has no rate oracle, so quoting is delegated to an injected QuoteProvider (src/quote-provider.ts). Without one, units is not advertised and any unit= request is rejected.
When a provider is configured, the LN-address payRequest advertises units:
"units": [{ "code": "USD", "name": "US Dollar", "symbol": "$", "decimals": 2 }]A payer denominates the callback with ?unit=<code> (optionally &receiveUnit=<code>). amount is then the smallest integer of that unit (e.g. amount=100&unit=USD = $1.00). The provider quotes it to millisats — which drives the bolt11 / offline swap — and the response echoes the quote:
{ "pr": "lnbc...", "routes": [], "paymentQuote": { "requested": { "amount": "100", "unit": "USD" }, "payment": { "amount": "162345000", "unit": "msat" } }, "verify": "..." }An unknown or unsupported unit returns { "status": "ERROR", "reason": "Unsupported unit" }.
Framework only — plug real rates, assets, and swap-backed quotes in behind
QuoteProvider. Quoting applies to the amount-denominated Lightning path (relay + offline swap); thearkadedestination rail rejectsunitfor now.
When DB_PATH is set an admin backend starts on ADMIN_PORT (default 3001), bound to ADMIN_BIND (default 127.0.0.1 for the CLI).
The admin API has no built-in authentication. Front it with Cloudflare Access, an nginx auth_basic block, or another authentication proxy. Do not publish port 3001 (-p 3001:3001) to the internet without a front proxy.
The Docker image binds to 0.0.0.0 so isolation happens at the container/proxy boundary.
| Method | Path | Purpose |
|---|---|---|
| GET | /admin/api/domains |
List domains |
| POST | /admin/api/domains |
Create domain |
| PATCH | /admin/api/domains/:id |
Update domain |
| DELETE | /admin/api/domains/:id |
Delete domain |
| GET | /admin/api/addresses |
List addresses (filter: domainId, status, q) |
| POST | /admin/api/addresses |
Reserve or mint an address |
| PATCH | /admin/api/addresses/:id |
Update address status (active/revoked) |
| DELETE | /admin/api/addresses/:id |
Delete address |
| GET | /admin/api/api-keys |
List API keys |
| POST | /admin/api/api-keys |
Create API key |
| DELETE | /admin/api/api-keys/:id |
Revoke API key |
| GET | /admin/api/blacklist |
List blacklist entries |
| POST | /admin/api/blacklist |
Add blacklist entry |
| DELETE | /admin/api/blacklist/:id |
Remove blacklist entry |
| GET | /admin/api/sessions |
List active session IDs |
| GET | /admin/api/settlements |
List settlement records (filter: settled, option, limit) — preimages/pr never exposed |
| GET/POST | /admin/api/solver-cards |
List or paste a manual solver card |
| PUT/PATCH/DELETE | /admin/api/solver-cards/:id |
Replace, enable/disable, or delete a card |
| GET | /admin/api/discovery |
Inspect active candidates, sources, cache use, and warnings |
| POST | /admin/api/discovery/refresh |
Refresh discovery immediately |
The admin port also serves a React SPA at / (the lnurl-admin UI).
- Reserve — creates a
reservedaddress with a one-timeclaimCode. Share the code with the wallet owner; the wallet uses it in aPOST /lnurl/addressrequest to claim and activate the address. - Mint — creates an
activeaddress pre-bound to asecret(the wallet token). The wallet can immediately use the address without claiming.
| Env Variable | Default | Description |
|---|---|---|
PORT |
3000 |
Public server port |
BASE_URL |
http://localhost:3000 |
Public URL for generating LNURLs |
MIN_SENDABLE |
1000 |
Minimum sendable amount in millisats |
ONCHAIN_MIN_SENDABLE_SATS |
10000 |
Economic floor for the onchain option, sats. arkd's dust (330) is what it will accept, not what is worth accepting: delivering an onchain payment costs the payer a Bitcoin transaction fee this server cannot see, so a dust-sized minimum invites payments that cost more to make than they deliver. Never lowers the rail below dust. |
MAX_SENDABLE |
100000000000 |
Maximum sendable amount in millisats |
INVOICE_TIMEOUT_MS |
30000 |
How long to wait (ms) for the wallet to provide a bolt11 |
VERIFY_TTL_MS |
86400000 |
How long (ms) a bolt11 record stays readable by verify. Past it the invoice is dead, so the record is hidden — and reclaimed only if no address owns it. A record belonging to a registered address is that owner's history and is kept for listPayments. |
DESTINATION_WATCH_MS |
604800000 |
How long (ms) a handed-out destination stays watched, verifiable and settleable. Separate from VERIFY_TTL_MS because an invoice expires and a destination does not: the callback advertises no expiry, so a payer may pay one long after it was issued. Raising it only widens the window in which such a payment is still observed. |
SOLVER_REGISTRY_URLS |
published network index | Optional comma-separated solver-registry index URL override. Leave unset to follow the network default; set it empty to disable registries. Successful bodies are cached for up to seven days. |
SOLVER_CARDS_FILE |
— | Startup JSON file containing an array of manually pinned cards. Cards can also be pasted into the admin UI and persisted in SQLite. |
SOLVER_RFQ_HTTP_URL |
— | Routes RFQs to a solver's HTTP ingress (POST /v1/swap) instead of over nostr. A solver running serve answers HTTP and never subscribes to a relay, so without this there is no way to quote against one — which is how a local stack is driven. Unset, solvers are reached over nostr using the relays on their own card, which is what a deployed one listens on. |
NOSTR_SECRET_KEY |
— | 32-byte hex Nostr identity for the RFQ transport; ephemeral per boot when unset. Key material — treat it like a private key; prefer the ephemeral default unless a stable identity is genuinely required. |
COVCLAIMD_URL |
— | covclaimd daemon base URL (non-interactive VHTLC claims). When set, must be the same instance the solver reveals to — see the warning under Offline receive. May be omitted with OFFLINE_SELF_CLAIM=true + OFFLINE_EMULATOR_URL (RFQ omits the claim packet). |
ARK_SERVER_URL |
— | Arkade operator URL (e.g. https://mutinynet.arkade.sh) — signer key, exit delay and network are read from it. |
OFFLINE_STAMP_CLAIM_PACKET |
false |
true sends the claim packet for the solver to stamp into the funding tx, naming our covclaimd in-band — which removes the pairing requirement above entirely. Only set it if the solver you quote carries arkade-os/intent-solver#47. An older solver forwards the packet to its own covclaimd as a ciphertext, cannot decrypt it, and the swap funds and refunds. |
OFFLINE_SELF_CLAIM |
on with OFFLINE_EMULATOR_URL |
Pushes each lockup's nonInteractiveClaim leaf here, alongside covclaimd when it is configured. On by default wherever an emulator is set, because covclaimd cannot claim this covenant today and a covclaimd-only deployment therefore claims nothing. Set false for a deliberate covclaimd-only rail. Needs no key — the leaf is signed by the operator and the emulator, and gated on the preimage this server already holds. Requires OFFLINE_EMULATOR_URL; COVCLAIMD_URL may be omitted. See Self-claim. |
OFFLINE_EMULATOR_URL |
— | Emulator base URL backing OFFLINE_SELF_CLAIM and OFFLINE_COVENANT_DESTINATIONS — it co-signs the covenant leaf after checking the spend pays the user. With COVCLAIMD_URL set, must be the emulator whose emulator_pub_key it reports; without it, must be the solver's emulator. Missing with either flag on, the server refuses to start. |
OFFLINE_COVENANT_DESTINATIONS |
false |
true gives each arkade-rail payment its own covenant address, so concurrent payments are told apart by script instead of by amount and arrival window. Requires OFFLINE_EMULATOR_URL and ARK_SERVER_URL (COVCLAIMD_URL optional — only needed alongside the lightning offline swap). See Per-payment destinations. |
OFFLINE_COVENANT_RECOVERY_DELAY_SECONDS |
86528 |
CSV delay before the user may sweep a covenant destination alone. |
OFFLINE_POLL_INTERVAL_MS |
15000 |
Offline settlement pass interval, and the covenant watcher's catch-up cadence. Neither is what normally settles a payment — a funding event does — so what is left on both is the backstop for a dropped subscription, plus the solver status check the RFQ transport cannot push. The covenant half matters because that subscription does drop in normal operation: an event lost to a reconnect window is only recovered by the next catch-up pass. Lower it to see settlement sooner, at the cost of solver requests. |
DB_PATH |
— | Path to SQLite database file. Omit for in-memory-only mode. |
TOKEN_ENCRYPTION_KEY |
— | 32-byte AES key (hex or base64). Required with DB_PATH unless the insecure fallback is explicitly allowed. |
ALLOW_INSECURE_TOKEN_STORAGE |
— | Set to 1 to use the source-known fallback key and accept that database token confidentiality is not provided. |
ADMIN_PORT |
3001 |
Admin backend port |
ADMIN_BIND |
127.0.0.1 |
Admin bind address (0.0.0.0 in Docker) |
BOOTSTRAP_DOMAIN |
— | Domain name to create on first startup if no domains exist |
REGISTRATION_RATE_LIMIT |
10 |
Max address registration requests per minute per IP |
TRUST_PROXY |
1 |
Express trust proxy value — number of hops or false |
TRACE_REQUESTS |
— | Set to 1 to log one structured line per request — method, path, Host, status, duration, request id, and the X-Forwarded-For / X-Forwarded-Proto / X-Real-Ip headers — on both the public and admin servers, plus the resolved startup config. Unmatched 404s are logged too, so no line at all means the request never reached the process. Off by default. |
MAX_SESSIONS |
5000 |
Global concurrent SSE session cap |
MAX_SESSIONS_PER_IP |
50 |
Concurrent SSE session cap per resolved client IP |
MAX_CONCURRENT_OFFLINE_QUOTES |
20 |
Global in-flight offline RFQ cap |
SHUTDOWN_TIMEOUT_MS |
15000 |
Grace period before lingering HTTP connections are forced closed |
pnpm install
pnpm dev # start with hot reload
pnpm build # build for production
pnpm type-check # typecheck without emittingFour suites, each answering a question the one below it cannot. A green unit run says nothing about a browser, and a green browser run against a mock says nothing about a solver — so the funded paths are proved against a real stack.
pnpm test # unit + integration, real HTTP servers, no mocks (Node 22+ for node:sqlite)
pnpm test:e2e # funded offline receive and restart recovery, against a local Arkade stack
pnpm test:browser:local # the wallet in Chromium, driving every rail against that same local stack
pnpm test:browser # the wallet in Chromium against the live mutinynet deploymentpnpm test:e2e and pnpm test:browser:local raise the Arkade regtest stack
themselves from the regtest submodule (git submodule update --init), pull the
solver image pinned in test/e2e/support/regtest.ts, and need Docker. The first
boot pulls a lot of images and takes a while; later runs reuse a healthy stack.
pnpm test:browser:local is the one that covers the feature matrix end to end —
every receive rail including a real Lightning payment from the stack's own node,
the send paths, the admin UI, and the wallet's own surface. Reaching the stack's
solver from a browser needs two things it does not provide, both supplied by the
preview server: it sends no CORS headers, so /solver is proxied same-origin, and
its card is in no published registry, so one is served from that card.
pnpm test:browser hits the live deployment and so costs real sats. The specs
tagged @funded and @provision are skipped in CI for that reason and need a
wallet provisioned and topped up by hand:
pnpm --filter @arkade-os/lnurl-demo-wallet exec playwright test --grep @provision
# fund the addresses it prints, then:
pnpm test:browser