aboutsummaryrefslogtreecommitdiffstats
path: root/src/nostr.js
Commit message (Collapse)AuthorAgeFilesLines
* Feed: a wheel scroll through a loading feed no longer stutters on a laptop. ↵Adam Soltys3 days1-1/+10
| | | | | | The phone was fine (its scroll is the compositor's) but the desktop's wheel coast is driven from the main thread, so every long task froze it, and a feed that was still loading ran dozens of them: each ready post went in through its own admitFeed → holdScroll → full render (200–450 ms apiece; a Popular switch did 49 in a few seconds, one of them 1.3 s). Inside those renders the window's list getter — run on every scroll frame too — filtered every note through the mute/spam/report checks, and each check re-derived the login identity (two parses of the stored state plus a bech32 encode): 11 s of a 2-minute profile. Now ready posts are admitted together, one render per 120 ms, and while the reader is moving the page the batch waits for a quiet moment (up to 1.5 s); nostrLoginIdentity keeps its answer for 250 ms (dropped by its own saves, a signer change or another wallet's key), npubOf is memoized, modOn reads the cached messages state, the foreign-language guess is remembered per post, and the window list is reused between renders. Measured on Adam's dev tab (tools/feed-scroll-jank.js: real CDP wheel notches, chip clicks mid-load, rAF frame gaps, long tasks, a CPU profile that names each busy stretch): stalled time during a switch 1.9–2.5 s → 0–83 ms, no frame over 50 ms on the wheel path, main thread busy 33% → 17%; settled scrolling was already clean. feed-streaming's "tap reveals the oldest ready new post" fails on the previous commit too. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Messages: NIP-42 inbox relays. isolabellart's kind-10050 inbox is ↵Adam Soltys5 days1-2/+64
| | | | | | | auth.nostr1.com and a personal relay, both of which answer a REQ for their gift wraps with CLOSED "auth-required" — we never authenticated, so DMs delivered only there (anything sent from Amethyst and other 10050-only clients) never reached v3. The DM subscription on our own inbox relays now authenticates as the DM identity (subscribeAuthed: per relay, AUTH then ask again, still closable; the AUTH event is signed before nostr-tools' cached auth promise so a failing signer can't wedge the socket), sends to a peer's inbox authenticate when a relay refuses the write with auth-required, and our sent copy also goes to our own inbox relays so other clients reading that list see it. Never Amber by intent (an app trip per connection). Checked live: auth.nostr1.com accepts the handshake. tools/dm-nip42-test.js Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
* Gentler on relays: a relay that says rate-limited (CLOSED/OK/NOTICE) gets no ↵Adam Soltys7 days1-2/+89
| | | | | | | one-shot reads for 30 s, doubling to 10 min; zap/reply tallies flush at most every 2 s in one REQ (two filters) and the live #e sub swaps at most every 10 s; quoted-note lookups batch per relay over 150 ms — scrolling a 300-follow feed went from ~21 to ~8.5 REQs per 10 s on the busiest relay, primal's "too many concurrent REQs" from 19 to 0 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01PCGjEEMD23CweKrefbTDJp
* Stream ready feed posts without waiting for catch-up batchesAdam Soltys13 days1-0/+19
|
* Serve a shared snapshot for the public feedAdam Soltys14 days1-1/+1
| | | | Build the default Popular feed once on the server and let browsers verify and paint its signed events before slow media finishes. Keep custom relays, pagination, filtering, and relay fallback on their existing paths.\n\nAdd stale-while-refresh persistence, service-worker bypass, development integration, and unit/browser coverage.
* A profile shows all of someone's posts, not just the two primal still holds.Adam Soltys14 days1-2/+25
| | | | | | | | | | | The phone had written off relay.coinos.io, nos.lol and damus after a network blip, so only primal answered. Home relays are now skipped for a minute at most and forgiven on resume/online; failures while hidden or offline don't count. Profiles also ask history-keeping relays, merge answers instead of replacing the page, and re-ask on reopen. Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01VP5Ljqk7EYcm5QQAbzDwog
* Console: NWC chatter behind a debug switch; dead relays remembered longer ↵Adam Soltys2026-09-261-8/+43
| | | | | | | | | | | | | | | | | | and dropped from the pool. `coinosDebug(true)` in the console (or ?debug=1) turns the nwc: listener logs back on; they are off by default. Warnings and errors stay. The red WebSocket lines were the pool's own reconnect loop redialling relays from other people's relay lists that have been dead for weeks — the sick list only stopped NEW dials, for ten minutes, and forgot on reload. A dead relay is now dropped from the pool (no reconnect loop), skipped twice as long each time it fails (up to a day), keyed by normalized URL, and the verdict survives a reload. A relay the pool has failed to reconnect to three times counts as dead. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01VhUKyXKtsJ67QAFfb7QR9D
* Feeds: follow packs from following.space (NIP-51 kind 39089) as a feed's people.Adam Soltys2026-09-201-0/+1
| | | | | | | | | | | A feed can name packs as well as people: the pack's current members are its authors, read from the pack event itself and cached on disk, refreshed hourly when a feed using it is opened — so a curator's change reaches the feed by itself. The editor takes a pasted following.space link or naddr, or searches packs by title (NIP-50 on relay.nostr.band). parseNostrRef learned naddr. Co-Authored-By: Claude Fable 5.1 <[email protected]>
* Chat keeps its relay subscriptions alive while the app is on screen.Adam Soltys2026-09-151-0/+32
| | | | | | | | | | | | | | | | | | | | | New messages rang in Vector but sat unseen in v3 until a reload. The resume hook only rebuilds subscriptions after an absence it was told about; a socket that errors while the page stays visible (a wifi hop, a laptop lid, a phone freezing the tab without the away timer noticing, another tab clearing that timer first) is dropped from the nostr-tools pool with every subscription it carried, and nothing dials it again. A watchdog now runs whenever the app is on screen: on focus, on becoming visible, and every ten seconds between. It asks the pool whether each relay the inbox and the rooms listen on still holds a live socket (a dial hung past 20s counts as dead, and a relay that closed a subscription on us counts too) and rebuilds the lot when one is missing, dropping a stuck relay first so the rebuild dials afresh. Rebuilds retry on a growing backoff while a relay keeps refusing. tools/chat-watchdog-test.js errors every socket under an open room and checks the recovery. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01JuwQqNWtDJcxPWtEm9134z
* The feed was showing half the posts it should.Adam Soltys2026-09-121-1/+5
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Compared against a wider net over the same six hours and the same follow list — 862 people, read off this wallet: before: 165 notes from 38 people after: 327 notes from 86 people, and nothing the wider net still finds Four things were losing posts, in order of how much: THE AUTHOR CAP. feedAuthors() stopped at 500. On an 862-follow list that silently dropped 362 people, and 164 of the 166 posts the wider net found were theirs. Gone: it takes everyone now, up to a couple of thousand, with each REQ carrying at most 400 authors so no single filter is absurd. THE DEFAULTS. relay.damus.io is the second-biggest relay this follow list writes to — 249 of them — and it wasn't in our set. 232 follows publish no relay list at all and lean entirely on those defaults, so they were being asked for on two relays instead of three. THE SOCKET CAP. Ten relays covered all but 66 follows; twenty-five covered all but 26. It's 24 now — Amethyst runs near a hundred, and this is the same idea kept to a number a phone can hold. MALFORMED RELAY LISTS. One list in these follows crams three URLs into a single r tag, space-separated. It passed our "starts with wss://" check, reached the pool, threw Invalid URL — and every author covered by that relay quietly returned nothing. Each URL has to parse now. And a quote-post is no longer mistaken for a reply. NIP-10 marks a reply's e tags 'root' or 'reply'; a quote's are 'mention', and NIP-18 quotes carry a q tag. We were hiding every note with an e tag, which included the quote-posts that now render as cards. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Answer a post: like it, boost it, quote it, or stop seeing its author.Adam Soltys2026-09-121-1/+4
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Under every post now: a heart with its count, a boost with its count, and a quote. Behind an ellipsis beside reply and zap: quote, boost, copy the note's reference, mute the author, or block them. Likes and boosts come back in the SAME relay request the zap tallies already make — one round trip answers all three — and each action paints before any relay does, the way the zap chip learned to. A like is kind 7 and a boost kind 6, published to the author's own write relays as well as ours, so the person being answered actually sees it. A quote opens the composer with a nostr:nevent reference already in the text and tags the post on publish, so other clients render the embed. Taking a like back is the only withdrawal nostr has — a deletion request naming the reaction. Mute is the NIP-51 list (kind 10000): their posts leave the feed at once and the list is published, so it follows you to other clients. Block is the same list plus an unfollow, which is why it says so. And a bug the mute found, which the follow list had too: the optimistic paint was being read back as fact. Both toggles paint the answer immediately and then publish onto the newest list the relays will give them — but when the relays had nothing newer, "the newest list" WAS the optimistic paint, so the code saw the follow already present and published a list without it. Every follow was a no-op that looked like it worked. The base is now the relays' copy only when it's genuinely newer, and otherwise what we held before the paint. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Each account in the switcher wears its own face, and its own renewal warning.Adam Soltys2026-09-101-0/+13
| | | | | | | | | | | | | | | | | | | Two accounts could show the same name and avatar: the identity list read a key stamped onto the account record, and the stamp was taken during the gap in a wallet switch where the new account is already loaded and saved but the features haven't re-inited — so the PREVIOUS account's signer was still answering "who are you". The live signer now re-checks on every read that it still belongs to the open wallet, not just at init(); and the switcher reads each account's own persisted login (or its seed's nostr key) instead of the stamped mirror, so a record stamped wrong before this still lists right. The Spending renewal warning belonged to the wallet that computed it and stayed on screen after switching — the old wallet's amount and deadline over the new wallet's balance, with a 30-minute throttle keeping it there. It now resets with the ark manager, and the next connect re-decides. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Durable sync outbox: fix the fresh-device phantom "Sent" that showed a zero ↵Adam Soltys2026-09-061-8/+22
| | | | | | | | | | | | | | | | | | balance (authored by codex). Root cause of the "signed in and my balance is 0 with a huge Sent I never made": a device's snapshot carrying its change/new coins never reached the relay. The publish was a setTimeout debounce that got canceled on page-hide / logout / feature stop, so coins created by another session were never synced; a fresh device then loaded only the old (now-spent) coins, and reconcile dressed the spent inputs as a definitive "Sent" row and zeroed the balance. No funds ever moved — verified on the ASP that no such payment existed and the change coin was still spendable. Fix (codex): - src/sync-outbox.js (new): a durable outbox. Snapshots are signed synchronously at save time under the current identity (no cross-account mixing) and persisted already-encrypted to localStorage (no keys/plaintext in storage). Delivery retries with backoff and wakes on online/pageshow/visibilitychange/storage, surviving suspension, logout and relay outages. The last copy doubles as a local recovery source if an account cache is cleared. - sync.js: replace the cancel-on-stop debounced publish with the outbox; fetchAllStates also merges the outbox's own (possibly-unpublished) events so a rejected publish is still recoverable locally; forgetAll clears it. - nostr.js: factor out stateEvent() (synchronous signing) and decodeStateEvents(), shared by fetch and the outbox. - ark.js: adoptArkSnapshot() applies a synced snapshot to a LIVE connected manager without restarting payment ceremonies (a snapshot arriving while connected previously stayed invisible until reload); the pre-handshake hold-key merges instead of overwrites; and the reconcile row is reworded from a red "Sent -N" to a neutral "History incomplete / Payment details not synced" (↻) with an explainer, since a spent input proves neither an amount nor a date. - app.js: clearAll also clears the outbox (forgetAll). Tests (codex): sync-outbox-test, sync-browser-test (real Chrome: relay rejects a snapshot, browser closes, reopens and retries; a second browser recovers the balance + history without reload), sync-split-test, plus build and morph smoke — all green. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Move nip44 decryption off the main thread — the remaining boot secp256k1 ↵Adam Soltys2026-09-041-1/+27
| | | | | | | after verification moved off (chunk-60dfb4nt in profiles). The verify worker grows two ops: 'openWraps' opens community (Concord) wraps given the symmetric stream conversation key — nip44 decrypt + the seal's schnorr verify + hash checks per wrap, no secret crossing the thread boundary since stream keys are group material; 'unwrapDMs' runs the full NIP-17 unwrap (two ECDH ops + seal verify) when the wallet holds the raw identity key. Remote signers can't export their key, so their DM path stays on the main thread, exactly as before. The time-sliced main-thread queues survive as fallbacks — a dead or stalled worker only changes WHERE the crypto runs (workerCrypto resolves null and callers re-enqueue), never whether chat catches up. Bonus: subChannel derives its stream key once per channel instead of once per wrap (groupKey does hkdf + ECDH per call — real curve work on a 200-wrap backfill) Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Nostr signature verification moves off the main thread: the pool's ↵Adam Soltys2026-09-031-6/+61
| | | | | | | synchronous verifyEvent (a schnorr check per event, ~560ms on a relay backlog — the profiled carousel-stutter cause) is switched off, and every event is now gated through a Web Worker before any caller sees it. Nothing unverified is ever trusted — the worker runs the same nostr-tools verifyEvent, just on another thread, with a synchronous fallback if the worker is unavailable (standalone file://) or stalls. Bundled to dist/verify-worker.js, precached in the SW Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
* nevent and nprofile references come alive: nprofile mentions render as ↵Adam Soltys2026-09-021-0/+12
| | | | | | | clickable @names beside their npub siblings, and note/nevent references become 'view note' links that fetch the event by id (relay hints first) and open its thread right in the app — the dim truncated stubs survive only for what genuinely can't resolve Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01MrkLsNScXe9b6BviUSdmMr
* Delivery moves at the speed of the best relay, not the worst: publish ↵Adam Soltys2026-08-181-6/+13
| | | | | | | resolves on the first acceptance, DMs stop idling on the peer's inbox lookup, and the dimming departs — a sent message simply appears Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_012u4oe1KPx9faCsDBLfZYqi
* Wallets go flat: every Spending and Savings is a top-level wallet in the ↵Adam Soltys2026-08-151-2/+4
| | | | | | | switcher, named for its purpose, renamable apart — and a new wallet can carry its own seed or live at the next BIP84 account of one you already back up Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
* Profiles wear their latest notes, each one zappable; a zap with no ark ↵Adam Soltys2026-08-081-0/+36
| | | | | | address to land on becomes a gift only their key can open Co-Authored-By: Claude Fable 5 <[email protected]>
* The Nostr card owns its built-in account; the exported key opens this wallet ↵Adam Soltys2026-08-041-1/+3
| | | | | | | anywhere Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M5kFodm9azZJVTQxnkDdKe
* Fix the service-worker ReferenceError; stop dialing dead relaysAdam Soltys2026-08-031-5/+47
| | | | | | | | | | | | | | | | | | | | | | | sw.js threw "NWC_WAKE_FALLBACK is not defined" on every load: the template substitution used String.replace with a string pattern, which replaces only the FIRST match — and that was the mention inside the comment above, leaving the real placeholder literal in the output (parsed as a block plus a bare identifier). replaceAll fixes it, the comment no longer spells the token, and {{VERSION}} got the same treatment. Both build profiles now emit a service worker that parses and activates cleanly. The relay noise: a browser logs failed WebSocket connections itself and JS cannot suppress that line, so the fix is to stop making the doomed connection. nostr.js now probes each new relay once, remembers unreachable ones for ten minutes, and filters them out of every subscribe/query/publish (never down to an empty set). Publish rejections that look like connection failures mark the relay sick rather than logging a scary "refused" line. relay.damus.io — down all week — also leaves the profile relay list. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Sync just works: NIP-65 relays, no card; gift-locking leftovers goneAdam Soltys2026-08-021-22/+3
| | | | | | | | | | | | | | | The Device sync card and toggle are gone — sync is always on, riding the user's own NIP-65 (kind 10002) write relays: the login npub's first, else the wallet key's, coinos relay as the fallback and always included. A fresh wallet identity with no NIP-65 publishes one (default relay) so other clients can find its events. The npub-locked-gift remnants go with it: the per-wallet nostr address card that pitched gift-locking, the how-it-works sentence, the dead NostrSync.sendDM/sendNostrDM path, and the orphaned claim-code field. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Profiles: view pages, editable own kind 0, zap recipients in historyAdam Soltys2026-08-021-0/+1
| | | | | | | | | | | | | | | | | | | Tapping any avatar or author name (chat rooms, DM threads, the chat home) opens a profile page: avatar, name, nip05, lightning address, about, npub, with Message and Pay actions (Pay runs the npub payment cascade). Your own profile — reachable from your avatar on the chat home — gets an editor for name, about, picture, and lightning address (prefilled with your coinos address); saving merges over the newest published kind 0 so fields we don't understand survive the round trip. Zaps now remember who they went to: the ark feature keys the recipient by what the movement already records (destination address or invoice) and history rows wear a clickable avatar chip that opens the profile. The messages feature exposes showProfile/profileChip seams so any feature can do the same. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Drop npub-locked gifts; collapse Nostr login behind a buttonAdam Soltys2026-08-011-20/+1
| | | | | | | | | | | | | | Gifts are plain bearer links again: the recipient-npub field, the one-time claim code, the DM delivery, the /lg/ claim route and the NIP-07 in-place decrypt are all gone, along with the crypto that only they used. Unclaimed gifts remain reclaimable by the sender, so nothing can be stranded by the removal. The unlock screen shows a single 'Sign in with Nostr' button that reveals the login options when tapped, instead of the full card competing with create/import. Co-Authored-By: Claude Fable 5 <[email protected]>
* Split device sync into per-domain slots with bounded, dirty-only publishesAdam Soltys2026-08-011-4/+24
| | | | | | | | | | | | | | | | One monolithic snapshot per device outgrew a vanilla relay's 64KB event cap the moment real payment traffic arrived, and sync silently died. Snapshots now fragment into per-domain replaceable events (core wallet, ark, nwc — an extension claims a domain by naming it), published under a new ':x:' d-tag marker that pre-split clients don't match, so they can never mistake a fragment for a full snapshot. Only domains whose content changed republish, a refused publish retries on the next save instead of being marked done, and readers apply full-snapshot semantics only to core-carrying events. The ark projection also caps its history: spent stubs at 200, done actions at 50 — the event size is bounded for life, no special relay rules required. Co-Authored-By: Claude Fable 5 <[email protected]>
* Keep sync snapshots under relay size limits, and say when they bounceAdam Soltys2026-08-011-1/+8
| | | | | | | | | | | | Tonight's payments pushed the encrypted snapshot past strfry's 64KB maxEventSize and every publish after the first zap was silently refused — the other device just showed no history, with nothing logged anywhere. The relay's limit is raised to 256KB; client-side, movements older than the last 50 now drop their bolt11/preimage (~half a KB per ln-send) and the cap tightens to 200, and a refused snapshot publish logs the relay's reason. Co-Authored-By: Claude Fable 5 <[email protected]>
* Never let a losing device's error shout over a paid zapAdam Soltys2026-08-011-0/+3
| | | | | | | | | | | Loose leader election for pay_invoice across a user's open tabs: before handling, defer a beat and stay silent if any reply for the request already exists on the relays; and before sending an ERROR reply, re-check — a device that lost the race (mid-init, stale state) was replying 'superseded' after another device's success, and clients trusted the later error, marking paid zaps as failed. Co-Authored-By: Claude Fable 5 <[email protected]>
* Keep relay sockets alive: enable pool ping + auto-reconnectAdam Soltys2026-08-011-1/+5
| | | | | | | | | | | | A silently dropped WebSocket (proxy idle timeout, network change) used to kill every live subscription with nothing re-subscribing — device sync stopped merging and the NWC service stopped answering until the next reload. That is how a connection created on the phone stayed invisible to an open laptop tab. The pool now pings every 29s (dummy-REQ fallback in browsers) and reconnects with backoff, re-firing open subscriptions from the last event seen. Co-Authored-By: Claude Fable 5 <[email protected]>
* Surface refused relay publishes; publish NWC info once per sessionAdam Soltys2026-08-011-1/+12
| | | | | | | | | | | | | relay.coinos.io's write policy was rejecting every 23195/13194 hal sent (kinds were whitelisted to coinos server pubkeys — incompatible with hal's per-connection service keys) and the rejections temp-banned the IP. publishOn swallowed all of it, which disguised a relay-side policy problem as a client bug. The relay policy is fixed separately; here, refused publishes now log the relay and reason, and the 13194 info event goes out once per connection per session instead of on every listen() so replies keep their share of the relay's per-IP rate budget. Co-Authored-By: Claude Fable 5 <[email protected]>
* Pass a single filter to subscribeMany — arrays are rejected by relaysAdam Soltys2026-08-011-2/+6
| | | | | | | | | | nostr-tools ≥2.23 changed subscribeMany to take one filter object; the old array form now serializes as ["REQ",id,[{...}]], which every relay answers with CLOSED "provided filter is not an object". That killed the NWC request subscription (the Amethyst forever-spinner), the device-sync live subscription, and the push notifier's relay watch — all silently. Co-Authored-By: Claude Fable 5 <[email protected]>
* Instrument the silent NWC request-drop pathsAdam Soltys2026-08-011-2/+2
| | | | | | | | | Every guard in onRequest now names itself when it drops an event, and listen()/stop() log subscription generations so a later listen() killing the live sub becomes visible. subscribeOn passes extra params through so relay-initiated CLOSED reasons surface too. Co-Authored-By: Claude Fable 5 <[email protected]>
* NWC: own the relay set instead of borrowing the sync feature'sAdam Soltys2026-08-011-0/+14
| | | | | | | | | | | | | | | | | | | | | The connection URI advertised up to four relays (sync relays + profile relays) while the subscription only ever listened on the sync relay, so a client could publish its request somewhere hal wasn't and spin forever waiting for a reply. Amethyst did exactly that. NWC now has its own relay list used for all three things that must agree: the URI, the subscription, and the replies. It no longer depends on device sync being enabled, which would otherwise have made the whole wallet service a silent no-op. Also republish the kind 13194 capability event on every start — a client that can't find it just spins, and a single publish at creation time is easy for a relay to miss. The relay transport is now injectable so the protocol can be exercised without a live relay (tools/nwc-test.js). Co-Authored-By: Claude Fable 5 <[email protected]>
* Add Nostr Wallet Connect (NIP-47) — hal as a wallet serviceAdam Soltys2026-08-011-0/+13
| | | | | | | | | | | | | | | | | | | | | | | | | Other apps can connect over nostr and pay from this wallet without touching its keys, within a per-payment and daily budget the user sets, revocable at any time. hal holds no native Lightning balance, so every operation runs over Ark: pay_invoice takes the swap bridge when configured and falls back to the ASP's lightning-send, make_invoice mints on the ASP. NWC availability therefore tracks Ark availability. - kinds 13194/23194/23195, nip44_v2 and nip04, replying in whichever scheme the request used; every request gets a reply so clients never hang - each connection gets its own service keypair, so revoking one can't affect the others and none is tied to the user's nostr identity - budgets are checked BEFORE spending, not reconciled after - requests are ignored unless signed by that connection's client key, and replayed/stale events (>120s) can't re-trigger a payment - Settings card to create, show (URI + QR) and revoke connections, and it says plainly that this only works while a tab is open tools/nwc-test.js drives it as a real client: both encryption schemes, budget refusal with a genuine over-limit invoice, unsupported-method errors, and a request from an unauthorized key being ignored. Co-Authored-By: Claude Fable 5 <[email protected]>
* Add NIP-57 Lightning zaps (npub / lightning address / lnurl)Adam Soltys2026-07-301-5/+21
| | | | | | | | | | | | | | | | | | | | | | | | | | Send a Lightning zap to a nostr npub, a lightning address (name@domain), or an lnurl. New src/lnurl.js does the resolution half — target → LNURL-pay params → signed kind-9734 zap request → callback → bolt11 — and the new zaps feature (src/features/zaps.js) drives the amount/comment UI, then hands the invoice to the existing Boltz submarine-swap flow via a new swaps.startLnPay hook, so there's one tested pay/review/confirm/success path. Routing: a bolt11 is still caught by swaps; an npub still prefers an instant free Ark zap when Ark can serve it, and only falls through to a Lightning zap otherwise — ark's "no Ark address" screen also offers an explicit "Zap over Lightning" fallback (gated on the zaps feature's canLnZap). A real zap receipt is attributed only when the server advertises allowsNostr and we know the recipient's pubkey; otherwise it degrades to a plain LNURL-pay. Nostr seam gains nostrSign (finalize without publishing), nostrProfile (kind-0 with lud16/lud06), and nostrRelays. Lightning addresses are debounced in matchSendText since their regex matches mid-typing. Verified: unit tests for parse/encode/zap-request; a live resolve→signed 9734→payable mainnet bolt11 against coinos; and an in-browser DOM smoke (zap form + LNURL params over CORS + allowsNostr hint + handoff). Co-Authored-By: Claude Opus 4.8 <[email protected]>
* Slim ark state for sync so it fits the relay's 64KB event capAdam Soltys2026-07-061-3/+2
| | | | | | | | | | | | | | Cross-device ark sync silently failed: the full snapshot (deep-genesis vtxo bytes + done-action signing hex) was ~153KB and relay.coinos.io rejects events over 64KB, so no publish ever landed and other devices never saw the balance. The synced ark state is now slimmed — spent vtxos drop their signed bytes (kept only for spendable/pending, which a device might spend/exit), done board/offboard/exit actions keep just the fields history labels need (hex stripped), in-flight actions and movements beyond the last 300 are dropped. Local storage keeps the full state; only the published copy is slim. ~150KB -> well under 64KB. Co-Authored-By: Claude Fable 5 <[email protected]>
* tmp: log publish result (debug)Adam Soltys2026-07-061-2/+3
|
* Per-device sync slots: devices never share a replaceable eventAdam Soltys2026-07-061-12/+50
| | | | | | | | | | | | | | | | | | | The clobber was structural: all devices published to one replaceable event (same seed -> same nostr key -> one slot), so whoever saved last won and a device saving before it merged wiped the others' coins. Now each device publishes to its OWN d-tag (<base>:d:<deviceId>, deviceId random per browser in localStorage), and readers pull ALL of the identity's kind-30078 events — every device slot plus the legacy shared tag for migration — merging ark vtxos across all of them and applying the newest full snapshot for on-chain state. No device can overwrite another's slot, so anti-entropy is no longer load-bearing (kept as belt-and-suspenders). Live subscription drops its #d filter to see all slots; publish and the echo-guard use the per-device tag. Verified: two same-seed devices produce two distinct relay slots that coexist, B sees A's funds, both converge after a spend. Co-Authored-By: Claude Fable 5 <[email protected]>
* Sync ark funds across devicesAdam Soltys2026-07-061-0/+14
| | | | | | | | | | | | | | | | | | | | | | | | Ark vtxos lived only in per-device storage, so a second device with the same seed saw the right on-chain balance (that rides the snapshot) but none of its ark funds — board/change vtxos exist only where created. Ark state now rides the encrypted nostr snapshot as a merge-safe cache extension: additive union (bring in vtxos a device is missing) with each device's own server reconcile() as the authority for spent-ness, so a stale or wrong 'spent' on one device can never propagate and poison the others. A connect-time reconcile catches coins spent elsewhere while a device was away, and a live subscription to our own replaceable event merges another device's new coins within seconds. New seam: registerCacheExtension({mergeAlways:true}) extensions get fed snapshots that aren't newer than local state too (last-writer-wins is right for rescannable chain data, wrong for merge-style vtxo state), and nostr.subscribeStates for the live path. Verified two same-seed devices over a mini relay: B sees A's boarded balance with zero mailbox activity, spends a synced vtxo, A converges to a stable balance with the spent vtxo correctly marked. Co-Authored-By: Claude Fable 5 <[email protected]>
* NWC bridge funding (PoC): ark wallets behind a lightning facadeAdam Soltys2026-07-061-0/+8
| | | | | | | | | | | | | | | | | | | | The compatibility play for ark zaps: an NWC wallet service (coinos, eventually) receives pay_invoice from any nostr client, asks the hal wallet to cover the amount over ark (kind 23196 funding request), hal auto-pays within a user-set allowance and acks (23197), the bridge receives the vtxo in its mailbox and settles the bolt11 over lightning. Custody window is the seconds the sats are in flight. hal side: a live nostr subscription primitive (nostrSubscribe through the sync seam) and an allowance-gated funding handler that arms when ark connects. PoC config via localStorage 'btc-wallet-arknwc' ({bridgePk, budgetSat}); proper UI when the protocol settles. Proven end-to-end on regtest (tools/.arknwc-e2e.tmp.js): mini relay + bridge process (ArkManager in bun + CLN) + NIP-47 client — invoice on clb paid via cl, preimage returned over NWC, hal debited 1,000 sats over ark, zero page errors. Co-Authored-By: Claude Fable 5 <[email protected]>
* Ark zaps PoC — a NIP-61-shaped zap protocol over arkoorAdam Soltys2026-07-061-0/+17
| | | | | | | | | | | | | | | | | | | | | Lightning zaps are welded to LNURL+bolt11; nutzaps (NIP-61) proved the alternative shape, and ark fits it better than Cashu because delivery already exists (the mailbox). Draft protocol, kinds provisional: kind 10037 (replaceable) announces the wallet's ark address+network; the zap itself is a plain instant arkoor send; kind 9737 is the sender-signed receipt tagging recipient, amount, comment, and the delivered vtxo id — so the receipt is checkable against the actual payment, unlike a bare attestation. UX: paste an npub into Send and hal offers the zap (needs both the ark and sync features; npub decoding is inline bech32 so ark-only builds stay lean). The nostr layer gains generic publishEvent/fetchEvents, exposed through the sync seam as wallet.nostrPublish/nostrFetch. Proven on regtest against a mini NIP-01 relay: announce, resolve, zap 1,000 sats with a comment, instant receipt+delivery, vtxo match. Co-Authored-By: Claude Fable 5 <[email protected]>
* Per-network nostr sync + honest balance loading stateAdam Soltys2026-07-041-4/+13
| | | | | | | | | | | | One shared d-tag let a network switch apply the mainnet snapshot to a signet wallet (the flash of wrong balance) and clobber mainnet state on the relays. Each network now syncs its own replaceable event (mainnet keeps the legacy bare tag), snapshots carry netName, and a foreign-net snapshot is never applied. The balance card spins until cache, Nostr, or a scan has populated this network once — 'scanning' alone missed the cache/Nostr window after wallet.load(). Co-Authored-By: Claude Fable 5 <[email protected]>
* Send gift DMs as NIP-17 only (drop NIP-04)Adam Soltys2026-06-281-11/+7
| | | | | | | Stop also sending the legacy NIP-04 kind-4 DM — clients that support both (e.g. Amethyst) were showing the claim code twice. NIP-17 alone covers current clients. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Nostr: use SimplePool (fixes WS console errors) + send DMs as NIP-04 AND NIP-17Adam Soltys2026-06-281-121/+33
| | | | | | | | | | | | | | | | - Migrated all relay I/O (profile/inbox fetch, DM publish, state sync) to a shared SimplePool. The old code opened a relay per request and closed it manually, which raced nostr-tools' CLOSE-after-EOSE and spammed 'WebSocket is already in CLOSING or CLOSED state'. SimplePool owns the connection lifecycle, so that's gone. Also reuses connections instead of churning one per fetch. - Gift DMs now go out as BOTH NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap). Older clients (Amethyst) read NIP-04; newer ones (Jumble) only support NIP-17 and ignore NIP-04 — sending both means the claim code shows up either way. Verified: profile/inbox fetch + DM still work via the pool; the NIP-17 wrap is published and unwraps to the original message; the NIP-04 kind-4 still lands too. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Locked gifts: let NIP-07 extension users decrypt in-place, skip the codeAdam Soltys2026-06-281-7/+14
| | | | | | | | | | | | | | The gift payload is now encrypted two ways in the link: under the one-time code (the DM / manual path) AND to the recipient's nostr pubkey via an ephemeral key. When a NIP-07 browser extension (window.nostr) is present, the claim view offers 'Claim with nostr extension' — it checks the extension's pubkey matches the gift, decrypts ctKey via window.nostr.nip44.decrypt, and claims into a fresh wallet with no code. The pasted-code path remains as the fallback for everyone else. Verified: both decrypt paths recover the payload from the blob; a different extension account is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Route the gift DM to the recipient's published inbox relaysAdam Soltys2026-06-281-4/+32
| | | | | | | | | | | | Discover where a recipient reads DMs before sending the claim code: prefer their NIP-17 DM relay list (kind 10050), else their NIP-65 read relays (kind 10002), unioned with the broad fallback set (so users without a list still get it). This makes the locked-gift DM far more likely to land in the recipient's client. Verified: resolves fiatjaf's real inbox relays; a key with no list returns none and falls back to defaults. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Lock a gift to a nostr key: public link, claim code DM'd to the recipientAdam Soltys2026-06-281-0/+70
| | | | | | | | | | | | | | | | | | | | | | | | A gift can now be locked to a recipient's nostr npub. The shareable link (/lg/<blob>) carries the amount + recipient in the clear (anyone can see what it is and who it's for), but the claim payload is encrypted under a one-time code. That code is delivered as an encrypted nostr DM (NIP-04) to the recipient — so only they can read it. They paste the code to decrypt, then claim into a fresh wallet exactly like a normal bearer gift. - nostr.js: parseNostrPubkey/npubOf, encryptWithCode/decryptWithCode (random 32-byte code), fetchNostrProfile, NostrSync.sendDM (broad relays). - wallet.js: lockGift → { blob, claimCode }, previewLockedGift, nostr identity + sendNostrDM. - app.js: gift form gains an optional 'Lock to npub' field that shows the recipient's avatar + name (nostr kind-0 profile); create encrypts + DMs the code (with sent/failed status + a copyable fallback); /lg/ links open a claim view showing the gift + recipient where you paste the code; account settings shows your own npub to share. Verified: code lock/unlock round-trip, a real nostr DM published and read back from relay.damus.io, and the UI loads + renders with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Configurable Nostr sync; fully reconcile wallet state on loadAdam Soltys2026-06-241-40/+98
| | | | | | | | | | | | | | | | | | | | | Device sync: - Add a "Device sync" settings card: toggle (on by default) + editable relay list (defaults to wss://relay.coinos.io). When off, the wallet never contacts a relay. nostr.js is now multi-relay and reads an enabled/relays preference from localStorage. Reconciliation (fixes a stray "payment received" + stale history on import, especially via the Nostr/cache load path): - Always full-scan on load instead of a frontier-only refreshLive, so balance, history confirmations and spends are reconciled and the celebration baselines against everything that already exists (cache/ Nostr still shows instantly first). - Actually start the periodic full-scan safety net (_deepTimer, 2 min); it was declared but never wired up. - WS pushes now refresh an existing tx's pending→confirmed status, not only add new transactions. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Sync wallet state across devices via encrypted Nostr eventsAdam Soltys2026-06-151-0/+94
Persist wallet state (addresses, utxos, history, indices, fees) as a NIP-44 encrypted, parameterized-replaceable event (kind 30078) addressed to self on relay.coinos.io. The Nostr identity is derived from the same seed (NIP-06), so no extra secret. On load we pull the latest state from Nostr — a device with no local cache gets full state without re-scanning, and another device's updates propagate — using savedAt to keep the newest copy. localStorage stays as the instant/offline cache; state is published (debounced) on every change. (Relay note: added kind 30078 to the strfry write-policy allowlist on prod.) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>