aboutsummaryrefslogtreecommitdiffstats
path: root/tools
Commit message (Collapse)AuthorAgeFilesLines
* Sync: stop resurrecting stale pending txs from other devices' snapshots.Adam Soltys2026-09-101-0/+37
| | | | | | | | | | | | | | | | A 5,000-sat send built on 2026-09-06 that the network never took sat in the history as a pending withdrawal for four days on every device. The local scan prunes such rows (explorer 404 / absent from address history), but the boot-time cross-device history union re-adopted it from a stale device slot's core snapshot, and the device then republished it — a phantom that no explorer knows, immortal across devices. The union now adopts a remote PENDING row only while it is under a day old (it has to prove itself to the local scan); confirmed rows still union as before, and the device's own rows are untouched. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Ark state moves to IndexedDB: raw bytes on disk, memory-fronted, write-behind.Adam Soltys2026-09-101-0/+90
| | | | | | | | | | | | | | | | | | | | | | | | | localStorage held a deep wallet's ark state as ~700KB of JSON (UTF-16 on disk), parsed on every boot and stringified on every save, and the wallet cache blob carried a second ~600KB copy. src/ark/store.js keeps the state in IndexedDB (db coinos-ark) with each coin's signed bytes as a Uint8Array, fronted by an in-memory map so loadArkState/saveArkState stay synchronous: reads return clones, writes coalesce (200ms) and flush immediately when the page hides. Existing localStorage copies migrate on first open (newest _rev wins) and are removed only after the IndexedDB write completed; no IndexedDB means localStorage exactly as before. Records keep their btc-wallet-cache:<id>:ark… keys, and a wallet wipe clears both stores via the new per-feature wipeCache hook. The ark cache extension is now syncOnly: it still rides the sync snapshot (and the Blossom blob) but the local wallet cache no longer embeds it — measured on a live wallet the cache blob went from 655KB to 10KB. Verified in the live tab: all wallets migrated, bytes raw on disk, balance and coin set unchanged, localStorage ark keys gone. tools/ark-store-test.js covers migration by _rev, clone-on-read, write-behind, wipe and fallback. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Ark: persist vtxo bytes as base64 (a third smaller than hex); hex still ↵Adam Soltys2026-09-101-11/+30
| | | | | | | | | | | | | | | | | | read, hex still on the wire. The live coins' signed bytes are most of a deep wallet's state — 560KB of hex, which localStorage then holds as UTF-16. vtxoBytesToStr/FromStr in proto.js persist base64 and read either form, _addVtxo writes base64, and pruneArkState converts hex to base64 on every save, so an existing state migrates itself the first time it saves. Measured on a live deep wallet: 904KB → 665KB (-26%), all 41 coins decoding identically. The sync wire (slimArkForSync) keeps sending hex for now: a device still on an older build would otherwise fail to decode every coin merged from a newer one. Flip it once pre-2026-09-10 builds are gone. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Ark: local state stops growing forever — spent coins shed bytes, done ↵Adam Soltys2026-09-101-0/+44
| | | | | | | | | | | | | | | | | | | actions shed hex. pruneArkState runs on every manager save: a spent coin loses its signed bytes once its spend is 30 days old and no in-flight action names it (a spent coin can never take the exit those bytes exist for), and an action done more than 7 days ago loses the output-vtxo hex it minted (the change / HTLC coins live in `vtxos` in their own right). Ids, amounts, inputIds and preimages stay for history rows and spend dedupe; failed ln-pays keep their HTLC bytes for the refund. Selective and idempotent; unit-tested. Measured on a live deep wallet the immediate saving is small (every recent spend is inside the window) — what remains is the live coins' own chains, which only a renewal shrinks. The change is a growth cap, not a diet. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Sync: oversized domains ride Blossom blobs — plus a coinos-hosted server.Adam Soltys2026-09-091-0/+188
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A wallet with deep Ark coins had a 633KB ark sync domain against the relay's 40KB budget: the publish silently failed forever, the slot stayed stale, and every boot retried. Relays can't carry it (64KB event cap) and neither can NIP-44 (64KB plaintext), so a domain over budget now goes to Blossom (nostr blob storage, BUD-01/02/06/11/12): sealed into an envelope ("CSB1" + xchacha20poly1305 under an HKDF of the wallet's NIP-44 self-key), uploaded to the configured servers, and the kind-30078 event carries only { blob: { sha256, size, servers } }. Readers resolve pointers from their own outbox copy first, else from the pointer's servers plus the configured list, hash-verified. Small domains are unchanged. The outbox uploads before it publishes (upload/delete authorizations are kind-24242 events signed at enqueue time, so delivery after logout still holds no key), keeps a local copy for recovery, and deletes superseded blobs once the replacement is acknowledged. Servers: Settings → Nostr → Sync storage (any Blossom server; a typed list is kept), else the user's BUD-03 list (kind 10063) with the coinos server as mirror, else blossom.coinos.io. blossom/server.js is that default: a Bun Blossom server that stores ONLY these envelopes (magic check → 415 for anything else), serves every blob as an octet-stream attachment with nosniff, requires signed auth for upload/list/delete, and enforces per-blob size, per-key quota (oldest evicted), rate limits, a disk cap and unused-blob expiry — it cannot serve as a file host. tools/sync-blob-test.js spawns it and covers the policy, the outbox flow and a fresh device resolving a pointer. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* tools: CDP main-thread profiler for the live app (laptop or phone over adb).Adam Soltys2026-09-092-0/+103
| | | | | | | | | | cdp-profile.js samples the page through the remote-debugging port around a reload / simulated refocus / manual action, and can serve a local dist into the real tab (service worker bypassed) to profile a fix before it ships. cdp-callers.js answers "who calls X" from the saved profile. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Fix "Cannot mix BigInt and other types" when creating an on-chain gift.Adam Soltys2026-09-071-0/+89
| | | | | | | | | _buildGiftPsbt (the v2 ephemeral-key gift builder) subtracted the funding fee — _giftFundFee returns a Number — directly from BigInt coin sums (sum - _giftFundFee(...), sum - gift - _giftFundFee(...)), which throws in JS. Any on-chain (Savings) gift creation hit it: "Split a coin first", "Lock the whole coin", and gift-all. Wrap the fee in BigInt at those sites (new fundFee() helper). Adds tools/gift-build-test.js — the on-chain gift BUILDER had no coverage, which is how this shipped. It builds a specific-amount gift, a whole-balance gift, and the split carve-out shape (all three fee branches) against a mock wallet and asserts they produce claimable v2 codes without throwing. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* Durable sync outbox: fix the fresh-device phantom "Sent" that showed a zero ↵Adam Soltys2026-09-063-0/+299
| | | | | | | | | | | | | | | | | | 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
* The wallet learns to read BOLT 12 for itself: offers and invoices decode ↵Adam Soltys2026-08-291-0/+63
| | | | | | | client-side, and every invoice the ASP fetches over onion messages is verified here — payment hash read from the raw TLVs, offer terms mirrored byte-for-byte, the issuer's schnorr signature checked against LDK's exact merkle construction (proven against a live CLN-signed fixture) — so no server can hand back an invoice whose preimage it already holds. Paste an lno1 or resolve a name whose record carries one and the existing HTLC pay flow carries it (captaind's Invoice enum takes lni strings natively); a name's owner may now publish their OWN node's offer in their DNS record, retiring the registrar's stand-in — senders fetch invoices straight from their node and no coinos service ever touches the money. The /lnquote fallback prices blinded paths by entry node + advertised payinfo Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019WboeoetMF3MTmgT7Ew8yi
* The app arrives in movements: real ESM chunks split the bundle (entry 333KB, ↵Adam Soltys2026-08-263-33/+27
| | | | | | | shared chunks alongside, 61KB of gifts/NWC/hats deferred past first paint into slots that keep their hook seniority), the lazy scripts wear IIFE coats so their names can't collide with the page's — which is what silently broke the remote-signer button — nip98 moves out of the login module so minimal builds stop hauling backup relays, and the smoke test learns the front door's new steps while the regtest stack rises from its IBD nap Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi
* The page arrives light and paints at once: index.html shrinks to 46KB with a ↵Adam Soltys2026-08-261-0/+3
| | | | | | | static boot shell baked in — brand row, and the profile frame when the path names one — while the 789KB bundle moves to a content-addressed app-<hash>.js the browser keeps (a repeat visit re-downloads kilobytes, not the app); the nostr-tools barrel import goes subpath, and the NIP-46 signer client ships as its own lazy file fetched on first bunker login Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_019eA8JRq9Wr7z2fRQk99HLi
* One record answers, and failure learns patience: a rewritten mirror disarms ↵Adam Soltys2026-08-221-3/+67
| | | | | | | stale sibling copies of its connections, the responder pays from the record whose network the invoice names (standing down when none fits), a ghost coin reconciles and retries instead of dying, and error replies wait out a grace period so a sibling's success reaches the client first Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_017Fc8SHJRvBBXgMCntjNCri
* The seed takes dice: an optional entropy field folds open on the generate ↵Adam Soltys2026-08-171-2/+2
| | | | | | | step, hashed with the device's own randomness so it can only strengthen the phrase — and the forget option now says all data, which is what it takes Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* One Log out button, one honest question: the popup offers the plain exit and ↵Adam Soltys2026-08-171-2/+7
| | | | | | | the full forget, each keeping its own warnings Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* The profile grows a second, honest exit: Log out leaves the room, Log out & ↵Adam Soltys2026-08-171-0/+13
| | | | | | | forget this device burns it down — behind the Delete-all warning, which now also sweeps the cached balances and history it always implied Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* The wallet row's delete earns a trash can instead of an ambiguous ✕, and ↵Adam Soltys2026-08-172-7/+7
| | | | | | | Clear all says Delete all — the word for what it does Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* A blank-password vault stops masquerading as a locked one: new wallets ↵Adam Soltys2026-08-171-0/+96
| | | | | | | auto-save through it again, setting a real password re-encrypts every saved wallet instead of dropping the absent ones, and the save-first lock prompt finally names the password it wants Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* The lock test learns the doors moved: logout is the front door and asks for ↵Adam Soltys2026-08-161-91/+66
| | | | | | | a password once ever (the guard is finally wired to its comment), the padlock keeps asking because it must, and the suite walks the real UI again Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* The padlock stops charging for hesitation: the password ask renders over the ↵Adam Soltys2026-08-161-0/+96
| | | | | | | open wallet and Not now means exactly that, the logo opens a passwordless vault instead of guarding it, and a seed is born as Savings alone — Spending appears when you actually set it up Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* The watcher stops hoarding what it cannot parse: one unfinalizable tx no ↵Adam Soltys2026-08-161-2/+30
| | | | | | | longer poisons the frame buffer into a 2GB firehose hoard, and exotic txs get their txid computed by hand instead of dropped Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01JtaatFfAbx91Nh3xqKVqKz
* The DOM is patched, not replaced: renders morph the live tree in place so ↵Adam Soltys2026-08-151-0/+96
| | | | | | | hover, focus, selection and scroll simply persist — with a smoke test that walks the app and proves node identity survives Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_014tE4U5nEjGTQy8PwYqamfv
* Answering while closed becomes the default: a new connection asks once, and ↵Adam Soltys2026-08-131-2/+2
| | | | | | | anywhere permission is already granted it arms itself — only an explicit Off is honored forever 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-082-0/+126
| | | | | | address to land on becomes a gift only their key can open Co-Authored-By: Claude Fable 5 <[email protected]>
* hal speaks pver 5: preimages prove their size, and fees round up as oneAdam Soltys2026-08-051-0/+34
| | | | | | | | | | 0.6.0 servers require the new hashlock clauses (OP_SIZE 32 guard) for Lightning and rounds. New policies/transitions carry the v1 scripts and type bytes; _v0 variants keep old vtxos reconstructing and exiting. The retired third-party HTLC policy is dropped (upstream took 0x08). Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01M5kFodm9azZJVTQxnkDdKe
* A wallet too small to renew warns before its coins expire; dust never diesAdam Soltys2026-08-041-0/+102
| | | | | | | | | | | | | | | | | | The warning: when the Spending balance minus round fee is under the 330-sat round minimum AND a vtxo enters the renewal window, auto-refresh can't save the coins — a wallet-screen notice (and a daily local notification, when permitted) says to receive any amount or spend before the deadline. New walletNotices feature hook renders under the balance card. The amnesty: sub-dust vtxos have no unilateral exit to protect, and regtest proves captaind keeps cosigning their spends past expiryHeight — even after the watchman claims the backing on-chain (tools/ark/expired-dust-test.js). Only hal's own receive validation stood in the way; it now exempts sub-330 vtxos from the expiry check. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015QwAtBSpKvECqp1rHLHjJk
* Ark gifts go down to 1 sat; negative amounts can't OOM the encoderAdam Soltys2026-08-041-0/+128
| | | | | | | | | | | | | | | | | | Regtest-proven (tools/ark/dust-gift-test.js): the ASP cosigns all-dust arkoor sends, so a sub-dust bearer vtxo sweeps to its claimer fine, and dust folds into rounds alongside bigger siblings. The 330-sat gift floor guarded a failure that can't happen — dropped to 1 sat. The experiment also caught a live bug: refresh() of a balance smaller than the round fee computed a negative output amount, and pbWriter's varint sign-extends negatives into an infinite loop — auto-refresh would OOM the tab of any wallet too small to renew. varint now throws on negatives and refresh() refuses cleanly, before marking coins pending, when balance minus fee is under the server's 330-sat output minimum. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_015QwAtBSpKvECqp1rHLHjJk
* One question fewer on the way in, and an 'or' that means itAdam Soltys2026-08-041-0/+3
| | | | | | | | | | | | | | | | | | | | | The sign-in screen stacked 'Connect a signer app' directly above a bunker/nsec field, which reads as two steps rather than two ways in. A divider says which it is. The wizard's second step asked whether you have a nostr account, then sent you to a screen that asks the same thing. Step one now offers both paths directly — Get started, or sign in with Nostr — and the middle step is gone, along with its strings. The welcome blurb goes too: the screen is one decision, and prose above it is something to read before you can make it. The footer sinks to the bottom of the window rather than trailing the content up the page. #app is a full-height column now; the chat card sizes itself against the viewport, so the layout test covers it at three sizes. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Offer a password the first time you lockAdam Soltys2026-08-041-0/+59
| | | | | | | | | | | | | | | | | | | | | Locking with no password locks nothing — the wallets sit on the device under an empty one and reopen with a tap. So the first deliberate lock now asks whether to set one, explaining plainly what happens if you don't. 'Not now' is a real answer, recorded, and never asked again: a wallet that nags is a wallet people stop reading. The offer rides the deliberate lock only. lock() is also where the idle timer, the last-wallet-removed path and boot-after-expiry land, and a password form appearing because you went to lunch is a different app than the one you left. Two subtleties worth naming: the offer is set after lock()'s ui reset block, which clears ui.pw and had been swallowing it; and taking the offer re-enters lock(), which now finds a password and lands on the unlock screen instead of the wallet list. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Call it 'Lock wallet', because that is what it doesAdam Soltys2026-08-031-6/+6
| | | | | | | | | | | | | | | | 'Logout' promises a return to a fresh app with nothing of yours left on it. This closes the open wallet and keeps everything saved, which is why it read as interchangeable with 'Clear all' — the one that really does send you back to the beginning. Renamed via a new key rather than rewording the old one: nineteen locale tables carry their own word for 'Logout' and t() prefers the locale's value, so reusing the key would have kept the wrong meaning in every language but English. The dead key is dropped from those files; they fall back to English until translated. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Logout shouldn't ask for a password nobody setAdam Soltys2026-08-031-0/+107
| | | | | | | | | | | | | | | | | | | | | | | Wallets save themselves to this device as soon as you open one, encrypted under an empty password unless you choose one. Logging out still sent you to 'Unlock saved wallets — enter your password', which asks for something that doesn't exist and makes logout look like it did something drastic. It didn't: logout only drops the keys from memory, and 'Clear all' is the one that deletes. With no password, logout now returns to the wallet list — honest, since nothing is protecting those wallets and a blank prompt protects nothing. With a password set it prompts exactly as before. Fixed underneath: mergeVaultList derived the comparison xpub with accountXpubFor's mainnet default, so on any other network it never matched the stored one and every unlock re-added the same wallet as a fresh entry — visible as the same wallet listed twice, once watch-only. The seed-mismatch checks in 'load recovery phrase' had the same default and would have rejected the correct seed on testnet. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* The unread dot holds stillAdam Soltys2026-08-031-0/+77
| | | | | | | | | | | | | | | It looked like the dot was flickering on and off after a refresh. It wasn't: measured over the first twelve seconds it appeared once and never went away, while its entrance animation started seven separate times. render() rebuilds the whole DOM, so the pop replayed on every repaint. A dot that means 'someone is waiting' has nothing to animate about, so the animation is gone rather than gated. tools/unread-dot-test.js counts animation starts against real appear/disappear transitions, so a future entrance effect here fails instead of shipping as a flicker. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* The chat window fits the screen, and the header stops asking questionsAdam Soltys2026-08-031-0/+133
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The message log had a fixed 62vh height, so the card was head + panels + log + composer tall and the input fell off the bottom of the screen. The card is now sized to the viewport and the log is the only part that flexes: opening the invite or member panel shrinks the scroll area instead of pushing the composer out of reach. Three header changes, all the same idea — don't show a control for a choice nobody is making: '+' becomes 'Add person', because a plus sign doesn't say what it adds. A lone channel isn't named at all. '#general' next to a composer that says 'Message #general' points at a choice of one; several channels become a select, which is what a choice looks like. The new-channel field hides behind a 'New channel' button. Sitting open right after you create a community, it reads as something you're supposed to fill in. tools/chat-layout-test.js holds the line at three viewport sizes with both panels open, and covers the header behaviour through actually adding a second channel. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* A deploy shouldn't refresh the page out from under youAdam Soltys2026-08-031-0/+95
| | | | | | | | | | | | | | | | | | | | | | The app is inlined in the page, so when a new service worker took control the page reloaded — otherwise it would keep running the old build. But navigations are network-first: the refresh that triggered the update had already fetched the new page, so the reload landed a second or two later as an unasked-for second refresh. The page and the worker now carry the same build token and the page asks which build took over, reloading only on a real mismatch. The token is hashed before substitution, since a page cannot contain a hash of itself. tools/sw-reload-test.js pins all three cases against a swappable server: a first visit doesn't reload itself, a refresh after a deploy is the only refresh, and a page left sitting open across a deploy is still reloaded — exactly once. The previous build takes two loads for one manual refresh; this one takes one. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Speak as the account you logged in with, or not at allAdam Soltys2026-08-031-0/+89
| | | | | | | | | | | | | | | | | | | | | | | | | | Logging in with a remote signer and reloading left the app showing your avatar and profile while signing everything with the wallet's own key — so chat messages arrived under a different npub with a stranger's punk on them. identity() had a fallback: when the login signer wasn't available it quietly used the wallet key instead. That fallback is gone. If you logged in as a nostr account, the app speaks as that account or it refuses and says so. Refusing is only reasonable if the signer usually comes back, so a login now persists what it takes to be that identity again. A remote signer stores its NIP-46 client key and pointer — the signer authorised that client key, not one page load, so rebuilding from it resumes the session without prompting. A pasted key stores the key, which is already the seed of everything here since the wallet's mnemonic derives from it. Resume is single-flight with a pause after failure, because identity() is asked on every send and a signer that isn't answering must not mean a relay connection per keystroke. Existing remote-signer logins predate the stored session, so they need one more sign-in to become resumable. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Only people you know get to buzz your phoneAdam Soltys2026-08-031-0/+101
| | | | | | | | | | | | | | | | | | | | | | | | | | | | A NIP-17 wrap is signed by a throwaway key with the sender sealed under two layers of nip44, so no server can filter DMs by who sent them. The notifier now ships the wrap inside the push payload and the service worker opens it locally: a message from someone you follow arrives with their name on it, a stranger's is dropped, and your own sent-copy — every DM is wrapped to the sender too — no longer notifies you about what you just typed. That needs the nostr identity key in the worker's IndexedDB, which dm-inbox.js states plainly: for a seed-derived identity it is a second copy of a key localStorage's seed already yields, and it exists only to keep strangers out. A remote signer never hands its key over, so those wallets store no secret and keep getting unfiltered notifications rather than silence. Silence is always the riskier failure here, so the rule leans the other way whenever it isn't sure: a wrap we can't open notifies, an account with no contact list published notifies (otherwise a new user's welcome message would vanish), and anyone you already have a thread with counts as known whether or not you follow them. The server-side DM cooldown drops to a second — with the device discarding pushes, a throttle there would let a discarded one swallow the message behind it. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Gift records belong to one wallet, not to the browserAdam Soltys2026-08-031-0/+79
| | | | | | | | | | | | | | | | giftRecords() caches the parsed blob on the wallet object, but the app runs a single Wallet for its lifetime and load() never dropped that cache. Switching wallets carried the old wallet's gift links into the new one, where they all rendered as claimed — none of their outpoints are live in a wallet that never issued them — and the first save wrote them back under the new wallet's key. Clear the cache on load alongside the other two gift caches, and forget records whose funding tx isn't in this wallet's own history, to clean up what earlier builds already misfiled. Co-Authored-By: Claude Opus 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Drop accidentally committed scratch testAdam Soltys2026-08-021-51/+0
| | | | | Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Offline keeps the classic wallet layoutAdam Soltys2026-08-021-0/+51
| | | | | | | | | | Offline mode forces the settings tab, and the full-page settings layout was hiding the balance card and tab strip there — walling an offline user off from their cached balance, history, and the Savings receive address. Offline now keeps balance + tabs around the settings content. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* One global account: you're in Spending or Savings everywhereAdam Soltys2026-08-021-1/+1
| | | | | | | | | | | | | | | The balance card's label becomes a Spending/Savings toggle and the selection is app-wide state (persisted per device): the headline shows the selected balance with the other quiet below, Receive shows the payment address in Spending and a fresh on-chain address in Savings (the per-page dropdown and its iconSelect widget are gone), and a gift's source follows the account instead of its own toggle. Wallets without Ark see the old single-balance card. Send stays destination-driven — an invoice pays from Spending, an address from Savings, as before. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Chat: Concord encrypted community messaging, with a default coinos roomAdam Soltys2026-08-021-0/+85
| | | | | | | | | | | | Implements the Concord protocol (CORD-01..04) — private streams, the control-plane fold, guestbook membership, owner-rooted authority — and ships a built-in "coinos" community founded by Adam's npub (owner/admin). Messages sign with the nostr login identity when a signer is live, else the wallet's NIP-06 key; all encryption rides derived stream keys so remote signers only ever sign seals. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Wvj4aoeMZwA7VUGdJP9Swi
* Fix bunker (NIP-46) login: the pointer goes through fromBunkerAdam Soltys2026-08-011-0/+19
| | | | | | | | | | | nostr-tools' BunkerSigner constructor takes (clientSecretKey, params) — passing the bunker pointer as the second argument left the signer with no pointer at all, so connecting died on "reading 'pubkey'" before any network call. Use the static fromBunker, and reject an address that lists no relays with something a human can act on. Covered by a test using a real Amethyst-issued address. Co-Authored-By: Claude Fable 5 <[email protected]>
* Restore two helpers an earlier edit deleted, and guard the class of bugAdam Soltys2026-08-011-0/+77
| | | | | | | | | | | | | | A bulk replacement removed lookupMine() and the flag along with the block it was rewriting, so names setup threw at runtime — the kind of break every existing test happily passed over because nothing ever constructed a feature and started it. tools/features-smoke-test.js does exactly that: builds every feature against a stubbed wallet with the network cut, runs init/render/stop, and fails on undefined identifiers. It caught the second missing helper immediately. Co-Authored-By: Claude Fable 5 <[email protected]>
* One wallet per Nostr identity, NIP-98 auth, npub payment addressesAdam Soltys2026-08-011-0/+17
| | | | | | | | | | | | | | | | | | | | | | | | | | Consistency fix with teeth: logging in with a pasted key derived one wallet while an extension login for the SAME identity found no backup and generated a different one. Now every signer kind runs one flow — look for the published association first (that's where the money is), then derive (key in hand) or generate (signer only) — and a derived seed is published too. That costs nothing, since the derivation is public and anyone holding the key could compute it anyway, and it makes the identity open the same wallet however you log in. Registrar auth is now NIP-98 (HTTP Auth, kind 27235) instead of a homemade envelope: the signature binds the URL, method and body hash, so a captured header can't be replayed elsewhere. NIP-78 already covered the encrypted association; NIP-26 delegation is deprecated, so owner→manager nomination lives in the registrar's own model: an npub-shaped name may only be claimed by that identity, which nominates the wallet key to keep the record updated afterwards (the wallet key is the only one always available — offline, in the worker). Payment addresses therefore default to the user's REAL npub in all three login methods, falling back to the wallet's own identity when no Nostr account is linked. Co-Authored-By: Claude Fable 5 <[email protected]>
* Log in with Nostr, and link a wallet to a Nostr accountAdam Soltys2026-08-011-0/+60
| | | | | | | | | | | | | | | | | | | | | | | | | Three ways in: a NIP-07 browser extension, a NIP-46 remote signer (Amber, Amethyst — paste its bunker:// address), or a pasted nsec/hex key that lives in memory for the session and is never stored. How an account gets a wallet depends on what we can see, and the two cases are genuinely different: * We hold the key (pasted nsec): the seed is DERIVED from it — sha256("coinos:wallet-seed:v1" | privkey) as BIP-39 entropy. The same Nostr key always opens the same wallet, on any device, with nothing stored anywhere. * We only have a signer: deriving is impossible (we never see the key, and neither signatures nor NIP-44 ciphertexts reproduce), so the seed is ASSOCIATED instead — generated once and published as a replaceable event encrypted to the user's own key, which only their signer can decrypt. Later logins find and decrypt it. The second path means the Nostr key can recover the money; the UI says so before publishing anything. Linking an already-open wallet uses the same publish path and refuses to overwrite an account's existing wallet. Co-Authored-By: Claude Fable 5 <[email protected]>
* Test follows the rebrand: the SW responder announces CoinosAdam Soltys2026-08-011-1/+1
| | | | Co-Authored-By: Claude Fable 5 <[email protected]>
* Zappable offer codes and per-name Lightning offersAdam Soltys2026-08-012-0/+138
| | | | | | | | | | | | | | | | | | | | | | | | | CLINK offers (noffer): every wallet gets a static offer keypair (synced across devices, first-minted wins) answering kind-21001 invoice requests — from open tabs and from the service worker while closed, which mints against the mirrored state (the record now carries chain-4 and chain-5 key windows) and raises an open-to-receive notification. Zap requests ride along and their kind-9735 receipts publish once the payment is claimed. The code is shown in the payment-address card for pasting into a Nostr profile; the notifier watches and relays kind 21001 alongside NWC. Payment names also gain a Lightning offer: the registrar creates a BOLT 12 offer per name on the node and appends lno= to the DNS record; a settle-forwarder pushes each received payment on to the name's ark destination from a small float wallet (custody window: seconds between Lightning settle and the ark send). Name resolution now runs full client-side DNSSEC validation (src/dnssec.js): binary DoH, root-anchor chain walk, RSA + ECDSA via WebCrypto — the resolver is untrusted transport, verified against live signed zones including the canonical BIP-353 test name. Co-Authored-By: Claude Fable 5 <[email protected]>
* Simplify: one Lightning path, one balance, fewer featuresAdam Soltys2026-08-018-772/+39
| | | | | | | | | | | | | | | | | | | | | | | | | Dropped: silent payments (feature, wallet chains, scan worker, indexer config), Boltz submarine/reverse swaps (feature + swap.js + tools), the third-party ark swap bridge (config, client flows, settings card — the ASP's own Lightning send is the one path now), and the Consolidate coins settings action. The NWC pouch is gone with them: background answering now spends the WHOLE ark balance. The IndexedDB record mirrors the real manager state plus the chain-3 keys for its live coins and a change-index window; the worker pays through the ASP exactly like the open app, and the app folds the worker's spends, change, and key allocations back in on connect (additive merge + reconcile against the server). A leftover chain-6 pouch from the short-lived pouch era is swept home automatically using the keys stored in its own record. The stated tradeoff: same-origin script access now reaches the ark balance, not a carve-out — the ark balance is walking-around money, the on-chain wallet's keys are never mirrored, and per-connection NWC budgets remain the enforced ceiling. Bundle: 726KB -> 602KB. Co-Authored-By: Claude Fable 5 <[email protected]>
* Background NWC: the worker now answers — and pays — from the pouchAdam Soltys2026-08-012-1/+141
| | | | | | | | | | | | | | | | | | | | | | | The no-tap half of background wallet-connect. The push payload carries the (encrypted, client-signed) request event; a bundled service-worker responder (src/sw-nwc.js -> nwc-respond.js) finds the matching pouch in IndexedDB, decrypts with the connection's service key, and answers get_info/get_balance/list_transactions from pouch state. pay_invoice pays through the swap bridge with an ArkManager running over the pouch's chain-6 key window (one HTLC lock, one HTTP swap), enforcing per-payment and daily budgets from the pouch's own spend log, checking the notifier's /answered endpoint so it never double-pays or shouts over an open device, and persisting the swap before handing it off so the app can refund a crashed flight. Anything it can't handle falls back to the wake-the-user notification. The notifier grows /publish (verified 23195s only, rate-limited) as the worker's reply pipe and /answered for race avoidance. The app gains pouch funding (mailbox-less arkoor self-send whose recipient bytes we keep), emptying, spend/budget reconciliation on open, and refund recovery with the pouch's own keys. Co-Authored-By: Claude Fable 5 <[email protected]>
* Split device sync into per-domain slots with bounded, dirty-only publishesAdam Soltys2026-08-011-0/+47
| | | | | | | | | | | | | | | | 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]>
* Never let a losing device's error shout over a paid zapAdam Soltys2026-08-011-0/+1
| | | | | | | | | | | 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]>