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:
commit
d50286a9a5
189 changed files with 44669 additions and 0 deletions
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal 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
874
RESEARCH.md
Normal 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
95
docs/SECURITY-ROADMAP.md
Normal 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
59
ops/Caddyfile
Normal 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
58
ops/Caddyfile.bns
Normal 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
85
ops/Caddyfile.wallet
Normal 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
|
||||
}
|
||||
}
|
||||
52
ops/Caddyfile.wallet-web-snippet
Normal file
52
ops/Caddyfile.wallet-web-snippet
Normal 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
234
ops/README.md
Normal 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
19
ops/_start-dgb.ps1
Normal 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
206
ops/deploy-wallet-web.sh
Normal 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
140
ops/deploy-wallet.sh
Normal 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
56
ops/dgb-daemon.service
Normal 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
42
ops/digibyte.conf
Normal 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
34
ops/digibyte.service
Normal 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
156
ops/nginx-digibyte-x.conf
Normal 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
101
ops/push.sh
Normal 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
321
ops/scratch-digibyte-x.html
Normal 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
248
ops/setup-local.ps1
Normal 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
192
ops/setup.sh
Normal 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
19443
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
9
package.json
Normal file
9
package.json
Normal 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/*"
|
||||
]
|
||||
}
|
||||
24
packages/blockbook/package.json
Normal file
24
packages/blockbook/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
64
packages/blockbook/scripts/smoke.mjs
Normal file
64
packages/blockbook/scripts/smoke.mjs
Normal 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);
|
||||
316
packages/blockbook/src/client.ts
Normal file
316
packages/blockbook/src/client.ts
Normal 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()?.();
|
||||
};
|
||||
}
|
||||
1
packages/blockbook/src/index.ts
Normal file
1
packages/blockbook/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './client.js';
|
||||
19
packages/blockbook/tsconfig.json
Normal file
19
packages/blockbook/tsconfig.json
Normal 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
3
packages/bns/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
55
packages/bns/README.md
Normal file
55
packages/bns/README.md
Normal 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
31
packages/bns/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
31
packages/bns/scripts/resolve-demo.ts
Normal file
31
packages/bns/scripts/resolve-demo.ts
Normal 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}`);
|
||||
}
|
||||
2
packages/bns/src/index.ts
Normal file
2
packages/bns/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './types.js';
|
||||
export * from './resolver.js';
|
||||
99
packages/bns/src/resolver.ts
Normal file
99
packages/bns/src/resolver.ts
Normal 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, '');
|
||||
}
|
||||
38
packages/bns/src/silentmode-resolver.d.ts
vendored
Normal file
38
packages/bns/src/silentmode-resolver.d.ts
vendored
Normal 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
29
packages/bns/src/types.ts
Normal 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
|
||||
}
|
||||
18
packages/bns/tsconfig.json
Normal file
18
packages/bns/tsconfig.json
Normal 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
4
packages/core/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
84
packages/core/README.md
Normal file
84
packages/core/README.md
Normal 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
1311
packages/core/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
36
packages/core/package.json
Normal file
36
packages/core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
49
packages/core/scripts/derive-from-seed.ts
Normal file
49
packages/core/scripts/derive-from-seed.ts
Normal 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');
|
||||
91
packages/core/scripts/recover-from-seed.ts
Normal file
91
packages/core/scripts/recover-from-seed.ts
Normal 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`);
|
||||
69
packages/core/src/address.ts
Normal file
69
packages/core/src/address.ts
Normal 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;
|
||||
}
|
||||
27
packages/core/src/descriptor.ts
Normal file
27
packages/core/src/descriptor.ts
Normal 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
43
packages/core/src/hd.ts
Normal 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 };
|
||||
6
packages/core/src/index.ts
Normal file
6
packages/core/src/index.ts
Normal 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';
|
||||
82
packages/core/src/network.ts
Normal file
82
packages/core/src/network.ts
Normal 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
114
packages/core/src/seed.ts
Normal 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
114
packages/core/src/wif.ts
Normal 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 };
|
||||
18
packages/core/tsconfig.json
Normal file
18
packages/core/tsconfig.json
Normal 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
3
packages/electrum/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
51
packages/electrum/README.md
Normal file
51
packages/electrum/README.md
Normal 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
|
||||
```
|
||||
31
packages/electrum/package.json
Normal file
31
packages/electrum/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
148
packages/electrum/scripts/scan-seed-demo.ts
Normal file
148
packages/electrum/scripts/scan-seed-demo.ts
Normal 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();
|
||||
149
packages/electrum/src/client.ts
Normal file
149
packages/electrum/src/client.ts
Normal 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')}`);
|
||||
}
|
||||
4
packages/electrum/src/index.ts
Normal file
4
packages/electrum/src/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './servers.js';
|
||||
export * from './client.js';
|
||||
export * from './methods.js';
|
||||
export * from './scripthash.js';
|
||||
79
packages/electrum/src/methods.ts
Normal file
79
packages/electrum/src/methods.ts
Normal 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 };
|
||||
}
|
||||
19
packages/electrum/src/scripthash.ts
Normal file
19
packages/electrum/src/scripthash.ts
Normal 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');
|
||||
}
|
||||
19
packages/electrum/src/servers.ts
Normal file
19
packages/electrum/src/servers.ts
Normal 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' },
|
||||
];
|
||||
18
packages/electrum/tsconfig.json
Normal file
18
packages/electrum/tsconfig.json
Normal 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"]
|
||||
}
|
||||
27
packages/keystore/package.json
Normal file
27
packages/keystore/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
59
packages/keystore/scripts/roundtrip.ts
Normal file
59
packages/keystore/scripts/roundtrip.ts
Normal 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);
|
||||
});
|
||||
125
packages/keystore/src/encrypt.ts
Normal file
125
packages/keystore/src/encrypt.ts
Normal 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;
|
||||
}
|
||||
3
packages/keystore/src/index.ts
Normal file
3
packages/keystore/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './encrypt.js';
|
||||
export * from './storage.js';
|
||||
export * from './keystore.js';
|
||||
88
packages/keystore/src/keystore.ts
Normal file
88
packages/keystore/src/keystore.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
51
packages/keystore/src/storage.ts
Normal file
51
packages/keystore/src/storage.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
packages/keystore/tsconfig.json
Normal file
19
packages/keystore/tsconfig.json
Normal 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
7
packages/mobile/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
*.log
|
||||
.metro-health-check*
|
||||
.DS_Store
|
||||
114
packages/mobile/README.md
Normal file
114
packages/mobile/README.md
Normal 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
15
packages/mobile/app.json
Normal 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 }
|
||||
}
|
||||
}
|
||||
6
packages/mobile/babel.config.js
Normal file
6
packages/mobile/babel.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
};
|
||||
};
|
||||
7
packages/mobile/index.js
Normal file
7
packages/mobile/index.js
Normal 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);
|
||||
30
packages/mobile/metro.config.js
Normal file
30
packages/mobile/metro.config.js
Normal 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;
|
||||
39
packages/mobile/package.json
Normal file
39
packages/mobile/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
85
packages/mobile/src/App.tsx
Normal file
85
packages/mobile/src/App.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
126
packages/mobile/src/BalanceScreen.tsx
Normal file
126
packages/mobile/src/BalanceScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
169
packages/mobile/src/ImportScreen.tsx
Normal file
169
packages/mobile/src/ImportScreen.tsx
Normal 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 },
|
||||
});
|
||||
225
packages/mobile/src/SendScreen.tsx
Normal file
225
packages/mobile/src/SendScreen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
packages/mobile/src/derive.ts
Normal file
57
packages/mobile/src/derive.ts
Normal 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;
|
||||
}
|
||||
64
packages/mobile/src/rpc-native.ts
Normal file
64
packages/mobile/src/rpc-native.ts
Normal 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;
|
||||
}
|
||||
100
packages/mobile/src/styles.ts
Normal file
100
packages/mobile/src/styles.ts
Normal 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 },
|
||||
});
|
||||
14
packages/mobile/tsconfig.json
Normal file
14
packages/mobile/tsconfig.json
Normal 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
27
packages/net/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
102
packages/net/scripts/socks5-loopback.ts
Normal file
102
packages/net/scripts/socks5-loopback.ts
Normal 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);
|
||||
});
|
||||
33
packages/net/src/direct.ts
Normal file
33
packages/net/src/direct.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
3
packages/net/src/index.ts
Normal file
3
packages/net/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './net-provider.js';
|
||||
export * from './direct.js';
|
||||
export * from './socks5.js';
|
||||
36
packages/net/src/net-provider.ts
Normal file
36
packages/net/src/net-provider.ts
Normal 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
217
packages/net/src/socks5.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
18
packages/net/tsconfig.json
Normal file
18
packages/net/tsconfig.json
Normal 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
3
packages/p2p/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
49
packages/p2p/README.md
Normal file
49
packages/p2p/README.md
Normal 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`).
|
||||
801
packages/p2p/bin/dgb-daemon.ts
Normal file
801
packages/p2p/bin/dgb-daemon.ts
Normal 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
39
packages/p2p/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
169
packages/p2p/scripts/block-fetch-demo.ts
Normal file
169
packages/p2p/scripts/block-fetch-demo.ts
Normal 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);
|
||||
}
|
||||
87
packages/p2p/scripts/cache-demo.ts
Normal file
87
packages/p2p/scripts/cache-demo.ts
Normal 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);
|
||||
142
packages/p2p/scripts/filter-sync-demo.ts
Normal file
142
packages/p2p/scripts/filter-sync-demo.ts
Normal 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);
|
||||
}
|
||||
70
packages/p2p/scripts/handshake-demo.ts
Normal file
70
packages/p2p/scripts/handshake-demo.ts
Normal 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);
|
||||
}
|
||||
104
packages/p2p/scripts/header-sync-demo.ts
Normal file
104
packages/p2p/scripts/header-sync-demo.ts
Normal 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);
|
||||
}
|
||||
73
packages/p2p/scripts/pool-demo.ts
Normal file
73
packages/p2p/scripts/pool-demo.ts
Normal 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);
|
||||
290
packages/p2p/src/broadcast.ts
Normal file
290
packages/p2p/src/broadcast.ts
Normal 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
133
packages/p2p/src/cache.ts
Normal 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
Loading…
Add table
Reference in a new issue