aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-05 22:35:56 -0700
committerAdam Soltys <[email protected]>2026-10-05 22:35:56 -0700
commitf571e46937d423d9ba44154a92aeb4136466daaa (patch)
tree658553eb48d4d85e632499f6711eda76071c9ba1
parenta08f48d67ad61eba5077fdb1a01d19fe5eea0fdb (diff)
downloadcoinosv3-f571e46937d423d9ba44154a92aeb4136466daaa.tar.gz
coinosv3-f571e46937d423d9ba44154a92aeb4136466daaa.zip
README rewritten for today's coinos v3; build switches are COINOS_* (HAL_* still accepted); silent-payment leftovers removed
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01YRC6aKhwAud231ot9FPmhi
-rw-r--r--README.md350
-rw-r--r--android/app/build.gradle4
-rw-r--r--android/app/src/webview/java/io/coinos/app/Assets.java2
-rw-r--r--build.js20
-rw-r--r--dev.js2
-rw-r--r--docs/REPRODUCIBLE.md4
-rw-r--r--package.json2
-rw-r--r--src/app.js11
-rw-r--r--src/build-flags.js2
-rw-r--r--src/features/index.js2
-rw-r--r--src/i18n.js1
-rw-r--r--src/locales/ar.json1
-rw-r--r--src/locales/bn.json1
-rw-r--r--src/locales/de.json1
-rw-r--r--src/locales/es.json1
-rw-r--r--src/locales/fr.json1
-rw-r--r--src/locales/hi.json1
-rw-r--r--src/locales/ja.json1
-rw-r--r--src/locales/ko.json1
-rw-r--r--src/locales/pt.json1
-rw-r--r--src/locales/ru.json1
-rw-r--r--src/locales/tr.json1
-rw-r--r--src/locales/zh.json1
-rw-r--r--src/silentpay.js341
-rw-r--r--tools/minimal-smoke.js2
25 files changed, 254 insertions, 501 deletions
diff --git a/README.md b/README.md
index 2b6fa25..3ef7ae0 100644
--- a/README.md
+++ b/README.md
@@ -1,102 +1,196 @@
# coinos v3
-A self-contained, **single-file** Bitcoin wallet that runs entirely in the
-browser. Think bitaddress.org, but a modern BIP84 HD wallet from a seed phrase
-that scans history, watches for payments, spends on-chain, pays Lightning
-invoices, and holds an off-chain balance on **Ark**. The whole wallet is one
-static `index.html` you can save and run offline, forever.
+A self-custodial Bitcoin wallet and Nostr client that runs entirely in the
+browser. Your keys are made and kept on your device; there is no account,
+email or password. One app holds an on-chain **Savings** balance, an
+off-chain **Spending** balance on [Ark](https://ark-protocol.org) with
+Lightning built in, a `[email protected]` payment address, and a full Nostr
+social client: feeds, zaps, private messages and group chats.
-Live at **https://v3.coinos.io** (the built `index.html` also runs straight
-from the filesystem with no server).
+- Live at **https://v3.coinos.io** (installable as a PWA)
+- Android: [Zapstore](https://zapstore.dev), a direct APK at
+ `v3.coinos.io/coinos.apk`, and an F-Droid build (`io.coinos.fdroid`, in review)
+- Offline: `standalone.html` is the whole wallet in one file you can save and
+ open from disk
## Features
-### Core (always included)
+### Wallet core (always included)
-- **BIP84 / native SegWit (p2wpkh)** HD wallet from a 12-word BIP39 seed
- (imports any valid BIP39 phrase; optional passphrase).
-- **Per-wallet networks**: each wallet is stamped with its network (mainnet,
- testnet, signet, mutinynet, regtest); switching wallets switches networks,
- and wallets on different networks coexist in one session.
-- **One fresh address at a time**: a new receive address is only handed out
- after the current one is paid, so used addresses stay contiguous and scans
- stay tiny (no 20-address gap probing).
-- **Choose your data source**: Electrum-over-WebSocket servers or Esplora
- REST explorers, per network, with silent failover, or point it at your own
- node.
-- **Real-time** incoming-payment push over WebSocket where the source supports
- it, with polling + periodic reconcile as the fallback. Replaced (RBF)
- transactions are detected and pruned from history automatically.
-- **Spending** with proper coin selection + fee estimation
- (`@scure/btc-signer`), multiple recipients, a fee-rate picker, send-max,
- manual coin control, and a QR scanner (address / BIP21 / signed tx).
+- **BIP84 / native SegWit** HD wallet from a 12-word BIP39 seed (any valid
+ phrase imports; optional passphrase).
+- **Watch-only wallets** from an account xpub/zpub: receive and view history
+ without the seed; load the seed later to spend.
- **Offline / air-gapped signing**: export a keyless snapshot on an online
- device, sign on an offline one, broadcast anywhere.
-- **Installable PWA**, global sats/BTC toggle, 19 languages.
+ device, sign on an offline one, and carry the signed transaction back by QR
+ to broadcast.
+- **Per-wallet networks**: mainnet, testnet, signet, mutinynet or regtest,
+ stamped on each wallet, so wallets on different networks coexist.
+- **Choose your data source**: Electrum-over-WebSocket servers
+ (`electrum.coinos.io` by default) or Esplora REST explorers, with failover,
+ or your own node.
+- **Real-time receives** over WebSocket where the source supports it, polling
+ as the fallback; replaced (RBF) transactions are pruned automatically.
+- **Sending** with coin selection, fee estimation, a fee-rate picker,
+ send-max, multiple recipients and a camera QR scanner (addresses, BIP21,
+ invoices, signed transactions).
+- **One fresh address at a time**, so scans stay small (no gap-limit probing).
+- **Fiat** balances and history values in your currency, with the rate at the
+ time of each payment.
+- 20 languages.
-### Ark: instant off-chain payments (feature: `ark`)
+### Spending on Ark (feature: `ark`)
-A **native-JS Ark client** (speaks gRPC-web + MuSig2 directly to
-[bark/captaind](https://github.com/ark-bitcoin/bark) servers: no WASM, no new
-dependencies, ~2k lines):
+A native-JS client for [bark/captaind](https://gitlab.com/ark-bitcoin/bark)
+Ark servers: gRPC-web and MuSig2 spoken directly, no WASM. coinos runs
+`ark.coinos.io`; other captaind servers can be chosen.
-- **Board** on-chain coins into an Ark server; **send and receive instantly**
- off-chain with zero mining fees; server fees shown before anything moves.
-- **Refresh / consolidate** vtxos (the round-based maintenance that resets
- expiry and chain depth).
-- **Collaborative offboard**: move the whole Ark balance back to your own
- on-chain address in one server-cosigned transaction.
-- **Unilateral exit**: the trustless backstop: publish the vtxo's pre-signed
- transaction chain on-chain (CPFP-bumped, TRUC-aware, one confirmed hop at a
- time), wait out the CSV timelock, and claim with your key alone. Works even
- if the server is gone; live progress (hop counter, blocks-left countdown) in
- Settings.
-- **Ark gifts**: bearer gift links whose secret *is* the link: claimed
- instantly and free, even into a brand-new empty wallet with zero on-chain
- footprint. Revocable until claimed.
-- Crash-safe by construction: every multi-step ceremony checkpoints to storage
- around each server round-trip, and local state reconciles against the
- server's authoritative vtxo status before money moves.
+- **Instant off-chain payments** between Ark users, with fees shown before
+ anything moves (free between coinos wallets).
+- **Lightning in and out of the Ark balance** through the Ark server's own
+ Lightning node (HTLC-locked VTXOs), with no swap service. Routing fees are
+ quoted before you pay.
+- **Board** on-chain coins into Spending and **offboard** back to Savings or
+ any on-chain address in one cooperative transaction.
+- **Automatic renewal** before coins expire, plus a server-side scheduled
+ backstop that renews them even while the app is closed.
+- **Unilateral exit**: publish the coins' pre-signed transaction chain
+ (CPFP-bumped, TRUC-aware) and claim after the timelock with your key alone,
+ even if the server is gone. Progress is shown live.
+- **Full receive-side validation** of every VTXO's transaction chain, and
+ crash-safe ceremonies that checkpoint around every server round-trip.
+- Ark state lives in IndexedDB, fronted by memory, and is reconciled against
+ the server before money moves.
-### Lightning (feature: `swaps`)
+### Payment addresses (feature: `names`)
-- Pay bolt11 invoices and receive over Lightning via **Boltz-compatible
- submarine swaps**, non-custodial either way: a provider can fail a swap but
- never take funds (claims/refunds are enforced on-chain). Pick a provider per
- network or point at your own.
+- **`[email protected]`**: a name claimed on first run that works as a
+ Lightning address (LNURL-pay) and as a **BIP-353** DNS payment address
+ (`₿[email protected]`, a DNSSEC-signed TXT record).
+- Paying a BIP-353 name validates its **DNSSEC chain in the browser**.
+- **BOLT12 offers**: pay `lno1…` offers (invoice signatures checked
+ client-side) and publish your own offer in your DNS record.
+- **CLINK** offer codes (`noffer1…`): request invoices over Nostr.
+- **Moving from coinos.io**: existing coinos.io users can bring their
+ username over; payments to the legacy account are forwarded to the new
+ wallet.
+
+### Zaps and Nostr Wallet Connect (features: `zaps`, `nwc`)
+
+- **NIP-57 zaps** to any npub, Lightning address or LNURL, with one-tap zaps
+ in the feed and a custom-amount screen on long press.
+- **coinos as an NWC wallet service (NIP-47)**: connect Damus, Amethyst,
+ Primal and others, with per-connection keys and budgets.
+- **Background answering**: a Web Push from `nwcpush` wakes the service
+ worker, which pays and replies from the browser even with the app closed
+ (keys never leave the device). Multi-device safe: one device answers each
+ request.
### Gifts (feature: `gifts`)
-- **Gift links**: presigned bearer transactions claimable by whoever opens
- the link, straight into a fresh wallet (with seed backup flow) or an
- existing one. Reclaim or revoke unclaimed gifts. Optionally **lock a gift to
- a nostr account** (the claim code is DM'd to them; the link alone is not
- enough).
+- **Gift links**: bitcoin you send as a link, claimable straight into a
+ brand-new wallet or an existing one. On-chain gifts use a one-time key so
+ they can't be front-run; Ark gifts are claimed instantly and free.
+- Reclaim or revoke unclaimed gifts; optionally lock a gift to a Nostr
+ account (the claim code is DM'd to them).
+
+### Point of sale (feature: `pos`)
+
+- Enter an amount, the customer picks a tip, and one Lightning invoice
+ settles it; bills and tips are kept apart in history.
+
+### Sign-in (feature: `nostrlogin`)
+
+- **Seed phrase**, created or imported.
+- **Passkeys**: the WebAuthn PRF extension derives the wallet's secret.
+- **Google**, via pomegranate: the Nostr key is FROST-sharded 2-of-3 across
+ independent operators.
+- **Nostr signers**: browser extensions (NIP-07), remote signers
+ (`bunker://` and `nostrconnect://`, NIP-46) and Amber on Android (NIP-55).
+ A pasted or signer key becomes a recovery key for the wallet.
+- Several identities side by side, each with its own wallet; switch or sign
+ out per identity.
### Cross-device sync (feature: `sync`)
-- Optional **encrypted state sync over Nostr**: wallet state NIP-44-encrypted
- to yourself as a replaceable event, per network, on relays you choose. The
- nostr identity derives from the same seed (NIP-06).
+- Wallet state, read receipts and custom feeds are **NIP-44 encrypted to
+ yourself** and published as replaceable Nostr events, one per domain (core
+ wallet, Ark, NWC, …), so only what changed republishes.
+- Domains too big for a relay event go to **Blossom** as an encrypted blob
+ (`blossom.coinos.io` by default, or any Blossom server); the event carries
+ its hash.
+- A durable outbox holds signed snapshots until a relay accepts them.
+
+### Social: a Nostr client (feature: `messages`)
+
+**Feeds**
+- **Following**, a curated **Popular** feed (prose in your language that
+ several people engaged with), and **custom feeds** from people, hashtags,
+ relays, follow packs or NIP-51 lists. Custom feeds sync across devices;
+ people feeds publish as NIP-51 follow sets.
+- Shareable feed links (`/feed/<npub>`, `/feed/t/<tag>`, packs and lists).
+- **Views**: All, an **Images** grid, **Videos** (full-screen vertical clips
+ with autoplay), and long-form **Articles** (NIP-23).
+- Cached posts paint first and new ones arrive without moving what you're
+ reading; long feeds are windowed.
+
+**Posts**
+- Replies, reposts, quotes, reactions, polls (NIP-88) and zaps on one
+ action bar, with who-reacted panels.
+- Link previews, YouTube embeds, audio players for podcast links, article
+ cards for `naddr` references, and inline quoted notes.
+- Compose with pictures and video (compressed on device, uploaded to
+ Blossom media hosts).
+- Linkable notes and profiles (`/nevent1…`, `/npub1…`), with server-rendered
+ pages for link previews elsewhere.
+
+**Moderation**
+- Your NIP-51 mute list (people, words, hashtags, threads, including its
+ private half) applied everywhere; reports (NIP-56) from people you follow
+ fold posts; duplicate and hashtag-spam filtering.
+
+**Messages**
+- **Private DMs** (NIP-17 gift-wrapped), with replies, reactions and
+ encrypted attachments.
+- **Communities** on Concord: end-to-end encrypted channels compatible with
+ Vector, including Vector's custom emoji packs (NIP-30) and community icons.
+ Every user joins the coinos community; anyone can create or join others.
+- **Group chats** on White Noise (Marmot), with a native-JS MLS (RFC 9420)
+ implementation.
+- **Notifications** for mentions, replies, reactions and zaps, with push
+ delivery to your devices.
+
+### Extras
+
+- **Hats** (feature: `hats`): supporter hats worn above your avatar.
+
## Modular builds
-Every feature above is a **plugin behind a build seam**. `HAL_FEATURES`
-selects what ships; excluded features never enter the bundle: their code,
-their network endpoints, their crypto:
+Every optional feature above is a **plugin behind a build seam**.
+`COINOS_FEATURES` selects what ships; excluded features never enter the
+bundle (their code, their network endpoints, their crypto):
```bash
-bun run build # everything (~610 KB)
-HAL_FEATURES=ark,gifts bun run build # core + ark + gifts
-bun run build:minimal # core on-chain wallet only (~320 KB)
+bun run build # everything
+COINOS_FEATURES=ark,gifts bun run build # core + ark + gifts
+bun run build:minimal # core on-chain wallet only
```
-`tools/minimal-smoke.js` verifies the minimal profile is a fully working
-on-chain wallet with zero feature code in the bundle. Features integrate
-through fixed seams on the core (`registerCoinLock`, `registerInputSigner`,
-cache extensions, UI hooks), so a stripped build isn't a crippled build, it's
-the same core wallet, smaller.
+Feature names: `gifts`, `ark`, `nostrlogin`, `names`, `zaps`, `nwc`, `hats`,
+`sync`, `messages`, `pos`. `tools/minimal-smoke.js` checks the minimal
+profile is a working on-chain wallet with no feature code in the bundle.
+
+Other build switches:
+
+| Variable | Effect |
+| --- | --- |
+| `COINOS_TARGET=staging` | mutinynet-first defaults and a visible staging stamp |
+| `COINOS_NO_SW=1` | no service worker (the bundled Android/F-Droid build) |
+
+The old `HAL_*` names still work.
+
## Develop
@@ -107,72 +201,86 @@ bun install
bun run dev # http://localhost:5173 (rebuilds on each refresh)
```
-The dev server also proxies local regtest backends (electrum, esplora, boltz)
-so the app works unchanged from a phone on the LAN. End-to-end
-suites in `tools/` drive a headless Chrome through the real flows (boarding,
-ark sends, offboard, unilateral exit, gifts, swaps) against a
-local regtest stack.
+The dev server proxies local regtest backends (Electrum, Esplora) so the app
+works unchanged from a phone on the LAN. The end-to-end suites in `tools/`
+drive headless Chrome through real flows (boarding, Ark sends, Lightning,
+offboard, exit, gifts, feeds, chat) against a regtest stack or live relays.
+
## Build
```bash
-bun run build # → dist/ (index.html + PWA sidecars)
+bun run build # → dist/
```
-`dist/index.html` inlines all code, the crypto/QR/Nostr libraries, and the CSS,
-save that one file and open it directly in a browser, no server or internet
-needed. The build also emits PWA extras (`manifest.webmanifest`, `sw.js`,
-icons, and a lazy-loaded `jsqr.js`) for the hosted site; they're optional and
-simply 404 when `index.html` is opened on its own.
+- `dist/index.html` is a small shell; the app loads as `app-<hash>.js` plus
+ lazily loaded `chunk-<hash>.js` features.
+- `sw.js`, `manifest.webmanifest` and icons make it an installable PWA that
+ works offline.
+- `dist/standalone.html` inlines everything (code, libraries, CSS, fonts):
+ save it and open it from disk with no server.
-## How state is kept
-Three layers, fastest first:
+## Services in this repo
+
+The app is static; these small services run beside it (Bun):
-1. **sessionStorage**: keeps the wallet open across a refresh (cleared on
- logout / tab close).
-2. **localStorage**: caches scanned state per wallet *and per network*, so a
- reload shows balances instantly.
-3. **Nostr**: encrypted, replaceable cross-device state per network (when
- sync is enabled).
+| Directory | Service |
+| --- | --- |
+| `names/` | Payment-name registrar (`names.coinos.io`): BIP-353 TXT records via the DNS provider's API, LNURL-pay for `@coinos.io` names, NIP-98 auth, link previews |
+| `feed/` | Public feed snapshot (`/api/feed`) and server-rendered note pages |
+| `nwcpush/` | Watches relays for NWC requests and push notifications, and wakes devices with Web Push |
+| `blossom/` | `blossom.coinos.io`: a Blossom server that only stores encrypted sync envelopes |
+| `bridge/` | Ark→Lightning swap bridge (retired; the app uses the Ark server's Lightning) |
+
+
+## How state is kept
+
+1. **sessionStorage** keeps the wallet open across a refresh (cleared on
+ lock or tab close).
+2. **localStorage** caches scanned state, feeds and profiles per wallet and
+ network, so a reload paints instantly.
+3. **IndexedDB** holds Ark coins, the feed media index and White Noise MLS
+ state.
+4. **Nostr** (and Blossom for large domains) holds the encrypted
+ cross-device copy.
-On load and on a timer the wallet reconciles against its data source; a manual
-**Settings → Rescan** forces a full re-scan on demand.
## Layout
-| File | Purpose |
+| Path | Purpose |
| --- | --- |
-| `src/wallet.js` | BIP84 core: derivation, scanning, coin selection, signing, realtime, cache, plus the feature seams |
-| `src/api.js` | Data-source selection per network (electrum/esplora presets, throttle/cooldown/failover) |
-| `src/ark/` | Native-JS Ark protocol: gRPC-web codec, arkoor sends, boarding, refresh, offboard, unilateral exit, validation, crash-safe manager |
-| `src/features/` | Feature plugins: `ark`, `swaps`, `gifts`(+`gifts-wallet`), `sp`(+`sp-wallet`), `sync`, and `index.js`, rewritten at build time by `HAL_FEATURES` |
-| `src/swap.js` | Boltz submarine/reverse swap engine |
-| `src/silentpay.js` / `src/sp-worker.js` | BIP-352 math + its Web Worker |
-| `src/nostr.js` | NIP-44 state sync, NIP-17 DMs, profiles |
-| `src/mls.js` / `src/marmot.js` / `src/marmot-client.js` | White Noise group chats: native-JS MLS (RFC 9420, one ciphersuite), the Marmot layer over it, and the per-device client (IndexedDB state, relay traffic) |
-| `src/electrum.js` / `src/scan.js` / `src/qr.js` | Electrum transport, camera QR scanner, QR rendering |
-| `src/app.js` | UI controller (vanilla DOM) + feature registry |
-| `build.js` / `dev.js` | Bun bundler → inlined `index.html` + PWA sidecars; dev server with regtest proxies |
+| `src/wallet.js` | BIP84 core: derivation, scanning, coin selection, signing, realtime, cache, and the feature seams |
+| `src/api.js` / `src/electrum.js` | Data sources: Esplora REST and Electrum-over-WebSocket, presets and failover |
+| `src/ark/` | Native-JS Ark: gRPC-web codec, arkoor sends, board, refresh rounds, offboard, Lightning, exit, validation, crash-safe manager, IndexedDB store |
+| `src/features/` | Feature plugins (see Modular builds); `index.js` is rewritten at build time by `COINOS_FEATURES` |
+| `src/nostr.js` / `src/sync-*.js` | Encrypted state sync, relay pool, Blossom envelopes, the sync outbox |
+| `src/nostr-login.js` / `src/passkey.js` / `src/pomegranate.js` | Nostr signers, passkey PRF sign-in, Google via FROST |
+| `src/dm.js` / `src/concord.js` / `src/mls.js` / `src/marmot*.js` | NIP-17 DMs, Concord communities, MLS and the White Noise layer |
+| `src/bip353.js` / `src/dnssec.js` / `src/bolt12.js` / `src/noffer.js` / `src/clink.js` / `src/lnurl.js` | Payment-name resolution with DNSSEC validation, BOLT12, CLINK, LNURL and zaps |
+| `src/sw-nwc.js` / `src/nwc-*.js` | Background NWC answering in the service worker |
+| `src/app.js` | UI controller (vanilla DOM, no framework) and feature registry |
+| `build.js` / `dev.js` | Bun bundler (split build, PWA, standalone file); dev server with regtest proxies |
+| `android/` | Android wrapper: a Trusted Web Activity build and a bundled F-Droid flavor |
+
## Security notes
-- Self-custody software handling real keys. Review the code before trusting
- it with funds. It's a hot wallet (keys live in the browser); for large
- amounts use a hardware signer or run it air-gapped.
-- The seed/passphrase live in memory while the page is open and in
- sessionStorage for the tab session; locking or closing the tab clears them.
-- **Ark trust model**: off-chain sends are co-signed by the Ark server, which
- is the arbiter of off-chain spent/unspent state, but it can never spend
- your coins, and the pre-signed exit path means you can always return
- on-chain without its cooperation. Ark coins expire (weeks) if never
- refreshed; the wallet surfaces this, but don't park long-term savings in an
- Ark.
-- Swap providers are non-custodial by protocol: worst case a swap fails and
- refunds on-chain.
-- Whichever data source you query sees your addresses and IP. Point it at
- your own node for full privacy. The localStorage cache and the Nostr event
- hold public chain data; the Nostr copy is encrypted end-to-end.
+- Self-custody software handling real keys: a hot wallet whose keys live in
+ the browser. Review the code before trusting it with funds; for large
+ amounts use the offline-signing flow or don't keep them in a hot wallet.
+- **Ark trust model**: the Ark server co-signs off-chain spends and decides
+ off-chain spent/unspent state, but it can never spend your coins, and the
+ pre-signed exit path returns them on-chain without its cooperation. Ark
+ coins expire if never renewed; the wallet renews them automatically.
+- **Lightning via the Ark server**: payments are HTLC-locked, so the server
+ can fail a payment but not take the funds.
+- **NWC background answering** means `nwcpush` learns your wallet-service
+ pubkeys and when requests arrive (not their contents).
+- Your data source sees your addresses and IP; point it at your own node
+ for privacy. Synced state is end-to-end encrypted; relays see only
+ ciphertext and timing.
+
## License
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 928f12d..3dc6caf 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -9,14 +9,14 @@ if (keystoreFile.exists()) keystoreProps.load(new FileInputStream(keystoreFile))
def signRelease = keystoreFile.exists() && System.getenv('COINOS_UNSIGNED') == null
// The bundled shell carries the built web app as assets. It is copied from
-// ../dist, which the web build produces first: HAL_NO_SW=1 bun run build
+// ../dist, which the web build produces first: COINOS_NO_SW=1 bun run build
// (the service worker is left out — inside the WebView the app is served
// from these assets, and a worker would fetch the live site over it).
tasks.register('copyWeb', Copy) {
def dist = rootProject.file('../dist') // the repo root's dist, not the module's
doFirst {
if (!new File(dist, 'index.html').exists())
- throw new GradleException('build the web app first: HAL_NO_SW=1 bun run build (in the repo root)')
+ throw new GradleException('build the web app first: COINOS_NO_SW=1 bun run build (in the repo root)')
}
from(dist) { exclude '*.apk', 'standalone.html', 'sw.js', 'zmq-watcher.js' }
into layout.buildDirectory.dir('www')
diff --git a/android/app/src/webview/java/io/coinos/app/Assets.java b/android/app/src/webview/java/io/coinos/app/Assets.java
index b946500..9e4f909 100644
--- a/android/app/src/webview/java/io/coinos/app/Assets.java
+++ b/android/app/src/webview/java/io/coinos/app/Assets.java
@@ -32,7 +32,7 @@ final class Assets {
private Assets() {}
static final String HOST = "v3.coinos.io";
- private static final String[] LIVE = { "/electrum", "/esplora/", "/sp/", "/lnurlp/", "/.well-known/", "/api/" };
+ private static final String[] LIVE = { "/electrum", "/esplora/", "/lnurlp/", "/.well-known/", "/api/" };
private static final Map<String, String> MIME = new HashMap<>();
static {
MIME.put("html", "text/html");
diff --git a/build.js b/build.js
index c90656f..da51245 100644
--- a/build.js
+++ b/build.js
@@ -252,7 +252,11 @@ export async function buildSwNwc({ minify = true } = {}) {
return result.outputs[0].text();
}
-// Optional-feature selection: HAL_FEATURES is a comma list of enabled
+// Build switches are COINOS_* environment variables. The old HAL_* names still
+// work: the cs deploy hook and pinned F-Droid recipes pass them.
+const env = (name) => process.env['COINOS_' + name] ?? process.env['HAL_' + name];
+
+// Optional-feature selection: COINOS_FEATURES is a comma list of enabled
// features (gifts,ark,zaps,nwc,…). Unset means all; "none"/"" means a minimal
// on-chain-only wallet. A build plugin swaps src/features/index.js for a
// generated module that only imports the enabled features, so a disabled
@@ -273,7 +277,7 @@ const ALL_FEATURES = { gifts: 'giftsFeature', ark: 'arkFeature', nostrlogin: 'no
// later chunk makes the header change after the rest of the page has painted.
const DEFERRED_FEATURES = ['gifts', 'nwc', 'pos'];
-export function enabledFeatures(spec = process.env.HAL_FEATURES) {
+export function enabledFeatures(spec = env('FEATURES')) {
if (spec == null) return Object.keys(ALL_FEATURES);
return spec.split(',').map((x) => x.trim().toLowerCase()).filter((x) => x in ALL_FEATURES);
}
@@ -305,9 +309,9 @@ const featurePlugin = (enabled) => ({
},
});
-// HAL_TARGET=staging flips src/build-flags.js in the bundle: mutinynet-first
+// COINOS_TARGET=staging flips src/build-flags.js in the bundle: mutinynet-first
// defaults and a visible staging stamp, without a diverging branch.
-export const isStaging = (target = process.env.HAL_TARGET) => target === 'staging';
+export const isStaging = (target = env('TARGET')) => target === 'staging';
const flagsPlugin = (staging) => ({
name: 'coinos-build-flags',
@@ -375,10 +379,10 @@ function bootShell(logo, staging) {
}
const BOOT_SHELL_SCRIPT = `<script>try{var m=location.pathname.match(/^\\/([A-Za-z0-9._-]{1,64})\\/?$/);if(m&&!/^(note|nevent|naddr)1/i.test(m[1])){document.getElementById('boot-shell').style.display='';document.getElementById('boot-shell-name').textContent=decodeURIComponent(m[1]);}}catch(e){}</script>`;
-// HAL_NO_SW=1: the page registers no service worker. The Android bundled
+// COINOS_NO_SW=1: the page registers no service worker. The Android bundled
// build serves this page from its own assets, where a worker would only
// fetch the live site over the top of them.
-const NO_SW = !!process.env.HAL_NO_SW;
+const NO_SW = !!env('NO_SW');
async function pageHtml({ css, staging, pwa, scriptHtml }) {
return `<!doctype html>
@@ -401,7 +405,7 @@ ${pwa && !NO_SW ? SW_REGISTER + '\n' : ''}</body>
}
// Self-contained single file (dev server, test tools, save-and-open-offline).
-export async function buildHtml({ minify = true, pwa = minify, features = process.env.HAL_FEATURES, staging = isStaging(), inlineFonts = false } = {}) {
+export async function buildHtml({ minify = true, pwa = minify, features = env('FEATURES'), staging = isStaging(), inlineFonts = false } = {}) {
const outputs = await bundleApp({ minify, features, staging });
let js = await outputs[0].text();
// Guard against a literal </script> inside the bundle closing our tag early.
@@ -422,7 +426,7 @@ export async function buildHtml({ minify = true, pwa = minify, features = proces
// features ride in their own files, fetched right after boot. The page stays
// tiny on every load while the heavy bundles are cached by name — a repeat
// visit re-downloads kilobytes, not the app.
-export async function buildSplit({ minify = true, features = process.env.HAL_FEATURES, staging = isStaging() } = {}) {
+export async function buildSplit({ minify = true, features = env('FEATURES'), staging = isStaging() } = {}) {
const outputs = await bundleApp({ minify, features, staging, splitting: true });
const entry = outputs.find((o) => o.kind === 'entry-point') || outputs[0];
const js = (await entry.text()).replaceAll('{{COMMIT}}', gitCommit());
diff --git a/dev.js b/dev.js
index feb315b..4f9ab0c 100644
--- a/dev.js
+++ b/dev.js
@@ -16,7 +16,7 @@ import { buildHtml, buildJsQr, buildNip46, buildVerifyWorker } from './build.js'
const port = Number(process.env.PORT || 5173);
const HTTP_PROXY = { '/boltz': 'http://localhost:9001', '/esplora': 'http://localhost:3000', '/sp': 'http://localhost:8888' };
-const WS_PROXY = { '/electrum': 'ws://localhost:50003', '/sp/ws': 'ws://localhost:8888/ws' };
+const WS_PROXY = { '/electrum': 'ws://localhost:50003' };
const JS_ASSETS = { '/jsqr.js': buildJsQr, '/nip46.js': buildNip46, '/verify-worker.js': buildVerifyWorker };
const CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET,POST,OPTIONS', 'access-control-allow-headers': 'content-type' };
diff --git a/docs/REPRODUCIBLE.md b/docs/REPRODUCIBLE.md
index 2a04eb7..58d2167 100644
--- a/docs/REPRODUCIBLE.md
+++ b/docs/REPRODUCIBLE.md
@@ -11,7 +11,7 @@ Android SDK build-tools 35, Gradle 8.12 (android/gradle/wrapper), AGP 8.7.3.
```sh
bun install --frozen-lockfile
-HAL_NO_SW=1 bun run build # dist/ without the service worker
+COINOS_NO_SW=1 bun run build # dist/ without the service worker (HAL_NO_SW=1 for 1.9 and older)
cd android
COINOS_UNSIGNED=1 gradle assembleFdroidRelease # unsigned
# app/build/outputs/apk/fdroid/release/app-fdroid-release-unsigned.apk
@@ -40,7 +40,7 @@ only `META-INF/` should differ).
assets by `Assets.java`, and a worker would fetch the live site over them.
- The page still lives at the `https://v3.coinos.io/` origin, so storage,
links and gift URLs behave as on the web; only the site's live API paths
- (`/electrum`, `/esplora/`, `/sp/`, `/lnurlp/`, `/.well-known/`, `/api/`)
+ (`/electrum`, `/esplora/`, `/lnurlp/`, `/.well-known/`, `/api/`)
reach the network. Any other path the APK doesn't carry is a 404, never a
fetch from the live site — for the visible app and for the off-screen
WebView a UnifiedPush wake starts (`WakeJob.java`) alike.
diff --git a/package.json b/package.json
index f857ca6..f728400 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,7 @@
"dev": "bun run dev.js",
"feed:serve": "bun run feed/server.js",
"build": "bun run build.js",
- "build:minimal": "HAL_FEATURES=none bun run build.js"
+ "build:minimal": "COINOS_FEATURES=none bun run build.js"
},
"dependencies": {
"@jsr/fiatjaf__promenade-trusted-dealer": "^0.4.3",
diff --git a/src/app.js b/src/app.js
index b574062..8908258 100644
--- a/src/app.js
+++ b/src/app.js
@@ -5498,12 +5498,7 @@ function reviewView() {
{ class: 'summary col', style: 'gap:0' },
...outs.map((o) =>
h('div', { class: 'line' },
- o.silent
- ? h('span', { class: 'k col', style: 'gap:2px;align-items:flex-start' },
- h('span', { class: 'mono break' }, shortAddr(o.silent, 12, 8)),
- h('span', { class: 'small faint' }, t('silentPaymentNote'))
- )
- : h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)),
+ h('span', { class: 'k mono break' }, shortAddr(o.address, 14, 8)),
h('span', { class: 'v' }, fmtAmount(o.amount), ' ', unitTag())
)
),
@@ -5623,7 +5618,7 @@ function txHistoryItem(tx) {
return h(
'div',
{ class: 'item', style: 'cursor:pointer', onClick: () => openTx(tx.txid) },
- // rail glyph in the direction-tinted circle: feature decoration (ark/sp/
+ // rail glyph in the direction-tinted circle: feature decoration (ark/
// gift) if any, else the Bitcoin mark for a plain on-chain tx. Direction
// is carried by the tint + signed amount.
deco
@@ -5734,7 +5729,7 @@ async function openTx(txid) {
ui.txDetail = txid;
render();
const tx = wallet.history.find((t) => t.txid === txid);
- if (!tx || tx.sp || tx.fee || wallet.offline) return;
+ if (!tx || tx.fee || wallet.offline) return;
try {
const full = await wallet.api.getTx(txid);
if (!full || ui.txDetail !== txid) return;
diff --git a/src/build-flags.js b/src/build-flags.js
index e466da2..46060b2 100644
--- a/src/build-flags.js
+++ b/src/build-flags.js
@@ -1,5 +1,5 @@
// Build-time flags. This module ships its defaults (a production build); the
-// deploy hook builds the staging webroot with HAL_TARGET=staging and build.js
+// deploy hook builds the staging webroot with COINOS_TARGET=staging and build.js
// swaps these values in the bundle (same mechanism as features/index.js) — so
// staging behavior lives on master instead of a perpetually-rebased branch.
//
diff --git a/src/features/index.js b/src/features/index.js
index 26324dc..0471c6a 100644
--- a/src/features/index.js
+++ b/src/features/index.js
@@ -1,6 +1,6 @@
// Which optional features this build includes. In dev (and by default in
// builds) everything is on. Release builds can exclude features entirely:
-// build.js replaces this module's contents based on HAL_FEATURES, so a
+// build.js replaces this module's contents based on COINOS_FEATURES, so a
// disabled feature's code — and its network endpoints — never enters the
// bundle. See featureIndexSource() in build.js.
//
diff --git a/src/i18n.js b/src/i18n.js
index bd2c440..1ff5baa 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -916,7 +916,6 @@ const STR = {
sourceUrlHint: 'wss:// for an Electrum server, or an Esplora REST base (https://…/api). The type is detected from the URL.',
detectedElectrum: 'Electrum: data + instant payment notifications.',
detectedEsplora: 'Block explorer (Esplora): data only; polls for payments.',
- silentPaymentNote: 'Silent payment',
receiveAddressTab: 'Savings',
receiveLnTab: 'Lightning',
arkReceiveIntro: 'Receive instantly and free, straight into Spending.',
diff --git a/src/locales/ar.json b/src/locales/ar.json
index 52be78b..bf7bcc6 100644
--- a/src/locales/ar.json
+++ b/src/locales/ar.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss: for an Electrum server, or an Esplora REST base (https://.../api). ويُكتشف هذا النوع من المتفجرات من مخلفات الحرب.",
"detectedElectrum": "Electrum - data + immediately payment notifications.",
"detectedEsplora": "مستكشف القفل )اسبورا( - البيانات فقط؛ عمليات الاقتراع للمدفوعات.",
- "silentPaymentNote": "مدفوعات صامتة",
"receiveAddressTab": "الوفورات",
"receiveLnTab": "البرق",
"receiveSpTab": "مدفوعات صامتة",
diff --git a/src/locales/bn.json b/src/locales/bn.json
index 0ae389a..2689e34 100644
--- a/src/locales/bn.json
+++ b/src/locales/bn.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "এসএপ্লারাম সার্ভার বা এসপিআরআরআরটি (http://http://. URL থেকে ধরন পাওয়া গিয়েছে।",
"detectedElectrum": "ইলেক্ট্রম, তথ্য + তাৎক্ষনিকভাবে দানের বিজ্ঞপ্তি",
"detectedEsplora": "ব্লক (এম্পোরা), কেবলমাত্র তথ্য; পেমেন্টের জন্য ভোট।",
- "silentPaymentNote": "নিঃশব্দ",
"receiveAddressTab": "সংরক্ষণ করা হচ্ছে",
"receiveLnTab": "হালকা বৃষ্টি",
"receiveSpTab": "নিঃশব্দ",
diff --git a/src/locales/de.json b/src/locales/de.json
index 796255a..eb64335 100644
--- a/src/locales/de.json
+++ b/src/locales/de.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss:// für einen Electrum-Server oder eine Esplora-REST-Basis (https://.../api). Der Typ wird von der URL erkannt.",
"detectedElectrum": "Electrum Daten + sofortige Zahlungsmeldungen.",
"detectedEsplora": "Block Explorer (Esplora) Nur Daten; Umfragen für Zahlungen.",
- "silentPaymentNote": "Stille Zahlung",
"receiveAddressTab": "Einsparungen",
"receiveLnTab": "Blitz",
"receiveSpTab": "Stille Zahlung",
diff --git a/src/locales/es.json b/src/locales/es.json
index c600f11..42d3e0a 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss:// for an Electrum server, or an Esplora REST base (https://.../api). El tipo se detecta desde la URL.",
"detectedElectrum": "Electrum, datos + notificaciones de pago instantáneo.",
"detectedEsplora": "Block explorer (Esplora), datos solamente; encuestas para pagos.",
- "silentPaymentNote": "Pago silencioso",
"receiveAddressTab": "Ahorros",
"receiveLnTab": "Rayos",
"receiveSpTab": "Pago silencioso",
diff --git a/src/locales/fr.json b/src/locales/fr.json
index 742e450..2ebc60c 100644
--- a/src/locales/fr.json
+++ b/src/locales/fr.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss:// pour un serveur Electrum, ou une base Esplora REST (https://.../api). Le type est détecté depuis l'URL.",
"detectedElectrum": "Electrum, données + notifications de paiement instantanées.",
"detectedEsplora": "Block Explorer (Esplora), données uniquement; sondages pour les paiements.",
- "silentPaymentNote": "Paiement silencieux",
"receiveAddressTab": "Économies",
"receiveLnTab": "Lumière",
"receiveSpTab": "Paiement silencieux",
diff --git a/src/locales/hi.json b/src/locales/hi.json
index fe0ceda..fdaa332 100644
--- a/src/locales/hi.json
+++ b/src/locales/hi.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "एक Electrum सर्वर, या एक Esplora REST बेस (https://.../api). प्रकार यूआरएल से पता लगाया जाता है।",
"detectedElectrum": "Electrum, डेटा + तत्काल भुगतान सूचनाएं।",
"detectedEsplora": "ब्लॉक एक्सप्लोरर (Esplora) - डेटा केवल; भुगतान के लिए मतदान।",
- "silentPaymentNote": "भुगतान",
"receiveAddressTab": "बचत",
"receiveLnTab": "बिजली",
"receiveSpTab": "भुगतान",
diff --git a/src/locales/ja.json b/src/locales/ja.json
index e9aa821..b8d9fb9 100644
--- a/src/locales/ja.json
+++ b/src/locales/ja.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss:// Electrum サーバ、または Esplora REST ベース (https://.../api) 用。 URLからタイプが検出されます。",
"detectedElectrum": "Electrum, データ + 即時決済通知。",
"detectedEsplora": "探索者(Esplora)をブロック, データのみ; 支払いの投票。",
- "silentPaymentNote": "無声支払い",
"receiveAddressTab": "節約",
"receiveLnTab": "ライトニング",
"receiveSpTab": "無声支払い",
diff --git a/src/locales/ko.json b/src/locales/ko.json
index c6b77e3..b81a617 100644
--- a/src/locales/ko.json
+++ b/src/locales/ko.json
@@ -476,7 +476,6 @@
"sourceUrlHint": "wss:// Electrum 서버, 또는 Esplora REST베이스 (https://.../api). 유형은 URL에서 감지됩니다.",
"detectedElectrum": "Electrum - 데이터 + 즉시 결제 알림.",
"detectedEsplora": "Block Explorer (Esplora) - 데이터 만; 지불에 대한 설문 조사.",
- "silentPaymentNote": "공급 능력",
"receiveAddressTab": "제품정보",
"receiveLnTab": "연락처",
"receiveSpTab": "공급 능력",
diff --git a/src/locales/pt.json b/src/locales/pt.json
index 10d1c32..ceb6c36 100644
--- a/src/locales/pt.json
+++ b/src/locales/pt.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss:// para um servidor Electrum, ou uma base Esplora REST (https://.../api). O tipo é detectado a partir do URL.",
"detectedElectrum": "Electrum, dados + notificações de pagamento instantâneas.",
"detectedEsplora": "Explorador de bloco (Espora), apenas dados; sondagens para pagamentos.",
- "silentPaymentNote": "Pagamento silencioso",
"receiveAddressTab": "Poupança",
"receiveLnTab": "Raios",
"receiveSpTab": "Pagamento silencioso",
diff --git a/src/locales/ru.json b/src/locales/ru.json
index cc82269..72f1d1b 100644
--- a/src/locales/ru.json
+++ b/src/locales/ru.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss:// для сервера Electrum или базы Esplora REST (https://.../api). Тип определяется по URL.",
"detectedElectrum": "Electrum, данные + уведомления о мгновенных платежах.",
"detectedEsplora": "Block explorer (Esplora), данные только; опросы по платежам.",
- "silentPaymentNote": "Безмолвный платеж",
"receiveAddressTab": "Экономия",
"receiveLnTab": "Молния",
"receiveSpTab": "Безмолвный платеж",
diff --git a/src/locales/tr.json b/src/locales/tr.json
index b2dede8..e0d87c6 100644
--- a/src/locales/tr.json
+++ b/src/locales/tr.json
@@ -476,7 +476,6 @@
"sourceUrlHint": "wss https:// for an Electrum server, or an Esplora REST üssü (https://.../api). Tür URL'den tespit edilir.",
"detectedElectrum": "Electrum - veri + anında ödeme bildirimleri.",
"detectedEsplora": "Block Explorer (Esplora) - veriler sadece; ödemeler için anketler.",
- "silentPaymentNote": "Sessiz ödeme",
"receiveAddressTab": "Tasarruf Tasarrufları",
"receiveLnTab": "Lightning Lightning Lightning",
"receiveSpTab": "Sessiz ödeme",
diff --git a/src/locales/zh.json b/src/locales/zh.json
index 5679fc4..9493ab8 100644
--- a/src/locales/zh.json
+++ b/src/locales/zh.json
@@ -477,7 +477,6 @@
"sourceUrlHint": "wss://用于Electrum服务器,或Esplora REST基础(https://./api). 从 URL 中检测到该类型 。",
"detectedElectrum": "Electrum,数据+即时付款通知.",
"detectedEsplora": "Block Explora (Esplora),仅提供数据;支付投票.",
- "silentPaymentNote": "无声付款",
"receiveAddressTab": "节余",
"receiveLnTab": "闪电",
"receiveSpTab": "无声付款",
diff --git a/src/silentpay.js b/src/silentpay.js
deleted file mode 100644
index eeabc1a..0000000
--- a/src/silentpay.js
+++ /dev/null
@@ -1,341 +0,0 @@
-// Silent Payments (BIP-352) — sending and receiving.
-//
-// A silent payment address (sp1…) is a static, reusable address the receiver
-// publishes once. The sender derives a unique one-time taproot output for each
-// payment from the receiver's address plus the sender's own input keys (ECDH),
-// so there's no on-chain link between payments and no address reuse — without
-// any interaction with the receiver.
-//
-// Sending (silentPaymentScripts) derives the output; we only spend compressed
-// P2WPKH inputs, so each input contributes its private key as-is. Receiving (see
-// the second half) can't scan the chain in a browser, so an indexing server
-// supplies per-tx tweaks + taproot UTXOs and we match them on-device.
-//
-// Validated against the official BIP-352 send + receive test vectors.
-
-import { secp256k1 } from '@noble/curves/secp256k1';
-import { sha256 } from '@noble/hashes/sha256';
-import { ripemd160 } from '@noble/hashes/ripemd160';
-import { hexToBytes, bytesToHex, concatBytes } from '@noble/hashes/utils';
-import { bech32m } from '@scure/base';
-
-const hash160 = (b) => ripemd160(sha256(b));
-
-const Point = secp256k1.ProjectivePoint;
-const G = Point.BASE;
-const N = secp256k1.CURVE.n;
-
-const modN = (x) => ((x % N) + N) % N;
-const toInt = (bytes) => BigInt('0x' + bytesToHex(bytes));
-// tagged hash: sha256(sha256(tag) || sha256(tag) || msg)
-const tagged = (tag, msg) => {
- const t = sha256(new TextEncoder().encode(tag));
- return sha256(concatBytes(t, t, msg));
-};
-const le32 = (n) => {
- const b = new Uint8Array(4);
- new DataView(b.buffer).setUint32(0, n >>> 0, true);
- return b;
-};
-const be32 = (n) => {
- const b = new Uint8Array(4);
- new DataView(b.buffer).setUint32(0, n >>> 0, false);
- return b;
-};
-// A taproot scriptPubKey for a 32-byte x-only key: OP_1 (0x51) PUSH32 (0x20) key.
-const taprootScript = (xonly) => concatBytes(Uint8Array.of(0x51, 0x20), xonly);
-
-export function isSilentPaymentAddress(addr) {
- if (typeof addr !== 'string') return false;
- const a = addr.toLowerCase();
- if (!a.startsWith('sp1') && !a.startsWith('tsp1')) return false;
- try {
- decodeSilentPaymentAddress(addr);
- return true;
- } catch {
- return false;
- }
-}
-
-// Decode sp1…/tsp1… → { scan, spend } (33-byte compressed pubkeys each) + net.
-export function decodeSilentPaymentAddress(addr) {
- const { prefix, words } = bech32m.decode(addr.toLowerCase(), 1023);
- if (prefix !== 'sp' && prefix !== 'tsp') throw new Error('Not a silent payment address.');
- if (words[0] !== 0) throw new Error('Unsupported silent payment version.');
- const data = bech32m.fromWords(words.slice(1));
- if (data.length !== 66) throw new Error('Malformed silent payment address.');
- return { scan: data.slice(0, 33), spend: data.slice(33, 66), testnet: prefix === 'tsp' };
-}
-
-// Derive the one-time taproot output scripts for a set of silent-payment outputs
-// that this transaction's inputs are funding.
-// inputs: [{ txid (display hex), vout, priv (Uint8Array 32), taproot? }] — ALL
-// eligible inputs of the tx; the derivation commits to every one. Set
-// taproot:true for P2TR inputs (e.g. our own silent-payment coins) so
-// their key is taken with even Y, per BIP-352.
-// outputs: [{ scan, spend }] — one entry per desired output (repeat the same
-// recipient to pay it multiple times; the k counter is handled here).
-// Returns a taproot scriptPubKey (Uint8Array) for each entry, in order.
-export function silentPaymentScripts(inputs, outputs) {
- if (!inputs.length) throw new Error('Silent payment needs at least one input.');
-
- // a = Σ input private keys (mod n); A = a·G. A taproot input contributes the
- // key for its even-Y x-only pubkey, so negate when the key gives odd Y.
- let a = 0n;
- for (const i of inputs) {
- let d = toInt(i.priv);
- if (i.taproot && Number(G.multiply(d).toRawBytes(true)[0]) === 0x03) d = modN(-d);
- a = modN(a + d);
- }
- if (a === 0n) throw new Error('Silent payment input key sum is zero.');
- const A = G.multiply(a);
-
- // input_hash = H(smallest_outpoint || A); outpoint = txid(wire order) || vout(LE)
- let smallest = null;
- for (const i of inputs) {
- const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout));
- if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op;
- }
- const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, A.toRawBytes(true)))));
-
- const counters = new Map(); // recipient → next k
- return outputs.map(({ scan, spend }) => {
- const rkey = bytesToHex(scan) + bytesToHex(spend);
- const k = counters.get(rkey) || 0;
- counters.set(rkey, k + 1);
- // ecdh = input_hash·a·B_scan ; t_k = H(ecdh || ser32(k)) ; P = B_spend + t_k·G
- const ecdh = Point.fromHex(scan).multiply(modN(inputHash * a)).toRawBytes(true);
- const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k)))));
- const Pout = Point.fromHex(spend).add(G.multiply(tk));
- return taprootScript(Pout.toRawBytes(true).slice(1)); // x-only
- });
-}
-
-// A unique, valid placeholder taproot scriptPubKey used to size a silent-payment
-// output during coin selection, before the real script can be derived (which
-// needs the final input set). Swapped out for the real one afterward.
-export function silentPaymentPlaceholder(index) {
- return taprootScript(sha256(concatBytes(new TextEncoder().encode('halsp-placeholder'), be32(index))));
-}
-
-// ===========================================================================
-// Receiving (BIP-352).
-//
-// A browser light client can't scan the chain itself, so an indexing server (an
-// SP "oracle") provides, per block, the tweaks — one 33-byte point (input_hash·A,
-// the sum of a tx's eligible input pubkeys folded with its input hash) per
-// eligible transaction — plus the taproot UTXOs created in that block. The
-// receiver then does the private part on-device: ecdh = b_scan · tweak, then for
-// k = 0,1,… derives P_k = B_spend + t_k·G and matches it against the block's
-// outputs. The scan key never leaves the device.
-//
-// The first half here (input pubkey extraction + tweak) is what the indexer
-// computes from raw blocks; the second half (scan, output key, key derivation,
-// address) is what the wallet uses. They share one implementation so the math
-// is validated once, against the official BIP-352 vectors.
-// ===========================================================================
-
-const numTo32 = (x) => hexToBytes(x.toString(16).padStart(64, '0'));
-
-// secp256k1 NUMS point H (BIP-341): a taproot input whose script-path reveals
-// this as the internal key is provably unspendable and NOT an eligible input.
-const NUMS_H = '50929b74c1a04954b78b4b6035e97a5e078a5a0f28ec96d547bfee9ace803ac0';
-
-// Parse a serialized witness (hex): compact-size item count, then each item as
-// compact-size length + bytes.
-function parseWitness(hex) {
- if (!hex) return [];
- const b = hexToBytes(hex);
- let o = 0;
- const rd = () => {
- let n = b[o++];
- if (n === 0xfd) { n = b[o] | (b[o + 1] << 8); o += 2; }
- else if (n === 0xfe) { n = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; }
- return n;
- };
- const count = rd();
- const items = [];
- for (let i = 0; i < count; i++) { const len = rd(); items.push(b.slice(o, o + len)); o += len; }
- return items;
-}
-
-// Witness stack items as byte arrays. Accepts either a serialized witness (hex
-// string, as in the BIP-352 vectors) or an array of hex stack items (as Core's
-// getblock RPC returns in txinwitness).
-function witnessItems(witness) {
- if (Array.isArray(witness)) return witness.map(hexToBytes);
- return parseWitness(witness);
-}
-
-// Pull the data pushes out of a scriptSig (hex) — enough to recover the pubkey
-// from P2PKH or the redeemScript from P2SH (push opcodes only).
-function scriptPushes(hex) {
- if (!hex) return [];
- const b = hexToBytes(hex);
- let o = 0;
- const pushes = [];
- while (o < b.length) {
- const op = b[o++];
- let len = -1;
- if (op >= 0x01 && op <= 0x4b) len = op;
- else if (op === 0x4c) len = b[o++];
- else if (op === 0x4d) { len = b[o] | (b[o + 1] << 8); o += 2; }
- else if (op === 0x4e) { len = b[o] | (b[o + 1] << 8) | (b[o + 2] << 16) | (b[o + 3] * 0x1000000); o += 4; }
- else continue; // non-push opcode
- if (len < 0 || o + len > b.length) break;
- pushes.push(b.slice(o, o + len)); o += len;
- }
- return pushes;
-}
-
-// Only compressed pubkeys are eligible — uncompressed-key inputs are skipped.
-const looksLikePubKey = (b) => !!b && b.length === 33 && (b[0] === 0x02 || b[0] === 0x03);
-
-// Recover the BIP-352-eligible input public key (33-byte compressed) from a tx
-// input, or null if the input type isn't eligible. Handles P2PKH, P2WPKH,
-// P2SH-P2WPKH and P2TR (key-path) inputs.
-export function inputPubKey({ scriptSig = '', witness = '', scriptPubKey = '' }) {
- try {
- const spk = hexToBytes(scriptPubKey);
- // P2TR: OP_1 PUSH32 <x-only>
- if (spk.length === 34 && spk[0] === 0x51 && spk[1] === 0x20) {
- let w = witnessItems(witness);
- if (w.length > 1 && w[w.length - 1][0] === 0x50) w = w.slice(0, -1); // strip annex
- if (w.length > 1 && bytesToHex(w[w.length - 1].slice(1, 33)) === NUMS_H) return null; // NUMS script-path
- return Point.fromHex(concatBytes(Uint8Array.of(0x02), spk.slice(2, 34))).toRawBytes(true); // even-Y lift
- }
- // P2WPKH: OP_0 PUSH20
- if (spk.length === 22 && spk[0] === 0x00 && spk[1] === 0x14) {
- const w = witnessItems(witness);
- const pk = w[w.length - 1];
- return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null;
- }
- // P2SH-P2WPKH: scriptSig pushes redeemScript 0014<20>; witness = [sig, pubkey]
- if (spk.length === 23 && spk[0] === 0xa9 && spk[1] === 0x14 && spk[22] === 0x87) {
- const rs = scriptPushes(scriptSig)[0];
- if (!rs || rs.length !== 22 || rs[0] !== 0x00 || rs[1] !== 0x14) return null;
- const w = witnessItems(witness);
- const pk = w[w.length - 1];
- return looksLikePubKey(pk) ? Point.fromHex(pk).toRawBytes(true) : null;
- }
- // P2PKH: OP_DUP OP_HASH160 PUSH20 <hash> ... — the eligible pubkey is the
- // scriptSig push whose hash160 matches <hash> (handles malleated scriptSigs).
- if (spk.length === 25 && spk[0] === 0x76 && spk[1] === 0xa9 && spk[2] === 0x14) {
- const target = bytesToHex(spk.slice(3, 23));
- const pushes = scriptPushes(scriptSig);
- for (let i = pushes.length - 1; i >= 0; i--) {
- const pk = pushes[i];
- if (looksLikePubKey(pk) && bytesToHex(hash160(pk)) === target) return Point.fromHex(pk).toRawBytes(true);
- }
- return null;
- }
- } catch {}
- return null;
-}
-
-// The oracle tweak for a transaction: 33-byte point input_hash·A, where A is the
-// sum of the eligible input pubkeys and input_hash = H_BIP0352/Inputs(outpoint_L
-// || A) with outpoint_L the smallest outpoint among ALL the tx's inputs.
-// inputs: [{ txid (display hex), vout, pubkey? (Uint8Array 33) }] — every input
-// of the tx; `pubkey` present only for BIP-352-eligible inputs.
-// Returns null if the tx has no eligible inputs (so it can't be a silent payment).
-export function silentPaymentTweak(inputs) {
- let A = null;
- for (const i of inputs) if (i.pubkey) A = A ? A.add(Point.fromHex(i.pubkey)) : Point.fromHex(i.pubkey);
- if (!A) return null;
- let Abytes;
- try { Abytes = A.toRawBytes(true); } catch { return null; } // A is the point at infinity → not a silent payment
- let smallest = null;
- for (const i of inputs) {
- const op = concatBytes(hexToBytes(i.txid).reverse(), le32(i.vout));
- if (!smallest || bytesToHex(op) < bytesToHex(smallest)) smallest = op;
- }
- const inputHash = modN(toInt(tagged('BIP0352/Inputs', concatBytes(smallest, Abytes))));
- return A.multiply(inputHash).toRawBytes(true);
-}
-
-// Scan a transaction's taproot outputs for payments to us.
-// scanPriv: Uint8Array(32) our scan private key (b_scan)
-// spendPub: Uint8Array(33) our spend public key (B_spend)
-// tweak: Uint8Array(33) the oracle tweak for this tx (input_hash·A)
-// outputs: array of 32-byte x-only output pubkeys (hex) in the tx
-// Returns the matched outputs: [{ output (x-only hex), k, tweak (t_k hex 32) }];
-// `tweak` (t_k) is added to the spend private key to spend the coin.
-export function silentPaymentScan({ scanPriv, spendPub, tweak, outputs, maxK = 30 }) {
- const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true);
- const B = Point.fromHex(spendPub);
- const remaining = new Set(outputs.map((o) => o.toLowerCase()));
- const found = [];
- for (let k = 0; k < maxK && remaining.size; k++) {
- const tk = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(k)))));
- const xonly = bytesToHex(B.add(G.multiply(tk)).toRawBytes(true).slice(1));
- if (!remaining.has(xonly)) break; // no output at this k → done with this tx
- found.push({ output: xonly, k, tweak: bytesToHex(numTo32(tk)) });
- remaining.delete(xonly);
- }
- return found;
-}
-
-// Spend private key for a found SP output: d = (spend_priv + t_k) mod n. Used as
-// the taproot key-path key (the signer negates for odd-Y per BIP-340).
-export function silentPaymentOutputPrivKey(spendPriv, tweakK) {
- return numTo32(modN(toInt(spendPriv) + toInt(tweakK)));
-}
-
-// Derive the BIP-352 scan + spend keys from a BIP32 master node.
-// spend: m/352'/coin'/account'/0'/0 scan: m/352'/coin'/account'/1'/0
-export function deriveSilentPaymentKeys(master, coin, account = 0) {
- const spend = master.derive(`m/352'/${coin}'/${account}'/0'/0`);
- const scan = master.derive(`m/352'/${coin}'/${account}'/1'/0`);
- return {
- scanPriv: scan.privateKey, scanPub: scan.publicKey,
- spendPriv: spend.privateKey, spendPub: spend.publicKey,
- };
-}
-
-// Encode an sp1…/tsp1… address from scan + spend public keys (33 bytes each).
-export function encodeSilentPaymentAddress(scanPub, spendPub, { testnet = false } = {}) {
- const hrp = testnet ? 'tsp' : 'sp';
- const words = [0, ...bech32m.toWords(concatBytes(scanPub, spendPub))];
- return bech32m.encode(hrp, words, 1023);
-}
-
-// ---- catch-up scaling: candidate keys + per-block Bloom filters -----------
-// For a large historical scan, downloading every block's outputs is wasteful.
-// The indexer gives, per block, the tweaks + a Bloom filter over that block's
-// taproot output keys. The client derives its candidate key P_0 from each tweak
-// and tests the filter; only on a hit does it pull the block's outputs and run
-// the full scan. (P_0 always exists if we're paid, so testing it alone suffices.)
-
-// Our candidate output key (P_0 = B_spend + t_0·G, x-only hex) for one tweak.
-export function silentPaymentCandidate({ scanPriv, spendPub, tweak }) {
- const ecdh = Point.fromHex(tweak).multiply(modN(toInt(scanPriv))).toRawBytes(true);
- const t0 = modN(toInt(tagged('BIP0352/SharedSecret', concatBytes(ecdh, be32(0)))));
- return bytesToHex(Point.fromHex(spendPub).add(G.multiply(t0)).toRawBytes(true).slice(1));
-}
-
-// k bit indices for a 32-byte x-only key (hex) in an m-bit filter, via
-// double-hashing one sha256(key): idx_i = (h1 + i·h2) mod m.
-function* bloomIndices(keyHex, m, k) {
- const h = sha256(hexToBytes(keyHex));
- const u32 = (o) => (h[o] | (h[o + 1] << 8) | (h[o + 2] << 16) | (h[o + 3] * 0x1000000)) >>> 0;
- const h1 = u32(0);
- const h2 = u32(4) | 1; // odd, so it's coprime-ish with m
- for (let i = 0; i < k; i++) yield ((h1 + i * h2) >>> 0) % m;
-}
-
-// Build a Bloom filter over x-only keys (hex). { m (bits), k (hashes), bits (hex) }.
-export function buildBloom(keysHex, bitsPerKey = 10, k = 7) {
- const m = Math.max(8, keysHex.length * bitsPerKey);
- const bytes = new Uint8Array(Math.ceil(m / 8));
- for (const key of keysHex) for (const idx of bloomIndices(key, m, k)) bytes[idx >> 3] |= 1 << (idx & 7);
- return { m, k, bits: bytesToHex(bytes) };
-}
-
-// Is an x-only key (hex) possibly in the filter? (false = definitely not.)
-export function bloomHas(filter, keyHex) {
- if (!filter || !filter.m) return true; // no filter ⇒ can't rule out → scan it
- const bytes = hexToBytes(filter.bits);
- for (const idx of bloomIndices(keyHex, filter.m, filter.k)) if (!(bytes[idx >> 3] & (1 << (idx & 7)))) return false;
- return true;
-}
diff --git a/tools/minimal-smoke.js b/tools/minimal-smoke.js
index 9d7b75b..ebb57f5 100644
--- a/tools/minimal-smoke.js
+++ b/tools/minimal-smoke.js
@@ -1,4 +1,4 @@
-// Smoke test for the minimal build profile (HAL_FEATURES=none): a plain
+// Smoke test for the minimal build profile (COINOS_FEATURES=none): a plain
// on-chain wallet with no swap/Ark/gift/SP surfaces — and still fully working
// (imports a wallet, receives a payment) against the regtest stack.
//