Repository navigation
Conversation
Replaces the hand-rolled payer half of src/lib/lnurl.ts with the shared client package, so the wallet and the server stop carrying separate implementations of the same protocol. Send/Form.tsx now uses resolve + requestInvoice instead of checkLnUrlConditions + fetchInvoice, and isValidLnUrl comes from the package. The three test files that imported the old module follow. What is left of src/lib/lnurl.ts is fetchArkAddress alone: it calls the callback with ?method=ark, which the Arkade lnurl-server does not implement, so the package has no equivalent by design and removing it would be a behaviour change to whichever provider it does serve. Two things worth knowing when reading this diff. requestInvoice is re-fetched rather than reusing the stored payRequest state, because that state holds sats while the callback wants millisats - reusing it would compare the two against each other. A LUD-06 response without tag: "payRequest" is now rejected. The spec mandates the field and the server always sends it, and the check also stops an HTML error page being parsed as a payRequest, but a non-conforming server that worked before will now fail. One test fixture omitted the field and has been corrected. Gate: 94 files / 829 passed + 1 skipped, matching the pre-change baseline. src/test/screens/wallet/swap.test.tsx is flaky under full-suite load - it flaked on the baseline too, has no lnurl reference, and passes 42/42 in isolation across three runs. NOT MERGEABLE AS-IS: the @arkade-os/lnurl-client dependency is a local link (link:../lnurl-server-client/packages/client) and must be swapped for a published version first.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the
Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe wallet adds LNURL receive and send flows, token payment quotes, receiver setup, activity synchronization, and receiver confirmations. It updates transaction details, labels, settings, and related tests. ChangesLNURL wallet flow
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~60 minutes Change: Feature Sequence Diagram(s)sequenceDiagram
participant ReceiveQRCode
participant useLnurlRail
participant LNURLServer
participant useLnurlRails
ReceiveQRCode->>useLnurlRail: Load receiver and capabilities
useLnurlRail->>LNURLServer: Request receiver data
ReceiveQRCode->>useLnurlRails: Resolve payment rails
useLnurlRails->>LNURLServer: Request rail targets
useLnurlRails-->>ReceiveQRCode: Return resolved targets
sequenceDiagram
participant SendForm
participant lnurlClient
participant sendRouter
participant SendDetails
participant lnurlSends
SendForm->>lnurlClient: Resolve payment conditions
SendForm->>sendRouter: Request a rail quote
SendForm->>SendDetails: Pass quote and target
SendDetails->>lnurlSends: Record payment and verification data
Suggested reviewers: Merge Risk: ⚪ Minimal · up to
|
package.json was hand-edited to turn pnpm's Windows backslash link path into
forward slashes, but the lockfile still held the backslash form, so
--frozen-lockfile refused to install:
ERR_PNPM_OUTDATED_LOCKFILE
- @arkade-os/lnurl-client (lockfile: link:..\lnurl-server-client\packages\client,
manifest: link:../lnurl-server-client/packages/client)
CI still cannot go green on this branch: the dependency is a local link to a
sibling checkout that does not exist on a runner. That is the known blocker
this PR is a draft for, and it clears when the package is published. This
commit only makes the failure the honest one rather than an avoidable
manifest/lockfile mismatch on top of it.
Deploying wallet-mutinynet with
|
| Latest commit: |
76e05af
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a0d1461c.arkade-wallet.pages.dev |
| Branch Preview URL: | https://feat-lnurl-client-migration.arkade-wallet.pages.dev |
Deploying wallet-bitcoin with
|
| Latest commit: |
76e05af
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://d0e2b5ce.wallet-bitcoin.pages.dev |
| Branch Preview URL: | https://feat-lnurl-client-migration.wallet-bitcoin.pages.dev |
First piece of pulling receives back from an lnurl-server. A wallet only
learns of a receive if it was online for it, and the offline-swap and
covenant rails complete with it absent by design, so the payments it can
never witness are exactly the ones worth recovering.
Records and watermarks are stored differently, on purpose. Records go in an
injectable store defaulting to IndexedDB, following swapRepository.ts, whose
comment records storage deliberately moving off localStorage because records
grow without bound. Watermarks go in localStorage as one JSON map: a
watermark is a bounded scalar, one number per address, and belongs with the
wallet's other cursors rather than in a record store.
The store is injected because this wallet has no fake-indexeddb and no
existing repository test, so a hard-wired IndexedDB dependency would have
been untestable. It follows assetSwapResolver(read = readSwaps), which
already uses that shape for the same reason. The default backend also opens
its connection lazily rather than at import, or the module would be
unimportable under jsdom and injection alone would not have helped.
Upsert rather than insert, keyed on `${baseUrl}|${identifier}`. The server's
nextSince cursor is inclusive by design, so every sync re-fetches the
boundary row; overwriting makes that a no-op instead of duplicating a
payment each time. baseUrl is in the key because identifiers are unique per
server, not globally.
Gate: 95 files / 832 passed + 1 skipped, against a 94 / 829 + 1 baseline.
The two failures are src/test/screens/wallet/swap.test.tsx timing out under
full-suite load - the known flake: it flaked on the baseline before this
work, contains no lnurl reference, and passes 42/42 in isolation twice.
The sync loop moved into @arkade-os/lnurl-client, so the wallet supplies storage rather than reimplementing the loop: createLnurlPaymentSyncStore bridges PaymentSyncStore onto the IndexedDB records and the localStorage watermarks that were already here. Drops the duplicated StoredLnurlPayment in favour of the package's StoredPayment so the shape cannot drift. Records stay in their own IndexedDB database rather than joining the swaps repository. RfqSwapRecord requires a lockupAddress that a bolt11 relay receive does not have, and a kind from a closed union RfqSwapManager branches on -- persisting a foreign kind is the failure that file is arranged to prevent. A separate object store in the same database would need a DB_VERSION bump in @arkade-os/swap, which swapRepository.ts records as one-way. Unifying them belongs behind a generic namespaced store upstream, not a foreign record shape here. byPaymentReference stays wallet-side; it is what the activity resolver correlates on and means nothing to the package.
An lnurl-server record says why money arrived; chain sync already says that it did. The resolver correlates the two on paymentReference -- the Arkade txid, and the only field tying a server record to a transaction the wallet can see -- so a covenant receive reads as a Lightning payment to alice@example.com rather than an unattributed credit. prepare() re-reads on every history load rather than indexing once at construction: the sync loop writes after the first load, and a cached index would leave those receives unattributed until the next reconnect. Known limit, unchanged by this: a resolver can only decorate transactions the wallet already has. A bolt11 relay receive settled while offline has no transaction to attach to, so it is stored and stays invisible. Surfacing those means synthesising activity rather than decorating it, which is a different capability.
StoredPayment gained preimage and paymentOption upstream. The resolver surfaces paymentOption so the activity view can tell which rail a receive settled on rather than only that it was a destination payment; fixtures follow the widened type.
Closes the gap that left the storage, loop and resolver inert: nothing called them. syncLnurlActivity derives a per-domain token from the wallet's Identity, asks each server which addresses that token owns, and drives the package's syncPayments over them. Targets are discovered, never remembered. A server may assign the username itself -- allocationModes includes "random" -- and an address can be revoked and re-registered under a different one, so listAddresses is the only source of truth for what a token owns. Storing a username the wallet picked would break on exactly the domains that do not let it pick. Fired without await after the wallet is usable: an unreachable lnurl-server must cost the activity view its attribution, never the wallet its startup. Discovery failures are per-server and merge into the loop's own failures, so one dead server does not hide the addresses that did sync. The server list is empty until something registers an address, so this stays a no-op until receive-side registration lands.
console.warn tripped no-console; consoleError is the convention here and also records the failure in the in-app log the support view reads.
The token derivation lowercases the domain (token.ts normalises before
hashing) and the server stores and looks domains up lowercased, so a stored
server entry of "Example.com" authenticated correctly and then matched
nothing: every returned address was compared verbatim against the
capitalised form and skipped.
The result was the worst shape of failure -- { synced: 0, failures: [] }.
No error, no addresses, a sync that reported success while doing nothing.
Both sides of the comparison are now normalised the same way the derivation
and the server already do it. Found by self-review, not in use anywhere yet.
The first half of moving receive onto lnurl: without a registered address there is no destination to resolve and the sync and resolver stay inert. Two things the shape has to respect. The username is optional and the returned one is authoritative -- a domain with allocationModes "random" assigns its own, so the Arkade bind uses the name that came back rather than the name that was asked for, or the two diverge on exactly those domains. And the server is remembered only after the bind succeeds: an address whose identity never bound serves only the interactive rail, so syncing it would report success while every offline rail stayed unavailable. svcWallet.identity is a full Identity, so the caller passes that straight in and no key material reaches the view layer.
The screen no longer negotiates its own RFQ receive. It shows the registered lnurl address, and the server mints an invoice per payer -- which is the whole point: a payment can now arrive while the wallet is closed, where the old flow required the wallet open and an amount typed first. encodeBip21 already had an lnurl parameter that emits `lightning=<lnurl>` when there is no invoice, so one QR still serves every rail. The amount gate is gone too: a lightning address needs no amount, where a solver-minted hold invoice did. Removes from this screen: solver discovery, the rendezvous bounds check, the RFQ transport, requestLnReceive, the three negotiation error states and the retry button -- about 90 lines. Their tests went with them; a wallet with no registered address now says so in one grey line, and the new test pins both that and its absence once registered. LnReceiveProvider stays mounted on purpose. It owns the RfqSwapManager the rest of the app uses, and claimReceive/toReceiveOrigin/toReceiveSwap still serve receives negotiated before this change. requestLnReceive is now dead production code, but removing it means proving the claim path for in-flight swaps does not go with it, which is a separate change with real money behind it rather than a tidy-up to bolt on here. Assets and on-chain boarding are untouched: lnurl-server serves neither.
Without this the previous commit is a regression: every wallet showed "Lightning unavailable" with no way to change that. The server is read from VITE_LNURL_SERVER rather than defaulted. Which lnurl-server a wallet trusts with its receive identity is not a choice to bake into a binary -- the token is a bearer credential and the registered Arkade address is where the covenant pays, so a wrong default would hand both to someone the user never picked. With none configured the screen says so and offers no button, because there is nothing to offer. Registration binds the ark address already on screen rather than asking the wallet for one again. They are the same value today, but the covenant pays whatever was registered, so a path where the two could differ is a path where funds land somewhere the user never saw. A failed claim surfaces its reason and leaves the button usable; the error copy deliberately avoids repeating the button label, which otherwise makes the two indistinguishable to anything matching on text.
The startup sync reads the server list once, before any address registered in this session exists. Without this, a payment arriving between claiming an address and the next app start shows as an unattributed credit -- which is the exact gap the feature was built to close, reopened by the ordering. Not awaited and not surfaced: registration has already succeeded by then, so a sync failure must not be reported as a failed claim. It is also not fired when the claim itself failed, since there is nothing new to sync.
`track` was the entry point for a receive this wallet negotiated itself, and nothing reaches it now that the Receive screen shows a registered lnurl address. It goes, with the grace-period wait and the held-elsewhere error that only it raised. Claiming is untouched and was verified separable before any of this moved: it reads the stored record through `assetSwapRepository.getRfqSwap`, while `toReceiveSwap` and `toReceiveOrigin` ran only at creation inside `track`. So a receive negotiated before this branch still claims. `admitted` stays because the restore path populates it too, and `granted` stays because the lock still decides which tab drives. Two tests were rewritten rather than deleted, because their subject is still live: one-tab-drives now asserts a single manager start across two providers without going through `track`, and the pending-lock case asserts the manager starts on the grant. Only the two admission tests are gone. The lib helpers are left in place. Removing them cascaded into shared test fixtures for `claimReceive`, which is real claim coverage, and they are pure functions nothing calls -- not worth unpicking that on a branch CI cannot check.
Brings the lnurl branch onto SDK 0.5 (sdk 0.5.0-rc.9, swap 0.1.0-rc.12). Rule: ts-sdk-0.5 wins for everything not lnurl; the branch's lnurl additions are kept. Conflicts and resolutions: - package.json: ts-sdk-0.5's dependency versions; kept only the branch's @arkade-os/lnurl-client entry (pnpm-lock.yaml auto-merged to the same shape). - src/screens/Wallet/Receive/QrCode.tsx: ts-sdk-0.5's file wholesale. The lnurl receive UI is dropped here and rebuilt as a separate rail module in a later commit; the swap-rail receive is intact. - src/providers/lnReceive.tsx, src/test/providers/lnReceive.test.tsx (deleted on ts-sdk-0.5, modified here): deleted. ts-sdk-0.5 folded the provider into providers/swaps.tsx; the branch's edits only removed the RFQ receive-admission path, not lnurl code. - src/test/screens/wallet/receive-lightning-error.test.tsx (deleted here, modified on ts-sdk-0.5): kept ts-sdk-0.5's version; it covers the swap-rail receive this merge restores. - src/providers/wallet.tsx: ts-sdk-0.5's swapRecordResolver block (and its removal of the storage migration); kept the lnurlResolver / syncLnurlActivity imports and activity.use(lnurlResolver()). - src/screens/Wallet/Send/Form.tsx: the lnurl-client resolve() call with #999's early-return guard (invoice && lnUrlResponse). - src/lib/lnurl.ts: the branch's slimmed module, plus checkLnUrlInvoice carrying #1003's invoice decode + amount-match check, which requestInvoice does not do; Form.tsx calls it on the bolt11. - src/test/lib/lnurl.test.ts: the branch's client-based tests, with #1003's invalid-invoice and amount-mismatch cases re-pointed at checkLnUrlInvoice.
Packed from ArkLabsHQ/lnurl-server daafb5a (packages/client) until the package is on npm. vendor/README.md has the refresh steps and the swap to a registry version.
The Docker install step ran before COPY . ., so the file:vendor tarball was missing (ENOENT). send.test.tsx now covers #1003's amount check at the form level, and the lnurl.ts header names checkLnUrlInvoice.
Replace the hand-rolled registerAddress/registerArkadeIdentity/localStorage plumbing in lnurlRegister.ts with a thin lnurlReceiver() wrapper over the vendored client's arkadeLnurl facade, which already does the claim + identity bind + discovery. Drop the stale receive-lnurl.test.tsx, which exercised the pre-rebase QrCode screen behaviour this facade replaces; Task 5 rewrites the screen's lnurl coverage against the new module.
…odule The RFQ negotiation (state, effect, retry, lost/claim-error outcome) moves from QrCode.tsx into useSwapRail (src/lib/receive/swapRail.ts), and its error/retry rendering into SwapRailStatus. The hook is called where the effect used to be declared, so effect order is unchanged. No behavior change: the existing receive tests pass unedited.
…E_LNURL_SERVER QrCode now composes two rails. With VITE_LNURL_SERVER set, useLnurlRail resolves the identity's owned() receiver at the configured server; with none it onboards from capabilities(), offering only the advertised modes: a chosen name (self), "Pick one for me" (random), a reserved name plus claim code (admin) and "No name" (session). A nameless receiver gets "Add a name" under the same gating, via receiver.upgrade(). The QR's BIP21 lightning= carries receiver.lnurl, which upgrade leaves unchanged, and the copy sheet gains the lightning address. The swap rail is disabled while an lnurl-server is configured. Unset, the screen is the swap rail alone, as on ts-sdk-0.5. A server that requires an API key gets no choices, because the facade cannot send one. A fresh claim syncs activity immediately, restoring the post-registration sync that 5e0cd89 added and the pre-merge Receive screen lost. Tests drive the real arkadeLnurl facade against an in-memory fake of the lnurl-server endpoints.
…; guard stale claims An owned nameless receiver is set before capabilities() is fetched. A capabilities failure now leaves no naming choices and shows the error, instead of failing the whole load and dropping the QR's only Lightning route. claim/upgrade capture the facade at call time and skip every write if it has changed by the time they settle, so a late result cannot overwrite the receiver the new facade loaded. The load effect also resets busy.
Packed from ArkLabsHQ/lnurl-server packages/client at 7504234: plain LUD-06/16 payRequests (no paymentOptions) now route through lnurl-lightning, and requestInvoice refuses a bolt11 that does not decode or whose amount differs from the request (#1003's check, now in the client). Two tests follow the new contract: the invoice-fetch test uses a real fixture invoice for its amount, and the Form's wrong-amount test matches the client's refusal text (still asserting no invoice reaches the flow).
createSendRouter registers the client's lnurlRails: the Arkade leg (arkRail) always, since it needs no swap client, and the Lightning leg only with one, delegating to the wallet's lightningRail. Priority puts lnurl-arkade then lnurl-lightning ahead of the plain lightning rail. quoteLnurl takes the ranked legs in order and falls back when one cannot quote (nothing is spent by a quote). Send/Form stops fetching the invoice itself. It keeps the conditions resolve (limits, fixed amount, transferAmounts for fetchArkAddress) and hands the sign screen the router's quote. #1003's invoice check now runs in the client (0.4.1 requestInvoice), before any solver is asked, so the wallet's checkLnUrlInvoice is removed; its refusal tests now run against the vendored client, and the Form test proves refusal on the router path. Send/Details pays that quote only if it is still for the target and amount on screen, and only where offchain sends are allowed. It records the send (target, rail, amount, fee, txid, swap id and preimage as they arrive) in localStorage via mergeSentPayment. The package's sentActivityResolver is registered beside the receive resolver. The SDK puts a tx claimed by two resolvers into both groups, so an LNURL send over Lightning would have shown twice beside its swap row; activitiesToTxs drops the absorbed lnurl-send group. Success reads the rail: lnurl-lightning is on the way, lnurl-arkade is sent.
Every recipient branch spread the previous sendInfo, so an LNURL survived a BTC, Ark or invoice typed after it (and an Ark address survived an LNURL typed after it). Continue checks lnUrl first, so the wallet quoted and paid the stale LNURL, or Details paid the stale Ark address. Each branch now clears address/arkAddress/invoice/lnUrl/pendingLnSend before setting its own target (functional updates throughout); the BIP21 asset branch clears lnUrl. lnUrlResponse is cleared when the LNURL target goes away or changes, in the conditions effect rather than per keystroke: clearing it in handleRecipientChange left Continue dead when the same LNURL was re-entered (the effect is keyed on lnUrl and does not re-run). A resolve that lands after the recipient changed is now ignored; it used to write the old closure's sendInfo back, restoring the LNURL and wiping the new address. Tests drive a useState-backed FlowContext through the real recipient input.
handleContinue awaits fetchArkAddress or quoteLnurl, then merged the result into sendInfo unconditionally. A recipient changed during the await got the old LNURL's Ark address or quote attached to it, and Details, which prefers arkAddress, would pay the old LNURL's destination. Two defences: each continuation (and its catch) drops the result when the LNURL target it was started for is no longer the current one, via a ref to the latest lnUrl; and the recipient input, with its paste/clear/scan buttons, is disabled while Continue is processing, so the window mostly cannot open.
|
Superseded by a split into single-feature PRs. Each part of this PR now lives here:
Each PR was built from this branch's tree on the current The branch |
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
…ver's confirmation The payer path moves onto @arkade-os/lnurl-client. Send resolves a lightning address or LNURL with the client, and the router gains the package's two LNURL rails: Arkade, ranked first, and Lightning, which needs the swap client. quoteLnurl tries them in order, a rail that cannot quote yields to the next, and the client refuses an invoice for any other amount before a solver is asked. lib/lnurl.ts keeps only fetchArkAddress (the ?method=ark call), which the package has no equivalent for. The form drops the previous target when the recipient changes, holds Continue until the LNURL conditions load, drops a quote that lands after the amount or recipient changed, and lets a BIP21 pay its address or invoice when its LNURL fails. A tab that is not driving swaps can still pay an LNURL over Arkade (sendRouter swapsOptional). The success screen calls an LNURL send over Lightning on its way and one inside Arkade sent. Each LNURL send is recorded (lnurlSends) and labelled in activity by the sent-payment resolver. When the rail hands out a verify URL, the wallet polls it in one batch per endpoint, resumes after a reload, and the transaction screen shows whether the receiver confirmed the payment settled (en/es). A reset forgets the pending checks. Split out of #1000.
The send success screen spelled its "keep the wallet open" warning as a hard-coded English string, with a broken character where the dash belonged. It now reads send.pendingOnchain in en and es, and the wording test renders it in both languages. Split out of #1000.
LOCAL_HOSTS, which decides when an http: app-intent callback is allowed, listed '::1' beside '[::1]'. WHATWG URL parsing always brackets an IPv6 hostname, so url.hostname can never be the bare form and the entry matched nothing. Removing it changes no accepted callback; '[::1]' stays. This rode along in the localization commit on #1000; it is a trust- boundary set, so it gets its own commit here. Split out of #1000.
faucetOffchain posted its JSON with curl -d '...' through child_process.exec. On Windows exec runs cmd.exe, which does not treat single quotes as quotes, so every funded spec failed locally with "curl: (3) unmatched close brace". curl also exits 0 on an HTTP error, so a refused request surfaced later as a balance timeout. It now uses fetch and throws on a non-2xx with the faucet's reason. Split out of #1000.
…nted The useBounceMorph fixes are already on ts-sdk-0.5; this is their regression test. A re-suspended Suspense boundary runs the layout cleanups of what it hides but not the passive ones, which reproduces the window deterministically, and the test asserts that no bounce starts while the tree is hidden. Split out of #1000.
A receive that completed while the wallet was closed is one it can never witness itself, so once the wallet is usable syncLnurlActivity pulls the payments of the identity's receiver at the configured lnurl-server into the lnurl payment store. It is not awaited and resolves to zero with no server or no owned address, so an unreachable server costs the activity view its labels, never the wallet its startup. A VTXO or UTXO update pulls again about 3s and 20s later, since the server records a payment as it settles and can trail the coin, and the history reloads whenever a pull wrote something. Locking or resetting cancels pending pulls. Writes stop once the init session aborts, and a reset waits out the writes already in flight before it clears the store, so a late sync cannot write the previous wallet's records back. Split out of #1000.
…#1077) * fix(send): localize the pending onchain warning The send success screen spelled its "keep the wallet open" warning as a hard-coded English string, with a broken character where the dash belonged. It now reads send.pendingOnchain in en and es, and the wording test renders it in both languages. Split out of #1000. * fix(app-intent): drop the bare ::1 from the local callback hosts LOCAL_HOSTS, which decides when an http: app-intent callback is allowed, listed '::1' beside '[::1]'. WHATWG URL parsing always brackets an IPv6 hostname, so url.hostname can never be the bare form and the entry matched nothing. Removing it changes no accepted callback; '[::1]' stays. This rode along in the localization commit on #1000; it is a trust- boundary set, so it gets its own commit here. Split out of #1000. * test(e2e): fund wallets with fetch, not curl through a shell faucetOffchain posted its JSON with curl -d '...' through child_process.exec. On Windows exec runs cmd.exe, which does not treat single quotes as quotes, so every funded spec failed locally with "curl: (3) unmatched close brace". curl also exits 0 on an HTTP error, so a refused request surfaced later as a balance timeout. It now uses fetch and throws on a non-2xx with the faucet's reason. Split out of #1000. * test(hooks): cover the bounce-morph loop while its controls are unmounted The useBounceMorph fixes are already on ts-sdk-0.5; this is their regression test. A re-suspended Suspense boundary runs the layout cleanups of what it hides but not the passive ones, which reproduces the window deterministically, and the test asserts that no bounce starts while the tree is hidden. Split out of #1000. * test(hooks): reset the shared onBounce mock between bounce-morph tests The module-level mock kept its calls across tests, which would make later assertions order-dependent as the describe block grows. * fix(send): localize the on-chain keep-open warning The ordinary on-chain branch of the success screen still rendered a hardcoded English sentence, so a Spanish wallet saw English after every on-chain send. Move it to send.onTheWayKeepOpen (en/es); the app-intent warning keeps send.pendingOnchain unchanged.
…odule (#1075) * refactor(receive): extract the solver-RFQ receive into its own rail module The Lightning receive the Receive screen negotiates through a solver moves out of QrCode.tsx into lib/receive/swapRail.ts (useSwapRail) and its status lines into Receive/SwapRail.tsx. The negotiation effect, its retry and no-driver handling and the lost/claim-failed outcome read are moved verbatim; the screen renders <SwapRailStatus> where it rendered them. useSwapRail takes an `enabled` flag, on by default, so a second receive rail can switch it off; nothing passes it yet. No behaviour change: the existing receive screen tests are the gate. Split out of #1000. * test(receive): pin that a disabled swap rail negotiates nothing useSwapRail(false) is what the LNURL rail relies on to keep the solver swap off while an lnurl-server is configured, and it had no direct test. The hook test checks an enabled rail negotiates for the amount and a disabled one never calls receiveLightning. The lint suppression now says why the invoice and pendingLnReceive reads stay out of the deps.
…ed (#1082) * feat(lnurl): attribute receives that landed while the wallet was closed A receive that completed while the wallet was closed is one it can never witness itself, so once the wallet is usable syncLnurlActivity pulls the payments of the identity's receiver at the configured lnurl-server into the lnurl payment store. It is not awaited and resolves to zero with no server or no owned address, so an unreachable server costs the activity view its labels, never the wallet its startup. A VTXO or UTXO update pulls again about 3s and 20s later, since the server records a payment as it settles and can trail the coin, and the history reloads whenever a pull wrote something. Locking or resetting cancels pending pulls. Writes stop once the init session aborts, and a reset waits out the writes already in flight before it clears the store, so a late sync cannot write the previous wallet's records back. Split out of #1000. * fix(lnurl): skip the post-sync reload once the init session is aborted A sync that wrote records before a lock or reset could still reload the wallet afterwards. reloadAfterSync now checks the init session's signal, so neither the startup sync nor a payment-triggered resync reloads a wallet that was locked or reset in the meantime. * refactor(activity): drop the per-caller write tracker for the store's own inFlightWrites only saw the writes syncLnurlActivity wrapped, so the claim-triggered sync() calls in receive/lnurlRail.ts — fire-and-forget on the shared sync store — were never in it, and a reset that waited on lnurlSyncWritesSettled could still clear ahead of them. The repository now gates and tracks every write itself, so this tracker only duplicated it for the one caller it could see; clearLnurlPaymentsForReset replaces the export. The signal check stays: it stops this module's own sync on a lock, where nothing closes the store. Its test for waiting out an in-flight write moves to the repository spec, which is where the tracker now lives. * fix(lnurl): forward readWatermark through the abort-aware store wrapper The wrapper spread the store to keep readWatermark, which copies only own properties: a PaymentSyncStore written as a class lost the method, and the receiver's sync failed before pulling anything. All three methods are now forwarded explicitly; the interface has no others.
…ver's confirmation (#1080) * feat(send): pay LNURL targets through the router, and track the receiver's confirmation The payer path moves onto @arkade-os/lnurl-client. Send resolves a lightning address or LNURL with the client, and the router gains the package's two LNURL rails: Arkade, ranked first, and Lightning, which needs the swap client. quoteLnurl tries them in order, a rail that cannot quote yields to the next, and the client refuses an invoice for any other amount before a solver is asked. lib/lnurl.ts keeps only fetchArkAddress (the ?method=ark call), which the package has no equivalent for. The form drops the previous target when the recipient changes, holds Continue until the LNURL conditions load, drops a quote that lands after the amount or recipient changed, and lets a BIP21 pay its address or invoice when its LNURL fails. A tab that is not driving swaps can still pay an LNURL over Arkade (sendRouter swapsOptional). The success screen calls an LNURL send over Lightning on its way and one inside Arkade sent. Each LNURL send is recorded (lnurlSends) and labelled in activity by the sent-payment resolver. When the rail hands out a verify URL, the wallet polls it in one batch per endpoint, resumes after a reload, and the transaction screen shows whether the receiver confirmed the payment settled (en/es). A reset forgets the pending checks. Split out of #1000. * fix(send): keep a resumed receiver confirmation to its original window Every reload resumed pending confirmations with a fresh three-minute deadline, so a send whose receiver's server stayed down never reached 'not confirmed' for a user who reopened the app. A confirmation now takes the time its wait began, and resuming passes the send's createdAt: an already-lapsed window fails on the first tick. The ?method=ark branch's comment also says it wins over the router's rails and pays a plain Arkade send, with no receiver confirmation. * fix(send): cut off a batch verify request that is held open The confirmation loop skips a tick while the previous one runs, and a batchVerify request has no timeout of its own. An endpoint holding the request open therefore stopped every later tick, the deadline sweep included, so a send waiting on that receiver never reached 'not confirmed'. Each batch request is now aborted after 10 s, or at the group's earliest deadline if sooner.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
With VITE_LNURL_SERVER set (and VITE_LNURL_DOMAIN where the LUD-16
domain differs from the API host), Receive composes a second rail beside
the solver swap rail. useLnurlRail resolves the identity's own receiver
at that server through the lnurl-client arkadeLnurl facade. When the
server allows nameless receivers it registers the wallet LNURL, which
encodes the wallet id and needs no name, and shows it in the QR as soon
as it is derived locally. Receive offers naming only when the user can
choose a name ("Add a name", "Pick one for me").
Lightning is the invoice the receiver's own LNURL issues for the amount,
as any payer would get it, or without one the receiver's address (its
LNURL while nameless). With config.receiveViaLnurl the Arkade and Bitcoin
legs resolve through the LNURL too; a destination the wallet can tell is
not its own (an Arkade address on another operator, an on-chain address
it did not register) is refused, and each rail that falls back to the
wallet's own address says why. The flag defaults off; its toggle ships
with the Settings page. The copy sheet gains the lightning address.
The swap rail stays off while an lnurl-server is configured, and takes
over when that server fails. Unset, Receive is the swap rail alone.
Deploy: the image reads VITE_LNURL_SERVER and VITE_LNURL_DOMAIN through
fromRuntimeEnv, like VITE_ARK_SERVER. The old VITE_LNURL_SERVER_URL is
gone; nothing in src/ read it, so a deploy still setting only that name
builds a wallet with the lnurl rail off, without an error.
Split out of #1000.
…ed (#1082) * feat(lnurl): attribute receives that landed while the wallet was closed A receive that completed while the wallet was closed is one it can never witness itself, so once the wallet is usable syncLnurlActivity pulls the payments of the identity's receiver at the configured lnurl-server into the lnurl payment store. It is not awaited and resolves to zero with no server or no owned address, so an unreachable server costs the activity view its labels, never the wallet its startup. A VTXO or UTXO update pulls again about 3s and 20s later, since the server records a payment as it settles and can trail the coin, and the history reloads whenever a pull wrote something. Locking or resetting cancels pending pulls. Writes stop once the init session aborts, and a reset waits out the writes already in flight before it clears the store, so a late sync cannot write the previous wallet's records back. Split out of #1000. * fix(lnurl): skip the post-sync reload once the init session is aborted A sync that wrote records before a lock or reset could still reload the wallet afterwards. reloadAfterSync now checks the init session's signal, so neither the startup sync nor a payment-triggered resync reloads a wallet that was locked or reset in the meantime. * refactor(activity): drop the per-caller write tracker for the store's own inFlightWrites only saw the writes syncLnurlActivity wrapped, so the claim-triggered sync() calls in receive/lnurlRail.ts — fire-and-forget on the shared sync store — were never in it, and a reset that waited on lnurlSyncWritesSettled could still clear ahead of them. The repository now gates and tracks every write itself, so this tracker only duplicated it for the one caller it could see; clearLnurlPaymentsForReset replaces the export. The signal check stays: it stops this module's own sync on a lock, where nothing closes the store. Its test for waiting out an in-flight write moves to the repository spec, which is where the tracker now lives. * fix(lnurl): forward readWatermark through the abort-aware store wrapper The wrapper spread the store to keep readWatermark, which copies only own properties: a PaymentSyncStore written as a class lost the method, and the receiver's sync failed before pulling anything. All three methods are now forwarded explicitly; the interface has no others.
With receiveViaLnurl and receiveViaTokens both on and an amount set, Receive offers the receiver's LNURL token options (stablecoins a third party swaps to Arkade BTC) beside its bitcoin rails. useLnurlTokenRails lists the options that are up, verifiable, name the provider holding the deposit and accept the amount, and quotes only the one picked, since a quote is a real provider order: re-requested when the amount changes and once it expires. A USD stablecoin quote outside 0.8x to 1.25x of what the sats are worth at the wallet's own price is treated as a misquote. Chain labels follow the BTCPay plugin's names. A token is picked from a sheet with its logo and a chain badge (@web3icons), which also replaces the Arkade mark in the QR centre. The note under the QR says what to send and how long the quote holds; on a phone it links the payment URI to the installed wallet. That URI is used only when it parses as a transfer of the quoted amount, token and chain to the quoted deposit (EIP-681 or Solana Pay), else the bare deposit. Copy takes the deposit address, never the URI, and the copy sheet now offers only the shown method's values; one value is copied outright. The QR stage is height-capped so it sits above the buttons on a short phone. lnurl-client states a rail's bounds in millisats; extractError now says "Amount must be between 10,000 and 100,000,000 sats" (errors.amountBetween). The receiveViaTokens flag defaults off; its toggle ships with the Settings page. Reviewed as #1051 before it merged into #1000. Split out of #1000.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000.
… page (#1084) * chore(deps): add @arkade-os/lnurl-client 0.6.0 and @web3icons/react 4.1.24 The published LNURL client the payer, receiver and activity work builds on, and the chain icons the token receive rails badge their logos with. Both pinned. Nothing imports either yet. Split out of #1000. * fix(bip21): read a Lightning address back from lightning= (#1079) * fix(bip21): read a Lightning address back from lightning= decodeBip21 read any lightning= value that starts with "ln" as an invoice and dropped anything else, so a Lightning address in lightning= was lost ("alice@...") or misread as an invoice ("lnbob@..."). A value with "@" is now kept as an LNURL when it is a valid Lightning address, and never read as an invoice: bech32 has no "@". encodeBitcoinLink builds the unified bitcoin: link with ark=, lightning= and an amount= that sizes only the on-chain and Arkade parts, and puts a Lightning address in lightning= (not BIP321, which is BOLT11 only). It lands here with the decoder so the two are tested as a round trip; the receive redesign is its first caller. Split out of #1068. * test(bip21): pin which link parts amount= sizes encodeBitcoinLink writes amount= only beside an on-chain or Arkade part; a Lightning-only link leaves it out because the invoice or LNURL carries the amount. Both cases are now asserted. * docs(bip21): say the decoder relies on isValidLnUrl accepting user@domain The Lightning-address branch depends on lnurl-client's isValidLnUrl accepting the user@domain form, so a future change there would drop these addresses; the comment now names that dependency. * fix(bip21): escape query delimiters inside link values encodeBitcoinLink wrote ark= and lightning= values raw, so a value holding '&amount=1' (a Lightning address comes from the LNURL server) added a parameter, and decoders reading the first amount= saw the injected one. Only what would end or split a query value is escaped now (% & = # + ? and whitespace); '@' stays literal, and URLSearchParams decodes the rest. * chore(deps): adopt published LNURL client 0.7.0 * test(bip21): check the decoded Lightning address in the delimiter case The case already checked that a query delimiter inside a Lightning address does not add a parameter; it now also checks that decodeBip21 hands the address back intact. Suggested by CodeRabbit on #1073. * chore(deps): adopt published LNURL client 0.8.0 0.8.0 ships the glue the LNURL layers above this one still write by hand: the localStorage watermark and sent-payment stores, sync that stops on a signal, receiver discovery that refreshes destinations and orders writes per identity and server, sessionLnurl() and addresses() on the facade, and the token-quote, payable-range, first-quote and chain-name helpers. Each layer adopts its part on top of this bump. The release also removes the package's HD receive entry point, which the wallet never imported, and with it the @scure/bip32 dependency. * feat(activity): show who an LNURL payment came from or went to lnurlPaymentRepository keeps the payments the lnurl-client sync store writes: an IndexedDB store, with per-address watermarks in localStorage, behind the package's PaymentSyncStore interface. Its activity resolver is registered on the wallet at startup, and a wallet reset clears the store, since it holds preimages. activitiesToTxs tags a row from either lnurl-client resolver with the address and the rail. A receive names the wallet's own lightning address it arrived at, never the payer: "Received at alice@...", or "Received via arkade" for a nameless one. A send names who it paid. The transaction details show Received at and Rail for a receive (en/es). A send that both a swap group and the sent group claim shows once, as the swap. Split out of #1000. * fix(activity): localize LNURL activity labels, and close the store on errors lnurlLabel built 'Received at …', 'Sent to …' and '… via …' in English, so a Spanish wallet's activity list mixed languages. It now takes t like lnSwapLabel, with transaction.lnurl{SentTo,ReceivedAt,SentVia,ReceivedVia} in en and es. The IndexedDB payment store closed its connection only on success; a failed read, write or clear left it open. Each error path now closes it before rejecting. * refactor(activity): skip a sent: group only when it has members The swap-dedup check skipped a sent: activity when every member was also claimed by another group, which [].every made true for an empty group. An empty group yields no rows either way, so this changes no output; the length check states the intent. The txid is also read once per member. * fix(activity): read a malformed watermark map as empty getStorageItem falls back only when JSON.parse throws, so a stored value that parses to something other than a plain object (null, an array, a number) reached the watermark code, which threw on read or write, or lost the write, until storage was cleared. Such a value now reads as no watermarks. * fix(activity): clear LNURL payments before a reset turns destructive The reset cleared the payment store after clearStorage() and swallowed the failure, so a clear that failed left the previous wallet's LNURL records, preimages included, in IndexedDB while the reset reported success. The next wallet's activity resolver then read them. The step now runs before anything destructive and rejects, leaving the wallet intact for a retry. syncLnurlActivity was not the only writer either: the claim-triggered sync() calls in receive/lnurlRail.ts are fire-and-forget through the shared sync store, so waiting on any per-caller tracker could miss them and let a write land after the clear. The repository therefore owns one write gate and one in-flight tracker, and clearLnurlPaymentsForReset closes the gate, waits out the accepted writes, then clears. The gate does not reopen, which a reset need not survive: it reloads the app. The watermark write honours it too, so a late sync cannot re-seed localStorage after clearStorage(). The swap-record clear stays non-fatal, as stale swap rows beat a wallet half-cleared. Reset.tsx now shows settings.resetFailed (en/es) instead of only logging, with a fixed message so a store error cannot surface payment data. * fix(activity): reopen the LNURL store when a reset's clear fails A failed clear abandons the reset with the wallet intact, but the store stayed closed, so every later LNURL write was dropped silently until a reload. The gate now reopens before the error is rethrown. * fix(activity): settle the LNURL store's IndexedDB calls on abort A transaction that fails at commit — a quota overrun, say — finds every request already done, so no request error fires and only `abort` reaches the transaction. `write` and `clear` listened for `complete` and `error` only, so the promise never settled and the connection stayed open. That stranded a wallet reset: `upsert` left the write in `inFlight` forever, `clearLnurlPaymentsForReset` awaited `writesSettled()`, and `resetWallet` never returned, so the Reset screen never reported the failure. A synchronous throw out of `transaction()` or `put()` rejected but leaked the connection in the same way. All three operations now run through one helper that rejects on `error`, on `abort` and on a synchronous throw, and closes the connection on every path. * fix(settings): say a failed reset may have cleared some data The LNURL step is the only fatal one, and it runs before anything destructive. Past it, `clearStorage()`, `svcWallet.clear()` and the repository clears can each throw with data already gone, so "Nothing was cleared" was a promise the screen could not keep. Copy only — no rollback. * fix(activity): reject an IndexedDB failure with its own error A request error bubbles to tx.onerror before IndexedDB sets tx.error, so rejecting there gave a bare AbortError and lost the cause (a quota overrun, say). The abort that always follows an uncancelled error carries it, so the store now settles on abort alone. The test fake now fires error before setting tx.error, as IndexedDB does, and the rejection assertions match the error's name: rejects.toThrow(regex) also passes for a null rejection. * fix(activity): abort the IndexedDB transaction when queuing a write throws A put that throws mid-batch (an invalid key, say) rejected the write but left the transaction live, and close() waits for it rather than cancelling it, so the puts queued before the throw still committed. The store now aborts that transaction and rejects with the original error once the abort lands. A result that cannot be read at completion now rejects instead of throwing out of oncomplete and leaving the promise pending. The abort-only cases now run for read as well as write and clear. * feat(send): pay LNURL targets through the router, and track the receiver's confirmation (#1080) * feat(send): pay LNURL targets through the router, and track the receiver's confirmation The payer path moves onto @arkade-os/lnurl-client. Send resolves a lightning address or LNURL with the client, and the router gains the package's two LNURL rails: Arkade, ranked first, and Lightning, which needs the swap client. quoteLnurl tries them in order, a rail that cannot quote yields to the next, and the client refuses an invoice for any other amount before a solver is asked. lib/lnurl.ts keeps only fetchArkAddress (the ?method=ark call), which the package has no equivalent for. The form drops the previous target when the recipient changes, holds Continue until the LNURL conditions load, drops a quote that lands after the amount or recipient changed, and lets a BIP21 pay its address or invoice when its LNURL fails. A tab that is not driving swaps can still pay an LNURL over Arkade (sendRouter swapsOptional). The success screen calls an LNURL send over Lightning on its way and one inside Arkade sent. Each LNURL send is recorded (lnurlSends) and labelled in activity by the sent-payment resolver. When the rail hands out a verify URL, the wallet polls it in one batch per endpoint, resumes after a reload, and the transaction screen shows whether the receiver confirmed the payment settled (en/es). A reset forgets the pending checks. Split out of #1000. * fix(send): keep a resumed receiver confirmation to its original window Every reload resumed pending confirmations with a fresh three-minute deadline, so a send whose receiver's server stayed down never reached 'not confirmed' for a user who reopened the app. A confirmation now takes the time its wait began, and resuming passes the send's createdAt: an already-lapsed window fails on the first tick. The ?method=ark branch's comment also says it wins over the router's rails and pays a plain Arkade send, with no receiver confirmation. * fix(send): cut off a batch verify request that is held open The confirmation loop skips a tick while the previous one runs, and a batchVerify request has no timeout of its own. An endpoint holding the request open therefore stopped every later tick, the deadline sweep included, so a send waiting on that receiver never reached 'not confirmed'. Each batch request is now aborted after 10 s, or at the group's earliest deadline if sooner. * refactor: adopt LNURL client storage and send lifecycle helpers * fix(send): honor supported LNURL option bounds * fix(lnurl): reject non-array stored send history * fix: skip onchain fee lookup for optional LNURL routers * test(lnurl): drop tests of lnurl-client internals 0.7.0 moved the payment repository, its reset gate, the IndexedDB store, pending confirmations and the send tracker into @arkade-os/lnurl-client. The package's own suite covers them (packages/client/test: repository, browser, confirmations, sends, activity); lnurlConfirmations.test.ts was a verbatim copy of ten of its cases. The wallet kept these tests running through thin re-exports, which is the only thing those exports were for. Keep what the wallet owns: the localStorage watermark adapter, including malformed maps; reset purging its key; the prepare-time reread that lets history see a sync written after construction; the stored-send parser; same-tab notification only after a successful write; and one wiring test each for recording a send and resolving it. Drop the test-only exports with them: createLnurlPaymentSyncStore (an identity function), the store and type re-exports, and lnurlConfirmations' re-exports of the package factory. * fix(send): show LNURL send refusals in the wallet's language Two refusals this PR added on the Details screen went out in English whatever the language: an account that may not send offchain, and a quote made for another target or amount. The first already had a key; the second gets one. The neighbouring guard for a quote made for another invoice gets the same treatment, since it is the same message family. Raised by Arkana on #1078. * refactor(lnurl): take the send and history glue from lnurl-client 0.8.0 The package now ships what this layer wrote by hand. The watermark and sent-payment stores come from its localStorage adapters, under the same keys and in the same formats, so existing wallets keep their cursors and recorded sends. Send listeners are the tracker's own subscribe, the receive resolver reads the repository through the package's async getter, history rows read resolver metadata through lnurlActivity, the send form takes its bounds from lnurlPayableRanges, and the router quotes through firstQuote and isLnurlRail. quoteLnurl keeps its own refusal for an address with no LNURL route, so that message does not change. The watermark tests that covered the adapter's parsing move with it; the wallet keeps the one showing its existing key is read and reset purges it. resumeLnurlConfirmations() gains a note on why every unlock may call it: the tracker keys each watch by txid, and the confirmation queue ignores a key it already holds. * test(lnurl): drop tests the lnurl-client suite already runs Each of these now exercises only package code. lnurl.test.ts called isValidLnUrl, toPayRequestUrl and the client's resolve and requestInvoice directly, with no wallet code in the path. The package's encoding, resolve and request-invoice tests cover every case it had, including the undecodable and wrong-amount invoices. The stored-send parser and same-tab notification were kept at 0.7.0 because the wallet owned them; 0.8.0 moved both into the package, whose browser and sends tests cover them ("reads %s as no sends, without rewriting it", "tells listeners after a write lands, never after one that failed, until they unsubscribe"). The three hand-built activity rows tested lnurlIntentOf's metadata parsing, which is now lnurlActivity and covered by the package's activity test. The wallet's mapping onto the row stays covered by the tests that drive the real resolvers end to end. The wiring tests stay: the existing storage key, the onError report, one recorded send, and the resolver reading the live store. * test: drop the LNURL fixtures only the removed lnurl test read * feat(receive): receive at a lightning address over the LNURL rail With VITE_LNURL_SERVER set (and VITE_LNURL_DOMAIN where the LUD-16 domain differs from the API host), Receive composes a second rail beside the solver swap rail. useLnurlRail resolves the identity's own receiver at that server through the lnurl-client arkadeLnurl facade. When the server allows nameless receivers it registers the wallet LNURL, which encodes the wallet id and needs no name, and shows it in the QR as soon as it is derived locally. Receive offers naming only when the user can choose a name ("Add a name", "Pick one for me"). Lightning is the invoice the receiver's own LNURL issues for the amount, as any payer would get it, or without one the receiver's address (its LNURL while nameless). With config.receiveViaLnurl the Arkade and Bitcoin legs resolve through the LNURL too; a destination the wallet can tell is not its own (an Arkade address on another operator, an on-chain address it did not register) is refused, and each rail that falls back to the wallet's own address says why. The flag defaults off; its toggle ships with the Settings page. The copy sheet gains the lightning address. The swap rail stays off while an lnurl-server is configured, and takes over when that server fails. Unset, Receive is the swap rail alone. Deploy: the image reads VITE_LNURL_SERVER and VITE_LNURL_DOMAIN through fromRuntimeEnv, like VITE_ARK_SERVER. The old VITE_LNURL_SERVER_URL is gone; nothing in src/ read it, so a deploy still setting only that name builds a wallet with the lnurl rail off, without an error. Split out of #1000. * fix(receive): wait for the wallet's own addresses before resolving the LNURL legs With receiveViaLnurl on, useLnurlRails asked for the Arkade and on-chain legs before the wallet's own addresses had loaded, then refused the server's answers as 'not this wallet's' or 'another operator' until the addresses arrived. A leg is now requested only once its own address is known; Lightning resolves straight away and the effect already re-runs when the addresses load. The rail panel and name form were English-only (unavailable notice, Add a name, Choose a name, Claim code, Claim name, Pick one for me); they now read receive.* keys in en and es. The trust comment on the Arkade check now says plainly that only the operator is verified. * fix(receive): localize claim errors, keep the facade ref to commits, and drop a stale swap invoice Claim errors (taken, not allowed, bad claim code, unavailable option, rate limited, and the fallback) were English-only; lnurlClaimErrorMessage now takes t and reads receive.* keys in en and es. The ref run() checks against was assigned during render, so an abandoned concurrent render could point it at an uncommitted facade. It is set in a layout effect now. A swap invoice left in the flow by an earlier visit was still preferred over the LNURL's invoice once the LNURL rail served Lightning. The QR now reads recvInfo.invoice only while the swap rail is on. * test(receive): pin that a late boarding address settles back on the same receiver A boarding address arriving after a session registered rebuilds the facade; the rail returns to ready with the same LNURL and no error. The claim-error key map is also typed Partial so a missing code reads as undefined. * fix(receive): drop trailing slashes from the configured LNURL server configuredLnurlServer kept a trailing slash from VITE_LNURL_SERVER, so https://lnurl.example/ derived the wallet LNURL as https://lnurl.example//lnurl/<id>, and handed the same base to the facade and the client. The base now loses trailing slashes once, where every caller reads it. * feat(lnurl): attribute receives that landed while the wallet was closed (#1082) * feat(lnurl): attribute receives that landed while the wallet was closed A receive that completed while the wallet was closed is one it can never witness itself, so once the wallet is usable syncLnurlActivity pulls the payments of the identity's receiver at the configured lnurl-server into the lnurl payment store. It is not awaited and resolves to zero with no server or no owned address, so an unreachable server costs the activity view its labels, never the wallet its startup. A VTXO or UTXO update pulls again about 3s and 20s later, since the server records a payment as it settles and can trail the coin, and the history reloads whenever a pull wrote something. Locking or resetting cancels pending pulls. Writes stop once the init session aborts, and a reset waits out the writes already in flight before it clears the store, so a late sync cannot write the previous wallet's records back. Split out of #1000. * fix(lnurl): skip the post-sync reload once the init session is aborted A sync that wrote records before a lock or reset could still reload the wallet afterwards. reloadAfterSync now checks the init session's signal, so neither the startup sync nor a payment-triggered resync reloads a wallet that was locked or reset in the meantime. * refactor(activity): drop the per-caller write tracker for the store's own inFlightWrites only saw the writes syncLnurlActivity wrapped, so the claim-triggered sync() calls in receive/lnurlRail.ts — fire-and-forget on the shared sync store — were never in it, and a reset that waited on lnurlSyncWritesSettled could still clear ahead of them. The repository now gates and tracks every write itself, so this tracker only duplicated it for the one caller it could see; clearLnurlPaymentsForReset replaces the export. The signal check stays: it stops this module's own sync on a lock, where nothing closes the store. Its test for waiting out an in-flight write moves to the repository spec, which is where the tracker now lives. * fix(lnurl): forward readWatermark through the abort-aware store wrapper The wrapper spread the store to keep readWatermark, which copies only own properties: a PaymentSyncStore written as a class lost the method, and the receiver's sync failed before pulling anything. All three methods are now forwarded explicitly; the interface has no others. * refactor: adopt client receive bootstrap and rail helpers * fix: complete LNURL receive review follow-up * fix(receive): retain Lightning when LNURL cannot be claimed * docs(receive): say why a named receiver's panel shows no LNURL error bootstrapReceiver returns a named receiver before it asks for capabilities, so capabilitiesError cannot accompany one, and the panel offers naming only while nameless. Answers the open Arkana item on #1081. * refactor(receive): take the session LNURL, address list and sync signal from lnurl-client 0.8.0 The facade now names its own session LNURL and lists every address on its domain, so the hook no longer derives the wallet id from the token itself or builds a second client to filter the list. Claim error keys are typed against the server's error codes. The activity sync hands its signal to receiver.sync(), which checks it before each write, instead of wrapping the store in a gate: a lock still stops writes, and the store passes through unwrapped. 0.8.0 also runs a facade's claim, discovery and upgrade one at a time per identity and server. Discovery under a new facade therefore waits for a claim still in flight and then finds it, so the hook test that expected such a claim to be dropped now expects the receiver to be held. * test(receive): drop resolveLnurlRails tests the package already runs The "consumer boundaries" block came over when the wallet's own rail resolution moved into lnurl-client, and since then it has called the package's resolveLnurlRails through a bare re-export, with no wallet code in the path. Every case has an equivalent in the package's receive-rails tests: picking the bitcoin option past token and disabled ones, refusing a Lightning rail the options omit, checking option bounds before the callback, and refusing a callback that answers for another option. * feat(receive): token rails from the LNURL, behind an opt-in setting With receiveViaLnurl and receiveViaTokens both on and an amount set, Receive offers the receiver's LNURL token options (stablecoins a third party swaps to Arkade BTC) beside its bitcoin rails. useLnurlTokenRails lists the options that are up, verifiable, name the provider holding the deposit and accept the amount, and quotes only the one picked, since a quote is a real provider order: re-requested when the amount changes and once it expires. A USD stablecoin quote outside 0.8x to 1.25x of what the sats are worth at the wallet's own price is treated as a misquote. Chain labels follow the BTCPay plugin's names. A token is picked from a sheet with its logo and a chain badge (@web3icons), which also replaces the Arkade mark in the QR centre. The note under the QR says what to send and how long the quote holds; on a phone it links the payment URI to the installed wallet. That URI is used only when it parses as a transfer of the quoted amount, token and chain to the quoted deposit (EIP-681 or Solana Pay), else the bare deposit. Copy takes the deposit address, never the URI, and the copy sheet now offers only the shown method's values; one value is copied outright. The QR stage is height-capped so it sits above the buttons on a short phone. lnurl-client states a rail's bounds in millisats; extractError now says "Amount must be between 10,000 and 100,000,000 sats" (errors.amountBetween). The receiveViaTokens flag defaults off; its toggle ships with the Settings page. Reviewed as #1051 before it merged into #1000. Split out of #1000. * fix(receive): offer only token options that state verifiable, and keep Copy off without a quote A token option with no verifiable flag was offered; lnurl-client calls an absent flag unknown, and lnurl-server always states it (true) for its FixedFloat options, so only an explicit verifiable: true is offered now. After a failed token quote the footer Copy stayed enabled but copied nothing, since there is no deposit to copy. It is disabled until the picked token has a quote. * fix(receive): refuse payment URIs with repeated bound fields, and keep Share off without a quote paysQuote read only the first value of each field it binds, so a URI that named a second, conflicting recipient or amount passed while the QR kept the whole URI, and a wallet reading the last value would pay elsewhere. address, uint256, spl-token and amount must now appear exactly once; any repeat falls back to the bare deposit address. Share stayed enabled when the picked token had no quote. It is disabled then, as Copy already is; the token-error test enables browser sharing so the assertion can fail. * test(receive): pin that Solana Pay URIs with a repeated field fall back paysQuote refuses a URI naming a bound field twice, but only the EIP-681 fields were tested. A Solana Pay quote now has a control that keeps its URI, plus a repeated spl-token and a repeated amount, each of which must show the bare deposit address instead. * fix(receive): expire a stablecoin quote by the clock, and regenerate it on request (#1085) * fix(receive): expire a stablecoin quote by the clock, and regenerate it on request Each token quote is a real provider order plus a solver swap, so useLnurlTokenRails no longer places a new one on its own at expiresAt. It marks the quote expired, stops exposing it, and offers regenerate(); a new quote is asked for only when the user taps. A sleeping phone holds setTimeout back, so the quote also counts as expired once Date.now() passes expiresAt, and a visibilitychange re-checks it on wake. offers(sats, id) says whether an option would still be offered for an amount, so a caller can drop a token pick before that amount lands. The hook's tests hold the regenerate request and check that no quote, old or new, is exposed while it is in flight. The Receive screen's expired notice and its "Get a new quote" button come with the receive redesign, which builds on this. Split out of #1068. * fix(receive): keep regenerate stable, and test which options an amount offers regenerate was a new function every render, so a caller using it in an effect or memo would re-run needlessly; it is memoised now. offers() had no direct test: it is false before the payRequest arrives, for an unknown or missing id, and for an amount out of the option's bounds or zero. The cleanup's comment now says why the expiry mark is cleared as well. * refactor(receive): use shared token quote normalization * fix(lnurl): keep shared rail bounds readable in sats * fix(receive): preserve keypad drafts across background updates * fix: allow retrying token receive quotes * refactor(receive): take token bounds, quotes and chain names from lnurl-client 0.8.0 tokenOptionAccepts checks an amount against an option's own bounds, and requestTokenQuote asks for the quote, refuses an answer that is not a token destination or that expires too soon, and normalizes the rest; the rails use both instead of repeating them. chainName replaces chainLabels.ts, which mirrored the same BTCPay chain directory. Its test goes too: lnurl-server now checks every chain it offers has a name (test/ff-catalogue.test.ts), and the package tests the fallback to the raw chain id. The wallet keeps its own refusals on top: a deposit that needs a memo the QR cannot carry, base units that are not a canonical positive integer, and a quote far from what the amount is worth. * feat(settings): a Lightning address page Settings > Advanced gains Lightning address, listed only when an lnurl-server is configured. It shows every address the wallet owns on the server, the wallet LNURL included as an entry of its own, and the server, and what a payer can send on each bitcoin rail in sats, as the receiver's own payRequest advertises it (lnurlRailLimits: token options are not rails of their own). A nameless receiver can be named here, with a claim code for a reserved name too; Receive offers only a chosen name or "Pick one for me". The Receive toggles live here, both off by default: "Route every receive through it" sets receiveViaLnurl, and, when the LNURL offers tokens, "Accept tokens" sets receiveViaTokens, after a one-time explainer that a third party holds the deposit. en/es. Split out of #1000. * fix(settings): show why the rail limits are missing, and drop a guessed server row A failed payRequest on the Lightning address page was only logged, so 'What payers can send' silently stayed empty. The error now shows under that heading. The server row is built only when a server is configured, rather than rendering server?.domain, and the receiveViaLnurl toggle handler is no longer an async function with nothing to await. * test(settings): cover the Lightning address page when the rail fails With no owned address and a failing capabilities call, the rail fails; the page now has a test that it shows the error and offers no naming. * refactor(settings): keep Arkade and Bitcoin routing out of Lightning address * refactor(settings): keep Arkade and Bitcoin routing out of Lightning address * fix(settings): hide the Receive section when no token option is offered The section's only control is the token switch, so a server advertising no token option left an orphaned "Receive" heading. Guard the whole section the way the limits section above it is guarded. Raised by Arkana on #1084. * fix(settings): say why the Lightning address page cannot load Two states left the page without a reason, both raised by CodeRabbit on #1084: - When the wallet's own addresses failed to load, no facade was ever built, so the rail stayed off and the page showed "Loading..." for good. It now shows the error. - A nameless receiver whose domain lookup failed was ready with an error but no naming choices, and the error only rendered inside the hidden naming section. It now renders under the address when there is nothing to name. * refactor(receive): stop re-exporting resolveLnurlRails, which nothing imports from here * feat(receive): offer to pay with stablecoins, shown as overlapping Tether and USDC logos The token rails the server offers are USD stablecoins, so the picker names them as such and shows the two most payers will recognise, Tether in front. The logos are aria-hidden: the button's accessible name stays its label. * fix(send): keep an LNURL's own query when asking for its Ark address fetchArkAddress appended '?method=ark' to the decoded payRequest URL, so an LNURL that already carries a query string (?id=abc) produced ?id=abc?method=ark and the server never saw the method. Set it through URLSearchParams instead. Raised by CodeRabbit. * fix(wallet): keep the wallet live when a reset's LNURL purge fails resetWallet stopped the status ping, the pending reload and the service-worker message listener before purging LNURL records. A rejected purge leaves the wallet intact, by design, but it then stopped updating balance and history until a reload. Only the LNURL writers need stopping before the purge: aborting the init session stops the history sync, and the resync is cancelled. The listeners now stop after the purge succeeds. Raised by CodeRabbit. * fix(receive): explain a lapsed quote where its code was, and offer a way back An expired or failed stablecoin quote left a blank square where the code had been, with a Try again button above the method tabs. That square now says the quote expired and not to send to the old address, or why there is no quote, with a Get a new quote button. A chosen stablecoin also gets a button beside it that goes back to the code shown before it; the tabs did this too, but with none of them lit after picking a stablecoin, nothing said so. * test(receive): pin the failed-rail message and an expired quote's disabled actions The unreachable-server test now also asserts the 'Lightning address unavailable' line that the failed rail renders, and the expired-quote test asserts that Copy and Share stay disabled. Both raised by Arkana. * ui fix * lint * fix(send): take an LNURL rail's fee out of the amount when sending Max Max set the amount to the whole balance and asked the LNURL rail to deliver all of it, so any rail with a fee (the Lightning swap) quoted a total above the balance and the sign screen refused with "Insufficient funds, you just have N sats". Max now re-quotes for the balance less the quoted fee, at most twice, and shows the amount that will arrive. A typed amount is left as typed. * fix(receive): stop requesting an LNURL invoice once the payment arrives The payment listener writes the paid amount into recvInfo.satoshis before it navigates to the success screen, and useLnurlRails asks for a new invoice whenever that amount changes. So every receive requested one more invoice, for the amount just received. On mainnet that left an unpaid hold invoice at the solver each time: a 1381-sat quote at 05:31:25, right after a 1381-sat payout. The LNURL and token rails now request nothing once recvInfo.received is set. A new receive starts from emptyRecvInfo, which clears the flag. * fix(send): route an LNURL send when this tab's swap client failed to start On the tab that holds the swap client, `sendRouter({ swapsOptional: true })` awaited `driving()`, which rejects when the client's repository is unreadable. The LNURL send then failed before quoting, even through the Arkade rail, which needs no swap client. The optional path now builds its router without the client in that case, as the tab that is not driving already does. The non-optional router still refuses. Raised by CodeRabbit on #1084. * feat(settings): claim a name from a sheet, with the claim code behind a link Settings → Lightning address showed the whole naming form inline: a name field, a claim code field and both buttons. Most users have no claim code, so the page now offers Claim name and Pick one for me, and Claim name opens a sheet. In the sheet the claim code stays behind a subtle "I have a claim code" link, which reveals and focuses the field. The sheet closes once the name lands. Receive is unchanged: it never offers claim codes, so its inline form shows no link. * feat(settings): let the claim code link show and hide its field The "I have a claim code" link now stays in place and toggles the claim code field, also on servers where a chosen name needs a code. A code typed and then hidden is not sent. The link reports its state through aria-expanded. Adds a test that a refused claim keeps the sheet open with its reason (Arkana). * feat(lnurl): ask for the claim code first where a name needs one; copy the address from Receive Claim sheet: on a server that offers claim codes but no `self` mode, a chosen name cannot be claimed without a code. The sheet now asks for the code first, above the name field, instead of hiding it behind the "I have a claim code" link. Where the code is optional, the link still shows and hides the field. Receive: the lightning address under the QR now shows only on the Unified and Lightning views, which are the ones it pays, and tapping it copies it, the way other copyable text in the wallet does. * feat(lnurl): claim with a code alone, and with a code when already named On @arkade-os/lnurl-client 0.9.0 and lnurl-server 0.6.0, a claim code can carry its own name: - On a server where a chosen name needs a code, the name field is now optional. A reserved name's code claims that name on its own. A code for any free name answers username_required, which the sheet explains: add a name and claim again. - A wallet that already has a lightning address can still redeem a code. Its addresses list offers "I have a claim code", and the claim registers a further address, since an upgrade only renames a nameless one. The sheet closes once the address changes and stays open with the reason when a claim is refused. The client bump is otherwise a drop-in: 0.9.0 adds registered methods and receive amounts, which the wallet does not use, and a facade given no methods never touches them. * feat(lnurl): choose the primary lightning address, and bind a claim that failed half way On @arkade-os/lnurl-client 0.9.1, whose owned({ handle }) returns a given active address: - Settings → Lightning address lists a "Primary address" picker when the wallet owns more than one active address. The choice is saved in the config as lnurlPrimary, and both Settings and Receive pass it to owned({ handle }), so Receive uses it straight away and after a restart. A new choice switches the receiver in place, without reloading the rail. - An address claimed with a code by an already named wallet becomes the primary, so Receive no longer falls back to the older one (CodeRabbit). - A claim can register its address and then fail to bind the identity, leaving the address with no Arkade destination. After a failed claim, the rail now runs a best-effort owned({ refresh: true }), which binds whatever was left unbound (CodeRabbit). - The username_required test goes on to add a name and claim (Arkana). The picker hides while the claim sheet is open, because Select reads arrow keys page-wide and the claim code field would feed it. * fix(lnurl): never offer a random name to a wallet that already holds one The rail can hold a nameless receiver while the identity owns a named address too: owned() picks the session address first, and the user can pick the nameless LNURL as primary. Settings and Receive then treated the wallet as unnamed and offered "Pick one for me". The rail now reports ownsName: the receiver's own name, or, when the receiver is nameless, any active named address in the identity's list, which it reads once. Both screens drop the random choice while it is true. Choosing a name, or claiming one with a code, stays available. * fix(i18n): spell out the claim error strings for the unused-key check Master's #1107 fails when a translation key is not referenced by a literal t('...') call. The claim error messages were reached through a code-to-key map and a dynamic t(key), so six used keys read as unused. They are now literal t() calls in a code-to-message map; the messages are unchanged. * fix(lnurl): adopt the address a failed claim's refresh binds After a claim that registered its address but failed to bind it, the background owned({ refresh: true }) bound the address, but its result was dropped. A first-time claimer stayed on the error until they left the screen. A wallet that had no address when it claimed now adopts the one the refresh finds and clears the error. A wallet that already had one keeps it (Arkana). --------- Co-authored-by: João Bordalo <bordalix@users.noreply.github.com>
Moves the wallet's lnurl integration onto the shared
@arkade-os/lnurl-clientpackage and converges it into one PR: this one now also carries what #1004 did (receive through a registered lnurl address), rebuilt onts-sdk-0.5(#936) so it lands with the SDK 0.5 send router. #1004 is closed in favour of this.Receive — two rails, one screen
QrCode.tsxintosrc/lib/receive/swapRail.tswith no behaviour change (the existing receive tests pass unedited).src/lib/receive/lnurlRail.ts): the client'sarkadeLnurlfacade in its identity form. The wallet's LNURL (lnurl1…→/lnurl/<sessionId>) encodes its wallet id, derived from the identity's signature, so it is equivalent to a lightning address with no name to pick. When the wallet owns nothing and the server allows it (sessionmode), Receive registers that LNURL on its own (idempotent server-side) — which binds the Arkade and on-chain rails and lets the server answer it offline — and the QR carries it as soon as it is derived locally, before that round-trip lands. Receive offers naming only when the user can choose their own name (a name field, "Pick one for me"); an operator-reserved name with a claim code is named from Settings. Naming happens in place: the LNURL keeps working and its history is kept. The QR's BIP21lightning=carries the LNURL.QrCode.tsxuses the lnurl rail only whenVITE_LNURL_SERVERis set, otherwise the swap rail — an unconfigured build's Receive is exactlyts-sdk-0.5's. If the configured lnurl-server can't be reached, Receive falls back to the swap rail. The server stays explicit, with no default: the session token is a bearer credential and the registered Arkade address is where the covenant pays.lightning=carry that invoice. Both ask withpaymentOption=lightningwhen the rail is advertised: the server serves a nameless receiver's session LNURL as its address, and client 0.6.0 takes the rails it advertises. Without an amount the method shows the lightning address, or the LNURL itself while nameless. If no invoice comes back, it keeps the address or LNURL and a grey line says why.arkadeandonchainoptions for that amount, and the Arkade and Bitcoin methods and the Unified QR carry the destinations it returns, so the lnurl-server tracks every payment. The wallet refuses an on-chain destination that isn't its own registered boarding address (the only one lnurl-server's onchain rail ever returns) and an Arkade one that isn't on its own operator and network. A rail the LNURL can't serve, or a refused destination, falls back to the wallet's own address, with a grey line saying why. A per-payment covenant address on the wallet's own operator can't be checked from the wallet, so that part trusts the user's lnurl-server, as every payer of the LNURL already does. Without an amount nothing changes. The same page lists every address the wallet owns — the wallet LNURL as an entry of its own, beside each lightning address — and the lnurl-server, each copyable; offers the full naming form (chosen name, claim code, "Pick one for me"); and shows and what a payer can send on each rail in sats, from its own payRequest (on mutinynet the onchain option starts at 10,000 sats; Lightning and Arkade at 330).SheetModal+ Settings-styleSelect) instead of a native<select>, whose option popup rendered light-on-white under the dark theme. A picked token shows its logo with the chain as a corner badge on the trigger, in the picker rows and in the QR centre, in place of the Arkade mark. Chain marks come from@web3icons/react(new dependency, pinned to4.1.24, MIT, no install scripts), imported per icon insrc/components/ChainLogo.tsxso only the mapped networks are bundled. The QR stage is capped by viewport height so the whole code and the quote note fit above the bottom buttons on a 375×667 phone. On a token receive, Copy copies the bare deposit address (the QR value is a payment URI a wallet's address field rejects), the copy sheet adds the exact amount, and on a phone the quote line links the payment URI so the installed wallet opens it; Share still sends the URI. Tron (bare address, no scheme) gets no link. The copy sheet follows the method on screen: unified lists its URI and the addresses inside it, a token its deposit address and amount, and Arkade, Bitcoin or Lightning their one value, which Copy copies outright without opening a one-row sheet.Send — through the router
createSendRouter'sPaymentRoutervia the client'slnurlRails: prioritylnurl-arkade,lnurl-lightningahead oflightning, every other rail unchanged. The Arkade leg is preferred when the address advertises it; a plain LUD-06/16 address (nopaymentOptions) routes throughlnurl-lightningto the wallet's ownlightningRail.requestInvoicerefuses an invoice that doesn't decode, doesn't match the requested amount, or is amountless for an amounted request — before any solver is asked.Activity
owned()+sync()) into the existing IndexedDB store and are labelled by the package'slnurlActivityResolver: rows read "Received at alice@…" (or "Received via arkade" when nameless). LNURL-pay doesn't identify the payer, so no row claims who paid.sentActivityResolver: "Sent to alice@…".verifyBatch(one request per endpoint per tick), with apollVerifyfallback; the transaction screen shows "Receiver confirmed" / "Not confirmed". Preimages are checked by the client.Published LNURL client
The wallet pins the public npm release
@arkade-os/lnurl-client@0.6.0, published from merged lnurl-server main931a635(lnurl-server #82). The downloaded registry archive matches the tested release byte-for-byte (SHA-2563a4613935e2a791cb95b52ca114493c911d45f4b4ba7a6ef6c87a7b094725f4b). The lockfile pins its registry integrity.The release retains token options, bounded SSE frames, verify-to-payment binding, receiver payRequest caching, and the unified URI Lightning-leg fix. It also replaces concrete SDK wallet/router types with small interfaces and keeps the SDK as an optional peer (
>=0.5.0-rc.0). The wallet remains on SDK0.5.0-rc.17; the client compatibility suite passed against that SDK (213 tests, types, build, and consumer smoke).The old LNURL client archive and vendor refresh instructions are removed, along with Docker's obsolete vendor copy step. Both wallet PRs #1000 and #1051 use the same published version.
The pending onchain warning is translated in English and Spanish. Missing-ID and later-manual-send claim guards remain covered by regression tests; the review response explains why they remain. The unreachable unbracketed IPv6 callback hostname entry is removed.
Worth knowing
sessionmode, the QR has no lightning route until the user names an address. A payer scanning the locally derived LNURL in the moment before its registration lands gets "no longer active"; a retry succeeds.controls.start()after its controls unmount. Under full-suite load this surfaced as an intermittent unhandled error insettings/contracts.test.tsx.VITE_LNURL_SERVER, and the recipient field now locks during Continue.?method=arkpath isn't recorded.Earlier follow-ups, now fixed
sendRouter({ swapsOptional: true })now builds that tab a router without the solver rails. Only the LNURL quote opts in; other sends keep the refusal that stops an on-chain send falling to the costlier exit. (8ad9954)arkRailcan't parse, so the send falls back to the Lightning leg instead of failing at send. (client 0.4.4)mempool.mutinynet.arkade.sh/apianswers again, so nothing is needed in the wallet.409 taken.Test plan
npm dependency refresh and review follow-up at
5dcfc0eb: frozen install, TypeScript, lint, formatting, CSP, production build, and all 1,104 unit tests passed (pre-refresh baseline: 1,102; two new warning translation regressions). Current-head CI: unit/quality/build, all four browser E2E jobs, and both Cloudflare preview builds passed. CodeRabbit skipped its incremental review after a manual request; its green check is not current-head review evidence. Formal changes-requested reviews remain uncleared.Previous integration baseline: updated onto
ts-sdk-0.5atb54b8b98in4192aebe. All eight conflicts are resolved: LNURL routing and status wording remain alongside app callbacks, driver-lock checks, and the base's animation lifecycle handling. Both sets of tests are retained.pnpm test:unit: 110 files / 1,102 tests passed; the previous head passed 106 files / 999 tests. Focused routing, callback, driver, and lifecycle tests: 9 files / 181 passed.pnpm exec tsc --noEmit, lint, format, CSP, and production build pass locally. CI passed at4192aebe: unit/quality/build job, all four browser E2E matrix jobs, and both Cloudflare preview builds. A manual CodeRabbit review was requested but rate limited; the existing formal changes-requested review has not been cleared.receive-lnurl.test.tsxpins the Lightning method: a nameless receiver's carries its LNURL; with an amount, a named and a nameless receiver's both carry the invoice their LNURL issues onpaymentOption=lightning; a callback error keeps the address and shows the reason. With the setting on, a named and a nameless receiver's Arkade and Bitcoin methods carry the LNURL'sarkade/onchaindestinations, a callback error leaves the wallet's own addresses with the reason shown, and a foreign Arkade or on-chain destination is refused the same way.Live mutinynet preview (
416ea885, built from4e06112), fresh wallet, nameless claim: the Lightning method carries the LNURL, and at 7,182 sats the invoice the LNURL issued (lntbs71820n1…). With "Route every receive through it" on, at 14,365 sats the Arkade method carries a per-payment covenant address on the wallet's own operator and Bitcoin the wallet's own boarding address, with the matching invoice in the Unified QR. At 7,182 sats, below theonchainoption's 10,000-sat minimum (Arkade and Lightning go down to 330), Bitcoin falls back to the wallet's own address with the reason shown. A real 15,562-sat payment from a funded mutinynet wallet to a per-payment covenant address showed "Payment received" on the preview within about 40 seconds, after the server swept it into the wallet's registered address (balance 0.00015562 BTC), and was sent back with no fee. On previewcd01209c(053cdba), a 14,429-sat payment to the LNURL covenant address of a wallet left open showed as "Received via arkade" with no reload; on416ea885, before the fix, the same kind of payment stayed "Received" until the app restarted. Both payments were sent back to the funding wallet.874bc3ff(wallet LNURL on first open, naming to Settings):pnpm test:unit112 files / 1,159 passed (baseline at2cb57cb8: 112 / 1,153).useLnurlRail.test.tspins the automatic registration, the LNURL being handed out before registration lands and unchanged after, and a refused registration failing soft.receive-lnurl.test.tsxpins that Receive never shows a claim code and shows no naming when the user cannot pick a name.settings/lnurl.test.tsxpins the address list and naming with a claim code, new and in place. Real browser against mutinynet, fresh wallet, no taps: the Unified QR'slightning=carriedLNURL1…decoding to/lnurl/<sessionId>, whose payRequest advertisedlightning,arkadeandonchain; Settings listed the same LNURL with the full naming form.90656f54(review fixes from CodeRabbit's full review): on an account that may not send offchain, abitcoin:<addr>?lightning=<lnurl>recipient now pays the address the screen shows instead of the LNURL quote. A newsend-lnurl-route.test.tsxcase failed before the fix (the quote'ssendwas called) and passes after. LNURL receipt notices and labels are translated (en/es).pnpm test:unit112 files / 1,160 passed.a8016aa1+322dcedd(payment URI guard): a token quote'spaymentURIis used for the QR, Share and Open in wallet only when it parses as exactly the quoted transfer (EIP-681: token contract, chain,address=deposit,uint256base units; Solana Pay: recipient deposit,spl-tokenmint, amount); otherwise the bare deposit address.lnurlTokenRails.test.tscovers ajavascript:URI, the deposit only in a memo, and another amount, chain and token; the last four passed the earlier substring check.pnpm test:unit112 files / 1,169 passed.91ef5077(contextual copy sheet):pnpm test:unit112 files / 1,164 passed. A token's sheet holds no unified or Arkade row, and an Arkade method copies its address with no sheet; both tests fail onfc2411e1. E2E receive helpers copy from the default unified method, which keeps every row.5ed6d24e+fc2411e1(badge geometry, copy/open-in-wallet): across QR sizes 21..177 the centre badge now reaches 0.954x the white clearing's radius (was ~1.14x, never over encoded modules).pnpm test:unit112 files / 1,163 passed; three newreceive-lnurl.test.tsxcases (Copy gives the deposit address then the amount; a phone gets an Open in wallet link with the EIP-681 URI; Tron gets none), the first two failing on the previous source. Checked in the browser harness at 375×667 with touch emulated: the link fits on the quote line above the buttons.30017eaf+64747b85(token picker, chain badges, QR fit):pnpm test:unit112 files / 1,160 passed (unchanged from42f911c9);receive-lnurl.test.tsxdrives the picker through the sheet. Real Receive screen rendered in Chromium with the test suite's fake LNURL server: at 375×667 the QR (251px) and quote note sit above the buttons (before: QR ran ~100px under them); at 390×844 the QR keeps 340px; USDT on Arbitrum and Tron badges checked in dark and light. A reported horizontal scroll did not reproduce at 320 or 375 wide (no element past the viewport).pnpm exec tsc --noEmit— exit 0pnpm lint(0 warnings),pnpm format:check,pnpm csp:check— exit 0docker build .(on651488a1) — exit 0; a container started withVITE_LNURL_SERVERset got it substitutedReal browser, live mutinynet (dev server with
VITE_ARK_SERVER/VITE_LNURL_SERVERset): nameless claim → LNURL in QR → server payRequest with all three rails → "Add a name" → same LNURL, both URLs resolve to the same receiversrc/test/e2e/lnurl.test.tsreplaces four specs that had been skipped since the Boltz removal. Those drove a receive flow that no longer exists. The new specs fake the LNURL endpoints in the browser, so no lnurl-server is added to the stack.alice@pay.lnurl.test2,000 sats, and the callback hands out a second wallet's real Arkade address. The spec checks the Details quote, that only the arkade option is requested, the "Sent to alice@…" row, both balances and the payee's receipt.Not covered: the Lightning leg of an LNURL send from a browser. The E2E stack has no Lightning solver card, which is also why
lnsend.test.tsskips.Summary by CodeRabbit