You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Share token quote URI/memo validation and normalization, receive-rail limits and destination checks, receiver bootstrap orchestration, and an optional IndexedDB backend through the LNURL client. The demo uses the shared startup, bounds and token payment helpers.
Add reusable LNURL client payment repositories with injected record/cursor backends and a reset write gate, pending receiver confirmations, and Arkade send tracking with resume and reset cancellation. The demo wallet shares the package confirmation loop. Batched verification now honors the client's injected fetch transport.
HD Lightning-address receiving — register a dedicated public branch instead of a fixed Arkade identity. Every callback reserves a fresh payout address and claim key, including offline invoices, with durable counters and owner-only allocation history for recovery. HD receivers refuse static registration and fallback; existing static receivers keep their behavior. Migration 26 retains allocation snapshots across configuration changes. The client adds /hd branch-export and contract-recovery helpers; applications must watch and shield the resulting payouts.
@arkade-os/lnurl-client 0.7.0: pay in a unit — requestInvoice(payRequest, { unit: "USD", amount: 500 }) pays $5.00 to a receiver that advertises units: amount is in the unit's smallest denomination, the answer's paymentQuote must be for exactly that amount and unit, and the invoice must be for the quoted millisats. The payment router takes the same as unitAmount beside raw, and arkadeLnurl().options() accepts a UnitAmount in place of sats; the lnurl rails price it on arkade or lightning and record it as lnurlQuoteMeta(quote).quote. SentPayment, synced received payments and both activity resolvers carry quote, shown as requested ("5.00 USD"). parseUnitAmount/formatUnitAmount convert typed decimals. Breaking:RequestInvoiceOptions is now sats or unit, so { amountSat, unit } no longer compiles; it never worked, since the amount went out in millisats labelled as the unit and the quoted invoice was then refused.
Quoted payments keep their unit amount — a callback priced in a LUD-XX unit records { amount, unit, decimals } on its settlement (migration 25), returned as quote by the owner's payments sync and shown in the admin Settlements view.
Unit quotes on the arkade and onchain destination rails — unit was refused there; only token-deposit rails, which quote their own payment, still refuse it. The demo wallet's send screen offers a target's units beside sats.
Solvers listed by pointer, with their markets read over Nostr — a registry index's solvers, and any pasted or file card carrying a discovery_pubkey and relays, are resolved on every refresh from the signed card each solver publishes over Nostr (kind 38859, d = the network); a card that passes every check replaces that solver's listed markets. To allow a solver the registry does not list, paste its signed pointer — a card with no markets, from cli card --pointer on intent-solver — in the Solvers tab. Unreadable relays leave discovery as it was, with a warning.
Price feeds for units quoting (QUOTE_UNITS) — the LUD-XX paymentQuote seam finally has a provider: each listed unit is priced from its own JSON feed (QUOTE_<CODE>_FEED_URL, QUOTE_<CODE>_DECIMALS, and QUOTE_<CODE>_PRICE_PATH unless the URL is Binance or CoinGecko), the feed contract intent-solver's asset markets use, with exact decimal math and the msat amount rounded up. Feeds refresh every QUOTE_REFRESH_MS (60 s) in the background; a unit more than three intervals stale is neither advertised nor quoted, and /health reports it as the optional priceFeeds check. Session LNURLs now advertise and quote units too, and priceFeedQuotes is exported for createServer embedders. Unset, nothing changes. With DB_PATH, units are also editable at runtime from the admin UI's Quotes tab and /admin/api/quote-units (migration 24 adds quote_units): an admin unit replaces the env unit of the same code, a feed must answer before it is saved, and admin-entered feed URLs are fetched through an SSRF guard that refuses private, loopback and link-local hosts.
@arkade-os/lnurl-client 0.6.0 — small receive-wallet and payment-router interfaces replace concrete SDK classes, with optional router injection and isolated SDK RC compatibility checks. The optional SDK peer now requires >=0.5.0-rc.0; callers explicitly typing returned routers as the SDK's PaymentRouter should use LnurlPaymentRouter instead. The main entry point remains SDK-free.
Optional CAIP-19 identifiers for BTC payment options and network-checked callback aliases, preserving bare rail selectors and token deposit options.
Compiler-generated v3 covenant destinations with permissionless batch renewal, separate fee funding, and durable successor tracking. Existing v1/v2 funded addresses retain their original construction.
Token deposits through FixedFloat (mainnet only) — with FIXEDFLOAT_API_KEY and FIXEDFLOAT_API_SECRET set, an address with an Arkade identity advertises one paymentOptions entry per stablecoin network (ff-usdtarbitrum, ff-usdcsol, ff-usdttrc, … — USDT and USDC on Ethereum, Arbitrum, Base, Optimism, Polygon, Avalanche, Solana and Tron), each with type set to its CAIP-2 namespace, a CAIP-19 asset, a unit naming a units[] entry, and provider: "FixedFloat". The callback creates the corridor swap's hold invoice and a fixed-rate FixedFloat order paying it, and answers the deposit address, an EIP-681 or Solana Pay paymentURI (none on Tron), and a paymentQuote in the token's base units whose expiresAt is the payer's deadline: the earlier of FixedFloat's deadline and the corridor invoice's less FIXEDFLOAT_SETTLE_MARGIN_SECONDS (600 s), refused when that leaves under FIXEDFLOAT_MIN_PAY_WINDOW_SECONDS (300 s). There is no top-level expiresAt on these answers. Settlement is the corridor's alone; verify then reports the payer's deposit txid as paymentReference. The server never holds or refunds the payer's tokens: a late or wrong deposit is resolved with FixedFloat. Known risk: FixedFloat may keep an order open past our deadline, and a payer who deposits in that gap may find the invoice dead and is not paid out; FixedFloat then holds the tokens in its emergency/refund flow. Expired orders stay watched until the TTL, so such a deposit is logged and shown in the admin view. It refuses to start with FixedFloat keys on any network but bitcoin, without a file-backed DB_PATH, or without offline receive. Every FixedFloat call shares one budget kept 25 units under FixedFloat's 250 a minute. The callback answers 429 past it, past FIXEDFLOAT_ORDERS_PER_IP token callbacks from one IP (10 per 10 minutes), past FIXEDFLOAT_MAX_OPEN_ORDERS_PER_ADDRESS unfunded orders for one receiver (5), or past FIXEDFLOAT_MAX_OPEN_ORDERS in all (20). Each token callback also spends one of the 30 callbacks a minute per IP that the offline Lightning and Arkade destination callbacks share, a limit with no setting, so a burst of them can refuse the same IP's other callbacks for the rest of that minute. After an HTTP 429 from FixedFloat, or five errors in a row, all FixedFloat calls pause for two minutes and the options read available: false. Two unroutable-invoice answers (304) in a row close one receiver's token callbacks for ten minutes. Migration 18 adds the ff_orders table; the admin rails and settlements views show the rail and each order (never its token).
FIXEDFLOAT_SIMULATE=true — an in-process stand-in for test networks (refused on bitcoin and with real keys) that answers the same shapes under ffsim- ids, provider: "Simulated", on testnet chains, so wallet and checkout UIs can be exercised without real tokens. It takes no deposit.
@arkade-os/lnurl-client 0.5.0: token options — PaymentOption gains asset, unit and provider; a destination result now carries paymentURI (always sent, previously dropped), paymentQuote, paymentDestinationTag, expiresAt and provider. tokenOptions(payRequest) parses token options exactly as the BTCPay LNURLVerify plugin does, and requestInvoice on one checks the answer as the plugin does: the option asked for, an address on the token's chain, and a whole amount of the token.
The admin Settlements view links each destination to its explorer and copies it whole on click — an Arkade destination opens in the Arkade explorer and an on-chain one in mempool, for the network solver discovery reports (no link while discovery is off). Clicking the shortened value copies the full destination, or a Lightning row's payment hash.
verifiable on every advertised payment option — whether that option's callback answers carry a LUD-21 verify URL, proposed for LUD-XX (lnurl/luds#303). Until now a payer that must detect settlement, such as a merchant backend, learnt that only by requesting a destination, and lnurl-server's onchain option never has one: every checkout cost a refused request, a settlement row and a slot of the per-IP destination budget. Lightning is true; arkade is true only while per-payment covenant destinations serve it, since the static-address fallback identifies no payment; onchain is false. An address served without server caps states nothing, because absent means unknown. @arkade-os/lnurl-client 0.4.5 types it as PaymentOption.verifiable.
lnurlQuoteMeta(quote) and the LnurlQuoteMeta type (@arkade-os/lnurl-client/arkade) — what an lnurl rail records on its quote (target, via, verify, verifyBatch), readable without casting quote.meta.lnurl. Undefined for a quote no lnurl rail made.
SentPayment.verify / verifyBatch — the receiver-confirmation URLs a send's quote carried, so an app can resume a confirmation that was still pending when it closed.
CALLBACK_RATE_LIMIT_PER_MINUTE — the per-IP budget for Lightning-address callbacks served without a live wallet session (offline-swap quotes and destinations), hard-coded at 30 a minute until now. Defaults to 30.
TRUSTED_FORWARDERS: per-IP limits apply per payer behind a server that forwards its payers' IPs — a server that calls LNURL callbacks for its payers, such as BTCPay Server, reaches lnurl-server from one address, so all of its checkouts shared one per-IP budget and a busy merchant was throttled on every rail. TRUSTED_FORWARDERS lists IPs and CIDRs, IPv4 or IPv6, whose X-Forwarded-For is believed at any hop on top of the TRUST_PROXY hops, so each payer a listed forwarder relays is limited as itself. Anyone else's X-Forwarded-For is ignored as before; a malformed entry, or a /0, refuses to start. Empty by default.
Changed
Solver relays on private hosts are refused off regtest — the relays a card names are dialled to read the solver's Nostr card and for RFQs, so a registry entry or a pasted card could point them at an internal service. Off regtest, a relay on a private, loopback, link-local or CGNAT IP literal, or on localhost, is now skipped with a warning in the discovery status, and a solver left with no relay is not a candidate. A hostname that resolves to a private address still passes. Regtest keeps its local relays.
Upgrade @arkade-os/solver-discovery to 0.3.0, @arkade-os/swap to 0.1.0-rc.24 and @arkade-os/sdk to 0.5.0-rc.21 across the server and demo wallet; swap rc.24 pins that SDK exactly, so one copy is installed.
A stale registry index no longer takes the solvers it lists down with it — staleness is now @arkade-os/solver-discovery's call: an index over seven days old still has its own snapshot markets held out, with a warning, but the solvers it lists keep trading on fresh signed cards, and pinned cards are unaffected. Its body is cached like any other, so those solvers stay resolvable through a registry outage.
Upgrade @arkade-os/sdk to 0.5.0-rc.19 and @arkade-os/swap to 0.1.0-rc.22 across the server and demo wallet; raise the minimum Node.js version to 22.13.0 to match.
@arkade-os/lnurl-client 0.5.1: Receiver.payRequest() fetches once per 30 s — every call was its own GET, so a receive screen asking from several places, and again on each amount change, repeated one request. One fetch now answers a Receiver's calls for 30 s, the window the LNURL send rails already reuse a payRequest for: concurrent calls share it, a failed fetch is not kept, and any write through the same arkadeLnurl (claim, upgrade, failed or not) retires it at once. A change made elsewhere, such as an operator's rail toggle, shows within 30 s; resolve(receiver.lnurl) still asks the server on every call, and the demo wallet's Reload options now does exactly that.
@arkade-os/sdk 0.5.0-rc.17 and @arkade-os/swap 0.1.0-rc.20 — a one-way upgrade — on its first start, 0.5 rebuilds the SDK's ark_vtxos table in this service's database: it adds is_swept, is_preconfirmed, commitment_txids_json, expires_at and expires_at_height, fills them from the old virtual_status_json column, and drops that column. A 0.4 build can no longer read the database afterwards — every VTXO read and write fails, and with covenant destinations on it refuses to boot — so there is no rollback: recovery is fix-forward. ark_contracts is untouched. Checked against a database written by 0.4.77 the way this service writes it (covenant destinations in every watch state, including pre-versioning v1 rows; swap lockups; spent, unspent, swept and preconfirmed VTXOs), 0.5 passed 86 of 87 forward checks: every contract row is byte-identical after the upgrade, every covenant and lockup rebuilds its script and address from its stored params, and every VTXO reads back as 0.4.77 stored it. The one miss was the checker's, not the data's: a pre-versioning covenant row gains version: "1" when re-serialized, as it always has. The swap package moved its corridor API to @arkade-os/swap/protocol and stopped exporting the receive-window gate; the server now runs the same gate through the published verifyReceiveWindow from @arkade-os/swap/advanced, so a quote whose pay deadline has passed, or whose refund opens within 30 minutes of it, is still refused before its invoice is handed out. The @noble/* and @scure/btc-signer pins, the client's included, move to the versions the SDK pins, so each is installed once.
paymentQuote.fees entries are { name, amount: { amount, unit } } — the shape @arkade-os/lnurl-client always read. The server's QuoteProvider type and its OpenAPI described { amount, unit, description }, which nothing produced.
Covenant destinations stop being watched once this service is finished with them — a per-payment destination is registered with the SDK as awaiting-funds, which the contract manager demotes once a VTXO lands at it. A destination nobody ever paid has no such moment, so it stayed subscribed for the life of the process and the watch set grew with every invoice ever handed out rather than with the payments still in play. Measured at 10,000 dead destinations: 10,000 scripts in the subscription arkd is asked for and an 8.9s restart, against 5 scripts and 1.2s after. A retirement loop now moves a destination to the SDK's retained watch state once its sweep is recorded, or, if it was never paid, as soon as it leaves DESTINATION_WATCH_MS. The two halves answer to different clocks on purpose: a settled destination stays watched however long its sweep takes, because money that arrived must always reach the user, while an unpaid one is watched only as long as a payment there could still settle — sweeping past that point would move funds verify goes on reporting as unpaid. Nothing is deleted: the row still answers reads, still annotates its VTXOs, and still holds the preimage its sweep leaf needs. A payment arriving at a retired destination stays recoverable by the user through the covenant's other two leaves, and was already unattributable once past DESTINATION_WATCH_MS. The sweeper and the watcher's catch-up scope their passes to the same set, so neither costs an indexer round trip per dead destination. pnpm tsx scripts/probe-watchset.ts reports the numbers.
Offline-receive lockups stop being watched once their swap is done — registerLockupContract writes no watch state, which the SDK reads as watched and never demotes, so every offline Lightning invoice request — every checkout against a receiver who is away — left a lockup script subscribed for the life of the process, unpaid, settled and claimed alike. The retirement loop now covers them too: a lockup is watched while its swap is unsettled and inside the window the settlement poller works, which is the same horizon listPending already used, so retirement never outlives an attempt to claim. Past it the poller has stopped trying and the solver reclaims its own lockup through the covenant's refund path, so there is nothing left for this service to watch. Measured across a restart with 155 payments on each rail: 310 scripts in the subscription before, 10 after, with all 310 contract rows kept — a claim rebuilds its covenant from the stored params. The loop also follows the contract manager rather than the covenant rail, so a deployment running offline receive without per-payment destinations gets it as well. Migration 19 adds the partial index both the offline poller's pending-swap read and the new lockup scope need: migration 4's keys the same rows by swap_id, so it holds every never-paid swap forever and a created_at range over it depended on the planner's statistics — 103µs to 60µs at 50,000 rows without ANALYZE, 314µs to 66µs with it, and no longer falling back to an index spanning every settlement row. It is a new version rather than an edit to 17, which has already shipped and run.
Three settlements reads no longer scan the table — migration 17 adds a partial index for each: the covenant scope every worker pass reads, as two indexed halves rather than one OR that fell back to scanning every never-paid destination (15.8ms to 0.09ms at 50,000 rows), the payment-reference check a static-rail pass makes once per candidate VTXO (5.6ms to 0.02ms), and the pending-destination read the watched-script resync makes every second (19.2ms to 0.09ms). Each index holds only the rows its query can return, so the cost tracks the payments still open rather than the whole watch window. The migration runner now applies any version it has no record of, rather than everything above the highest one applied: with two branches numbering in parallel, whichever merged second would otherwise have been skipped for good on every database that had already reached the other's version. Merge order no longer matters.
Four more reads stop walking rows they only throw away — node:sqlite is synchronous, so each one held up every request in the process for as long as it ran. The offline poller's pending-swap read lacked the swap_id IS NOT NULL that migration 19's index needs, so on every pass it walked every settlement of the last 24 hours, on every rail (504ms to 0.9ms at 50,000 settlements, 7.1s to 19ms at 500,000). The covenant watcher and sweeper named the contract type beside the active scripts, and with three or more scripts SQLite answered that from the type index, reading every covenant destination ever issued (44ms to 0.15ms at 50,000 contracts, 731ms to 0.27ms at 500,000); the scope now names the scripts alone, the table's primary key, and the type is checked in code. The arkade watcher's every-second read returned every unpaid destination of the seven-day window, covenant and onchain ones included, only for the watcher to drop them; it now returns the static-address ones it keeps, from migration 20's partial index (510ms to 0.6ms at 50,000 unpaid covenant destinations, 8.3s to 0.5ms at 500,000). The admin bulk reconcile built a row for every address to keep 200, and the filter and the cap are now part of the query (856ms to 25ms at 50,000 addresses, 12.6s to 129ms at 500,000).
Migration 21 drops idx_settlements_pending_destinations, which nothing reads — migration 17 added it for the arkade watcher's pending-destination read, which migration 20 moved to an index holding only the static-address rows that watcher keeps. It still cost a write on every unpaid destination, when created and again when settled. None of the 178 distinct statements the test suite prepares plans through it, and the only two in the code whose filter it could serve plan through other indexes.
The admin address list answers a page at a time — an admin-API shape change — GET /admin/api/addresses read, mapped and serialized every address in one synchronous pass, so opening the Addresses tab or the Dashboard stalled every request in the process: 3.6s at 50,000 addresses and 56s, for a 513MB body, at 500,000 (server-side medians on a heavily loaded machine). It now answers { addresses, nextCursor }: at most limit addresses (default 200, at most 1000), newest first by created_at then id, so addresses created in the same millisecond page without a gap or a repeat. Pass nextCursor back as cursor for the next page; it is absent on the last page, and a malformed cursor is a 400, and a well-formed one past the data answers an empty page. A script that read the bare array must read addresses and follow nextCursor. The domainId, status and q filters still apply before the limit. A page takes 18ms at 50,000 addresses and 15ms at 500,000, 19ms halfway down; a username search with no match drops from 287ms to 79ms at 500,000, and the bulk reconcile's read from 1.8s to 1.1ms of SQL. Migration 22 adds the index the pages walk, addresses(created_at, id, status, username): the trailing columns let a status filter or a username search reject a row from the index itself, where an index on created_at alone made both slower than the scan it replaced. A domainId filter still sorts its whole domain, 1.1s for a domain holding 450,000 addresses. The Dashboard counts through the new GET /admin/api/addresses/count (117ms at 500,000) and sums online addresses from the live sessions, and the Addresses tab pages with Newer and Older.
Removed
Removed the unused @noble/ciphers dependency.
Fixed
Run client package publishing with an OIDC-capable npm CLI and include repository metadata for provenance.
Unit quotes round up to a whole sat — a price-feed quote landed on an exact millisat, which the offline-swap corridor refuses, so a unit payment to an offline receiver failed at most prices.
The settlement poller stops asking the solver about a swap the solver has closed — an offline invoice nobody paid stayed pending for VERIFY_TTL_MS, and every 15 s pass asked the solver for its status over Nostr: about 5,600 requests per unpaid invoice. Once the solver reports expired, refused or refunded, the poller logs offline_swap_closed once with that state, closes the swap's solver transport and stops asking. It keeps trying the self-claim until the refund deadline, so a solver that closes a swap by mistake cannot strand a paid lockup. stuck does not count as closed, since its operator may still settle it.
Offline-swap log lines name their swap — the settlement poller's events and offline_lockup_register_failed logged swapId, which the logger always redacts because it is 64-character hex, so no line could be traced back to its swap. They now log swapRef, the first 12 characters of the id, as the lockup watcher and the self-claim timing line already do.
offline_swap_refund_deadline_passed is logged once per swap, not every settlement pass — a self-claim swap past its refund deadline stayed pending until VERIFY_TTL_MS, and each OFFLINE_POLL_INTERVAL_MS pass re-ran the claim and logged the error again: about 240 lines an hour per swap. The poller now stops claiming such a swap after the first report; it still asks the solver, so a claim made before the deadline can still settle. A restart reports each one once more.
An offline invoice nobody paid no longer logs offline_swap_refund_deadline_passed — at the refund deadline the self-claim now looks the lockup up once, spent VTXOs included. The error stays for a lockup that was funded: a paid receive the server can no longer claim, which the solver reclaims. An invoice nobody paid logs offline_swap_expired_unpaid at info instead. The self-claimer also drops the swap's registration at that point, so unpaid swaps no longer stay in memory until a restart.
One unreachable Nostr relay no longer crashes the server on Node 22 — nostr-tools 2.25.x closes a socket inside its error handler, and Node 22's built-in WebSocket re-fires error synchronously when a still-connecting socket is closed, so a refused or unresolvable relay on the offline-receive RFQ path overflowed the stack and exited the process. It is fixed at the source by the @arkade-os/swap release carrying arkade-os/ts-sdk#1073, whose relay pools close a socket only once, so the interim nostr-tools 2.24.1 pin is lifted.
Covenant destinations use arkd's /v1/info unilateral exit delay when OFFLINE_COVENANT_RECOVERY_DELAY_SECONDS is unset, preventing the fixed one-day default from blocking mainnet startup. Explicit overrides retain their validation.
Activity details derive and display the covenant's Arkade address from its script and the wallet's server key and prefix.
An oversized body is refused without waiting on its stream's cancel — the solver-registry read and @arkade-os/lnurl-client 0.5.3's session stream both awaited reader.cancel() before raising the size-limit error, so a body whose cancel never settled hung the refusal (and, on the registry path, the whole discovery refresh). The cancel is now started and its rejection consumed, and the error is raised at once.
@arkade-os/lnurl-client 0.5.2: a verify answer can be held to the payment it was issued for — the preimage check proved only that a settled answer opened its own pr, so a verify endpoint could answer for an invoice it had already settled, preimage and all, or with a destination status, and pollVerify resolved settled. pollVerify(url, { expect }) now fails with LnurlError on an answer about any other payment: a different payment hash, a destination status for an invoice, or another rail or destination. expect takes the callback's answer as it came; lnurl rails record it on their quotes as lnurlQuoteMeta(quote).expect, and verifyStatusIsFor(status, expect) is the same check for batched and streamed answers. The demo wallet passes it on every confirmation path. Without expect nothing changes.
@arkade-os/lnurl-client 0.5.2: a session's SSE frames stop at 1 MiB — the parser kept everything after the last blank line, so a stream that never finished a frame grew the client's buffer for as long as it ran. An unterminated frame past 1 MiB now fails the stream with LnurlTransportError and cancels the body.
Solver registry responses are read up to 1 MiB — a larger body is cancelled mid-read and treated like an unreachable registry, so the cached index serves while it is fresh, instead of being buffered whole first.
Same-second static-address arrivals settle the same records whatever order the indexer lists them in — ties are taken smallest first, then by outpoint. An older small request no longer takes the larger payment a newer request needed just because the indexer listed it first.
A stale solver registry no longer hides a card the operator pins locally — discovery ignores a registry whose index is over seven days old, but it dropped that registry's markets only after discover() had collapsed identical markets into one copy, and it keeps the registry's. Pinning the very card a stale registry lists, through SOLVER_CARDS_FILE or /admin/api/solver-cards, therefore served nothing: on mutinynet, whose published index is 20 days old, offline receive stayed down with /readyz reporting "no usable lightning-receive solver cards" while the admin API called the pasted card active. Stale registries are now left out before the copies are collapsed, so the local card serves the market, as does a fresh registry listing the same one.
A payment that settles or is paid out behind a newer one now reaches its owner's wallet — GET /lnurl/address/:handle/payments paged on created_at, so once a newer payment existed, a wallet's cursor had moved past the older row for good. Its settlement, or the sweep txid a covenant payment records later, never reached the wallet, and the receive stayed a plain "Received" in its history. Settlements now carry updated_at, stamped on every write, and the route lists rows least recently changed first with nextSince on that column, so a row that changes is listed again. Migration 16 adds the column and backfills it from settled_at, else created_at. A cursor a client already holds still works: no row's updated_at is below its created_at.
@arkade-os/lnurl-client pays a nameless receiver on every rail it advertises — the server serves a nameless receiver's session LNURL as its address, paymentOptions and units included, but the client keyed its rail guards off the URL's shape: requestInvoice refused a paymentOption or unit on any session payRequest, and the lnurl rails offered only Lightning there. A wallet paying a nameless receiver never reached its Arkade leg, and the receiver's own wallet could not ask for an Arkade or on-chain destination. The guards now key off what the payRequest advertises, so a live session, which advertises nothing, is still refused. @arkade-os/lnurl-client 0.4.6.
The demo wallet's Send takes the amount a pasted payment request asks for — pasting a BIP321 URI with amount= (for example a merchant's unified checkout QR) left the amount field at its previous value. The router sends an explicit amount over the request's own, so paying without noticing underpaid the request. The field is now filled from the URI's amount= when one is present, else from its invoice's amount, bare or inside the URI, and is left alone for a target naming neither.
A unified BIP21 keeps its Lightning leg when its invoice costs more than amount= — a swap-backed receiver's QR prices the invoice above amount= by its swap fee (6002 sats against 5936, say), and solverLightningRail drops itself for any requested amount but the invoice's. With the Send amount now filled from amount=, pasting such a QR offered only ark and onchain. arkadePaymentRouter now hands the lightning rail the request without an amount that only restates amount=, letting the invoice price its own leg; an amount the URI does not name still drops it. Ships in @arkade-os/lnurl-client 0.4.7.
Covenant destinations refuse to start with a recovery delay shorter than arkd's unilateral exit delay — OFFLINE_COVENANT_RECOVERY_DELAY_SECONDS was checked only for being a positive multiple of 512. Below arkd's unilateralExitDelay, arkd refuses every off-chain spend of the covenant address ("exit delay is too short"), the server's own sweep included, so the arkade rail would stall silently while payers kept funding addresses the server could not sweep. Boot now compares the setting with the value arkd advertises and refuses to start below it. The default (86,528 s) clears mutinynet's 2,048 s.
An offline receive settles on covclaimd's claim, not on the catch-up poll — the lockup watcher ran a settlement pass only when the solver funded a lockup, and on funding alone the solver still reports the swap unsettled, so settlement waited for the 15s catch-up. The watcher now also runs a pass when a pending swap's lockup is spent, then re-asks at 1s and 3s, since the solver settles a moment after the claim. A payer's LUD-21 verify now sees settled within seconds of the claim instead of on the next catch-up.
Admin routes refuse a path id that is not a positive integer, and a status change for a missing address is a 404 — Number(req.params.id) turned abc into NaN, which the driver matched against nothing, and PATCH /admin/api/addresses/:id answered { ok: true } for an id that did not exist, confirming a change that never happened. Every admin :id route now answers 400 for a malformed id, and the status change checks the address exists, as the rail-policy route beside it already did.
A covenant sweep's payout is recorded from the spend, not from the emulator's reply — sweepOne returned whatever transaction id the emulator answered with, and the pass wrote it to payoutReference as soon as one VTXO had been submitted. That field is terminal: both stores drop the script from listActiveCovenantScripts, so the sweeper, the covenant watcher's catch-up and retirement all stop looking at the destination. A sweep arkd never accepted was therefore recorded as paid out and never retried, and a sub-amount VTXO arriving first was swept, its still-unsettled record marked paid out and its contract retired — leaving the real payment that followed never observed, settled or swept, against the invariant the watcher's catch-up documents. The sweep now requires the signed transaction to be the one it built, since a txid commits to no witness data and the emulator's signatures cannot change it, and the payout is recorded only for a settled record whose settling output is observed spent — one catch-up pass (15s) behind the sweep. Every unspent output in scope is still swept, a sub-amount one included: moving a partial payment to the user's own registered address is correct, while waiting for every output at a destination to be spent would let one unsweepable sub-dust coin, which anyone can send, hold it in scope for good.
A wallet's invoice rejection must be short text — POST /lnurl/session/:id/invoice relayed error to the payer's wallet whatever its type or length. It is now refused with 400 unless it is a string of at most 500 characters.
A callback amount must be written as a plain integer — both LNURL-pay callbacks read ?amount= with Number(), which also takes 1e6, 0x3e8, 1000 and 1000.0. They now accept only an optionally signed run of digits, so a non-positive amount still gets the range message it always did. The client's local check had folded "not an integer" into the range message as "Amount must be an integer between…", which no longer matched the server's wording; the two are separate again.
A failing background pass no longer takes the process down — the covenant sweeper, the offline settlement poller and the Arkade watcher ran each pass as void pass().finally(schedule), which schedules the next pass but lets the rejection escape; nothing awaited it, so Node exited on the unhandledRejection. A terminal error from the contract manager in the sweep was enough, and a persistent one meant a restart loop that dropped every SSE session each time. All four loops now share one startCatchUpLoop that reports a failed pass and carries on, as the covenant watcher already did.
An unexpected error no longer answers with its stack trace — no route had an error handler, so anything a handler did not catch reached Express's default, which renders err.stack as HTML whenever NODE_ENV is not production, and neither the Dockerfile nor the compose file sets it. Routes now throw typed errors (LnurlError for LUD-06 bodies, BadRequest/NotFound/… for the REST API) answered by one handler per app; anything else is logged with its request id and answered 500 {"error":"Internal server error"}. Malformed and oversized JSON bodies are now answered as JSON too.
Odd-length tokens no longer share a session with their even-length prefix — isValidToken accepted any hex of 32+ characters, and Buffer.from(hex) drops a trailing odd nibble, so T and T + "0" derived the same sessionId. Tokens must now be whole bytes, POST /lnurl/session uses the same check instead of its own regex, and deriveSessionId refuses an invalid token rather than truncating it.
A claim whose identity bind failed can be retried — claim() registers the address and then binds the wallet's Arkade identity, so a failure in the second call left a registered row with no identity, and the same wallet re-claiming its own name got 409 taken: locked out of a name only it owned, and stuck advertising no paymentOptions. POST /lnurl/address with a username now returns the existing row with 200 (the status a repeated nameless call already used) when the requesting session already owns it, active and named; the bind can then be retried on its own. Ownership is the session id every other owner route checks, so a different session — including one replaying a consumed claim code — still gets 409 and can neither take over nor rebind the row. A revoked row stays taken, keeping DELETE final, and random allocation is unchanged: a retry there mints a new name.
An lnurl rail refuses at quote time a destination its inner rail cannot pay — arkRail.quote() does not re-check its target, so a callback destination it could not parse produced a quote that failed only at send, too late to fall back to the lightning option. The rail now asks the inner rail's match() first and throws, so a caller trying rails in order moves on.
The client loads under require() — its exports map offered only an import condition, so anything loading it through CommonJS (Playwright specs, for one) failed with No "exports" main defined. Both entries now also export default, which Node's require(esm) loads; the package is still ESM-only, as its @noble dependencies are.
Plain Lightning addresses now route — a LUD-06/16 payRequest with no paymentOptions at all (a non-Arkade server) made both lnurl rails report unavailable, so the SDK router found no route. The lightning rail now reads such a payRequest's top-level minSendable/maxSendable directly and sends no paymentOption.
The client refuses an invoice that doesn't match the requested amount — requestInvoice never checked the bolt11 it got back; it now decodes the HRP amount and throws when it's missing, unparseable, or disagrees with the requested amount, including an amountless invoice answering an amounted request.
The offline-receive invoice check and the client now ignore a payment-hash field whose length isn't 52, as payers do — both decoders took the first p field whatever its declared data_length and always read 52 words out of it, while BOLT11 fixes p at 52 and has a reader fail the payment on any other length. An invoice of [p(length 53, first 52 words = the expected hash), p(length 52, the hash a payer reads)] therefore passed a check against a hash nobody pays: the offline-receive gate (verifyReceiveInvoice) would arm a swap on a solver invoice payable to the solver's own hash, and the client's verify would accept a settlement proven with a preimage for a hash the payer never paid. A p counts only at length 52 now; the first such one still wins and the rest are skipped.
Added
lnurlActivityResolver, sentActivityResolver and mergeSentPayment move into the client — the demo wallet's activity resolvers were storage-agnostic already (getter args), so they now ship from @arkade-os/lnurl-client/arkade for any Arkade wallet to reuse; the demo imports them instead of keeping its own copy. The client's @arkade-os/sdk peer range widens to >=0.4.0 || >=0.5.0-rc.0.
verifyBatch: one request, or one stream, for every pending invoice — implements the proposed LUD in lnurl/luds#299. LUD-21 gives one verify URL per invoice, so a payer tracking N pending invoices made N requests per poll and waited out the poll interval for every settlement. The callback and verify responses now advertise verifyBatch; GET /lnurl/verifyBatch?verify=…&verify=… answers the whole set, and with Accept: text/event-stream it streams the snapshot and then each settlement as it happens, with ?session=…&add=…&remove=… changing the tracked set without reconnecting. An over-cap request answers 414, the status the spec tells clients to split on, and the client's batchVerify does split. The client's openVerifyBatchStream never sent its verify set, so against this server every stream was a 400; it does now, retries without the stream Accept on a 406, and the demo wallet's Receive tab reopens a closed stream with backoff instead of reporting "not settled".
Nameless receivers — a stable LNURL with no lightning address — a session that never picks a name previously answered "no longer active" the moment the wallet closed, because offline receive, rails, covenant destinations and payment sync all hang off an addresses row keyed by username. POST /lnurl/address { token, nameless: true } (new session allocation mode) now creates that row anyway, keyed by the session id itself, and idempotently on repeat. /lnurl/<sessionId> and its /callback serve it with full parity — offline swap, rails, covenant — through the same path .well-known uses, but with no text/identifier in its metadata and refused at .well-known/lnurlp/<sid> (a hex handle is never a lightning address). Migration 15 adds addresses.session_lnurl plus a partial unique index on (domain_id, session_id).
In-place upgrade from nameless to named — PATCH /lnurl/address/:handle (owner-authed, { username? }, or { username, claimCode } for a reserved name) gives a nameless row a name without disturbing its Arkade identity, rail policy, or settlement history; the session LNURL keeps paying the same row afterward, and the owner routes (DELETE, /arkade, /payments, PATCH) keep answering to the old session id for that row's owner, so a second device still holding the nameless handle does not 404. Upgrading onto a reserved name keeps any rails the operator disabled on the reservation, merged with the nameless row's own. This links the previously-anonymous LNURL to the new name publicly. A wallet that would rather keep them unlinked can call claim({ username }) instead, at the cost of a separate address counted against the domain's per-wallet limit.
GET /lnurl/domain — public capability discovery ({ domain, allocationModes, usernameRules, requireApiKey }) so a wallet can build onboarding from what a domain actually allows instead of guessing.
Client: upgradeAddress, domainCapabilities, and storedPayments({ domain, handle }) — the low-level counterparts to the two new routes, plus a lookup key that works for a nameless row (whose lightningAddress is null).
The demo wallet exercises every client feature, and shows the call behind each action — onboarding and "Add a name" offer a chosen name, a random one, just an LNURL, or an operator-reserved name with a claim code, driven by what GET /lnurl/domain says the instance allows. Receive keeps every pending invoice on one verifyBatch stream, adding and removing them with update(). Each action has a collapsed snippet of the exact client call it makes, and the demo README opens with a map from feature to UI to call.
arkadeLnurl(...) — the whole receiver in one object, for anyone holding an SDK wallet — the client package was a faithful protocol surface that left every consumer to wire the same five things: derive the token with ECDSA pinned, register the address, bind the identity, drive the sync loop, register the LNURL rails. The demo wallet's copy of that wiring ran to 250 lines, and called deriveToken twice in one function because nothing memoised it — two extra signatures per boot, and free to get wrong. arkadeLnurl({ wallet, baseUrl, store }) takes wallet.identity for signing and exposes token, owned, claim, lightningAddress, lnurl, payRequest, payments, sync, router and options. claim does both halves that matter — the name and the identity bind, without which the address advertises no paymentOptions at all — and carries the boarding address so the onchain rail exists from the first payRequest. The domain defaults to baseUrl's hostname because a LUD-16 domain cannot carry a port, and payRequest addresses its own LNURL through baseUrl for the same reason, which is what lets a server on a port resolve at all.
The facade takes an identity where there is no wallet — consuming it found the gap: the demo wallet's own tests hold a MnemonicIdentity and an address, never a Wallet, so a wallet-only signature was unusable in exactly the place it most needed proving. arkadeLnurl now takes either { wallet } or { identity, arkadeAddress, boardingAddress? }, discriminated so the compiler picks one. Receiving needs only a signer; spending the wallet's own coins needs the wallet, so router() and options() say that rather than failing somewhere obscure. The demo wallet's lnurl.ts goes from 159 lines to 21, and with payment-store.ts and router.ts that is 214 lines of glue deleted for a package the consumer already had.
arkadePaymentRouter(...) and browserPaymentStore() — paying is not receiving, so the router is available without an address of your own: it returns the SDK router with the LNURL rails registered, which is what makes route() accept a Lightning address rather than throw no rail for. browserPaymentStore() is a localStoragePaymentSyncStore — enough for a demo and wrong past it, which the README now says in place of claiming the package ships no storage at all. The demo wallet drops its own copies of both: payment-store.ts is gone, and router.ts goes from 33 lines to 12.
Changed
The demo wallet's Send shows the price before it pays — each route's button read Pay <typed amount> sats and sent in one click, whatever the rail would charge: a Lightning leg pays its invoice plus the solver's fee, an onchain leg adds the exit fee. Each route now offers Quote, which shows the quote's amount and fee, then Pay for the quote's total. Editing the target or the amount clears the listed routes, which otherwise stayed payable for the request they were found for.
Errors are typed, and their HTTP status is decided in one place — the domain throws classes from src/errors.ts (InvalidSessionTokenError, ProvisioningError, InvoiceRequestError, UpstreamError, MalformedRecordError, ConfigError, …), each with a kind; httpErrorHandler maps every kind to a status in an exhaustive switch, so a new kind does not compile until it is mapped. Routes no longer catch and rethrow. Two visible effects: the admin API now answers a provisioning failure with the same status as the public one (invalid_username 400, forbidden_mode 403, limit_reached 429, where POST /admin/api/addresses answered 409 for all of them), and a payer is told why an invoice request failed only when the wallet said so — any other failure reads "Failed to get invoice" and is logged, instead of its message reaching the payer. A configuration error at boot prints as one line rather than a stack. A server-side domain error keeps its status behind the generic body, so a failed upstream answers 502 rather than 500.
The arkadeLnurl facade is handle-based, not username-based — claim() takes { username } (self), { username, claimCode } (reserved), { nameless: true } (session), or nothing (random), and owned() returns a Receiver (preferring a flagged session row, among the facade's own domain only) rather than a plain address record; claim() and upgrade() send the configured domain, like the rest of the facade. Receiver carries upgrade(), so a nameless claim can be named later without a second facade call. GET /lnurl/address and its payments route gain handle/nameless/sessionLnurl (source.handle on payments); lightningAddress and username are null on a nameless row. RegisterArkadeIdentityRequest.username is renamed handle, since a nameless row's handle is its session id, not a username.
The client's LNURLs are uppercase — encodeLnurl, and so Receiver.lnurl, returned lowercase bech32 while the server's lnurl fields were already uppercase. Both now emit uppercase, the form QR codes encode in their denser alphanumeric mode. Decoding still accepts either case; anything matching the string case-sensitively must now expect LNURL1….
Security
32-hex usernames are reserved wherever a name is chosen — self, random and reserve modes now refuse ^[0-9a-f]{32}$. Without it, a name freed by an upgrade (or picked coincidentally) could collide with a session id still serving as someone else's /lnurl/<sid>, letting a new lightning address silently shadow an existing receiver. Existing rows that already hold a hex name are left alone.
The client no longer takes a verify response's word for settlement — a settled bolt11 status is accepted only when SHA256(preimage) equals the payment hash decoded from its pr; a mismatch, or a pr that cannot be decoded, is rejected (pollVerify) or becomes that entry's error (batchVerify). The LUD requires it, and nothing checked it before, including pollVerify.
Fixed
Upgrading onto a reserved name is one transaction — merging the reservation's rail policy, deleting the reservation and renaming the receiver ran as separate statements, so a failure between them could lose the reservation or leave a merged policy on a row that was never renamed. A failure now rolls all three back.
The send-rails browser test never made a real payment — it created its invoice on lnd, the solver's own node, which refunded it as a self-payment, and it passed on any outcome including "payment failed". The invoice now lives on lnd-peer, and the test requires the payee's invoice to reach SETTLED, so a refused quote fails it with the reason. The demo wallet's unit tests are now type-checked too, which surfaced three type errors that are fixed.
A reverted migration burned version 13, so production never got payout_reference — a different migration shipped under that number, was deployed, and was then reverted in the source only. Every database that had already run it records 13, and the forward-only runner skips m.version <= current — so the payout_reference step that later claimed 13 could never apply there. DbSettlementStore.markObserved then queried a column that did not exist, and because the covenant watcher called settleFrombeforeonFunded, the throw escaped the event handler and took the sweep trigger with it. The visible cost was not a missing column: every covenant payment fell back to the sweeper's catch-up, measured at 8s, 10.05s and 12.0s against 463ms on a local stack, and verify reported settled: false indefinitely. No suite could catch it, because a fresh database applies the new 13 and is correct — only a database that ran the old one is broken. Migration 14 adds the column where it is missing, guarded on PRAGMA table_info since SQLite has no ADD COLUMN IF NOT EXISTS and a fresh database already has it, and backfills the static rail as 13 would have. The watcher now treats recording a settlement as bookkeeping that cannot cost the recipient their money: a failure is logged and the sweep proceeds. The two tests that pinned the schema version against a literal now read it from the migration list.
Fixed
A sent payment says who it paid, instead of only "sent" — the feed described every outgoing payment with the word sent, an amount and a txid. lnurlActivityResolver joins on payoutReference from the records synced for this wallet's own Lightning address, but the server that recorded a send was the recipient's, so its record is theirs; the resolver matched nothing and the row fell back to intent?.label ?? "sent". None of the data was missing: the rail already returns meta.lnurl.target — the address the user typed — alongside railId, amount and fee, and RouteResult carries the txid, swap id and preimage. pay() formatted them into a status line and dropped them. They are now written down at send time and joined back onto the wallet's own activity row through the SDK's own seam, prepare() loading the notes and resolve() staying pure. Joined on the txid only, never on amount and timing as an incoming quote may be: on a receive a wrong guess mislabels money already held, while on a send it would assert a recipient who was never paid — and for the same reason the receiver's verdict is absent until verify answers rather than reading "has not confirmed". The row's label is the counterparty rather than the rail, because ActivityRow gives it a fixed column that clipped lnurl-arkade → user@host to lnurl-ark…; the rail, the fee, the swap and the preimage moved into the expander. send-paths.spec.ts asserted only the recipient's feed, which is why nothing caught this, and now checks the payer's row too.
Fixed
The shipped container had contract events switched off, so every watcher polled — the image launched node --experimental-sqlite dist/cli.js, and Node has no global EventSource without --experimental-eventsource. resolveEventSource() therefore threw on every subscription, and the covenant watcher, the watchScript destination watcher and the lockup watcher all fell back to the SDK's 20s failsafe poll: still correct, roughly ten times slower, and completely silent — the startup log read watched + 15s catch-up either way. Measured on mutinynet from a payer/recipient HAR pair: the sender's Arkade transaction finalised at t+18.95s and the swept output was not created until t+29.0s, a 10.05s sweep against 463ms on a local stack, with a second capture at ~8s — both samples inside the 0–20s window a 20s poll produces. No suite could have caught this, because every harness hard-codes its own argv and the container's was the one launch nothing ever ran. The flag is now on NODE_OPTIONS in the image rather than only in the CMD — a host that starts the container with its own command, as a deploy panel does, never sees the CMD, and pnpm start carried the identical gap. A missing EventSource is reported at startup instead of left to be inferred from latency, and test/dockerfile-launch.test.ts pins every launch path against the flags the e2e harness uses. Verified on the built image: overriding the entrypoint still yields typeof EventSource === "function".
Changed
A pushed arrival costs the demo wallet one indexer round trip instead of two — refresh() ran refreshVtxos() and then getBalance(), but getBalance() syncs against the indexer itself: vtxoSyncMaxAgeMs defaults to zero, so the freshness guard never holds and every call re-reads. On a push that sync is redundant twice over, because the SDK's handleContractEvent already syncs the funded contract and persists it before emitting the event the wallet is listening to. refreshVtxos stays on the pull paths — mount, the thirty-second net, the Refresh button — because it alone advances the global sync cursor, without which the indexer query window grows without bound. Measured from a recipient's HAR against mutinynet: three serialised indexer reads followed the arrival event, 553 + 592 + 550ms, and the middle one is now gone.
Security
A covenant destination now enforces where its assets go, not just its sats — enforcePayTo constrained the output script and the sat value and said nothing about assets, so an operator could satisfy the value clause and send any asset carried by that payment somewhere else. The covenant secured the money it was written for and quietly trusted the service with everything else. The sweep leaf now carries OP_TUNNEL with the asset flag, which requires every asset on the spent input to leave on the same output the script already pins to the user. The opcode costs nothing on a BTC-only sweep — with no asset packet it compares two empty maps — and the byte is spliced directly because the SDK's opcode table stops at SIGHASH (0xf6).
REQUIRES an emulator with OP_TUNNEL: v0.0.8-rc.0 or later. v0.0.7 maps 0xf7 to opcodeInvalid and refuses the sweep, which would strand EVERY covenant destination rather than only the ones carrying assets. The bundled regtest submodule moves to that image in the same change. Measured, not assumed: an identical asset payment was refused under v0.0.7 and swept under v0.0.8-rc.0.
Fixed
An asset sent to a covenant destination is no longer stranded — arkd rejects a spend of asset-carrying inputs that declares no asset packet, and the sweep declared none, so any destination that ever received an asset could not be swept at all. The money was not destroyed, as the docs claim it would be; it sat at an address only this service can move it from until the user's own timelocked leaf opened about a day later, while the sweeper logged a failure every pass with no indication that assets were the cause. The sweep now emits an asset packet preserving the input's assets to the recipient. Verified end to end on a live stack: an asset sent to a covenant destination arrives at the recipient's address with its id and amount intact.
Changed
The covenant construction is versioned — the script is derived from stored params and never stored, so changing the builder would silently rebuild every existing row into a different address. Attribution would survive, because that compares stored hex, but the sweep would address a taptree nobody funded: arkd refuses it, the sweeper retries forever, and the money sits at the old address with nothing surfacing an error. Params now carry a version, absent meaning v1, and both derivation sites branch on it — including the emulator packet in the sweeper, which independently rebuilds the covenant bytes and would otherwise hand a v1 destination v2 bytes that do not reproduce its cosigner. A compatibility fixture pins a literal stored v1 row to the address it must keep producing.
Fixed
The demo wallet's balance updates on arrival, and says so when it stops — it called getBalance() every five seconds and swallowed every failure, so one transient error froze the number with nothing to distinguish it from an idle wallet, and a manual refresh looked like the fix because it was the only thing that cleared it. It now subscribes with notifyIncomingFunds, and a failure says so in the header. The interval survives at thirty seconds as the net behind a dropped subscription.
Changed
One activity row per payment, and a wallet row says what a holder can act on — the wallet's own transaction and the server's record of the same payment were listed side by side, the same money twice with two different states. The wallet half is the row now and the server's half is detail on it: an explorer link, and an expander carrying the txid, the rail, the address it was paid to, the amount quoted, the swap id, the preimage, the covenant script, and the rail's own cut as the gap between quoted and received. A wallet row shows no settled/pending at all — the transaction being in the history is the fact, and a status beside it only invited the question of which of the two rows to believe. Only a quote keeps a status, because that is all a quote has. Matching is by payoutReference; until the server records one, a quote whose amount and timing a wallet row already accounts for is folded in and labelled as inferred, since an unmatched quote beside its own payment reads as a double charge. That inference is display-only — the exact-amount rule is tight enough that only a swap's own cut widens it, so a large quote can never swallow a small arrival.
Changed
A static-address payment is observed in under a second, not up to fifteen — these land at an address this server does not own, so there is no contract to subscribe to as the covenant rail has; the SDK's watchScript reports activity at any script over the subscription the contract manager already holds. Opening a second subscription instead does not work: arkd delivers to the first and the loser reports an EventSource error for the life of the process. The watch only ever triggers a pass — correlation stays in settleDestinationPayments, unchanged, and is still the only thing that decides which record a payment belongs to. Degrades to the catch-up where no contract manager exists. A destination is registered as the callback hands it out rather than found on the next resync — the payer does not wait for a tick, and registering re-announces what is already at the script, so the announcement of a payment made in between is the event that settles it. Measured wallet-to-wallet at production intervals: 14827ms to observed, down to 296ms — the same floor as a bare Arkade transfer (287ms). The resync remains as the net, covering a restart and unregistering scripts whose records have closed.
Changed
A covenant payment is swept the moment it lands, not up to fifteen seconds later — the sweep is what the recipient can actually spend, and startCovenantSweeper ran on a blind interval while the covenant watcher already knew, from a contract event, the instant a destination was funded. The two are now wired together: the sweeper takes a trigger, the watcher calls it from the handler it already had, and what remains reschedules itself after each pass rather than ticking. Measured on the local stack at production intervals, wallet-to-wallet: 14971ms to spendable, down to 306ms — parity with a bare Arkade transfer (307ms), which is the floor.
Added
A rail latency benchmark — pnpm test:bench:local, a third suite beside the correctness ones. It runs with production intervals on purpose: the browser harness sets OFFLINE_POLL_INTERVAL_MS=2000, which flatters every polled rail by 7x and hides exactly what a benchmark is for. Reports quote, observe and spendable latency per rail against a bare Arkade transfer as the floor, so a scheduling artefact is told apart from protocol cost.
Changed
The offline rail settles from its own claim, and its interval became a catch-up — the claim is what makes the solver settle the hold invoice, so once it lands the solver can only confirm what we already caused. The pass now marks the swap settled directly and skips the status call, taking a round trip to a remote party off every happy-path receive; the solver is still asked for swaps the claim could not resolve. The fixed 15s setInterval is gone: src/lockup-watcher.ts was already the mechanism — a contract-event subscription that triggers a pass the moment a lockup is funded — and what remains reschedules itself after each pass finishes rather than ticking on a wall-clock grid, so a slow solver spaces passes out instead of queueing them. Settled now means the emulator accepted our claim submission rather than that the solver confirmed it, which is the earlier and more truthful of the two for a recipient's own service.
Security
payoutReference is owner-only, never on verify — verify is public under LUD-21, and on the covenant rail that txid pays the user's static address, so exposing it would link a per-payment destination back to the address that rail exists to keep separate. It is served only from the authenticated owner payment sync and the admin API. paymentReference is unaffected: it names the covenant output the payer already paid to.
Added
Settlement records carry the txid that credited the recipient, on every rail — payoutReference, beside the existing paymentReference. The two differ wherever the payment did not land at the recipient directly: a covenant destination is observed at the covenant script and only its later sweep pays the user, and an offline swap settles against a preimage with no txid at all until the claim is pushed. paymentReference is what the service saw; payoutReference is what the owner's wallet holds, which is the only one their own transaction history can be matched against. Filled by the watcher on the static rail (where the observed payment is the credit, derived from the record so no caller can pass the wrong answer), by the sweeper on a covenant destination, and by the offline poller from the claim. First-writer-wins, so a repeated sweep pass cannot rewrite it. Exposed on verify, on the owner payment sync, and in the admin settlements view; backfilled for already-settled static-rail records, without which every past payment would have stayed unmatchable.
The demo wallet labels its own activity rows with the LNURL record, through the SDK's resolver — registered on wallet.activity rather than merged into a view afterwards. Both halves held a record of one payment and rendering both listed it twice, with contradictory states: the wallet's pending beside the server's settled. The resolver joins on payoutReference, so the row carries the rail and the address it was paid to. What still appears on its own is what has no transaction to enhance — quotes nobody paid, and payments not yet credited.
Fixed
Erasing a wallet clears the SDK's stored chain data, not just the keys — reset removed two localStorage entries and left the SDK's IndexedDB untouched, so the next identity opened on top of the previous one's records: its VTXOs rendered as a balance the new keys could not spend, and its contracts produced MissingSigningDescriptorError on the first send, reporting that the contract belonged to a different identity. The store now belongs to one identity at a time — openWallet clears it whenever the key opening it is not the key that last wrote it, which also covers a browser that dropped localStorage while keeping IndexedDB — and erasing clears it outright. The SDK applies an IndexedDB default to its wallet and contract repositories but not to the intent one, which is why "no storage passed" did not mean "nothing stored".
Added
A settling indicator in the demo wallet header — which does not fire yet, and an honest account of why — the header renders a separate "sats settling" line whenever intentLocked is positive. On this wallet it never is: the SDK reads intent locks from config.storage.intentRepository, and unlike walletRepository and contractRepository that field gets no IndexedDB default, so a wallet passing no storage reports intentLocked: 0 forever. The indicator is kept as the shape the eventual fix takes once a repository is wired. The reported zero balance during an offboard is therefore not explained by intent locks and remains open — available here cannot be reduced by them at all, so the cause is something else, most likely inputs going terminal before the change VTXO exists. A funded mutinynet offboard of 12000 sats confirmed both halves: no indicator appeared, and no zero appeared either.
The matrix spec's onchain leg requires a settled balance, not just a lower one — it asserted only that the payer's balance dropped by the amount sent, which a transient zero satisfies; it would have passed just as happily if the money had genuinely vanished. It now also requires the balance to settle above a plausible floor.
Changed
The server claims for itself by default — OFFLINE_SELF_CLAIM is on wherever OFFLINE_EMULATOR_URL is set. This server generates the swap preimage, so it can push the covenant's claim leaf itself, holding no key and unable to redirect the payout; deferring to covclaimd made a third party the single point of failure for every receive, and since it cannot claim this covenant today a covclaimd-only deployment claimed nothing at all. COVCLAIMD_URL is no longer what makes the rail legal. Flipping the default required separating two questions that enabled asked as one: a claimer must exist, but an emulator also serves the covenant rail, so whether to serve lightning swaps now needs configuration that means nothing else — cards, a registry override, COVCLAIMD_URL, SOLVER_RFQ_HTTP_URL, NOSTR_SECRET_KEY, or an explicit opt-in. false remains an opt-out.
The onchain option carries an economic floor — dust is what arkd will accept, not what is worth accepting: delivering an onchain receive costs the payer a Bitcoin transaction fee, so a 330-sat minimum invited payments costing several times more to make than they deliver. ONCHAIN_MIN_SENDABLE_SATS defaults to 10000 and can never take the rail below dust. It is policy rather than arithmetic on purpose — arkd states intent fees as formula strings with no evaluator exported, they are operator-configured (zero on mutinynet, non-zero on the local regtest), and the dominant cost is the payer's own fee, which this server has no onchain provider to estimate.
@arkade-os/sdk 0.4.74 and @arkade-os/swap 0.0.20 — the SDK release carries watchScript/unwatchScript taking string | string[], so a watcher registering many scripts costs one subscription update rather than one per script. The pnpm.overrides pin moves with the dependency: it exists to keep every transitive consumer on a single SDK instance, and a dependency bump alone would have been silently overridden back.
The Arkade watcher reads every destination in one batch — a pass did one indexer round trip per open destination record, sequentially, so at a ~117ms round trip it filled its own 15s interval at roughly 128 open payments and settlement latency then grew without bound. Records join that set when the callback hands out a destination and stay for DESTINATION_WATCH_MS (seven days), so abandoned quotes accumulate into it. Reads are now grouped by script and chunked at 32, matching the SDK's own limit: forty destinations cost two requests instead of forty. The correlation is unchanged — oldest record first, each VTXO assigned once, under-payments flipping nothing — because a shared address leaves amount and arrival window as the only evidence there is.
Changed
The demo wallet shows one balance, one receive screen, and one way out of a wallet — settled versus preconfirmed is an Arkade implementation detail and both are spendable, so splitting them invited a holder to read half their money as not really theirs. The rails moved into the address card, because they are what the address is rather than a panel beside it. Restore stopped being a routine setting: it ended one wallet and started another while sitting among endpoint overrides, so it now lives behind the same confirmation as erasing, which either starts fresh or adopts a pasted phrase — validated before anything is wiped, so a typo cannot cost the caller the wallet they had. The funding copy singled out the onchain rail; every rail credits the address.
The wallet says why its history is empty — the Activity feed already read the SDK's getActivityHistory, but swallowed failures and returned an empty list, so a broken call and a wallet with no transactions rendered identically — indistinguishable from never having consulted the SDK at all.
Added
A rail that is offered but currently down is advertised as such — LUD-XX defines available on a payment option, and omitting a rail instead makes a different and wrong statement: it tells the payer this address does not do lightning at all, so they go elsewhere rather than retry. Rail state now separates applicable (this address has what the rail needs) from available (and the server can serve it now), because the old single flag conflated "cannot serve" with "never set up". Offered-but-down is available: false; never-set-up and disabled-outright stay omitted. Emitted only when false, since the spec says an absent value means true.
Destination quotes say when they stop being watched, and hand back a payable URI — a BOLT11 carries its own expiry and an address carries none, but this server has one anyway: past DESTINATION_WATCH_MS it stops attributing payments to the quote. expiresAt states it. Deliberately not a deadline on the money — past it a payment still reaches the destination and the covenant sweeper still moves it, because that reads the contract manager rather than the settlement store; what lapses is the record, so verify stops answering and the payment leaves no trace in the address's history. The onchain rail gets none, since nothing here watches Bitcoin. paymentURI — the name lnurl/luds#303 gives it — is the instruction a payer would otherwise rebuild from an address and an amount this server already holds, built through the SDK's own helper so the Arkade destination lands in ark= rather than the address slot the scheme reserves for an onchain one.
The reconcile check has an operator surface — the Addresses tab expands a per-address panel and the Settlements tab carries the batch sweep, beside the records it is asking about. An unattributed arrival states plainly that the money reached the user and only the record lapsed, because the obvious reading is that funds went missing and an operator acting on that starts a recovery they do not need. On demand only: it reads the indexer for every address.
A post-mortem reconcile for payments the service stopped tracking — GET /admin/api/reconcile (batched, ?ids= or every address holding an Arkade identity) and GET /admin/api/addresses/:id/reconcile ask the indexer what actually arrived at an address and cross-references the observed references settlements carry, flagging each arrival as attributed or not. It answers the support case nothing else could: a payment made after the watch window is never recorded, so verify says "not found" and the history is blank, while the money itself is fine. Read-only — re-attributing a lapsed payment at an address shared by every payment to it would be guesswork. Indexer reads are chunked, so a sweep across a hundred addresses costs a handful of round trips, and a per-address failure is reported in place rather than failing the batch.
An EntropyProvider seam for the per-payment preimage — the covenant destination's sweep-leaf secret and the offline swap's HTLC secret were both randomBytes inline, leaving a deployment that wants them from elsewhere nowhere to say so. Both now take an optional provider defaulting to the same randomBytes(32) call, so behaviour is unchanged unless one is supplied. The value is length-checked at the seam: a covenant commits to HASH160(preimage) and a VHTLC to its hash, so a short one is not a type error but an address nobody can spend.
Changed
The demo wallet receives through its Lightning address and nothing else — it published an Arkade address, a boarding address and a unified BIP321 QR built from both, making itself a second and staler source of truth for routing information the address already owns. Receive is now the address, its QR, and a list of what that address accepts; options are loaded on demand and requested one at a time, because a callback mints a destination and files a settlement record, so rendering the list by calling every rail would leave a trail of quotes nobody asked for. Funding stops being a special case — request the onchain rail and pay what it hands back.
Activity shows the wallet's own transactions alongside the server's records — it listed only what had been quoted against the LN address, so a payment this wallet sent, a deposit it boarded, or a transfer it received had no record anywhere in it. The SDK's getActivityHistory() and the LNURL rows are now merged: the wallet knows what its keys moved, the server knows what was quoted, and neither subsumes the other.
Changed
The covenant rail stopped paying for the same answer N times — a catch-up pass looked each contract's settlement record up by scanning the entire pending-destination set, so it cost contracts × open payments; fifty funded contracts measured fifty full scans where the attribution key has been uniquely indexed since migration 7. It is now a single indexed lookup per contract, and the predicates deciding what a watcher may settle are shared with the list path rather than restated. Separately, the provider cached the resolved operator/emulator keys instead of the promise producing them, so every derivation arriving after the TTL expired started its own pair of fetches — ten concurrent derivations made ten round trips where one would do.
Every dependency fetch is bounded — none of the arkd, covclaimd or emulator key fetches had a timeout. A dependency that accepts the connection and never answers is the bad case rather than one that refuses: the request parks indefinitely, and because these sit in the quote path they hold the MAX_CONCURRENT_OFFLINE_QUOTES slots with them until offline receive is wedged. The boot probes take a longer ceiling, where the consequence is refusing to start rather than serving a degraded rail.
Fixed
The Arkade watcher reported onchain destinations it was never meant to read — the pending set is every non-lightning rail, and this watcher owns one of them, so each pass tried to decode boarding addresses as Arkade ones and logged the failure; a bcrt1p… never becomes decodable, so the noise ran for the whole seven-day destination window and the consecutive-message dedupe missed it because several open records rotate past each other. The cost was not the log lines: a pass reporting any failure also skips the clean-pass reset, so chatter from a rail this watcher does not own could bury a real indexer outage indefinitely. Records now carry the rail they were quoted on, and a genuinely malformed Arkade address is still reported.
The published schema hid the per-rail bounds the payRequest emits — a paymentOptions entry was documented as { id, type }, so the two fields saying what a rail can actually carry were invisible to anything generated from the spec, which then falls back to the top-level pair (the lightning rail's). A client cannot honour an Arkade minimum it was never told about. The regression test cross-checks a real payRequest against the published schema, so a field added to an option has to be documented to pass.
The payRequest advertised amounts arkd would refuse — the arkade and onchain options published the admin/domain envelope unchanged, offering a 0.4-sat minimum against an arkd dust floor of 330 sats, so a payer following the advertised range sends an amount that cannot land; on the onchain rail the money is spent before anyone finds out. Each rail is bounded by its own backend, not by what the operator configured, and the machinery for saying so was only ever wired to one of them: withSolverRange folds a discovered solver's quoted market into offline-swap, which is why lightning alone showed a realistic range. The rails whose payment lands as a VTXO or boarding UTXO now carry arkd's dust as a floor, applied after the operator's and the solver's bounds because a protocol limit is not one they may narrow past. Those rails also settle in whole sats, so a millisat-precision bound is unrepresentable rather than merely optimistic — minimums round up and maximums down even where dust is unknown. The lightning rails are untouched: they carry millisats natively and a sub-sat minimum is legal there.
The funded-restart release gate failed in setup rather than in a test — fundSolverFloat retried only "No inputs found", so a DigestMismatchError (whose own message asks for the request to be rebuilt under refreshed server info) or an input still held by a prior attempt's intent aborted the run and skipped every test behind it. Those races are now classified by a named predicate rather than a string match inside the loop, and the helper judges itself on what it is for: retries exhausted but spendable value present means the float is funded.
Self-claim refused every multi-output lockup, and the money was lost — the claim skipped any lockup that was not exactly one spendable VTXO, reporting it unfunded forever. A solver that funds piecemeal is a documented state of the corridor, and nothing else recovers from it: every non-claim leaf of this covenant belongs to the solver, so an unclaimed lockup is reclaimed at refund_locktime and the payer refunded, leaving the receiver with nothing. The claim now spends every live output in one transaction — one payout per input, in input order, because the covenant checks output[i] against input[i] — with the emulator packet carrying an entry per input and the extension output after them all. The value gate becomes a sum over those outputs rather than a single outpoint, which is safe precisely because the same transaction spends all of them; a short lockup is still refused as underfunded rather than revealing the preimage for less than the quote.
Nothing stopped a claim being pushed after the solver's refund deadline — the request-time gate already refused a quote whose claim window was too short, but no later check existed, so the poller retried a claim indefinitely, including inside the window the solver may reclaim in. Publishing the preimage there risks losing the race and giving the secret away for nothing. The claim now refuses once the deadline has passed, reported as its own expired outcome and logged at error level, because that one means the payment is gone rather than merely late. The deadline needs no new field or migration: it is already inside the persisted covenant parameters, so swaps quoted before this change are gated too. A block-height-typed locktime is not wall clock and cannot be compared to one, so the gate arms only above the BIP-65 threshold and otherwise leaves behaviour exactly as it was.
A settled payment could stay pending in a wallet forever — syncPayments persisted the newest row's createdAt as its cursor, but settled mutates after a row is created, so once any newer payment existed an older pending row was never re-read; a real capture showed 72 consecutive polls at one unchanged since while the UI held a row pending below a newer settled one. The client now persists the oldest row still waiting to settle, reaching one page back at most, and never anchors on an onchain destination — nothing server-side watches Bitcoin, so an unsettled row there is terminal, not late. PaymentSyncStore is unchanged, but a store that clamps writeWatermark to move forward only must stop: the cursor now moves backwards by design.
The wallet showed pending on a rail whose settlement nothing observes — an onchain record can never leave settled: false, so rendering it as pending promised an update that was never coming. Those now read not tracked.
A covenant destination could stay unsettled forever after a dropped subscription — settlement runs off the contract manager's vtxo_received event plus one catch-up pass at subscribe time, and that pass re-armed itself only from its catch branch, so a pass that completed was never scheduled again. The subscription does drop in normal operation (ContractWatcher subscription disconnected; reconnecting, observed in a process sixty seconds old), and an event lost to that window was lost permanently. Meanwhile the sweeper — interval-driven, and reading the contract manager rather than the settlement store — kept moving the money, which is the reported symptom exactly: the sweep logs, the recipient's balance rises, and verify still answers "not settled". The pass now re-arms whichever way it ends, sized by OFFLINE_POLL_INTERVAL_MS, whose documented job is already the backstop for a dropped subscription.
The demo wallet's Lightning send rail spoke a framing no solver listens on — it built relayTransport, the dev broker's {op:"sub"|"event"} protocol, so a browser could never reach a solver over the swap corridor on mutinynet or a local stack. It failed as an empty market rather than a transport error, which is indistinguishable from "no solver serves this". connect now mirrors src/cli.ts: httpTransport when a solver HTTP URL is configured, otherwise the production nostrRfqTransport, imported dynamically so the HTTP path never loads the optional nostr-tools peer. The rail no longer needs the wallet identity — relays and solver key come off the rendezvous, and the transport key is deliberately fresh per negotiation.
Every solver-lightning send was cancelled while being recorded — the wallet's persist round tripped the swap through JSON.stringify, which refuses the BigInt amounts a swap record carries (TypeError: Do not know how to serialize a BigInt). It runs before funding and a rejection cancels the send, so this was fatal rather than cosmetic; it was simply unreachable until the transport above worked.
The OpenAPI schema omitted boardingAddress — the arkade-identity registration has accepted it since the onchain rail landed, and the server validates it, but the published spec never listed it, so a client generated from it could not register one and the onchain rail was unreachable rather than merely undocumented. onchain is now named in the callback's paymentOption too, which had described arkade as if it were the only destination rail.
The admin per-address rail view contradicted the payRequest on the onchain rail — both admin address views built their RailAddress without the address's boarding address, so effectiveRails judged onchain on a field it was never given. An address advertising ["lightning","arkade","onchain"] was reported here as having no boarding address at all, on the list and on the per-address rails PATCH alike.
Added
Local browser coverage for the whole feature matrix (pnpm test:browser:local) — the suite proved one path, an offline-swap receive; it now drives every rail the service offers against the real src/cli.ts on a real regtest stack. Receive covers interactive lightning (a live session's own BOLT11, really paid by the stack's counterparty LND), the arkade rail's static and per-payment covenant destinations, and onchain as a proved negative — faucet-funded for real, mined, still unsettled past two watcher ticks, because nothing here watches Bitcoin. Send covers wallet to wallet over lnurl-arkade through to the receiver confirming it, a bare tark1, and a BOLT11 over the solver's corridor. Also boarding auto-settle, the admin UI (rails reaching the payRequest, addresses and settlements linking both ways, blacklist, domains, settings, API keys) and the wallet's own surface (backup and erase, restore by phrase, endpoint overrides, the receive QR toggle, activity sync). Reaching the stack's solver from a browser needed two things it does not provide: it sends no CORS headers, so the preview server proxies /solver same-origin, and its card is in no published registry, so the preview server serves one built from that card.
Added
An onchain receive rail paying the owner's Arkade boarding address — reserved in the registry since the rails work landed, now real. POST /lnurl/address/{username}/arkade takes an optional boardingAddress (migration 12 adds the column), and an address that has one advertises onchain alongside arkade. The callback answers it in the same DestinationResult shape as the arkade rail, with paymentDestination set to the boarding address — and deliberately no verify. The reason is stronger than "it is static": arkade-watcher polls the Arkade indexer, not Bitcoin, so nothing here ever observes an onchain payment and a verify URL could only ever answer "not settled". It is independent of the Arkade identity, since a boarding address is the owner's own onchain key, and it needs no server configuration — the admin rails view reports it configured and ready, with each address deciding for itself.
Added
Wallet backup, settings, a unified receive QR, verify polling and self-settling boarding — the demo wallet can now reveal, restore and safely erase its mnemonic; override its endpoints while pinning IS_MAINNET, because a derivation that followed the URL would hand the same phrase different keys; toggle the receive QR between the LUD-16 address and a BIP321 URI carrying on-chain, Arkade and Lightning together (built with the SDK's BIP21); poll LUD-21 verify after sending, which the rail was already carrying and nothing read; sync payment activity through syncPayments into a local store instead of re-polling a page; and onboard confirmed boarding funds by itself.
boardingAddress on the client's Arkade-identity registration — registerArkadeIdentity and arkadeIdentityRequest accept one and send it only when defined, so omission leaves an existing rail alone. Deliberately unvalidated: a boarding address is an ordinary Bitcoin address on the operator's network, which the client cannot know.
Added
Browser end-to-end tests against a local regtest stack (pnpm test:browser:local) — the browser suite previously only ran against the live mutinynet deployment, which costs real sats, cannot be reset, and cannot exercise a path mutinynet does not offer. A second Playwright config brings up the repo's Arkade regtest stack, runs the real src/cli.ts against it with a file-backed database, and drives the built wallet at it. The proving test claims a username in a real browser and then has the stack's counterparty LND genuinely pay the hold invoice, asserting the preimage verify reveals is the one the payer's node settled with — the first receive that needs nobody in the loop. The demo wallet's network, emulator key and solver registry join its existing endpoint overrides; IS_MAINNET deliberately stays unreachable from any stored value, since every offered network derives BIP44 coin type 1.
SOLVER_RFQ_HTTP_URL — routes RFQs to a solver's HTTP ingress. The regtest solver runs serve, which answers POST /v1/swap and never subscribes to a relay, while cli.ts hardcoded the nostr transport — so the shipped entrypoint could not quote against a local stack at all.
Added
A browser end-to-end suite (packages/demo-wallet/e2e/, pnpm test:browser) — Playwright drives the previewed production bundle in Chromium against the live mutinynet endpoints: onboarding through to the receive page, the LUD-16 address resolving at the pinned domain rather than the page's own host, the QR and boarding address, the send box routing through PaymentRouter, and the activity tab. It previews a real build rather than the dev server, because a build is what Pages serves. Each test revokes what it claimed, since these register on a shared instance; re-deriving the session token in Node to do so also proves it matches the one the browser derived. browser-e2e.yml runs it on pull requests and on pushes touching the wallet or the client.
The demo wallet is published to GitHub Pages on every merge — .github/workflows/pages.yml builds the client package first (the wallet imports it by its package entry, which points at dist/, and nothing else in CI builds it beforehand), then the wallet with DEMO_WALLET_BASE set to the repo path, since a Pages project site is served from /<repo>/ and a root-relative bundle 404s there. Deployments are serialised and never cancelled mid-flight, which would leave the site on a half-published build. The receive page now takes its LUD-16 domain from the pinned config rather than location.hostname — on Pages that is the GitHub host, so it would have shown every user an address at a domain that serves no LNURL.
A feature sweep for the wallet's LNURL surface (packages/demo-wallet/test/features.test.ts) — drives the address lifecycle (claim, list, revoke, duplicate refusal, undecodable Arkade address), the LUD-06 payRequest envelope and comment allowance, paymentOptions appearing only once an identity is bound, the arkade option answering with a destination rather than an invoice, rail availability and delegation through lnurlRails, and the payment-activity envelope including its refusal for a token that owns nothing. It complements test/client-contract.test.ts rather than repeating it: that pins the client/server wire, this pins the layer above it.
LNURL payment rails for the SDK's PaymentRouter — @arkade-os/lnurl-client/arkade now exports lnurlRails, filling the SDK's standing TODO(lnurl): it exports isLnurl with no rail consuming it, so route() throws on a Lightning address today. A rail here is a decorator rather than a payment method — it turns an LNURL target into a concrete one and delegates to the rail that already pays it, so lnurl-arkade hands an Arkade destination to arkRail and lnurl-lightning hands a BOLT11 to a supplied lightning rail, and the fee, expiry and execution stay the inner rail's. The split falls where the interface does: match() is synchronous and amount-blind, so it is format-only, while available() may be async and is where the payRequest is resolved and the option's own minSendable/maxSendable gate — the per-rail bounds this release added. Both rails share one memoised resolve, since options() calls available() on each. They live here rather than upstream because a rail needs an LNURL client, the same reason the solver rails ship from @arkade-os/swap and are registered by the app; the core entry point is untouched and still references no SDK.
A demo Arkade wallet — packages/demo-wallet, private, is a small React/Vite browser wallet that drives a receive page through this server: it generates an Arkade identity, claims a username and binds the identity in one action, shows the LUD-16 address, LNURL and QR alongside a live balance, lists payment activity, and pays another address over the arkade rail. It consumes @arkade-os/lnurl-client by workspace reference, so a client regression breaks it rather than being noticed after publish. Onboarding claims the name and binds the identity together on purpose: until the bind lands the payRequest advertises no paymentOptions, so an address stuck between the two silently cannot receive offline, and packages/demo-wallet/test/onboard.test.ts pins both directions of that against a real createServer. It deliberately opens no SSE session — an open session makes the interactive-lightning rail available and the server would ask a wallet with no Lightning node for a BOLT11 invoice.
Pluggable receive rails with per-address policy — every backend (interactive lightning, solver-mediated offline swap bolt11->arkade-btc, direct arkade destination, per-payment covenant destinations) is one entry in the rail registry (src/rails.ts), so future transports (including onchain BTC -> arkade BTC offline receive) slot in with no plumbing changes. GET /admin/api/rails lists what the process wired, each LN address carries its own disabled set (PATCH /admin/api/addresses/{id}/rails, or the Rails controls in the admin UI), and the payRequest advertises only the surviving rails.
A shared LNURL client package — @arkade-os/lnurl-client, published from packages/client, is a full-duplex TypeScript client for this protocol: payer (resolve, requestInvoice, pollVerify) and receiver (POST-SSE sessions, LUD-21 settled reporting, address and Arkade-identity registration). Runtime deps are @scure/base and @noble/hashes only and it never imports the server, so app bundles stay free of express/sqlite. A contract suite (test/client-contract.test.ts) drives it against the real createServer, so the vendored protocol types cannot drift silently.
Fixed
An offline-receive lockup waited up to 15s to be claimed — funding was discovered only by the settlement poller, and since the claim is what makes the solver settle the hold invoice, the chain was two serialised interval waits. Lockups are now registered as contracts at quote time (registerLockupContract) and src/lockup-watcher.ts claims on the contract manager's vtxo_received event, the same push mechanism the covenant watcher already used. A pass also runs at startup, closing a 15s dead window. The poller remains the fallback: it is driven by the durable swap rows rather than the contract store, so a subscription that dies costs speed and not the claim, and the startup log now prints claim=event-driven or claim=polled so the mode is visible. The ContractManager is built for either rail rather than only under OFFLINE_COVENANT_DESTINATIONS, without which this fix would have been inert wherever covenant destinations were off.
Added
OFFLINE_POLL_INTERVAL_MS (default 15000) — the remaining wait is asking the solver whether it settled, and RfqTransport exposes requestQuote/status/close with no subscribe, so that half cannot be pushed. This is the honest lever for it; unset, behaviour is byte-identical.
Fixed
The payRequest advertised amounts the solver would refuse — the offline-swap rail's bounds now narrow to the payer-side (*_quote_amount) range published by the discovered solver cards, taken as the union across cards and re-derived per request. Reported from production: 100_000 sats returned "Unable to create offline invoice" against an envelope advertising exactly that, because the mutinynet card caps the BOLT11 leg at 25_000. The quote leg is what binds, not the base leg. A second bug surfaced on the way — railCaps was evaluated once in createServer, so discoveryReady was frozen at boot and a server that started healthy kept claiming so. And a refusal the rail marks payer-safe is now quoted back (no solver card supports a 50000 sat lightning receive, the string the logs already carried) instead of the generic message, while anything naming a solver stays generic.
The demo wallet's Reset wiped the whole browser origin — localStorage.clear() on a GitHub Pages origin removes every other app served from it. Reset now removes this wallet's own keys, behind a typed confirmation, and the mnemonic it destroys is finally visible beforehand.
A restored wallet could not reclaim its own username — the server refuses to register an existing username to anyone, its rightful owner included, so a phrase restored on a fresh browser was stuck. The wallet now asks which username its token owns rather than re-claiming it; boot takes the same path, since a phrase without a username has to ask however it got that way.
Fixed
A covenant address could be handed to a non-arkade destination rail — covenant derivation ran for any destination-kind paymentOption, not just arkade, and its result overrode whatever address that rail had resolved. Selecting the new onchain rail therefore answered with an Arkade covenant address and a verify URL instead of the boarding address: a payer following it would have sent to the wrong chain. Derivation is now gated on the arkade rail. Only an instance with covenant destinations configured showed it, which no test did — the case is now covered with a provider wired and onchain requested.
Fixed
A static destination no longer advertises a verify URL the server cannot honour — the arkade rail handed the payer a LUD-21 verify link whether or not a per-payment covenant address was derived. A covenant script identifies the payment; the static fallback is one address reused for every payment and settled by amount/window correlation in arkade-watcher, so two payers sending the same amount in the same window are indistinguishable and one could be told the other's arrived. verify is now emitted only when the destination identifies the payment. The settlement record is still written either way — the watcher settles it and it appears in payment activity — and /lnurl/verify/{id} is unchanged; what goes away is the promise made to the payer. The served spec documents the rule, since absence means "not individually attributable", not "will not settle".
Fixed
The demo wallet could not create an identity at all — MnemonicIdentity.fromMnemonic defaults to mainnet BIP44 derivation (coin type 0), and the Arkade Service refuses a wallet whose derivation disagrees with its own network, so every attempt against mutinynet failed with "Network mismatch" the moment a user pressed Create. The unit suite, the type-check and the production build were green throughout, because nothing below a real browser ever executed Wallet.create. The identity now derives from IS_MAINNET in the wallet's config, and the browser suite above covers it so this class of failure cannot ship again.
A solver quote priced in the asset-leg string form is now refused instead of mis-compared — @arkade-os/swap 0.0.19 widens RfqQuote.from_amount/to_amount to number | string: sats as a number on HTLC-class corridors, a canonical decimal string only on arkade↔arkade asset legs, whose bigint range a JS number cannot hold. Offline receive is an HTLC corridor, so a string there is a corridor nobody asked for, and both ways of carrying on are wrong — coercing with Number() would silently round an asset amount, while leaving it be is worse, since "50" !== 50 rejects a correct quote and "9" > "10" is true, so the "pays out more than it takes in" guard would invert. quoteSats now rejects it at the boundary and the three downstream uses take the checked number. Not reachable before the bump, when the field was typed number.
Arkade dependencies moved to their released versions — @arkade-os/sdk 0.4.73, @arkade-os/swap 0.0.19 and @arkade-os/solver-discovery 0.2.7, picking up operator-cosignature verification on wallet submits, the settle-side intent-proof and boarding-input checks, the onchain-send claim funding gate, and a late settlement no longer being recorded as a refund. A pnpm.overrides pin keeps the SDK to a single copy: its optional peer under packages/client had stayed on 0.4.72, and two copies make PaymentRail and RouterContext structurally incompatible across the rails.
The four dependency advisories that were actually reachable — vitest moves to 4.1.11, and pnpm.overrides pins esbuild to ^0.28.1 (reached through tsup and vite) and form-data to ^4.0.6 (reached through @types/supertest). All four are build- or test-time only; none is in the server's runtime path. pnpm audit now reports none. The other seventeen open alerts were already answered by the lockfile this branch regenerated — vite 8.0.16, postcss 8.5.28, nanoid 3.3.18, path-to-regexp 8.4.2, qs 6.16.0 and body-parser 2.3.0 all resolve at or above their patched versions — so they need a Dependabot rescan, not a change here.
A browser could not register on a domain that requires an API key — the CORS allowlist named only Content-Type and Authorization, and an explicit allowedHeaders replaces cors's default of reflecting the requested headers. POST /lnurl/address reads X-API-Key, so on a requireApiKey domain the browser dropped the request at the preflight and surfaced an opaque CORS error instead of the 401 the route would have returned. Server-side callers were unaffected, which is why the server's own tests never saw it. X-Request-Id is now in exposedHeaders too — the server sets it on every response and tells operators to quote it, but a cross-origin caller could not read it.
An omitted solver-registry override left offline receive unavailable — discovery now follows @arkade-os/solver-discovery's published per-network registry when SOLVER_REGISTRY_URLS is absent. A non-empty variable still overrides the default, while an explicitly empty variable disables registry discovery. If no usable Lightning-receive card is available, the interactive LNURL relay remains ready while readiness and the admin discovery view report the unavailable optional capability; sessionless offline callbacks fail loudly per request instead of stalling.