aboutsummaryrefslogtreecommitdiffstats
path: root/src
Commit message (Collapse)AuthorAgeFilesLines
...
* Ark: drop the activity list from the Settings cardAdam Soltys2026-07-032-10/+0
| | | | | | | | History is the home for Ark movements now; the Settings card keeps balance, coin count, board, refresh, and in-progress operations. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
* Ark: show Ark movements in the History tabAdam Soltys2026-07-032-6/+36
| | | | | | | | | Receives, sends, and boards interleave with on-chain transactions in one time-sorted, paginated timeline, each tagged 'Ark'. Refreshes are internal churn and stay in the Settings activity list only. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
* Ark: 'Payment received!' celebration for Ark receivesAdam Soltys2026-07-032-0/+33
| | | | | | | | | Same card and tap-to-acknowledge flow as on-chain receives; the ack persists in the ark state (shows exactly once) and a 2h recency guard keeps old payments from celebrating when a wallet is opened later. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
* Ark: wire into the wallet UIAdam Soltys2026-07-034-5/+340
| | | | | | | | | | | | | | | | | | | | - Settings: Ark server card (off by default, per-network presets incl. Second mainnet + local regtest, custom URLs) with balance, coin count, board form (on-chain -> Ark; funding output pinned to vout 0 via the new buildTx noSort option), refresh/consolidate, and recent activity. - Receive: Ark option in the address-type dropdown (QR + copy). - Send: t?ark1 addresses are recognized in the normal send form and route to an Ark confirmation (no mining fee) with a success view. - Balance card: Ark balance line under the on-chain figure. - ArkManager lifecycle: per-wallet instance created on account activation, background sync every 5s (regtest) / 30s, state persisted under the wallet cache key. Verified end-to-end in a real browser (tools/ark/ui-test.js, puppeteer against the regtest stack): receive, send, board, and refresh all green. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
* Ark: native-JS protocol client + persistent ArkManagerAdam Soltys2026-07-036-0/+1830
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Speaks Second's bark/captaind Ark protocol directly from the browser — gRPC-web over fetch with hand-rolled protobuf, no wasm, no new deps (crypto via the existing @scure/@noble stack incl. btc-signer's BIP-327 musig2 module). src/ark: - proto.js transport, ark address codec, mailbox blinding/auth, VTXO ProtocolEncoding v2 decode - send.js arkoor send: checkpoint+arkoor txs, BIP-341 sighashes, MuSig2 cosign ceremony, signed-VTXO assembly - board.js onchain -> ark: funding taproot, exit-tx cosign - refresh.js delegated round (hArk): participation, tx-chain reconstruction, hash-locked leaf cosign, forfeit swap - validate.js receive-side verification: chain must land on the vtxo point, anchor checked onchain, every transition sig verified - manager.js persistent wallet layer: storage-checkpointed step-machine actions (send/board/refresh) that survive crashes, mailbox sync with validation, multi-input consolidation refresh tools/ark: end-to-end regtest tests (receive, send, board, refresh, manager incl. crash-mid-send + resume), all passing against captaind with the bark CLI as counterparty. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01QFAcD4V284DvYZZHP7Rxxy
* Add coinos (swap.coinos.io) mainnet Boltz presetAdam Soltys2026-07-021-0/+3
| | | | | | | | | | Production Boltz service now live on cs at swap.coinos.io (dedicated mainnet CLN, chain = coinos production bc on desk over Tailscale). Add it as a selectable provider. Mainnet default stays on public Boltz Exchange until our node has channel liquidity. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01RtuRuzDQ6ffngL5wFw4f9v
* Swaps: reuse the generic 'Payment received' screen for LN receivesAdam Soltys2026-06-303-13/+42
| | | | | | | | | | | | A reverse-swap claim is an on-chain receive, so instead of a separate LN 'Received!' card (which stacked with the generic celebration and needed suppression), credit the claimed coin to the wallet the instant we broadcast the claim — new wallet.creditReceive() mirrors the instant-payment watcher path (adds the utxo + pending row, advances the receive index, emits). The generic 'Payment received!' then fires immediately with the right amount, one screen, one tap. Dropped the LN success card + the suppression logic. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Receive: don't stack the generic celebration over the Lightning 'Received!'Adam Soltys2026-06-301-2/+6
| | | | | | | | | | A reverse-swap claim is also an on-chain receive, so after the LN 'Received!' card the generic 'Payment received' celebration would fire too — making the first Done tap appear to do nothing (it just revealed the second screen). Suppress the generic celebration while the LN claim card is shown, and have its Done ack the generic one as well, so a single tap dismisses both. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Wallet: don't understate 'amount sent' for multi-input/swap-funding txsAdam Soltys2026-06-291-4/+16
| | | | | | | | | | | | | _txSummary computed a tx's spend only from inputs whose prevout address is on the scanned frontier (addrMap). A coin spent from an address not currently in that set — e.g. a fresh reverse-swap claim — was dropped from the total, so the net showed e.g. -4,768 instead of -50,794, and the scan overwrote applySentTx's correct net with it. Now: applySentTx counts every input it spent (falling back to the witness-utxo value if a coin was dropped from the utxo set) and marks the tx 'local'; the rescan preserves a local tx's net/fee instead of recomputing. Funds were always correct on-chain — this was a display-only undercount. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps: don't reuse a burned swap index (deterministic-preimage collision)Adam Soltys2026-06-292-8/+31
| | | | | | | | | | | | | | | A reverse swap's preimage is sha256(swapNode(idx).privateKey) — deterministic per index. nextSwapIndex was derived only from *saved* swaps, so an attempt that created the swap at Boltz but threw before saving (e.g. the lockup-address-mismatch bug) never advanced the index; the next try reused the same preimage and Boltz rejected it ('a swap with this preimage hash exists already'). Make nextSwapIndex a persisted, monotonic counter (reserves the index on every call, seeded from saved swaps), and have startReverse retry with the next index when Boltz reports the preimage already exists — which also recovers wallets already stuck on a burned index. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps: fix lockup address mismatch on mutinynet (missing NETS entry)Adam Soltys2026-06-291-1/+4
| | | | | | | | | | | | swapP2TR resolves the network via netFor(name) → NETS[name] || btc.NETWORK, but NETS had no mutinynet entry, so it fell back to MAINNET — Hal computed a bc1p… swap address while Boltz (bitcoinSignet) returns tb1p…, tripping 'lockup address mismatch — not paying'. Add mutinynet: btc.TEST_NETWORK (signet/testnet params, matching the wallet's own netCfg). Verified: swapP2TR now derives tb1p… for mutinynet (was bc1p…). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps: add coinos staging (mutinynet) Boltz providerAdam Soltys2026-06-291-1/+3
| | | | | | | | | Point mutinynet wallets at the new self-hosted staging Boltz backend (swap-staging.coinos.io) by default, instead of mainnet api.boltz.exchange. Backend: dedicated boltz CLN + shared mut bitcoind on cs, behind a CORS shim + cloudflared. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Send progressive disclosure, swap context in tx detail, LN amount placeholderAdam Soltys2026-06-294-48/+68
| | | | | | | | | | | | | | | | | | - Send: show only the destination input ('Address or Lightning invoice') until a valid on-chain/SP address is entered, then reveal amount + fee rate + coin selection + Review. A pasted bolt11 still auto-advances to its own confirmation, so those controls never appear for it. New wallet.isOnchainAddress() validator + a destReady() gate; recipient onInput/paste re-render only when validity flips. - History: stop listing swaps at the top. Instead, a send/receive that was part of a swap shows its Lightning context (invoice, swap fee, status, refund) on the tx detail screen via swaps.findByTxid(). Removed the dead swapListView. - Lightning receive: drop the '50000' amount placeholder — a bare number looked like a value was already entered; the min/max hint below covers it. Verified in-browser: controls hidden until a valid address, then revealed; LN receive placeholder empty; no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Copy: 'The swap provider pays the invoice once the on-chain lockup confirms'Adam Soltys2026-06-292-2/+2
| | | | Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Receive: drop the dropdown label, rename 'Address' → 'Bitcoin address'Adam Soltys2026-06-292-6/+3
| | | | | | | | The 'Receive over' label was redundant above a self-explanatory dropdown; remove it (and the now-unused i18n key). Rename the first option to 'Bitcoin address' so it's clear alongside Lightning and Silent payment. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps: say 'Swap fee' / provider-agnostic copy instead of naming BoltzAdam Soltys2026-06-292-3/+3
| | | | | | | | | The fee label and status copy shouldn't hardcode the provider, since the swap backend is configurable. 'Boltz fee' → 'Swap fee', and the pay/settle messages no longer name Boltz. (The settings card still says 'Boltz-compatible provider', which is an accurate description of the API standard.) Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Receive dropdown, drop the Swap tab, auto-detect pasted invoicesAdam Soltys2026-06-292-39/+29
| | | | | | | | | | | | | | | | | - Receive: replace the 3-button segmented control (too wide on mobile) with a compact 'Receive over' dropdown that scales to more address types. - Remove the standalone Swap tab. Paying an invoice now lives in Send; the in-flight/recent swap list folds into the top of History. Deleted the dead swapTab + doSubmarine. - Send: as soon as a valid bolt11 is pasted/typed/scanned into the recipient field, jump straight to the Lightning confirmation (submarine-swap quote with itemized cost) — the invoice's baked-in amount is used, so the amount field is skipped entirely. Wired into the recipient onInput, the paste button, and QR scan. Verified in-browser: Swap tab gone, dropdown renders, and a pasted 60k invoice auto-progresses to 'Pay over Lightning' with the Boltz fee shown, no Review tap. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Dev: proxy all regtest backends through the dev server for LAN/phone testingAdam Soltys2026-06-292-5/+16
| | | | | | | | | | | | | | | | The dev server already binds 0.0.0.0, but the app's regtest data source + SP indexer pointed at localhost, which on a phone means the phone's own localhost. Proxy them all through the dev server (same-origin), like /boltz already was: HTTP by prefix (/boltz, /esplora, /sp) and WebSocket by exact path (/electrum → Fulcrum, /sp/ws → SP indexer), with a Bun websocket relay. The regtest presets become relative paths, and a small absWsUrl() resolves '/path' WS URLs to ws(s)://<page-host>/path so they work from any device hitting the dev server. No per-device config, no CORS. Verified: /electrum relays Fulcrum 2.1.1, /esplora returns the tip height, and the desktop wallet still loads (balance 98,444) and connects through the proxy with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Send: pay a pasted Lightning invoice via an auto submarine swap (itemized cost)Adam Soltys2026-06-293-4/+87
| | | | | | | | | | | | | | | | | Paste a bolt11 into the normal Send recipient field and Review now detects it and routes through a Boltz submarine swap — no 'swap' concept surfaced. Split startSubmarine into quoteSubmarine (POST + build-but-don't-broadcast the funding tx) and fundQuotedSubmarine, so the review shows an itemized cost before committing: Amount + Boltz fee + on-chain network fee = Total. Pay funds the lockup; Boltz pays the invoice once it confirms. Verified on regtest: a 60,000-sat invoice quotes to Amount 60,000 / Boltz 362 / network 442 / Total 60,804, with a Pay button. Lightning-address (LNURL) input + removing the standalone Swap tab are the remaining pieces. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Receive: add a Lightning option (reverse swap) next to Address + Silent paymentAdam Soltys2026-06-282-35/+77
| | | | | | | | | | | | Receive now has a segmented [Address | Lightning | Silent payment] toggle. 'Lightning' takes an amount and creates a Boltz reverse-swap bolt11 to share; when it's paid, Boltz locks up on-chain and the SwapManager claims it into this wallet automatically (shown live, then 'Received!'). Gated to non-watch-only wallets (a reverse swap needs our keys). Removed the duplicate 'Receive over Lightning' card from the Swap tab. Verified in-browser on regtest: real limits (Min 50,000) load via the proxy and an invoice is created. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps: mark a submarine swap done at invoice.paid, not transaction.claimedAdam Soltys2026-06-281-1/+5
| | | | | | | | | | | | | | A submarine swap's success is invoice.paid — Boltz has paid the Lightning invoice, which is the whole point and irreversible. Boltz then claims its on-chain lockup (transaction.claim.pending → claimed), often deferred to a later batch, so the old done-set ({transaction.claimed, invoice.settled}) left Hal stuck showing 'paying / waiting for Boltz to pay' indefinitely after the payment had already gone through. Add invoice.paid + transaction.claim.pending to the done set. Verified: a live swap stuck at transaction.claim.pending flips to 'success · Invoice paid ✓' after the fix. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps(regtest dev): proxy boltz through the dev server to fix CORS 'failed ↵Adam Soltys2026-06-281-1/+1
| | | | | | | | | | | | | | | | | | to fetch' The regtest boltz API (localhost:9001) sends no CORS headers, so the browser blocked cross-origin fetches — creating a Lightning invoice failed with 'failed to fetch', and the limits GET silently fell back to defaults (min showed 25k instead of regtest's real 50k). bun E2E tests didn't hit this (no CORS enforced). dev.js now proxies /boltz/* to localhost:9001 (adding CORS + handling the OPTIONS preflight), and the 'local' boltz preset points at the same-origin /boltz path. Public providers (mainnet/testnet) already send CORS and are unaffected. Verified: GET limits via proxy returns the real 50k/40.29M with CORS; POST create-reverse succeeds; the browser swap form now shows Min 50,000. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps: gate 'Receive over Lightning' on watch-only (was throwing 'uint8array ↵Adam Soltys2026-06-281-9/+15
| | | | | | | | | | | | expected') A watch-only wallet has no private key, so swapNode().privateKey is null and startReverse's sha256(node.privateKey) threw 'uint8array expected'. The receive card wasn't gated by watchOnly (the pay card already was). Hide both for watch-only and show a prompt to re-enter the recovery phrase, since swaps need the key to derive the preimage and claim on-chain. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Swaps: bound reverse amount by the provider's published limits, not a ↵Adam Soltys2026-06-282-4/+30
| | | | | | | | | | | | | | | | | hardcoded 50k doReverse rejected anything under a hardcoded 50,000 sats — which happens to be the regtest provider's minimum, but mainnet boltz's real minimum is 25,000 (and providers/networks differ), so valid 25k-50k mainnet swaps were refused and the max was unchecked client-side. Add SwapManager.reverseLimits() (GET /v2/swap/reverse, cached per API) and use it in the swap UI: show the real min/max range under the amount field and validate against it. Reset the cached limits when the network or provider changes. Verified: regtest min/max 50,000/40,294,967; mainnet 25,000/25,000,000. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Merge branch 'master' into boltz-swapsAdam Soltys2026-06-287-290/+1613
|\ | | | | | | | | | | # Conflicts: # src/api.js # src/app.js
| * Fix Add wallet reusing the current wallet's seed instead of a new oneAdam Soltys2026-06-281-2/+2
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The create pane shows the existing draftMnemonic's backup if one is set, only falling back to the Generate-seed step when it's empty. Nothing cleared the draft after a wallet was created, and Add wallet jumped to the create tab without resetting it — so Add wallet presented the already-active wallet's seed, and 'creating' it just re-opened the same account (no new wallet). Clear draftMnemonic (+ createStep, confirm) when Add wallet starts a fresh creation, and clear it in openWallet once a seed has been used to create/enter a wallet, so each new creation generates a fresh seed. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Gifts: let recipients claim into an existing wallet, not just a new oneAdam Soltys2026-06-282-8/+82
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Opening a gift link when you already have wallet(s) now shows a 'Choose where to receive it' screen — each known wallet (full accounts this session plus the durable directory, deduped by xpub) plus 'Receive into a new wallet'. A first-timer with no wallets goes straight to a fresh wallet as before. Claiming into an existing wallet activates it non-provisionally and sweeps the gift to its receive address (watch-only is fine — re-enter the seed to spend), skipping the new-seed backup step. Both bearer and locked (NIP-17) gifts route through the same claimGift() entry. Verified in-browser: selection lists the wallet + new option + amount; claim-into-address mechanic proven on regtest. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Gifts: re-view link/QR for unclaimed gifts + mark claimed ones in historyAdam Soltys2026-06-283-10/+77
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Persist a per-account gift record (shareable link/code + amount, plus a locked gift's claim code) when a gift is created, so: - Unclaimed gifts in History get a 'Gift link' button that re-opens the QR + link (and the claim code for locked gifts), to re-share without recreating the gift. - Once a gift's coin is spent and we didn't revoke it, it shows in History as 'claimed' instead of vanishing. Revoking marks the record so a reclaim isn't mislabelled as claimed. Verified on regtest: create → outstanding=1, link stored; after the recipient claims → outstanding=0, claimed=1 with the right amount. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * SP indexer → sp.coinos.io (clearer than tweaks); tunnel on http2Adam Soltys2026-06-281-1/+1
| | | | | | | | | | | | | | | | Renamed the default mainnet SP-indexer endpoint to sp.coinos.io. Also switched the cloudflared bitcoin tunnel to http2 (QUIC was intermittently dropping edge connections → transient 502s). tweaks.coinos.io still resolves to the same indexer. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Send gift DMs as NIP-17 only (drop NIP-04)Adam Soltys2026-06-281-11/+7
| | | | | | | | | | | | | | Stop also sending the legacy NIP-04 kind-4 DM — clients that support both (e.g. Amethyst) were showing the claim code twice. NIP-17 alone covers current clients. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Nostr: use SimplePool (fixes WS console errors) + send DMs as NIP-04 AND NIP-17Adam Soltys2026-06-281-121/+33
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - Migrated all relay I/O (profile/inbox fetch, DM publish, state sync) to a shared SimplePool. The old code opened a relay per request and closed it manually, which raced nostr-tools' CLOSE-after-EOSE and spammed 'WebSocket is already in CLOSING or CLOSED state'. SimplePool owns the connection lifecycle, so that's gone. Also reuses connections instead of churning one per fetch. - Gift DMs now go out as BOTH NIP-04 (kind 4) and NIP-17 (kind 1059 gift wrap). Older clients (Amethyst) read NIP-04; newer ones (Jumble) only support NIP-17 and ignore NIP-04 — sending both means the claim code shows up either way. Verified: profile/inbox fetch + DM still work via the pool; the NIP-17 wrap is published and unwraps to the original message; the NIP-04 kind-4 still lands too. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Remove the 'reusable address that can't be tracked' note from the SP receive ↵Adam Soltys2026-06-281-2/+1
| | | | | | | | | | | | card Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Consolidate coins: a Settings action + a gift-time recommendationAdam Soltys2026-06-283-11/+76
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - Settings gains a 'Consolidate coins' card (shown when you hold >1 spendable coin): wallet.consolidate() does a max self-send merging them into one, so future sends/gifts use a single input — cheaper and a smaller gift QR. - At gift creation, detect when the gift would bundle several coins (giftCoinSummary returns the input count) and trigger the split/consolidate offer when count ≥ 3 (not just when it frees change). The offer now explains it plainly ('would bundle N coins, costly to claim') and recommends splitting into one coin first, with 'Use N coins anyway' as the explicit opt-out. Verified on regtest: 6 small coins → a 1000-sat gift needs 3 inputs and triggers the offer; consolidate merges them to 1 coin, after which the same gift is a clean single input. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Fix gift creation silently failing on large (many-input) gifts; one Back buttonAdam Soltys2026-06-282-3/+10
| | | | | | | | | | | | | | | | | | | | | | - A many-input gift produces a URL too long for a QR, and qrSvg threw — uncaught, inside render() — so the click did nothing and showed no error. Wrap the gift QR in try/catch: render the QR when it fits, otherwise fall back to a 'too large for a QR — use the link' note. The link itself still works. - The split-offer card has its own Back; hide the gift sub-view's outer Back while the split offer is showing, so there aren't two stacked Back buttons. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Locked gifts: let NIP-07 extension users decrypt in-place, skip the codeAdam Soltys2026-06-284-13/+45
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The gift payload is now encrypted two ways in the link: under the one-time code (the DM / manual path) AND to the recipient's nostr pubkey via an ephemeral key. When a NIP-07 browser extension (window.nostr) is present, the claim view offers 'Claim with nostr extension' — it checks the extension's pubkey matches the gift, decrypts ctKey via window.nostr.nip44.decrypt, and claims into a fresh wallet with no code. The pasted-code path remains as the fallback for everyone else. Verified: both decrypt paths recover the payload from the blob; a different extension account is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Route the gift DM to the recipient's published inbox relaysAdam Soltys2026-06-281-4/+32
| | | | | | | | | | | | | | | | | | | | | | | | Discover where a recipient reads DMs before sending the claim code: prefer their NIP-17 DM relay list (kind 10050), else their NIP-65 read relays (kind 10002), unioned with the broad fallback set (so users without a list still get it). This makes the locked-gift DM far more likely to land in the recipient's client. Verified: resolves fiatjaf's real inbox relays; a key with no list returns none and falls back to defaults. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Lock a gift to a nostr key: public link, claim code DM'd to the recipientAdam Soltys2026-06-284-13/+251
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A gift can now be locked to a recipient's nostr npub. The shareable link (/lg/<blob>) carries the amount + recipient in the clear (anyone can see what it is and who it's for), but the claim payload is encrypted under a one-time code. That code is delivered as an encrypted nostr DM (NIP-04) to the recipient — so only they can read it. They paste the code to decrypt, then claim into a fresh wallet exactly like a normal bearer gift. - nostr.js: parseNostrPubkey/npubOf, encryptWithCode/decryptWithCode (random 32-byte code), fetchNostrProfile, NostrSync.sendDM (broad relays). - wallet.js: lockGift → { blob, claimCode }, previewLockedGift, nostr identity + sendNostrDM. - app.js: gift form gains an optional 'Lock to npub' field that shows the recipient's avatar + name (nostr kind-0 profile); create encrypts + DMs the code (with sent/failed status + a copyable fallback); /lg/ links open a claim view showing the gift + recipient where you paste the code; account settings shows your own npub to share. Verified: code lock/unlock round-trip, a real nostr DM published and read back from relay.damus.io, and the UI loads + renders with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Source lists: show only the URL, protocol strippedAdam Soltys2026-06-281-2/+2
| | | | | | | | | | | | | | | | Drop the human-readable name and the scheme prefix in the data-source and SP-indexer dropdowns, e.g. 'electrum.coinos.io', 'mempool.space/api', 'tweaks.coinos.io'. 'Custom' stays bare. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Show full URLs in the source lists; SP indexer → tweaks.coinos.ioAdam Soltys2026-06-282-3/+3
| | | | | | | | | | | | | | | | | | | | | | | | - Data-source and SP-indexer dropdowns now show the endpoint URL alongside the name (e.g. 'coinos — wss://electrum.coinos.io'), so it's clear where data comes from; 'Custom' stays bare. - The tweak indexer is now served at https://tweaks.coinos.io (was halwallet.app/ sp): a cloudflared route on the bitcoin tunnel → the indexer at :8888, with a DNS CNAME. Verified from a browser: /height, /mempool (1064 items), and /ws all work (the coinos.io Bot Fight Mode challenges non-browser clients only). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Unify data sources into one list; coinos → electrum.coinos.ioAdam Soltys2026-06-283-106/+111
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - Combined the separate Electrum and Explorer settings into a single data-source list. Each preset knows its type (electrum vs esplora); a custom source's type is detected from its URL scheme (ws/wss → Electrum, http/https → Esplora). The settings UI is now one dropdown + a URL field for Custom, with a one-line note showing the detected/selected type. getBackend/electrumCandidates/resolveHosts derive from the selected source. Old split settings migrate automatically. - coinos now points at wss://electrum.coinos.io (was wss://halwallet.app/electrum). Set up as a cloudflared route on the bitcoin tunnel → the Fulcrum WS (:50003), with a DNS CNAME. Verified the endpoint serves Electrum from a browser (Fulcrum 2.1.1 replies; cloudflare Bot Fight Mode only challenges non-browser clients). Verified: combined list + auto-detection (unit + browser), old-settings migration, regtest data flow unchanged, and the app loads over electrum.coinos.io with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Spend SP coins like any coin — confirmed-only, and able to fund SP paymentsAdam Soltys2026-06-282-14/+29
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two things: - Only confirmed SP coins enter the spend pool. Unconfirmed ones are excluded (the sender's tx could be replaced, invalidating our spend) — matching the spendable headline, which already counted confirmed-only. - SP→SP sending: our own silent-payment coins can now fund a payment TO an sp1 address (previously excluded). _applySilentPayments includes SP inputs with their key d = spend_priv + t_k, and silentPaymentScripts negates a taproot input's key when it gives odd Y, per BIP-352 (so the sender's A = a·G matches what the indexer reconstructs). signing was already SP-aware. Validated: BIP-352 sending vectors pass for all clean key-path-taproot-input cases (the remaining vector failures are NUMS script-path / labels / uncompressed keys — input types the wallet never spends). End-to-end on regtest: an SP coin funds a payment to another sp address, testmempoolaccept passes, and the recipient scans and finds it. Note: silentPaymentScripts derives input_hash from the eligible inputs it's given, which is correct because the wallet only ever spends all-eligible coins (P2WPKH + key-path SP); it isn't a general-purpose sender for txs with ineligible inputs. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Stop input/select fields losing focusAdam Soltys2026-06-282-4/+36
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Two causes, both fixed: - _scanSpMempool emitted on every WS push even when our pending set was unchanged. The mainnet mempool churns constantly, so this re-rendered the whole app every few seconds, stealing focus mid-type. Now it only emits when our pending SP set actually changes. - render() rebuilds the DOM (replaceChildren), which inherently drops focus. Added focus + caret preservation: capture the focused field's index-path and selection before the rebuild, restore them after. So any render source (poll, payment push, scan) is now invisible to someone typing. Verified via CDP: typing 'bc1q…' in the Send address, caret at 6, then forcing a re-render — focus, value, and caret all retained. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Run silent-payment scanning in a Web WorkerAdam Soltys2026-06-282-39/+152
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | The EC crypto (candidate derivation + full scan) now runs in a worker, off the main thread, so even a large mainnet scan can't jank the UI at all. The worker is bundled separately and embedded as base64 (single-file app), spawned from a Blob URL; it holds the keys + per-tweak cache. If workers are unavailable or the bundle is missing, scanning falls back to the main thread (still cached + yielding). scanSilentPayments / _scanSpItems / _scanSpMempool now go through _spHitBlocks and _spScanItems, which dispatch to the worker (or the fallback). +27KB to index.html for the worker's crypto bundle. Verified via CDP: worker runs keys/candidates/scan correctly in-browser; a real reload + mainnet scan threw 0 exceptions and the SP address renders. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Scan silent payments from each wallet's birth, not the indexer's startAdam Soltys2026-06-282-7/+18
| | | | | | | | | | | | | | | | | | | | | | | | | | A freshly-generated wallet can't have received SP before it existed, so set its scan watermark to the current tip on first connect (skipping the whole historical catch-up) instead of scanning from 0. Threaded a 'generated' flag through the create flow (openWallet → enterWallet → activateAccount → load spFresh); imported wallets still scan from the indexer start since they may have prior receipts. Verified on regtest: a fresh wallet skips the block-5780 receipt (watermark=tip, balance 0); the same seed imported finds it. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Fix SP scanning jank: memoize by tweak + yield + coalesceAdam Soltys2026-06-271-21/+73
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Profiling (CDP) showed the toggle lag/high CPU was _scanSpMempool re-scanning the ENTIRE mempool item set on every WS push (the mempool churns constantly), redoing an EC point-multiplication per tweak on the main thread — ~1s blocks. - Memoize silentPaymentScan/silentPaymentCandidate by tweak (outcome is fixed), cleared when the SP keys change, capped to bound memory. Steady-state CPU from re-scanning dropped from ~1s/push to ~0 (measured 18ms over 9s idle). - Yield to the event loop during the catch-up block loop and the mempool scan, so a large first/cold scan (mainnet) doesn't freeze the UI. - Coalesce mempool scans (latest-wins, never two at once) with a race guard so a block confirming mid-scan can't duplicate an outpoint. Measured after: receive toggle 8/8 switches, avg 5ms / max 9ms (was 1-2s). Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Wire in-app back buttons to pop browser historyAdam Soltys2026-06-271-6/+15
| | | | | | | | | | | | | | | | | | | | | | | | | | | | The screen-changing back buttons (tx detail, gift sub-view, all-gifts, bump, account settings, accounts) now call goBack(), which does history.back() when there's an entry to pop — so they no longer push a duplicate entry. Falls back to an explicit parent-screen navigation when there's no in-app history (e.g. reloaded straight into a sub-screen). Modal-dismiss buttons (revoke, rename, password, draft) are unchanged — they don't touch nav state. Verified via CDP: detail (i=2) → in-app back → i=1 (popped) → Forward restores the detail. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Browser history / back-button navigationAdam Soltys2026-06-271-0/+50
| | | | | | | | | | | | | | | | | | | | | | | | | | | | Mirror the app's screen position (screen, tab, txDetail, bump, giftsAll, giftMode, claimStep) into the browser history, so Back/Forward (and Android system back) move between screens actually viewed. render() snapshots just these nav fields and pushState()s on change — incidental re-renders (typing, polling) don't add entries. popstate restores the snapshot and re-renders. History-API calls are guarded so they can never break a render. Verified via CDP: tab nav and tx-detail push entries; Back/Forward (incl. A→B→A) restore the right screen and the DOM follows. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Default mainnet to the hosted coinos silent-payment indexerAdam Soltys2026-06-271-2/+2
| | | | | | | | | | | | | | Mainnet wallets now use https://halwallet.app/sp (our indexer on cs, behind nginx /sp), enabling silent-payment receiving on mainnet. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Make silent-payment receives first-class in the UIAdam Soltys2026-06-273-56/+65
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | - One Receive card with an Address ⇄ Silent payment toggle (no separate card, no BIP-352 badge, no scan-status line or Rescan button — discovery is automatic). - SP receipts appear in History like normal payments: wallet.history merges BIP84 txs with per-tx SP receive rows (spTxRows), newest first; the detail view and pagination use it. SP outputs carry a firstSeen timestamp. - Receive description simplified to 'A reusable address that can't be tracked.' - Removed the '· Silent payment' tag from the Send recipient line. Verified on regtest: a received SP shows in history and balance with no manual scan. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
| * Catch-up scaling: dust filter + per-block Bloom filtersAdam Soltys2026-06-272-13/+67
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | silentpay.js: silentPaymentCandidate() (our P_0 for a tweak) + buildBloom/bloomHas (double-hashed sha256, ~0.8% FP). Validated: candidate == P_0 on all no-label vectors. Indexer /scan now returns, per block, the tweaks + a Bloom over that block's taproot output keys (no outputs), with an optional dustLimit. The wallet derives a candidate per tweak, tests the filter, and only fetches /block on a hit — so a big historical scan costs candidate math + a handful of fetches instead of downloading and scanning every block. Live WS pushes still carry full items. SP_DUST_LIMIT defaults to 0 (receive everything; the indexer honors any value). Verified end-to-end on regtest: the filter flow finds the output. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>