DigiByte.X Wallet — recovery-first web wallet monorepo

Packages: core (BIP39/32/44/49/84/86 derivation, WIF), keystore (PBKDF2 +
AES-GCM), psbt, p2p (BIP157/158 SPV daemon, Dandelion++ broadcast with
relay-echo verification), blockbook, rpc, electrum, net, bns, web-ui
(React/Vite), mobile (Expo). ops/ holds the nginx vhost, systemd unit and
push.sh deploy script for digibyte.x/web.
This commit is contained in:
Digibyte.X 2026-09-20 20:16:52 +02:00
commit d50286a9a5
189 changed files with 44669 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# dependencies / build output
node_modules/
dist/
packages/*/dist/
packages/mobile/.expo/
# local secrets & env (never commit — RPC creds, API keys, wallets)
.env
.env.*
*.env
rpc.env
nownodes.env
wallets.json
sia-s3.json
*.pem
*.key
# editor / tooling state
.claude/
.vscode/
.idea/
*.log
.DS_Store
Thumbs.db
packages/p2p/--clear/

874
RESEARCH.md Normal file
View file

@ -0,0 +1,874 @@
# DigiByte Mobile Wallet — Research Report
Compiled 2026-08-02, revised 2026-08-03. Purpose: decide the shape of a
new DigiByte mobile wallet that supports **BIP39 seed phrase recovery**
and **raw private key (WIF) import**, given the ecosystem's current
state.
> **Note on Jared Tate (correction, 2026-08-03):** the "founder gone"
> phrasing in §1 below overstates the situation. Per the user, Tate has
> remained active in 2025–2026. Treat the 2020 stepdown / 2023
> disengagement quotes as historical, not current, and re-verify current
> activity before treating "founder absent" as a real ecosystem risk.
> **Architecture note (2026-08-03):** §4 and §7 recommend Blockbook as
> the default backend. **That recommendation is superseded by §10** —
> the P2P-native SPV design with user-selectable privacy modes. §4 and
> §7 are retained as history of the design evolution.
---
## 1. Is DigiByte actually dying?
Short answer: **the chain is alive, the ecosystem is thin.** Building is
feasible; you'd be one of the more active teams in it.
- **Core is maintained.** DigiByte Core `v8.26.2` released 2026-01-16 (previous
`8.26.1` Oct 2025 shipped Taproot). Dev branch is `9.26.0`.
- **Founder gone.** Jared Tate stepped down in May 2020, publicly disengaged
in July 2023 ("$500 of DGB left, no longer meaningful involvement").
- **No foundation, no funded dev org.** Everything is volunteer. DGBAT
(~20-person outreach team) does marketing/exchange liaison, not development.
- **Market cap** roughly $65–107 M — small-cap altcoin tier. Never listed on
Coinbase. Still on Binance/Kraken/KuCoin as of the search snapshot (verify
directly if load-bearing).
- **Official mobile wallet.** iOS listing `id6481703766` "DigiByte - Wallet"
by **Sigma Systems Inc.** — a *replacement* for the old official app
(the prior repo is marked `DEPRECATED---digibyteios`). Newer repo
`digibytewallet-ios` is Electrum-based; recent App Store notes mention
an "Urgent Electrum blockchain connection error fix" — reliability has
been actively patched but the whole thing rests on one small publisher.
**Implication for us:** low competition, but no external funding, small user
base to distribute to, and real bus-factor risk in the wider ecosystem.
---
## 2. Recent hardfork — what changed, what's dead
- **Last activated fork: Taproot at block 21,168,000 (2025-04-07 UTC)**,
shipped in Core `v8.22.2`, productized in `8.26.0`.
- **Next scheduled: DigiDollar / AlgoLock at block 23,869,440** (not yet
reached; defined in `chainparams.cpp`).
Consensus/protocol changes in the recent line:
- **BIP 340/341/342** Taproot / Schnorr / MAST / Tapscript, **BIP 86**
descriptors, new witness-v1 HRP `dgb1p…`.
- **BIP 157/158** compact block filters (server-side).
- **BIP 324** encrypted v2 P2P transport.
- Protocol version 70018 → 70019.
- Coinbase maturity dropped to 8 blocks after a height threshold.
- `v8.22.0` removed legacy Odocrypt pre-fork code — anything before
Odocrypt (block 9,112,320, July 2019) is on a dead fork.
Historical relevant forks: DigiShield, MultiAlgo (5 PoW algos: SHA256,
Scrypt, Skein, Qubit, Odocrypt), MultiShield, DigiSpeed, SegWit
(activated April 2017 — DGB was first major altcoin).
**Third-party wallet compatibility right now:**
- Atomic, Exodus, Coinomi, Trust, Guarda — all light/hosted, none stranded
by any recent fork.
- **None of them issue `dgb1p…` (Taproot) addresses yet.** That is a
genuine differentiation opening for a new wallet.
---
## 3. Full-node reality (why mobile can't just run one)
- **Chain height:** ~22.77 M blocks as of 2026-01-09.
- **Block time:** 15 seconds (target across 5 algos combined).
- **Full node on disk:** roughly **45–60 GB**; pruned recommended at
`prune=8192` (~8 GB).
- **Blocks/year:** ~2,102,400 — **40× Bitcoin's** rate.
- **Just the header chain is ~1.8 GB** (22.7 M × 80 bytes) vs Bitcoin's
~70 MB. Header download + per-algo PoW validation dominate any
attempted mobile sync — this is why prior "load-all-blocks" mobile
approaches failed.
---
## 4. Lightweight-wallet options
| Approach | Verdict for DGB |
|---|---|
| **BIP37 bloom-filter SPV** | Defaults are off in Core 8.x (following Bitcoin's 2019 deprecation). Not recommended. |
| **BIP157/158 Neutrino** | Server-side code is in Core, but no maintained DGB fork of `btcd`/`lnd`/`neutrino` with DGB net params. **Effectively unavailable.** |
| **Electrum-DGB servers** | `ibbtco/electrum-dgb-server` (ElectrumX fork) exists. ~5–15 usable public servers (`electrum1.cyberbits.eu:50022` etc.). Thin but usable. |
| **Blockbook / hosted indexer** | **Best option.** SatoshiLabs' Blockbook has first-class DGB config; Trezor Suite uses it in production. NOWNodes offers hosted. Self-host for reliability. |
**Recommended architecture:**
```
┌────────────────────────────────────────────────┐
│ Mobile Wallet App │
│ │
│ Key management: bitcoinjs-lib + DGB network │
│ - BIP39 seed (12/24 word) │
│ - BIP44/49/84/86 derivation (coin type 20) │
│ - PSBT construction & signing on-device │
│ - WIF import │
│ │
│ Never leaves the phone: keys, seed, xprv │
└──────────────────┬─────────────────────────────┘
│ HTTPS + WebSocket
▼
┌────────────────────────────────────────────────┐
│ Self-hosted Blockbook (DGB config) │
│ + fallback Electrum-DGB pool │
│ - address balance / UTXO / history │
│ - tx broadcast │
└──────────────────┬─────────────────────────────┘
│
▼
DigiByte Core 8.26.x
```
Optional: background SPV header verification for trust-minimization.
**Delivery estimate:** weeks (hosted-indexer), not months (Neutrino port).
---
## 5. Seed-phrase & private-key recovery — the actual problem
The user's stated pain point: existing wallets don't reliably let you
recover DGB from a seed. Findings:
### DigiByte Core (desktop) — the root of the confusion
- Uses `wallet.dat` (BDB/sqlite) + `dumpprivkey`/`dumpwallet` RPC.
- Newer builds do expose an HD seed, **but it is NOT a portable BIP39
mnemonic.** The DigiByte Wiki explicitly warns: *"DigiByte Core wallets
cannot be restored on other BIP39-compatible apps."*
- **Do not copy this design.** This non-portability is exactly the
interoperability failure users complain about.
### Third-party wallets today — the important distinction
**Having a seed phrase is not the same as being able to import one.**
Most consumer wallets generate their own seed on first launch and only
"restore" seeds they themselves issued. Verified empirically:
| Wallet | Lists DGB | Can I *import* an external DGB seed and see my coins? | Verification status |
|---|---|---|---|
| **Atomic** | Yes | **No** — restore only accepts Atomic-generated seeds; no per-coin import; derivation path not user-visible | ✅ User-verified 2026-08-03 |
| **Exodus** | Yes | **No** — restore only accepts Exodus-generated seeds; legacy P2PKH only; no bech32; no derivation-path exposure | ✅ User-verified 2026-08-03 |
| **Guarda** | **NO — does not list DGB** | n/a | ✅ User-verified 2026-08-03 (contradicts earlier research claim) |
| **Trust Wallet** | Reportedly yes | Reportedly imports a "multi-coin wallet" seed at Trust's fixed paths; no path selection | ⚠️ Needs re-verification — earlier research on this table has been wrong twice |
| **Coinomi** | Reportedly yes | Reportedly yes, but **often needs manual account-index tweak** (…/0'H vs …/1'H); recurring support ticket | ⚠️ Needs re-verification |
| **DigiByte Go / DigiWallet (official)** | Yes | Yes — BIP39 since v1.2 per README | ⚠️ Repo state should be re-verified given the ecosystem's small size |
| **Ian Coleman BIP39 tool** (offline) | n/a | Yes — the community fallback when everything else fails | ✅ Source-verified (`hdCoin: 20`) |
**Health warning on this table:** the automated research populating the
first version overstated wallet support in at least two rows. Rows marked
⚠️ should be treated as unverified until the user or a fresh, hands-on
check confirms them. In particular, "wallet lists DGB" and "wallet lets
you import an external BIP39 seed for DGB" are separate capabilities and
the earlier research conflated them.
The ecosystem summary in one sentence: **you can back up seeds from most
of these wallets, but you cannot bring an existing DGB seed *into* the
mainstream ones (Atomic, Exodus, Trust with caveats).** This is the exact
gap your v1 must fill.
The community's "Wallet Warning" article (Medium, pappawecho) describes a
past incident where a third-party DGB wallet went offline **without following
BIP44** — users' seeds couldn't be restored anywhere. That is the incident
your requirement is really guarding against.
### Chain parameters our wallet must implement
From `src/kernel/chainparams.cpp` `CMainParams`:
| Parameter | Value |
|---|---|
| PUBKEY_ADDRESS | `30` (0x1E) → `D…` |
| SCRIPT_ADDRESS | `63` (0x3F) → `S…` |
| SECRET_KEY (WIF) | `128` (0x80) → `Q`/`L`/`K` compressed, `6` uncompressed |
| EXT_PUBLIC_KEY (xpub) | `04 88 B2 1E` |
| EXT_SECRET_KEY (xprv) | `04 88 AD E4` |
| bech32 HRP | `dgb` (→ `dgb1q…` v0, `dgb1p…` v1/Taproot) |
| Magic bytes | `FA C3 B6 DA` |
| P2P port | `12024` (RPC `14022`) |
| DNS seeds | `seed.digibyte.io`, `seed.diginode.tools`, `seed.digibyte.link`, `seed.aroundtheblock.app`, `seed.tuyul.cc` |
| SLIP-0044 coin type | **20** (`0x80000014`) |
### Derivation paths to support
```
m/44'/20'/0'/0/x → D… (legacy P2PKH)
m/49'/20'/0'/0/x → S… (P2SH-wrapped SegWit)
m/84'/20'/0'/0/x → dgb1q… (native SegWit v0) ← default
m/86'/20'/0'/0/x → dgb1p… (Taproot v1) ← differentiator
```
Supporting all four keeps us interoperable with everything above **and**
Ian Coleman's BIP39 tool (which has DGB in its coin dropdown at
`hdCoin: 20` — the community's recommended fallback).
### Recovery tools users already trust (cross-check against these)
- **Ian Coleman BIP39 tool** — `iancoleman.io/bip39`, source has
`"DGB - Digibyte"` entry
- **btcrecover / seedrecover.py**
- **hd-wallet-derive** (PHP)
- **bip_utils** (Python) — `DigibyteMainNet`
---
## 6. Libraries for the build
- **JS (recommended):** upstream `bitcoinjs-lib` accepts a custom `Network`
object — pass DGB's version bytes above and you get keygen, address
derivation, and PSBT for all four descriptor classes without needing a
fork. Cleanest path.
- Fork alternatives: `DigiByte-Team/digibytejs-lib`,
`RenzoDD/digibyte-js` (Bitcore-style).
- **.NET/C++/Python:** no maintained DGB-specific forks; `NBitcoin` and
`python-bitcoinlib` both accept custom chain params.
- **Blockbook client:** SatoshiLabs ships a JS/HTTP client — WebSocket
subscriptions for real-time balance updates.
---
## 7. Proposed build shape
**Client (mobile, React Native or Flutter):**
1. Onboarding: generate BIP39 12/24-word seed OR import existing seed
OR import raw WIF.
2. Derive addresses at all four descriptor classes; default receive to
`dgb1q…`, offer `dgb1p…` (Taproot) as an opt-in per-account setting.
3. Keys/xprv encrypted with device secure enclave + PIN; seed export
available in Settings > Backup.
4. Build & sign transactions on-device with `bitcoinjs-lib` (PSBT).
**Backend (self-hosted, one small VM):**
1. DigiByte Core 8.26.x full node.
2. Blockbook (Trezor's `digibyte.json` config).
3. HTTPS + WebSocket exposed to mobile client.
4. Fallback list of public Electrum-DGB servers baked into the app.
**Explicit non-goals (v1):** no lightning, no DigiAssets, no in-app swap,
no in-app buy. Ship the recovery-first wallet first.
### 7a. Import UX requirements (first-class, not buried in settings)
Recovery is the product's reason to exist. It gets primary onboarding real
estate, not a "restore" link in a menu.
**Onboarding entry points (equal weight on first screen):**
1. Create new wallet (generate BIP39 12/24-word seed).
2. **Import from seed phrase** — accepts any BIP39 seed from any source.
3. **Import single private key (WIF)** — accepts `Q…`/`L…`/`K…` compressed
and `6…` uncompressed.
4. Import from `xprv`/`xpub` — for advanced users or watch-only.
**"Import from seed phrase" flow — must-haves:**
- Accept 12, 15, 18, 21, or 24 words (all BIP39 lengths).
- Live word-list validation and checksum verification as the user types.
- Optional BIP39 passphrase field ("25th word"), clearly labeled as
optional, with a warning that it changes the wallet.
- **Derivation-path selection is visible, not hidden:**
- Default: **"Scan all standard paths"** — derives at BIP44/49/84/86,
coin type 20, accounts 0 through 4, and shows every path where funds
are found. This is what solves the Coinomi account-index problem
without making the user diagnose it.
- Advanced: manual path entry (`m/44'/20'/0'/0/*`, etc.) for
non-standard wallets and forensic recovery.
- Show the derivation path next to each discovered address in the review
screen — no opacity. Users need to know what they're importing.
- **Detect and warn on non-portable seeds:** if the user pastes a seed
that decodes as valid BIP39 but yields no addresses at any standard
path, offer a "This may be a DigiByte Core wallet seed — Core seeds are
not BIP39-compatible. See recovery guide." link.
**"Import private key (WIF)" flow — must-haves:**
- Validate base58 checksum before accepting.
- Verify the version byte is `0x80` (DGB WIF); reject with a clear
message if it's `0x9E` (DGB testnet), `0xEF` (Bitcoin testnet), or any
other prefix. Do not silently accept the wrong network.
- Detect compressed vs uncompressed and derive both P2PKH and
P2WPKH addresses; check balances on both; show whichever has funds
(or both if both do).
- Import as **watch-with-key** by default — the imported key is stored
separately from the HD seed and clearly labeled as "Imported" in the
account list. Do not silently merge it into the HD tree.
**"Compatibility test matrix" (must pass before ship):**
- Round-trip: Ian Coleman → our wallet → Ian Coleman.
- One-way import: seed generated in DigiByte Go → our wallet → balance
and full history visible.
- One-way import: seed generated in Coinomi at *both* account-index
variants (`…/0'H` and `…/1'H`) → our wallet auto-discovers both.
- One-way import: seed generated in Trust Wallet → our wallet.
- WIF export from Core `dumpprivkey` → wallet import → balance appears.
- Legacy-only Exodus seed → our wallet → derives `D…` addresses and
finds funds without user having to guess the format.
- **Negative test:** DigiByte Core HD seed (non-BIP39) → wallet shows
the warning above, does not silently produce empty addresses.
**Copy positioning:** the app-store one-liner is literally
*"Recover any DigiByte wallet from your seed phrase or private key."*
That's the differentiator. Lead with it.
---
## 8. Browser-extension variant (MetaMask / Rabby / Xverse style)
The mobile wallet solves the recovery pain. A **browser extension** solves
the *DigiDollar* pain — because DigiDollar's whole promise is web-based
stablecoin usage, and without a signing surface in the browser it can't
have web dApps. This is exactly the position MetaMask created for
Ethereum in 2016.
### Is a UTXO-chain browser extension even a thing? Yes.
The MetaMask model was invented for account-based EVM chains, but the same
pattern has been ported to UTXO chains successfully:
| Extension | Chain | Model |
|---|---|---|
| **Xverse** | Bitcoin, Stacks, Ordinals | Chrome/Firefox extension + mobile; injects `window.XverseProviders` |
| **Unisat** | Bitcoin, BRC-20, Ordinals | Extension only; injects `window.unisat` — became de-facto for BRC-20 dApps |
| **Leather** (ex-Hiro) | Bitcoin, Stacks | Extension; PSBT signing surface |
| **OKX Wallet** | Multichain incl. BTC | Extension |
| **Sparrow** | Bitcoin (desktop, not extension) | Reference for advanced PSBT UX |
For DigiByte the space is **empty** — there is no equivalent of any of
these. That's a moat opportunity if DigiDollar picks up.
### Architecture (extension shares almost everything with mobile)
```
┌──────────────────────────────────────────────────────────┐
│ Browser Extension (Manifest V3) │
│ │
│ Popup UI (React) — send/receive, DigiDollar mint/burn │
│ Options page — seed backup, imports, network settings │
│ │
│ Background service worker │
│ - key storage (encrypted with password + WebCrypto) │
│ - PSBT signing (bitcoinjs-lib) │
│ - Blockbook RPC client │
│ │
│ Content script → injects into every page: │
│ window.digibyte = { request, on, ... } │
└──────────────────────┬───────────────────────────────────┘
│ postMessage
▼
dApp page (DigiDollar web app)
calls window.digibyte.request(...)
```
**Key management:** same `bitcoinjs-lib` + DGB network params as mobile.
Keys encrypted with WebCrypto (`AES-GCM` derived via `PBKDF2` from a
password), stored in `chrome.storage.local`. Never leaves the service
worker; popup and content scripts only ever see PSBTs and signed
transactions.
**Manifest V3 constraints to be aware of:**
- No persistent background page — service worker only. Long-running
Blockbook WebSockets need reconnection logic on wake.
- `chrome.storage.local` has a 10 MB cap; fine for keys, but tx history
should stay in IndexedDB or be re-fetched from Blockbook.
- Content-script → dApp communication via `postMessage`; requests must
be origin-checked and user-approved in a popup, MetaMask-style.
### Provider API — the DigiDollar dApp contract
Define `window.digibyte` (name TBD; consider `window.digibyteWallet` to
avoid collisions) with a small, versioned interface:
```js
window.digibyte = {
isConnected(): boolean,
request({ method, params }): Promise<any>,
on(event, handler): void,
}
```
Core methods to ship for DigiDollar to be usable:
| Method | Purpose |
|---|---|
| `dgb_requestAccounts` | Connect flow; returns array of `{address, path}` after user approves in popup |
| `dgb_getAccounts` | Cached read; returns `[]` if not connected |
| `dgb_getBalance` | For a given address |
| `dgb_signPsbt` | The workhorse — dApp builds PSBT, extension signs after user approval |
| `dgb_sendPsbt` | Sign + broadcast in one call |
| `dgb_signMessage` | BIP-322 message signing for auth flows |
| `dgb_getPublicKey` | For dApps building PSBTs client-side |
| `dgb_switchNetwork` | mainnet ↔ testnet |
| Events: `accountsChanged`, `networkChanged`, `disconnect` | Match EVM conventions so dApp developers coming from MetaMask feel at home |
For **DigiDollar-specific** operations (mint, burn, transfer of the
stablecoin), add higher-level convenience wrappers *only after* the
DigiDollar spec lands — until block 23,869,440 activates the AlgoLock
fork the surface area isn't final.
### Delivery — extension after mobile, share the crypto core
Extract `keys`, `derivation`, `psbt`, `blockbook-client`, and
`network-params` into a single `@dgb-wallet/core` TypeScript package.
Mobile (React Native) and extension (React + MV3) both depend on it.
This means:
- One place to fix a signing bug.
- Compatibility matrix runs against the shared core, not per-app.
- The core can also be published standalone as an SDK for DigiDollar
dApp developers who want to build PSBTs without the extension.
### Effort estimate
- **Mobile v1** (recovery-focused, hosted-indexer): ~6–10 weeks, one
engineer.
- **Shared core extraction:** ~1 week additional if planned from day one.
- **Extension v1** (send/receive/PSBT sign + provider API): ~4–6 weeks
once the core exists.
- **DigiDollar-specific dApp integrations:** dependent on the
DigiDollar spec finalizing.
### Strategic framing given DigiDollar
The decline symptom you named — no mobile wallet, hard recovery — is
mostly a *distribution and UX* failure, not a protocol failure. Core is
being maintained and DigiDollar is being built. Without a wallet that
(a) any user can recover on their own and (b) any web dApp can sign
through, DigiDollar ships to no one. **A mobile wallet + browser
extension pair, sharing one crypto core, is the smallest set of
software that makes DigiDollar usable at all.**
That reframes the build as "critical DigiDollar infrastructure" rather
than "another altcoin wallet," which is a much stronger pitch to the
DGBAT community for adoption, listing, and any grant funding that might
appear once DigiDollar activates.
---
## 9. Open decisions for you
1. **Platform mix:** mobile-first (RN), extension-first (Chrome MV3),
or parallel from day one with a shared TypeScript core?
2. **RN vs Flutter vs native** for the mobile side. RN wins if we're
also shipping an extension — shared `bitcoinjs-lib` + core package.
3. **Backend hosting:** self-hosted Blockbook on a VPS (one-time setup,
full control) vs paid NOWNodes (zero infra, ongoing cost)?
4. **Fork vs greenfield:** ignore the existing `digibytewallet-ios`
(Sigma Systems / Electrum-based) and build fresh, or fork it?
5. **Taproot (`dgb1p…`) support at launch:** yes (differentiator, needed
for DigiDollar path) or defer?
6. **Extension provider-API naming:** `window.digibyte` (short, may
collide) or `window.digibyteWallet` (safer, longer)?
7. **DigiDollar coordination:** wait for the DigiDollar spec to
finalize before adding stablecoin surface area, or engage
DGBAT / Core devs now to co-design the provider API?
---
## 10. Revised architecture (2026-08-03) — supersedes §4 and §7
The earlier sections defaulted to "hosted-indexer (Blockbook)" for practical
reasons — fewer moving parts, faster to ship. During review the user made
a series of points that changed the default:
1. Bitcoin/DGB is a **P2P system by design**. Connecting to one node
isn't the model; connecting to many is. Any architecture that
pretends otherwise inherits centralization the base protocol doesn't
require.
2. Centralized indexers (Blockbook, public block explorers) **collect
wallet-address queries by IP** and expose full transaction histories
of every address to the world. This is a real privacy regression from
Satoshi's design, not a feature of the chain.
3. Pruned nodes are more capable than the industry narrative suggests —
`scantxoutset` answers current-balance queries against the UTXO set
directly, without any indexer, on a pruned node.
The revised default is a **P2P-native SPV wallet with user-selectable
privacy modes**. Indexers remain available as an explicit convenience
option, never as the default.
### 10.1 Reorg safety — findings
- **No documented DGB reorg deeper than a handful of blocks** in ~12
years of operation. April 2020 51%-attack speculation was investigated
and publicly refuted by Jared Tate. No chain-explorer or post-mortem
citing a deeper reorg was surfaced.
- **Peer comparison:** Vertcoin 307-block reorg (Dec 2018), Ravencoin
X16R exploit (May–June 2020, ~$5.7 M), Litecoin 13-block MWEB reorg
(25 Apr 2026). **No 5-algo multi-PoW chain has been documented as
51%-attacked.** DGB is essentially the only widely-observed 5-algo
chain, so this is weak evidence rather than proof, but empirically
the MultiShield + 5-algo defense has held.
- **DGB Core `MIN_BLOCKS_TO_KEEP` = 288**, unchanged from Bitcoin
(`src/validation.h:56`). At 15-second block time that is ~72 minutes
of chain — a much thinner wall-clock buffer than Bitcoin's ~48 hours.
Empirically fine given DGB's reorg history, but worth knowing when
sizing a pruned node.
- **`prune=` semantics:** value in **MiB**, minimum **550**. Core
always keeps at least `MIN_BLOCKS_TO_KEEP` recent blocks regardless
of the disk budget. `prune=550` therefore covers days of recent
chain in DGB terms; you cannot practically go lower.
### 10.2 Dandelion++ (BIP156) — what it protects
Standard tx relay floods to all peers immediately; a network observer
can identify the originating node from broadcast pattern. Dandelion++
splits relay into two phases:
- **Stem phase.** Tx is forwarded hop-by-hop through one random peer
at a time.
- **Fluff phase.** At a probabilistic point (~10 hops), some node in
the stem switches to normal flooding.
Effect: the fluff-origin is somewhere random along the stem, not the
true origin. Passive observers cannot link the tx back to the sender's
IP from broadcast timing.
**What Dandelion protects — and does not:**
| Concern | Protected? |
|---|---|
| Passive network observer identifying originating IP | ✅ |
| Correlating multiple txs to same wallet via broadcast timing | ✅ Partial |
| On-chain address clustering (change-address heuristics etc.) | ❌ |
| Indexer server (Blockbook / Electrum) learning your addresses | ❌ |
| Sybil attacker controlling many peers in the stem | ⚠️ Degraded |
**Architectural consequence:** Dandelion protects broadcasts that enter
the P2P network directly. **Broadcasting via a Blockbook HTTP endpoint
bypasses Dandelion's protection for you specifically** — the operator
sees your IP and the raw tx before P2P relay begins, regardless of what
Dandelion does downstream. Any privacy-conscious mode broadcasts through
a peer relationship (own local Core, or wallet-as-P2P-participant), not
through an indexer's HTTP API.
*Verification pending:* confirm activation height and default-on status
of Dandelion in DGB Core 8.26. Server-side BIP156 support is in the
Bitcoin Core lineage DGB 8.x tracks; needs source check.
### 10.3 Broadcast semantics — correction
Blockbook does expose a broadcast endpoint (`POST /api/v2/sendtx`), but
it is a **submission funnel**, not a P2P relay participant. The flow is:
```
Wallet → HTTPS → Blockbook → RPC → its Core → P2P peers
▲
operator sees raw tx + your IP + timestamp
```
Three consequences: Dandelion protection is defeated for you specifically
(operator sees pre-relay); every user's tx goes through one operator's
Core (single-point censorship risk); the operator can correlate the tx
with prior address queries in the same session.
Blockbook broadcast belongs in the "Convenience mode with explicit
warning" bucket, never as a default.
### 10.4 Architecture — default (Maximum Privacy)
```
┌────────────────────────────────────────────────────────────┐
│ Mobile Wallet / Browser Extension │
│ │
│ Key management (@dgb-wallet/core): │
│ BIP39 seed · BIP32/44/49/84/86 derivation · PSBT sign │
│ WIF import · seed import w/ multi-path scan │
│ │
│ P2P client: │
│ • Connect to ~8 DGB peers via DNS seeds │
│ • Download block headers (~1.8 GB one-time, then trickle)│
│ • Download BIP158 compact block filters per block │
│ • Probe filters LOCALLY against wallet's scripts │
│ • Request only filter-hit blocks from any NODE_NETWORK │
│ peer │
│ • Broadcast via P2P (Dandelion protects origin) │
└────────────────────────────────────────────────────────────┘
│
│ speaks native DGB P2P (BIP324 encrypted)
▼
DGB peers on the public network
```
**Properties:** no third-party server ever sees a wallet address. No
single point of failure. Broadcast benefits from Dandelion. Read privacy
is preserved by filter-probing locally.
**Cost:** 2–4 weeks of client-side work to implement P2P + BIP158 in
TypeScript. Nearest bases: `bcoin` (JS), `nakamoto`/`neutrino` (Rust/Go).
Any of them needs DGB chainparams + a light wrapper.
### 10.5 Architecture — optional (broadcast bastion)
Users who run their own hardware can add a small pruned DGB Core:
```
Wallet ──► own pruned Core (prune=550, ~600 MB)
│
└──► P2P peers (Dandelion)
```
- Broadcast privacy: only *you* see the raw tx before P2P entry.
- Wallet still uses P2P + BIP158 for reads; Core is broadcast-only.
- Fits on ~600 MB, runs on a €4/month VPS or a local desktop.
### 10.6 Privacy modes (settings)
Four modes. Shown side-by-side in first-run wizard with one-line
trade-offs. Changeable any time. Current mode surfaced as an icon in
the app bar; per-op badges when a request leaks to a third party.
**🛡️ Maximum Privacy (default, recommended)**
- Reads: P2P + BIP158
- Broadcast: P2P direct (or own Core if configured)
- No third-party server touched
- Trade-off: slower initial sync, more device work
**⚖️ Balanced**
- Reads (address-revealing): P2P + BIP158
- Reads (metadata: fee estimate, chain tip, mempool stats): Blockbook
- Broadcast: P2P or own Core
- Trade-off: Blockbook knows you're active, not which addresses
**⚡ Convenience**
- Reads: Blockbook (yours or vetted public)
- Broadcast: Blockbook or Core, user's choice
- Fastest UX
- Trade-off: Blockbook operator sees every address you query
**🔧 Custom**
- Per-op toggles: reads / fees / broadcast independently
- Optional Tor per surface
- One-line privacy note next to every toggle
### 10.7 Design principle (for future contributors)
**Default to preserving the P2P properties of the chain. Make
convenience the user's explicit trade-off, not our default.**
Any PR that moves a read or broadcast from the P2P path to a third-party
indexer must (a) be opt-in only, (b) surface the privacy consequence in
the UI at the moment of use, and (c) not change the Maximum Privacy
mode's behavior.
### 10.7a DGB has TWO version bytes per address type — recovery must handle both
Discovered 2026-08-03 during implementation cross-check against Ian
Coleman's BIP39 tool. `src/kernel/chainparams.cpp` `CMainParams` on the
DGB Core master branch defines four legacy-compatibility constants that
matter for wallet recovery:
| Constant | Value | Purpose |
|---|---|---|
| `PUBKEY_ADDRESS` | 30 (0x1e) | Current P2PKH → `D…` |
| `SCRIPT_ADDRESS` | 63 (0x3f) | Current P2SH → `S…` |
| `SCRIPT_ADDRESS_OLD` | 5 (0x05) | **Legacy** P2SH → `3…` (Bitcoin-compatible) |
| `SECRET_KEY` | 128 (0x80) | Current WIF |
| `SECRET_KEY_OLD` | 158 (0x9e) | **Legacy** WIF |
**Why this matters:** Ian Coleman's BIP39 tool (the community's gold
standard for offline recovery) uses `scriptHash = 0x05` for DGB — the
legacy prefix. Any BIP49 address it generates starts with `3…`, not
`S…`. Older Atomic/Exodus vintages, hd-wallet-derive, and various
bitcoinjs-lib forks inherited the same legacy value.
**On-chain reality:** a P2SH output on the DGB chain is
`OP_HASH160 <20-byte-script-hash> OP_EQUAL` — the version byte is not
in the output, only in the human-readable encoding. So `3XXX…` and
`SXXX…` addresses referring to the same script-hash **refer to the
same on-chain output.** But wallets that store addresses as *strings*
(and query indexers by *string*) will silently miss funds unless they
derive both variants.
**The recovery failure mode our wallet exists to prevent:**
1. User creates seed in Ian Coleman / older Atomic / etc → receives DGB
at `3XXX…` addresses.
2. Later imports that seed into a modern wallet.
3. Modern wallet only derives `SXXX…`, queries indexer for `SXXX…`,
sees nothing.
4. User believes funds are gone; they are not.
**Same problem class for WIF:** DGB Core keeps `SECRET_KEY_OLD = 0x9e`
for backward compat. Older private-key exports may have prefix `0x9e`
rather than the current `0x80`. Import must try both.
**Implemented fix in `@dgb-wallet/core`:**
- `network.ts` exports `digibyte` (current), `digibyteLegacyP2SH`
(scriptHash 0x05), and `digibyteLegacyWIF` (wif 0x9e).
- `address.ts` exports `p2shP2wpkhAddressesBoth(node)` returning
`{ modern, legacy }`.
- `wif.ts` `importWif(wif)` tries modern 0x80 first, falls back to
legacy 0x9e, returns `{ keyPair, variant }`.
- Recovery script now scans both P2SH variants explicitly.
**Consequences downstream:**
- Any indexer-facing query for a P2SH address must be issued with both
version bytes when the seed came from an unknown source.
- New receive addresses generated by our wallet always use the current
(0x3f / 0x80) bytes.
- UX must surface when a legacy variant successfully decoded a user's
import ("This is a legacy-format key from an older DGB tool") so
they aren't confused when the same key looks different in other apps.
### 10.7b Tor by default — a fourth design principle (added 2026-08-03)
**"Every outbound socket the wallet opens goes through Tor by default.
Direct connections require an explicit user opt-out with a visible
warning."**
Applies uniformly to all backends: P2P peers, Electrum servers, HTTP
explorers, broadcast targets. No exceptions.
This tightens §10.7 from "prefer P2P over indexers" to also "prefer
Tor-wrapped sockets over clear ones." Combined, they make the wallet's
default privacy posture materially better than any other DGB wallet
currently in the ecosystem.
**Architecture:**
- A single `NetProvider` abstraction handles all outbound TCP.
- Implementations: `DirectNet`, `Socks5Net` (any local proxy),
`TorNet` (embedded).
- Every package that opens sockets (`@dgb-wallet/p2p`,
`@dgb-wallet/electrum`, any HTTP client) accepts a `NetProvider` and
never calls `net.connect` / `fetch` directly.
**Mobile:** embed Tor via `libtor` (Guardian Project, Android) and
`Tor.framework` (iCepa, iOS). ~5–10 MB app size, ~5–15s bootstrap first
launch (cached after).
**Extension:** detect Tor Browser; on Firefox use per-request `browser.proxy`
API; on Chromium recommend Tor Browser (MV3 extensions can't easily
embed Tor).
**Graceful degradation:** if Tor bootstrap fails, prompt user with the
consequence and require explicit opt-in to direct: *"Tor unavailable.
Continue over direct connection? Your IP will be visible to DGB peers."*
**Cost accepted:** +5–10 MB app size, ~5–15s first-launch bootstrap,
~300–800ms per-connection latency. All acceptable for a wallet's
request profile (low-frequency reads, low-frequency broadcasts).
### 10.7c PKI via BCH BNS (added 2026-08-03) — supersedes the WebPKI assumption
Silent Mode's BCH Name Service (BNS) at `D:\Dev\SilentMode\Argus`
provides the identity layer we were about to reinvent. The wallet's
backend is a **`.dgb` BNS name** (product decision: `rpc.dgb`) whose
on-chain records include `ip` (IPv4 to dial) and `tls` (SHA-256
fingerprint over DER of the server's cert).
**Trust chain:**
```
rpc.dgb BNS name
↓ resolveEndpoint via @dgb-wallet/bns
BCH chain (CashToken cert + OP_RETURN BNS1 records)
↓
{ ip, tls fingerprint }
↓ TLS handshake, fingerprint pinned via node:tls checkServerIdentity
VPS running DigiByte Core (disablewallet=1)
```
No ICANN, no CA/Browser Forum, no Let's Encrypt. Cert rotation is a
single on-chain UPD tx — every wallet picks up the new fingerprint at
next resolve, no client update.
**`.dgb` TLD status:** already activated in the 2026-07-30 bootstrap
of `tlds.bch`. No TLD activation needed — just register individual
names (e.g. `rpc.dgb`) via the existing `silentmode.st/register.html`
flow.
**Implementation:**
- `@dgb-wallet/bns` wraps `resolver-web.js` from Argus. `resolveEndpoint(name)`
returns `{ ip, tls }` or throws if either record is missing.
- `@dgb-wallet/rpc` `RpcClient({ bnsName, username, password })` does the
lookup and pins the TLS handshake to the on-chain fingerprint.
Direct-URL mode still exists as a dev escape hatch.
- `ops/setup.sh` provisions the VPS with a **self-signed cert** (no ACME),
prints the fingerprint at the end, ready to paste into the BNS record.
- `ops/Caddyfile.bns` uses on-disk cert instead of Let's Encrypt.
**Product name commitment:** the wallet ships as **"DigiByte.X Wallet"**
under the existing `.X` BNS TLD, distinct from other DigiByte wallets.
Product-brand "Aegis" is reserved for a future multicoin wallet.
**Distribution:** NOT under `dl.silentmode.st` — Silent Mode is scoped to
web infrastructure (browsers, resolvers, protocols). DigiByte.X Wallet
is its own product using `.dgb` TLD for its own operational needs
(`rpc.dgb`, `wallet.dgb`, `docs.dgb`, and later `usd.dgb`/`digidollar.dgb`
for DigiDollar dApp surfaces).
### 10.8 Build order (revised, 2026-08-03)
Three tracks, each shippable in independent pieces.
**Track A — Chain data (client-side privacy via BIP158)**
| # | Milestone | Status |
|---|---|---|
| A1 | Peer connect + version handshake | ✅ Done, 8/8 handshakes vs. real mainnet |
| A2 | Peer discovery (DNS seeds + `getaddr`) | |
| A3 | Header sync (`getheaders`) — ~1.8 GB one-time | |
| A4 | BIP157 negotiation + BIP158 filter download | Confirmed available: several peers advertise `NODE_COMPACT_FILTERS` |
| A5 | Filter probing (scripthash → hit → fetch block) | |
| A6 | Persistent header/filter cache | |
**Track B — Broadcast**
| # | Milestone |
|---|---|
| B1 | Broadcast via P2P (`inv` / `tx`) |
| B2 | Dandelion++ stem/fluff |
**Track C — Transport privacy**
| # | Milestone |
|---|---|
| C1 | Pluggable `NetProvider` transport abstraction |
| C2 | SOCKS5 client (wallet becomes Tor-usable via external Tor) |
| C3 | Embedded Tor on mobile (Tor by default) |
| C4 | `.onion` peer discovery + onion-only mode |
| C5 | Extension Tor story (Tor Browser detection, Firefox `browser.proxy`) |
**Track D — Persistence / UX**
| # | Milestone |
|---|---|
| D1 | Encrypted keystore (WebCrypto) |
| D2 | Recovery UX from §7a (mobile + extension shells) |
| D3 | Privacy-mode toggle UI (§10.6) |
**Empirical facts confirmed during A1 (2026-08-03):**
- DGB P2P network is alive; DNS seeds resolve to 100+ IPv4 peers.
- Chain tip observed: ~23.97M (well past DigiDollar / AlgoLock at 23,869,440 — **DigiDollar has already activated on mainnet**).
- Live peer versions: DGB Core 8.26.2, 9.26.4, 9.26.5.
- Protocol version `70019` accepted.
- `NODE_COMPACT_FILTERS` advertised by real peers — the BIP158 dependency for Maximum Privacy is real, not aspirational.
- `chainparams.cpp` magic bytes `{0xFA, 0xC3, 0xB6, 0xDA}` must be stored as integer `0xDAB6C3FA` for `writeUInt32LE`. Common gotcha; comment left in `network.ts`.
### 10.9 Legacy build-order (superseded)
1. **Now → shared `@dgb-wallet/core` TypeScript package.** BIP39, BIP32
with DGB chainparams, BIP44/49/84/86 derivation, address encoders
(`D…`, `S…`, `dgb1q…`, `dgb1p…`), WIF import/export, PSBT primitives.
No node required to develop or test. Scaffolded 2026-08-03 at
`packages/core/`.
2. **Next → P2P + BIP158 client.** Adapt from `bcoin` or write from
scratch against the DGB P2P spec. Peer discovery via DNS seeds.
Header sync, filter download, filter probing, block fetching.
3. **Then → RPC adapter for a local pruned Core** (optional bastion path).
4. **Then → mobile shell (React Native) + extension shell (Chrome MV3),
both depending on `@dgb-wallet/core`.**
5. **Later → Blockbook client** as the Convenience-mode backend.
---
## Sources
Full URLs are in the four raw research outputs (project status, hardfork,
node/SPV, seed recovery). Key references:
- github.com/DigiByte-Core/digibyte (repo, releases, chainparams.cpp)
- github.com/DigiByte-Core/digibyte/releases/tag/v8.26.0 (Taproot)
- github.com/satoshilabs/slips/blob/master/slip-0044.md (coin type 20)
- github.com/trezor/blockbook/blob/master/configs/coins/digibyte.json
- iancoleman.io/bip39 (recovery cross-check)
- dgbwiki.com/index.php?title=Common_support_queries (Core non-portability)
- github.com/DigiByte-Core/digibytego (official mobile reference)

95
docs/SECURITY-ROADMAP.md Normal file
View file

@ -0,0 +1,95 @@
# DigiByte.X Wallet — Security Roadmap
Derived from `D:\Dev\SilentCode\web_wallet_security_analysis.md` (the
general web-wallet threat model) and mapped onto what this wallet
actually does today. Each item says where we stand and what the next
concrete step is. Ordered by the analysis' own priority: CSP first,
then key-splitting, then hardware isolation.
Last reviewed: 2026-09-09.
---
## Where we stand against the threat model
| Threat (analysis §1–2) | Status | Notes |
|---|---|---|
| Custodial key control | ✅ N/A | Non-custodial. Seed never leaves the browser; only signed tx bytes go out. |
| Server breach exposes keys | ✅ Mitigated | Nothing key-related on the server. Encrypted blobs live only in the user's `localStorage`. |
| Phishing / typo-squat | ⚠️ Partial | `digibyte.x` is a BNS name with IP + TLS fingerprint on-chain, so wallet-aware clients pin the server. Plain browsers reaching us through `navigate.st` trust that gateway's TLS. |
| XSS / injected script | ✅ Mitigated | Strict CSP live on `digibyte.x/web/` since 2026-09-06 (`script-src 'self'` + hash of the one inline script); asset tags carry SRI since 2026-09-09. |
| Malicious extensions | ⚠️ Inherent | Nothing in-page can stop an extension with page access. Mitigation is the multi-sig item below. |
| Local malware reading storage | ⚠️ Partial | Blobs are PBKDF2-210k + AES-GCM under the master password. Persistent PIN blob (100k rounds) weakens this for users who set a PIN — documented in `session-auth.ts`. |
| DNS spoofing | ✅ Mitigated for pinned clients | On-chain fingerprint pinning; see `ops/nginx-digibyte-x.conf` header comment. |
## What the wallet already does (analysis §2 "Client-Side Architecture")
- **Local cryptography.** BIP39/BIP32 via `@dgb-wallet/core`; WebCrypto PBKDF2 + AES-GCM in `packages/keystore` and `packages/web-ui/src/session-auth.ts`.
- **Encrypted local storage.** One master password per device; every wallet blob encrypted under it. Session auto-locks after 30 min idle. PIN is a shortcut with a 3-attempt budget; the master password restores it.
- **Local signing.** PSBT built and signed in `SendScreen.tsx`; keys are derived on demand and dropped with the component.
- **Verified broadcast.** `packages/p2p/src/broadcast.ts` treats a send as done only when an uninvolved peer announces the tx back (DGB Core drops policy failures silently). Fees sized to DGB's 100 sat/vB relay floor.
- **Address hygiene (opt-in).** Settings → Security → "Fresh addresses" rotates receive and change addresses to the first unused index. Off by default because "used" is only complete with Blockbook enabled.
---
## Roadmap
### 1. Strict Content Security Policy — MANDATORY (analysis §3) — ✅ APPLIED 2026-09-06
Add to the nginx `location /web/` block on the VPS (and keep `ops/nginx-digibyte-x.conf` in sync):
```nginx
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-MYCXjyaeuO4n6g9JktOO1yXmG9rBTuGJ5Wk4GojDwig=' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.coingecko.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; object-src 'none'" always;
```
Notes:
- The `sha256-…` value is the hash of the inline `<base>`-fixing script in the **built** `dist/index.html`. It changes whenever `packages/web-ui/index.html`'s inline script changes; recompute on each such change (a one-liner is in the deploy notes below) or move the script to a separate file under `assets/` to drop the hash entirely.
- `base-uri 'self'` (not `'none'`): the inline script injects a `<base>` tag pointing at our own origin.
- `connect-src` needs CoinGecko only while the price feed is opt-in enabled; everything else (`../p2p/`, `../blockbook/`) is same-origin.
- `'unsafe-inline'` for styles is needed by a handful of inline `style=` attributes; removing them is a cleanup item, not a blocker.
- The `navigate.st` gateway serves the same HTML under its own headers; CSP there is the gateway's to set.
Recompute the hash after a build:
```bash
python -c "import re,hashlib,base64;h=open('packages/web-ui/dist/index.html',encoding='utf-8').read();s=re.findall(r'<script>(.*?)</script>',h,re.S)[0];print('sha256-'+base64.b64encode(hashlib.sha256(s.encode()).digest()).decode())"
```
Applied and verified (zero violations through both digibyte.x and the navigate.st gateway). The repo mirror is `ops/nginx-digibyte-x.conf`; `ops/push.sh` warns when the live header's hash no longer matches the built inline script.
### 2. Subresource integrity + reproducible bundle — ✅ SRI done 2026-09-09
- ✅ `integrity="sha384-…"` + `crossorigin` on the `<script>`/`<link>` tags in `dist/index.html` at build time (`sri()` plugin in `packages/web-ui/vite.config.ts`), so a tampered asset file fails to load even if the HTML is intact.
- Publish the bundle hash alongside each deploy (e.g. in the BNS record's `h` field or a signed `RELEASES.md`) so a user can compare what their browser loaded against what was released.
### 3. Drop the remaining inline script — ❌ NOT POSSIBLE as stated
The inline script must run before any relative URL is resolved, because the navigate.st gateway injects its own `<base href="/bns/digibyte.x/">` at the top of `<head>`; an external `./base-fix.js` would itself be resolved against that wrong base and never load. The script stays inline and hashed; `packages/web-ui/scripts/csp-hash.mjs` prints the hash and `ops/push.sh` checks it on every deploy, so the recompute step is automated instead of removed.
### 4. Multi-signature co-signing (analysis §4) — replaces "2FA"
DigiByte supports the same script system as Bitcoin, so a 2-of-2 (or 2-of-3 with a recovery key) P2WSH multisig is available today without smart contracts.
- `@dgb-wallet/core`: derive a second account for the co-signer and build BIP48-style (`m/48'/20'/0'/2'`) P2WSH multisig scripts; `@dgb-wallet/psbt` already handles PSBT, so add `witnessScript` inputs and partial-signature merging.
- Second device = the `packages/mobile` app: it receives the PSBT (QR or the `/p2p/` daemon acting as a relay), shows outputs and amounts on its own screen, signs with Key B, returns the PSBT.
- Browser finalizes when both signatures are present; broadcast path unchanged.
- Recovery: the seed backup for each key plus the redeem script descriptor (`wsh(multi(2,…))`) — export it from Settings → Security → Wallet keys.
This is the item that neutralises the extension / XSS / local-malware residual risk: a compromised browser can leak Key A and still cannot move funds.
### 5. Hardware wallet bridge (analysis §5, "maximum protection")
WebUSB/WebHID to a Ledger or Trezor that carries DigiByte support. Keys never enter the page; the browser only assembles PSBTs. Same PSBT plumbing as item 4, so do it after multisig lands.
### 6. Browser-extension packaging (analysis §3)
Ship `packages/web-ui` as a Manifest V3 extension. Static code reviewed by the store, isolated origin, no gateway in the path. The runtime `<base>` fix and the `../p2p/` relative fetches already make the bundle origin-agnostic; the remaining work is the manifest, a fixed backend origin setting, and CSP in the manifest instead of nginx.
### 7. Smaller hardening items
- **PIN blob** lives in `localStorage` by design (user requirement: PIN survives reloads). Document it in-app (done, Settings → Security) and consider a device-bound key via WebAuthn PRF when browser support settles, so the blob is useless off-device.
- **Master password change** re-encrypts all wallets and clears the PIN (done).
- **Auto-lock** is 30 min idle; expose it as a setting.
- **Clipboard**: the copy buttons put addresses on the clipboard; never the seed. Keep it that way.
- **Blockbook exposure**: keep it opt-in; the Settings hint spells out that the indexer operator sees queried addresses.
- **Rate limiting / error hygiene**: gateway text errors and indexer 429s must never look like "no funds". Handled in `p2p-fetch.ts` and the Blockbook client (parse-before-JSON, 429 backoff, small concurrency cap).

59
ops/Caddyfile Normal file
View file

@ -0,0 +1,59 @@
# Caddy reverse proxy for the DGB RPC backend.
# Terminates TLS via Let's Encrypt. Proxies to Core RPC on localhost:14022.
# Rate-limits per client IP. Forwards Authorization header for Core basic auth.
#
# The @DOMAIN@ token is substituted by setup.sh.
@DOMAIN@ {
encode zstd gzip
# Basic security hardening for the endpoint
header {
# No caching of RPC responses
Cache-Control "no-store"
# Disable browser MIME sniffing
X-Content-Type-Options "nosniff"
# No referrer bleed
Referrer-Policy "no-referrer"
# Strict transport security (2 years)
Strict-Transport-Security "max-age=63072000; includeSubDomains"
# Remove server identification
-Server
}
# Rate limit — 60 requests per minute per IP, per remote endpoint.
# Requires github.com/mholt/caddy-ratelimit plugin.
rate_limit {
zone rpc_zone {
key {client.ip}
events 60
window 1m
}
}
# Only POST (JSON-RPC). Reject everything else.
@not_post {
not method POST
}
respond @not_post "Method Not Allowed" 405
# Proxy JSON-RPC to Core. Basic-auth is passed through unchanged;
# Core validates rpcuser/rpcpassword against digibyte.conf.
reverse_proxy 127.0.0.1:14022 {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
transport http {
keepalive 30s
keepalive_idle_conns 8
}
}
# Structured access log; journalctl -u caddy
log {
output stdout
format json
level INFO
}
}

58
ops/Caddyfile.bns Normal file
View file

@ -0,0 +1,58 @@
# Caddy reverse proxy — BNS/self-signed variant.
# TLS uses an on-disk cert whose SHA-256 is pinned on-chain (@dgb-wallet/bns).
# No ACME, no Let's Encrypt, no domain validation.
#
# Tokens replaced by ops/setup.sh:
# @BNS_NAME@ the BNS name (e.g. rpc.dgb)
# @CERT_FILE@ path to server.crt
# @KEY_FILE@ path to server.key
{
auto_https off
}
:443 {
tls @CERT_FILE@ @KEY_FILE@
encode zstd gzip
header {
Cache-Control "no-store"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
-Server
}
# Rate limit — 60 requests/min per IP. Requires caddy-ratelimit plugin.
# (Kept as a rate_limit block; if caddy is compiled without the plugin,
# comment this block out and rely on Core's rpcworkqueue + fail2ban.)
# rate_limit {
# zone rpc_zone {
# key {client.ip}
# events 60
# window 1m
# }
# }
@not_post {
not method POST
}
respond @not_post "Method Not Allowed" 405
reverse_proxy 127.0.0.1:14022 {
header_up Host {host}
header_up X-Real-IP {remote}
header_up X-Forwarded-For {remote}
header_up X-Forwarded-Proto {scheme}
transport http {
keepalive 30s
keepalive_idle_conns 8
}
}
log {
output stdout
format json
level INFO
}
}

85
ops/Caddyfile.wallet Normal file
View file

@ -0,0 +1,85 @@
# Caddy config for digibyte.x — hosts the static wallet bundle AND
# reverse-proxies /p2p/ to the local dgb-daemon on the same VPS.
#
# Trust model:
# TLS is a self-signed cert whose SHA-256 is pinned on-chain via the
# digibyte.x BNS record (`tls` field). No ACME, no Let's Encrypt.
# Wallet clients verify by resolving the name → {ip, tls} and
# comparing the observed cert to the pinned fingerprint.
#
# Runtime shape:
# - / and /assets/* → static files from /var/www/dgb-wallet (the
# built `dist/` bundle from @dgb-wallet/web-ui)
# - /p2p/* → dgb-daemon (127.0.0.1:5174) so Balance/Send
# work for visitors without a local daemon
# - Everything else → 404
#
# Tokens replaced by ops/deploy-wallet.sh:
# @CERT_FILE@ path to server.crt (DER SHA-256 goes in the BNS record)
# @KEY_FILE@ path to server.key
# @SITE_ROOT@ filesystem path to the deployed dist/ bundle
#
# Rate limiting (caddy-ratelimit plugin) caps abuse — /p2p/balance is
# cheap per call but the daemon has finite CPU and finite peer slots.
{
auto_https off
}
:443 {
tls @CERT_FILE@ @KEY_FILE@
encode zstd gzip
header {
# Wallet bundle is versioned in-filename; long cache on assets,
# never cache the entry HTML (needs to pick up new asset hashes).
Cache-Control "no-store"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
# CSP: the wallet runs entirely from its own origin; block
# everything else. `unsafe-inline` on styles because Vite emits
# a tiny inline style block for its own runtime.
Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
@assets path /assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# /p2p/* → local dgb-daemon. Strip the /p2p prefix so daemon sees
# /health, /balance, /broadcast at the roots it registered.
handle_path /p2p/* {
# rate_limit {
# zone p2p_zone {
# key {client.ip}
# events 60
# window 1m
# }
# }
reverse_proxy 127.0.0.1:5174 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
transport http {
keepalive 30s
keepalive_idle_conns 4
}
}
}
# Static site — SPA fallback so any unknown path serves index.html
# (the tabs are all client-side; no routing to break).
handle {
root * @SITE_ROOT@
try_files {path} /index.html
file_server
}
log {
output stdout
format json
level INFO
}
}

View file

@ -0,0 +1,52 @@
# Snippet — merge into whatever Caddyfile already serves digibyte.x.
# Meant for the layout "wallet lives at /web/, other content owns /".
#
# The two `handle_path` blocks are order-sensitive: /p2p/* MUST come
# BEFORE /web/* so daemon traffic never falls into the SPA fallback.
#
# Tokens replaced by ops/deploy-wallet-web.sh (or by hand):
# @SITE_ROOT@ filesystem path to the deployed dist/ bundle
# (dist/ is a plain production build: relative
# <script src="./assets/…"> plus an inline runtime
# <base href="<origin>/web/"> fix in index.html, so
# assets resolve to /web/assets/… under this path)
# --- START DGB WALLET SNIPPET ---
# /p2p/* → local dgb-daemon. Strip the prefix so daemon sees
# /health, /balance, /broadcast at the roots it registered.
handle_path /p2p/* {
reverse_proxy 127.0.0.1:5174 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
transport http {
keepalive 30s
keepalive_idle_conns 4
}
}
}
# /web/* → static wallet SPA. Note: NOT handle_path — we keep the
# /web prefix so Vite's asset URLs (built with base=/web/) resolve.
handle /web/* {
root * @SITE_ROOT@
# SPA fallback — any unknown /web path serves the index; the tab
# switcher is entirely client-side so no server routing to break.
try_files {path} /web/index.html
file_server
header {
Cache-Control "no-store"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'"
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
@assets path /web/assets/*
header @assets Cache-Control "public, max-age=31536000, immutable"
}
# --- END DGB WALLET SNIPPET ---

234
ops/README.md Normal file
View file

@ -0,0 +1,234 @@
# DGB backend — local or VPS
## Production layout (digibyte.x, as deployed)
What actually runs today on the coinspectrum VPS, and the only thing the
web wallet needs:
| Piece | Where | Notes |
|---|---|---|
| nginx vhost | `/etc/nginx/sites-enabled/digibyte-x` — mirrored in [`nginx-digibyte-x.conf`](nginx-digibyte-x.conf) | SNI-selected on `digibyte.x`, self-signed cert pinned on-chain via the BNS `tls` record. Serves `/web/` (wallet), proxies `/p2p/*` → daemon and `/blockbook/*` → NOWNodes (API key injected server-side from `/etc/dgb-core/nownodes.env`), strict Content-Security-Policy on `/web/`. |
| `dgb-daemon.service` | `/opt/dgb-wallet`, runs `packages/p2p/bin/dgb-daemon.ts` via tsx, binds `127.0.0.1:5174` | The wallet's only server-side component: header sync with reorg handling, BIP157/158 filter cache (packed 1000/file under `/var/lib/dgb-wallet/.dgb-wallet/p2p/packs/`), incremental balance scans remembered in `scans.json`, Dandelion++ broadcast with relay-echo verification. |
| **No DigiByte Core, no Fulcrum** | — | Removed 2026-09-06. A full node with indexes needs ≥40 GB disk and ≥4 GB RAM; the VPS has neither, and the daemon does not use it. |
Routine deploy from the dev box: **[`push.sh`](push.sh)** — `ops/push.sh` (web + daemon), `ops/push.sh web`, `ops/push.sh daemon`. It builds, ships, swaps atomically, restarts the daemon, verifies the served bundle hash and warns if the CSP hash for the inline script drifted.
---
Deployment shapes documented below (the first two describe an optional DigiByte Core backend for `@dgb-wallet/rpc`; **not used by the web wallet**):
- **[Local install on Windows](#local-install-windows-desktop)** — fastest to get running; DGB Core lives on your desktop under `D:\Program Files\DigiByte\`, RPC on `http://127.0.0.1:14022`. Best for development.
- **[Turnkey RPC VPS install](#turnkey-vps-install)** — pruned DGB Core behind Caddy for the `@dgb-wallet/rpc` client, TLS-pinned via BCH BNS. Best when the wallet needs its own backend.
- **[Wallet-site VPS install](#wallet-site-vps-install)** — hosts the built web wallet at a `.x`/`.dgb` name AND reverse-proxies `/p2p/` to the daemon, so visitors get Balance/Send without a local daemon. See `deploy-wallet.sh` + `Caddyfile.wallet` + `dgb-daemon.service`.
- **[Wallet-subpath install](#wallet-subpath-install)** — same idea as above but mounts the wallet at `/web/` on an existing site (e.g. `digibyte.x/web`), leaving the site's `/` alone. See `deploy-wallet-web.sh` + `Caddyfile.wallet-web-snippet` + `dgb-daemon.service`.
Both use the same `@dgb-wallet/rpc` client. Only the transport differs.
---
## Local install (Windows desktop)
Requires: Windows 10/11, ~60 GB free disk (for pruned; grows slowly), 2+ GB RAM available for the Core process, an elevated PowerShell for the one-time Defender exclusion step.
```powershell
cd "D:\Dev\SilentCode\Digibyte\ops"
Set-ExecutionPolicy -Scope Process Bypass
.\setup-local.ps1
```
The script:
1. Downloads DGB Core `v8.26.2` for Windows, verifies SHA-256 against the release's `SHA256SUMS`
2. Installs binaries to `D:\Program Files\DigiByte\bin\`
3. Creates `D:\Program Files\DigiByte\data\` (chain data) and writes `digibyte.conf` with pruned settings, `disablewallet=1`, random 40-char RPC password
4. Adds `data\` to Windows Defender exclusions (real 3x sync speedup)
5. Creates a Startup shortcut so Core auto-starts at logon
6. Launches Core immediately for the initial sync
At the end, prints:
```
RPC endpoint: http://127.0.0.1:14022
Username: dgb_xxxxxxxx
Password: <40-char base64>
```
Paste those into your wallet's env for the RPC demo:
```powershell
$env:DGB_RPC_URL='http://127.0.0.1:14022'
$env:DGB_RPC_USER='dgb_xxxxxxxx'
$env:DGB_RPC_PASSWORD='<paste>'
npm run demo -w @dgb-wallet/rpc
```
Initial pruned sync takes ~4–8 hours on a 16 GB / SSD desktop. Monitor with:
```powershell
& "D:\Program Files\DigiByte\bin\digibyte-cli.exe" -conf="D:\Program Files\DigiByte\digibyte.conf" getblockchaininfo | Select-Object -First 20
```
**Uninstall:** kill `digibyted.exe`, delete `D:\Program Files\DigiByte`, remove the Startup shortcut from `shell:startup`, and (optionally) remove the Defender exclusion via Windows Security settings.
**Flags:**
- `-InstallDir "E:\dgb"` — install somewhere else
- `-Version "8.26.2"` — pin a different Core release
- `-SkipDefenderExclusion` — don't touch Defender (script will run without admin)
- `-SkipStartup` — don't create the auto-start shortcut
---
## Turnkey VPS install
Runs a pruned DigiByte Core node with wallet functionality disabled, behind Caddy for HTTPS + rate limiting. Total footprint: ~600 MB disk, 1 GB RAM, one CPU core. Fits comfortably on a €4/month VPS.
## What you get
- **Chain state:** current UTXO set (~500 MB), always in sync
- **Chain-state RPC methods available over HTTPS + basic auth:**
- `scantxoutset` — the workhorse; scan UTXO set for descriptors/xpubs
- `sendrawtransaction` — broadcast signed transactions
- `estimatesmartfee` — fee estimation
- `getblockchaininfo`, `getnetworkinfo`, `getrawmempool`
- **Wallet functionality disabled** (`disablewallet=1`) — the node holds no keys, so RPC compromise leaks chain metadata at worst, never funds
## Fresh-VPS quickstart (Debian 12 / Ubuntu 24.04)
Provision a VPS with public IPv4/IPv6. SSH in as root (or a sudo user). Then:
```bash
# Fetch and run the setup script
wget https://raw.githubusercontent.com/<TBD>/main/ops/setup.sh
chmod +x setup.sh
sudo ./setup.sh your-domain.example.com
```
The script:
1. Creates unprivileged `digibyte` user
2. Downloads DGB Core `v8.26.2`, verifies SHA-256 against the release's `SHA256SUMS`
3. Writes `digibyte.conf` (pruned, wallet disabled, RPC bound to localhost, random 40-char password)
4. Installs the systemd unit and starts `digibyted`
5. Installs Caddy, configures it for your domain with automatic Let's Encrypt TLS
6. Opens firewall ports 22 (SSH), 443 (HTTPS), 12024 (DGB P2P)
7. Prints the generated RPC credentials for you to paste into your wallet
Expected total time: ~20 min including the initial pruned sync.
## Manual step-by-step
If you'd rather understand each piece, see [MANUAL.md](MANUAL.md) — same steps as `setup.sh`, done by hand.
## Files in this directory
| File | Purpose |
|---|---|
| `push.sh` | **Routine deploy from the dev box** — build, ship web bundle and/or daemon sources, restart, verify (see Production layout) |
| `nginx-digibyte-x.conf` | Mirror of the live nginx vhost for digibyte.x (`@NOWNODES_API_KEY@` placeholder; render with `sed` as the file header shows) |
| `setup.sh` | RPC-VPS bootstrap (Core + Caddy in front of it) — optional Core backend, not deployed |
| `deploy-wallet.sh` | Wallet-site VPS bootstrap (web bundle at `/` + P2P daemon reverse-proxy) |
| `deploy-wallet-web.sh` | Wallet-subpath bootstrap — plain production build (relative `./` base), ships bundle to `<SITE_ROOT>/web/`, renders Caddy import snippet |
| `digibyte.conf` | Sample Core config; script substitutes the domain and password |
| `digibyte.service` | systemd unit for `digibyted` |
| `dgb-daemon.service` | systemd unit for `@dgb-wallet/p2p`'s HTTP daemon |
| `Caddyfile` | Caddy reverse-proxy config for the RPC-VPS shape (Core over HTTPS + basic auth) |
| `Caddyfile.bns` | Same as `Caddyfile` but with a pinned self-signed cert (no ACME) |
| `Caddyfile.wallet` | Serves `dist/` at `/` and proxies `/p2p/*` to `dgb-daemon` |
| `Caddyfile.wallet-web-snippet` | Imported into an existing site's Caddyfile — adds `/web/*` (static) and `/p2p/*` (reverse-proxy) handlers |
---
## Wallet-site VPS install
Hosts the built `@dgb-wallet/web-ui` bundle under a `.x`/`.dgb` BNS name, plus reverse-proxies `/p2p/*` to a local `dgb-daemon` so Balance and Send work without visitors running a daemon locally.
Prereqs on the VPS: Debian 12 / Ubuntu 24.04, ≥2 GB RAM (8 GB heap ceiling for the daemon), public IPv4, ports 22/443 reachable, 12024 outbound for DGB P2P.
```bash
# On your dev box — push the checkout to the VPS:
scp -r D:\Dev\SilentCode\Digibyte user@vps:/opt/dgb-wallet
# On the VPS:
sudo /opt/dgb-wallet/ops/deploy-wallet.sh digibyte.x
```
The script installs Caddy + Node 20, `npm install`s the monorepo, builds the web bundle to `/var/www/dgb-wallet/`, generates a self-signed cert with CN=`digibyte.x`, wires the daemon into systemd, and prints the exact BCH BNS UPD payload:
```bash
# On your BCH-wallet host (has D:\Dev\NameCoin):
cd D:\Dev\NameCoin
node src/update.js digibyte.x '{
"ip": "<VPS IPv4>",
"tls": "<SHA-256 fingerprint>",
"u": "https://digibyte.x"
}'
```
After 1–2 BCH confirmations, `resolveEndpoint('digibyte.x')` returns the pinned pair and any wallet-aware client validates the TLS handshake against the fingerprint. Balance/Send tabs in the deployed bundle POST to `/p2p/balance` and `/p2p/broadcast`, transparently proxied to the daemon.
---
## Wallet-subpath install
Use this when the target host already serves other content at `/` and the wallet lives at `/web/` — e.g. the current `digibyte.x/web` layout, where the apex hosts existing pages and only the wallet section is added.
Difference from the previous section: `deploy-wallet.sh` overwrites `/etc/caddy/Caddyfile` and manages its own TLS cert. `deploy-wallet-web.sh` does neither — it renders an import snippet, optionally wires it into an existing Caddyfile between sentinel markers, and inherits whatever TLS the host already has configured.
```bash
# On your dev box:
scp -r D:\Dev\SilentCode\Digibyte user@vps:/opt/dgb-wallet
# On the VPS — automatic wiring:
sudo /opt/dgb-wallet/ops/deploy-wallet-web.sh --merge /etc/caddy/Caddyfile
# …or, manual wiring — script prints the exact three lines to paste
# inside your existing :443 { } block:
sudo /opt/dgb-wallet/ops/deploy-wallet-web.sh
```
The script:
1. Installs Caddy + Node 20 if missing.
2. `npm install`s the monorepo and runs a plain `npm run build -w @dgb-wallet/web-ui`. Production builds use a relative `./` asset base; `index.html` carries an inline script that re-anchors a `<base href>` at `<origin>…/web/` at runtime, so one bundle serves both `https://digibyte.x/web/` and the `navigate.st/bns/digibyte.x/web/` gateway. The script refuses to run if `DGB_UI_BASE` is set, and aborts if `dist/index.html` lacks `./assets/…` references or the runtime `<base>` script.
3. Rsyncs `packages/web-ui/dist/` → `<SITE_ROOT>/web/` (default `/var/www/dgb-wallet/web/`).
4. Creates the `dgb-wallet` system user and installs `dgb-daemon.service` (systemd) bound to `127.0.0.1:5174`.
5. Renders `Caddyfile.wallet-web-snippet` with `@SITE_ROOT@` substituted to `/etc/caddy/snippets/dgb-wallet.caddyfile`.
6. With `--merge <path>`: idempotently inserts (or replaces between `# --- START DGB WALLET IMPORT ---` / `# --- END DGB WALLET IMPORT ---`) an `import` line inside the last `:443 { }` block in the target Caddyfile, then `caddy validate` and reload. Without `--merge`: prints the three-line insert for the operator to paste by hand.
7. Prints the final wallet URLs (`/web/`, `/p2p/health`, `/p2p/balance`, `/p2p/broadcast`) and the redeploy one-liner.
Flags:
- `--merge <path>` — Caddyfile to inject the import line into.
- `--site-root <path>` — override the `/var/www/dgb-wallet` default. The bundle always lands under `<SITE_ROOT>/web/`.
Rollback: remove the three sentinel lines from the host Caddyfile and `systemctl reload caddy`. `systemctl disable --now dgb-daemon` stops the P2P daemon. `<SITE_ROOT>/web/` can be `rm -rf`'d without touching anything else the site serves.
## Wallet-side
Point `@dgb-wallet/rpc` at `https://your-domain.example.com` with the credentials `setup.sh` printed. See [../packages/rpc/README.md](../packages/rpc/README.md) for the demo.
## Security notes
Web wallet (nginx + daemon):
- **Strict CSP on `/web/`.** `script-src 'self'` plus the sha256 of the one inline script (the runtime `<base>` fix). Recompute with `node packages/web-ui/scripts/csp-hash.mjs` after editing `packages/web-ui/index.html`; `push.sh` warns on drift. Built asset tags carry `integrity=` (SRI) so a tampered asset file fails to load.
- **No keys on the server.** The daemon only ever sees xpubs / scripts to scan and signed transaction bytes to broadcast.
- **Blockbook is opt-in** and proxied so the upstream sees the VPS's IP, not the user's; the API key never reaches the browser.
Optional Core backend (`setup.sh` shape):
- **Wallet is disabled on the node.** Even a full RPC compromise cannot spend funds — there are no keys on the server.
- **RPC is not exposed on port 14022.** Core binds RPC to `127.0.0.1` only. All external access goes through Caddy on 443 with TLS + basic auth.
- **Rate limiting.** Caddy config limits any single IP to 60 requests/minute per endpoint (`scantxoutset` is cheap on a pruned node but not free).
- **Keep the RPC password strong.** The setup script generates a 40-char random one; don't shorten it. If you lose it, regenerate on the VPS and rewrite `digibyte.conf`.
- **No wallet method whitelist yet.** With `disablewallet=1` there's nothing sensitive to whitelist against; future hardening could restrict to only the six methods listed above. Low priority given the disabled-wallet posture.
## What's next
Once this is running, `@dgb-wallet/rpc` connects to it in seconds. Recovery flow becomes:
```
seed → derive addresses (offline) → scantxoutset via VPS → real balances
→ sign PSBT (offline) → broadcast via VPS
```
Whole loop, real chain data, no local Core install, no third-party service. Full self-sovereignty as soon as the VPS is yours.

19
ops/_start-dgb.ps1 Normal file
View file

@ -0,0 +1,19 @@
# Tiny launcher — starts digibyted.exe detached with the correct config.
# Used by the fix-pass in this session; setup-local.ps1 has the same
# logic inline for first-run installs.
param(
[string]$InstallDir = "D:\Program Files\DigiByte"
)
$exe = Join-Path $InstallDir "daemon\digibyted.exe"
$conf = Join-Path $InstallDir "digibyte.conf"
if (-not (Test-Path $exe)) { throw "not found: $exe" }
if (-not (Test-Path $conf)) { throw "not found: $conf" }
$proc = Start-Process -FilePath $exe -ArgumentList "-conf=`"$conf`"" -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 4
$still = Get-Process -Id $proc.Id -ErrorAction SilentlyContinue
if ($still) {
Write-Host "Started digibyted.exe (PID $($proc.Id))."
} else {
throw "digibyted.exe exited immediately (PID $($proc.Id) is gone). Check the debug log tail."
}

206
ops/deploy-wallet-web.sh Normal file
View file

@ -0,0 +1,206 @@
#!/usr/bin/env bash
# Bootstrap the DigiByte.X Wallet SUBPATH deployment — the web bundle
# served at `https://<host>/web/` alongside whatever else that host
# already serves at `/`. Companion to Caddyfile.wallet-web-snippet.
#
# Difference from deploy-wallet.sh:
# deploy-wallet.sh — wallet OWNS the whole site (Caddyfile.wallet at :443)
# deploy-wallet-web — wallet is ONE section of an existing site
# (Caddyfile.wallet-web-snippet imported into the
# host's existing Caddyfile)
#
# Usage:
# scp -r . user@vps:/opt/dgb-wallet
# ssh user@vps
# sudo /opt/dgb-wallet/ops/deploy-wallet-web.sh
# (optional) --merge /etc/caddy/Caddyfile
# (optional) --site-root /var/www/dgb-wallet
#
# Steps:
# 1. install caddy + node20 (if missing)
# 2. install monorepo deps, build web-ui (plain production build →
# relative ./assets base + runtime <base> fix in index.html)
# 3. create dgb-wallet system user, sync bundle to <SITE_ROOT>/web/
# 4. install dgb-daemon.service, enable + start
# 5. render snippet with SITE_ROOT into /etc/caddy/snippets/dgb-wallet.caddyfile
# 6. (--merge) insert `import` line into the given Caddyfile between
# sentinel markers; else print manual-merge instructions
# 7. caddy validate, reload
# 8. print status
set -euo pipefail
if [[ $EUID -ne 0 ]]; then
echo "Run as root (sudo)." >&2
exit 1
fi
REPO_DIR="${REPO_DIR:-/opt/dgb-wallet}"
SITE_ROOT="/var/www/dgb-wallet"
DAEMON_HOME="/var/lib/dgb-wallet"
SNIPPET_DIR="/etc/caddy/snippets"
SNIPPET_FILE="$SNIPPET_DIR/dgb-wallet.caddyfile"
MERGE_TARGET=""
OPS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
while [[ $# -gt 0 ]]; do
case "$1" in
--merge) MERGE_TARGET="${2:-}"; shift 2 ;;
--site-root) SITE_ROOT="${2:-}"; shift 2 ;;
-h|--help)
sed -n '2,25p' "$0"; exit 0 ;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
done
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
log "Step 1/7: install prerequisites (caddy, node 20)"
apt-get update -qq
apt-get install -y -qq curl gpg ca-certificates rsync debian-keyring debian-archive-keyring apt-transport-https
if ! command -v caddy >/dev/null; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
apt-get update -qq
apt-get install -y -qq caddy
fi
if ! command -v node >/dev/null || ! node -v | grep -qE '^v(20|22|24)\.'; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y -qq nodejs
fi
log "Step 2/7: install deps and build web-ui bundle (relative ./ base)"
cd "$REPO_DIR"
npm install --silent
# Plain production build. vite.config.ts defaults production to a
# RELATIVE base ('./'), and index.html carries an inline script that
# re-anchors a <base href> at <origin>…/web/ at runtime. That single
# bundle therefore works both at https://<host>/web/ and behind the
# navigate.st BNS gateway (which injects its own <base>). Do NOT set
# DGB_UI_BASE=/web/ here — an absolute base breaks the gateway path.
if [[ -n "${DGB_UI_BASE:-}" ]]; then
echo "ERROR: DGB_UI_BASE is set ('$DGB_UI_BASE'). Unset it — the subpath deploy relies on the relative './' base." >&2
exit 1
fi
npm run build -w @dgb-wallet/web-ui
# Sanity check — asset URLs are relative (./assets/*), not absolute.
if ! grep -q 'src="\./assets/' "$REPO_DIR/packages/web-ui/dist/index.html"; then
echo "ERROR: dist/index.html has no ./assets/ references — build did not produce a relative base" >&2
exit 1
fi
if ! grep -q 'indexOf(./web/.)' "$REPO_DIR/packages/web-ui/dist/index.html"; then
echo "ERROR: dist/index.html is missing the runtime <base> fix script — /web/ subpath serving will break" >&2
exit 1
fi
mkdir -p "$SITE_ROOT/web"
rsync -a --delete "$REPO_DIR/packages/web-ui/dist/" "$SITE_ROOT/web/"
log "Step 3/7: create dgb-wallet user"
if ! id -u dgb-wallet >/dev/null 2>&1; then
useradd --system --home "$DAEMON_HOME" --shell /usr/sbin/nologin dgb-wallet
fi
mkdir -p "$DAEMON_HOME"
chown -R dgb-wallet:dgb-wallet "$DAEMON_HOME" "$REPO_DIR"
log "Step 4/7: install dgb-daemon.service"
install -m 0644 "$OPS_DIR/dgb-daemon.service" /etc/systemd/system/dgb-daemon.service
systemctl daemon-reload
systemctl enable --now dgb-daemon.service
sleep 2
systemctl --no-pager --lines=5 status dgb-daemon.service || true
log "Step 5/7: render Caddy snippet → $SNIPPET_FILE"
mkdir -p "$SNIPPET_DIR"
sed -e "s|@SITE_ROOT@|$SITE_ROOT|g" \
"$OPS_DIR/Caddyfile.wallet-web-snippet" > "$SNIPPET_FILE"
log "Step 6/7: wire snippet into the host Caddyfile"
IMPORT_LINE=" import $SNIPPET_FILE"
START_MARK="# --- START DGB WALLET IMPORT ---"
END_MARK="# --- END DGB WALLET IMPORT ---"
if [[ -n "$MERGE_TARGET" ]]; then
if [[ ! -f "$MERGE_TARGET" ]]; then
echo "ERROR: --merge target does not exist: $MERGE_TARGET" >&2
exit 1
fi
# Idempotent: if the sentinel block already exists, replace between
# markers; otherwise insert before the LAST `}` in the file (assumed
# to close the site block).
if grep -qF "$START_MARK" "$MERGE_TARGET"; then
awk -v s="$START_MARK" -v e="$END_MARK" -v line="$IMPORT_LINE" '
$0 ~ s { print; print line; skip=1; next }
$0 ~ e { skip=0 }
!skip { print }
' "$MERGE_TARGET" > "$MERGE_TARGET.tmp"
mv "$MERGE_TARGET.tmp" "$MERGE_TARGET"
echo " updated existing import block in $MERGE_TARGET"
else
# Insert marker block before the last `}` on its own line.
LAST_BRACE_LINE=$(grep -n '^\s*}\s*$' "$MERGE_TARGET" | tail -n1 | cut -d: -f1)
if [[ -z "$LAST_BRACE_LINE" ]]; then
echo "ERROR: could not locate a site-block close brace in $MERGE_TARGET." >&2
echo " Add these three lines inside your :443 block by hand:" >&2
echo " $START_MARK" >&2
echo " $IMPORT_LINE" >&2
echo " $END_MARK" >&2
exit 1
fi
awk -v n="$LAST_BRACE_LINE" -v s="$START_MARK" -v line="$IMPORT_LINE" -v e="$END_MARK" '
NR == n { print " " s; print line; print " " e }
{ print }
' "$MERGE_TARGET" > "$MERGE_TARGET.tmp"
mv "$MERGE_TARGET.tmp" "$MERGE_TARGET"
echo " inserted import block into $MERGE_TARGET before line $LAST_BRACE_LINE"
fi
caddy validate --config "$MERGE_TARGET"
systemctl reload caddy || systemctl restart caddy
else
cat <<EOF
No --merge target given. Snippet rendered to:
$SNIPPET_FILE
Add this single line INSIDE your existing :443 { … } site block in
the host Caddyfile (e.g. /etc/caddy/Caddyfile):
$START_MARK
$IMPORT_LINE
$END_MARK
Then: sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Re-run this script with --merge <path> to have it done automatically.
EOF
fi
log "Step 7/7: DONE."
VPS_IP="$(curl -s -4 https://ifconfig.me || echo '<unknown>')"
cat <<EOF
===================================================================
DigiByte.X Wallet — subpath deploy complete
===================================================================
Site root: $SITE_ROOT/web/
Snippet: $SNIPPET_FILE
Daemon: dgb-daemon.service (bound 127.0.0.1:5174)
VPS IPv4: $VPS_IP
Wallet endpoints (once the host Caddyfile imports the snippet):
https://<host>/web/ static bundle
https://<host>/p2p/health daemon health (reverse-proxied)
https://<host>/p2p/balance BIP158 scan
https://<host>/p2p/broadcast Dandelion++ broadcast
To re-deploy after a code change, from the DEV BOX:
ops/push.sh # web bundle + daemon sources, restart daemon
ops/push.sh web # bundle only
ops/push.sh daemon # daemon only
(builds locally, ships over ssh, swaps atomically, verifies the served hash)
Tail logs:
journalctl -u dgb-daemon -u caddy -f
EOF

140
ops/deploy-wallet.sh Normal file
View file

@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Bootstrap a fresh VPS to serve digibyte.x — static wallet bundle +
# reverse-proxied /p2p/ daemon behind a self-signed TLS cert whose
# fingerprint you paste into the digibyte.x BNS record.
#
# Usage:
# scp -r . user@vps:/opt/dgb-wallet # get the monorepo onto the box
# ssh user@vps
# sudo /opt/dgb-wallet/ops/deploy-wallet.sh digibyte.x
#
# Steps:
# 1. install caddy + node20 (if missing)
# 2. install monorepo deps, build web-ui
# 3. create dgb-wallet system user, seed /var/lib/dgb-wallet
# 4. install dgb-daemon.service, enable + start
# 5. generate self-signed cert (CN=digibyte.x), print SHA-256
# 6. render Caddyfile.wallet with cert paths + site root
# 7. install Caddy service, reload
# 8. print the exact BNS UPD payload for you to sign
set -euo pipefail
if [[ $EUID -ne 0 ]]; then
echo "Run as root (sudo)." >&2
exit 1
fi
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <bns-name> e.g. $0 digibyte.x" >&2
exit 1
fi
BNS_NAME="$1"
REPO_DIR="${REPO_DIR:-/opt/dgb-wallet}"
SITE_ROOT="/var/www/dgb-wallet"
CERT_DIR="/etc/ssl/dgb-wallet"
DAEMON_HOME="/var/lib/dgb-wallet"
OPS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
log "Step 1/8: install prerequisites (caddy, node 20)"
apt-get update -qq
apt-get install -y -qq curl gpg ca-certificates openssl rsync debian-keyring debian-archive-keyring apt-transport-https
if ! command -v caddy >/dev/null; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list >/dev/null
apt-get update -qq
apt-get install -y -qq caddy
fi
if ! command -v node >/dev/null || ! node -v | grep -qE '^v(20|22|24)\.'; then
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
apt-get install -y -qq nodejs
fi
log "Step 2/8: install deps and build web-ui bundle"
cd "$REPO_DIR"
npm install --silent
npm run build -w @dgb-wallet/web-ui
mkdir -p "$SITE_ROOT"
rsync -a --delete "$REPO_DIR/packages/web-ui/dist/" "$SITE_ROOT/"
log "Step 3/8: create dgb-wallet user"
if ! id -u dgb-wallet >/dev/null 2>&1; then
useradd --system --home "$DAEMON_HOME" --shell /usr/sbin/nologin dgb-wallet
fi
mkdir -p "$DAEMON_HOME"
chown -R dgb-wallet:dgb-wallet "$DAEMON_HOME" "$REPO_DIR"
log "Step 4/8: install dgb-daemon.service"
install -m 0644 "$OPS_DIR/dgb-daemon.service" /etc/systemd/system/dgb-daemon.service
systemctl daemon-reload
systemctl enable --now dgb-daemon.service
sleep 2
systemctl --no-pager --lines=5 status dgb-daemon.service || true
log "Step 5/8: generate self-signed TLS cert (CN=$BNS_NAME)"
mkdir -p "$CERT_DIR"
if [[ ! -f "$CERT_DIR/server.crt" ]]; then
openssl req -x509 -newkey ec:<(openssl ecparam -name prime256v1) \
-sha256 -days 3650 -nodes \
-keyout "$CERT_DIR/server.key" \
-out "$CERT_DIR/server.crt" \
-subj "/CN=$BNS_NAME"
chmod 600 "$CERT_DIR/server.key"
fi
TLS_FINGERPRINT="$(openssl x509 -in "$CERT_DIR/server.crt" -outform DER | openssl dgst -sha256 -binary | xxd -p -c 64)"
log "Step 6/8: render Caddyfile"
sed \
-e "s|@CERT_FILE@|$CERT_DIR/server.crt|g" \
-e "s|@KEY_FILE@|$CERT_DIR/server.key|g" \
-e "s|@SITE_ROOT@|$SITE_ROOT|g" \
"$OPS_DIR/Caddyfile.wallet" > /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy || systemctl restart caddy
log "Step 7/8: open firewall (443 https, 12024 DGB p2p out only)"
if command -v ufw >/dev/null; then
ufw --force enable
ufw allow 22/tcp
ufw allow 443/tcp
fi
log "Step 8/8: DONE."
VPS_IP="$(curl -s -4 https://ifconfig.me || echo '<unknown>')"
cat <<EOF
===================================================================
digibyte.x deploy complete
===================================================================
Site root: $SITE_ROOT
Daemon: dgb-daemon.service (bound 127.0.0.1:5174)
Reverse proxy: Caddy on :443 → /p2p/* to daemon, / to bundle
VPS IPv4: $VPS_IP
TLS fingerprint: $TLS_FINGERPRINT
Paste this into the BNS UPD (from your BCH wallet host):
cd D:\\Dev\\NameCoin
node src/update.js $BNS_NAME '{
"ip": "$VPS_IP",
"tls": "$TLS_FINGERPRINT",
"u": "https://$BNS_NAME"
}'
Wait 1–2 confirmations, then load https://$BNS_NAME/ in a wallet-
aware client. Balance/Send will hit https://$BNS_NAME/p2p/balance
transparently.
To re-deploy after a code change:
cd $REPO_DIR && git pull && npm install && \\
npm run build -w @dgb-wallet/web-ui && \\
rsync -a --delete packages/web-ui/dist/ $SITE_ROOT/ && \\
systemctl restart dgb-daemon
Tail logs:
journalctl -u dgb-daemon -u caddy -f
EOF

56
ops/dgb-daemon.service Normal file
View file

@ -0,0 +1,56 @@
# systemd unit for the @dgb-wallet/p2p HTTP daemon on a wallet VPS.
# Ships with digibyte.x — Caddy reverse-proxies /p2p/ here.
#
# Prereqs (all provisioned by ops/deploy-wallet.sh):
# - /opt/dgb-wallet/ contains the monorepo checkout (or at minimum
# packages/{core,p2p,net}/ + workspace root)
# - Node 20+ installed as /usr/bin/node
# - `npm install` already run at /opt/dgb-wallet/ so node_modules
# is populated
# - dgb-wallet user owns /opt/dgb-wallet and /var/lib/dgb-wallet
#
# The daemon binds 127.0.0.1:5174 only. It is NOT reachable from the
# public internet directly; Caddy on 443 fronts it via /p2p/*.
[Unit]
Description=DigiByte.X Wallet P2P Daemon (BIP157/158 SPV + broadcast)
Documentation=https://digibyte.x
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=dgb-wallet
Group=dgb-wallet
WorkingDirectory=/opt/dgb-wallet
# 8 GB heap ceiling — the header chain (~7.8M entries × 80 B + JS
# overhead) sits around 3-4 GB in steady state; leave headroom for
# scan bursts. Match packages/p2p/package.json's daemon script.
Environment="NODE_OPTIONS=--max-old-space-size=8192"
ExecStart=/usr/bin/node --import tsx/esm packages/p2p/bin/dgb-daemon.ts
Restart=on-failure
RestartSec=5s
# Hardening — daemon reads/writes only its data dir and its checkout.
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ReadWritePaths=/var/lib/dgb-wallet
Environment="APPDATA=/var/lib/dgb-wallet"
# Kill on OOM rather than let systemd escalate to the whole slice.
OOMScoreAdjust=500
# Journal so `journalctl -u dgb-daemon -f` shows peer connect/disconnect
# and scan telemetry.
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target

42
ops/digibyte.conf Normal file
View file

@ -0,0 +1,42 @@
# DigiByte Core — pruned chain-state indexer for wallet backend.
# Deployed by ops/setup.sh; edit that script rather than this file.
# --- Storage ---
datadir=/var/lib/digibyte
prune=550 # minimum; keeps ~600 MB of recent chain
txindex=0 # no historical tx index needed; wallet uses scantxoutset
# --- Wallet: DISABLED ---
disablewallet=1 # node holds no keys; RPC compromise cannot spend funds
# --- RPC (bound to localhost, Caddy proxies) ---
server=1
rpcbind=127.0.0.1
rpcallowip=127.0.0.1
rpcport=14022
rpcuser=__RPC_USER__ # substituted by setup.sh
rpcpassword=__RPC_PASSWORD__ # substituted by setup.sh (40-char random)
# Increase worker threads a bit — scantxoutset is single-request but multiple
# clients may query concurrently.
rpcworkqueue=64
rpcthreads=8
# --- P2P ---
listen=1
maxconnections=40
port=12024
# --- Perf / safety ---
dbcache=450 # LevelDB cache; comfortable on 1 GB RAM
maxmempool=100
mempoolexpiry=72
# --- Logging (keep small; journalctl captures) ---
debug=0
logtimestamps=1
logips=0
# --- ZMQ (optional; enables future notify path for real-time balance updates) ---
# zmqpubhashblock=tcp://127.0.0.1:28332
# zmqpubrawtx=tcp://127.0.0.1:28333

34
ops/digibyte.service Normal file
View file

@ -0,0 +1,34 @@
[Unit]
Description=DigiByte Core (pruned wallet backend)
Documentation=https://github.com/DigiByte-Core/digibyte
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=digibyte
Group=digibyte
ExecStart=/usr/local/bin/digibyted -conf=/etc/digibyte/digibyte.conf -datadir=/var/lib/digibyte
ExecStop=/usr/local/bin/digibyte-cli -conf=/etc/digibyte/digibyte.conf stop
Restart=on-failure
RestartSec=10
TimeoutStopSec=120
# --- Hardening ---
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/digibyte /etc/digibyte
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictRealtime=true
RestrictSUIDSGID=true
[Install]
WantedBy=multi-user.target

156
ops/nginx-digibyte-x.conf Normal file
View file

@ -0,0 +1,156 @@
# nginx vhost for digibyte.x on the coinspectrum VPS — the LIVE layout.
# Mirror of /etc/nginx/sites-enabled/digibyte-x (synced 2026-09-09).
#
# Deploy: render the one secret, then install + reload:
# . /etc/dgb-core/nownodes.env # NOWNODES_API_KEY=…
# sed "s|@NOWNODES_API_KEY@|$NOWNODES_API_KEY|" ops/nginx-digibyte-x.conf # > /etc/nginx/sites-enabled/digibyte-x && nginx -t && systemctl reload nginx
#
# The Content-Security-Policy hash below pins the inline <base>-fix
# script in the built index.html. Recompute after any change to
# packages/web-ui/index.html: node packages/web-ui/scripts/csp-hash.mjs
#
# nginx vhost for digibyte.x on coinspectrum.
#
# Trust model: SELF-SIGNED cert (10y), pinned on-chain via the digibyte.x
# BNS `tls` record. Wallet clients resolve digibyte.x → {ip, tls}, dial
# the IP on :443 with SNI=digibyte.x, and verify by comparing the observed
# cert's DER SHA-256 to the on-chain pin. No CA, no ACME.
#
# nginx selects this cert only for SNI=digibyte.x — every other vhost on
# this box (navigate.st, silentmode.st, mail.silentmode.st, etc.) keeps
# its Let's Encrypt cert untouched.
#
# Layout:
# / → 302 to /web/ (wallet is the only thing here)
# /web/* → static bundle from /var/www/dgb-wallet/web/
# /p2p/* → reverse-proxy to dgb-daemon on 127.0.0.1:5174
# (strip /p2p prefix so daemon sees /health, /balance, etc.)
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name digibyte.x;
# Self-signed cert. Fingerprint is pinned on-chain — rotating the cert
# is a single BNS UPD, no client update.
ssl_certificate /etc/ssl/dgb-wallet/server.crt;
ssl_certificate_key /etc/ssl/dgb-wallet/server.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
# No HSTS: HSTS presupposes CA-issued trust. We use fingerprint pinning
# instead — the wallet client rejects any cert whose DER SHA-256 does
# not match the on-chain record, regardless of browser trust store.
# Belt-and-suspenders: keep this vhost strictly for digibyte.x. If
# SNI doesn't match the server_name above, nginx falls back to the
# default_server (coinspectrum), which has its own LE cert — so a
# wrong-SNI connection never sees our self-signed cert anyway.
# /web/* — static wallet bundle. Vite emits absolute URLs like
# /web/assets/index-<hash>.js so we keep the /web/ prefix in the
# filesystem path (root, not alias).
location /web/ {
root /var/www/dgb-wallet;
try_files $uri $uri/ /web/index.html;
# index.html must never be cached — new deploys change the asset
# filename hashes, and the entry HTML has to fetch the new ones.
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
# Strict CSP (docs/SECURITY-ROADMAP.md item 1). The sha256 is the
# inline <base>-fix script in the built index.html — recompute
# whenever packages/web-ui/index.html changes.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'sha256-MYCXjyaeuO4n6g9JktOO1yXmG9rBTuGJ5Wk4GojDwig=' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.coingecko.com; img-src 'self' data:; font-src 'self'; base-uri 'self'; frame-ancestors 'none'; form-action 'none'; object-src 'none'" always;
}
# Content-hashed assets: cache forever.
location /web/assets/ {
root /var/www/dgb-wallet;
add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
}
# /p2p/* → daemon. The daemon registered /health, /balance, /broadcast
# at its own roots, so strip the /p2p prefix before proxying.
location /p2p/ {
proxy_pass http://127.0.0.1:5174/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 120s; # BIP158 scans can take a while
# The daemon already emits CORS headers for '*' — no need to add
# them here. Leave request bodies (POST /broadcast, /balance) unbuffered.
proxy_request_buffering off;
}
# /blockbook/* → same-origin reverse-proxy to a public Blockbook.
# Two reasons this exists:
# 1. Atomic's Blockbook sends no CORS headers, so a direct
# browser fetch from the wallet origin is silently blocked
# and the wallet reads "zero balance" for every xpub.
# 2. Requests go out under this VPS's IP, not the individual
# user's — the upstream can't correlate address queries to
# individual browsers. (This VPS operator still can — the
# wallet's Blockbook backend toggle carries an inline note.)
location /blockbook/ {
# Upstream: NOWNodes DGB Blockbook (fully synced; api-key
# injected server-side so the browser never sees the credential).
# Same posture as Atomic — proxy hides individual user IPs from
# the upstream operator. Both Atomic and NOWNodes' Blockbook
# fork rejects raw bech32 address queries; `/xpub` works for
# both (server-side derivation covers all script kinds).
proxy_pass https://dgbbook.nownodes.io/api/v2/;
proxy_ssl_server_name on;
proxy_ssl_name dgbbook.nownodes.io;
proxy_set_header Host dgbbook.nownodes.io;
proxy_set_header X-Forwarded-For "";
proxy_set_header X-Real-IP "";
proxy_set_header api-key "@NOWNODES_API_KEY@";
proxy_http_version 1.1;
proxy_read_timeout 60s;
proxy_hide_header Access-Control-Allow-Origin;
proxy_hide_header Access-Control-Allow-Methods;
proxy_hide_header Access-Control-Allow-Headers;
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
if ($request_method = OPTIONS) { return 204; }
}
# Bare / — redirect to /web/. This vhost exists ONLY to host the wallet;
# no reason to leave a hint here for hostile traffic.
# /web (no trailing slash) — redirect so bare /web works. Sits above /web/ (longest-prefix) so it does not shadow the static block.
location = /web {
# BNS gateway strips the Location header, so a plain 302 goes
# nowhere for gateway-loaded users. Return HTML that redirects
# via meta-refresh + JS with a RELATIVE href — resolves against
# the current URL, works under both digibyte.x and navigate.st.
default_type text/html;
return 200 "<!doctype html><html><head><meta http-equiv=\"refresh\" content=\"0;url=web/\"><script>location.replace(\"web/\")</script></head><body>Redirecting to <a href=\"web/\">web/</a></body></html>";
}
# Marketing landing page — served at the apex. Wallet is at /web/.
location = / {
root /var/www/dgb-wallet;
try_files /index.html =404;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
}
# Root-level static files (favicon.svg, robots.txt, …). Anything
# not on disk still 404s, and the wallet routes above (which are
# longer-prefix) still win. Kept explicit-only to avoid accidental
# exposure of /var/www/dgb-wallet subdirs.
location = /favicon.svg { root /var/www/dgb-wallet; }
location = /robots.txt { root /var/www/dgb-wallet; }
# Everything else on this vhost — 404. No indexer/status page.
location / {
return 404;
}
}

101
ops/push.sh Normal file
View file

@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Push a build to the wallet VPS from the dev box. This is the routine
# deploy — deploy-wallet-web.sh is the one-time bootstrap.
#
# ops/push.sh web bundle + daemon sources, restart daemon
# ops/push.sh web web bundle only (no daemon restart)
# ops/push.sh daemon daemon sources only, restart daemon
#
# What it does:
# web — `npm run build -w @dgb-wallet/web-ui`, tar dist/ to the VPS,
# atomic swap into $SITE_ROOT/web/, verify the served bundle
# hash matches the local build, and warn if the inline script's
# CSP hash no longer matches the live Content-Security-Policy.
# daemon — ship packages/p2p/{src,bin} and packages/core/{src,dist}
# (the daemon runs from source via tsx and imports core's
# dist), chown to the service user, restart dgb-daemon, wait
# for /health.
#
# Requires an `ssh` host alias (default: silentmode) with root access.
set -euo pipefail
HOST="${DGB_VPS_HOST:-silentmode}"
REPO_DIR="${DGB_VPS_REPO:-/opt/dgb-wallet}"
SITE_ROOT="${DGB_VPS_SITE_ROOT:-/var/www/dgb-wallet}"
SNI_HOST="${DGB_SNI_HOST:-digibyte.x}"
WHAT="${1:-all}"
here="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$here"
log() { printf '\n\033[1;36m>>> %s\033[0m\n' "$*"; }
push_web() {
log "building web-ui"
# Plain production build → relative ./assets base + runtime <base> fix.
# Never DGB_UI_BASE=/web/ here (see deploy-wallet-web.sh).
MSYS_NO_PATHCONV=1 npm run build -w @dgb-wallet/web-ui >/dev/null
local js
js="$(ls packages/web-ui/dist/assets/index-*.js | head -1)"
local local_hash
local_hash="$(sha256sum "$js" | cut -c1-64)"
log "shipping bundle → $HOST:$SITE_ROOT/web/"
tar -cz -C packages/web-ui/dist . | ssh "$HOST" "set -e
mkdir -p '$SITE_ROOT/web.new'
tar -xz -C '$SITE_ROOT/web.new'
chown -R www-data:www-data '$SITE_ROOT/web.new'
rm -rf '$SITE_ROOT/web.old'
mv '$SITE_ROOT/web' '$SITE_ROOT/web.old'
mv '$SITE_ROOT/web.new' '$SITE_ROOT/web'
rm -rf '$SITE_ROOT/web.old'"
log "verifying served bundle"
local served
served="$(ssh "$HOST" "curl -sk --resolve $SNI_HOST:443:127.0.0.1 https://$SNI_HOST/web/ | grep -o 'index-[A-Za-z0-9_-]*\\.js' | head -1")"
local remote_hash
remote_hash="$(ssh "$HOST" "sha256sum '$SITE_ROOT/web/assets/$served' | cut -c1-64")"
if [[ "$remote_hash" != "$local_hash" ]]; then
echo "ERROR: served $served hash $remote_hash != local $local_hash" >&2
exit 1
fi
echo " served $served — hash matches"
# CSP hash drift check: the inline <base>-fix script in index.html is
# pinned by sha256 in the live nginx Content-Security-Policy.
local want live_csp
want="$(node packages/web-ui/scripts/csp-hash.mjs)"
live_csp="$(ssh "$HOST" "curl -sk -D - -o /dev/null --resolve $SNI_HOST:443:127.0.0.1 https://$SNI_HOST/web/ | tr -d '\\r' | grep -i '^content-security-policy' || true")"
if [[ -n "$live_csp" && "$live_csp" != *"$want"* ]]; then
echo "WARNING: live CSP does not contain the current inline-script hash ($want)." >&2
echo " Update ops/nginx-digibyte-x.conf + /etc/nginx/sites-enabled/digibyte-x, or the wallet will not load." >&2
else
echo " CSP hash OK ($want)"
fi
}
push_daemon() {
log "typechecking p2p"
(cd packages/p2p && npx tsc --noEmit -p .)
log "shipping daemon sources → $HOST:$REPO_DIR"
tar -cz packages/p2p/src packages/p2p/bin packages/core/src packages/core/dist packages/core/package.json packages/p2p/package.json \
| ssh "$HOST" "set -e
tar -xz -C '$REPO_DIR'
chown -R dgb-wallet:dgb-wallet '$REPO_DIR/packages/p2p' '$REPO_DIR/packages/core'
systemctl restart dgb-daemon
for i in \$(seq 1 30); do
if curl -s -m 3 http://127.0.0.1:5174/health >/dev/null; then break; fi
sleep 2
done
systemctl is-active dgb-daemon
curl -s -m 5 http://127.0.0.1:5174/health | head -c 140; echo"
}
case "$WHAT" in
all) push_web; push_daemon ;;
web) push_web ;;
daemon) push_daemon ;;
*) echo "usage: ops/push.sh [all|web|daemon]" >&2; exit 1 ;;
esac
log "done"

321
ops/scratch-digibyte-x.html Normal file
View file

@ -0,0 +1,321 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head><base href="/bns/digibyte.x/">
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DigiByte — no ICO, no company, no VCs</title>
<meta name="description" content="DigiByte: 15-second blocks, five mining algorithms, no premine, no company, community-run since 2014. Layer-1 assets via DigiAssets.">
<style>
:root{
/* DigiByte brand palette — deep digital blue + bright accent */
--dgb-deep:#002583;
--dgb-blue:#22A0FF;
--dgb-bright:#4AC8FF;
--dgb-black:#000000;
--dgb-white:#F8FBFF;
--dgb-slate:#1E2A40;
/* Sister-chain accent — used only on the BCH column of the compare grid. */
--bch-green:#0AC18E;
--mono:ui-monospace,"SF Mono",Menlo,Consolas,"Courier New",monospace;
--sans:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;
}
:root[data-theme="dark"]{
--bg:var(--dgb-black);
--panel:#0e0e10;
--panel-hover:#141419;
--line:rgba(248,251,255,.10);
--line-strong:rgba(248,251,255,.22);
--txt:var(--dgb-white);
--dim:#7a848f;
--accent:var(--dgb-blue);
--glow:rgba(34,160,255,.55);
--glow-soft:rgba(34,160,255,.20);
}
:root[data-theme="light"]{
--bg:var(--dgb-white);
--panel:#ffffff;
--panel-hover:#f2f5fa;
--line:rgba(30,42,64,.12);
--line-strong:rgba(30,42,64,.24);
--txt:var(--dgb-black);
--dim:var(--dgb-slate);
--accent:var(--dgb-deep);
--glow:rgba(0,37,131,.35);
--glow-soft:rgba(0,37,131,.14);
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--bg);color:var(--txt);font-family:var(--sans);min-height:100vh}
body{
background:
radial-gradient(1000px 600px at 12% -10%, rgba(34,160,255,.14), transparent 60%),
radial-gradient(800px 500px at 92% 100%, rgba(34,160,255,.08), transparent 60%),
var(--bg);
padding:0 22px 96px;
transition:background-color .2s ease, color .2s ease;
}
.wrap{max-width:1000px;margin:0 auto;position:relative;z-index:2}
.theme-toggle{
position:fixed;top:16px;right:16px;z-index:10;
background:transparent;color:var(--dim);border:1px solid var(--line);
padding:7px 12px;border-radius:6px;cursor:pointer;
font-family:var(--mono);font-size:11px;letter-spacing:.22em;text-transform:uppercase;
}
.theme-toggle:hover{color:var(--accent);border-color:var(--accent)}
/* ---------- Hero ---------- */
.hero{
min-height:100vh;
display:flex;flex-direction:column;
align-items:center;justify-content:center;
text-align:center;padding:0;
}
.kicker{
display:inline-flex;align-items:center;gap:8px;
font-family:var(--mono);font-size:11px;letter-spacing:.35em;text-transform:uppercase;
color:var(--accent);
padding:6px 12px 6px 8px;border:1px solid var(--line);border-radius:100px;
margin-bottom:32px;
}
.dmark{width:16px;height:16px;flex:0 0 16px}
.wordmark{
font-weight:900;line-height:.95;letter-spacing:-.01em;
font-size:clamp(56px,12vw,148px);
color:var(--txt);
}
.wordmark .byte{
color:var(--accent);
text-shadow:0 0 12px var(--glow),0 0 40px var(--glow-soft);
}
.tag{
margin-top:22px;
font-size:clamp(16px,2.2vw,24px);
color:var(--dim);font-weight:400;letter-spacing:.01em;
}
.tag b{color:var(--accent);font-weight:600}
/* ---------- Sections ---------- */
section{padding:48px 0 24px}
section h2{
font-family:var(--mono);font-size:12px;letter-spacing:.3em;text-transform:uppercase;
color:var(--accent);margin-bottom:20px;
}
section .lede{
font-size:clamp(20px,2.4vw,28px);line-height:1.4;
max-width:780px;color:var(--txt);font-weight:400;
margin-bottom:22px;
}
section .lede b{color:var(--accent);font-weight:600}
section .body{
font-size:16px;line-height:1.65;color:var(--txt);opacity:.88;
max-width:720px;
}
section .body p+p{margin-top:14px}
.stats{
display:grid;gap:14px;margin-top:26px;
grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
max-width:820px;
}
.stat{
border:1px solid var(--line);background:var(--panel);
padding:18px 18px 16px;border-radius:8px;
}
.stat .n{
font-family:var(--mono);font-size:24px;font-weight:800;
color:var(--accent);letter-spacing:-.01em;
}
.stat .l{
font-family:var(--mono);font-size:10.5px;letter-spacing:.24em;text-transform:uppercase;
color:var(--dim);margin-top:6px;
}
/* ---------- Sister-chain compare ---------- */
.siblings .lede{margin-bottom:28px}
.compare{
display:grid;gap:14px;
grid-template-columns:repeat(auto-fit,minmax(300px,1fr));
max-width:920px;
}
.col{
border:1px solid var(--line);background:var(--panel);
padding:20px 22px 22px;border-radius:10px;
display:flex;flex-direction:column;gap:14px;
}
.col-h{
font-family:var(--mono);font-size:11px;letter-spacing:.28em;text-transform:uppercase;
color:var(--dim);
padding-bottom:12px;border-bottom:1px solid var(--line);
display:flex;align-items:center;gap:8px;
}
.col-h .swatch{
width:10px;height:10px;border-radius:2px;flex:0 0 10px;
}
.col.dgb .col-h{color:var(--accent)}
.col.dgb .col-h .swatch{background:var(--accent);box-shadow:0 0 8px var(--glow)}
.col.bch .col-h{color:var(--bch-green)}
.col.bch .col-h .swatch{background:var(--bch-green);box-shadow:0 0 8px rgba(10,193,142,.55)}
.col ul{list-style:none;display:flex;flex-direction:column;gap:10px}
.col li{
display:grid;grid-template-columns:38% 1fr;gap:12px;
font-size:14.5px;line-height:1.45;
}
.col li .k{
font-family:var(--mono);font-size:11px;letter-spacing:.18em;text-transform:uppercase;
color:var(--dim);padding-top:2px;
}
.col li .v{color:var(--txt);opacity:.94}
.compare-note{
margin-top:20px;font-size:14px;color:var(--dim);
}
.compare-note a{color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)}
.compare-note a:hover{opacity:.75}
/* ---------- Placeholder / soon card ---------- */
.soon{
margin-top:26px;padding:22px 24px;border-radius:10px;
border:1px dashed var(--line-strong);background:var(--panel);
display:flex;align-items:center;gap:16px;flex-wrap:wrap;
}
.soon .blink{
width:10px;height:10px;border-radius:2px;background:var(--accent);
box-shadow:0 0 10px var(--glow);
animation:blink 1.1s steps(2,end) infinite;
}
@keyframes blink{50%{opacity:.25}}
.soon .label{
font-family:var(--mono);font-size:11px;letter-spacing:.25em;text-transform:uppercase;
color:var(--accent);font-weight:700;
}
.soon .msg{color:var(--txt);opacity:.9;font-size:15px}
/* ---------- Footer ---------- */
footer{
margin-top:64px;padding:22px 0 0;border-top:1px solid var(--line);
display:flex;justify-content:space-between;align-items:center;gap:16px;flex-wrap:wrap;
color:var(--dim);font-family:var(--mono);
font-size:11.5px;letter-spacing:.15em;text-transform:uppercase;
}
footer a{color:var(--accent);text-decoration:none;border-bottom:1px dotted var(--accent)}
footer a:hover{opacity:.7}
footer .wm .g{color:var(--accent)}
@media (max-width:600px){
body{padding:0 16px 60px}
.theme-toggle{top:12px;right:12px;padding:6px 10px}
.col li{grid-template-columns:1fr;gap:2px}
}
</style>
</head>
<body>
<button class="theme-toggle" type="button" id="themeToggle" aria-label="Toggle theme">◐ Theme</button>
<div class="wrap">
<header class="hero">
<span class="kicker">
<!-- Stylized "D" mark — DigiByte's identity is the deep-blue-hex Rubik shape;
this is a simple geometric stand-in so the page stays self-contained. -->
<svg class="dmark" viewBox="0 0 32 32" fill="none" aria-hidden="true">
<polygon points="16,2 30,10 30,22 16,30 2,22 2,10" fill="var(--dgb-blue)"/>
<text x="16" y="21" text-anchor="middle" font-family="system-ui" font-weight="900" font-size="14" fill="#fff">D</text>
</svg>
Since 2014 · Community-run
</span>
<h1 class="wordmark">Digi<span class="byte">Byte</span></h1>
<p class="tag">No <b>ICO</b>. No <b>company</b>. No <b>VCs</b>. Just the chain.</p>
</header>
<section>
<h2>What it is</h2>
<p class="lede">
DigiByte is a proof-of-work chain that launched in January 2014 with <b>no premine, no ICO, no founder allocation</b> — and is still run by the community that showed up, not by a company that owns it.
</p>
<div class="body">
<p>Where Bitcoin picked a 10-minute block target for a settlement layer, DigiByte picked 15 seconds for a payments layer. Where Bitcoin picked one hash function, DigiByte picked five — so no single class of miner (ASIC, GPU, CPU) can dominate the chain, and no algo failure takes it down.</p>
<p>It has run continuously since 2014 without a foundation, a treasury, or a corporate address. If that sounds unremarkable, look at how few chains that describes.</p>
</div>
<div class="stats">
<div class="stat"><div class="n">15 s</div><div class="l">Block target</div></div>
<div class="stat"><div class="n">5</div><div class="l">Mining algorithms</div></div>
<div class="stat"><div class="n">21 B</div><div class="l">Max supply</div></div>
<div class="stat"><div class="n">0</div><div class="l">Premine · ICO · VCs</div></div>
</div>
</section>
<section>
<h2>What you can build on it</h2>
<p class="lede">
<b>DigiAssets</b> — a Layer-2 asset issuance protocol on top of DigiByte. Issue tokens, NFTs, identity attestations, receipts, and event-tickets that inherit DigiByte's multi-algo security.
</p>
<div class="body">
<p>DigiID is the companion — password-less authentication signed by a DigiByte key, no server-side account database to breach. Both have been live for years, with reference implementations and open specifications.</p>
<p>Everything settles on the base chain. No sidechain, no rollup, no bridge — same design ethos as the Bitcoin Cash side of this registry.</p>
</div>
</section>
<section class="siblings">
<h2>Sister chains</h2>
<p class="lede">
DigiByte and Bitcoin Cash both keep money on the base chain. Different specialisations, same conviction that <b>the chain enforces, not a server</b>.
</p>
<div class="compare">
<div class="col dgb">
<div class="col-h"><span class="swatch" aria-hidden="true"></span>DigiByte</div>
<ul>
<li><span class="k">Launched</span><span class="v">January 2014</span></li>
<li><span class="k">Block time</span><span class="v">15 seconds</span></li>
<li><span class="k">Mining</span><span class="v">5 algorithms (multi-PoW)</span></li>
<li><span class="k">Supply cap</span><span class="v">21 billion</span></li>
<li><span class="k">Assets</span><span class="v">DigiAssets · Layer 2</span></li>
<li><span class="k">Foundation · premine</span><span class="v">None · none</span></li>
</ul>
</div>
<div class="col bch">
<div class="col-h"><span class="swatch" aria-hidden="true"></span>Bitcoin Cash</div>
<ul>
<li><span class="k">Launched</span><span class="v">August 2017 (fork at 478,558)</span></li>
<li><span class="k">Block time</span><span class="v">10 minutes</span></li>
<li><span class="k">Mining</span><span class="v">SHA-256</span></li>
<li><span class="k">Supply cap</span><span class="v">21 million</span></li>
<li><span class="k">Assets</span><span class="v">CashTokens · Layer 1 (2023)</span></li>
<li><span class="k">Foundation · premine</span><span class="v">None · none</span></li>
</ul>
</div>
</div>
<p class="compare-note">Read the sister page at <a href="https://navigate.st/bns/bitcoincash.x/">bitcoincash.x</a>.</p>
</section>
<section class="community">
<h2>Community</h2>
<div class="soon" role="status">
<span class="blink" aria-hidden="true"></span>
<span class="label">Coming soon</span>
<span class="msg">Curated Telegram / Discord / Reddit links land here — added deliberately after verification.</span>
</div>
</section>
<footer>
<span class="wm">digibyte<span class="g">.x</span> · on the Bitcoin Cash name registry</span>
<span>A <a href="https://silentmode.st/">Silent Mode</a> project</span>
</footer>
</div>
<script>
(function(){
var root=document.documentElement,btn=document.getElementById('themeToggle');
var saved=null;try{saved=localStorage.getItem('dgbx-theme')}catch(e){}
if(!saved){saved=window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches?'light':'dark';}
root.setAttribute('data-theme',saved);
btn.addEventListener('click',function(){
var t=root.getAttribute('data-theme')==='dark'?'light':'dark';
root.setAttribute('data-theme',t);
try{localStorage.setItem('dgbx-theme',t)}catch(e){}
});
})();
</script>
</body>
</html>

248
ops/setup-local.ps1 Normal file
View file

@ -0,0 +1,248 @@
# Local DGB Core installer for Windows — puts a pruned indexer at
# D:\Program Files\DigiByte\ and prints the RPC credentials for
# @dgb-wallet/rpc.
#
# Usage: Run from an elevated PowerShell:
# Set-ExecutionPolicy -Scope Process Bypass
# .\setup-local.ps1
#
# Optional flags:
# -InstallDir "E:\dgb" override install location
# -Version "8.26.2" pick a different Core release
# -SkipDefenderExclusion don't add the data folder to Defender exclusions
# -SkipStartup don't create a startup shortcut
#
# The script is idempotent: safe to re-run to upgrade / repair.
param(
[string]$InstallDir = "D:\Program Files\DigiByte",
[string]$Version = "8.26.2",
[switch]$SkipDefenderExclusion,
[switch]$SkipStartup
)
$ErrorActionPreference = "Stop"
function Log($msg) { Write-Host "`n>>> $msg" -ForegroundColor Cyan }
# --- Elevation check (Defender exclusion requires admin) ---
$isAdmin = ([Security.Principal.WindowsPrincipal] `
[Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $isAdmin -and -not $SkipDefenderExclusion) {
Write-Warning "Not running as Administrator. Defender exclusion will be skipped."
Write-Warning "Right-click PowerShell → Run as Administrator to add the exclusion (recommended — cuts sync time 3x)."
$SkipDefenderExclusion = $true
}
# DGB Core's Windows release ships as an NSIS installer (.exe) — not a
# portable ZIP. We run it silently with /S /D=<path>. The installer
# writes daemon binaries into <InstallDir>\daemon\ and the GUI wallet
# into <InstallDir>\; our BinDir is therefore <InstallDir>\daemon.
$BinDir = Join-Path $InstallDir "daemon"
$DataDir = Join-Path $InstallDir "data"
$ConfPath = Join-Path $InstallDir "digibyte.conf"
Log "Step 0/6: check for a running digibyted.exe"
$Running = Get-Process digibyted -ErrorAction SilentlyContinue
if ($Running) {
Write-Warning "digibyted.exe is already running (PID $($Running.Id -join ', '))."
Write-Warning "This script will not touch it — running installers over a live node corrupts data."
Write-Warning "Stop it first from an elevated PowerShell:"
Write-Warning " Stop-Process -Id $($Running.Id -join ',') -Force"
Write-Warning "Then re-run setup-local.ps1."
exit 1
}
Log "Step 1/6: create $InstallDir"
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
New-Item -ItemType Directory -Force -Path $DataDir | Out-Null
# --- Download + install Core ---
Log "Step 2/6: download DigiByte Core $Version (Windows NSIS installer)"
$Installer = "digibyte-$Version-win64-setup.exe"
$Url = "https://github.com/DigiByte-Core/digibyte/releases/download/v$Version/$Installer"
$Tmp = Join-Path $env:TEMP "dgb-install"
New-Item -ItemType Directory -Force -Path $Tmp | Out-Null
$InstallerPath = Join-Path $Tmp $Installer
$ExistingExe = Join-Path $BinDir "digibyted.exe"
$AlreadyInstalled = $false
if (Test-Path $ExistingExe) {
$ver = & $ExistingExe -version 2>$null | Select-String -Pattern "DigiByte Core version" | Select-Object -First 1
if ($ver -match [regex]::Escape($Version)) {
Write-Host " digibyted.exe $Version already installed at $ExistingExe"
$AlreadyInstalled = $true
}
}
if (-not $AlreadyInstalled) {
Write-Host " Downloading $Url ..."
Invoke-WebRequest -Uri $Url -OutFile $InstallerPath -UseBasicParsing
# The v8.26.2 release does not publish a SHA256SUMS file. We compute
# and record the local hash so future upgrades can detect drift and
# you have a value to cross-check against the community if you want.
$ActualHash = (Get-FileHash $InstallerPath -Algorithm SHA256).Hash.ToLower()
Write-Host " Downloaded SHA-256: $ActualHash"
Write-Host " (No SHA256SUMS published in this release; trust chain is GitHub HTTPS + DigiByte-Core org.)"
Set-Content -Path (Join-Path $InstallDir "installed-version.txt") -Value @"
version: $Version
installer: $Installer
sha256: $ActualHash
url: $Url
"@
Write-Host " Running silent install to $InstallDir ..."
# /S = silent; /D= must be last and unquoted; spaces in the path are OK
# because /D takes everything after "=" as the destination.
$proc = Start-Process -FilePath $InstallerPath `
-ArgumentList "/S", "/D=$InstallDir" `
-Wait -PassThru
if ($proc.ExitCode -ne 0) {
throw "Installer exited with code $($proc.ExitCode). Check $InstallDir for a partial install."
}
if (-not (Test-Path $ExistingExe)) {
throw "Installer completed but digibyted.exe not found at $ExistingExe. Try running $InstallerPath manually to see where it installed."
}
Remove-Item -Force $InstallerPath
Write-Host " Installed to $InstallDir (daemon binaries in $BinDir)"
}
# --- digibyte.conf ---
Log "Step 3/6: write digibyte.conf"
# The NSIS installer drops its own commented-out sample digibyte.conf in
# $InstallDir. We must distinguish that from a real user config generated
# by this script — the marker line 'Generated by ops/setup-local.ps1'
# is the tell.
$IsOurConfig = $false
if (Test-Path $ConfPath) {
$existing = Get-Content $ConfPath -Raw
$IsOurConfig = $existing -match 'Generated by ops/setup-local\.ps1'
}
if ($IsOurConfig) {
Write-Host " $ConfPath already exists (previously generated by this script) — leaving in place"
$conf = Get-Content $ConfPath -Raw
$RpcUser = if ($conf -match "(?m)^rpcuser=(.+)$") { $Matches[1].Trim() } else { $null }
$RpcPassword = if ($conf -match "(?m)^rpcpassword=(.+)$") { $Matches[1].Trim() } else { $null }
} else {
if (Test-Path $ConfPath) {
$backup = "$ConfPath.installer-sample.bak"
Move-Item -Force $ConfPath $backup
Write-Host " Detected the NSIS installer's sample digibyte.conf — backed it up to $backup and writing our own."
}
$RpcUser = "dgb_" + -join ((48..57) + (97..122) | Get-Random -Count 8 | ForEach-Object { [char]$_ })
$RpcPasswordBytes = New-Object byte[] 30
[System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($RpcPasswordBytes)
$RpcPassword = [Convert]::ToBase64String($RpcPasswordBytes) -replace '[+/=]', ''
$RpcPassword = $RpcPassword.Substring(0, [Math]::Min(40, $RpcPassword.Length))
@"
# DigiByte Core — pruned local backend for @dgb-wallet
# Generated by ops/setup-local.ps1 on $(Get-Date -Format u).
# --- Storage ---
datadir=$($DataDir -replace '\\', '/')
prune=550
txindex=0
# --- Wallet: DISABLED (client-side keys only) ---
disablewallet=1
# --- RPC (localhost only) ---
server=1
rpcbind=127.0.0.1
rpcallowip=127.0.0.1
rpcport=14022
rpcuser=$RpcUser
rpcpassword=$RpcPassword
rpcworkqueue=32
rpcthreads=4
# --- P2P ---
listen=1
maxconnections=40
port=12024
# --- Perf ---
dbcache=1024
maxmempool=100
"@ | Set-Content -Encoding ASCII $ConfPath
Write-Host " Wrote $ConfPath with random 40-char RPC password"
}
# --- Windows Defender exclusion (real speedup) ---
if (-not $SkipDefenderExclusion) {
Log "Step 4/6: add Windows Defender exclusion for $DataDir"
try {
Add-MpPreference -ExclusionPath $DataDir -ErrorAction Stop
Write-Host " Excluded $DataDir from real-time scanning."
} catch {
Write-Warning " Could not add exclusion: $_"
Write-Warning " Add manually: Settings > Windows Security > Virus & threat protection > Manage settings > Add or remove exclusions > Add $DataDir"
}
} else {
Log "Step 4/6: SKIPPED Defender exclusion"
}
# --- Startup shortcut (runs digibyted at logon) ---
if (-not $SkipStartup) {
Log "Step 5/6: install startup shortcut"
$StartupDir = [Environment]::GetFolderPath('Startup')
$ShortcutPath = Join-Path $StartupDir "DigiByte Core.lnk"
$WshShell = New-Object -ComObject WScript.Shell
$Shortcut = $WshShell.CreateShortcut($ShortcutPath)
$Shortcut.TargetPath = Join-Path $BinDir "digibyted.exe"
$Shortcut.Arguments = "-conf=`"$ConfPath`""
$Shortcut.WorkingDirectory = $BinDir
$Shortcut.WindowStyle = 7 # minimized
$Shortcut.Description = "DigiByte Core pruned indexer for DigiByte.X Wallet"
$Shortcut.Save()
Write-Host " Installed shortcut at $ShortcutPath (starts at logon, minimized)"
} else {
Log "Step 5/6: SKIPPED startup shortcut"
}
# --- First-run launch ---
Log "Step 6/6: launch DigiByte Core now (background)"
$ExistingProc = Get-Process -Name "digibyted" -ErrorAction SilentlyContinue
if ($ExistingProc) {
Write-Host " digibyted.exe already running (PID $($ExistingProc.Id)) — leaving it alone."
} else {
Start-Process -FilePath (Join-Path $BinDir "digibyted.exe") `
-ArgumentList "-conf=`"$ConfPath`"" `
-WindowStyle Hidden
Start-Sleep -Seconds 3
$Proc = Get-Process -Name "digibyted" -ErrorAction SilentlyContinue
if ($Proc) {
Write-Host " digibyted.exe started (PID $($Proc.Id))."
} else {
Write-Warning " digibyted did not appear to start. Check the log:"
Write-Warning " Get-Content (Join-Path $DataDir 'debug.log') -Tail 50"
}
}
Write-Host ""
Write-Host "--------------------------------------------------------------------" -ForegroundColor Green
Write-Host "DigiByte Core installed and running locally." -ForegroundColor Green
Write-Host "--------------------------------------------------------------------" -ForegroundColor Green
Write-Host ""
Write-Host "RPC endpoint: http://127.0.0.1:14022"
Write-Host "Username: $RpcUser"
Write-Host "Password: $RpcPassword"
Write-Host ""
Write-Host "Sync progress:"
Write-Host " & `"$BinDir\digibyte-cli.exe`" -conf=`"$ConfPath`" getblockchaininfo | Select-Object -First 20"
Write-Host ""
Write-Host "Initial pruned sync takes ~4-8 hours on this machine (16 GB RAM,"
Write-Host "dbcache=1024). RPC is available during sync but scantxoutset"
Write-Host "returns incomplete results until it reaches the current tip."
Write-Host ""
Write-Host "Wallet-side test (once sync catches up):"
Write-Host " cd `"D:\Dev\SilentCode\Digibyte`""
Write-Host " `$env:DGB_RPC_URL='http://127.0.0.1:14022'"
Write-Host " `$env:DGB_RPC_USER='$RpcUser'"
Write-Host " `$env:DGB_RPC_PASSWORD='$RpcPassword'"
Write-Host " npm run demo -w @dgb-wallet/rpc"
Write-Host ""

192
ops/setup.sh Normal file
View file

@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Turnkey DGB VPS backend bootstrap — BNS/BCNR variant.
#
# Usage: sudo ./setup.sh rpc.dgb # BNS name, no ICANN domain
# sudo ./setup.sh rpc.dgb 12024 # optional custom P2P port
#
# Idempotent: safe to re-run. Skips already-completed steps.
# Tested on Debian 12 / Ubuntu 24.04. Requires root.
#
# Cert model: SELF-SIGNED. The BNS name's on-chain `tls` record pins
# the SHA-256 fingerprint of this cert, so the wallet trusts it
# without any CA involvement. The script prints the fingerprint at
# the end for you to paste into the BNS UPD.
set -euo pipefail
if [[ $EUID -ne 0 ]]; then
echo "Run as root (sudo)." >&2
exit 1
fi
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <bns-name>" >&2
echo "Example: $0 rpc.dgb" >&2
exit 1
fi
BNS_NAME="$1"
# CN in the self-signed cert. Wallet doesn't validate CN (it pins the
# fingerprint) but browsers/curl for admin-debugging read it.
CERT_CN="$BNS_NAME"
DGB_VERSION="8.26.2"
DGB_TARBALL="digibyte-${DGB_VERSION}-x86_64-linux-gnu.tar.gz"
DGB_URL="https://github.com/DigiByte-Core/digibyte/releases/download/v${DGB_VERSION}/${DGB_TARBALL}"
DGB_SHA_URL="https://github.com/DigiByte-Core/digibyte/releases/download/v${DGB_VERSION}/SHA256SUMS"
OPS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
log() { echo -e "\n\033[1;36m>>> $*\033[0m"; }
log "Step 1/7: install prerequisites"
apt-get update -qq
apt-get install -y -qq \
curl wget gpg ca-certificates ufw \
debian-keyring debian-archive-keyring apt-transport-https
log "Step 2/7: create digibyte user"
if ! id -u digibyte >/dev/null 2>&1; then
useradd --system --home /var/lib/digibyte --shell /usr/sbin/nologin digibyte
fi
mkdir -p /var/lib/digibyte /etc/digibyte
chown digibyte:digibyte /var/lib/digibyte
log "Step 3/7: download & verify DigiByte Core ${DGB_VERSION}"
cd /tmp
if [[ ! -x /usr/local/bin/digibyted ]] || ! /usr/local/bin/digibyted -version 2>/dev/null | grep -q "${DGB_VERSION}"; then
wget -q --show-progress "${DGB_URL}"
wget -q "${DGB_SHA_URL}"
grep "${DGB_TARBALL}" SHA256SUMS | sha256sum -c -
tar -xzf "${DGB_TARBALL}"
install -m 0755 "digibyte-${DGB_VERSION}/bin/digibyted" /usr/local/bin/
install -m 0755 "digibyte-${DGB_VERSION}/bin/digibyte-cli" /usr/local/bin/
rm -rf "digibyte-${DGB_VERSION}" "${DGB_TARBALL}" SHA256SUMS
else
echo " digibyted ${DGB_VERSION} already installed"
fi
log "Step 4/7: write digibyte.conf (generating 40-char RPC password)"
if [[ ! -f /etc/digibyte/digibyte.conf ]]; then
RPC_USER="dgb_$(openssl rand -hex 4)"
RPC_PASSWORD="$(openssl rand -base64 40 | tr -d '/+=\n' | cut -c1-40)"
sed \
-e "s|__RPC_USER__|${RPC_USER}|g" \
-e "s|__RPC_PASSWORD__|${RPC_PASSWORD}|g" \
"${OPS_DIR}/digibyte.conf" > /etc/digibyte/digibyte.conf
chown root:digibyte /etc/digibyte/digibyte.conf
chmod 640 /etc/digibyte/digibyte.conf
# Persist credentials in a root-only file for later reference
cat > /root/dgb-rpc-credentials.txt <<EOF
DGB RPC credentials — keep secret.
URL: https://${DOMAIN}/
username: ${RPC_USER}
password: ${RPC_PASSWORD}
EOF
chmod 600 /root/dgb-rpc-credentials.txt
else
echo " /etc/digibyte/digibyte.conf already exists — leaving in place"
RPC_USER="$(grep -oP '^rpcuser=\K.*' /etc/digibyte/digibyte.conf)"
RPC_PASSWORD="$(grep -oP '^rpcpassword=\K.*' /etc/digibyte/digibyte.conf)"
fi
log "Step 5/7: install systemd unit and start digibyted"
install -m 0644 "${OPS_DIR}/digibyte.service" /etc/systemd/system/digibyted.service
systemctl daemon-reload
systemctl enable digibyted.service
systemctl restart digibyted.service
sleep 3
if ! systemctl is-active --quiet digibyted; then
echo "digibyted failed to start. Check: journalctl -u digibyted -e" >&2
exit 1
fi
log "Step 6a/7: generate self-signed TLS cert"
CERT_DIR=/etc/digibyte/tls
mkdir -p "${CERT_DIR}"
CERT_FILE="${CERT_DIR}/server.crt"
KEY_FILE="${CERT_DIR}/server.key"
if [[ ! -f "${CERT_FILE}" ]]; then
openssl req -x509 -newkey rsa:4096 -sha256 -nodes \
-keyout "${KEY_FILE}" -out "${CERT_FILE}" \
-subj "/CN=${CERT_CN}" \
-days 3650 \
-addext "subjectAltName=DNS:${BNS_NAME},DNS:localhost,IP:127.0.0.1" >/dev/null 2>&1
chmod 640 "${KEY_FILE}"
chown root:caddy "${KEY_FILE}" 2>/dev/null || chown root:root "${KEY_FILE}"
echo " new self-signed cert at ${CERT_FILE} (10-year validity)"
else
echo " reusing existing cert at ${CERT_FILE}"
fi
CERT_FINGERPRINT=$(openssl x509 -in "${CERT_FILE}" -noout -fingerprint -sha256 \
| sed 's/^SHA256 Fingerprint=//' | tr -d ':' | tr '[:upper:]' '[:lower:]')
log "Step 6b/7: install and configure Caddy"
if ! command -v caddy >/dev/null; then
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
> /etc/apt/sources.list.d/caddy-stable.list
apt-get update -qq
apt-get install -y -qq caddy
fi
# Use the BNS-variant Caddyfile that reads the on-disk cert (no ACME).
sed -e "s|@BNS_NAME@|${BNS_NAME}|g" \
-e "s|@CERT_FILE@|${CERT_FILE}|g" \
-e "s|@KEY_FILE@|${KEY_FILE}|g" \
"${OPS_DIR}/Caddyfile.bns" > /etc/caddy/Caddyfile
systemctl restart caddy
log "Step 7/7: firewall"
ufw --force default deny incoming
ufw --force default allow outgoing
ufw allow 22/tcp comment "SSH"
ufw allow 443/tcp comment "HTTPS (RPC)"
ufw allow 12024/tcp comment "DGB P2P"
ufw --force enable
log "Setup complete."
# Determine this VPS's public IPv4 for the BNS record snippet.
PUBLIC_IP=$(curl -sSL --max-time 5 https://api.ipify.org 2>/dev/null \
|| curl -sSL --max-time 5 https://ifconfig.me 2>/dev/null \
|| echo "<detect-manually>")
cat <<EOF
--------------------------------------------------------------------
DGB backend deployed. Now register the BNS name on BCH:
1. Open your BNS registrar (silentmode.st/register.html or your CLI)
2. Register the name and set records:
name: ${BNS_NAME}
ip: ${PUBLIC_IP}
tls: sha256/${CERT_FINGERPRINT}
u: https://${BNS_NAME}/ (optional, gateway redirect)
3. Confirm the UPD tx and wait <60s for indexers to pick it up.
Once the record is live, wallet connects with:
const client = new RpcClient({
bnsName: '${BNS_NAME}',
username: '${RPC_USER}',
password: '${RPC_PASSWORD}',
});
Credentials also saved to /root/dgb-rpc-credentials.txt (mode 600).
--- Node health ---
Status: systemctl status digibyted
Sync progress: journalctl -u digibyted -f
digibyte-cli -conf=/etc/digibyte/digibyte.conf getblockchaininfo | head
Initial pruned sync takes ~1-2 days; RPC is available during sync,
but scantxoutset results are incomplete until sync reaches tip.
--- Fingerprint (also above) ---
Cert file: ${CERT_FILE}
SHA-256: ${CERT_FINGERPRINT}
(regenerate cert → new fingerprint → one BNS UPD to rotate.)
--------------------------------------------------------------------
EOF

19443
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

9
package.json Normal file
View file

@ -0,0 +1,9 @@
{
"name": "dgb-wallet",
"private": true,
"version": "0.0.0",
"description": "DigiByte wallet monorepo — recovery-first mobile + browser-extension wallet",
"workspaces": [
"packages/*"
]
}

View file

@ -0,0 +1,24 @@
{
"name": "@dgb-wallet/blockbook",
"version": "0.0.1",
"description": "Thin Blockbook v2 client for the DGB wallet. Fast address/UTXO/xpub queries via a public indexer (Atomic Wallet's DGB Blockbook is the only reliable one at time of writing). Trade-off: the server sees every query. NOT the privacy-preserving default — use the P2P daemon backend when that matters.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": ["dist", "src"],
"scripts": {
"build": "tsc",
"probe": "node scripts/probe.mjs"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,64 @@
// End-to-end smoke test of Blockbook scan flow.
// Derives all four purposes × 2 accounts × 2 changes × 20 gap from
// the standard abandon seed, hits /utxo/{addr} for each in parallel,
// aggregates. Also probes a known-live address to prove client works
// against real balances.
import {
seedFromPhrase, rootFromSeed, accountNode, addressNode,
p2pkhAddress, p2shP2wpkhAddress, p2wpkhAddress, p2trAddress,
} from '@dgb-wallet/core';
import { BlockbookClient } from '../dist/index.js';
const bb = new BlockbookClient();
console.log('=== status ===');
const s = await bb.status();
console.log(` coin=${s.coin} tip=${s.tip.toLocaleString()} inSync=${s.inSync} v=${s.version}`);
console.log('\n=== known-live address (DGB mining pool) ===');
const known = await bb.utxos('DLgDJd61zTsAhNN6xMja4z9ZNxy6Yb9Mrn');
console.log(` utxo count=${known.length} first=${JSON.stringify({txid: known[0]?.txid.slice(0,12)+'…', value: known[0]?.value.toString()})}`);
console.log('\n=== abandon seed all-paths scan ===');
const t0 = Date.now();
const seed = await seedFromPhrase('abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about');
const root = rootFromSeed(seed);
const addrs = [];
for (const purpose of [44, 49, 84, 86]) {
for (let a = 0; a < 2; a++) {
const acct = accountNode(root, purpose, a);
for (const change of [0, 1]) {
for (let i = 0; i < 20; i++) {
const node = addressNode(acct, change, i);
const address =
purpose === 44 ? p2pkhAddress(node) :
purpose === 49 ? p2shP2wpkhAddress(node) :
purpose === 84 ? p2wpkhAddress(node) :
p2trAddress(node);
addrs.push({ address, purpose, account: a, change, index: i });
}
}
}
}
console.log(` derived ${addrs.length} addresses in ${Date.now() - t0}ms`);
const t1 = Date.now();
let cursor = 0;
let totalSats = 0n;
let hitCount = 0;
const workers = Array.from({ length: 8 }, async () => {
while (cursor < addrs.length) {
const item = addrs[cursor++];
const list = await bb.utxos(item.address).catch(() => []);
if (list.length > 0) {
hitCount++;
for (const u of list) totalSats += u.value;
console.log(` hit: ${item.address} m/${item.purpose}'/20'/${item.account}'/${item.change}/${item.index} utxos=${list.length} value=${Number(list.reduce((s,u)=>s+u.value, 0n))/1e8} DGB`);
}
}
});
await Promise.all(workers);
const elapsed = Date.now() - t1;
console.log(`\n scanned ${addrs.length} addresses in ${elapsed}ms hits=${hitCount} totalSats=${totalSats} totalDGB=${Number(totalSats)/1e8}`);
console.log(` effective rate: ${(addrs.length / (elapsed/1000)).toFixed(0)} addr/s`);
process.exit(0);

View file

@ -0,0 +1,316 @@
// Thin Blockbook v2 client — https://github.com/trezor/blockbook/blob/master/docs/api.md
//
// Only what @dgb-wallet actually calls today. Every method is a
// single fetch, no batching, no local caching. Consumers should
// throttle themselves if they enumerate many addresses in a loop.
//
// PRIVACY: every call reveals the queried address (and the caller's
// IP) to the Blockbook operator. When wiring into wallet UI, show a
// warning next to the backend selector — this is not the private
// default. See dgb-wallet-privacy-first memory.
export const DEFAULT_DGB_BLOCKBOOK = 'https://digibyte.atomicwallet.io/api/v2';
export interface BlockbookOptions {
baseUrl?: string;
timeoutMs?: number;
// Optional callback invoked with every URL fetched — for a debug
// banner in the UI or for a call counter. NEVER log this to a third
// party; it contains user address queries.
onFetch?: (url: string) => void;
}
export interface BlockbookStatus {
coin: string;
tip: number;
inSync: boolean;
mempoolSize?: number;
version?: string;
bestHash?: string;
lastBlockTime?: string;
}
export interface BlockbookAddress {
address: string;
balanceSat: bigint;
totalReceivedSat: bigint;
totalSentSat: bigint;
txs: number;
txids: string[];
}
export interface BlockbookUtxo {
txid: string;
vout: number;
value: bigint;
height: number;
confirmations: number;
address?: string;
scriptPubKey?: string;
coinbase?: boolean;
}
export interface BlockbookTx {
txid: string;
hex: string;
blockHash?: string;
blockHeight?: number;
confirmations: number;
}
// One row from /xpub?details=txs. The `isOwn` bits let us classify a
// tx as sent-out / received / self-transfer without hitting /tx for
// each one. `netSat` = ownReceived - ownSpent (positive: received,
// negative: sent).
export interface BlockbookHistoryTx {
txid: string;
blockHash?: string;
blockHeight?: number;
blockTime?: number; // unix seconds, when known
confirmations: number;
netSat: bigint;
feeSat: bigint;
vin: Array<{ isOwn: boolean; value: bigint; addresses?: string[] }>;
vout: Array<{ isOwn: boolean; value: bigint; addresses?: string[] }>;
}
export interface BlockbookHistoryPage {
page: number;
totalPages: number;
itemsOnPage: number;
totalTxs: number;
transactions: BlockbookHistoryTx[];
}
// XpubInfo: what /xpub returns when the server supports it. Blockbook
// does the derivation, gap-limit scanning, and aggregation server-side
// (BIP44 style). A single call returns aggregated balance + all
// derived-address UTXOs.
export interface BlockbookXpub {
balanceSat: bigint;
totalReceivedSat: bigint;
totalSentSat: bigint;
txs: number;
tokens?: Array<{ name?: string; balance?: string; totalReceived?: string; transfers?: number }>;
}
export class BlockbookClient {
readonly baseUrl: string;
private readonly timeoutMs: number;
private readonly onFetch?: (url: string) => void;
constructor(opts: BlockbookOptions = {}) {
this.baseUrl = (opts.baseUrl ?? DEFAULT_DGB_BLOCKBOOK).replace(/\/+$/, '');
this.timeoutMs = opts.timeoutMs ?? 15_000;
this.onFetch = opts.onFetch;
}
async status(): Promise<BlockbookStatus> {
const raw = await this.getJson(`${this.baseUrl.replace(/\/api\/v2$/, '/api')}/`);
const bb = raw.blockbook ?? {};
return {
coin: bb.coin ?? raw.chain ?? 'unknown',
tip: bb.bestHeight ?? raw.backend?.blocks ?? 0,
inSync: !!bb.inSync,
mempoolSize: bb.mempoolSize,
version: bb.version,
bestHash: bb.bestHash,
lastBlockTime: bb.lastBlockTime,
};
}
async address(addr: string): Promise<BlockbookAddress> {
const raw = await this.getJson(`${this.baseUrl}/address/${encodeURIComponent(addr)}?details=basic`);
return {
address: addr,
balanceSat: BigInt(raw.balance ?? '0'),
totalReceivedSat: BigInt(raw.totalReceived ?? '0'),
totalSentSat: BigInt(raw.totalSent ?? '0'),
txs: Number(raw.txs ?? 0),
txids: Array.isArray(raw.txids) ? raw.txids : [],
};
}
// NOTE: Blockbook's `?confirmed=false` means "include unconfirmed
// alongside confirmed". Default (no param) also returns confirmed
// only. We want BOTH by default so a recently-broadcast tx shows up.
async utxos(addr: string, includeUnconfirmed = true): Promise<BlockbookUtxo[]> {
const suffix = includeUnconfirmed ? '?confirmed=false' : '';
const raw = await this.getJson(`${this.baseUrl}/utxo/${encodeURIComponent(addr)}${suffix}`);
if (!Array.isArray(raw)) return [];
return raw.map((u) => ({
txid: String(u.txid),
vout: Number(u.vout),
value: BigInt(u.value ?? '0'),
height: Number(u.height ?? 0),
confirmations: Number(u.confirmations ?? 0),
address: u.address,
scriptPubKey: u.scriptPubKey,
coinbase: !!u.coinbase,
}));
}
// Try the /xpub endpoint. Not all Blockbook deployments support it
// (Atomic's returns 500 for at least some inputs). Caller should
// catch and fall back to enumerating child addresses via `address()`.
async xpub(xpub: string, gap = 20): Promise<BlockbookXpub> {
const raw = await this.getJson(`${this.baseUrl}/xpub/${encodeURIComponent(xpub)}?details=basic&gap=${gap}`);
return {
balanceSat: BigInt(raw.balance ?? '0'),
totalReceivedSat: BigInt(raw.totalReceived ?? '0'),
totalSentSat: BigInt(raw.totalSent ?? '0'),
txs: Number(raw.txs ?? 0),
tokens: raw.tokens,
};
}
// Full tx history for every address derived under `xpub` up to `gap`.
// Blockbook does the derivation + aggregation server-side and marks
// each vin/vout with `isAddress` + `isOwn`, so classification is
// trivial. One call per xpub — for a 4-purpose wallet that's four
// calls, done in parallel by the hook.
async xpubTxs(xpub: string, opts: { gap?: number; pageSize?: number; page?: number } = {}): Promise<BlockbookHistoryPage> {
const gap = opts.gap ?? 20;
const pageSize = opts.pageSize ?? 100;
const page = opts.page ?? 1;
const raw = await this.getJson(
`${this.baseUrl}/xpub/${encodeURIComponent(xpub)}?details=txs&gap=${gap}&pageSize=${pageSize}&page=${page}`,
);
const transactions: BlockbookHistoryTx[] = ((raw.transactions ?? []) as any[]).map((t: any) => {
const vin = ((t.vin ?? []) as any[]).map((i) => ({
isOwn: !!i.isOwn,
value: BigInt(i.value ?? '0'),
addresses: Array.isArray(i.addresses) ? i.addresses : undefined,
}));
const vout = ((t.vout ?? []) as any[]).map((o) => ({
isOwn: !!o.isOwn,
value: BigInt(o.value ?? '0'),
addresses: Array.isArray(o.addresses) ? o.addresses : undefined,
}));
const received = vout.filter((o) => o.isOwn).reduce((a, b) => a + b.value, 0n);
const spent = vin.filter((i) => i.isOwn).reduce((a, b) => a + b.value, 0n);
return {
txid: String(t.txid),
blockHash: t.blockHash,
blockHeight: typeof t.blockHeight === 'number' ? t.blockHeight : undefined,
blockTime: typeof t.blockTime === 'number' ? t.blockTime : undefined,
confirmations: Number(t.confirmations ?? 0),
netSat: received - spent,
feeSat: BigInt(t.fees ?? '0'),
vin,
vout,
};
});
return {
page: Number(raw.page ?? 1),
totalPages: Number(raw.totalPages ?? 1),
itemsOnPage: Number(raw.itemsOnPage ?? transactions.length),
totalTxs: Number(raw.txs ?? transactions.length),
transactions,
};
}
async xpubUtxos(xpub: string, gap = 20, includeUnconfirmed = true): Promise<BlockbookUtxo[]> {
const conf = includeUnconfirmed ? '&confirmed=false' : '';
const raw = await this.getJson(`${this.baseUrl}/utxo/${encodeURIComponent(xpub)}?gap=${gap}${conf}`);
if (!Array.isArray(raw)) return [];
return raw.map((u) => ({
txid: String(u.txid),
vout: Number(u.vout),
value: BigInt(u.value ?? '0'),
height: Number(u.height ?? 0),
confirmations: Number(u.confirmations ?? 0),
address: u.address,
scriptPubKey: u.scriptPubKey,
coinbase: !!u.coinbase,
}));
}
async tx(txid: string): Promise<BlockbookTx> {
const raw = await this.getJson(`${this.baseUrl}/tx/${encodeURIComponent(txid)}`);
return {
txid,
hex: String(raw.hex ?? ''),
blockHash: raw.blockHash,
blockHeight: raw.blockHeight,
confirmations: Number(raw.confirmations ?? 0),
};
}
async sendTx(hex: string): Promise<{ txid: string }> {
const url = `${this.baseUrl}/sendtx/${hex}`;
this.onFetch?.(url);
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal });
const body = await res.json().catch(() => ({}));
if (!res.ok || body.error) {
throw new Error(`Blockbook sendtx failed: HTTP ${res.status} — ${body.error?.message ?? JSON.stringify(body)}`);
}
return { txid: String(body.result ?? body.txid ?? '') };
} finally { clearTimeout(timer); }
}
private async getJson(url: string): Promise<Record<string, unknown> & Record<string, any>> {
this.onFetch?.(url);
// Public indexers (NOWNodes in front of digibyte.x/blockbook) rate-
// limit per key. A dashboard refresh fires ~9 requests at once
// (4 xpubs × summary+utxos, plus history), which is exactly the
// burst that earns a 429. Two guards:
// 1. a small global concurrency cap with a short gap between
// dispatches, so a refresh trickles instead of bursting;
// 2. on 429, honour Retry-After (or back off ~1.5s, doubling)
// and retry a couple of times before giving up.
for (let attempt = 0; ; attempt++) {
const release = await acquireSlot();
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
try {
const res = await fetch(url, { signal: ctrl.signal });
if (res.status === 429 && attempt < MAX_429_RETRIES) {
const ra = Number(res.headers.get('retry-after'));
const waitMs = Number.isFinite(ra) && ra > 0 ? ra * 1000 : BACKOFF_BASE_MS * 2 ** attempt;
await res.text().catch(() => '');
clearTimeout(timer); release();
await sleep(waitMs);
continue;
}
if (!res.ok) {
const body = await res.text().catch(() => '');
const hint = res.status === 429 ? ' (indexer rate limit — slow the auto-refresh in Settings → Display)' : '';
throw new Error(`Blockbook HTTP ${res.status}${hint} at ${url}${body ? ` — ${body.slice(0, 200)}` : ''}`);
}
const text = await res.text();
try { return JSON.parse(text); }
catch { throw new Error(`Blockbook returned non-JSON at ${url}: ${text.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 120)}`); }
} finally { clearTimeout(timer); release(); }
}
}
}
// ---- request pacing (module-wide, shared by every client instance) ----
const MAX_IN_FLIGHT = 3;
const DISPATCH_GAP_MS = 120;
const MAX_429_RETRIES = 2;
const BACKOFF_BASE_MS = 1500;
let inFlight = 0;
let lastDispatch = 0;
const waiters: Array<() => void> = [];
function sleep(ms: number): Promise<void> { return new Promise((r) => setTimeout(r, ms)); }
async function acquireSlot(): Promise<() => void> {
while (inFlight >= MAX_IN_FLIGHT) await new Promise<void>((r) => waiters.push(r));
inFlight++;
const wait = lastDispatch + DISPATCH_GAP_MS - Date.now();
if (wait > 0) await sleep(wait);
lastDispatch = Date.now();
let released = false;
return () => {
if (released) return;
released = true;
inFlight--;
waiters.shift()?.();
};
}

View file

@ -0,0 +1 @@
export * from './client.js';

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "scripts"]
}

3
packages/bns/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
*.log

55
packages/bns/README.md Normal file
View file

@ -0,0 +1,55 @@
# @dgb-wallet/bns
BNS (Bitcoin Cash Name Service) resolver client for the DigiByte.X Wallet. Wraps the canonical zero-dependency `resolver-web.js` from `D:\Dev\SilentMode\Argus\src\lib\` (single source of truth, kept in lockstep with the rest of the Silent Mode stack per its `PROTOCOL.md`).
Handles `.dgb` and every other activated BNS TLD (`.bch`, `.p2p`, `.deviant`, `.nav`, `.sia`, `.nt`, `.sm`, `.lm`) as of the 2026-07-30 bootstrap.
## API
```ts
import { resolveName, resolveEndpoint, normalizeName, normalizeFingerprint } from '@dgb-wallet/bns';
// Full resolve — returns everything on-chain for a name
const entry = await resolveName('rpc.dgb');
// { name: 'rpc.dgb', records: { ip: '...', tls: '...' }, height, txid, category }
// Endpoint-only — enforces both ip AND tls fingerprint present
const { ip, tls } = await resolveEndpoint('rpc.dgb');
// { name: 'rpc.dgb', ip: '203.0.113.42', tls: 'ab12cd34...' }
// Canonicalize any input to <label>.<tld>
normalizeName('RPC.DGB') // 'rpc.dgb'
normalizeName('rpc.dgb/foo') // 'rpc.dgb'
// Accepts colon-hex, sha256/ prefix, or plain hex
normalizeFingerprint('sha256/AB:CD:EF:...') // 'abcdef...'
```
`resolveEndpoint` is what `@dgb-wallet/rpc` uses to look up its backend — refuses to return a result unless *both* `ip` and `tls` records are present, because connecting without a pinned fingerprint would defeat the whole point of the BCH-anchored PKI model.
## Demo
```bash
cd "D:\Dev\SilentCode\Digibyte"
npm install # first time only
npm run demo -w @dgb-wallet/bns # resolves tlds.bch by default
npm run demo -w @dgb-wallet/bns -- rpc.dgb
```
## How the trust chain flows
```
BNS name (rpc.dgb)
↓ resolveEndpoint
BCH chain (CashToken cert + OP_RETURN records)
↓
{ ip, tls fingerprint }
↓ TLS handshake with fingerprint pin
VPS running DigiByte Core
```
No ICANN, no Let's Encrypt, no CA/Browser Forum. Trust anchor is the BCH chain plus the BNS Root CA (installed system-wide by Ariadne if you're on a machine with Silent Mode; verified inline by `@dgb-wallet/rpc` regardless).
## Coupling with Silent Mode
`src/resolver.ts` imports `resolver-web.js` via a relative path (`../../../../../SilentMode/Argus/src/lib/resolver-web.js`). This documents the dependency; both projects sit on the same developer machine and evolve together. If `resolver-web.js` is later published as an npm package (e.g. `@silentmode/bns-resolver`), the relative import becomes `import ... from '@silentmode/bns-resolver'` and nothing else changes.

31
packages/bns/package.json Normal file
View file

@ -0,0 +1,31 @@
{
"name": "@dgb-wallet/bns",
"version": "0.0.1",
"description": "BNS (Bitcoin Cash Name Service) resolver client. Wraps the zero-dependency resolver-web.js from D:\\Dev\\SilentMode\\Argus, adds Node WebSocket injection and typed helpers. Resolves .dgb / .bch / any activated BNS TLD to on-chain records (ip, tls fingerprint, s3, u, ...).",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"demo": "tsx scripts/resolve-demo.ts"
},
"dependencies": {
"ws": "^8.16.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"@types/ws": "^8.5.10",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,31 @@
// Resolve a BNS name against real chipnet electrum servers.
// Default: resolve tlds.bch (the well-known root TLD certificate) and
// print the activated TLD list. Pass any name as an arg to resolve it.
import { resolveName, DEFAULT_ELECTRUM } from '../src/index.js';
const name = process.argv[2] ?? 'tlds.bch';
console.log(`\nBNS resolve: ${name}`);
console.log(`Electrum pool (${DEFAULT_ELECTRUM.length}):`);
for (const s of DEFAULT_ELECTRUM) console.log(` ${s.url}${s.ip ? ' (ip ' + s.ip + ')' : ''}`);
console.log();
const t0 = performance.now();
const entry = await resolveName(name);
const ms = (performance.now() - t0).toFixed(0);
if (!entry) {
console.log(` ${name} — not registered (index built in ${ms} ms)`);
process.exit(1);
}
console.log(` ${entry.name} (index built in ${ms} ms)`);
if (entry.height) console.log(` height: ${entry.height}`);
if (entry.txid) console.log(` txid: ${entry.txid}`);
console.log(` records:`);
for (const [k, v] of Object.entries(entry.records)) {
if (v == null) continue;
const shown = v.length > 200 ? v.slice(0, 200) + '...' : v;
console.log(` ${k}: ${shown}`);
}

View file

@ -0,0 +1,2 @@
export * from './types.js';
export * from './resolver.js';

View file

@ -0,0 +1,99 @@
import WebSocket from 'ws';
import type { BnsEntry, ElectrumServer } from './types.js';
// resolver-web.js is the canonical BNS resolver — a zero-dependency
// module maintained in D:\Dev\SilentMode\Argus. We import it directly
// via relative path rather than duplicating logic; keeping two
// implementations in sync would be a consensus risk (per PROTOCOL.md
// "the two must stay in lockstep").
//
// If the SilentMode project publishes it as an npm package later,
// swap this import for the package name and drop the tsconfig
// `allowJs` flag. Meanwhile the relative path documents the coupling.
//
// TypeScript pathing: from packages/bns/src/resolver.ts up 5 levels
// to D:\Dev\, then into SilentMode/Argus/src/lib/. tsconfig has
// allowJs:true so the untyped JS module is imported directly.
import * as resolverWeb from '../../../../../SilentMode/Argus/src/lib/resolver-web.js';
// Default electrum pool: Silent Mode's own BNS indexer first, public
// fallbacks after. Mirrors CHIPNET_ELECTRUM in resolver-web.js so
// resolver builds using the same peer set the browser/mobile clients use.
export const DEFAULT_ELECTRUM: ElectrumServer[] =
(resolverWeb as { CHIPNET_ELECTRUM: ElectrumServer[] }).CHIPNET_ELECTRUM ?? [
{ url: 'wss://coinspectrum.duckdns.org:50011', ip: '195.184.247.106' },
{ url: 'wss://chipnet.bch.ninja:50004', ip: '135.148.236.244' },
{ url: 'wss://chipnet.imaginary.cash:50004', ip: '66.29.152.199' },
];
export interface ResolveOptions {
electrum?: ElectrumServer[];
// Time to keep a built name-index warm before rebuilding, ms. The
// resolver otherwise rebuilds on every call.
cacheTtlMs?: number;
}
// Cached index built via resolver-web's buildIndex. Populated on first
// resolve, refreshed after cacheTtlMs.
let indexCache: { at: number; index: Map<string, BnsEntry> } | null = null;
async function ensureIndex(opts: ResolveOptions = {}): Promise<Map<string, BnsEntry>> {
const ttl = opts.cacheTtlMs ?? 5 * 60 * 1000; // 5 min default
if (indexCache && Date.now() - indexCache.at < ttl) return indexCache.index;
const servers = opts.electrum ?? DEFAULT_ELECTRUM;
const build = (resolverWeb as {
buildIndex: (args: {
WebSocket: typeof WebSocket;
electrum: ElectrumServer[];
}) => Promise<Map<string, BnsEntry>>;
}).buildIndex;
const index = await build({ WebSocket, electrum: servers });
indexCache = { at: Date.now(), index };
return index;
}
// Normalize input into canonical <label>.<tld>.
export function normalizeName(name: string): string {
const norm = (resolverWeb as { normalizeName: (s: string) => string }).normalizeName;
return norm(name);
}
// Resolve a BNS name to its Entry (records + metadata), or null if
// the name is not registered. Uses a shared cached index; opt into
// cacheTtlMs=0 for a fresh index on every call.
export async function resolveName(
name: string,
opts: ResolveOptions = {},
): Promise<BnsEntry | null> {
const key = normalizeName(name);
const index = await ensureIndex(opts);
return index.get(key) ?? null;
}
// Convenience: extract just the network-endpoint fields the wallet needs.
export interface EndpointRecord {
name: string;
ip: string;
tls: string; // hex SHA-256 fingerprint over DER
}
export async function resolveEndpoint(
name: string,
opts: ResolveOptions = {},
): Promise<EndpointRecord> {
const entry = await resolveName(name, opts);
if (!entry) throw new Error(`BNS name "${name}" is not registered`);
const { ip, tls } = entry.records;
if (!ip) throw new Error(`BNS name "${name}" has no ip record`);
if (!tls) throw new Error(`BNS name "${name}" has no tls record — refusing to connect without fingerprint pinning`);
return { name: entry.name, ip, tls: normalizeFingerprint(tls) };
}
// Normalize a fingerprint to lowercase hex with no separators. Accepts
// the common forms: "AB:CD:EF...", "sha256/AB:CD:EF...", "abcdef...".
export function normalizeFingerprint(fp: string): string {
return fp
.toLowerCase()
.replace(/^sha256\//, '')
.replace(/[:\s]/g, '');
}

View file

@ -0,0 +1,38 @@
// Ambient declaration for the untyped resolver-web.js module imported
// from D:\Dev\SilentMode\Argus. Wildcard pattern matches any import
// ending in `resolver-web.js`, so future path shuffles don't break
// this. TypeScript uses this shape; Node resolves the actual file
// via the relative path in the import statement.
declare module '*resolver-web.js' {
export const CHIPNET_ELECTRUM: Array<{ url: string; ip?: string }>;
export const BEACON: string;
export const PREFIX: string;
export const TLD: string;
export const DEFAULT_TLD: string;
export function normalizeName(input: string): string;
export function buildIndex(args: {
WebSocket: unknown;
electrum: Array<{ url: string; ip?: string }>;
}): Promise<
Map<
string,
{
name: string;
records: Record<string, string | undefined>;
height?: number;
txid?: string;
category?: string;
}
>
>;
export function fetchBcnrTlds(args: {
WebSocket: unknown;
electrum: Array<{ url: string; ip?: string }>;
}): Promise<string[]>;
export function getBcnrTlds(
index: Map<string, { records: Record<string, string | undefined> }>,
): string[];
export function parseTldsRecord(raw: string | undefined): string[];
export function isBcnrNativeTld(tlds: string[], tld: string): boolean;
}

29
packages/bns/src/types.ts Normal file
View file

@ -0,0 +1,29 @@
// Shape of a resolved BNS name. Mirrors the Entry structure produced by
// resolver-web.js in Argus — record fields are optional and populated
// only if present in the on-chain payload.
export interface BnsRecords {
// Content records
u?: string; // https URL redirect
h?: string; // inline HTML (on-chain website)
p?: string; // upstream URL to reverse-proxy
ip?: string; // IPv4 answering directly for the name
s3?: string; // Sia bucket/key or bucket/prefix/
// Identity records
a?: string; // cashaddr for payments
tls?: string; // SHA-256 fingerprint of the name's TLS cert, hex over DER
// Meta
[key: string]: string | undefined; // forward-compat for future record types
}
export interface BnsEntry {
name: string; // canonical <label>.<tld>
records: BnsRecords;
category?: string; // CashToken category (name's permanent identity)
height?: number; // block height of latest UPD/REG
txid?: string; // txid of latest UPD/REG
}
export interface ElectrumServer {
url: string; // wss://host:port
ip?: string; // optional pinned IP for DNS-independent bootstrap
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "scripts"]
}

4
packages/core/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store

84
packages/core/README.md Normal file
View file

@ -0,0 +1,84 @@
# @dgb-wallet/core
DigiByte wallet primitives — no node required.
- BIP39 seed generation / validation / import
- BIP32 hierarchical deterministic derivation with DGB xpub/xprv version bytes
- BIP44 legacy, BIP49 P2SH-SegWit, BIP84 native SegWit, BIP86 Taproot
- Address encoding for `D…`, `S…`, `dgb1q…`, `dgb1p…`
- WIF (Wallet Import Format) import/export with network-byte validation
This package is the shared crypto core that the mobile app and browser
extension both depend on. All keygen and signing happens locally; the
package never talks to a network.
## Quick start
```bash
cd "D:\Program Files\DigiByte-Wallet\packages\core" # or wherever you put it
npm install
npm run demo
```
Generates a fresh 12-word seed and prints the first three addresses at
each of the four descriptor classes (legacy, P2SH-SegWit, native SegWit,
Taproot).
To recover from an existing seed and dump many candidate addresses,
generate a throwaway test seed first (never use a real seed):
```bash
# 1. generate a fresh throwaway seed and copy it from stdout
npm run demo
# 2. paste ONLY those 12 words back — nothing else, no placeholder text
npm run recover -- "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"
```
> ⚠️ **Never paste a real seed phrase into a terminal, a chat, a support
> ticket, or any input field that isn't the wallet itself.** Terminal
> command history persists in `~/.bash_history` /
> `%USERPROFILE%\.bash_history` / PowerShell's `PSReadLine` history —
> anyone with access to your machine can recover it. Use a fresh test
> seed for any script that takes a seed as an argument.
Cross-check any produced address against `iancoleman.io/bip39` with
coin = "DGB - Digibyte" — they should match byte-for-byte.
## Network parameters
Sourced from `src/kernel/chainparams.cpp` `CMainParams` in
[DigiByte-Core/digibyte](https://github.com/DigiByte-Core/digibyte).
| | Mainnet | Testnet |
|---|---|---|
| bech32 HRP | `dgb` | `dgbt` |
| pubKeyHash | `0x1e` → `D…` | `0x7e` |
| scriptHash | `0x3f` → `S…` | `0x8c` |
| WIF | `0x80` | `0xef` |
| xpub / xprv | `0x0488b21e` / `0x0488ade4` | `0x043587cf` / `0x04358394` |
| SLIP-0044 coin type | **20** | 1 (all testnets) |
## Layout
```
src/
network.ts — DGB mainnet + testnet params, P2P constants
seed.ts — BIP39: generate, validate, mnemonic → seed
hd.ts — BIP32: root, account, address nodes
address.ts — P2PKH, P2SH-P2WPKH, P2WPKH, P2TR encoders
wif.ts — WIF import/export with version-byte validation
descriptor.ts — derivation-path strings, xpub/xprv export
index.ts — re-exports
scripts/
derive-from-seed.ts — generate wallet, show 3 addresses per path
recover-from-seed.ts — scan 5 accounts × 20 addresses per path
```
## Roadmap
- **`@dgb-wallet/p2p`** — DGB P2P client with BIP157/158 compact block filters
- **`@dgb-wallet/rpc`** — thin adapter for a local pruned Core (broadcast bastion)
- **`@dgb-wallet/blockbook`** — opt-in convenience-mode backend
- **`@dgb-wallet/psbt`** — transaction construction / signing on top of `core`

1311
packages/core/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,36 @@
{
"name": "@dgb-wallet/core",
"version": "0.0.1",
"description": "DigiByte wallet core: BIP39 seed, BIP32/44/49/84/86 derivation, address encoding, WIF import, PSBT primitives. No node required.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"demo": "tsx scripts/derive-from-seed.ts",
"recover": "tsx scripts/recover-from-seed.ts"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.1.1",
"@noble/hashes": "^1.8.0",
"bip32": "^4.0.0",
"bip39": "^3.1.0",
"bitcoinjs-lib": "^6.1.7",
"ecpair": "^2.1.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,49 @@
import {
generateSeedPhrase,
seedFromPhrase,
rootFromSeed,
accountNode,
addressNode,
p2pkhAddress,
p2shP2wpkhAddress,
p2wpkhAddress,
p2trAddress,
accountXpub,
PURPOSE_LABEL,
type Purpose,
} from '../src/index.js';
const args = process.argv.slice(2);
const phrase = args.length > 0 ? args.join(' ').trim() : generateSeedPhrase(128);
console.log('\n=== DigiByte HD Derivation Demo ===\n');
console.log(`Seed phrase (${phrase.split(/\s+/).length} words):`);
console.log(` ${phrase}\n`);
const seed = await seedFromPhrase(phrase);
const root = rootFromSeed(seed);
const specs: Array<{
purpose: Purpose;
deriver: (n: ReturnType<typeof addressNode>) => string;
}> = [
{ purpose: 44, deriver: p2pkhAddress },
{ purpose: 49, deriver: p2shP2wpkhAddress },
{ purpose: 84, deriver: p2wpkhAddress },
{ purpose: 86, deriver: p2trAddress },
];
for (const spec of specs) {
const acct = accountNode(root, spec.purpose, 0);
console.log(`── ${PURPOSE_LABEL[spec.purpose]}`);
console.log(` account xpub: ${accountXpub(acct)}`);
console.log(` first 3 receive addresses (m/${spec.purpose}'/20'/0'/0/*):`);
for (let i = 0; i < 3; i++) {
const key = addressNode(acct, 0, i);
console.log(` [${i}] ${spec.deriver(key)}`);
}
console.log();
}
console.log('Done. Cross-check any of these addresses against Ian Coleman');
console.log('iancoleman.io/bip39 with coin = "DGB - Digibyte" — they should match.\n');

View file

@ -0,0 +1,91 @@
// Recovery demo — takes a seed phrase and prints candidate addresses at
// every standard DGB derivation path across the first 5 accounts and
// first 20 addresses per account. Meant for offline verification against
// a block explorer or Ian Coleman; the actual wallet's "scan all paths"
// import flow will run the same shape of derivation and then check each
// address for balance via the P2P+BIP158 client.
import {
seedFromPhrase,
validateSeedPhrase,
rootFromSeed,
accountNode,
addressNode,
p2pkhAddress,
p2shP2wpkhAddress,
p2shP2wpkhAddressesBoth,
p2wpkhAddress,
p2trAddress,
PURPOSE_LABEL,
derivationPath,
digibyteLegacyP2SH,
type Purpose,
} from '../src/index.js';
const args = process.argv.slice(2);
if (args.length === 0) {
console.error('Usage: npm run recover -- "word1 word2 ... word12"');
console.error('');
console.error('WARNING: never paste a real seed here. Generate a throwaway seed with');
console.error('`npm run demo` and use that. Terminal history persists.');
process.exit(1);
}
const phrase = args.join(' ').trim();
const wordCount = phrase.split(/\s+/).length;
const validCounts = [12, 15, 18, 21, 24];
if (!validCounts.includes(wordCount)) {
console.error(`Got ${wordCount} words. BIP39 requires exactly one of: ${validCounts.join(', ')}.`);
console.error('');
console.error('Common cause: pasting placeholder text along with the real words.');
console.error('Quote just the seed phrase itself, with no extra words before or after.');
process.exit(1);
}
if (!validateSeedPhrase(phrase)) {
console.error(`Got ${wordCount} words at a valid length, but the BIP39 checksum failed.`);
console.error('Check for typos or non-BIP39 wordlist words.');
process.exit(1);
}
const seed = await seedFromPhrase(phrase);
const root = rootFromSeed(seed);
const ACCOUNTS = 5;
const ADDRESSES = 20;
console.log(`\n=== Recovery Scan ===`);
console.log(`Seed: ${phrase.split(/\s+/).length}-word BIP39`);
console.log(`Accounts: 0..${ACCOUNTS - 1}`);
console.log(`Addresses: 0..${ADDRESSES - 1} per account, external chain only\n`);
function scan(
label: string,
purpose: Purpose,
derive: (n: ReturnType<typeof addressNode>) => string,
): void {
console.log(`── ${label}`);
for (let a = 0; a < ACCOUNTS; a++) {
const acct = accountNode(root, purpose, a);
for (let i = 0; i < ADDRESSES; i++) {
const key = addressNode(acct, 0, i);
console.log(` ${derivationPath(purpose, a, 0, i).padEnd(24)} ${derive(key)}`);
}
}
console.log();
}
scan(PURPOSE_LABEL[44], 44, p2pkhAddress);
scan(`${PURPOSE_LABEL[49]} (modern "S..." — DGB Core default)`, 49, p2shP2wpkhAddress);
scan(
`${PURPOSE_LABEL[49]} (legacy "3..." — Ian Coleman / older tools)`,
49,
(n) => p2shP2wpkhAddressesBoth(n).legacy,
);
scan(PURPOSE_LABEL[84], 84, p2wpkhAddress);
scan(PURPOSE_LABEL[86], 86, p2trAddress);
console.log('Note: BIP49 shown twice on purpose. Same underlying key, two');
console.log('valid DGB P2SH version bytes. Any recovery scan must check both.');
console.log(`(legacyP2SH network for reference: scriptHash=0x${digibyteLegacyP2SH.scriptHash.toString(16)})\n`);

View file

@ -0,0 +1,69 @@
import { payments, initEccLib } from 'bitcoinjs-lib';
import * as ecc from '@bitcoinerlab/secp256k1';
import type { BIP32Interface } from 'bip32';
import { digibyte, digibyteLegacyP2SH, type Network } from './network.js';
// Required for Taproot address derivation (P2TR).
initEccLib(ecc);
export function p2pkhAddress(
node: BIP32Interface,
network: Network = digibyte,
): string {
const { address } = payments.p2pkh({
pubkey: Buffer.from(node.publicKey),
network,
});
if (!address) throw new Error('p2pkh derivation returned no address');
return address;
}
export function p2shP2wpkhAddress(
node: BIP32Interface,
network: Network = digibyte,
): string {
const redeem = payments.p2wpkh({
pubkey: Buffer.from(node.publicKey),
network,
});
const { address } = payments.p2sh({ redeem, network });
if (!address) throw new Error('p2sh-p2wpkh derivation returned no address');
return address;
}
// Derive the "S..." (current DGB, scriptHash 0x3f) AND "3..." (legacy
// Bitcoin-compatible, scriptHash 0x05) BIP49 addresses for the same
// key. Ian Coleman's BIP39 tool and several older wallets generate the
// legacy "3..." variant, so any seed-recovery scan must check both.
export function p2shP2wpkhAddressesBoth(
node: BIP32Interface,
): { modern: string; legacy: string } {
return {
modern: p2shP2wpkhAddress(node, digibyte),
legacy: p2shP2wpkhAddress(node, digibyteLegacyP2SH),
};
}
export function p2wpkhAddress(
node: BIP32Interface,
network: Network = digibyte,
): string {
const { address } = payments.p2wpkh({
pubkey: Buffer.from(node.publicKey),
network,
});
if (!address) throw new Error('p2wpkh derivation returned no address');
return address;
}
// BIP86 Taproot address using the x-only pubkey with no script tree,
// which applies the standard BIP86 tweak internally in bitcoinjs-lib.
export function p2trAddress(
node: BIP32Interface,
network: Network = digibyte,
): string {
const internalPubkey = Buffer.from(node.publicKey.subarray(1, 33));
const { address } = payments.p2tr({ internalPubkey, network });
if (!address) throw new Error('p2tr derivation returned no address');
return address;
}

View file

@ -0,0 +1,27 @@
import { DGB_COIN_TYPE } from './network.js';
import type { BIP32Interface } from 'bip32';
import type { Purpose } from './hd.js';
// Convenience helper: full derivation-path string for a specific
// address, e.g. m/84'/20'/0'/0/5.
export function derivationPath(
purpose: Purpose,
account: number,
change: 0 | 1,
index: number,
): string {
return `m/${purpose}'/${DGB_COIN_TYPE}'/${account}'/${change}/${index}`;
}
// xpub for an account, ready to be shared with a watch-only or
// external indexer. Never share the corresponding xprv.
export function accountXpub(accountNode: BIP32Interface): string {
return accountNode.neutered().toBase58();
}
export function accountXprv(accountNode: BIP32Interface): string {
if (!accountNode.privateKey) {
throw new Error('Node has no private key; cannot export xprv');
}
return accountNode.toBase58();
}

43
packages/core/src/hd.ts Normal file
View file

@ -0,0 +1,43 @@
import { BIP32Factory, type BIP32Interface } from 'bip32';
import * as ecc from '@bitcoinerlab/secp256k1';
import { digibyte, DGB_COIN_TYPE, type Network } from './network.js';
const bip32 = BIP32Factory(ecc);
export type Purpose = 44 | 49 | 84 | 86;
export const PURPOSE_LABEL: Record<Purpose, string> = {
44: 'BIP44 legacy P2PKH',
49: 'BIP49 P2SH-wrapped SegWit',
84: 'BIP84 native SegWit v0',
86: 'BIP86 Taproot (SegWit v1)',
};
export function rootFromSeed(
seed: Buffer,
network: Network = digibyte,
): BIP32Interface {
return bip32.fromSeed(seed, network);
}
// Standard account-level derivation: m/purpose'/coin'/account'.
// account defaults to 0 (the first account).
export function accountNode(
root: BIP32Interface,
purpose: Purpose,
account = 0,
): BIP32Interface {
return root.derivePath(`m/${purpose}'/${DGB_COIN_TYPE}'/${account}'`);
}
// Address-level derivation from an account node.
// change = 0 for external (receive) addresses, 1 for internal (change).
export function addressNode(
account: BIP32Interface,
change: 0 | 1,
index: number,
): BIP32Interface {
return account.derive(change).derive(index);
}
export type { BIP32Interface };

View file

@ -0,0 +1,6 @@
export * from './network.js';
export * from './seed.js';
export * from './hd.js';
export * from './address.js';
export * from './wif.js';
export * from './descriptor.js';

View file

@ -0,0 +1,82 @@
import type { networks } from 'bitcoinjs-lib';
export type Network = (typeof networks)['bitcoin'];
// DigiByte mainnet parameters. Sourced from
// github.com/DigiByte-Core/digibyte src/kernel/chainparams.cpp CMainParams.
//
// Note on scriptHash: DGB Core defines BOTH SCRIPT_ADDRESS = 0x3f ("S..."
// addresses, the current default) and SCRIPT_ADDRESS2 = 0x05 ("3..."
// addresses, kept for Bitcoin compatibility). Older BIP39 tools and
// wallets that forked bitcoinjs-lib params generate 3-addresses; current
// DGB Core generates S-addresses. `digibyte` below uses the current
// default; use `digibyteLegacyP2SH` when recovering from Ian Coleman's
// BIP39 tool, older Atomic/Exodus vintages, or anything else that
// inherited Bitcoin's 0x05 P2SH byte. Both are valid on-chain.
export const digibyte: Network = {
messagePrefix: '\x19DigiByte Signed Message:\n',
bech32: 'dgb',
bip32: {
public: 0x0488b21e,
private: 0x0488ade4,
},
pubKeyHash: 0x1e,
scriptHash: 0x3f,
wif: 0x80,
};
// Same as `digibyte`, but with the legacy Bitcoin-compatible P2SH
// version byte. Use for producing/scanning "3..."-style P2SH addresses
// during seed recovery from tools that predate the 0x3f switch.
// DGB Core keeps SCRIPT_ADDRESS_OLD = 5 for backward compatibility.
export const digibyteLegacyP2SH: Network = {
...digibyte,
scriptHash: 0x05,
};
// Same as `digibyte`, but with the legacy WIF version byte.
// DGB Core keeps SECRET_KEY_OLD = 158 (0x9e) for backward compatibility.
// Older DGB tools may have exported private keys with this prefix.
// The wallet's WIF-import path should try both `digibyte` and this
// variant before rejecting a key.
export const digibyteLegacyWIF: Network = {
...digibyte,
wif: 0x9e,
};
// DigiByte testnet parameters. From CTestNetParams in chainparams.cpp.
export const digibyteTestnet: Network = {
messagePrefix: '\x19DigiByte Signed Message:\n',
bech32: 'dgbt',
bip32: {
public: 0x043587cf,
private: 0x04358394,
},
pubKeyHash: 0x7e,
scriptHash: 0x8c,
wif: 0xef,
};
// SLIP-0044 registered coin type.
export const DGB_COIN_TYPE = 20;
// P2P network constants (unused by @dgb-wallet/core itself, exposed for
// the P2P client package that will consume them).
export const DGB_P2P = {
// pchMessageStart in DGB Core chainparams.cpp is the byte sequence
// 0xFA 0xC3 0xB6 0xDA on the wire. `writeUInt32LE(magic)` writes
// least-significant-byte first, so the integer value stored here must
// be the LE-reading: 0xDAB6C3FA (byte 0 = 0xFA, byte 3 = 0xDA).
// Getting this wrong means peers see wrong-network frames and close
// immediately at handshake.
magic: 0xdab6c3fa,
defaultPort: 12024,
rpcPort: 14022,
dnsSeeds: [
'seed.digibyte.io',
'seed.diginode.tools',
'seed.digibyte.link',
'seed.aroundtheblock.app',
'seed.tuyul.cc',
],
} as const;

114
packages/core/src/seed.ts Normal file
View file

@ -0,0 +1,114 @@
import {
generateMnemonic,
validateMnemonic,
mnemonicToSeed,
wordlists,
} from 'bip39';
import { pbkdf2 } from '@noble/hashes/pbkdf2';
import { sha512, sha256 } from '@noble/hashes/sha2';
import { hmac } from '@noble/hashes/hmac';
export type SeedStrength = 128 | 160 | 192 | 224 | 256;
// Word count → entropy strength for BIP39.
// 12 → 128, 15 → 160, 18 → 192, 21 → 224, 24 → 256.
export function generateSeedPhrase(strength: SeedStrength = 128): string {
return generateMnemonic(strength);
}
// BIP39 checksum + wordlist validation. Returns false for typos, bad
// word counts, and out-of-wordlist words.
export function validateSeedPhrase(
phrase: string,
wordlist: string[] = wordlists.english,
): boolean {
return validateMnemonic(phrase.trim(), wordlist);
}
// BIP39 seed derivation. Passphrase is the optional "25th word";
// changing it produces a different wallet from the same mnemonic.
export async function seedFromPhrase(
phrase: string,
passphrase = '',
): Promise<Buffer> {
return mnemonicToSeed(phrase.trim(), passphrase);
}
// ---- Electrum seeds -----------------------------------------------------
//
// Electrum's own mnemonic → seed derivation is NOT compatible with BIP39:
//
// - the wordlist is the same in practice (Electrum uses the BIP39
// English list for standard seeds), but the "seed type" is encoded
// as a version prefix in HMAC-SHA512(key='Seed version', msg=phrase)
// rather than as a checksum in the phrase itself
// - the seed is PBKDF2(hmac-sha512, phrase, 'electrum'+passphrase,
// 2048 rounds, 64 bytes) — different salt from BIP39
// - address derivation uses different paths (see network/derivation)
//
// Supported here: 'standard' (legacy P2PKH, prefix 01) and 'segwit'
// (native SegWit, prefix 100). Two-factor variants are not supported.
//
// Refs: Electrum protocol seed handling, https://electrum.org
export type ElectrumSeedType = 'standard' | 'segwit';
const ELECTRUM_PREFIX: Record<ElectrumSeedType, string> = {
standard: '01',
segwit: '100',
};
/** Normalize a phrase the way Electrum does before HMAC / PBKDF2: NFKD
* + lowercase + collapse whitespace + strip CJK boundary spaces. We
* implement only the parts BIP39 wordlist phrases actually need
* (NFKD, lowercase, whitespace-collapse) which is sufficient for the
* English wordlist used in practice. */
function normalizeElectrumPhrase(phrase: string): string {
return phrase.normalize('NFKD').toLowerCase().replace(/\s+/g, ' ').trim();
}
function hex(u8: Uint8Array): string {
let s = '';
for (const b of u8) s += b.toString(16).padStart(2, '0');
return s;
}
/** Determine whether a phrase is a valid Electrum seed, and if so which
* type. Returns null when the phrase's HMAC prefix matches no
* supported Electrum seed type. */
export function detectElectrumSeedType(phrase: string): ElectrumSeedType | null {
const normalized = normalizeElectrumPhrase(phrase);
const key = new TextEncoder().encode('Seed version');
const msg = new TextEncoder().encode(normalized);
const mac = hmac(sha512, key, msg);
const prefix = hex(mac).toLowerCase();
if (prefix.startsWith(ELECTRUM_PREFIX.standard)) return 'standard';
if (prefix.startsWith(ELECTRUM_PREFIX.segwit)) return 'segwit';
return null;
}
/** True when the phrase is recognized as an Electrum standard or segwit
* seed. Does not accept two-factor (2fa) variants. */
export function validateElectrumPhrase(phrase: string): boolean {
return detectElectrumSeedType(phrase) !== null;
}
/** PBKDF2(hmac-sha512, normalized-phrase, 'electrum'+passphrase, 2048,
* 64 bytes) — the Electrum-specific seed derivation. Returns a Buffer
* so downstream code (bip32.fromSeed) can consume it the same way it
* consumes a BIP39 seed. */
export async function seedFromElectrumPhrase(
phrase: string,
passphrase = '',
): Promise<Buffer> {
const normalized = normalizeElectrumPhrase(phrase);
const salt = new TextEncoder().encode(`electrum${passphrase}`);
const out = pbkdf2(sha512, new TextEncoder().encode(normalized), salt, {
c: 2048,
dkLen: 64,
});
return Buffer.from(out);
}
// Silence the unused sha256 import when tree-shaking notices — kept for
// symmetry with the noble import block; some future variants (2FA) need it.
void sha256;

114
packages/core/src/wif.ts Normal file
View file

@ -0,0 +1,114 @@
import { ECPairFactory, type ECPairInterface } from 'ecpair';
import * as ecc from '@bitcoinerlab/secp256k1';
import { payments, initEccLib } from 'bitcoinjs-lib';
import { digibyte, digibyteLegacyP2SH, digibyteLegacyWIF, type Network } from './network.js';
const ECPair = ECPairFactory(ecc);
initEccLib(ecc);
export type WifImportResult = {
keyPair: ECPairInterface;
// Which network variant successfully decoded the WIF. "modern" =
// SECRET_KEY 0x80 (current DGB Core default). "legacy" =
// SECRET_KEY_OLD 0x9e (older DGB tools). Callers should surface this
// to the user so they know they imported a legacy-format key.
variant: 'modern' | 'legacy';
};
// Import a DigiByte private key in WIF (Wallet Import Format).
// Tries the modern 0x80 prefix first, falls back to the legacy 0x9e
// prefix that older DGB tools produced. Rejects anything else with a
// clear message that identifies the network mismatch.
export function importWif(wif: string): WifImportResult {
const trimmed = wif.trim();
try {
return { keyPair: ECPair.fromWIF(trimmed, digibyte), variant: 'modern' };
} catch {
// fall through to legacy attempt
}
try {
return { keyPair: ECPair.fromWIF(trimmed, digibyteLegacyWIF), variant: 'legacy' };
} catch (e) {
throw new Error(
`Not a valid DigiByte WIF (tried both current 0x80 and legacy 0x9e prefixes). ` +
`Underlying error: ${(e as Error).message}. Check the key is for DGB mainnet, not testnet or another chain.`,
);
}
}
// Import against a specific network only (advanced / testing).
export function importWifStrict(
wif: string,
network: Network = digibyte,
): ECPairInterface {
return ECPair.fromWIF(wif.trim(), network);
}
export function exportWif(keyPair: ECPairInterface): string {
return keyPair.toWIF();
}
// Every address a single pubkey can produce on DGB mainnet, plus the
// raw scriptPubKey hexes the wallet needs to hand a filter probe. One
// key → four scripts (P2SH has one script even though DGB renders it
// under two address prefixes, per RESEARCH.md §10.7a).
export interface PubkeyAddresses {
p2pkh: string; // D…
p2shP2wpkhModern: string; // S…
p2shP2wpkhLegacy: string; // 3…
p2wpkh: string; // dgb1q…
p2tr: string; // dgb1p…
scripts: {
p2pkh: string; // hex
p2shP2wpkh: string;
p2wpkh: string;
p2tr: string;
};
}
export function addressesForPubkey(pubkey: Buffer, network: Network = digibyte): PubkeyAddresses {
const p2pkh = payments.p2pkh({ pubkey, network });
const wpkhRedeem = payments.p2wpkh({ pubkey, network });
// bitcoinjs-lib enforces `redeem.network === outerNetwork` (identity
// comparison, not shape). To render the legacy 3-prefix P2SH address
// we need a fresh redeem whose .network property is the legacy variant
// — same bytes on the wire, different object identity.
const wpkhRedeemLegacy = payments.p2wpkh({ pubkey, network: digibyteLegacyP2SH });
const p2shModern = payments.p2sh({ redeem: wpkhRedeem, network });
const p2shLegacy = payments.p2sh({ redeem: wpkhRedeemLegacy, network: digibyteLegacyP2SH });
const p2wpkh = payments.p2wpkh({ pubkey, network });
const p2tr = payments.p2tr({ internalPubkey: pubkey.subarray(1, 33), network });
if (!p2pkh.address || !p2shModern.address || !p2shLegacy.address || !p2wpkh.address || !p2tr.address) {
throw new Error('bitcoinjs-lib returned an empty address for one of the payment types');
}
if (!p2pkh.output || !p2shModern.output || !p2wpkh.output || !p2tr.output) {
throw new Error('bitcoinjs-lib returned an empty scriptPubKey for one of the payment types');
}
return {
p2pkh: p2pkh.address,
p2shP2wpkhModern: p2shModern.address,
p2shP2wpkhLegacy: p2shLegacy.address,
p2wpkh: p2wpkh.address,
p2tr: p2tr.address,
scripts: {
p2pkh: Buffer.from(p2pkh.output).toString('hex'),
p2shP2wpkh: Buffer.from(p2shModern.output).toString('hex'),
p2wpkh: Buffer.from(p2wpkh.output).toString('hex'),
p2tr: Buffer.from(p2tr.output).toString('hex'),
},
};
}
// Convenience that combines importWif + addressesForPubkey. Callers who
// only care about the address view (no signing intent) can use this
// and never touch the ECPair.
export interface WifImportWithAddresses extends WifImportResult, PubkeyAddresses {}
export function importWifWithAddresses(wif: string): WifImportWithAddresses {
const imported = importWif(wif);
const pubkey = Buffer.from(imported.keyPair.publicKey);
const addrs = addressesForPubkey(pubkey);
return { ...imported, ...addrs };
}
export type { ECPairInterface };

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "scripts"]
}

3
packages/electrum/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
*.log

View file

@ -0,0 +1,51 @@
# @dgb-wallet/electrum
Minimal DigiByte Electrum protocol client. TCP+TLS JSON-RPC to public Electrum-DGB servers for balance, UTXO listing, transaction broadcast, and chain tip. No API keys, no signup.
Positioned as a **Convenience-mode backend** in the wallet's privacy design (RESEARCH.md §10.6) — an Electrum server sees which addresses you query. For Maximum Privacy the wallet uses `@dgb-wallet/p2p` (BIP157/158 SPV) instead. Electrum still beats a single hosted Blockbook because you can distribute queries across many community-run servers with connection-per-server privacy.
## API
- `connectAny(servers, opts)` — try servers in order, return the first that connects and handshakes
- `getBalance(client, address)` — `{ confirmed, unconfirmed }` in satoshis
- `getBalances(client, addresses)` — parallelized bulk lookup over one connection
- `listUnspent(client, address)` — array of `{ txid, vout, value, height }`
- `broadcast(client, rawTxHex)` — returns txid or throws with server error
- `getChainTip(client)` — `{ height, headerHex }`
Handles both current DGB address format (`S...`) and legacy Bitcoin-compatible P2SH (`3...`) via `scripthashOf`, so recovery scans that scan both variants (per RESEARCH.md §10.7a) work transparently.
## Demo — real mainnet recovery scan
```bash
cd "D:\Dev\SilentCode\Digibyte"
npm install # first time only
npm run build -w @dgb-wallet/core
npm run demo -w @dgb-wallet/electrum
```
Connects to a public Electrum-DGB server, derives the public all-abandon BIP39 test seed's addresses at every path (BIP44, both BIP49 variants, BIP84, BIP86), and queries live balance for each. Fetches UTXOs for any address that has funds.
Run with your own seed (do NOT paste a seed protecting real funds anywhere but the wallet itself):
```bash
npm run demo -w @dgb-wallet/electrum -- "your twelve word seed phrase here"
```
## Public servers
`src/servers.ts` lists community-run peers. All unaffiliated with this project — availability varies. Update from the DigiByte community forums or `github.com/spesmilo/electrum-doc` when peers change.
## Layout
```
src/
servers.ts — known public DGB Electrum peers
client.ts — JSON-RPC over TCP+TLS, multi-server failover
scripthash.ts — address → Electrum scripthash (handles both DGB P2SH variants)
methods.ts — typed wrappers for common methods
index.ts — re-exports
scripts/
scan-seed-demo.ts — recover any seed, scan real mainnet, print balances & UTXOs
```

View file

@ -0,0 +1,31 @@
{
"name": "@dgb-wallet/electrum",
"version": "0.0.1",
"description": "Minimal DigiByte Electrum protocol client — TCP+TLS JSON-RPC to public Electrum-DGB servers for balance, UTXOs, and broadcast. Convenience-mode backend for @dgb-wallet.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"demo": "tsx scripts/scan-seed-demo.ts"
},
"dependencies": {
"@dgb-wallet/core": "*",
"bitcoinjs-lib": "^6.1.7"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,148 @@
// End-to-end recovery scan against real DigiByte mainnet via public
// Electrum-DGB servers. Takes a seed phrase, derives addresses at
// BIP44/49-modern/49-legacy/84/86, and reports live balance for each.
//
// This is the recovery-check-balance flow, real chain data, no local
// node required. Feed the resulting UTXOs into @dgb-wallet/psbt and
// you have a working end-to-end wallet.
import {
seedFromPhrase,
validateSeedPhrase,
rootFromSeed,
accountNode,
addressNode,
p2pkhAddress,
p2shP2wpkhAddress,
p2shP2wpkhAddressesBoth,
p2wpkhAddress,
p2trAddress,
} from '@dgb-wallet/core';
import { DEFAULT_SERVERS, connectAny, getBalances, getChainTip, listUnspent } from '../src/index.js';
const args = process.argv.slice(2);
const phrase =
args.length > 0
? args.join(' ').trim()
: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
if (!validateSeedPhrase(phrase)) {
console.error('Invalid BIP39 seed phrase.');
console.error('Usage: npm run demo -w @dgb-wallet/electrum -- "word1 word2 ... word12"');
console.error('(with no args, uses the public all-abandon BIP39 test vector)');
process.exit(1);
}
const ACCOUNTS = 1;
const ADDRESSES_PER_ACCOUNT = 5;
console.log('\n=== DigiByte Recovery Scan (real mainnet) ===\n');
console.log(`Seed: ${phrase.split(/\s+/).length}-word BIP39 (all-abandon test seed shown by default)`);
console.log(`Accounts: 0..${ACCOUNTS - 1}`);
console.log(`Depth: ${ADDRESSES_PER_ACCOUNT} receive addresses per account per path\n`);
console.log('Connecting to a public Electrum-DGB server...');
let client;
try {
client = await connectAny(DEFAULT_SERVERS, { timeoutMs: 10_000 });
} catch (e) {
console.error(`\nCould not reach any Electrum-DGB server:\n${(e as Error).message}\n`);
console.error('Servers are community-run and may be temporarily offline.');
console.error('Update DEFAULT_SERVERS in src/servers.ts if you know of live peers.');
process.exit(1);
}
console.log(` connected: ${client.server.host}:${client.server.port} (${client.server.protocol})\n`);
const tip = await getChainTip(client);
console.log(`Chain tip: height ${tip.height.toLocaleString()}\n`);
const seed = await seedFromPhrase(phrase);
const root = rootFromSeed(seed);
interface AddressJob {
path: string;
label: string;
address: string;
}
const jobs: AddressJob[] = [];
for (let a = 0; a < ACCOUNTS; a++) {
const bip44 = accountNode(root, 44, a);
const bip49 = accountNode(root, 49, a);
const bip84 = accountNode(root, 84, a);
const bip86 = accountNode(root, 86, a);
for (let i = 0; i < ADDRESSES_PER_ACCOUNT; i++) {
jobs.push({
path: `m/44'/20'/${a}'/0/${i}`,
label: 'BIP44',
address: p2pkhAddress(addressNode(bip44, 0, i)),
});
jobs.push({
path: `m/49'/20'/${a}'/0/${i}`,
label: 'BIP49-S',
address: p2shP2wpkhAddress(addressNode(bip49, 0, i)),
});
jobs.push({
path: `m/49'/20'/${a}'/0/${i}`,
label: 'BIP49-3',
address: p2shP2wpkhAddressesBoth(addressNode(bip49, 0, i)).legacy,
});
jobs.push({
path: `m/84'/20'/${a}'/0/${i}`,
label: 'BIP84',
address: p2wpkhAddress(addressNode(bip84, 0, i)),
});
jobs.push({
path: `m/86'/20'/${a}'/0/${i}`,
label: 'BIP86',
address: p2trAddress(addressNode(bip86, 0, i)),
});
}
}
console.log(`Querying ${jobs.length} addresses in parallel over one connection...\n`);
const balances = await getBalances(client, jobs.map((j) => j.address));
let totalConfirmed = 0;
let totalUnconfirmed = 0;
let anyWithFunds = false;
console.log('label path address confirmed unconfirmed');
console.log('─'.repeat(148));
for (const j of jobs) {
const b = balances.get(j.address)!;
totalConfirmed += b.confirmed;
totalUnconfirmed += b.unconfirmed;
const marker = b.confirmed > 0 || b.unconfirmed > 0 ? ' ★' : ' ';
if (b.confirmed > 0 || b.unconfirmed > 0) anyWithFunds = true;
console.log(
`${j.label.padEnd(9)} ${j.path.padEnd(24)} ${j.address.padEnd(66)} ${(b.confirmed / 1e8).toFixed(8).padStart(16)} DGB ${(b.unconfirmed / 1e8).toFixed(8).padStart(16)} DGB${marker}`,
);
}
console.log('─'.repeat(148));
console.log(` ${(totalConfirmed / 1e8).toFixed(8).padStart(16)} DGB ${(totalUnconfirmed / 1e8).toFixed(8).padStart(16)} DGB`);
console.log();
if (anyWithFunds) {
console.log('★ Addresses with funds — fetching UTXOs:\n');
for (const j of jobs) {
const b = balances.get(j.address)!;
if (b.confirmed === 0 && b.unconfirmed === 0) continue;
const utxos = await listUnspent(client, j.address);
for (const u of utxos) {
console.log(` ${j.address}`);
console.log(` ${u.txid}:${u.vout} value=${(u.value / 1e8).toFixed(8)} DGB height=${u.height}`);
}
}
console.log();
console.log('These UTXOs can be fed to @dgb-wallet/psbt to construct a spend.');
} else {
console.log('No funds at any scanned address (expected for the all-abandon test seed).');
console.log('Run with your own seed: npm run demo -w @dgb-wallet/electrum -- "your words here"');
console.log('(Reminder: never paste a real seed protecting funds anywhere but the wallet itself.)');
}
console.log();
client.close();

View file

@ -0,0 +1,149 @@
import * as net from 'node:net';
import * as tls from 'node:tls';
import type { ElectrumServer } from './servers.js';
// Line-delimited JSON-RPC 2.0 client for the Electrum protocol.
// Protocol reference: electrumx.readthedocs.io/en/latest/protocol.html
//
// Each message is a single line ending in '\n'. Server responses can
// arrive in any order (via matching `id`), and unsolicited notifications
// (subscription events) can arrive at any time (no `id`, method-named).
type JsonRpcResponse =
| { jsonrpc?: '2.0'; id: number; result: unknown; error?: undefined }
| { jsonrpc?: '2.0'; id: number; error: { code: number; message: string }; result?: undefined };
export interface ClientOptions {
timeoutMs?: number; // per-request timeout; default 15s
clientName?: string; // sent in server.version handshake
protocolVersion?: string; // Electrum protocol version to negotiate
}
const DEFAULTS: Required<ClientOptions> = {
timeoutMs: 15_000,
clientName: '@dgb-wallet/electrum 0.0.1',
protocolVersion: '1.4',
};
export class ElectrumClient {
private socket: net.Socket | tls.TLSSocket | null = null;
private nextId = 1;
private pending = new Map<
number,
{ resolve: (v: unknown) => void; reject: (e: Error) => void; timer: NodeJS.Timeout }
>();
private buffer = '';
private readonly opts: Required<ClientOptions>;
constructor(
public readonly server: ElectrumServer,
opts: ClientOptions = {},
) {
this.opts = { ...DEFAULTS, ...opts };
}
connect(): Promise<void> {
return new Promise((resolve, reject) => {
const onConnect = () => {
this.socket!.on('data', (chunk) => this.onData(chunk.toString('utf8')));
this.socket!.on('error', (err) => this.failAllPending(err));
this.socket!.on('close', () =>
this.failAllPending(new Error(`Electrum server ${this.server.host} closed connection`)),
);
this.request('server.version', [this.opts.clientName, this.opts.protocolVersion])
.then(() => resolve())
.catch(reject);
};
if (this.server.protocol === 'ssl') {
this.socket = tls.connect(
{ host: this.server.host, port: this.server.port, servername: this.server.host },
onConnect,
);
} else {
this.socket = net.connect({ host: this.server.host, port: this.server.port }, onConnect);
}
this.socket.once('error', reject);
});
}
close(): void {
this.socket?.destroy();
this.socket = null;
}
request<T = unknown>(method: string, params: unknown[] = []): Promise<T> {
if (!this.socket) return Promise.reject(new Error('Electrum client not connected'));
const id = this.nextId++;
const line = JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n';
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id);
reject(new Error(`Electrum ${method} timed out after ${this.opts.timeoutMs}ms`));
}, this.opts.timeoutMs);
this.pending.set(id, {
resolve: (v) => resolve(v as T),
reject,
timer,
});
this.socket!.write(line);
});
}
private onData(chunk: string): void {
this.buffer += chunk;
let idx: number;
while ((idx = this.buffer.indexOf('\n')) >= 0) {
const line = this.buffer.slice(0, idx).trim();
this.buffer = this.buffer.slice(idx + 1);
if (!line) continue;
let msg: JsonRpcResponse | { method: string; params: unknown[] };
try {
msg = JSON.parse(line);
} catch {
continue; // malformed line; skip
}
if ('id' in msg && typeof msg.id === 'number') {
const p = this.pending.get(msg.id);
if (!p) continue; // late response for a timed-out request
this.pending.delete(msg.id);
clearTimeout(p.timer);
if ('error' in msg && msg.error) {
p.reject(new Error(`Electrum error ${msg.error.code}: ${msg.error.message}`));
} else {
p.resolve(msg.result);
}
}
// Notifications (no `id`) are ignored in v0 — no subscription support yet.
}
}
private failAllPending(err: Error): void {
for (const [, p] of this.pending) {
clearTimeout(p.timer);
p.reject(err);
}
this.pending.clear();
}
}
// Try each server in order until one connects and successfully
// handshakes. Callers get back a ready ElectrumClient. Throws if all
// servers fail — callers should catch and either surface to the user or
// fall back to another backend.
export async function connectAny(
servers: ElectrumServer[],
opts?: ClientOptions,
): Promise<ElectrumClient> {
const errors: string[] = [];
for (const s of servers) {
const client = new ElectrumClient(s, opts);
try {
await client.connect();
return client;
} catch (e) {
errors.push(` ${s.host}:${s.port} (${s.protocol}) — ${(e as Error).message}`);
client.close();
}
}
throw new Error(`All Electrum servers failed:\n${errors.join('\n')}`);
}

View file

@ -0,0 +1,4 @@
export * from './servers.js';
export * from './client.js';
export * from './methods.js';
export * from './scripthash.js';

View file

@ -0,0 +1,79 @@
import type { ElectrumClient } from './client.js';
import { scripthashOf } from './scripthash.js';
import type { Network } from '@dgb-wallet/core';
export interface Balance {
confirmed: number; // satoshis
unconfirmed: number; // satoshis
}
export interface UnspentOutput {
txid: string;
vout: number;
value: number; // satoshis
height: number; // 0 = unconfirmed
}
// Balance for a single address. Convenience wrapper — computes the
// scripthash internally and calls blockchain.scripthash.get_balance.
export async function getBalance(
client: ElectrumClient,
address: string,
network?: Network,
): Promise<Balance> {
const sh = scripthashOf(address, network);
const raw = await client.request<{ confirmed: number; unconfirmed: number }>(
'blockchain.scripthash.get_balance',
[sh],
);
return { confirmed: raw.confirmed, unconfirmed: raw.unconfirmed };
}
// Batched balance lookup. Sends N parallel requests over the same
// socket connection. Electrum servers process requests concurrently, so
// this is much faster than sequential for wallet-scan use cases.
export async function getBalances(
client: ElectrumClient,
addresses: string[],
network?: Network,
): Promise<Map<string, Balance>> {
const results = await Promise.all(addresses.map((a) => getBalance(client, a, network)));
const map = new Map<string, Balance>();
addresses.forEach((a, i) => map.set(a, results[i]));
return map;
}
export async function listUnspent(
client: ElectrumClient,
address: string,
network?: Network,
): Promise<UnspentOutput[]> {
const sh = scripthashOf(address, network);
const raw = await client.request<
Array<{ tx_hash: string; tx_pos: number; value: number; height: number }>
>('blockchain.scripthash.listunspent', [sh]);
return raw.map((u) => ({
txid: u.tx_hash,
vout: u.tx_pos,
value: u.value,
height: u.height,
}));
}
// Broadcast a signed raw transaction. Returns the txid on success;
// throws with the server's error message on failure (e.g., mempool
// rejection, bad-signature, insufficient-fee).
export async function broadcast(
client: ElectrumClient,
rawTxHex: string,
): Promise<string> {
return client.request<string>('blockchain.transaction.broadcast', [rawTxHex]);
}
// Chain tip — height + block header hex — for freshness checks.
export async function getChainTip(
client: ElectrumClient,
): Promise<{ height: number; headerHex: string }> {
const raw = await client.request<{ height: number; hex: string }>('blockchain.headers.subscribe');
return { height: raw.height, headerHex: raw.hex };
}

View file

@ -0,0 +1,19 @@
import { createHash } from 'node:crypto';
import { address as bitcoinAddress } from 'bitcoinjs-lib';
import { digibyte, digibyteLegacyP2SH, type Network } from '@dgb-wallet/core';
// Electrum indexes UTXOs by "scripthash", not by address. For a given
// address, the scripthash is: sha256(scriptPubKey) with the resulting
// 32-byte hash BYTE-REVERSED, then hex-encoded. Reversal is because
// Electrum inherits Bitcoin's little-endian txid/scripthash convention.
export function scripthashOf(address: string, network: Network = digibyte): string {
let scriptPubKey: Buffer;
try {
scriptPubKey = bitcoinAddress.toOutputScript(address, network);
} catch {
// Might be a legacy-P2SH ("3..." style) DGB address; retry.
scriptPubKey = bitcoinAddress.toOutputScript(address, digibyteLegacyP2SH);
}
const hash = createHash('sha256').update(scriptPubKey).digest();
return Buffer.from(hash).reverse().toString('hex');
}

View file

@ -0,0 +1,19 @@
// Publicly known DigiByte Electrum servers. All community-run and
// unaffiliated with this project — availability varies. The client
// tries them in order; failures fall through to the next entry.
//
// Update this list from
// github.com/spesmilo/electrum-doc / DigiByte community forums
// when a peer goes offline for good.
export interface ElectrumServer {
host: string;
port: number;
protocol: 'ssl' | 'tcp';
}
export const DEFAULT_SERVERS: ElectrumServer[] = [
{ host: 'electrum1.cyberbits.eu', port: 50022, protocol: 'ssl' },
{ host: 'electrum2.cyberbits.eu', port: 50022, protocol: 'ssl' },
{ host: 'electrum3.cyberbits.eu', port: 50022, protocol: 'ssl' },
{ host: 'electrum4.cyberbits.eu', port: 50022, protocol: 'ssl' },
];

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "scripts"]
}

View file

@ -0,0 +1,27 @@
{
"name": "@dgb-wallet/keystore",
"version": "0.0.1",
"description": "Encrypted-at-rest seed storage. WebCrypto (PBKDF2 + AES-GCM) over a pluggable Storage adapter — localStorage in the browser, expo-secure-store on mobile, in-memory in tests.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"test": "tsx scripts/roundtrip.ts"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,59 @@
// Smoke test — encrypt a seed, decrypt it back, negative-test a wrong
// passphrase, exercise the Keystore class over InMemoryAdapter.
//
// npm test -w @dgb-wallet/keystore
import { Keystore, InMemoryAdapter, encryptString, decryptString } from '../src/index.js';
const SEED = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const PASS = 'correct horse battery staple';
async function main(): Promise<void> {
// 1. Raw encrypt/decrypt round-trip.
const t0 = Date.now();
const blob = await encryptString(SEED, PASS);
const encMs = Date.now() - t0;
const back = await decryptString(blob, PASS);
const decMs = Date.now() - t0 - encMs;
if (back !== SEED) throw new Error('round-trip mismatch');
console.log(`✅ round-trip: encrypt=${encMs}ms decrypt=${decMs}ms (PBKDF2 iter=${blob.kdf.iter})`);
// 2. Wrong passphrase rejected.
try {
await decryptString(blob, 'wrong passphrase');
throw new Error('wrong passphrase should have failed');
} catch (e) {
if (!(e as Error).message.includes('decrypt failed')) throw e;
console.log(`✅ wrong passphrase → ${(e as Error).message}`);
}
// 3. Keystore save/list/unlock/delete over in-memory adapter.
const ks = new Keystore(new InMemoryAdapter());
await ks.save('primary', SEED, PASS);
const list = await ks.list();
if (list.length !== 1 || list[0].label !== 'primary') throw new Error('list mismatch');
const unlocked = await ks.unlock('primary', PASS);
if (unlocked !== SEED) throw new Error('unlock mismatch');
await ks.delete('primary');
if ((await ks.list()).length !== 0) throw new Error('delete failed');
console.log('✅ keystore save/list/unlock/delete');
// 4. Overwrite guard.
await ks.save('primary', SEED, PASS);
try {
await ks.save('primary', 'different seed', PASS);
throw new Error('overwrite guard failed to trigger');
} catch (e) {
if (!(e as Error).message.includes('already exists')) throw e;
console.log(`✅ overwrite guard → ${(e as Error).message}`);
}
await ks.save('primary', 'newer', PASS, true);
const overwritten = await ks.unlock('primary', PASS);
if (overwritten !== 'newer') throw new Error('overwrite=true did not replace');
console.log('✅ overwrite=true replaces existing');
}
main().catch((e) => {
console.error('❌', e);
process.exit(1);
});

View file

@ -0,0 +1,125 @@
// Passphrase-based encryption using WebCrypto. Standard PBKDF2 + AES-GCM
// — the same shape as Ethereum's V3 keystore, MetaMask's vault, and
// Bitcoin Core's wallet encryption. No dependencies; works in browser,
// Node 20+, and React Native 0.74+ (Hermes / RN core provide SubtleCrypto).
//
// Blob format (JSON, versioned so we can migrate later):
// {
// "v": 1,
// "kdf": { "algo": "PBKDF2", "hash": "SHA-256",
// "salt": "<hex>", "iter": 210000 },
// "enc": { "algo": "AES-GCM", "iv": "<hex>",
// "ct": "<hex — ciphertext + 16-byte auth tag> " }
// }
//
// 210,000 iterations follows OWASP 2023's recommendation for
// PBKDF2-SHA256; each additional factor of 10 buys ~one bit of
// resistance for the same wall-clock cost on the user.
export const KEYSTORE_VERSION = 1;
const PBKDF2_ITERATIONS = 210_000;
const SALT_LEN = 16;
const IV_LEN = 12;
export interface EncryptedBlob {
v: number;
kdf: { algo: 'PBKDF2'; hash: 'SHA-256'; salt: string; iter: number };
enc: { algo: 'AES-GCM'; iv: string; ct: string };
}
// Encrypt a UTF-8 string (typically a seed phrase) with the passphrase.
// Every call generates a fresh salt + IV — never reuse. Returns a
// self-describing JSON-serializable blob.
export async function encryptString(plaintext: string, passphrase: string): Promise<EncryptedBlob> {
const enc = new TextEncoder();
const salt = randomBytes(SALT_LEN);
const iv = randomBytes(IV_LEN);
const aesKey = await deriveKey(passphrase, salt);
const ctBuf = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
aesKey,
enc.encode(plaintext),
);
return {
v: KEYSTORE_VERSION,
kdf: { algo: 'PBKDF2', hash: 'SHA-256', salt: toHex(salt), iter: PBKDF2_ITERATIONS },
enc: { algo: 'AES-GCM', iv: toHex(iv), ct: toHex(new Uint8Array(ctBuf)) },
};
}
// Decrypt a blob produced by encryptString. Throws a specific error
// on wrong passphrase (AES-GCM auth tag mismatch surfaces as a generic
// DOMException — we translate it into something the UI can display).
export async function decryptString(blob: EncryptedBlob, passphrase: string): Promise<string> {
assertSupportedBlob(blob);
const salt = fromHex(blob.kdf.salt);
const iv = fromHex(blob.enc.iv);
const ct = fromHex(blob.enc.ct);
const aesKey = await deriveKey(passphrase, salt, blob.kdf.iter);
let ptBuf: ArrayBuffer;
try {
ptBuf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, aesKey, ct);
} catch {
// WebCrypto AES-GCM throws a generic OperationError on tag mismatch
// — same signal for wrong passphrase and for tampered ciphertext.
// We can't distinguish the two, so we say "wrong passphrase or
// corrupted blob" and leave forensics to logs.
throw new Error('decrypt failed — wrong passphrase or corrupted keystore blob');
}
return new TextDecoder().decode(ptBuf);
}
function assertSupportedBlob(blob: EncryptedBlob): void {
if (blob.v !== KEYSTORE_VERSION) {
throw new Error(`unsupported keystore version ${blob.v} (this build understands v${KEYSTORE_VERSION})`);
}
if (blob.kdf.algo !== 'PBKDF2' || blob.kdf.hash !== 'SHA-256') {
throw new Error(`unsupported KDF ${blob.kdf.algo}/${blob.kdf.hash}`);
}
if (blob.enc.algo !== 'AES-GCM') {
throw new Error(`unsupported cipher ${blob.enc.algo}`);
}
}
async function deriveKey(
passphrase: string,
salt: Uint8Array<ArrayBuffer>,
iterations = PBKDF2_ITERATIONS,
): Promise<CryptoKey> {
const raw = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
{ name: 'PBKDF2' },
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
raw,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
// Byte buffers explicitly backed by ArrayBuffer (never SharedArrayBuffer)
// so the resulting Uint8Array type is `Uint8Array<ArrayBuffer>`, which
// satisfies BufferSource under TS 5.6+ lib.dom.
function randomBytes(n: number): Uint8Array<ArrayBuffer> {
const out = new Uint8Array(new ArrayBuffer(n));
crypto.getRandomValues(out);
return out;
}
function toHex(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += b.toString(16).padStart(2, '0');
return s;
}
function fromHex(hex: string): Uint8Array<ArrayBuffer> {
const clean = hex.length % 2 === 0 ? hex : `0${hex}`;
const out = new Uint8Array(new ArrayBuffer(clean.length / 2));
for (let i = 0; i < out.length; i++) out[i] = parseInt(clean.substr(i * 2, 2), 16);
return out;
}

View file

@ -0,0 +1,3 @@
export * from './encrypt.js';
export * from './storage.js';
export * from './keystore.js';

View file

@ -0,0 +1,88 @@
import type { StorageAdapter } from './storage.js';
import { encryptString, decryptString, type EncryptedBlob } from './encrypt.js';
// Namespace under which encrypted seed blobs live in the adapter's
// keyspace. Adapters may hold unrelated things (settings, UI state)
// — a prefix keeps Keystore.list from returning them.
const NS = 'dgb-wallet:seed:';
export interface StoredSeed {
label: string;
createdAt: number;
}
// Passphrase-encrypted seed vault. One instance per adapter.
//
// The class is stateless w.r.t. plaintext — passphrases and decrypted
// seeds are never held on the instance. That means every read costs a
// PBKDF2 (~500ms on a laptop, more on a phone), which is exactly the
// resistance we want: it also gates offline brute-force at the same
// cost per guess.
export class Keystore {
constructor(private readonly storage: StorageAdapter) {}
// Encrypt `seedPhrase` with `passphrase` and persist under `label`.
// Fails if a seed already exists at `label` unless overwrite=true —
// we don't want a slip in the UI to silently clobber a saved seed.
async save(label: string, seedPhrase: string, passphrase: string, overwrite = false): Promise<void> {
const key = NS + label;
if (!overwrite) {
const existing = await this.storage.get(key);
if (existing) throw new Error(`keystore: "${label}" already exists (pass overwrite=true to replace)`);
}
const blob = await encryptString(seedPhrase, passphrase);
const payload = JSON.stringify({ ...blob, meta: { label, createdAt: Date.now() } });
await this.storage.put(key, payload);
}
// Decrypt and return the seed phrase for `label`. Throws on wrong
// passphrase or unknown label.
async unlock(label: string, passphrase: string): Promise<string> {
const raw = await this.storage.get(NS + label);
if (!raw) throw new Error(`keystore: no seed named "${label}"`);
const blob = JSON.parse(raw) as EncryptedBlob & { meta?: { label: string; createdAt: number } };
return decryptString(blob, passphrase);
}
// Enumerate saved seeds without decrypting them — cheap.
async list(): Promise<StoredSeed[]> {
const keys = await this.storage.list(NS);
const out: StoredSeed[] = [];
for (const k of keys) {
const raw = await this.storage.get(k);
if (!raw) continue;
try {
const blob = JSON.parse(raw) as { meta?: { label: string; createdAt: number } };
const label = k.substring(NS.length);
out.push({ label, createdAt: blob.meta?.createdAt ?? 0 });
} catch {
// Skip malformed entries silently — probably not ours.
}
}
return out.sort((a, b) => b.createdAt - a.createdAt);
}
async delete(label: string): Promise<void> {
await this.storage.delete(NS + label);
}
// Rename = re-key the same encrypted blob. The ciphertext is untouched
// (the label is not part of the encryption), only the storage key and
// the embedded meta.label change. New label must be free.
async rename(oldLabel: string, newLabel: string): Promise<void> {
const next = newLabel.trim();
if (!next) throw new Error('keystore: new label is empty');
if (next === oldLabel) return;
const raw = await this.storage.get(NS + oldLabel);
if (!raw) throw new Error(`keystore: no seed named "${oldLabel}"`);
if (await this.storage.get(NS + next)) throw new Error(`keystore: "${next}" already exists`);
const blob = JSON.parse(raw) as EncryptedBlob & { meta?: { label: string; createdAt: number } };
blob.meta = { label: next, createdAt: blob.meta?.createdAt ?? Date.now() };
await this.storage.put(NS + next, JSON.stringify(blob));
await this.storage.delete(NS + oldLabel);
}
async exists(label: string): Promise<boolean> {
return (await this.storage.get(NS + label)) !== null;
}
}

View file

@ -0,0 +1,51 @@
// Storage adapter — an intentionally tiny surface so we can plug in
// whatever the host platform gives us for at-rest bytes:
// - browser → LocalStorageAdapter (uses window.localStorage)
// - RN → SecureStoreAdapter (expo-secure-store, wired later)
// - Node/CLI → NodeFsAdapter (writes JSON files under a dir)
// - tests → InMemoryAdapter (Map-backed)
//
// The keystore itself doesn't care which one it got — it just persists
// EncryptedBlob JSON strings keyed by user-chosen label.
export interface StorageAdapter {
readonly name: string;
get(key: string): Promise<string | null>;
put(key: string, value: string): Promise<void>;
delete(key: string): Promise<void>;
list(prefix?: string): Promise<string[]>;
}
// Pure in-memory adapter, for tests and CLIs that don't want to touch
// disk. Also useful as a default when a platform adapter isn't ready.
export class InMemoryAdapter implements StorageAdapter {
readonly name = 'memory';
private readonly map = new Map<string, string>();
async get(key: string): Promise<string | null> { return this.map.get(key) ?? null; }
async put(key: string, value: string): Promise<void> { this.map.set(key, value); }
async delete(key: string): Promise<void> { this.map.delete(key); }
async list(prefix = ''): Promise<string[]> {
return [...this.map.keys()].filter((k) => k.startsWith(prefix));
}
}
// Browser adapter over window.localStorage. Synchronous under the hood
// but we present the async interface so consumers can swap in a real
// async backend (IndexedDB, expo-secure-store) without rewrites.
export class LocalStorageAdapter implements StorageAdapter {
readonly name = 'localStorage';
constructor(private readonly ls: Storage = globalThis.localStorage) {
if (!this.ls) throw new Error('LocalStorageAdapter: no localStorage available in this environment');
}
async get(key: string): Promise<string | null> { return this.ls.getItem(key); }
async put(key: string, value: string): Promise<void> { this.ls.setItem(key, value); }
async delete(key: string): Promise<void> { this.ls.removeItem(key); }
async list(prefix = ''): Promise<string[]> {
const out: string[] = [];
for (let i = 0; i < this.ls.length; i++) {
const k = this.ls.key(i);
if (k && k.startsWith(prefix)) out.push(k);
}
return out;
}
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true,
"lib": ["ES2022", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "scripts"]
}

7
packages/mobile/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
node_modules/
.expo/
dist/
web-build/
*.log
.metro-health-check*
.DS_Store

114
packages/mobile/README.md Normal file
View file

@ -0,0 +1,114 @@
# @dgb-wallet/mobile
DigiByte.X Wallet — React Native (Expo) app. Ports the same recovery-first UI as `@dgb-wallet/web-ui` using React Native primitives (`View`/`Text`/`Pressable` instead of `div`/`button`).
Shares the same crypto core via the monorepo workspace — no re-implementation of address derivation or PSBT signing.
## Prerequisites
- Node 20+ (already installed)
- **Android Studio** with:
- Android SDK (API 34+ recommended)
- An Android emulator device (e.g., Pixel 6 running Android 14)
- `ANDROID_HOME` env var set (usually `%LOCALAPPDATA%\Android\Sdk`)
- Java JDK 17 (Android Studio bundles one)
## First run
```powershell
cd "D:\Dev\SilentCode\Digibyte"
# 1. Install (installs Expo + RN + polyfills in packages/mobile)
npm install
# 2. Start Android emulator from Android Studio (Device Manager > play)
# 3. Boot the app
npm run android -w @dgb-wallet/mobile
```
First launch takes ~5 min: Metro bundles ~2000 modules, Expo compiles the dev client, APK installs on emulator. Subsequent launches are seconds.
## What's in the box (v0.0.2)
- **Three tabs**, bottom navigation via `@react-navigation/bottom-tabs`:
- **Import** — paste seed, live BIP39 validation, derive all 5 descriptor paths, show first 5 addresses per path.
- **Balance** — derive BIP84 xpub, POST to the wallet daemon at `http://10.0.2.2:5174/balance` (Android emulator alias for host localhost), show totalSats + UTXO list + filter/block/timing stats.
- **Send** — build & sign a BIP84 P2WPKH tx, broadcast via daemon; toggle between Dandelion++ (default) and direct fan-out.
Balance and Send both talk to `@dgb-wallet/p2p`'s daemon over HTTP — run it in a separate terminal before opening those tabs:
```powershell
npm run daemon -w @dgb-wallet/p2p
```
For an iOS simulator, edit `src/rpc-native.ts` and switch `DAEMON_URL` to `http://127.0.0.1:5174` — the `10.0.2.2` alias is Android-emulator-only.
## Verified state
- `tsc --noEmit` passes cleanly on `packages/mobile` (once `npm install` has run at the repo root — the `@react-navigation/*`, `react-native-screens`, and `react-native-safe-area-context` deps hoist into `packages/mobile/node_modules`).
- The daemon HTTP contract that `src/rpc-native.ts` calls (`POST /balance` with `{xpub, purpose, account, gapLimit}`, `POST /broadcast` with `{hex}`) is verified end-to-end via the `@dgb-wallet/web-ui` package, which hits the same daemon endpoints with the same crypto core. The mobile screens are a straight React Native port of those flows.
- **Emulator boot is blocked on Intel VT-x being disabled in BIOS** on the current dev machine — see next section.
## Requires: hardware virtualization
Android x86 emulators need a hypervisor. On Windows that means either **AEHD** (Android Emulator Hypervisor Driver, HAXM's successor) or **WHPX** (Windows Hypervisor Platform). Both silently fall over if Intel VT-x / AMD-V is off in firmware.
**Check first:**
```powershell
Get-CimInstance Win32_Processor | Select-Object Name, VirtualizationFirmwareEnabled, SecondLevelAddressTranslationExtensions
```
If `VirtualizationFirmwareEnabled` is `False`, no software fix will work — reboot into BIOS/UEFI, find "Intel Virtualization Technology" / "VT-x" (typically under Advanced → CPU Configuration or Security), enable it, save, boot back to Windows.
**Then install AEHD (already downloaded with the SDK):**
```powershell
cd "$env:LOCALAPPDATA\Android\Sdk\extras\google\Android_Emulator_Hypervisor_Driver"
.\silent_install.bat # runs elevated via UAC
sc query aehd # STATE should say RUNNING
```
If AEHD's service is already registered but stops with `sc start aehd` returning `error 4294967201` (`ERROR_NOT_SUPPORTED`), that's the VT-x-off symptom — driver installed fine, but the CPU refuses to enter root mode. Fix in BIOS.
**Then boot the emulator normally:**
```powershell
& "$env:LOCALAPPDATA\Android\Sdk\emulator\emulator.exe" -avd Pixel_3a_API_30_x86
```
## Crypto polyfills — the non-obvious bit
React Native has no `crypto` or `Buffer` globals like Node/browser. This app plugs those gaps via:
- `react-native-quick-crypto` — implements Node's `crypto` API using native code (fast, correct)
- `buffer` npm package — Buffer polyfill
- `react-native-get-random-values` — polyfills `crypto.getRandomValues` used by `bip39.generateMnemonic`
All wired up in `index.js` (loads BEFORE any app code) and `metro.config.js` (aliases so bitcoinjs-lib et al find them).
## Layout
```
packages/mobile/
├── app.json Expo config (name, package, orientation)
├── babel.config.js babel-preset-expo
├── index.js Entry — installs crypto polyfill THEN app
├── metro.config.js Monorepo + Node-shim resolution
├── src/
│ ├── App.tsx Root (header + bottom-tab navigation)
│ ├── ImportScreen.tsx Recovery UX in RN primitives
│ ├── BalanceScreen.tsx P2P daemon /balance client
│ ├── SendScreen.tsx P2P build+sign+broadcast (Dandelion default)
│ ├── derive.ts Same as web-ui/src/derive.ts (kept parallel for now)
│ ├── rpc-native.ts Fetch helpers pointed at 10.0.2.2:5174
│ └── styles.ts Shared StyleSheet across screens
└── tsconfig.json extends expo's base config
```
## Known limitations at this scaffold stage
- **No on-device run yet.** Balance/Send tabs are wired against the daemon HTTP contract that the browser web-ui exercises today, but they haven't run on an actual Android surface — that's blocked by VT-x on this workstation (see previous section). A physical Android phone with USB debugging would unblock the same verification without touching BIOS.
- No secure keystore integration yet — imported seeds live in component state only. Production will use `expo-secure-store` (Android Keystore / iOS Keychain).
- Send tab uses BIP84 only, single account, flat 5000-sat fee (same demo scope as web-ui).

15
packages/mobile/app.json Normal file
View file

@ -0,0 +1,15 @@
{
"expo": {
"name": "DigiByte.X Wallet",
"slug": "digibyte-x-wallet",
"version": "0.0.1",
"orientation": "portrait",
"userInterfaceStyle": "dark",
"assetBundlePatterns": ["**/*"],
"android": {
"package": "dgb.x.wallet",
"adaptiveIcon": { "backgroundColor": "#0a0e1a" }
},
"ios": { "bundleIdentifier": "dgb.x.wallet", "supportsTablet": false }
}
}

View file

@ -0,0 +1,6 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
};
};

7
packages/mobile/index.js Normal file
View file

@ -0,0 +1,7 @@
// Entry — must load polyfills BEFORE anything imports crypto libs.
import 'react-native-get-random-values';
import { install } from 'react-native-quick-crypto';
install();
import { registerRootComponent } from 'expo';
import App from './src/App';
registerRootComponent(App);

View file

@ -0,0 +1,30 @@
// Metro (React Native bundler) needs help resolving:
// 1. Workspace packages (@dgb-wallet/*) — enable symlink traversal
// 2. Node built-ins (crypto, buffer, stream) used by bitcoinjs-lib —
// shim them to browser-compatible replacements
const path = require('path');
const { getDefaultConfig } = require('expo/metro-config');
const projectRoot = __dirname;
const monorepoRoot = path.resolve(projectRoot, '../..');
const config = getDefaultConfig(projectRoot);
// Watch the monorepo so workspace packages hot-reload.
config.watchFolders = [monorepoRoot];
// Resolve modules from both the app's node_modules and the monorepo root.
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, 'node_modules'),
path.resolve(monorepoRoot, 'node_modules'),
];
config.resolver.disableHierarchicalLookup = true;
// Polyfills for Node built-ins used by bitcoinjs-lib et al.
config.resolver.extraNodeModules = {
crypto: require.resolve('react-native-quick-crypto'),
buffer: require.resolve('buffer'),
stream: require.resolve('readable-stream'),
};
module.exports = config;

View file

@ -0,0 +1,39 @@
{
"name": "@dgb-wallet/mobile",
"version": "0.0.1",
"description": "DigiByte.X Wallet — React Native (Expo) app. Shares crypto core with the web UI; ports the same screens using React Native primitives.",
"type": "module",
"private": true,
"main": "index.js",
"scripts": {
"start": "expo start",
"android": "expo start --android",
"ios": "expo start --ios",
"web": "expo start --web"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.1.1",
"@dgb-wallet/core": "*",
"@dgb-wallet/psbt": "*",
"@dgb-wallet/rpc": "*",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
"bitcoinjs-lib": "^6.1.7",
"buffer": "^6.0.3",
"ecpair": "^2.1.0",
"expo": "~51.0.0",
"expo-status-bar": "~1.12.1",
"react": "18.2.0",
"react-native": "0.74.5",
"react-native-gesture-handler": "~2.16.1",
"react-native-get-random-values": "~1.11.0",
"react-native-quick-crypto": "^0.7.0",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@types/react": "~18.2.79",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,85 @@
import { SafeAreaView, StatusBar, View, Text, StyleSheet } from 'react-native';
import { NavigationContainer } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import { ImportScreen } from './ImportScreen';
import { BalanceScreen } from './BalanceScreen';
import { SendScreen } from './SendScreen';
const Tab = createBottomTabNavigator();
export default function App(): JSX.Element {
return (
<SafeAreaView style={styles.root}>
<StatusBar barStyle="light-content" backgroundColor="#0a0e1a" />
<View style={styles.header}>
<View style={{ flex: 1 }}>
<Text style={styles.title}>DigiByte.X Wallet</Text>
<Text style={styles.tagline}>recovery-first · privacy by default</Text>
</View>
<View style={styles.badge}>
<Text style={styles.badgeText}>🛡 Local</Text>
</View>
</View>
<NavigationContainer theme={NAV_THEME}>
<Tab.Navigator
screenOptions={{
headerShown: false,
tabBarStyle: styles.tabBar,
tabBarActiveTintColor: '#3b82f6',
tabBarInactiveTintColor: '#94a3b8',
tabBarLabelStyle: { fontSize: 12 },
}}
>
<Tab.Screen name="Import" component={ImportScreen} />
<Tab.Screen name="Balance" component={BalanceScreen} />
<Tab.Screen name="Send" component={SendScreen} />
</Tab.Navigator>
</NavigationContainer>
</SafeAreaView>
);
}
const NAV_THEME = {
dark: true,
colors: {
primary: '#3b82f6',
background: '#0a0e1a',
card: '#0a0e1a',
text: '#e2e8f0',
border: '#1e293b',
notification: '#3b82f6',
},
fonts: {
regular: { fontFamily: 'System', fontWeight: '400' as const },
medium: { fontFamily: 'System', fontWeight: '500' as const },
bold: { fontFamily: 'System', fontWeight: '700' as const },
heavy: { fontFamily: 'System', fontWeight: '900' as const },
},
};
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: '#0a0e1a' },
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#1e293b',
},
title: { color: '#e2e8f0', fontSize: 17, fontWeight: '600' },
tagline: { color: '#94a3b8', fontSize: 13, marginTop: 2 },
badge: {
backgroundColor: 'rgba(16, 185, 129, 0.15)',
borderColor: 'rgba(16, 185, 129, 0.3)',
borderWidth: 1,
borderRadius: 100,
paddingHorizontal: 10,
paddingVertical: 4,
},
badgeText: { color: '#10b981', fontSize: 11 },
tabBar: {
backgroundColor: '#131a2b',
borderTopColor: '#1e293b',
borderTopWidth: 1,
},
});

View file

@ -0,0 +1,126 @@
import { useState } from 'react';
import { View, Text, TextInput, Pressable, ScrollView, ActivityIndicator } from 'react-native';
import {
seedFromPhrase,
validateSeedPhrase,
rootFromSeed,
accountNode,
accountXpub as toXpub,
} from '@dgb-wallet/core';
import { p2pBalance, type P2PBalanceResponse } from './rpc-native';
import { styles } from './styles';
type Status =
| { kind: 'idle' }
| { kind: 'scanning'; msg: string }
| { kind: 'done'; result: P2PBalanceResponse }
| { kind: 'error'; msg: string };
export function BalanceScreen(): JSX.Element {
const [phrase, setPhrase] = useState('');
const [status, setStatus] = useState<Status>({ kind: 'idle' });
const trimmed = phrase.trim();
const ok = validateSeedPhrase(trimmed);
async function onScan() {
if (!ok) return;
setStatus({ kind: 'scanning', msg: 'Deriving BIP84 xpub…' });
try {
const seed = await seedFromPhrase(trimmed);
const root = rootFromSeed(seed);
const xpub = toXpub(accountNode(root, 84, 0));
setStatus({ kind: 'scanning', msg: 'Contacting P2P daemon; probing BIP158 filters…' });
const result = await p2pBalance({ xpub, purpose: 84, account: 0, gapLimit: 20 });
setStatus({ kind: 'done', result });
} catch (e) {
setStatus({ kind: 'error', msg: (e as Error).message });
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.tipBlock}>
<Text style={styles.tipText}>
<Text style={styles.tipStrong}>Balance scan (P2P): </Text>
derives your BIP84 xpub and asks the local wallet daemon to probe
BIP158 filters directly against DGB peers. No indexer sees your
addresses.
</Text>
</View>
<Text style={styles.label}>BIP39 seed phrase</Text>
<TextInput
style={styles.textarea}
placeholder="word1 word2 word3 ... word12"
placeholderTextColor="#475569"
value={phrase}
onChangeText={setPhrase}
multiline
autoCapitalize="none"
autoCorrect={false}
/>
{trimmed.length > 0 && !ok && <Text style={styles.errText}>Invalid BIP39 seed</Text>}
<Pressable
style={[styles.button, (!ok || status.kind === 'scanning') && styles.buttonDisabled]}
disabled={!ok || status.kind === 'scanning'}
onPress={onScan}
>
{status.kind === 'scanning'
? <ActivityIndicator color="white" />
: <Text style={styles.buttonText}>Scan balance</Text>}
</Pressable>
{status.kind === 'scanning' && <Text style={styles.tipText}>{status.msg}</Text>}
{status.kind === 'error' && <Text style={styles.errText}>{status.msg}</Text>}
{status.kind === 'done' && (
<View style={styles.results}>
<View style={styles.pathGroup}>
<Text style={styles.pathTitle}>Total balance</Text>
<Text style={styles.balanceBig}>
{(status.result.totalSats / 1e8).toFixed(8)} DGB
</Text>
<Text style={styles.pathDesc}>
blocks {status.result.scannedRange.startHeight.toLocaleString()}
–{status.result.scannedRange.endHeight.toLocaleString()}
{' · '}
{status.result.filterHits} hit{status.result.filterHits === 1 ? '' : 's'}
{' · '}
{status.result.blocksFetched} block{status.result.blocksFetched === 1 ? '' : 's'}
{' · '}
{status.result.elapsedMs}ms
</Text>
</View>
{status.result.utxos.length === 0 ? (
<View style={styles.tipBlock}>
<Text style={styles.tipText}>
No UTXOs found for this seed in the scanned window. For deeper
history, pass a lower startHeight (follow-up feature).
</Text>
</View>
) : (
<View style={styles.pathGroup}>
<Text style={styles.pathTitle}>UTXOs ({status.result.utxos.length})</Text>
{status.result.utxos.slice(0, 20).map((u, i) => (
<View key={i} style={styles.addrRow}>
<Text style={styles.addrIdx}>{u.vout}</Text>
<Text style={styles.addrText} numberOfLines={1}>
{u.txid.slice(0, 16)}… · {(u.value / 1e8).toFixed(8)} DGB
</Text>
</View>
))}
{status.result.utxos.length > 20 && (
<Text style={styles.pathDesc}>
and {status.result.utxos.length - 20} more…
</Text>
)}
</View>
)}
</View>
)}
</ScrollView>
);
}

View file

@ -0,0 +1,169 @@
import { useMemo, useState } from 'react';
import { View, Text, TextInput, Pressable, ScrollView, StyleSheet, ActivityIndicator } from 'react-native';
import { validateSeedPhrase } from '@dgb-wallet/core';
import { deriveAll, type DerivationGroup } from './derive';
const VALID_LENGTHS = [12, 15, 18, 21, 24];
export function ImportScreen(): JSX.Element {
const [phrase, setPhrase] = useState('');
const [groups, setGroups] = useState<DerivationGroup[] | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const trimmed = phrase.trim();
const wordCount = trimmed.length === 0 ? 0 : trimmed.split(/\s+/).length;
const lengthOk = VALID_LENGTHS.includes(wordCount);
const checksumOk = lengthOk && validateSeedPhrase(trimmed);
const status: { color: string; text: string } | null = useMemo(() => {
if (wordCount === 0) return null;
if (!lengthOk) return { color: '#f59e0b', text: `${wordCount} words — need one of ${VALID_LENGTHS.join(', ')}` };
if (!checksumOk) return { color: '#ef4444', text: 'BIP39 checksum failed' };
return { color: '#10b981', text: `Valid BIP39 seed (${wordCount} words)` };
}, [wordCount, lengthOk, checksumOk]);
async function onRecover() {
if (!checksumOk) return;
setBusy(true); setErr(null); setGroups(null);
try {
setGroups(await deriveAll(trimmed));
} catch (e) {
setErr((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.warningBlock}>
<Text style={styles.warningText}>
⚠️ Never paste a real-funds seed on a device you don't fully trust. Derivation
runs locally, but a compromised OS could log keystrokes.
</Text>
</View>
<View style={styles.tipBlock}>
<Text style={styles.tipText}>
<Text style={styles.tipStrong}>What this does: </Text>
derives your DigiByte addresses at every standard path (BIP44/49/84/86) so you
can find funds no matter which wallet created the seed.
</Text>
</View>
<Text style={styles.label}>BIP39 seed phrase</Text>
<TextInput
style={styles.textarea}
placeholder="word1 word2 word3 ... word12"
placeholderTextColor="#475569"
value={phrase}
onChangeText={setPhrase}
multiline
autoCapitalize="none"
autoCorrect={false}
/>
{status && <Text style={[styles.statusText, { color: status.color }]}>{status.text}</Text>}
<Pressable
style={[styles.button, (!checksumOk || busy) && styles.buttonDisabled]}
disabled={!checksumOk || busy}
onPress={onRecover}
>
{busy ? <ActivityIndicator color="white" /> : <Text style={styles.buttonText}>Recover addresses</Text>}
</Pressable>
{err && <Text style={styles.errText}>{err}</Text>}
{groups && (
<View style={styles.results}>
{groups.map((g, i) => (
<View key={i} style={styles.pathGroup}>
<Text style={styles.pathTitle}>{g.label}</Text>
<Text style={styles.pathDesc}>{g.descriptor}</Text>
{g.addresses.map((a) => (
<View key={a.index} style={styles.addrRow}>
<Text style={styles.addrIdx}>[{a.index}]</Text>
<Text style={styles.addrText}>{a.address}</Text>
</View>
))}
</View>
))}
</View>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0a0e1a' },
content: { padding: 20, paddingBottom: 40 },
warningBlock: {
backgroundColor: 'rgba(245, 158, 11, 0.1)',
borderWidth: 1,
borderColor: 'rgba(245, 158, 11, 0.3)',
borderRadius: 10,
padding: 12,
marginBottom: 12,
},
warningText: { color: '#f59e0b', fontSize: 13, lineHeight: 20 },
tipBlock: {
backgroundColor: 'rgba(59, 130, 246, 0.08)',
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.25)',
borderRadius: 10,
padding: 12,
marginBottom: 16,
},
tipText: { color: '#94a3b8', fontSize: 13, lineHeight: 20 },
tipStrong: { color: '#e2e8f0', fontWeight: '500' },
label: {
color: '#94a3b8',
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
},
textarea: {
backgroundColor: '#131a2b',
color: '#e2e8f0',
borderWidth: 1,
borderColor: '#1e293b',
borderRadius: 10,
padding: 14,
fontFamily: 'monospace',
fontSize: 14,
minHeight: 100,
textAlignVertical: 'top',
},
statusText: { fontSize: 12, marginTop: 6 },
button: {
backgroundColor: '#3b82f6',
borderRadius: 10,
padding: 14,
alignItems: 'center',
marginTop: 14,
},
buttonDisabled: { opacity: 0.4 },
buttonText: { color: 'white', fontSize: 15, fontWeight: '500' },
errText: { color: '#ef4444', fontSize: 13, marginTop: 12 },
results: { marginTop: 20, gap: 16 },
pathGroup: {
backgroundColor: '#131a2b',
borderWidth: 1,
borderColor: '#1e293b',
borderRadius: 10,
padding: 14,
},
pathTitle: { color: '#e2e8f0', fontSize: 13, fontWeight: '600', marginBottom: 4 },
pathDesc: { color: '#94a3b8', fontFamily: 'monospace', fontSize: 11, marginBottom: 10 },
addrRow: {
flexDirection: 'row',
gap: 8,
paddingVertical: 6,
borderTopWidth: 1,
borderTopColor: '#1e293b',
},
addrIdx: { color: '#94a3b8', fontFamily: 'monospace', fontSize: 11, minWidth: 24, textAlign: 'right' },
addrText: { color: '#e2e8f0', fontFamily: 'monospace', fontSize: 11, flex: 1 },
});

View file

@ -0,0 +1,225 @@
import { useState } from 'react';
import { View, Text, TextInput, Pressable, ScrollView, ActivityIndicator } from 'react-native';
import {
seedFromPhrase,
validateSeedPhrase,
rootFromSeed,
accountNode,
accountXpub as toXpub,
addressNode,
p2wpkhAddress,
digibyte,
} from '@dgb-wallet/core';
import { buildPsbt, finalizeAndExtract, type Utxo } from '@dgb-wallet/psbt';
import { payments } from 'bitcoinjs-lib';
import { ECPairFactory } from 'ecpair';
import * as ecc from '@bitcoinerlab/secp256k1';
import { p2pBalance, p2pBroadcast, type P2PBroadcastResponse } from './rpc-native';
import { styles } from './styles';
const ECPair = ECPairFactory(ecc);
const FIXED_FEE_SATS = 5000;
const GAP = 20;
type Status =
| { kind: 'idle' }
| { kind: 'busy'; msg: string }
| { kind: 'signed'; hex: string; txid: string; fee: number }
| { kind: 'broadcast'; res: P2PBroadcastResponse }
| { kind: 'error'; msg: string };
export function SendScreen(): JSX.Element {
const [phrase, setPhrase] = useState('');
const [dest, setDest] = useState('');
const [amtDgb, setAmtDgb] = useState('');
const [useDandelion, setUseDandelion] = useState(true);
const [status, setStatus] = useState<Status>({ kind: 'idle' });
const okSeed = validateSeedPhrase(phrase.trim());
const okDest = dest.trim().length > 20;
const okAmt = Number(amtDgb) > 0;
const ready = okSeed && okDest && okAmt;
async function onSign() {
if (!ready) return;
setStatus({ kind: 'busy', msg: 'Deriving keys and scanning P2P for UTXOs…' });
try {
const seed = await seedFromPhrase(phrase.trim());
const root = rootFromSeed(seed);
const acct = accountNode(root, 84, 0);
const xpub = toXpub(acct);
// Pre-derive every candidate script → private key so we can look
// up each returned UTXO by its scriptPubKey hex in O(1).
const spkToKey = new Map<string, Buffer>();
const spkToAddr = new Map<string, string>();
for (const ch of [0, 1] as const) {
for (let idx = 0; idx < GAP; idx++) {
const node = addressNode(acct, ch, idx);
const pubkey = Buffer.from(node.publicKey);
const spk = payments.p2wpkh({ pubkey, network: digibyte }).output;
if (!spk || !node.privateKey) continue;
const spkHex = Buffer.from(spk).toString('hex');
spkToKey.set(spkHex, Buffer.from(node.privateKey));
spkToAddr.set(spkHex, p2wpkhAddress(node));
}
}
const scan = await p2pBalance({ xpub, purpose: 84, account: 0, gapLimit: GAP });
if (scan.utxos.length === 0) {
throw new Error('No spendable UTXOs on any BIP84 address for this seed.');
}
const amtSats = Math.round(Number(amtDgb) * 1e8);
const utxosSorted = [...scan.utxos].sort((a, b) => b.value - a.value);
const inputs: Utxo[] = [];
const inputKeys: Buffer[] = [];
let inputSats = 0;
for (const u of utxosSorted) {
const key = spkToKey.get(u.scriptPubKey);
const addr = spkToAddr.get(u.scriptPubKey);
if (!key || !addr) continue;
inputs.push({
txid: u.txid,
vout: u.vout,
value: u.value,
address: addr,
scriptPubKey: u.scriptPubKey,
witness: { scriptHex: u.scriptPubKey, value: u.value },
});
inputKeys.push(key);
inputSats += u.value;
if (inputSats >= amtSats + FIXED_FEE_SATS) break;
}
if (inputSats < amtSats + FIXED_FEE_SATS) {
throw new Error(`Insufficient funds: have ${(inputSats / 1e8).toFixed(8)}, need ${((amtSats + FIXED_FEE_SATS) / 1e8).toFixed(8)}`);
}
setStatus({ kind: 'busy', msg: 'Building & signing PSBT…' });
const changeAmt = inputSats - amtSats - FIXED_FEE_SATS;
const changeAddr = p2wpkhAddress(addressNode(acct, 1, 0));
const psbt = buildPsbt({
inputs,
outputs: [
{ address: dest.trim(), value: amtSats },
...(changeAmt > 546 ? [{ address: changeAddr, value: changeAmt }] : []),
],
});
for (const [i, key] of inputKeys.entries()) {
const kp = ECPair.fromPrivateKey(key, { network: digibyte });
psbt.signInput(i, kp);
}
const { hex, txid } = finalizeAndExtract(psbt);
setStatus({ kind: 'signed', hex, txid, fee: FIXED_FEE_SATS });
} catch (e) {
setStatus({ kind: 'error', msg: (e as Error).message });
}
}
async function onBroadcast() {
if (status.kind !== 'signed') return;
setStatus({ kind: 'busy', msg: `Broadcasting${useDandelion ? ' (Dandelion++)' : ''}…` });
try {
const res = await p2pBroadcast(status.hex, useDandelion);
setStatus({ kind: 'broadcast', res });
} catch (e) {
setStatus({ kind: 'error', msg: (e as Error).message });
}
}
return (
<ScrollView style={styles.container} contentContainerStyle={styles.content}>
<View style={styles.warningBlock}>
<Text style={styles.warningText}>
⚠️ Demo scope: BIP84 only, single account, flat 5000-sat fee. Never paste a
real-funds seed on an untrusted device.
</Text>
</View>
<Text style={styles.label}>BIP39 seed</Text>
<TextInput
style={styles.textarea}
placeholder="word1 word2 ... word12"
placeholderTextColor="#475569"
value={phrase} onChangeText={setPhrase}
multiline autoCapitalize="none" autoCorrect={false}
/>
<Text style={styles.label}>Recipient (dgb1q… address)</Text>
<TextInput
style={styles.textInput}
placeholder="dgb1q..." placeholderTextColor="#475569"
value={dest} onChangeText={setDest}
autoCapitalize="none" autoCorrect={false}
/>
<Text style={styles.label}>Amount (DGB)</Text>
<TextInput
style={styles.textInput}
placeholder="0.0" placeholderTextColor="#475569"
keyboardType="decimal-pad"
value={amtDgb} onChangeText={setAmtDgb}
/>
<Text style={styles.tipText}>Fee is a flat {(FIXED_FEE_SATS / 1e8).toFixed(8)} DGB for the demo.</Text>
<Pressable
style={[styles.button, (!ready || status.kind === 'busy') && styles.buttonDisabled]}
disabled={!ready || status.kind === 'busy'}
onPress={onSign}
>
{status.kind === 'busy'
? <ActivityIndicator color="white" />
: <Text style={styles.buttonText}>Build & sign transaction</Text>}
</Pressable>
{status.kind === 'error' && <Text style={styles.errText}>{status.msg}</Text>}
{status.kind === 'signed' && (
<View style={styles.results}>
<View style={styles.pathGroup}>
<Text style={styles.pathTitle}>Signed transaction</Text>
<Text style={styles.pathDesc}>txid: {status.txid}</Text>
<Text style={styles.pathDesc}>fee: {(status.fee / 1e8).toFixed(8)} DGB</Text>
<Text style={styles.label}>Broadcast path</Text>
<View style={styles.toggleRow}>
<Pressable
style={[styles.toggleButton, useDandelion ? styles.toggleActive : styles.toggleInactive]}
onPress={() => setUseDandelion(true)}>
<Text style={styles.toggleText}>Dandelion++ (private)</Text>
</Pressable>
<Pressable
style={[styles.toggleButton, !useDandelion ? styles.toggleActive : styles.toggleInactive]}
onPress={() => setUseDandelion(false)}>
<Text style={styles.toggleText}>Direct fan-out</Text>
</Pressable>
</View>
<Text style={styles.tipText}>
{useDandelion
? 'Relays via a random Dandelion-capable peer before fluff. Falls back to direct if none available.'
: 'Announces to every ready peer. Faster but reveals your IP as the origin.'}
</Text>
<Pressable style={styles.button} onPress={onBroadcast}>
<Text style={styles.buttonText}>Broadcast now</Text>
</Pressable>
</View>
</View>
)}
{status.kind === 'broadcast' && (
<View style={styles.tipBlock}>
<Text style={styles.tipStrong}>Broadcast successful</Text>
<Text style={styles.tipText}>via: {status.res.via ?? 'p2p'}</Text>
{status.res.stemPeer && <Text style={styles.tipText}>stem: {status.res.stemPeer}</Text>}
<Text style={styles.tipText} selectable>txid: {status.res.txid}</Text>
{status.res.via !== 'dandelion' && (
<Text style={styles.tipText}>
{status.res.requestedBy}/{status.res.totalPeers} peers requested the tx.
</Text>
)}
</View>
)}
</ScrollView>
);
}

View file

@ -0,0 +1,57 @@
// Same derivation logic as the web-ui's derive.ts. Kept as a copy
// rather than shared package because the web-ui's paths ultimately
// import from './derive.js' with the workspace's .ts extension
// resolution — Metro handles this differently. Small duplication now;
// extract to a shared `@dgb-wallet/screens` package later.
import {
seedFromPhrase,
rootFromSeed,
accountNode,
addressNode,
p2pkhAddress,
p2shP2wpkhAddress,
p2shP2wpkhAddressesBoth,
p2wpkhAddress,
p2trAddress,
derivationPath,
type Purpose,
} from '@dgb-wallet/core';
export interface DerivedAddress { index: number; path: string; address: string; }
export interface DerivationGroup {
label: string;
descriptor: string;
addresses: DerivedAddress[];
}
export async function deriveAll(phrase: string, addressesPerPath = 5): Promise<DerivationGroup[]> {
const seed = await seedFromPhrase(phrase);
const root = rootFromSeed(seed);
const specs: Array<{
label: string;
purpose: Purpose;
render: (acct: ReturnType<typeof accountNode>, i: number) => string;
}> = [
{ label: 'BIP44 legacy (D…)', purpose: 44, render: (a, i) => p2pkhAddress(addressNode(a, 0, i)) },
{ label: 'BIP49 modern (S…)', purpose: 49, render: (a, i) => p2shP2wpkhAddress(addressNode(a, 0, i)) },
{ label: 'BIP49 legacy (3…)', purpose: 49, render: (a, i) => p2shP2wpkhAddressesBoth(addressNode(a, 0, i)).legacy },
{ label: 'BIP84 SegWit (dgb1q…)', purpose: 84, render: (a, i) => p2wpkhAddress(addressNode(a, 0, i)) },
{ label: 'BIP86 Taproot (dgb1p…)', purpose: 86, render: (a, i) => p2trAddress(addressNode(a, 0, i)) },
];
const groups: DerivationGroup[] = [];
for (const spec of specs) {
const acct = accountNode(root, spec.purpose, 0);
const addresses: DerivedAddress[] = [];
for (let i = 0; i < addressesPerPath; i++) {
addresses.push({
index: i,
path: derivationPath(spec.purpose, 0, 0, i),
address: spec.render(acct, i),
});
}
groups.push({ label: spec.label, descriptor: `m/${spec.purpose}'/20'/0'/0/*`, addresses });
}
return groups;
}

View file

@ -0,0 +1,64 @@
// Native (React Native) HTTP client for the P2P daemon. Points at
// 10.0.2.2:5174 because that's the Android emulator's alias for the
// host machine's localhost; the daemon binds 127.0.0.1 there.
//
// iOS simulator uses the real localhost — set DAEMON_URL below when
// running on that target.
export const DAEMON_URL = 'http://10.0.2.2:5174';
export interface P2PUtxo {
txid: string;
vout: number;
value: number;
scriptPubKey: string;
blockHash: string;
}
export interface P2PBalanceResponse {
totalSats: number;
utxos: P2PUtxo[];
scannedRange: { startHeight: number; endHeight: number };
blocksFetched: number;
filterHits: number;
filtersFromCache?: number;
filtersFromNetwork?: number;
elapsedMs: number;
note?: string;
}
export async function p2pBalance(body: {
xpub: string;
purpose?: 44 | 49 | 84 | 86;
account?: number;
gapLimit?: number;
startHeight?: number;
endHeight?: number;
}): Promise<P2PBalanceResponse> {
const res = await fetch(`${DAEMON_URL}/balance`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(`daemon HTTP ${res.status}: ${await res.text()}`);
return (await res.json()) as P2PBalanceResponse;
}
export interface P2PBroadcastResponse {
txid: string;
requestedBy: number;
totalPeers: number;
via?: 'dandelion' | 'direct';
stemPeer?: string;
}
export async function p2pBroadcast(hex: string, useDandelion = true): Promise<P2PBroadcastResponse> {
const url = `${DAEMON_URL}/broadcast${useDandelion ? '' : '?dandelion=0'}`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hex }),
});
if (!res.ok) throw new Error(`daemon HTTP ${res.status}: ${await res.text()}`);
return (await res.json()) as P2PBroadcastResponse;
}

View file

@ -0,0 +1,100 @@
import { StyleSheet } from 'react-native';
// Shared color palette + primitives across all screens. Kept as a
// separate module so the Import/Balance/Send screens stay visually
// identical without duplicating five copies of StyleSheet.create.
export const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#0a0e1a' },
content: { padding: 20, paddingBottom: 40 },
warningBlock: {
backgroundColor: 'rgba(245, 158, 11, 0.1)',
borderWidth: 1,
borderColor: 'rgba(245, 158, 11, 0.3)',
borderRadius: 10,
padding: 12,
marginBottom: 12,
},
warningText: { color: '#f59e0b', fontSize: 13, lineHeight: 20 },
tipBlock: {
backgroundColor: 'rgba(59, 130, 246, 0.08)',
borderWidth: 1,
borderColor: 'rgba(59, 130, 246, 0.25)',
borderRadius: 10,
padding: 12,
marginBottom: 16,
},
tipText: { color: '#94a3b8', fontSize: 13, lineHeight: 20 },
tipStrong: { color: '#e2e8f0', fontWeight: '500' },
label: {
color: '#94a3b8',
fontSize: 12,
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
marginTop: 10,
},
textInput: {
backgroundColor: '#131a2b',
color: '#e2e8f0',
borderWidth: 1,
borderColor: '#1e293b',
borderRadius: 10,
padding: 12,
fontFamily: 'monospace',
fontSize: 14,
},
textarea: {
backgroundColor: '#131a2b',
color: '#e2e8f0',
borderWidth: 1,
borderColor: '#1e293b',
borderRadius: 10,
padding: 14,
fontFamily: 'monospace',
fontSize: 14,
minHeight: 100,
textAlignVertical: 'top',
},
statusText: { fontSize: 12, marginTop: 6 },
button: {
backgroundColor: '#3b82f6',
borderRadius: 10,
padding: 14,
alignItems: 'center',
marginTop: 14,
},
buttonDisabled: { opacity: 0.4 },
buttonText: { color: 'white', fontSize: 15, fontWeight: '500' },
toggleRow: { flexDirection: 'row', gap: 8, marginTop: 8, marginBottom: 6 },
toggleButton: {
flex: 1,
padding: 10,
borderRadius: 10,
borderWidth: 1,
alignItems: 'center',
},
toggleActive: { backgroundColor: '#3b82f6', borderColor: '#3b82f6' },
toggleInactive: { backgroundColor: '#131a2b', borderColor: '#1e293b' },
toggleText: { color: '#e2e8f0', fontSize: 12 },
errText: { color: '#ef4444', fontSize: 13, marginTop: 12 },
results: { marginTop: 20, gap: 16 },
pathGroup: {
backgroundColor: '#131a2b',
borderWidth: 1,
borderColor: '#1e293b',
borderRadius: 10,
padding: 14,
},
pathTitle: { color: '#e2e8f0', fontSize: 13, fontWeight: '600', marginBottom: 4 },
pathDesc: { color: '#94a3b8', fontFamily: 'monospace', fontSize: 11, marginBottom: 10 },
balanceBig: { color: '#e2e8f0', fontSize: 22, fontWeight: '500', marginTop: 4, marginBottom: 6 },
addrRow: {
flexDirection: 'row',
gap: 8,
paddingVertical: 6,
borderTopWidth: 1,
borderTopColor: '#1e293b',
},
addrIdx: { color: '#94a3b8', fontFamily: 'monospace', fontSize: 11, minWidth: 24, textAlign: 'right' },
addrText: { color: '#e2e8f0', fontFamily: 'monospace', fontSize: 11, flex: 1 },
});

View file

@ -0,0 +1,14 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"moduleResolution": "bundler",
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"skipLibCheck": true
},
"include": ["**/*.ts", "**/*.tsx"]
}

27
packages/net/package.json Normal file
View file

@ -0,0 +1,27 @@
{
"name": "@dgb-wallet/net",
"version": "0.0.1",
"description": "Transport-privacy abstraction for the DGB wallet: every outbound socket goes through a NetProvider (direct or SOCKS5). Lets us swap in Tor without any consumer knowing.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"test:socks5": "tsx scripts/socks5-loopback.ts"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,102 @@
// Minimal loopback smoke test — spins up an in-process SOCKS5 server
// that answers with an echo target, then connects Socks5Net to it and
// verifies bytes round-trip. Meant as a regression check for the
// handshake, not a real network test.
//
// npm run test:socks5 -w @dgb-wallet/net
import * as net from 'node:net';
import { Socks5Net } from '../src/socks5.js';
const PROXY_PORT = 19050;
const ECHO_PORT = 19051;
// Trivial echo server on ECHO_PORT.
const echo = net.createServer((sock) => {
sock.on('data', (chunk) => sock.write(chunk));
});
// Minimal SOCKS5 server: NO-AUTH only, CONNECT to any host:port,
// implemented against 127.0.0.1 (the only place we route in this test).
const proxy = net.createServer((client) => {
let stage = 'greet' as 'greet' | 'connect' | 'piped';
let buf: Buffer = Buffer.alloc(0);
let outbound: net.Socket | null = null;
client.on('data', (chunk) => {
if (stage === 'piped') return;
buf = Buffer.concat([buf, chunk]);
if (stage === 'greet') {
if (buf.length < 2) return;
const n = buf[1];
if (buf.length < 2 + n) return;
client.write(Buffer.from([0x05, 0x00])); // NO-AUTH accepted
buf = buf.subarray(2 + n);
stage = 'connect';
}
if (stage === 'connect') {
if (buf.length < 4) return;
const atyp = buf[3];
let addrLen: number;
let addr: string;
let cursor: number;
if (atyp === 0x03) {
if (buf.length < 5) return;
addrLen = buf[4];
if (buf.length < 5 + addrLen + 2) return;
addr = buf.subarray(5, 5 + addrLen).toString('ascii');
cursor = 5 + addrLen;
} else if (atyp === 0x01) {
if (buf.length < 4 + 4 + 2) return;
addr = Array.from(buf.subarray(4, 8)).join('.');
cursor = 8;
} else {
client.end(); return;
}
const port = buf.readUInt16BE(cursor);
// Success reply — bound address 0.0.0.0:0 (Tor sends this too).
client.write(Buffer.from([0x05, 0x00, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
buf = buf.subarray(cursor + 2);
stage = 'piped';
outbound = net.connect({ host: addr === 'localhost' ? '127.0.0.1' : addr, port });
outbound.on('data', (d) => client.write(d));
client.on('data', (d) => outbound?.write(d));
outbound.on('close', () => client.end());
client.on('close', () => outbound?.end());
if (buf.length > 0) outbound.write(buf);
}
});
});
async function main(): Promise<void> {
await new Promise<void>((r) => echo.listen(ECHO_PORT, '127.0.0.1', r));
await new Promise<void>((r) => proxy.listen(PROXY_PORT, '127.0.0.1', r));
const client = new Socks5Net({ proxyHost: '127.0.0.1', proxyPort: PROXY_PORT });
const sock = await client.connect({ host: '127.0.0.1', port: ECHO_PORT });
const roundtrip = await new Promise<string>((resolve, reject) => {
let chunks = '';
sock.on('data', (d) => {
chunks += d.toString('utf8');
if (chunks.length >= 5) resolve(chunks);
});
sock.on('error', reject);
sock.write('hello');
});
if (roundtrip.startsWith('hello')) {
console.log(`✅ socks5 loopback: echoed ${JSON.stringify(roundtrip)}`);
} else {
console.error(`❌ unexpected response: ${JSON.stringify(roundtrip)}`);
process.exitCode = 1;
}
sock.destroy();
proxy.close();
echo.close();
}
main().catch((e) => {
console.error('❌ test failed:', e);
process.exit(1);
});

View file

@ -0,0 +1,33 @@
import * as net from 'node:net';
import type { NetProvider, ConnectOptions } from './net-provider.js';
// Plain TCP. The "no privacy" default that ships turned off — see
// RESEARCH.md §10.7b: direct connections require an explicit opt-out
// with a visible privacy warning. This class exists so that opt-out
// still goes through the same NetProvider contract.
export class DirectNet implements NetProvider {
readonly name = 'direct';
connect(opts: ConnectOptions): Promise<net.Socket> {
const { host, port, timeoutMs = 15_000 } = opts;
return new Promise((resolve, reject) => {
const socket = net.connect({ host, port });
const timer = setTimeout(() => {
socket.destroy();
reject(new Error(`DirectNet: connect to ${host}:${port} timed out after ${timeoutMs}ms`));
}, timeoutMs);
const onReady = () => {
clearTimeout(timer);
socket.off('error', onError);
resolve(socket);
};
const onError = (err: Error) => {
clearTimeout(timer);
socket.destroy();
reject(err);
};
socket.once('connect', onReady);
socket.once('error', onError);
});
}
}

View file

@ -0,0 +1,3 @@
export * from './net-provider.js';
export * from './direct.js';
export * from './socks5.js';

View file

@ -0,0 +1,36 @@
// The one interface every socket-opening call in the wallet routes
// through. Consumers (@dgb-wallet/p2p, @dgb-wallet/electrum, any HTTP
// client) accept a NetProvider and never call `net.connect` directly.
//
// Swapping in `Socks5Net`/`TorNet` at the app entry point flips the
// entire wallet to Tor with zero code changes downstream — this is
// the whole point of the abstraction (see RESEARCH.md §10.7b).
import type { Socket } from 'node:net';
export interface ConnectOptions {
host: string;
port: number;
// Wall-clock ceiling for the full connect handshake. Includes the
// TCP SYN/ACK, the SOCKS negotiation, and any post-CONNECT
// ancillary handshakes. Providers should reject with a timeout
// error if the socket isn't usable within this window.
timeoutMs?: number;
}
// Providers return the already-connected socket. For a DirectNet this
// is just the raw net.Socket; for Socks5Net it's the tunneled socket
// after the CONNECT handshake completes — byte-transparent for the
// caller.
export interface NetProvider {
readonly name: string;
connect(opts: ConnectOptions): Promise<Socket>;
}
// Not all providers can reach every host. .onion targets need Tor;
// clearnet targets don't. Callers use `canReach` to pick a peer set
// their provider can actually dial, so an all-Tor build doesn't try
// clearnet peers and vice versa.
export function isOnion(host: string): boolean {
return /\.onion$/i.test(host);
}

217
packages/net/src/socks5.ts Normal file
View file

@ -0,0 +1,217 @@
import * as net from 'node:net';
import type { NetProvider, ConnectOptions } from './net-provider.js';
import { isOnion } from './net-provider.js';
// SOCKS5 client per RFC 1928, targeting Tor's proxy specifically but
// spec-compliant enough to work against Shadowsocks, Dante, or any
// other SOCKS5 endpoint. Only method 0x00 (no auth) is offered —
// Tor's default 9050/9150 SOCKS ports accept unauthenticated CONNECT.
//
// Wire dance:
// 1. Client → Proxy: [0x05][0x01][0x00] // ver, nmethods, method NO-AUTH
// 2. Proxy → Client: [0x05][0x00] // ver, chosen method
// 3. Client → Proxy: [0x05][0x01][0x00][atyp]... // ver, CONNECT, RSV, addr, port
// 4. Proxy → Client: [0x05][0x00][0x00][atyp]... // ver, REP=success, RSV, bound
// After step 4 the socket is a bidirectional pipe to the target.
//
// Hostname resolution: we deliberately use atyp=0x03 (DOMAINNAME) and
// send the raw hostname over SOCKS. Tor then does the DNS itself
// inside the network — the wallet never leaks names to the local
// resolver. For .onion this is required (they have no DNS at all).
export interface Socks5Options {
proxyHost: string; // e.g. '127.0.0.1' for a local Tor
proxyPort: number; // e.g. 9050 (Tor daemon) or 9150 (Tor Browser bundle)
// Optional user/pass for RFC 1929 auth. Tor accepts *any* value here
// and uses it as a stream-isolation token — same {user,pass} share
// a circuit; different tokens get separate circuits. Very useful:
// pass {user: peerHost, pass: peerHost} to isolate every peer.
username?: string;
password?: string;
}
export class Socks5Net implements NetProvider {
readonly name = 'socks5';
private readonly opts: Socks5Options;
constructor(opts: Socks5Options) {
this.opts = opts;
}
connect(reqOpts: ConnectOptions): Promise<net.Socket> {
const { host, port, timeoutMs = 30_000 } = reqOpts;
const { proxyHost, proxyPort, username, password } = this.opts;
const authOffered = username !== undefined && password !== undefined;
return new Promise((resolve, reject) => {
const socket = net.connect({ host: proxyHost, port: proxyPort });
let phase: 'greet' | 'auth' | 'connect' = 'greet';
let buf: Buffer = Buffer.alloc(0);
const done = (err?: Error) => {
clearTimeout(timer);
socket.off('data', onData);
socket.off('error', onError);
socket.off('close', onClose);
if (err) {
socket.destroy();
reject(err);
} else {
resolve(socket);
}
};
const timer = setTimeout(
() => done(new Error(`Socks5Net: handshake to ${host}:${port} via ${proxyHost}:${proxyPort} timed out after ${timeoutMs}ms`)),
timeoutMs,
);
const onError = (err: Error) => done(err);
const onClose = () => done(new Error('Socks5Net: proxy closed connection during handshake'));
const sendGreeting = () => {
const methods = authOffered ? [0x00, 0x02] : [0x00];
const b = Buffer.from([0x05, methods.length, ...methods]);
socket.write(b);
};
const sendAuth = () => {
// RFC 1929 username/password sub-negotiation:
// [0x01][ulen][user][plen][pass]
const u = Buffer.from(username!, 'utf8');
const p = Buffer.from(password!, 'utf8');
if (u.length > 255 || p.length > 255) return done(new Error('Socks5Net: user/pass too long'));
const out = Buffer.concat([
Buffer.from([0x01, u.length]), u,
Buffer.from([p.length]), p,
]);
socket.write(out);
};
const sendConnect = () => {
let addrPart: Buffer;
if (net.isIPv4(host)) {
addrPart = Buffer.concat([Buffer.from([0x01]), Buffer.from(host.split('.').map((s) => Number(s)))]);
} else if (net.isIPv6(host)) {
addrPart = Buffer.concat([Buffer.from([0x04]), parseIPv6(host)]);
} else {
const name = Buffer.from(host, 'ascii');
if (name.length > 255) return done(new Error(`Socks5Net: hostname too long (${name.length})`));
addrPart = Buffer.concat([Buffer.from([0x03, name.length]), name]);
}
const portPart = Buffer.alloc(2);
portPart.writeUInt16BE(port, 0);
const req = Buffer.concat([Buffer.from([0x05, 0x01, 0x00]), addrPart, portPart]);
socket.write(req);
};
const onData = (chunk: Buffer) => {
buf = Buffer.concat([buf, chunk]);
if (phase === 'greet') {
if (buf.length < 2) return;
if (buf[0] !== 0x05) return done(new Error(`Socks5Net: bad version 0x${buf[0].toString(16)}`));
const method = buf[1];
buf = buf.subarray(2);
if (method === 0x00) { phase = 'connect'; sendConnect(); }
else if (method === 0x02 && authOffered) { phase = 'auth'; sendAuth(); }
else if (method === 0xff) return done(new Error('Socks5Net: proxy offered no acceptable auth method'));
else return done(new Error(`Socks5Net: unexpected auth method 0x${method.toString(16)}`));
}
if (phase === 'auth') {
if (buf.length < 2) return;
if (buf[0] !== 0x01 || buf[1] !== 0x00) return done(new Error(`Socks5Net: auth rejected (status 0x${buf[1].toString(16)})`));
buf = buf.subarray(2);
phase = 'connect';
sendConnect();
}
if (phase === 'connect') {
// Reply header is 4 bytes + variable addr + 2 bytes port.
if (buf.length < 4) return;
if (buf[0] !== 0x05) return done(new Error(`Socks5Net: bad reply version 0x${buf[0].toString(16)}`));
const rep = buf[1];
if (rep !== 0x00) return done(new Error(`Socks5Net: CONNECT failed (${describeRep(rep)})`));
const atyp = buf[3];
let need = 4;
if (atyp === 0x01) need += 4 + 2;
else if (atyp === 0x04) need += 16 + 2;
else if (atyp === 0x03) {
if (buf.length < 5) return;
need += 1 + buf[4] + 2;
} else return done(new Error(`Socks5Net: unknown reply atyp 0x${atyp.toString(16)}`));
if (buf.length < need) return;
// Whatever tail bytes remain past the reply are already
// payload from the target — feed them back through the
// socket so downstream code sees them via normal 'data'.
const trailing = buf.subarray(need);
done();
if (trailing.length > 0) {
queueMicrotask(() => socket.emit('data', trailing));
}
}
};
socket.once('connect', sendGreeting);
socket.on('data', onData);
socket.once('error', onError);
socket.once('close', onClose);
});
}
}
function parseIPv6(addr: string): Buffer {
// Node's net doesn't ship a parser; do the minimum we need.
// Reject anything we can't handle rather than get creative.
const parts = addr.split('::');
if (parts.length > 2) throw new Error(`Socks5Net: invalid IPv6 ${addr}`);
const left = parts[0] ? parts[0].split(':') : [];
const right = parts[1] ? parts[1].split(':') : [];
const missing = 8 - left.length - right.length;
if (missing < 0) throw new Error(`Socks5Net: invalid IPv6 ${addr}`);
const groups = [...left, ...Array(missing).fill('0'), ...right];
const out = Buffer.alloc(16);
for (let i = 0; i < 8; i++) {
const v = parseInt(groups[i] || '0', 16);
out.writeUInt16BE(v, i * 2);
}
return out;
}
function describeRep(rep: number): string {
switch (rep) {
case 0x01: return 'general SOCKS server failure';
case 0x02: return 'connection not allowed by ruleset';
case 0x03: return 'network unreachable';
case 0x04: return 'host unreachable';
case 0x05: return 'connection refused';
case 0x06: return 'TTL expired';
case 0x07: return 'command not supported';
case 0x08: return 'address type not supported';
default: return `reply 0x${rep.toString(16)}`;
}
}
// Convenience: a Socks5Net preset for Tor on the standard local port.
// Uses per-target stream isolation by default so DGB peers don't share
// a circuit — each peer host gets its own SOCKS auth token, which Tor
// interprets as "give me a separate circuit for this stream". Same
// idea Bitcoin Core enables with -proxyrandomize.
export function torSocks5(proxyPort = 9050): (isolationKey: string) => Socks5Net {
return (isolationKey) => new Socks5Net({
proxyHost: '127.0.0.1',
proxyPort,
username: isolationKey,
password: isolationKey,
});
}
// Router that picks a provider per target — Socks5Net for .onion, an
// arbitrary base provider for clearnet. Useful during migration from
// clear to Tor: opt-in to .onion peers only, keep DirectNet everywhere
// else. Not necessary once TorNet is the default for all targets.
export class OnionAwareNet implements NetProvider {
readonly name = 'onion-aware';
constructor(
private readonly clearProvider: NetProvider,
private readonly onionProvider: NetProvider,
) {}
connect(opts: ConnectOptions): Promise<net.Socket> {
return (isOnion(opts.host) ? this.onionProvider : this.clearProvider).connect(opts);
}
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"skipLibCheck": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "scripts"]
}

3
packages/p2p/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
*.log

49
packages/p2p/README.md Normal file
View file

@ -0,0 +1,49 @@
# @dgb-wallet/p2p
DigiByte P2P client for the Maximum Privacy backend. Client-side chain data via BIP157/158 compact block filters — the wallet does its own address matching locally, so peers never learn which addresses it cares about.
Current status: **Milestone 1 (peer connect + version handshake)**.
Planned milestones:
1. ✅ Peer connect + version handshake
2. Peer discovery via DNS seeds + `getaddr`
3. Header sync (`getheaders` / `headers`)
4. BIP157 filter negotiation + BIP158 filter download
5. Filter probing (address → scripthash → filter match)
6. Block fetching for filter hits
7. Transaction broadcast (`inv` / `tx`)
8. Dandelion++ integration
9. Persistent cache
## Milestone 1 demo
```bash
cd "D:\Dev\SilentCode\Digibyte"
npm install # first time only
npm run build -w @dgb-wallet/core
npm run handshake -w @dgb-wallet/p2p
```
Resolves DGB DNS seeds → connects to real live peers on port 12024 → performs the version/verack handshake → reports each peer's protocol version, user agent, service flags, and chain tip height.
To target a specific peer IP:
```bash
npm run handshake -w @dgb-wallet/p2p -- 1.2.3.4
```
## Layout
```
src/
hash.ts — double-SHA256 for message checksums
varint.ts — Bitcoin/DGB compact-size int encoding
framing.ts — 24-byte header + payload + checksum, multi-frame parsing
version.ts — version/verack message encode/decode, service flag bits
peer.ts — per-peer TCP + handshake state machine
index.ts — re-exports
scripts/
handshake-demo.ts — Milestone 1 end-to-end demo
```
Depends on `@dgb-wallet/core` for network parameters (`DGB_P2P.magic`, `defaultPort`, `dnsSeeds`).

View file

@ -0,0 +1,801 @@
// Local HTTP daemon that exposes @dgb-wallet/p2p features to non-Node
// consumers (the web UI, which can't do raw TCP).
//
// Endpoints:
// GET /health — pool + chain status
// POST /broadcast — {hex} → {txid, requestedBy, totalPeers, relayed, relayedBy, via}
// replies within EARLY_REPLY_MS; if the relay
// echo is still pending → {txid, pending:true}
// GET /broadcast/status?txid=… → same shape, state:'pending'|'done'|'failed'
// POST /balance — {xpub, purpose, account,
// gapLimit?, startHeight?} → {totalSats, utxos, ...}
//
// Bound to 127.0.0.1 only. No auth (localhost trust boundary).
//
// Usage: npm run daemon -w @dgb-wallet/p2p [--port 5174]
import * as http from 'node:http';
import { existsSync, readFileSync, writeFileSync, renameSync } from 'node:fs';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { BIP32Factory } from 'bip32';
import * as ecc from '@bitcoinerlab/secp256k1';
import { payments, Transaction } from 'bitcoinjs-lib';
import { digibyte, DGB_COIN_TYPE } from '@dgb-wallet/core';
import type { Block } from 'bitcoinjs-lib';
import {
PeerPool,
HeaderChain,
FilterCache,
defaultCacheDir,
encodeGetHeaders,
encodeGetBlock,
broadcastTx,
broadcastDandelion,
scanWithPeers,
crawlFilters,
NODE_COMPACT_FILTERS,
NODE_NETWORK,
NODE_NETWORK_LIMITED,
type BlockHeader,
} from '../src/index.js';
const args = process.argv.slice(2);
const portFlagIdx = args.indexOf('--port');
const PORT = portFlagIdx >= 0 ? Number(args[portFlagIdx + 1]) : 5174;
// A tiny fraction of the DGB network advertises NODE_COMPACT_FILTERS
// (only a few nodes have the BIP157/158 patchset), so a small pool
// starves the filter path. 32 gets us a couple of CF peers in practice.
const POOL_SIZE = 32;
const DGB_GENESIS_HASH = '7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496';
const bip32 = BIP32Factory(ecc);
console.log(`[daemon] starting P2P pool (target ${POOL_SIZE}, needs at least 1 CF-serving peer for /balance)`);
const pool = new PeerPool({ poolSize: POOL_SIZE, peerOpts: { handshakeTimeoutMs: 10_000 } });
pool.on('connect', ({ host, services }: { host: string; services: string }) =>
console.log(`[pool] +${host} services=${services}`),
);
pool.on('disconnect', ({ key }: { key: string }) => console.log(`[pool] -${key}`));
// ---- Disk-backed header chain (constant RAM) + background sync ----
const filterCache = new FilterCache({ dir: defaultCacheDir() });
const chain = new HeaderChain({ dir: defaultCacheDir() });
chain.appendGenesis({
hash: DGB_GENESIS_HASH, prevBlock: '00'.repeat(32), merkleRoot: '',
version: 0, timestamp: 0, bits: 0, nonce: 0, raw: Buffer.alloc(80),
}, 0);
console.log(`[chain] disk-backed, tip ${chain.tipHeight.toLocaleString()} (adopted from ${defaultCacheDir()})`);
let syncing = false;
let syncRound = 0;
// Header sync. Rotates through ready peers so no single peer decides
// what our chain looks like, and handles forks: when a peer's batch does
// not attach to our tip, find where it does attach, roll back to there
// and re-grow along the peer's branch. Reorgs are logged loudly.
async function backgroundSync(): Promise<void> {
if (syncing) return;
syncing = true;
let before = chain.tipHeight;
let stalled = false;
try {
// Round-robin over every ready peer. (Sorting by advertised height
// was tried and picked exactly the nodes that lie about it and never
// answer getheaders.) The advertised height is still used below as
// a guard: a reorg is only accepted from a peer at least as high as
// our tip.
const ready = pool.pickReady();
const readyPeer = ready[syncRound++ % Math.max(1, ready.length)];
if (!readyPeer) {
syncing = false;
setTimeout(backgroundSync, 2000);
return;
}
before = chain.tipHeight;
stalled = false;
let lastBatchAt = Date.now();
// Walk the peer's chain. Peers answer from the highest locator entry
// they recognise. If we sit on a stale branch, that entry is deep
// below our tip and the first batches are headers we already have;
// we must keep walking UP the peer's chain (locator = last header of
// the batch) until it either diverges from ours (→ reorg candidate)
// or climbs past our tip (→ plain append). Restarting from our own
// tip locator each time would loop forever on the same known batch —
// which is exactly how the daemon sat on a garbage branch for hours.
let cursor: string | null = null; // last header hash of a fully-known batch, when walking up
const ask = (locator: string[]) => readyPeer.send('getheaders', encodeGetHeaders(70019, locator));
const onHeaders = (headers: BlockHeader[]) => {
lastBatchAt = Date.now();
const t0 = Date.now();
if (headers.length === 0) { stalled = true; return; }
let r = chain.append(headers);
if (r.rejectedAt && r.appended === 0) {
const forkH = chain.findHeight(headers[0].prevBlock);
if (forkH < 0) {
console.log(`[chain] batch from ${readyPeer.host} rejected: ${r.reason?.slice(0, 100)} (fork point not in our chain — next peer)`);
stalled = true; return;
}
// How much of the batch do we already have on our branch?
let known = 0;
while (known < headers.length && chain.getByHeight(forkH + 1 + known)?.hash === headers[known].hash) known++;
if (known === headers.length) {
// Entirely known: keep walking up the peer's chain from here.
cursor = headers[headers.length - 1].hash;
const nextH = forkH + known;
if (nextH >= chain.tipHeight) { stalled = true; return; } // peer is behind us
ask([cursor, DGB_GENESIS_HASH]);
return;
}
const divergeH = forkH + known; // last common height
const depth = chain.tipHeight - divergeH; // what we would drop
const branch = headers.length - known; // what the peer showed us so far
const peerHeight = readyPeer.info?.startHeight ?? 0;
if (peerHeight < chain.tipHeight) {
console.log(`[chain] ${readyPeer.host} (adv. height ${peerHeight.toLocaleString()}) offered a branch at ${divergeH.toLocaleString()} against our ${depth.toLocaleString()}-deep tip ${chain.tipHeight.toLocaleString()} — ignored (lower than us)`);
stalled = true; return;
}
if (branch < depth && branch < 2000) {
console.log(`[chain] ${readyPeer.host} offered a ${branch}-header branch at ${divergeH.toLocaleString()} against our ${depth.toLocaleString()}-deep tip — ignored (too short)`);
stalled = true; return;
}
console.log(`[chain] REORG via ${readyPeer.host} (adv. height ${peerHeight.toLocaleString()}): our tip ${chain.tipHeight.toLocaleString()} is off the network chain; rolling back ${depth.toLocaleString()} headers to ${divergeH.toLocaleString()} (${Date.now() - t0}ms to locate)`);
chain.rollbackTo(divergeH);
r = chain.append(headers.slice(known));
if (chain.tipHeight < before) {
let forgot = 0;
for (const [k, rs] of lastScan) if (rs.endHeight > chain.tipHeight) { lastScan.delete(k); forgot++; }
if (forgot) { saveLastScansSoon(); console.log(`[scan] forgot ${forgot} remembered scan(s) that reached past the fork point`); }
}
}
if (r.rejectedAt) {
console.log(`[chain] batch from ${readyPeer.host} partially applied (+${r.appended}): ${r.reason?.slice(0, 120)}`);
}
cursor = null;
// DGB peers answer with up to 2000 headers (some send more); a short
// batch means we are at their tip.
if (headers.length >= 2000) ask(chain.buildLocator());
else stalled = true;
};
readyPeer.on('headers', onHeaders as never);
readyPeer.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
// Poll until caught up, or the peer goes quiet for 20s.
for (let i = 0; i < 600; i++) {
await sleep(1000);
if (stalled) break;
if (Date.now() - lastBatchAt > 30_000) { console.log(`[chain] ${readyPeer.host} (adv. height ${(readyPeer.info?.startHeight ?? 0).toLocaleString()}) stopped answering getheaders — next round uses another peer`); break; }
}
readyPeer.off('headers', onHeaders as never);
if (chain.tipHeight !== before) {
// append() wrote each batch straight to disk — no separate save.
const delta = chain.tipHeight - before;
console.log(`[chain] ${delta >= 0 ? '+' : ''}${delta.toLocaleString()} headers, tip ${chain.tipHeight.toLocaleString()} (${new Date((chain.tip?.timestamp ?? 0) * 1000).toISOString()})`);
}
} finally {
syncing = false;
}
// Re-sync every minute — DGB blocks are 15s, and a fresh tip is what
// makes the P2P balance scan see recent coins. When the last round got
// nothing at all, try the next peer right away instead of idling.
setTimeout(backgroundSync, chain.tipHeight === before && stalled === false ? 5 * 1000 : 60 * 1000);
}
// Kick off after peers likely connected.
setTimeout(backgroundSync, 5000);
// ---- Background filter crawl ------------------------------------------
// Keep the cfilter cache populated for the last CRAWL_BLOCKS blocks (top
// down, newest first) and follow the tip. With the cache warm, a wallet
// balance scan over the default window never waits on a CF peer — it is
// CPU-bound matching, which scan-driver yields around. Override with
// DGB_CRAWL_BLOCKS (env); 0 disables.
const CRAWL_BLOCKS = Number(process.env.DGB_CRAWL_BLOCKS ?? 365 * 5760);
let crawling = false;
const crawlStatus = { from: 0, to: 0, fetched: 0, failedSpans: 0, lastRun: 0, running: false };
function shuffled<T>(arr: T[]): T[] {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; }
return a;
}
async function backgroundCrawl(): Promise<void> {
if (crawling || CRAWL_BLOCKS <= 0) return;
crawling = true;
crawlStatus.running = true;
try {
const tip = chain.tipHeight;
if (tip < 1000 || syncing) return;
const from = Math.max(1, tip - CRAWL_BLOCKS);
const cfPeers = () => shuffled(pool.pickWithServices(NODE_COMPACT_FILTERS));
if (cfPeers().length === 0) return;
crawlStatus.from = from; crawlStatus.to = tip;
const t0 = Date.now();
const r = await crawlFilters({
cfPeers, chain, filterCache, fromHeight: from, toHeight: tip,
filterTimeoutMs: 20_000, pauseMs: 200,
// Wallet scans get the CF peers to themselves; the crawl resumes
// as soon as no scan job is pending.
waitWhile: () => [...scanJobs.values()].some((j) => j.state === 'pending'),
onSpan: (i) => {
if (i.failed) console.log(`[crawl] span ${i.start.toLocaleString()}–${i.end.toLocaleString()}: no CF peer answered`);
else if (i.start % 100_000 < 1000) console.log(`[crawl] at ${i.start.toLocaleString()} (+${i.fetched} filters this span, ${Math.round((Date.now() - t0) / 1000)}s)`);
},
});
crawlStatus.fetched += r.fetched; crawlStatus.failedSpans = r.failedSpans; crawlStatus.lastRun = Date.now();
if (r.fetched > 0 || r.failedSpans > 0) {
console.log(`[crawl] pass done: +${r.fetched.toLocaleString()} filters, ${r.failedSpans} spans failed, window ${from.toLocaleString()}–${tip.toLocaleString()}, ${Math.round((Date.now() - t0) / 1000)}s`);
}
} catch (e) {
console.log(`[crawl] error: ${(e as Error).message}`);
} finally {
crawling = false;
crawlStatus.running = false;
// Follow the tip: a fresh pass every 2 min only fetches what is new.
setTimeout(backgroundCrawl, 2 * 60 * 1000);
}
}
setTimeout(backgroundCrawl, 45_000);
const server = http.createServer(async (req, res) => {
const setCors = () => {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
};
if (req.method === 'OPTIONS') { setCors(); res.writeHead(204); res.end(); return; }
setCors();
res.setHeader('Content-Type', 'application/json');
try {
if (req.method === 'GET' && req.url?.startsWith('/balance/status')) {
const id = new URL(req.url, 'http://localhost').searchParams.get('job') ?? '';
const job = scanJobs.get(id);
if (!job) { res.writeHead(404); res.end(JSON.stringify({ error: 'unknown job', job: id })); return; }
res.writeHead(200); res.end(scanJobView(job));
return;
}
if (req.method === 'GET' && req.url?.startsWith('/broadcast/status')) {
const txid = new URL(req.url, 'http://localhost').searchParams.get('txid') ?? '';
const job = broadcasts.get(txid);
if (!job) { res.writeHead(404); res.end(JSON.stringify({ error: 'unknown txid', txid })); return; }
res.writeHead(200); res.end(JSON.stringify(jobView(job)));
return;
}
if (req.method === 'GET' && req.url === '/health') {
const peers = pool.pickReady().map((p) => ({
host: p.host, port: p.port, services: p.services.toString(),
hasCF: (p.services & NODE_COMPACT_FILTERS) === NODE_COMPACT_FILTERS,
}));
res.writeHead(200);
res.end(JSON.stringify({
readyPeers: peers.length,
cfPeers: peers.filter((p) => p.hasCF).length,
candidates: pool.candidateCount,
chainHeight: chain.tipHeight,
crawl: crawlStatus,
filterCache: filterCache.summary(),
syncing,
peers,
}));
return;
}
if (req.method === 'GET' && req.url?.startsWith('/height-at')) {
// Resolve a wall-clock time to a block height by binary search
// over the header chain's on-disk timestamps. Used by the wallet
// UI's "since 2019" recovery presets so users pick the year the
// seed was created rather than a raw block number.
//
// GET /height-at?year=2019 → block on/after Jan 1 2019 UTC
// GET /height-at?ts=1546300800 → same, raw unix seconds
const q = new URL(req.url, 'http://localhost').searchParams;
let targetTs: number | null = null;
const y = q.get('year');
const t = q.get('ts');
if (y) {
const year = Number(y);
if (!Number.isInteger(year) || year < 2014 || year > 2100) {
res.writeHead(400); res.end(JSON.stringify({ error: 'year out of range (2014–2100)' })); return;
}
targetTs = Math.floor(Date.UTC(year, 0, 1) / 1000);
} else if (t) {
targetTs = Number(t);
if (!Number.isFinite(targetTs)) { res.writeHead(400); res.end(JSON.stringify({ error: 'ts not a number' })); return; }
} else {
res.writeHead(400); res.end(JSON.stringify({ error: 'expected ?year=YYYY or ?ts=<unix>' })); return;
}
if (chain.tipHeight <= 0) {
res.writeHead(503); res.end(JSON.stringify({ error: 'chain not synced' })); return;
}
// Binary search — headers are ordered by height AND by timestamp
// (block timestamps are monotonic enough for this purpose; small
// occasional out-of-order stamps don't matter since we're picking
// the first block on/after the target).
let lo = 1;
let hi = chain.tipHeight;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
const h = chain.getByHeight(mid);
if (!h) { hi = mid - 1; continue; }
if (h.timestamp < targetTs) lo = mid + 1;
else hi = mid;
}
const found = chain.getByHeight(lo);
res.writeHead(200); res.end(JSON.stringify({
targetTs,
height: lo,
blockHash: found?.hash ?? null,
blockTimestamp: found?.timestamp ?? null,
chainTip: chain.tipHeight,
}));
return;
}
if (req.method === 'GET' && req.url?.startsWith('/tx/')) {
// Fetch a full transaction hex by txid, requiring the caller to
// supply the containing block hash (they get it from /balance's
// UTXO records). We fetch the whole block from a peer and pluck
// the tx out. Without the block hash we'd need a full mempool
// index — which the daemon doesn't run and doesn't want to run.
//
// Used by the Send flow to build BIP44 legacy P2PKH inputs which
// require `nonWitnessUtxo` (the full prev-tx) for signing.
//
// GET /tx/<txid>?blockHash=<hex> → {hex, blockHash, txid}
const parts = req.url.split('?');
const txid = parts[0].slice(4);
const q = new URL(req.url, 'http://localhost').searchParams;
const blockHash = q.get('blockHash');
if (!/^[0-9a-f]{64}$/i.test(txid)) {
res.writeHead(400); res.end(JSON.stringify({ error: 'txid must be 64 hex chars' })); return;
}
if (!blockHash || !/^[0-9a-f]{64}$/i.test(blockHash)) {
res.writeHead(400); res.end(JSON.stringify({ error: 'expected ?blockHash=<64 hex chars>' })); return;
}
const ready = pool.pickReady();
if (ready.length === 0) { res.writeHead(503); res.end(JSON.stringify({ error: 'no ready peer' })); return; }
const peer = ready[0];
try {
const block = await fetchBlockOnce(peer, blockHash, 30_000);
const found = block.transactions?.find((t) => t.getId() === txid);
if (!found) {
res.writeHead(404); res.end(JSON.stringify({ error: `tx ${txid} not found in block ${blockHash}` })); return;
}
res.writeHead(200); res.end(JSON.stringify({
txid,
blockHash,
hex: found.toHex(),
}));
} catch (e) {
res.writeHead(504); res.end(JSON.stringify({ error: (e as Error).message }));
}
return;
}
if ((req.method === 'POST' || req.method === 'GET') && req.url?.match(/^\/broadcast(\?|$)/)) {
// Same POST-with-body / GET-with-?body= duality as /balance.
let bodyRaw: string;
const q = new URL(req.url!, 'http://localhost').searchParams;
if (req.method === 'POST') {
bodyRaw = await readBody(req);
} else {
const b = q.get('body');
if (!b) { res.writeHead(400); res.end(JSON.stringify({ error: 'GET /broadcast needs ?body=<base64url-encoded {hex:...}>' })); return; }
try { bodyRaw = Buffer.from(b, 'base64url').toString('utf8'); }
catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: `body= not valid base64url: ${(e as Error).message}` })); return; }
}
const { hex } = JSON.parse(bodyRaw);
if (typeof hex !== 'string' || !/^[0-9a-fA-F]+$/.test(hex)) {
res.writeHead(400); res.end(JSON.stringify({ error: 'expected {hex: "..."} hex-only' })); return;
}
const ready = pool.pickReady();
if (ready.length < 3) { res.writeHead(503); res.end(JSON.stringify({ error: `only ${ready.length} peers ready, need 3` })); return; }
// Dandelion++ stem-phase relay is on by default (privacy-first).
// Pass ?dandelion=0 to force the direct fan-out path.
//
// Announce to at most 8 peers; watch the WHOLE ready pool for the
// tx to be announced back by a peer we did not hand it to. That
// echo is the only proof of mempool acceptance available over
// P2P (Core drops policy failures silently). `relayed: false` in
// the response means "not known to be accepted" — the wallet
// treats it as a failed send.
const useDandelion = q.get('dandelion') !== '0';
const announceTo = ready.slice(0, 8);
// The full dance (getdata wait + relay-echo wait) can run 20-45s.
// Reverse proxies in front of us — notably the navigate.st BNS
// gateway — give up around 15s and hand the wallet a 502 for a
// send that then SUCCEEDS in the background. So: run the job
// detached, keyed by txid, and reply as soon as it finishes OR
// after EARLY_REPLY_MS with {pending:true}; the wallet polls
// /broadcast/status. A repeat POST for the same txid (wallet retry
// after a proxy error) attaches to the running job instead of
// announcing the tx a second time.
let txid: string;
try { txid = Transaction.fromHex(hex).getId(); }
catch (e) { res.writeHead(400); res.end(JSON.stringify({ error: `bad tx hex: ${(e as Error).message}` })); return; }
pruneBroadcasts();
let job = broadcasts.get(txid);
if (!job) {
const t0 = Date.now();
const promise = useDandelion
? broadcastDandelion(announceTo, hex, { listeners: ready, relayWaitMs: 30_000 })
: broadcastTx(announceTo, hex, { listeners: ready, relayWaitMs: 20_000 }).then((r) => ({ ...r, via: 'direct' as const }));
job = { txid, startedAt: t0, state: 'pending', via: useDandelion ? 'dandelion' : 'direct' };
broadcasts.set(txid, job);
promise.then((result) => {
job!.state = 'done'; job!.result = result; job!.finishedAt = Date.now();
console.log(
`[broadcast] ${result.txid} via=${result.via}${'stemPeer' in result && result.stemPeer ? `(${result.stemPeer})` : ''} ` +
`requestedBy=${result.requestedBy}/${result.totalPeers} relayed=${result.relayed}${result.relayedBy ? ` by ${result.relayedBy}` : ''} ` +
`listeners=${result.listeners} rejects=${result.rejects.length} ${Date.now() - t0}ms`,
);
}).catch((e: Error) => {
job!.state = 'failed'; job!.error = e.message; job!.finishedAt = Date.now();
console.log(`[broadcast] ${txid} FAILED after ${Date.now() - t0}ms: ${e.message}`);
});
job.promise = promise.then(() => undefined, () => undefined);
}
await Promise.race([job.promise, sleep(EARLY_REPLY_MS)]);
res.writeHead(200); res.end(JSON.stringify(jobView(job)));
return;
}
if ((req.method === 'POST' || req.method === 'GET') && req.url?.match(/^\/balance(\?|$)/)) {
// Accepts POST with JSON body OR GET with ?body=<base64url JSON>.
// The GET path is a compat fallback for browsers/clients that
// downgrade POST-with-body to GET (Theseus BNS-resolved fetch
// does this empirically) and thereby lose the body.
let raw: string;
if (req.method === 'POST') {
raw = await readBody(req);
} else {
const q = new URL(req.url!, 'http://localhost').searchParams;
const b = q.get('body');
if (!b) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'GET /balance needs ?body=<base64url-encoded JSON> — the same object POST would carry' }));
return;
}
try {
raw = Buffer.from(b, 'base64url').toString('utf8');
} catch (e) {
res.writeHead(400); res.end(JSON.stringify({ error: `body= not valid base64url: ${(e as Error).message}` })); return;
}
}
const body = JSON.parse(raw) as {
xpub?: string;
purpose?: 44 | 49 | 84 | 86;
account?: number;
descriptors?: Array<{ xpub: string; purpose: 44 | 49 | 84 | 86; account?: number }>;
// Raw scriptPubKey hex list. Used for WIF-import scans where
// the client already knows the exact scripts to watch and there
// are no xpub-derived children to enumerate.
scripts?: string[];
gapLimit?: number;
startHeight?: number;
endHeight?: number;
};
const descriptors = body.descriptors
?? (typeof body.xpub === 'string'
? [{ xpub: body.xpub, purpose: body.purpose ?? 84, account: body.account ?? 0 }]
: null);
const rawScripts = Array.isArray(body.scripts)
? body.scripts.filter((s): s is string => typeof s === 'string' && /^[0-9a-f]+$/i.test(s))
: [];
if ((!descriptors || descriptors.length === 0) && rawScripts.length === 0) {
res.writeHead(400);
res.end(JSON.stringify({ error: 'expected {xpub}, {descriptors:[...]}, or {scripts:[hex,…]}' }));
return;
}
const gapLimit = Math.min(body.gapLimit ?? 20, 100);
const DEFAULT_WINDOW = 1000;
const rawStart = body.startHeight ?? Math.max(1, chain.tipHeight - DEFAULT_WINDOW);
const startHeight = Math.max(1, rawStart);
const endHeight = Math.min(chain.tipHeight, body.endHeight ?? chain.tipHeight);
if (endHeight < startHeight) {
res.writeHead(503); res.end(JSON.stringify({ error: `chain not synced enough (tip ${endHeight})` })); return;
}
const cfPeers = pool.pickWithServices(NODE_COMPACT_FILTERS);
if (cfPeers.length === 0) {
res.writeHead(503); res.end(JSON.stringify({ error: 'no CF-serving peer ready' })); return;
}
// Shuffle so a hot peer doesn't get pummeled and we spread load.
for (let i = cfPeers.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[cfPeers[i], cfPeers[j]] = [cfPeers[j], cfPeers[i]];
}
// Merge all descriptors' scriptPubKeys into one watch set. Same
// scan work regardless of descriptor count — filter probing is
// O(watched-scripts × filter-bits), block extract is O(tx-outs).
// The daemon does the whole recovery sweep in one filter pass.
const scriptBuffers: Buffer[] = [];
const watchScripts = new Set<string>();
const perScript = new Map<string, { purpose: number; account: number; change: 0 | 1; index: number } | { kind: 'wif' }>();
if (descriptors) {
for (const d of descriptors) {
const account = bip32.fromBase58(d.xpub, digibyte);
for (const change of [0, 1] as const) {
const chainNode = account.derive(change);
for (let i = 0; i < gapLimit; i++) {
const node = chainNode.derive(i);
const pubkey = Buffer.from(node.publicKey);
const spk = spkForPurpose(d.purpose, pubkey);
const hex = spk.toString('hex');
if (watchScripts.has(hex)) continue; // dedupe across descriptors
scriptBuffers.push(spk);
watchScripts.add(hex);
perScript.set(hex, {
purpose: d.purpose, account: d.account ?? 0, change, index: i,
});
}
}
}
}
for (const hex of rawScripts) {
const lower = hex.toLowerCase();
if (watchScripts.has(lower)) continue;
scriptBuffers.push(Buffer.from(lower, 'hex'));
watchScripts.add(lower);
perScript.set(lower, { kind: 'wif' });
}
// Filters from CF peers with per-span failover; blocks from FULL
// nodes (pruned peers ignore getdata for anything older than ~288
// blocks — the old "blocks=0/3 … timed out" failure). See
// scan-driver.ts.
//
// Scans run as JOBS, like broadcasts: the request returns within
// EARLY_REPLY_MS with either the finished result or {pending:true,
// job} for the wallet to poll at /balance/status. Two reasons:
// proxies in front of us time out at ~15s, and a one-year window
// is minutes of filter matching even from cache.
//
// Results are remembered per script-set. A later request for the
// same scripts only scans the blocks above the previous result
// and merges (spends of previously-found coins are detected via
// the owned-seed), so a periodic refresh is a few new blocks.
const key = createHash('sha256').update([...watchScripts].sort().join(',')).digest('hex').slice(0, 24);
const jobId = `${key}:${startHeight}:${endHeight}`;
pruneScanJobs();
let job = scanJobs.get(jobId);
if (!job) {
const prev = lastScan.get(key);
// Incremental when the previous result covers our start and is
// not ahead of us (a narrower re-scan after a wider one is fine:
// the previous coins are all inside the requested window's past).
const incremental = !!prev && prev.startHeight <= startHeight && prev.endHeight < endHeight && prev.endHeight >= startHeight - 1;
const scanFrom = incremental ? prev!.endHeight + 1 : startHeight;
const blockPeers = shuffled(pool.pickReady().filter((p) =>
(p.services & NODE_NETWORK) === NODE_NETWORK && (p.services & NODE_NETWORK_LIMITED) === 0n));
const blockPool = blockPeers.length > 0 ? blockPeers : pool.pickReady();
const t0 = Date.now();
job = { id: jobId, key, startedAt: t0, state: 'pending', startHeight, endHeight, scanFrom, progress: scanFrom - 1, incremental };
scanJobs.set(jobId, job);
const perScriptSnapshot = perScript;
const run = (async () => {
const r = await scanWithPeers({
cfPeers, blockPeers: blockPool, chain, startHeight: scanFrom, endHeight,
watchScripts, scriptBuffers, filterCache,
filterTimeoutMs: 12_000, blockTimeoutMs: 15_000,
ownedSeed: incremental ? prev!.utxos.map((u) => `${u.txid}:${u.vout}`) : undefined,
onProgress: (p) => {
job!.progress = p.height;
if ((p.height - scanFrom) % 100_000 < 1000) {
console.log(`[scan] ${key.slice(0, 8)} … ${p.height.toLocaleString()}/${endHeight.toLocaleString()} cache=${p.fromCache} net=${p.fromNetwork} hits=${p.hits} ${Date.now() - t0}ms`);
}
},
});
// Merge with the previous result in incremental mode.
const spentSet = new Set(r.spent);
const base = incremental ? prev!.utxos.filter((u) => !spentSet.has(`${u.txid}:${u.vout}`)) : [];
const utxos = [...base, ...r.utxos.map((u) => ({
txid: u.txid, vout: u.vout, value: Number(u.value),
scriptPubKey: u.scriptPubKey.toString('hex'), blockHash: u.blockHash,
}))];
const totalSats = utxos.reduce((a, u) => a + u.value, 0);
const result = {
totalSats,
utxos: utxos.map((u) => ({ ...u, path: perScriptSnapshot.get(u.scriptPubKey) ?? null })),
scannedRange: { startHeight, endHeight },
incremental, scannedFrom: scanFrom,
blocksFetched: r.blocksFetched, filterHits: r.filterHits,
filtersFromCache: r.filtersFromCache, filtersFromNetwork: r.filtersFromNetwork,
elapsedMs: Date.now() - t0,
descriptorsScanned: descriptors?.length ?? 0,
watchedScripts: watchScripts.size,
};
lastScan.set(key, { startHeight, endHeight, utxos, at: Date.now() });
saveLastScansSoon();
console.log(`[scan] ${key.slice(0, 8)} ok${incremental ? ' (incremental from ' + scanFrom.toLocaleString() + ')' : ''}: ${utxos.length} utxos, ${totalSats} sat, ${r.filtersFromCache}c/${r.filtersFromNetwork}n filters, ${r.blocksFetched} blocks, ${Date.now() - t0}ms`);
return result;
})();
run.then((result) => { job!.state = 'done'; job!.result = result; job!.finishedAt = Date.now(); })
.catch((e: Error) => {
job!.state = 'failed'; job!.error = e.message; job!.finishedAt = Date.now();
console.log(`[scan] ${key.slice(0, 8)} ${scanFrom}-${endHeight} failed after ${Date.now() - t0}ms: ${e.message.slice(0, 160)}`);
});
job.promise = run.then(() => undefined, () => undefined);
}
await Promise.race([job.promise, sleep(EARLY_REPLY_MS)]);
res.writeHead(200);
res.end(scanJobView(job));
return;
}
res.writeHead(404);
res.end(JSON.stringify({ error: 'not found', endpoints: ['/health', 'POST /broadcast', 'POST /balance'] }));
} catch (e) {
// Never let a handler error take the whole daemon down.
console.log(`[http] ${req.method} ${req.url?.split('?')[0]} failed: ${(e as Error).stack ?? (e as Error).message}`);
if (res.headersSent) { try { res.end(); } catch { /* socket gone */ } return; }
res.writeHead(500);
res.end(JSON.stringify({ error: (e as Error).message }));
}
});
// Last-resort guards: log and keep serving rather than exit on a stray
// rejection from a peer handler.
process.on('unhandledRejection', (e) => console.log(`[daemon] unhandled rejection: ${(e as Error)?.stack ?? String(e)}`));
process.on('uncaughtException', (e) => console.log(`[daemon] uncaught exception: ${e.stack ?? e.message}`));
// Request a single block from `peer` by hash, resolve when it arrives
// (or reject on timeout). Detaches its listener afterwards so it does
// not leak across concurrent /tx/ requests.
function fetchBlockOnce(peer: import('../src/peer.js').Peer, blockHash: string, timeoutMs: number): Promise<Block> {
return new Promise((resolve, reject) => {
const onBlock = (b: Block) => {
if (b.getId() !== blockHash) return; // not ours; keep waiting
cleanup();
resolve(b);
};
const cleanup = () => {
clearTimeout(timer);
peer.off('block', onBlock as never);
};
const timer = setTimeout(() => {
cleanup();
reject(new Error(`fetchBlockOnce: no block ${blockHash} in ${timeoutMs}ms`));
}, timeoutMs);
peer.on('block', onBlock as never);
peer.send('getdata', encodeGetBlock(blockHash));
});
}
function spkForPurpose(purpose: 44 | 49 | 84 | 86, pubkey: Buffer): Buffer {
switch (purpose) {
case 44:
return Buffer.from(payments.p2pkh({ pubkey, network: digibyte }).output!);
case 49: {
const redeem = payments.p2wpkh({ pubkey, network: digibyte });
return Buffer.from(payments.p2sh({ redeem, network: digibyte }).output!);
}
case 84:
return Buffer.from(payments.p2wpkh({ pubkey, network: digibyte }).output!);
case 86:
return Buffer.from(payments.p2tr({ internalPubkey: pubkey.subarray(1, 33), network: digibyte }).output!);
}
}
server.listen(PORT, '127.0.0.1', () => {
console.log(`[daemon] http://127.0.0.1:${PORT}`);
console.log(`[daemon] GET /health`);
console.log(`[daemon] POST /broadcast {"hex": "..."}`);
console.log(`[daemon] POST /balance {"xpub":"...","purpose":84,"account":0,"gapLimit":20,"startHeight":...}`);
});
async function readBody(req: http.IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
return Buffer.concat(chunks).toString('utf8');
}
function sleep(ms: number): Promise<void> { return new Promise((r) => setTimeout(r, ms)); }
// ---- scan job registry + per-script-set memory (see /balance handler) ----
interface ScanJob {
id: string; key: string; startedAt: number; finishedAt?: number;
state: 'pending' | 'done' | 'failed';
startHeight: number; endHeight: number; scanFrom: number; progress: number; incremental: boolean;
result?: unknown; error?: string; promise?: Promise<void>;
}
interface RememberedScan {
startHeight: number; endHeight: number; at: number;
utxos: Array<{ txid: string; vout: number; value: number; scriptPubKey: string; blockHash: string }>;
}
const scanJobs = new Map<string, ScanJob>();
const lastScan = new Map<string, RememberedScan>();
const SCAN_JOB_TTL_MS = 30 * 60_000;
// Remembered results are what make refreshes incremental. Keep them for
// a month (a wallet that has not been opened for a month re-scans its
// window once) and PERSIST them, so a daemon restart does not send every
// wallet back to a from-scratch scan. Nothing sensitive is stored: only
// a hash of the watched scripts, the scanned range, and the found coins
// (txid/vout/value/script) — the same data any block explorer shows.
const LAST_SCAN_TTL_MS = 30 * 24 * 60 * 60_000;
const LAST_SCAN_PATH = join(defaultCacheDir(), 'scans.json');
function loadLastScans(): void {
try {
if (!existsSync(LAST_SCAN_PATH)) return;
const raw = JSON.parse(readFileSync(LAST_SCAN_PATH, 'utf8')) as Record<string, RememberedScan>;
const now = Date.now();
for (const [k, r] of Object.entries(raw)) {
if (r && typeof r.endHeight === 'number' && Array.isArray(r.utxos) && now - r.at <= LAST_SCAN_TTL_MS) lastScan.set(k, r);
}
console.log(`[scan] restored ${lastScan.size} remembered wallet scan(s) from ${LAST_SCAN_PATH}`);
} catch (e) {
console.log(`[scan] could not restore remembered scans: ${(e as Error).message}`);
}
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
function saveLastScansSoon(): void {
if (saveTimer) return;
saveTimer = setTimeout(() => {
saveTimer = null;
try {
const tmp = LAST_SCAN_PATH + '.tmp';
writeFileSync(tmp, JSON.stringify(Object.fromEntries(lastScan)));
renameSync(tmp, LAST_SCAN_PATH);
} catch (e) {
console.log(`[scan] could not persist remembered scans: ${(e as Error).message}`);
}
}, 1000);
}
function pruneScanJobs(): void {
const now = Date.now();
for (const [k, j] of scanJobs) if (j.state !== 'pending' && now - j.startedAt > SCAN_JOB_TTL_MS) scanJobs.delete(k);
let dropped = false;
for (const [k, r] of lastScan) if (now - r.at > LAST_SCAN_TTL_MS) { lastScan.delete(k); dropped = true; }
if (dropped) saveLastScansSoon();
}
loadLastScans();
function scanJobView(j: ScanJob): string {
const elapsedMs = (j.finishedAt ?? Date.now()) - j.startedAt;
if (j.state === 'done') return JSON.stringify({ ...(j.result as object), state: 'done', pending: false, job: j.id, elapsedMs });
if (j.state === 'failed') return JSON.stringify({ state: 'failed', pending: false, job: j.id, error: j.error, elapsedMs });
const total = Math.max(1, j.endHeight - j.scanFrom + 1);
const done = Math.max(0, j.progress - j.scanFrom + 1);
return JSON.stringify({
state: 'pending', pending: true, job: j.id, elapsedMs,
progress: { height: j.progress, from: j.scanFrom, to: j.endHeight, percent: Math.min(100, Math.round(100 * done / total)) },
incremental: j.incremental,
});
}
// ---- broadcast job registry (see /broadcast handler) ----
const EARLY_REPLY_MS = 6_000; // well under any proxy's ~15s timeout
const BROADCAST_TTL_MS = 15 * 60_000;
interface BroadcastJob {
txid: string;
startedAt: number;
finishedAt?: number;
state: 'pending' | 'done' | 'failed';
via: 'dandelion' | 'direct';
result?: Awaited<ReturnType<typeof broadcastDandelion>>;
error?: string;
promise?: Promise<void>;
}
const broadcasts = new Map<string, BroadcastJob>();
function pruneBroadcasts(): void {
const now = Date.now();
for (const [k, j] of broadcasts) if (now - j.startedAt > BROADCAST_TTL_MS) broadcasts.delete(k);
}
function jobView(j: BroadcastJob): Record<string, unknown> {
if (j.state === 'done' && j.result) return { ...j.result, state: 'done', pending: false, elapsedMs: (j.finishedAt ?? Date.now()) - j.startedAt };
if (j.state === 'failed') return { txid: j.txid, state: 'failed', pending: false, relayed: false, error: j.error, elapsedMs: (j.finishedAt ?? Date.now()) - j.startedAt };
return { txid: j.txid, state: 'pending', pending: true, via: j.via, elapsedMs: Date.now() - j.startedAt };
}
for (const sig of ['SIGINT', 'SIGTERM'] as const) {
process.on(sig, () => {
console.log(`\n[daemon] ${sig} — shutting down`);
server.close();
pool.stop();
chain.close();
process.exit(0);
});
}
// Reference kept so it's not stripped by dead-code elimination
void DGB_COIN_TYPE;

39
packages/p2p/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "@dgb-wallet/p2p",
"version": "0.0.1",
"description": "DigiByte P2P client — direct peer connections, BIP157/158 compact block filters, client-side address matching. The Maximum Privacy backend for @dgb-wallet.",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"handshake": "tsx scripts/handshake-demo.ts",
"pool": "tsx scripts/pool-demo.ts",
"headers": "tsx scripts/header-sync-demo.ts",
"filters": "tsx scripts/filter-sync-demo.ts",
"blocks": "tsx scripts/block-fetch-demo.ts",
"cache": "tsx scripts/cache-demo.ts",
"daemon": "tsx bin/dgb-daemon.ts"
},
"dependencies": {
"@bitcoinerlab/secp256k1": "^1.1.1",
"@dgb-wallet/core": "*",
"bip32": "^4.0.0",
"bitcoinjs-lib": "^6.1.7"
},
"devDependencies": {
"@types/node": "^20.11.0",
"tsx": "^4.7.0",
"typescript": "^5.3.0"
}
}

View file

@ -0,0 +1,169 @@
// Milestone A5 demo — filter probe → block fetch → UTXO extraction.
// Given a set of watched scripts and a small block range:
// 1. Sync headers up to the range
// 2. Download filters for the range
// 3. For any filter that matches: fetch the full block
// 4. Extract our UTXOs from those blocks
// Also fetches one arbitrary block unconditionally to prove the
// block-fetch + decode pipeline works even when the wallet has no
// hits in the tested range (the common case for a throwaway seed).
import {
PeerPool,
HeaderChain,
encodeGetHeaders,
encodeGetCfilters,
encodeGetBlock,
filterMatchesAny,
extractFromBlock,
FILTER_TYPE_BASIC,
NODE_COMPACT_FILTERS,
type BlockHeader,
type CfilterMessage,
} from '../src/index.js';
import type { Block } from 'bitcoinjs-lib';
import {
seedFromPhrase,
rootFromSeed,
accountNode,
addressNode,
digibyte,
} from '@dgb-wallet/core';
import { payments } from 'bitcoinjs-lib';
const DGB_GENESIS_HASH = '7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496';
const START = Number(process.argv[2]) || 145_000;
const COUNT = Number(process.argv[3]) || 100;
const RUN_MS = 120_000;
console.log('\n=== DigiByte BIP158 Filter → Block Fetch → UTXO Extract ===\n');
console.log(`Filter range: ${START.toLocaleString()} .. ${(START + COUNT).toLocaleString()}\n`);
const testSeed = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = await seedFromPhrase(testSeed);
const root = rootFromSeed(seed);
const acct = accountNode(root, 84, 0);
const watchScripts = new Set<string>();
const scriptBuffers: Buffer[] = [];
for (let i = 0; i < 20; i++) {
const node = addressNode(acct, 0, i);
const spk = payments.p2wpkh({ pubkey: Buffer.from(node.publicKey), network: digibyte }).output!;
const buf = Buffer.from(spk);
scriptBuffers.push(buf);
watchScripts.add(buf.toString('hex'));
}
const ownedOutpoints = new Set<string>();
// Use a bigger pool so we're likely to hit a CF-serving peer quickly
// (only ~30% of DGB peers advertise NODE_COMPACT_FILTERS per A2 data).
const pool = new PeerPool({ poolSize: 12, peerOpts: { handshakeTimeoutMs: 10_000 } });
const chain = new HeaderChain();
chain.appendGenesis({
hash: DGB_GENESIS_HASH, prevBlock: '00'.repeat(32), merkleRoot: '',
version: 0, timestamp: 0, bits: 0, nonce: 0, raw: Buffer.alloc(80),
}, 0);
let peer: ReturnType<typeof pool.pickReady>[number] | null = null;
let phase: 'headers' | 'filters' | 'blocks' | 'done' = 'headers';
let filterCount = 0;
const matchedHashes: string[] = [];
let blocksFetched = 0;
let totalDiscovered = 0;
let didUnconditionalFetch = false;
const t0 = Date.now();
pool.on('connect', () => {
if (peer) return;
const cfPeers = pool.pickWithServices(NODE_COMPACT_FILTERS);
if (cfPeers.length === 0) return; // wait for a CF-serving peer to connect
peer = cfPeers[0];
console.log(`Peer: ${peer.host}:${peer.port} (advertises COMPACT_FILTERS)\n`);
peer.on('headers', (headers: BlockHeader[]) => {
if (phase !== 'headers') return;
chain.append(headers);
process.stdout.write(`\r headers: ${chain.tipHeight.toLocaleString()} `);
if (chain.tipHeight >= START + COUNT) {
const stop = chain.getByHeight(START + COUNT - 1);
if (!stop) return finish('no stop header');
phase = 'filters';
console.log(`\n header sync done in ${((Date.now() - t0) / 1000).toFixed(1)}s\n`);
console.log(`Requesting cfilters ${START}..${START + COUNT - 1}`);
peer!.send('getcfilters', encodeGetCfilters(FILTER_TYPE_BASIC, START, stop.hash));
return;
}
if (headers.length >= 2000) {
peer!.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
}
});
peer.on('cfilter', (msg: CfilterMessage) => {
if (phase !== 'filters') return;
filterCount++;
if (filterMatchesAny(msg.filter, msg.blockHash, scriptBuffers)) {
console.log(` HIT block ${msg.blockHash}`);
matchedHashes.push(msg.blockHash);
}
if (filterCount % 25 === 0) {
process.stdout.write(`\r filters: ${filterCount}/${COUNT}, hits: ${matchedHashes.length} `);
}
if (filterCount >= COUNT) {
phase = 'blocks';
console.log(`\n\n filter sweep done in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
console.log(` ${matchedHashes.length} filter hits, ${COUNT - matchedHashes.length} clean\n`);
// Always fetch one block unconditionally, so we prove the fetch/
// decode pipeline works even when there are no filter hits.
const proofBlock = chain.getByHeight(START)!;
console.log(`Unconditional proof-of-fetch: block ${proofBlock.hash}`);
peer!.send('getdata', encodeGetBlock(proofBlock.hash));
for (const h of matchedHashes) {
peer!.send('getdata', encodeGetBlock(h));
}
}
});
peer.on('block', (block: Block) => {
if (phase !== 'blocks' && phase !== 'done') return;
const blockHash = Buffer.from(block.getHash()).reverse().toString('hex');
blocksFetched++;
if (!didUnconditionalFetch) {
didUnconditionalFetch = true;
const txs = block.transactions ?? [];
console.log(` ✓ decoded block ${blockHash}: ${txs.length} txs, first tx has ${txs[0]?.outs?.length ?? 0} outputs`);
}
const { discovered, spent } = extractFromBlock(block, watchScripts, ownedOutpoints);
for (const u of discovered) {
totalDiscovered++;
console.log(` 💰 UTXO ${u.txid}:${u.vout} value=${u.value / 1e8} DGB (block ${blockHash})`);
}
for (const s of spent) {
console.log(` ⊘ spent ${s.spent.prevTxid}:${s.spent.prevVout} by ${s.txid}`);
}
if (blocksFetched >= matchedHashes.length + 1) finish('all blocks fetched');
});
peer.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
});
const runTimer = setTimeout(() => finish('runtime cap reached'), RUN_MS);
function finish(reason: string): void {
clearTimeout(runTimer);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
console.log('\n--- summary ---');
console.log(` reason: ${reason}`);
console.log(` elapsed: ${elapsed}s`);
console.log(` headers synced: ${chain.tipHeight.toLocaleString()}`);
console.log(` filters received: ${filterCount}`);
console.log(` filter hits: ${matchedHashes.length}`);
console.log(` blocks fetched: ${blocksFetched}`);
console.log(` UTXOs discovered: ${totalDiscovered}`);
if (totalDiscovered === 0 && didUnconditionalFetch) {
console.log('\n 0 UTXOs is expected — test seed has no history here.');
console.log(' Block-fetch + decode pipeline verified via the unconditional proof-of-fetch above.');
}
pool.stop();
process.exit(0);
}

View file

@ -0,0 +1,87 @@
// Milestone A6 demo — persistent, disk-backed header chain.
// Run once: downloads N headers from network, writes them to disk as
// they arrive. Run again: adopts the tip from disk in milliseconds
// and resumes from there.
//
// Usage:
// npm run cache -w @dgb-wallet/p2p [target_height] [cache_dir]
import { rm } from 'node:fs/promises';
import {
PeerPool,
HeaderChain,
encodeGetHeaders,
defaultCacheDir,
type BlockHeader,
} from '../src/index.js';
const DGB_GENESIS_HASH = '7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496';
const args = process.argv.slice(2).filter((a) => !a.startsWith('--'));
const TARGET = Number(args[0]) || 80_000;
const CACHE_DIR = args[1] || defaultCacheDir();
const CLEAR = process.argv.includes('--clear');
console.log('\n=== DigiByte Header Cache Demo (A6, disk-backed) ===\n');
console.log(`Cache dir: ${CACHE_DIR}`);
console.log(`Target: ${TARGET.toLocaleString()} headers`);
if (CLEAR) console.log('Flag --clear given → wiping cache first');
console.log();
if (CLEAR) {
await rm(CACHE_DIR, { recursive: true, force: true });
}
const t0 = Date.now();
const chain = new HeaderChain({ dir: CACHE_DIR });
chain.appendGenesis({
hash: DGB_GENESIS_HASH, prevBlock: '00'.repeat(32), merkleRoot: '',
version: 0, timestamp: 0, bits: 0, nonce: 0, raw: Buffer.alloc(80),
}, 0);
const startTip = chain.tipHeight;
const openMs = Date.now() - t0;
console.log(`[chain] opened in ${openMs}ms — tip=${startTip.toLocaleString()} (adopted from disk)`);
if (chain.tipHeight >= TARGET) {
console.log(`\nAlready at or past target height ${TARGET.toLocaleString()}. Nothing to sync.`);
console.log(`Elapsed: ${(Date.now() - t0)}ms (this launch is entirely disk-served)`);
chain.close();
process.exit(0);
}
console.log(`\nSyncing headers from ${chain.tipHeight.toLocaleString()} to ${TARGET.toLocaleString()}...`);
const pool = new PeerPool({ poolSize: 3, peerOpts: { handshakeTimeoutMs: 10_000 } });
let peer: ReturnType<typeof pool.pickReady>[number] | null = null;
const netStart = Date.now();
pool.on('connect', () => {
if (peer) return;
peer = pool.pickReady()[0];
if (!peer) return;
console.log(` peer: ${peer.host}:${peer.port}`);
peer.on('headers', (headers: BlockHeader[]) => {
chain.append(headers); // writes straight to disk, batched per call
process.stdout.write(`\r height: ${chain.tipHeight.toLocaleString()} `);
if (chain.tipHeight >= TARGET) {
const netMs = Date.now() - netStart;
const added = chain.tipHeight - startTip;
console.log(`\n\n network sync: ${netMs}ms for ${added.toLocaleString()} new headers`);
console.log(` disk usage: ${(chain.tipHeight * 80 / 1024 / 1024).toFixed(1)} MiB (80 bytes × ${chain.tipHeight.toLocaleString()} headers)`);
chain.close();
pool.stop();
process.exit(0);
} else if (headers.length >= 2000) {
peer!.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
}
});
peer.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
});
setTimeout(() => {
console.error('\ntimed out before reaching target');
chain.close();
pool.stop();
process.exit(1);
}, 120_000);

View file

@ -0,0 +1,142 @@
// Milestone A4 demo — download BIP158 compact filters from a peer,
// decode them, probe them locally against test scripts, report which
// blocks matched. Does NOT fetch full blocks or extract UTXOs (that's
// A5). This demo proves the wire messages + Golomb-Rice decode +
// SipHash key derivation are correct end-to-end against a real peer.
//
// Usage:
// npm run filters -w @dgb-wallet/p2p [start_height] [count]
// defaults: start=145000 count=200 (right after MultiAlgo activation)
import {
PeerPool,
HeaderChain,
encodeGetHeaders,
encodeGetCfilters,
filterMatchesAny,
FILTER_TYPE_BASIC,
NODE_COMPACT_FILTERS,
type BlockHeader,
type CfilterMessage,
} from '../src/index.js';
const DGB_GENESIS_HASH = '7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496';
const START = Number(process.argv[2]) || 145_000; // after MultiAlgo activation
const COUNT = Number(process.argv[3]) || 200;
const RUN_MS = 60_000;
console.log('\n=== DigiByte BIP158 Compact Filter Sync Demo ===\n');
console.log(`Target range: block ${START.toLocaleString()} .. ${(START + COUNT).toLocaleString()} (${COUNT} filters)\n`);
// Some throwaway scripts to probe filters against. These almost
// certainly WON'T match early-chain blocks (they're derived from a
// fresh random seed), but the demo proves the probing pipeline works:
// no false positives on random data, but also no crashes on real
// filters. To see a real match, feed a script that appeared in a
// block within the tested range.
import { seedFromPhrase, rootFromSeed, accountNode, addressNode, p2wpkhAddress, digibyte } from '@dgb-wallet/core';
import { payments } from 'bitcoinjs-lib';
// Use the well-known abandon-abandon test seed for reproducibility.
const testSeed = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
const seed = await seedFromPhrase(testSeed);
const root = rootFromSeed(seed);
const acct = accountNode(root, 84, 0);
const testScripts: Buffer[] = [];
for (let i = 0; i < 20; i++) {
const node = addressNode(acct, 0, i);
const spk = payments.p2wpkh({ pubkey: Buffer.from(node.publicKey), network: digibyte }).output!;
testScripts.push(Buffer.from(spk));
}
console.log(`Probing filters against ${testScripts.length} BIP84 scripts derived from the public all-abandon seed.\n`);
// Bigger pool improves the odds of a CF-serving peer landing early.
const pool = new PeerPool({ poolSize: 12, peerOpts: { handshakeTimeoutMs: 10_000 } });
const chain = new HeaderChain();
chain.appendGenesis({
hash: DGB_GENESIS_HASH, prevBlock: '00'.repeat(32), merkleRoot: '',
version: 0, timestamp: 0, bits: 0, nonce: 0, raw: Buffer.alloc(80),
}, 0);
let phase: 'headers' | 'filters' | 'done' = 'headers';
let peer: ReturnType<typeof pool.pickReady>[number] | null = null;
let stopHash: string | null = null;
let filterCount = 0;
let matchCount = 0;
const t0 = Date.now();
pool.on('connect', () => {
if (peer) return;
const cfPeers = pool.pickWithServices(NODE_COMPACT_FILTERS);
if (cfPeers.length === 0) return; // wait for a CF-serving peer
peer = cfPeers[0];
console.log(`Using peer ${peer.host}:${peer.port} (advertises COMPACT_FILTERS)\n`);
peer.on('headers', (headers: BlockHeader[]) => {
if (phase !== 'headers') return;
const res = chain.append(headers);
process.stdout.write(`\r headers: ${chain.tipHeight.toLocaleString()} `);
if (chain.tipHeight >= START + COUNT) {
// We reached (or passed) the stop height. Grab the block at
// START+COUNT-1 as our filter-request stopHash.
const stopH = chain.getByHeight(START + COUNT - 1);
if (!stopH) {
console.log(`\n ERROR: no header at height ${START + COUNT - 1}`);
pool.stop();
process.exit(1);
}
stopHash = stopH.hash;
phase = 'filters';
console.log(`\n header sync done in ${((Date.now() - t0) / 1000).toFixed(1)}s\n`);
console.log(`Requesting cfilters ${START} .. ${START + COUNT - 1}...`);
peer!.send('getcfilters', encodeGetCfilters(FILTER_TYPE_BASIC, START, stopHash));
return;
}
if (headers.length >= 2000) {
peer!.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
} else if (res.appended === 0) {
// Peer short-batched us before we reached target — try one more.
peer!.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
}
});
peer.on('cfilter', (msg: CfilterMessage) => {
if (phase !== 'filters') return;
filterCount++;
const hit = filterMatchesAny(msg.filter, msg.blockHash, testScripts);
if (hit) {
matchCount++;
console.log(` HIT block ${msg.blockHash} (filter #${filterCount})`);
}
if (filterCount % 25 === 0) {
process.stdout.write(`\r filters: ${filterCount}/${COUNT} matches: ${matchCount} `);
}
if (filterCount >= COUNT) finish('all filters received');
});
peer.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
});
const runTimer = setTimeout(() => finish('runtime cap reached'), RUN_MS);
function finish(reason: string): void {
clearTimeout(runTimer);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
console.log('\n\n--- summary ---');
console.log(` reason: ${reason}`);
console.log(` elapsed: ${elapsed}s`);
console.log(` headers: ${chain.tipHeight.toLocaleString()}`);
console.log(` filters got: ${filterCount}`);
console.log(` filter hits: ${matchCount}`);
console.log(
` bandwidth eq: ${(filterCount * 30 / 1024).toFixed(1)} KB avg (30 bytes/filter avg for DGB)`,
);
console.log(
matchCount === 0
? '\n (0 hits is expected — test seed has no history in this range.)'
: `\n ${matchCount} hits means the test seed touched blocks in this range.\n Next step (A5): fetch those blocks and extract our UTXOs from them.`,
);
pool.stop();
process.exit(0);
}

View file

@ -0,0 +1,70 @@
// Milestone 1 demo: resolve DGB DNS seeds → connect to real peers →
// perform the P2P version/verack handshake → report per-peer service
// flags and user agent.
//
// This proves: correct magic bytes, correct port, correct protocol
// version, correct framing/varint/version-message encoding — all
// against real live peers on the DigiByte mainnet.
import { promises as dns } from 'node:dns';
import { DGB_P2P } from '@dgb-wallet/core';
import { Peer, servicesString } from '../src/index.js';
const PEERS_PER_SEED = 3;
const HANDSHAKE_TIMEOUT_MS = 10_000;
console.log('\n=== DigiByte P2P Handshake Demo ===\n');
console.log(`Discovering peers via DNS seeds on port ${DGB_P2P.defaultPort}...`);
const ips = new Set<string>();
for (const seed of DGB_P2P.dnsSeeds) {
try {
const addrs = await dns.resolve4(seed).catch(() => [] as string[]);
const addrs6 = await dns.resolve6(seed).catch(() => [] as string[]);
// Milestone 1 only supports IPv4 outbound in `writeNetAddr`.
for (const a of addrs.slice(0, PEERS_PER_SEED)) ips.add(a);
console.log(` ${seed} -> ${addrs.length} IPv4 + ${addrs6.length} IPv6`);
} catch (e) {
console.log(` ${seed} -> ${(e as Error).message}`);
}
}
const candidates = [...ips].slice(0, 8);
if (candidates.length === 0) {
console.error('\nNo peer IPs resolved. DGB DNS seed infrastructure may be offline.');
console.error('You can still hand-set a peer: npm run handshake -w @dgb-wallet/p2p -- <ip>');
process.exit(1);
}
// Allow overriding with a hand-supplied IP on the command line.
const override = process.argv.slice(2)[0];
const targets = override ? [override] : candidates;
console.log(`\nAttempting handshake with ${targets.length} peer(s)...\n`);
let successes = 0;
for (const ip of targets) {
const peer = new Peer({ host: ip, handshakeTimeoutMs: HANDSHAKE_TIMEOUT_MS });
try {
const info = await peer.handshake();
successes++;
console.log(`✓ ${ip}:${info.port}`);
console.log(` protocol: ${info.peer.protocolVersion}`);
console.log(` user agent: ${info.peer.userAgent}`);
console.log(` services: ${info.peerServices}`);
console.log(` tip height: ${info.peer.startHeight.toLocaleString()}`);
console.log(` rtt: ${info.durationMs}ms`);
} catch (e) {
console.log(`✗ ${ip} — ${(e as Error).message}`);
} finally {
peer.disconnect();
}
console.log();
}
console.log(`Result: ${successes}/${targets.length} handshakes succeeded.`);
if (successes === 0) {
console.log('\nIf everything timed out, check that outbound TCP to port 12024 is allowed');
console.log('and that at least one peer supports the protocol version we advertised.');
process.exit(1);
}

View file

@ -0,0 +1,104 @@
// Milestone A3 demo — download block headers from a real DGB peer,
// chain them by prev-hash, report tip height and hash.
//
// Scope note: this does NOT do PoW validation. That requires
// implementing the 5 DGB algos (SHA256/Scrypt/Skein/Qubit/Odocrypt)
// with per-algo difficulty targets (MultiShield). Separate milestone.
// For now we trust the peer's chain and validate only the prev-hash
// linkage — enough to prove the wire protocol works end-to-end.
import { PeerPool, HeaderChain, encodeGetHeaders, type BlockHeader } from '../src/index.js';
// DGB mainnet genesis block hash (chainparams.cpp). Verified via
// `digibyte-cli getblockhash 0` on a synced Core.
const DGB_GENESIS_HASH = '7497ea1b465eb39f1c8f507bc877078fe016d6fcb6dfad3a64c98dcc6e1e8496';
const RUN_MS = Number(process.argv[2]) || 60_000;
const TARGET_HEADERS = Number(process.argv[3]) || 20_000; // stop when we hit this many
console.log('\n=== DigiByte P2P Header Sync Demo ===\n');
console.log(`Runtime cap: ${RUN_MS / 1000}s`);
console.log(`Target: ${TARGET_HEADERS.toLocaleString()} headers\n`);
const pool = new PeerPool({ poolSize: 3, peerOpts: { handshakeTimeoutMs: 10_000 } });
const chain = new HeaderChain();
// Seed with a stub genesis (hash-only) so buildLocator() has an anchor.
// We don't actually have the 80-byte genesis header, but for chaining
// forward only the hash matters.
chain.appendGenesis(
{
hash: DGB_GENESIS_HASH,
prevBlock: '00'.repeat(32),
merkleRoot: '',
version: 0,
timestamp: 0,
bits: 0,
nonce: 0,
raw: Buffer.alloc(80),
},
0,
);
let startedRequest = false;
let received = 0;
let batches = 0;
const t0 = Date.now();
// Wait for at least one peer to be ready, then start syncing through it.
pool.on(
'connect',
({ host, port }: { host: string; port: number }) => {
if (startedRequest) return;
startedRequest = true;
console.log(`Using peer ${host}:${port} for header sync.\n`);
const peer = pool.pickReady()[0];
if (!peer) return;
peer.on('headers', (headers: BlockHeader[]) => {
batches++;
received += headers.length;
const res = chain.append(headers);
const tipStr = chain.tip ? chain.tip.hash.slice(0, 12) + '...' : '(none)';
console.log(
` batch #${batches}: got ${headers.length}, appended ${res.appended}, tip=${chain.tipHeight} ${tipStr}` +
(res.rejectedAt ? ` [REJECTED: ${res.reason}]` : ''),
);
if (chain.tipHeight >= TARGET_HEADERS) {
finish('target reached');
return;
}
// Request the next batch: locator = our current tip.
if (headers.length >= 2000) {
// Peer will send at most 2000 per batch; if it sent 2000 there
// are likely more. Request next.
peer.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
} else {
finish(`peer sent short batch (${headers.length}) — at their tip`);
}
});
// Kick off the sync — locator = [genesis]. Peer sends 2000
// headers starting from block 1 forward.
peer.send('getheaders', encodeGetHeaders(70019, chain.buildLocator()));
},
);
const runTimer = setTimeout(() => finish('runtime cap reached'), RUN_MS);
function finish(reason: string): void {
clearTimeout(runTimer);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
const rate = received > 0 ? (received / (Date.now() - t0) * 1000).toFixed(0) : '0';
console.log('\n--- summary ---');
console.log(` reason: ${reason}`);
console.log(` elapsed: ${elapsed}s`);
console.log(` batches: ${batches}`);
console.log(` headers total: ${received.toLocaleString()}`);
console.log(` chain tip: ${chain.tipHeight.toLocaleString()}`);
console.log(` chain size: ${chain.size.toLocaleString()}`);
console.log(` effective rate: ${rate} headers/sec`);
if (chain.tip) console.log(` tip hash: ${chain.tip.hash}`);
pool.stop();
process.exit(0);
}

View file

@ -0,0 +1,73 @@
// Milestone A2 demo — start a peer pool, watch it bootstrap from DNS
// seeds, connect to N peers, exchange getaddr, discover more peers.
// Prints every meaningful event and a summary after ~30 seconds.
import { PeerPool } from '../src/index.js';
const RUN_MS = Number(process.argv[2]) || 30_000;
const POOL_SIZE = Number(process.argv[3]) || 8;
console.log('\n=== DigiByte P2P Peer Pool Demo ===\n');
console.log(`Target pool size: ${POOL_SIZE}`);
console.log(`Runtime: ${RUN_MS / 1000}s\n`);
const pool = new PeerPool({
poolSize: POOL_SIZE,
peerOpts: { handshakeTimeoutMs: 10_000 },
});
let seededTotal = 0;
let connectCount = 0;
let failCount = 0;
let discoveredTotal = 0;
pool.on('seed', ({ seed, ips }: { seed: string; ips: string[] }) => {
console.log(` [seed] ${seed} -> ${ips.length} IPv4`);
seededTotal += ips.length;
});
pool.on('seedError', ({ seed, error }: { seed: string; error: string }) => {
console.log(` [seed] ${seed} -> ERROR ${error}`);
});
pool.on(
'connect',
({ host, port, userAgent, services, startHeight }: {
host: string;
port: number;
userAgent: string;
services: string;
startHeight: number;
}) => {
connectCount++;
console.log(` [connect] ${host}:${port} ua=${userAgent} h=${startHeight.toLocaleString()} services=${services}`);
},
);
pool.on('connectFail', ({ host, port, error }: { host: string; port: number; error: string }) => {
failCount++;
// Only log the first handful of failures; a healthy pool sees many.
if (failCount <= 5) {
console.log(` [connectFail] ${host}:${port} -> ${error}`);
}
});
pool.on(
'discovered',
({ count, poolSize, candidates }: { count: number; poolSize: number; candidates: number }) => {
discoveredTotal += count;
console.log(` [discovered] +${count} new peers (pool=${poolSize} live, ${candidates} queued)`);
},
);
pool.on('disconnect', ({ key }: { key: string }) => {
console.log(` [disconnect] ${key}`);
});
setTimeout(() => {
console.log('\n--- summary ---');
console.log(` seeded from DNS: ${seededTotal} IPs`);
console.log(` connect successes: ${connectCount}`);
console.log(` connect failures: ${failCount}${failCount > 5 ? ' (first 5 shown above)' : ''}`);
console.log(` peers discovered: ${discoveredTotal} via getaddr`);
console.log(` live at end: ${pool.liveCount}/${POOL_SIZE}`);
console.log(` queued candidates: ${pool.candidateCount}`);
console.log();
pool.stop();
process.exit(0);
}, RUN_MS);

View file

@ -0,0 +1,290 @@
import { Transaction } from 'bitcoinjs-lib';
import type { Peer } from './peer.js';
import { encodeVarint } from './varint.js';
import { INV_MSG_WITNESS_TX, INV_MSG_DANDELION_TX } from './messages/block.js';
// Broadcast a signed transaction into the DGB P2P network.
//
// Protocol dance per peer:
// 1. We send `inv` [MSG_WITNESS_TX, txid] — advertise we have it
// 2. Peer sends `getdata` if it doesn't already have the tx
// 3. We reply with the full `tx` message
// 4. Peer validates against its mempool policy. Modern Core never
// sends `reject` — a policy failure (fee below minrelaytxfee,
// dust output, bad signature…) is a SILENT drop.
//
// Because of (4), "a peer asked for the tx" proves nothing about
// acceptance. The only positive evidence available at this layer is
// seeing the tx come BACK to us: a node that accepted it into its
// mempool announces it to its other peers, and we are one of them
// (our version message sets relay=true so peers send us tx invs).
// So after handing the tx over we watch the REST of the pool for an
// `inv` carrying our txid from a peer we did not give it to. That is
// `relayed` in the result. Callers must treat `relayed: false` as a
// failed broadcast — nothing is known to have been accepted.
export interface BroadcastOptions {
timeoutMs?: number; // default 15s — wait for a getdata
requireGetDataFrom?: number; // default 1 (hand-over counts once N peers request it)
/**
* Peers to watch for the relay echo. Should be the whole ready pool;
* the announce targets are excluded automatically (a node never
* re-announces a tx to the peer it got it from). Defaults to `peers`,
* which can never observe an echo — pass a bigger set.
*/
listeners?: Peer[];
relayWaitMs?: number; // default 20s — how long to wait for the echo
}
export interface BroadcastResult {
txid: string;
requestedBy: number; // count of peers that sent getdata
totalPeers: number;
rejects: Array<{ host: string; reason: string }>;
/** True iff an uninvolved peer announced the tx back to us. */
relayed: boolean;
relayedBy?: string;
relayWaitMs: number;
listeners: number; // how many peers we watched for the echo
}
export async function broadcastTx(
peers: Peer[],
rawTxHex: string,
opts: BroadcastOptions = {},
): Promise<BroadcastResult> {
const timeoutMs = opts.timeoutMs ?? 15_000;
const require = opts.requireGetDataFrom ?? 1;
const relayWaitMs = opts.relayWaitMs ?? 20_000;
if (peers.length === 0) throw new Error('broadcast: no peers');
const raw = Buffer.from(rawTxHex, 'hex');
const tx = Transaction.fromBuffer(raw);
const txid = tx.getId(); // big-endian display form
// Encode inv[MSG_WITNESS_TX, txid]. Payload:
// count varint (1) + [type u32 LE + hash 32 bytes LE]
const invPayload = Buffer.concat([
encodeVarint(1),
(() => {
const b = Buffer.alloc(36);
b.writeUInt32LE(INV_MSG_WITNESS_TX, 0);
Buffer.from(Buffer.from(txid, 'hex')).reverse().copy(b, 4);
return b;
})(),
]);
// Start watching for the echo BEFORE announcing, so a fast relay
// cannot slip past us between hand-over and listener attach.
const announceSet = new Set(peers);
const listeners = (opts.listeners ?? peers).filter((p) => !announceSet.has(p));
const echo = waitForRelayInv(listeners, txid, relayWaitMs);
let requestedBy = 0;
const rejects: BroadcastResult['rejects'] = [];
const requestedTracked = new Set<Peer>();
const handedOver = new Promise<void>((resolve, reject) => {
const perPeerHandlers: Array<() => void> = [];
const cleanup = () => {
clearTimeout(timer);
for (const off of perPeerHandlers) off();
};
for (const peer of peers) {
const onUnhandled = (frame: { command: string; payload: Buffer }) => {
if (frame.command !== 'getdata') return;
if (!containsOurTxid(frame.payload, txid)) return;
if (requestedTracked.has(peer)) return;
requestedTracked.add(peer);
peer.send('tx', raw);
requestedBy++;
if (requestedBy >= require) { cleanup(); resolve(); }
};
const onReject = (payload: Buffer) => {
// Legacy `reject` message: [msg-varstr][code u8][reason-varstr][data optional]
rejects.push({ host: peer.host, reason: `peer sent reject (${payload.length}B)` });
};
peer.on('unhandled', onUnhandled);
peer.on('reject', onReject as never);
perPeerHandlers.push(() => {
peer.off('unhandled', onUnhandled);
peer.off('reject', onReject as never);
});
peer.send('inv', invPayload);
}
const timer = setTimeout(() => {
cleanup();
if (requestedBy > 0) resolve();
else reject(new Error(`broadcast: no peer requested the tx after ${timeoutMs}ms (${peers.length} peers announced to)`));
}, timeoutMs);
});
try {
await handedOver;
} catch (e) {
echo.cancel();
throw e;
}
const relayedBy = await echo.result;
return {
txid,
requestedBy,
totalPeers: peers.length,
rejects,
relayed: relayedBy !== null,
relayedBy: relayedBy ?? undefined,
relayWaitMs,
listeners: listeners.length,
};
}
// Dandelion++ (BIP156) origin-protection broadcast. Instead of
// flooding an inv to N peers — which lets network observers pin us as
// the origin — we hand the tx to ONE randomly chosen Dandelion-capable
// peer using MSG_DANDELION_TX. That peer relays it along a private
// stem before fluff-phase flooding, so listeners see the last stem hop
// as the origin, not us. Callers get a { via: 'dandelion' | 'direct' }
// discriminator so UI can show which path was actually used.
//
// Relay evidence works the same way as broadcastTx: once the stem
// fluffs, the tx floods the network and some peer in our pool announces
// it back to us. Stem embargo adds latency, so allow a longer wait.
//
// Fallbacks to a direct broadcast (privacy downgrade, recorded in the
// result) happen when (a) no peer advertised Dandelion, (b) the stem
// peer never requested the tx, or (c) the stem produced no relay echo
// within the wait window — the alternative is leaving the user with an
// unbroadcast tx and no way to tell.
export interface DandelionResult extends BroadcastResult {
via: 'dandelion' | 'direct';
stemPeer?: string; // host we used for stem
}
export async function broadcastDandelion(
peers: Peer[],
rawTxHex: string,
opts: BroadcastOptions = {},
): Promise<DandelionResult> {
if (peers.length === 0) throw new Error('broadcast: no peers');
const stemCandidates = peers.filter((p) => p.hasDandelion);
if (stemCandidates.length === 0) {
const res = await broadcastTx(peers, rawTxHex, opts);
return { ...res, via: 'direct' };
}
const stem = stemCandidates[Math.floor(Math.random() * stemCandidates.length)];
const timeoutMs = opts.timeoutMs ?? 15_000;
const relayWaitMs = opts.relayWaitMs ?? 30_000;
const raw = Buffer.from(rawTxHex, 'hex');
const tx = Transaction.fromBuffer(raw);
const txid = tx.getId();
const txidLE = Buffer.from(Buffer.from(txid, 'hex')).reverse();
const invPayload = Buffer.concat([
encodeVarint(1),
(() => {
const b = Buffer.alloc(36);
b.writeUInt32LE(INV_MSG_DANDELION_TX, 0);
txidLE.copy(b, 4);
return b;
})(),
]);
const listeners = (opts.listeners ?? peers).filter((p) => p !== stem);
const echo = waitForRelayInv(listeners, txid, relayWaitMs);
const rejects: BroadcastResult['rejects'] = [];
const stemRequested = await new Promise<boolean>((resolve) => {
const onUnhandled = (frame: { command: string; payload: Buffer }) => {
if (frame.command !== 'getdata') return;
if (!containsOurTxid(frame.payload, txid)) return;
stem.send('tx', raw);
cleanup();
resolve(true);
};
const onReject = (payload: Buffer) => {
rejects.push({ host: stem.host, reason: `stem peer sent reject (${payload.length}B)` });
};
const cleanup = () => {
clearTimeout(timer);
stem.off('unhandled', onUnhandled);
stem.off('reject', onReject as never);
};
stem.on('unhandled', onUnhandled);
stem.on('reject', onReject as never);
stem.send('inv', invPayload);
const timer = setTimeout(() => { cleanup(); resolve(false); }, timeoutMs);
});
if (stemRequested) {
const relayedBy = await echo.result;
if (relayedBy !== null) {
return {
txid, requestedBy: 1, totalPeers: peers.length, rejects,
relayed: true, relayedBy, relayWaitMs, listeners: listeners.length,
via: 'dandelion', stemPeer: stem.host,
};
}
// Stem took the tx but nothing fluffed back to us. Either the tx is
// unacceptable (policy) or the stem is slow/stuck. Try direct: if
// the tx is fine this gets it out; if not, direct fails the same
// way and the caller sees relayed=false.
} else {
echo.cancel();
}
const res = await broadcastTx(peers, rawTxHex, opts);
return { ...res, rejects: [...rejects, ...res.rejects], via: 'direct', stemPeer: stem.host };
}
// Watch `listeners` for an inv that carries our txid. Resolves with the
// announcing peer's host, or null on timeout. `cancel()` detaches early.
function waitForRelayInv(
listeners: Peer[],
txid: string,
waitMs: number,
): { result: Promise<string | null>; cancel: () => void } {
let settle: (v: string | null) => void = () => {};
const offs: Array<() => void> = [];
let timer: ReturnType<typeof setTimeout> | null = null;
const cleanup = () => {
if (timer) clearTimeout(timer);
for (const off of offs) off();
offs.length = 0;
};
const result = new Promise<string | null>((resolve) => {
settle = (v) => { cleanup(); resolve(v); };
if (listeners.length === 0) { resolve(null); return; }
for (const peer of listeners) {
const onUnhandled = (frame: { command: string; payload: Buffer }) => {
if (frame.command !== 'inv') return;
if (!containsOurTxid(frame.payload, txid)) return;
settle(peer.host);
};
peer.on('unhandled', onUnhandled);
offs.push(() => peer.off('unhandled', onUnhandled));
}
timer = setTimeout(() => settle(null), waitMs);
});
return { result, cancel: () => settle(null) };
}
// Parse an inv/getdata payload and check whether any entry references
// our txid. Type field is ignored on purpose: peers may announce with
// MSG_TX, MSG_WITNESS_TX or (post-stem) MSG_DANDELION_TX.
function containsOurTxid(payload: Buffer, txid: string): boolean {
const txidLE = Buffer.from(Buffer.from(txid, 'hex')).reverse();
// Skip the count varint by finding start of first entry (varint 1B for count < 0xfd, 3B for < 0xffff)
const first = payload[0];
let o = first < 0xfd ? 1 : first === 0xfd ? 3 : first === 0xfe ? 5 : 9;
while (o + 36 <= payload.length) {
// type is 4 bytes at o, hash is 32 bytes at o+4
const hash = payload.subarray(o + 4, o + 36);
if (hash.equals(txidLE)) return true;
o += 36;
}
return false;
}

133
packages/p2p/src/cache.ts Normal file
View file

@ -0,0 +1,133 @@
import { mkdirSync, existsSync, readFileSync, appendFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
// HeaderCache used to live here — it's now folded into HeaderChain
// (disk-backed with LRU) so persistence and access go through one
// object. See dgb-wallet-disk-first-chain-data memory.
export interface CacheOptions {
dir: string;
// Packs kept decoded in memory. The scanner and crawler walk heights
// sequentially, so a handful is enough; 64 packs ≈ 64k filters ≈ 10 MB.
packLru?: number;
}
const PACK_SPAN = 1000; // blocks per pack file — matches BIP157's getcfilters cap
const HASH_LEN = 32;
// Compact-filter cache, packed 1000 blocks per file.
//
// The first version wrote one file per filter. A year of DGB is 2.1M
// blocks, and 2.1M tiny files exhausted the VPS's inodes (ENOSPC with
// gigabytes still free). Packing by height keeps the same block-hash
// keyed API but puts ~1000 filters in one append-only file:
//
// <dir>/packs/p<N>.bin — entries for heights [N*1000, N*1000+999]
// entry = hash(32 bytes, big-endian display order) + len(u32 LE) + filter
//
// Appends are atomic enough for our single writer; on load the last
// entry for a hash wins, so a re-fetched filter simply supersedes.
// Callers pass the height they already know so we can find the pack;
// lookups without a height fall back to the legacy per-file layout only
// (kept so old scans still find what they cached).
export class FilterCache {
private readonly dir: string;
private readonly packsDir: string;
private readonly legacyDir: string;
private readonly packLru: number;
private packs = new Map<number, Map<string, Buffer>>(); // insertion-ordered LRU
private stats = { packsLoaded: 0, filtersAppended: 0 };
constructor(opts: CacheOptions) {
this.dir = opts.dir;
this.packsDir = join(opts.dir, 'packs');
this.legacyDir = join(opts.dir, 'filters');
this.packLru = opts.packLru ?? 64;
mkdirSync(this.packsDir, { recursive: true });
}
has(blockHash: string, height?: number): boolean {
if (height === undefined) return existsSync(join(this.legacyDir, `${blockHash}.bin`));
return this.pack(height).has(blockHash);
}
get(blockHash: string, height?: number): Buffer | null {
if (height === undefined) {
const p = join(this.legacyDir, `${blockHash}.bin`);
return existsSync(p) ? readFileSync(p) : null;
}
return this.pack(height).get(blockHash) ?? null;
}
put(blockHash: string, filter: Buffer, height?: number): void {
if (height === undefined) return; // legacy layout is read-only now — never create more tiny files
const map = this.pack(height);
if (map.has(blockHash)) return;
const head = Buffer.alloc(HASH_LEN + 4);
Buffer.from(blockHash, 'hex').copy(head, 0);
head.writeUInt32LE(filter.length, HASH_LEN);
appendFileSync(this.packPath(height), Buffer.concat([head, filter]));
map.set(blockHash, filter);
this.stats.filtersAppended++;
}
/** Rough on-disk footprint for /health. */
summary(): { packs: number; bytes: number; packsLoaded: number; filtersAppended: number } {
let packs = 0; let bytes = 0;
try {
for (const f of readdirSync(this.packsDir)) {
if (!f.startsWith('p') || !f.endsWith('.bin')) continue;
packs++;
try { bytes += statSync(join(this.packsDir, f)).size; } catch { /* racing writer */ }
}
} catch { /* dir missing */ }
return { packs, bytes, ...this.stats };
}
private packPath(height: number): string {
return join(this.packsDir, `p${Math.floor(height / PACK_SPAN)}.bin`);
}
private pack(height: number): Map<string, Buffer> {
const n = Math.floor(height / PACK_SPAN);
const hit = this.packs.get(n);
if (hit) {
// refresh LRU position
this.packs.delete(n);
this.packs.set(n, hit);
return hit;
}
const map = new Map<string, Buffer>();
const p = join(this.packsDir, `p${n}.bin`);
if (existsSync(p)) {
const buf = readFileSync(p);
let o = 0;
while (o + HASH_LEN + 4 <= buf.length) {
const hash = buf.subarray(o, o + HASH_LEN).toString('hex');
const len = buf.readUInt32LE(o + HASH_LEN);
const start = o + HASH_LEN + 4;
if (start + len > buf.length) break; // truncated tail (crash mid-append) — ignore
map.set(hash, buf.subarray(start, start + len));
o = start + len;
}
this.stats.packsLoaded++;
}
this.packs.set(n, map);
if (this.packs.size > this.packLru) {
const oldest = this.packs.keys().next().value as number | undefined;
if (oldest !== undefined) this.packs.delete(oldest);
}
return map;
}
}
// Suggested default directory: alongside the wallet data.
// On Windows: %APPDATA%\dgb-wallet\p2p\
// On Linux/Mac: ~/.dgb-wallet/p2p/
export function defaultCacheDir(): string {
const home = process.env.HOME ?? process.env.USERPROFILE ?? '.';
if (process.platform === 'win32') {
return join(process.env.APPDATA ?? home, 'dgb-wallet', 'p2p');
}
return join(home, '.dgb-wallet', 'p2p');
}

Some files were not shown because too many files have changed in this diff Show more