aboutsummaryrefslogtreecommitdiffstats
Commit message (Collapse)AuthorAgeFilesLines
...
* The wake had to be a job, not a foreground service — Android says so.Adam Soltys2026-09-1211-160/+189
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Tested on a real phone, and the first attempt didn't work at all: Background started FGS: Disallowed [... code:DENIED ...] startForegroundService() not allowed due to mAllowStartForeground false Android 12 forbids starting a foreground service from a broadcast receiver. An expedited job is the sanctioned replacement — it runs promptly from the background and needs no notification of its own to apologise for existing — so WakeService is now WakeJob, and the notification is decided by what arrived rather than by whether we could wake: a payment or a message is news either way, a wallet-connect request the wallet answers by itself is not. Two more things the phone found. A job with a connectivity constraint needs ACCESS_NETWORK_STATE, or schedule() throws and nothing runs at all. And a wake with nothing to answer used to hold a WebView open for the full thirty seconds — a locked wallet, no wallet on the device, or no NWC connections all end the job immediately now. Also a bug worth naming: ctx carried `wakeDone` by shorthand, which captured the binding while it was still undefined (ctx is built before it's defined). The page could never say it had finished, so every wake ran its full timeout. It reads through a closure now, like its neighbour. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A reply can carry a picture, and the WebView build has a name you can say.Adam Soltys2026-09-124-9/+56
| | | | | | | | | | | | The reply box gets the same paperclip the post composer has: one uploader now, handing the URL to whichever draft asked for it, and the reply's own imeta tag describing what it carries. And the UnifiedPush build is graphene.apk — what people will actually call it, and short enough to read down a phone line. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A second Android build that can wake the wallet, because it holds it.Adam Soltys2026-09-1216-7/+471
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The TWA can't. Chrome renders the site, so the wallet's keys live in Chrome's storage for its own origin, and nothing outside Chrome can reach them — a UnifiedPush message could raise a notification and no more. On a phone with Google Play Services none of this matters; without it, background answering was simply unavailable. So: a second flavour, io.coinos.webview, "Coinos (UnifiedPush)". The wallet runs in a WebView this app owns, which means a push can start it off-screen and let it answer for itself. WakeService loads it with ?wake=<payload> into the same storage the visible app uses, the page subscribes and answers whatever the payload was about, then calls CoinosHost.done() so the service stops — capped at 25 seconds from the page and 30 from the service, because nothing here is allowed to sit in the background costing battery. Its foreground notification says what it's doing rather than apologising for existing. The WebView carries what the wallet actually needs: DOM storage and IndexedDB, the camera for the QR scanner (granted only once Android has granted it to us), a file chooser for attaching pictures, and links to anywhere but coinos.io handed to a real browser. The trade is named in the code and worth naming here: a separate app is a separate wallet. Its storage is its own, so signing in there is signing in again. That's why this is a second build rather than a replacement — the TWA is untouched, still the better browser for anyone whose phone has push. The shared push receiver now asks the system for whatever front door this build has, and asks a per-flavour Wake class whether the wallet can be woken at all — no flavour checks in the middle of it. Both APKs build and ship: coinos.apk as before, coinos-unifiedpush.apk beside it. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* UnifiedPush: reach a phone that has no Google in it.Adam Soltys2026-09-1211-15/+504
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Web push on Android is Firebase's, so a phone without Google Play Services — GrapheneOS, CalyxOS, any de-Googled Android — cannot be woken by a web app at all. UnifiedPush is the way that doesn't involve Google: the user runs a distributor (ntfy and friends), apps ask it for an endpoint, and whoever wants to reach the device POSTs to that endpoint. THE WRAPPER speaks the protocol directly — four broadcasts each way, so it pulls in no library; an APK this small has no business growing a Kotlin runtime for eight intents. It asks the distributor on every launch (the call is idempotent, and it's how a distributor installed later gets noticed), raises a notification when a message arrives, and acknowledges it so the distributor stops resending. THE ENDPOINT reaches the web app on the launch URL as ?up=, the same road payment intents already travel — because the distributor talks to the app and only the web app knows which pubkeys are worth watching. It's registered with the notifier in place of a browser subscription, for notifications and for NWC alike, and needs neither permission nor push service. THE NOTIFIER sends to either kind: one deliver() that POSTs the payload to a UnifiedPush endpoint, or hands it to web-push as before. Same payloads, same 404/410 pruning. The endpoint is the only secret there, so it's never logged in full. Verified: a POST to a live ntfy endpoint delivers the payload byte for byte, so the app parses exactly what the notifier sent; the APK builds, carries the receiver, and is signed with the certificate assetlinks already pins (v1.6). tools/unifiedpush-test.js drives a browser with no push service and checks the endpoint is taken off the URL, kept, and registered. Honest about the limit: a UnifiedPush message wakes the ANDROID app, not Chrome's service worker, and the wallet's keys live in the browser's storage where nothing here can reach them. So a zap request arrives as a notification you tap, and the wallet answers when it opens — one tap instead of none. Automatic answering while closed still needs a browser with a push service. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Tell a phone with no push service what's actually wrong.Adam Soltys2026-09-124-9/+129
| | | | | | | | | | | | | | | | | | | | | | | | | "Registration failed - push service error" is Chrome's own wording, and it tells nobody anything. The cause is that Chromium routes web push through Firebase: on GrapheneOS — or any Android without Google Play Services — there is no push service to register with, so the app cannot be woken while it's closed, and no amount of tapping the toggle will change that. Both places that ask for push (the chat's offer and the NWC "answer while closed" toggle) now recognise that specific failure and say: this browser has no push service; on Android that's usually a phone without Play Services; Firefox brings its own and works there; and zaps are still answered whenever coinos is open. The last part matters most — the feature isn't broken, it's narrower than they thought. The failure is remembered so the app stops retrying it on every launch, and an explicit tap on the toggle always clears that and tries again, because a phone that changed browsers deserves another go. tools/push-unavailable-test.js runs a browser whose subscribe() fails exactly as Chrome's does and reads back what the user is told. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Every answer to a post lives in one row beneath it.Adam Soltys2026-09-123-50/+96
| | | | | | | | | | | | | | | | | | | Reply and zap have come down from the header to join boost, quote and the heart, spread across the post's full width so each is a comfortable target instead of five buttons huddled in a corner. Only the ellipsis stays up top. Proper icons at a readable size, all the same weight: a speech bubble for reply (a return arrow is what a keyboard does, not what a person does), the repeat arrows for boost, quote marks, a heart, a bolt. Counts sit beside the icon they count. And the heart asks which feeling: tapping it opens the same six emoji the chat sheet offers, and the one you pick is the one the post then shows — NIP-25 takes any content, so there was never a reason to assume a heart. Tapping your own reaction still takes it back. 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-125-14/+417
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* A picture in a post opens full screen, and the paperclip is a button again.Adam Soltys2026-09-124-5/+63
| | | | | | | | | | | | | | | | | | | | The attach control was a .btn-sm holding a 16px icon, wedged between two full-height buttons — a sliver to aim at on a phone. It's a 48px square the height of the row now, with a 20px clip in it. And tapping an image opens it full screen: the image centred on a dark ground, tap anywhere (or Escape) to close. Posts crop images to 320px so a feed stays a feed, which makes a photograph a thumbnail — this is where you actually look at one. Two details in the viewer. The page underneath stops scrolling while it's open, and the scrollbar gutter the page keeps reserved stands down with it: fixed positioning is sized to the viewport WITHOUT that gutter, so an "inset: 0" backdrop leaves a pale stripe down the side of an otherwise full-screen picture. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The carousel's tap guard watches the finger, not only the scroll.Adam Soltys2026-09-111-2/+10
| | | | | | | | | | | | A tap is "the finger didn't move AND the strip didn't scroll", the same pair the mouse path has always used. Scroll position alone would have been enough for a finger (native panning moves the strip while it's down, so a real drag never looks like a tap), but only because the snap-back happens after the finger lifts — which is a timing detail to be relying on. The touchmove flag says it outright. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A tap on the balance card waited 700ms to show anything.Adam Soltys2026-09-111-1/+14
| | | | | | | | | | | | | | | | | | | | | | | | Tapping the unit tag on a phone took the best part of a second to change the number. Measured on the device: 824ms, three taps running. The same tap on a laptop was 30ms. The balance carousel freezes itself while a finger is down — renders are held and the strip is marked _skipMorph, so a background repaint can't rebuild it mid-drag and yank the scroll. Touch-end lifts the render hold but leaves the strip frozen until a scroll settles... and a TAP produces no scroll events at all. So the freeze stood until the 700ms safety net fired, and every render in between was refused by the morph — including the one the tap had just asked for. The mouse path has always had this guard (it only freezes once the pointer has moved four pixels, and unfreezes immediately on a release that never moved), which is why this never showed up on a laptop. The touch path now notices the same thing the only way it can: the strip's scroll position is recorded when the finger lands, and if it hasn't moved by the time the finger lifts, that was a tap and the strip unfreezes there and then. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Say the currency once, and stop hedging about old prices.Adam Soltys2026-09-114-33/+44
| | | | | | | | | | | | | | | | | | | | | | | | | The balance read "US$74.67 USD" — the symbol and the unit tag saying the same thing twice. Amounts now format bare, like sats and BTC do: whatever labels the number says which money it is. The currency symbol stays where a number stands alone in prose, which is the "Worth then" line and nowhere else. The '~' marking a payment older than any price we sampled is gone too. It was bookkeeping the beta doesn't need: a payment we have a price for is shown at that price, one we don't is shown at today's, and nobody has to learn a squiggle. The currency picker has moved to sit beside the language picker at the foot of the page — display preferences together, where they're findable. It spent one day under Settings → Network, where it wasn't. And a bug this feature exposed rather than caused: nine notices end in the word "sats" in every language we ship, and were being handed the display unit. "Uncooperative exit costs about US$2.03 sats" — and long before fiat, "about 0.00435397 sats" for anyone in BTC. They're given sats now, which is what they claim to be showing. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The feed opens at twenty posts and runs to the page's edge.Adam Soltys2026-09-113-3/+45
| | | | | | | | | | | | | | | | It was rendering everything it held — a hundred-odd notes to show the few you read. It now paints twenty, and reaching the bottom shows twenty more; only when the window catches up with what's cached does it go back to the relays for older posts. Posts arriving live at the top widen the window rather than pushing off the one you'd scrolled to. And the posts are the page now, not a card sitting inside one: they step out through the chat page's padding to the container's own edge, leaving one continuous list with hairlines between the posts instead of two sets of card walls. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Every payment remembers what it was worth.Adam Soltys2026-09-116-18/+290
| | | | | | | | | | | | | | | | | | | | | | | | | | | | A payment is worth what it was worth WHEN IT HAPPENED, and that number is gone by the time you look at it unless someone wrote it down. So every price the app fetches is kept as a sample — one an hour, a few months back — and a payment's fiat value is read off the sample nearest its own timestamp. The history rows and both detail pages (on-chain and Spending) pass the time the money moved; balances and forms use the live price, as they should. A payment older than any sample we hold is priced at today's number and says so with a '~', because that is a different claim and shouldn't look like the same one. The detail page spells it out: "Worth then $X · $Y now". Fiat is a third position on the unit toggle rather than a second number beside the first: sats → BTC → your money → sats. One figure, and which figure is your choice. The ring skips fiat entirely until a price has been fetched — a third position showing blanks is worse than two that work. Typing an amount with the unit on USD means dollars, priced now. The rates are coinos' own endpoint, a map of every currency it knows, so there's no third party watching what the wallet asks about; the currency picker under Settings → Network lists all of them. Changing it starts the recorded history again rather than relabelling samples taken in another currency. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The feed reads each author on the relays they actually publish to.Adam Soltys2026-09-112-19/+171
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Our set is three relays — relay.coinos.io, nos.lol, relay.primal.net — plus whatever the user's own NIP-65 names. Asking those for everyone's posts only finds the people who happen to use them; anyone else looks like they stopped posting. So the feed now reads NIP-65: relay lists are fetched in batches of a hundred, kept for a week, and the authors covered greedily — the relay carrying the most people not yet covered, repeated up to ten sockets. A feed of hundreds is a handful of connections that between them reach everyone, rather than one each. Our relays are still asked about everyone, not just the people with no list. Measured across eight busy accounts, each source holds posts the other misses: reading the author's own relays surfaced 7 to 40 notes ours had never seen (jb55 +40, verbiricha +25, pablo +23), and ours held plenty theirs didn't — fiatjaf's own three relays answered 42 where ours answered 67. The feed wants the union, so the outbox adds reach rather than replacing it. On the test set that took the feed from 83 posts to 118. Profile pages get it for free: relaysOf now reads the batch's cache first, so opening someone you follow costs no extra round trip. The test records every socket the page opens and checks the plan by what it actually dialled. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Follow people, read a feed of them, and put pictures in a post.Adam Soltys2026-09-114-34/+388
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | FOLLOWING. A profile page gains Follow / Following beside DM and Pay. The contact list is the kind-3 event, kept locally so the button is right in the first frame and refreshed from relays behind it. Publishing one is delicate: a contact list is a shared document — other clients keep relay hints in its content and petnames in its tags — so a follow re-fetches the newest list first and adds or removes exactly one p tag, preserving everything else. The button flips immediately and flips back if the publish fails. The service worker's friend filter reads the same list, so following someone also means their DMs stop being treated as a stranger's. THE FEED. Posts by the people you follow, newest first, at the top of the messages home. Replies are left out: a reply belongs to its thread, and a timeline of half-conversations reads like eavesdropping. The last screenful is kept locally so it opens with posts in it rather than a spinner, new posts arrive over a live subscription while you're looking at it, and older ones stream in as you reach the bottom — the same shape as the profile pages. A wallet that follows nobody is told so and offered the people search. MEDIA. The composer (now shared by the feed and the profile page) takes a picture or a video: it uploads when you pick it and appends the URL to the text, which is how every nostr client reads media, with a NIP-92 imeta tag published beside it for the ones that would rather be told than guess. Posts already render images and video inline, so they show up where they land. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Include mining fees in the Spending deposit previewAdam Soltys2026-09-112-9/+80
|
* Report rejected Ark sends and exclude server-rejected expired inputsAdam Soltys2026-09-113-13/+108
|
* Restore profile editor drafts across refreshesAdam Soltys2026-09-113-11/+144
|
* Offer migration only for submitted unmigrated legacy namesAdam Soltys2026-09-114-26/+161
|
* Move connected app notifications below general notification settingsAdam Soltys2026-09-111-1/+1
|
* Retry avatar thumbnail failures from the old download methodAdam Soltys2026-09-102-13/+61
|
* The thumbnail fetches the bytes instead of trusting a crossOrigin <img>.Adam Soltys2026-09-102-24/+54
| | | | | | | | | | | | | | | | | | | | | | | | | | Pinguimdarelva's avatar still flashed, and the reason was one I'd have kept guessing at: from the live browser, `new Image()` with crossOrigin set FAILS for that picture, while `fetch(url, {mode:'cors', cache:'reload'})` for the very same URL returns 200. The host allows us perfectly well. The picture sits behind a 302, and the avatar is painted as an ordinary background-image — so the bytes are already in the HTTP cache under the redirect's target as a no-CORS entry, and the crossOrigin <img> reuses that entry and fails the CORS check. The image loads, the CORS copy "errors", and the face gets written off. So the thumbnailer fetches the bytes itself and draws from a blob: no crossOrigin element, no cache collision, and an untainted canvas without depending on the element's CORS bookkeeping. With no taint left to detect, failures no longer split into hard and soft — each one just backs off, from six hours up to a fortnight, so a host that truly won't have us is asked twice a month instead of every boot. The test now serves a picture behind a redirect and loads it plainly before thumbnailing it, which is the shape that broke; it fails on the old code. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A transient failure shouldn't cost a face its thumbnail for good.Adam Soltys2026-09-102-11/+153
| | | | | | | | | | | | | | | | | | | | | | | | | Pinguimdarelva's picture is 52KB on a CDN that answers in 700ms one minute and times out the next — I measured both from this machine. The thumbnail attempt hit one of the bad minutes, and `thumbFail` was written as if the host had refused us: never retried, so that avatar went back to fetching the original on every boot. Only a tainted canvas is a real refusal (no CORS header, nothing to be done), and that is remembered for good. Everything else — a load error, or a host that won't answer inside twenty seconds — is stamped with the time and tried again in six hours. The timeout also frees the slot: three thumbnails run at a time, and a hanging image used to hold one of them indefinitely. And zap tallies are remembered between sessions. They were asked of the relays on every boot, which takes seconds, so a room full of zapped messages painted bare and filled in afterwards. The totals (never the receipts) are kept locally and painted straight away; the moment the relays answer for a message, their word replaces it — so a tally that changed, or a receipt that was deleted, still settles correctly. Opening a DM thread doesn't forget what the chat had learned, and the store is capped at 400. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A face we've never seen is drawn from the pubkey, not left blank.Adam Soltys2026-09-102-6/+31
| | | | | | | | | | | | | | | | | | | | | | | The avatar delay left on the messages page wasn't the image at all. A person with no cached profile got an empty circle until a relay answered — and the one that stayed blank longest (npub1k6f89cg, in the live cache) is never cached at all: persistProfile deliberately refuses to write a row with no name and no picture, so every boot re-asked the relays and every boot sat blank for seconds. When it finally arrived it was a punk, which is why it looked like slow art rather than a slow lookup. The punk is derived from the pubkey alone, so it can be drawn in the first frame. It's already the right answer for everyone with no picture, and for a coinos user who kept the default it IS their published picture — same formula, same file. A real photo replaces it when it lands. A name lookup in flight still gets the quiet circle: a picture is expected there, and punk art must not flash in front of it. Also: the punk rewrite now recognises the bare relative path a few older profiles carry, not just the absolute URL. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Ship the punks at the size we actually draw them.Adam Soltys2026-09-1069-12/+90
| | | | | | | | | | | | | | | | | | | | The art is 240x240 (20KB each, 1.5MB for the set) because that's what a punk published as someone's nostr picture should be — but the circles the app draws are 18 to 64px, so a new wallet with nothing cached was fetching eight times the pixels it could show, one request per face. punks-sm carries a 128px copy of each: 4KB instead of 20KB, indistinguishable at 64px. Small circles and the wizard's picker tiles load those; only the 64px profile avatar and the value the wizard publishes stay full-size. The bigger win is that a punk picture is our own art wherever it turns up. Everyone who keeps the default publishes v3.coinos.io/punks/N.webp as their nostr picture, so those faces now paint straight off disk at the size being drawn — no request, no thumbnail to make, and no broken circle when the legacy coinos.io copies 502 (which they do for a third of the set). Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Keep a thumbnail of every face, so they're on screen in the first frame.Adam Soltys2026-09-102-3/+201
| | | | | | | | | | | | | | | | | | | | | | | | | | | A profile picture is whatever its owner uploaded, and that is very often the full-size original. Measured against the faces this wallet had cached: one avatar is a 4,988,208-byte JPEG, another 3,901,966 — megabytes fetched and decoded to paint a 30px circle. That is the second of blank circles on the messages page after a refresh, and it repeats every time the upstream cache header expires. So the first time we see a picture we downscale it once (96px, webp) and keep the thumbnail beside the profile in localStorage, which is read synchronously at boot. Small avatars then paint from it alone and never ask the network for the original again; the 64px profile avatar layers them, so the thumbnail shows instantly while the full-size original loads over the top. Reading the pixels needs CORS — most avatar hosts allow it (6 of the 9 here), and the ones that don't are remembered as failures rather than retried every boot. A refresh of the profile carries the thumbnail across when the picture hasn't changed. Thumbnails live on a 180k budget, oldest face first, and Data Saver opts out of making them at all. tools/avatar-thumb-test.js watches a wallet meet a large cross-origin picture and checks the next boot fetches no picture at all. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* Don't show "Get started" to someone who is signed in.Adam Soltys2026-09-102-1/+85
| | | | | | | | | | | | | | | | | | | | | Boot decides whether a session restores, but plenty renders before it gets there — the deferred feature chunks land and repaint, an emitter ticks — and a render that early has no idea whether anyone is signed in. It painted the welcome screen anyway, so a signed-in wallet flashed the sign-in page for the ~75ms before boot restored it. Waiting for the ark store widened the gap; the race was already there. The signed-out screen is now the one screen boot won't guess at: until the session question is answered, the frame holds the boot shell (the brand row it's already showing). Every other screen is a deliberate choice — a gift claim, a locked vault, a chat — and still paints immediately. A release timer makes sure a boot path that returns early can't hold it back for good. tools/boot-signin-flash-test.js widens the race and checks every frame of four reloads; before this it caught the flash on every one. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The wallet waits for its own state before painting, so nothing jumps.Adam Soltys2026-09-104-1/+107
| | | | | | | | | | | | | | | | | | | | | | | | | | | The Spending balance and its history are read from the ark store, which is IndexedDB: synchronous to read, asynchronous to OPEN. Boot didn't wait for it, so every refresh painted a Spending wallet with nothing in it — no balance, no history — and filled it in a beat later. On a phone that second paint arrived as a change rather than an arrival: the balance counted itself up from 0 in green, wearing the animation a received payment gets. Boot now waits for the store (capped at 800ms — a store that won't open must not hold the app hostage), and the shell stays up a moment longer instead of a wrong balance going up. If the store does land past the cap, initArk retries on it — so a Spending wallet whose state arrived late also connects, which it previously didn't until something else happened to kick it — and that catch-up paint forgets the last amounts first, so it reads as state arriving rather than money. Also: the zap tally chip loses its underline. In chat the chip is a reaction chip too, and .chat-react.on still rings it; on a post row the underline just read as an artifact. tools/boot-balance-test.js pins the ordering, with IndexedDB slowed so it can't pass by luck. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The padlock answers where you tap it, including on your profile page.Adam Soltys2026-09-102-7/+88
| | | | | | | | | | | | | | | | | | | | | Feature screens (a profile, a chat thread, a search) are drawn ahead of the wallet screen — and the padlock's "Protect this device?" card was drawn BY the wallet screen. So tapping the padlock from a profile did nothing you could see: the card was waiting behind the page, and only appeared once you navigated back to the wallet. The ask now outranks feature screens (with onboarding still ahead of it), and declining leaves you exactly where you tapped, because nothing else moves. A lock that goes through does move: it drops the profile/chat/search the padlock was tapped from, so you land on the wallet wearing a closed padlock instead of reading a profile page with only a toast to say what happened. Logging out drops it too — a page belonging to the locked session had no business outliving it, or greeting whoever unlocks next. tools/padlock-ask-test.js covers the tap and the decline. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* A zap of 21 showed 42 the moment it landed.Adam Soltys2026-09-101-12/+38
| | | | | | | | | | | | | | | | | The optimistic amount is supposed to stand down when the real receipt is counted, and it did — but only by deleting itself. An ark zap publishes its own receipt, which comes back over the live subscription BEFORE the send call resolves, so the flow's success report arrived second and re-created the amount on top of the receipt already in the total. 21 paid, 42 shown. The pending entry now has a state (flying / paid / void) instead of a deletion: our receipt voids it, and a settle arriving afterwards can't resurrect it. A receipt older than the tap — the backlog the first paint fetches for a message zapped before — doesn't void anything, so zapping the same message twice still counts both. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_019mrt228Upn9nNCwGixzSLx
* The zap chip lands on the tap and pulses until the payment settles.Adam Soltys2026-09-105-14/+88
| | | | | | | | | | | | | | | | | | | A zap took a few seconds to pay and its public receipt trailed that by seconds more, so the tally chip appeared long after the tap — the tap looked ignored, and the only way to tell it had worked was the toast. Now the chip appears on the tap carrying your amount, breathing while the payment is in flight; it goes solid the moment the payment lands, and steps aside when the real receipt arrives (which is also how it avoids counting your zap twice). The ark and Lightning one-tap flows report their outcome back — a failure, or a fallback into the form, takes the chip straight off again rather than leaving it pulsing. An unreported zap gives up after 45s. A zap sent from the form reports too, so the tally shows it right away instead of waiting on a relay. Reduced-motion gets a dimmed chip, no pulse. 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-104-16/+64
| | | | | | | | | | | | | | | | | | | 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
* Zaps in chat, and a bolt + sats tally on chat messages, DMs and posts.Adam Soltys2026-09-103-8/+128
| | | | | | | | | | | | | | | | | | | | | Chat and DM message sheets grow a ⚡ Zap item (not on your own messages), riding the same one-tap flow as a post zap — Ark first, Lightning fallback, the amount remembered from the first time. A chat message is a rumor with an id like any event, so the zap e-tags that id; the public receipt reveals nothing but the id. Every post row, chat bubble and DM shows how much it's been zapped as a little amber bolt + sats chip (1.2k / 21k / 1.5M), fed by the public receipts that e-tag it: NIP-57 kind 9735 (amount from the bolt11, the zap request's amount as fallback) and coinos' own Ark receipt, kind 9737 (network-checked). Summed per id, deduped by receipt; underlined when one of ours is in it. Ids are asked once each (batched per paint, 150 per REQ), and ONE live subscription follows the ~200 ids most recently on screen so a zap landing while you watch — yours included, rechecked at 4/12/30s — shows up without a reload. The tally chip is itself a zap button on others' messages. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01URmfo8VimCNkw6Y7JndYrv
* Identity switching moves to the profile page, with faces.Adam Soltys2026-09-103-10/+86
| | | | | | | | | | | | | | | | The Accounts screen is about a seed's Spending/Savings faces — its rows say nothing about WHO each pair belongs to, so a "Sign into another account" button there was the wrong door. It's gone from there. On your own profile, "Sign into another account" now opens a switcher: every identity already signed in on this device as an avatar + name row (one per seed, wearing the nostr key its header avatar shows — the login key when there is one, else the seed's own; remembered on the account and in the durable directory by xpub, so vault-restored accounts have a face too), tap to switch with nothing logged out, plus "Sign into a new account" for the additive sign-in doors. With no other identity on the device the button goes straight to those doors. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01URmfo8VimCNkw6Y7JndYrv
* Profile page gets "Sign into another account" beside Log out.Adam Soltys2026-09-102-3/+21
| | | | | | | | | | | | The additive sign-in door lived only at the foot of the Accounts screen; the profile page — where people actually go looking for a way out of the current identity — offered just Log out. Now it carries the same button, via a shared signInAnother action on the feature ctx (clears the profile/chat state so the unlock screen's Back lands on the wallet, and the new identity doesn't open onto the old identity's profile). Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01URmfo8VimCNkw6Y7JndYrv
* Profile posts keep a draft across reloads, like DMs and channels do.Adam Soltys2026-09-101-5/+12
| | | | | | | | | | | The new-post composer held its text in a transient ui field, so the reload that reconnects a signer (or any reload) lost the post. It now rides the same persisted draft store under a 'post' key: the composer reopens with the text after a reload, posting clears it, Cancel closes the composer and keeps the text for next time. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Profile posts: a missing signer opens the reconnect screen (with the nsec ↵Adam Soltys2026-09-101-8/+15
| | | | | | | | | | | | | | | | field), not a toast. A pasted-nsec login is never stored, so a reload leaves the link without a signer. The post, reply and profile-edit paths checked for an identity by THROWING the "signer isn't connected" message, which the composer then caught and toasted — bypassing noIdToast, whose whole job is to open the reconnect screen where the nsec (or extension / bunker) can be re-entered. Those paths now go through requireIdentity(): it opens the screen (or toasts, when there's no link to reconnect) and throws a silent error the callers don't repeat. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* names: a migration grant unlocks a reserved legacy name.Adam Soltys2026-09-101-7/+10
| | | | | | | | | | | | | Migrating the coinos.io house account `coinos` swept its balance fine, then every claim bounced with "name is reserved": the reserved-word check ran before the migration grant was consulted. A reserved word that is also a real coinos.io account can only reach the registrar through a migration, whose grant is the old account's authenticated say-so — so the grant now clears the reserved check too. Reserved names without a grant stay reserved. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Onboarding: fix the crash after a name claim/migration; keep a login-owned ↵Adam Soltys2026-09-102-3/+16
| | | | | | | | | | | | | | | | | | | name through signer resume. "Something broke" right after a coinos.io migration completed: the spend step advances past the avatar ask by calling nextAfterAvatar(), which was a const declared further down the same function — a temporal-dead-zone throw for any profile that already had a picture. Now a hoisted function. Also, the claimed-name ownership check only counted the LIVE login signer. A bunker/Google login resumes seconds after load, so for that window a name owned by the login identity (every migrated coinos.io name) looked foreign, was dropped, and the wallet key's npub-shaped default took its place — the address visibly flipped from [email protected] to npub…@coinos.io. The stored login pubkey now counts as ours too. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Electrum source: answer txStatus, so the replaced/evicted-tx pruner actually ↵Adam Soltys2026-09-101-0/+16
| | | | | | | | | | | | | | | | | prunes. _pruneReplaced only drops a pending row on a definitive "not found", and the Electrum adapter had no txStatus at all — the call threw, the catch said "transient", and nothing was ever pruned on the default data source. A 5,000-sat send the network never accepted sat in History as a pending withdrawal for four days (and the cross-device union kept it fed). Fulcrum's verbose transaction.get answers both halves: bitcoind's "No such mempool or blockchain transaction" is the definitive miss (→ null), a blockhash means confirmed. Other failures still throw. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Sync: stop resurrecting stale pending txs from other devices' snapshots.Adam Soltys2026-09-102-2/+49
| | | | | | | | | | | | | | | | 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-105-27/+334
| | | | | | | | | | | | | | | | | | | | | | | | | 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-104-26/+61
| | | | | | | | | | | | | | | | | | 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-102-0/+96
| | | | | | | | | | | | | | | | | | | 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
* Phone boot: remember opened DM wraps across boots; supersede stuck ↵Adam Soltys2026-09-092-6/+33
| | | | | | | | | | | | | | | | | | | | | relay-only sync records. A remote-signer (bunker) wallet re-opened the relay's last ~400 gift wraps on EVERY boot — the seen-set lived in memory only — and each wrap costs two NIP-46 round trips with a schnorr sign+verify apiece on the client. On the Pixel that was ~4s of a 15s pegged boot. Opened (and given-up) wrap ids are now kept in the messages feature state, bounded to 1500 id prefixes, and loaded before the inbox subscription. The outbox also replaces a record whose route changed even when its digest matches: the 874KB relay-only ark record that could never publish would otherwise have stayed in front of its Blossom successor forever. Verified live: the wallet's 633KB ark domain uploaded to blossom.coinos.io and its pointer was acknowledged by relay.coinos.io on the next boot. 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-097-14/+786
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | 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
* Boot: price the Ark exit advisory by size only — no more 5s of secp256k1 ↵Adam Soltys2026-09-093-24/+61
| | | | | | | | | | | | | | | | | | | | | | | | | | | on load. The exit-depth advisory rebuilt every spendable coin's exit chain to learn its fee, and each hop's input taproot key ran a musig key aggregation (several secp256k1 multiplies on uncached points). With 27 coins of depth up to 86 (1685 hops) that was ~5.3s of solid main-thread work on every boot, laptop and phone alike — requestIdleCallback only delayed it, and the Android wrapper hits a full boot whenever the OS evicts the app, so "reopening the app" froze for seconds. A fee only needs vsizes, and a taproot output script is 34 bytes whatever key it holds: vtxoTransactions/transitionInputTxout gain a sizeOnly mode with stand-in keys, exit.js exposes exitTxVsizes (same chain checks, no EC, nothing broadcastable), and the advisory + coins page use it. Verified against the real signed chain on the live coin set: identical vsizes on all 27 coins, 9s → 26ms. Also: the sync-slot replay at boot merged the ark snapshot and rewrote ~1MB of state (plus re-adopting it into the manager) twice per boot even when the union brought nothing. Compare the serialized union to what's stored and skip the write/adopt when unchanged. Co-Authored-By: Claude Fable 5.1 <[email protected]> Claude-Session: https://claude.ai/code/session_01Nw1Dijik1tLVKRM1WkU7xE
* Add "Sign into another account" — switch identities without logging out.Adam Soltys2026-09-082-0/+11
| | | | | | | | | The only way to move between identities (e.g. Adam via a Nostr remote signer and passkeyguy via passkey) was logout → forget all data → sign in again, which wipes every account. But the machinery to hold several accounts at once already exists: enterWallet/addOrGetAccount ADD an account (they don't replace), the unlock screen carries the sign-in doors (Nostr/passkey/Google via unlockExtra) plus a fromWallet Back button, activateAccount cleanly tears down and re-inits per-account nostr-login state, and the Accounts screen already switches between saved accounts. The nostr-login handoff even saves its session AFTER activating the new account, so each identity's signer state lands under its own namespace and resumes silently (persisted passkey sk / bunker session). The missing piece was a non-destructive entry point. Added a "Sign into another account" button on the Accounts screen that opens the unlock screen in additive mode (ui.fromWallet), so signing in adds the identity alongside the current one — then switching is just tapping a row on the Accounts screen (reached via the balance card's Accounts button), no logout, no data loss. Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz
* DMs: one nostr identity per wallet (stop the phantom seed-key inbox).Adam Soltys2026-09-081-2/+20
| | | | | | | | | A wallet has two nostr keys — the seed-derived key and, when signed in, the login identity (extension/bunker/passkey/Google). Sending already used one identity, but RECEIVING/advertising used both: startDMs subscribed kind-1059 gift wraps on '#p' for both keys and fetched both keys' inbox relays, and ensureDmRelayList always published a kind-10050 inbox for the seed key even under a login. Result: a login wallet advertised the seed key as a SECOND DM inbox, so the welcomer greeted both keys (duplicate welcome) and a person's DMs split across two identities — one showed up as if from "another user". Collapse to a single DM identity: new dmSubKeys() returns just the login pubkey when signed in, else the seed key, and startDMs subscribes/fetches only that; ensureDmRelayList no longer advertises the seed key when a login identity is active. isMe() and dmDecryptors() stay tolerant of both keys so already-received messages still thread and align. Not a privacy fix — nothing ever leaked to a third party (both keys were the user's own); this removes the confusing second self-identity going forward. (Existing cached DMs to the seed key on a given device don't retro-clean; a chat re-sync/forget clears them.) Co-Authored-By: Claude Opus 4.8 <[email protected]> Claude-Session: https://claude.ai/code/session_01MGzU12QrAA4G6BYGzGVdXz