| Commit message (Collapse) | Author | Age | Files | Lines |
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
- 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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
- 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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
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]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Indexer: scan the mempool (getrawmempool + getrawtransaction; prevouts fetched
explicitly since verbosity-2 omits them for mempool txs), track SP-eligible
unconfirmed txs, serve /mempool, and push {type:'mempool'} on change. Switched
Core RPC to node:http (no keep-alive) + serialized calls — Bun's fetch was
dropping sockets under the added mempool load.
Wallet: handle the mempool push and /mempool catch-up (_scanSpMempool) — pending
SP receipts rebuild on each update (also drops RBF'd/vanished ones); block pushes
promote a pending entry to confirmed. So a silent payment shows the instant it's
broadcast, like a normal receive, with no mining.
Verified on regtest: an unconfirmed SP is detected and served; clean logs.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
| |
The header reads wallet.spendable (and pendingIncoming), which reduce over
this.utxos only; SP outputs live in this.spUtxos, so received SP value showed in
the Receive card but not the balance. Add confirmed SP to spendable and pending
SP to pendingIncoming.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
| |
The visible spScanning state is now set only for manual scans; auto-scans use a
separate concurrency guard (_spScanBusy). Previously an auto-scan set spScanning
and the final emit fired before it was cleared (and silent scans skipped the
clearing emit), leaving the Receive card stuck on 'Scanning…'.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
The indexer now serves a /ws endpoint and publishes each newly-indexed block's
SP items to subscribers. The wallet subscribes (_spConnect) and scans pushed
items on arrival (_scanSpItems) — folding receipts into the balance instantly —
catching up via /scan on connect/reconnect. Polling for SP is now only a fallback
for when the socket isn't live.
Verified on regtest: connect, mine a block, wallet receives the push.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
scanSilentPayments() now recomputes the balance so received SP value shows in the
wallet total (not just the Receive card), and gains a silent mode. It's triggered
automatically after each full scan and on the poll loop (incremental, cheap), so
payments appear without tapping anything. The Receive card now shows a status
line ('Watching for payments' / found amount) with a small manual Rescan instead
of a primary Scan button.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
buildTx now includes received SP outputs in the coin pool, and sign() spends them
as taproot key-path inputs signed manually with d = spend_priv + t_k (the output
key is used raw — no BIP-341 tweak — so btc-signer's auto key-path signing
doesn't apply; we set tapKeySig from preimageWitnessV1 + schnorr.sign). The fee
estimator is fed a tapInternalKey (sizing only) with disableScriptCheck. SP value
folds into the displayed balance. Paying an sp1… recipient still uses BIP84 coins
(SP-input-funded SP sends need even-Y handling, deferred).
Validated on regtest via testmempoolaccept: a wallet-built, wallet-signed spend
of a received SP output is accepted by Core.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
wallet.js: scanSilentPayments() pulls the indexer's /scan range, matches outputs
on-device, and verifies each is unspent via the chain backend; spUtxos + balance
are tracked in their own bucket (one-time taproot keys), persisted in the cache.
silentPaymentsAvailable()/spBalance() expose state.
app.js: the Receive tab shows the reusable sp address (QR + copy), a 'Scan for
silent payments' button, and the found amount; Settings gains a per-network SP
indexer picker (under Data source) with custom/self-host support.
sp-indexer.js: added /scan/{from} so a client fetches a whole range in one
request instead of one fetch per block.
Receiving verified end-to-end on regtest (scan finds the output, 49,999,800 sat
confirmed). Spending SP outputs is the remaining piece.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
api.js: per-network SP indexer config (presets + custom, stored per network) with
getSpIndexerConfig/spIndexerUrl/spReceiveEnabled — regtest defaults to the local
indexer, others to custom; a hosted mainnet default slots in once we run one.
wallet.js: silentPaymentKeys() derives BIP-352 scan/spend keys from the master
seed (needs the mnemonic / depth-0 xprv), and silentPaymentAddress() returns the
sp1…/tsp1… address. Verified it matches the vector-validated e2e address.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
All pending txs share blockTime 0, so _sortTxs's blockTime comparator returned 0
for every pending pair and they kept array (scan/arrival-append) order — so the
most recently received tx sat at the bottom of the pending group instead of the
top. Order pending txs by firstSeen descending (the timestamp we already record
when a tx is first seen unconfirmed); confirmed txs still sort by block time.
Also sort on cache restore so the order is right immediately, not only after the
first refresh.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
detect
After a socket drop the in-flight _rpcCall requests were never rejected (only
stopRealtime did that), so dead calls held every _rpcPump slot until they timed
out — stalling the post-reconnect refresh by ~10s even though the new socket and
its pushes were already live. Now reject pending calls in ws.onclose and in
_reconnectNow (which nulls onclose), so the pump frees instantly and the on-open
refreshLive runs right away.
Also tighten the half-open watchdog: ping every 10s and recycle the socket after
21s of silence (two unanswered pings) instead of 45s, so a silently-dead
connection — no onclose, no online/visibility event — resumes pushing in ~20s
instead of ~45s. Verified via CDP: force-closing the live socket reconnects in
~0.6s and the next deposit pushes in ~0.4s (UI ~1.2s).
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
The network card is now just the network picker. Each network's endpoints
(Electrum server + block explorer) live solely in the Data source card, whose
options change per network and are stored per network (so switching networks
remembers each one's choice). Removes the duplicate regtest Esplora/WS fields.
Adds mutinynet (signet — testnet address format, explorer at mutinynet.com).
Per-network defaults: mainnet → coinos Electrum, testnet/mutinynet → explorer,
regtest → local Fulcrum. Fixes tx-explorer links to use each network's web base
(no double /testnet).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
_rpcId could go non-numeric, so ++ produced NaN and requests serialized with
"id":null. _handleRpc matches responses with `msg.id != null`, so every
data-call response was dropped → the call timed out → _reconcileHeld skipped
the address → confirmations never reflected (until an unrelated full refresh).
Coerce to a number on every send. Verified via CDP: a mined deposit now flips
pending→confirmed in ~1s instead of stalling.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Regtest now talks to a local Fulcrum over its native Electrum WebSocket for
both chain data and realtime push, instead of esplora REST + a separate watcher.
This matches prod (Fulcrum) and gives ~2-3x lower latency than the previous
electrs polling. getBackend()/electrumCandidates() route regtest to the
Electrum backend at the configured Fulcrum WS (default ws://localhost:50003);
the wallet's realtime path is ungated for regtest. ElectrumApi already covers
every call (utxos, history, fees, broadcast), incl. the swap feature.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
| |
New `regtest` network (bech32 `bcrt`, testnet version bytes) selectable in
Settings, pointed at a configurable local Esplora (default http://localhost:3000).
Adds getNetwork/setNetwork + getRegtestEsplora persistence, a regtest branch in
resolveHosts, mainnet-only gating for the realtime watcher + Electrum backend,
and a Network selector card. Account loading reads the active network.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
The light frontier poll walks past already-used addresses, and _reconcileHeld
refreshed held-coin balances but not the history tx summaries — so a
pending->confirmed transition only showed via the realtime WebSocket push.
Backends without that push (custom electrs/esplora, regtest) left the History
row stuck on "pending" even after the tx confirmed. Re-fetch and update tx
summaries for changed held addresses during reconcile.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
The Electrum data path fired every request at once, so a scan burst dozens of
calls at a rate-limited public server, which then dropped some (the timeouts).
Add a concurrency cap (4 in flight; Electrum pipelines, so it stays fast) with
excess queued — mirrors the Esplora scheduler's intent. Verified a full scan via
the public jochen server completes in ~4.5s with peak concurrency 4, no timeouts.
The throttle queue is drained on disconnect/stop alongside in-flight calls.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Two Electrum-backend issues:
1. Stale balance after a send made on another device: _reconcileHeld set each
address's balance from get_balance/address-stats, where a confirmed coin being
spent by an unconfirmed tx STILL counts as confirmed (with a compensating
negative unconfirmed). So a device that didn't make the send kept showing the
spent coin's value. Now the entry balance is derived from the actual unspent
set (listunspent, which excludes spent coins) — verified a spent coin clears.
2. Per-address rescan failing with 'electrum timeout: blockchain.scripthash.
get_balance': public Electrum servers can drop a response or the socket can
flap mid-call. _rpcCall now retries a couple of times on a fresh id (12s each)
before giving up.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
| |
On the Electrum backend the post-send rescan is many round-trips, so the balance
lagged a few seconds. Now a successful broadcast immediately applies the tx
locally (applySentTx): drop the spent coins, credit change as pending, add a
pending history row, recompute the balance, and emit — so the UI reflects the
send instantly. The realtime watcher / backstop poll reconciles and fills in the
confirmation once the explorer has indexed it (no immediate rescan, so a
not-yet-indexed tx can't briefly revert the optimistic state). Verified the
balance/utxo/history math.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The logout timer is now an account setting (stored per account in the session and
the durable directory), not a global one, and it removes only that wallet from
the device — its session entry, directory entry, cached state (xpub + seed keys),
and its vault entry — leaving other wallets untouched.
- wipeAccount(id) surgically removes one wallet; the active one falls back to the
next account (or the unlock screen).
- Background-away timestamp drives per-account evaluation on resume/boot; an armed
timer fires for the soonest-due wallet so a blurred-but-visible window still
signs out on time. Overdue saved wallets are removed from the vault when it's
unlocked (the only point we can re-encrypt it).
- New cacheKeyFor() export lets app code target one wallet's cache keys exactly
(verified against the wallet's seed/xpub/watch keys).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Closing the browser without 'Save to device' used to wipe the wallet entirely.
Now every wallet's account xpub is mirrored to a durable localStorage directory
(never the seed), so it survives a session wipe as a watch-only entry — you keep
seeing balance and history, and re-enter the seed to spend again.
- Durable account directory: saveDirectory() mirrors {id,label,xpub} for every
account; restore falls back to it as watch-only when there's no session/vault.
- Instant view: a full wallet also caches under its xpub key, so the downgraded
watch-only shows balance/history immediately (no rescan). Verified round-trip.
- Re-enter seed to spend: the Send tab (now shown for watch-only) and Settings
prompt for the recovery phrase / xprv; it must derive to this wallet's xpub
(verified via accountXpubFor — wrong seed/passphrase rejected), with an option
to Save to device at the same time. Upgrade is in place (id/label/cache kept).
- Vault merge now dedupes by xpub so saved + non-saved wallets coexist without
duplicates.
Privacy note: the xpub now persists by default (someone with device access can
see balance/history without the seed); 'Remove' clears it.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Failover (live now): the Electrum backend now tries an ordered list of servers,
advancing to the next when one fails to connect or dies within ~5s. Named public
presets fall over to the other public servers; a custom your-node URL does NOT
fall over (won't silently leak your addresses to a server you didn't pick).
Verified: dead primary → auto-advances to a working server.
Cutover switch (dormant): COINOS_ELECTRUM (false) flips 'coinos' mode from
explorer-REST + watcher push to a full Electrum backend pointed at
wss://coinos.io/electrum, with the public servers as failover, and disables the
watcher for coinos mode. Flip it true once Fulcrum serves data behind that
endpoint — no new logic needed at cutover. Flag off = current behavior exactly
(default coinos → esplora + watcher), so this ships safely while Fulcrum syncs.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Settings → Data source now offers 'Electrum' alongside the Esplora REST explorer.
One Electrum WebSocket serves BOTH the wallet's data queries and scripthash push
notifications, so pointing Hal at your own Fulcrum/electrs gives a fully
self-hosted setup — no coinos, no public REST, nothing shared off your node.
- src/electrum.js: ElectrumApi maps the Esplora shapes the wallet consumes onto
Electrum methods (scripthash get_balance/listunspent/get_history, estimatefee,
transaction.broadcast). electrs has no verbose tx lookup, so it parses raw txs
and resolves input values from parent txs to compute fees (cached, in-flight
deduped, bounded-parallel). Validated field-for-field against mempool.space:
addressTxs (fee/vin/vout/status), getTx, outspend, balance, utxos.
- wallet.js: the realtime socket already spoke Electrum's notify protocol; added
request/response (_rpcCall) so the same connection answers data calls. Real
Electrum notifications carry no tx data, so they reconcile via the backend.
Replaced the fragile newline parser with a string-aware brace-depth scanner
that handles every framing (newline, one-per-frame, concatenated, split) —
fixes a buffer-poisoning hang under concurrent calls; also hardens the coinos
watcher path. Esplora stays the default; nothing changes for existing users.
Verified end-to-end against blockstream's electrs: concurrent transport, a full
fresh-wallet scan (~2s), and the parser unit-tested on all framings.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Pay sp1… addresses: the wallet derives the one-time taproot output from the
recipient's address + the transaction's own input keys (ECDH), so payments are
unlinkable on-chain with no interaction. New src/silentpay.js implements the
sender derivation + bech32m address decode, validated against the official
BIP-352 test vectors (all P2WPKH-input / single-recipient cases pass; the rest
exercise input types Hal never spends).
buildTx detects sp1… recipients, selects coins against a same-size placeholder
taproot output, then swaps in the script derived from the final input set
(updateOutput). Works for normal sends, multi-recipient, and send-max. The
review screen shows the sp1… address with a 'Silent payment' label (the on-chain
output is a derived one-time taproot address). End-to-end signing verified.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
| |
Persist the receive-ack the moment the 'Payment received!' screen is shown
(not only on tap), so refreshing or navigating away without tapping won't bring
it back. It still stays visible in the current session until dismissed.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Gift links now carry a compact binary encoding of just the claim-essential data
(presigned inputs + the committed change output) instead of a full PSBT (~25%
fewer bytes), base32-encoded and served at an uppercase /g/<code> path. The QR
renders the uppercase URL in the QR alphanumeric mode (5.5 bits/char vs byte
mode's 8), so the grid drops from ~73 to ~53 modules — much easier to scan.
- wallet: _encodeCompactGift/_decodeCompactGift + _giftTx dispatcher; legacy
base64url-PSBT links still claim (auto-detected by a magic byte). Verified
round-trip + claim-byte-identical across 1/2/3-input and whole-balance gifts,
against the real exported preview/claim/outpoint functions.
- app: giftUrl uses /g/<code>; opener parses /g/ (and legacy #gift=); QR uses
uppercase + Alphanumeric mode (qrSvg gains a mode option w/ byte fallback).
- nginx already SPA-routes /g/*; the SW serves the shell for the new path.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
Opening a gift link now checks whether its funding coin is already spent — via
the Esplora outspend endpoint, which reports a spend even by an unconfirmed
claim. The Claim button is gated until that check passes; if the coin is spent,
show an 'Already claimed' screen with a link to the claim tx instead of a
claimable amount. doClaim also re-checks on broadcast failure (the race window),
and refuses to run while unchecked/taken — so a second opener can't attempt a
doomed double-spend.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
| |
The watcher's block (rawblock) push now carries confirmed=true + the block time,
and _applyTxNotification flips the matching history row and coins from pending to
confirmed directly — for incoming and outgoing txs (flag-only, so no overcount).
Confirmations no longer wait on a mempool.space reconcile; the 30s backstop poll
just fills in block height / fee afterward.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
The watcher push now carries the tx data (matched outputs + confirmed flag), so
the app credits an incoming payment the moment the push arrives — no mempool.space
round-trip. This removes the rate-limited REST reconcile from the path a user
actually waits on, and lets us back the frontier poll way off when the socket is
live (30s backstop vs the old 3s receive-tab hammer), cutting the 429s to a
trickle.
- watcher: notification includes { txid, confirmed, outputs:[{vout,value}] }.
- wallet: _applyTxNotification credits the receive coin + a pending history row
optimistically (receives only; change reconciles in the background), advances
the frontier, updates the balance, and schedules a background reconcile for
full accuracy off the critical path.
- poll intervals relaxed now that the WS does the real work instantly.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Replace the unreliable public WebSocket push (mempool/blockchain.info address
subscriptions never reliably fire) with a private notification service:
- tools/zmq-watcher.js: a standalone Bun service (reusing coinos lib/zmq.ts's
ZMTP subscriber) that reads Bitcoin Core's ZMQ rawtx/rawblock, computes the
scripthash of each output, and pushes a blockchain.scripthash.subscribe
notification to subscribed clients. No historical index (~24MB RAM) — just the
live tx stream. Runs on the coinos box reading its Core's ZMQ.
- wallet.js: realtime client now speaks the Electrum protocol (server.version,
scripthash.subscribe, server.ping) over the WS; any scripthash notification
triggers the existing refreshLive() (REST stays the source of truth).
- api.js: wsUrl -> wss://coinos.io/electrum.
Verified end-to-end: live mempool txs to subscribed addresses notify instantly.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
| |
The WS badge reads Live but mempool.space's free address tracking doesn't
reliably push incoming mempool txs, so a deposit could take ~18s to appear (the
25s safety poll catching it). While the user is on the Receive tab (online), now
poll just the fresh receive address every ~3s regardless of Live state — one
cheap request that guarantees near-instant arrival. Toggled idempotently from
render() based on the active tab; stops when they navigate away.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
- _scheduleReconnect now backs off exponentially from ~0.6s (was a flat 4s), so
a first socket attempt that's transiently rejected retries fast — the wallet
reaches Live in well under a second instead of ~4s. Backoff resets on open.
- refreshLive now stamps _lastPoll at its start, so a socket-open refresh and the
poll-timer tick don't both fire reconcileHeld back-to-back on load. A cached
load does one held-coin reconcile (verify coins still unspent + frontier check)
instead of ~3x.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
A pending tx is judged 'stuck' once it has waited ~10 blocks (≈100 min) and
its fee rate (fee/vsize) is below the mempool's current floor (minimumFee) —
i.e. it's been purged and won't confirm without a bump. _txSummary now records
vsize and a firstSeen timestamp (persisted in cache) to support this.
Stuck txs are dropped from active monitoring: excluded from the watched-address
set and from the fast-poll trigger, so they don't hold a socket slot or keep us
in the 6s lane. The slow ~25s reconcile still catches one if the floor later
drops and it confirms. History flags them (⚠ stuck) and the detail view explains
it — pointing outgoing ones at the existing fee bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
- watchedAddresses() now includes any address holding an unconfirmed coin (not
just the frontier), so a pending deposit's confirmation pushes over the socket
instantly. Capped at the socket's 10-address limit. refreshLive/rescanAddress
retrack when a new pending coin changes the set.
- While any coin is pending, the frontier poll runs every ~6s even when live, so
confirmations show fast as a belt-and-suspenders to the WS push.
- Per-address rescans no longer disable each other: clicking many queues them,
and the api.js global scheduler (1 concurrent, 500ms spacing, 429 backoff)
serializes and staggers the underlying requests within the rate limit.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Remove the periodic full re-scan entirely — the WS and the frontier poll keep
the balance current, and refreshLive's _reconcileHeld catches spends of held
coins. The only gap (a deposit to a reused old address) is now handled
explicitly: Settings → Rescan opens a paginated list of every known address,
each with its own Rescan button (wallet.rescanAddress re-queries just that one).
Replaces the full 'Rescan wallet' button.
This sharply cuts recurring REST volume (the main rate-limit driver). Trade-off:
when the socket is down, a pending tx's confirmed badge in History may lag until
the WS reconnects or you rescan — the balance itself stays correct via reconcile.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Incoming deposits show instantly when the mempool.space WebSocket is healthy
(_ingestWs updates immediately). The lag came from the socket being starved:
a 20s frontier poll plus a full wallet re-scan every 120s generated enough
REST volume to trip mempool's per-IP rate limit (the 429/CORS errors), which
in turn degraded the socket — a feedback loop.
- While the socket is live, skip the periodic full scan entirely (the WS pushes
confirmations and block events) and poll the frontier only every ~25s as a
half-open-socket safety net — near-zero recurring REST.
- While the socket is down, poll the frontier every ~6s (was 20s) so receives
still show quickly, and keep the 120s reconcile scan.
- Refresh the frontier on WS (re)connect to catch deposits that landed while
disconnected.
- Surface a Live/Polling badge on the balance card so a stuck socket is visible.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
A gift PSBT commits a sender change output (>= dust), so the entered amount
always needed gift + 294 — making it impossible to gift the entire balance.
Add a Max toggle on the gift form that builds a no-change sweep: all inputs
SIGHASH_NONE, no change output, so the recipient gets the whole balance minus
their claim fee. previewGift/buildClaimTx now treat a zero-output PSBT as
zero change. A specific-amount gift that can't keep a dust change now points
the user to Max instead of failing with a confusing 'not enough funds'.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
| |
- Spendable headline no longer counts pending incoming receives: compute it
from UTXOs (confirmed coins + our own unconfirmed change, minus gift locks)
so a pending spend still debits immediately but a pending receive shows only
on the PENDING line (new pendingIncoming getter).
- Claim button shows the fee in parens in slightly smaller font, no dot.
- Gift recipients now get the same "Verify backup" / "Skip verification" flow
as new-wallet creation instead of a single "I've written it down" button.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| |
Gift split no longer makes the sender wait for confirmation: createGiftFromSplit
broadcasts the carve-out, waits only until it's visible in the mempool, then
builds the gift spending that still-unconfirmed coin. Funding + claim form a
chain that confirms together; the recipient sees a pending balance until then.
Polls for mempool visibility before reserving so a background refresh can't drop
a not-yet-indexed coin and prune the reservation (double-spend risk).
Regular sends can already draw on unconfirmed coins; surface that honestly:
a 'pending' tag on unconfirmed coins in coin control, and a plain-language
warning at review when a payment spends unconfirmed bitcoin (won't confirm
until the ancestor does).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
|
|
|
|
|
| |
Gift coin selection now picks the smallest single coin that covers the gift
(was largest-first), locking the least value until claim. When no near-exact
coin exists, offer to pre-split: broadcast a self-send carving out a gift+dust
coin so the gift later locks only ~the gift amount.
The split is only offered when the liquidity it frees (lock - gift - dust)
exceeds the split's own fee, so it never costs more than it regains. Decision
is surfaced as an interstitial on Create, since a full re-render drops input
focus and a live preview isn't viable.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|
| |
|
|
|
|
|
|
|
| |
Coins reserved behind unclaimed gift links were already excluded from
spending, but the headline balance still counted them. Show the spendable
balance (total minus gift locks) as the headline and surface the locked
amount as its own line, so a gift debits immediately rather than at claim.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
|