diff --git a/packages/core/src/hd.ts b/packages/core/src/hd.ts index cf70372..f590fc3 100644 --- a/packages/core/src/hd.ts +++ b/packages/core/src/hd.ts @@ -1,9 +1,19 @@ import { BIP32Factory, type BIP32Interface } from 'bip32'; import * as ecc from '@bitcoinerlab/secp256k1'; +import { hmac } from '@noble/hashes/hmac'; +import { sha512 } from '@noble/hashes/sha512'; import { digibyte, DGB_COIN_TYPE, type Network } from './network.js'; const bip32 = BIP32Factory(ecc); +// BIP32 derives the master key as HMAC-SHA512(key, seed) with the key +// "Bitcoin seed". DigiByte's 2018-19 official mobile wallets (BreadWallet +// forks) used "DigiByte seed" instead, so the SAME 12 words give a +// completely different key tree — every address differs. A recovery +// that only tries the standard key never finds those coins. +export const HMAC_BITCOIN_SEED = 'Bitcoin seed'; +export const HMAC_DIGIBYTE_SEED = 'DigiByte seed'; + export type Purpose = 44 | 49 | 84 | 86; export const PURPOSE_LABEL: Record = { @@ -16,8 +26,11 @@ export const PURPOSE_LABEL: Record = { export function rootFromSeed( seed: Buffer, network: Network = digibyte, + hmacKey: string = HMAC_BITCOIN_SEED, ): BIP32Interface { - return bip32.fromSeed(seed, network); + if (hmacKey === HMAC_BITCOIN_SEED) return bip32.fromSeed(seed, network); + const I = hmac(sha512, new TextEncoder().encode(hmacKey), seed); + return bip32.fromPrivateKey(Buffer.from(I.slice(0, 32)), Buffer.from(I.slice(32)), network); } // Standard account-level derivation: m/purpose'/coin'/account'. diff --git a/packages/web-ui/src/PathSearchPanel.tsx b/packages/web-ui/src/PathSearchPanel.tsx index 7f170dc..664dfae 100644 --- a/packages/web-ui/src/PathSearchPanel.tsx +++ b/packages/web-ui/src/PathSearchPanel.tsx @@ -1,8 +1,8 @@ import { useEffect, useMemo, useState } from 'react'; -import { searchPaths, searchSingle, PATH_CANDIDATES, type PathHit, type PathCandidate } from './path-search.js'; +import { searchPaths, searchSingle, PATH_CANDIDATES, candidateKey, type PathHit, type PathCandidate } from './path-search.js'; import { useActiveSeed } from './ActiveSeed.js'; import { - extrasKey, readExtras, addExtra, removeExtra, subscribeExtras, + extrasKey, readExtras, addExtra, removeExtra, subscribeExtras, extraKey, type ExtraPath, type ExtraPathScript, } from './wallet-extra-paths.js'; @@ -29,13 +29,8 @@ import { // Paths the default balance scan already covers — anything on these // isn't news. Kept in sync with useWalletBalance.scanSeedViaP2p. const DEFAULT_SCAN_PATHS = new Set([ - "m/44'/20'/0'", - "m/49'/20'/0'", - "m/84'/20'/0'", - "m/86'/20'/0'", - "m/0'", - "m/44'/0'/0'", -]); + "m/44'/20'/0'", "m/49'/20'/0'", "m/84'/20'/0'", "m/86'/20'/0'", "m/0'", "m/44'/0'/0'", +].map((p) => candidateKey({ path: p })).concat([candidateKey({ path: "m/0'", hmac: 'DigiByte seed' })])); const GAP = 100; @@ -82,7 +77,7 @@ export function PathSearchPanel(): JSX.Element | null { const extrasByPath = useMemo(() => { const m = new Map(); - for (const e of extras) m.set(`${e.path}::${e.script}`, e); + for (const e of extras) m.set(extraKey(e), e); return m; }, [extras]); @@ -103,11 +98,11 @@ export function PathSearchPanel(): JSX.Element | null { // Pre-populate every candidate as busy so all 11 rows appear right // away — user sees the full plan before any query returns. const init = new Map(); - for (const c of PATH_CANDIDATES) init.set(c.path, { busy: true }); + for (const c of PATH_CANDIDATES) init.set(candidateKey(c), { busy: true }); setRows(init); try { await searchPaths(phrase, GAP, (hit) => { - setRow(hit.candidate.path, { hit, busy: false }); + setRow(candidateKey(hit.candidate), { hit, busy: false }); }); } finally { // Belt-and-suspenders: any still-busy row (should be none) is @@ -123,12 +118,12 @@ export function PathSearchPanel(): JSX.Element | null { async function retryOne(c: PathCandidate): Promise { if (!phrase) return; - setRow(c.path, { busy: true }); + setRow(candidateKey(c), { busy: true }); try { const hit = await searchSingle(phrase, c, GAP); - setRow(c.path, { hit, busy: false }); + setRow(candidateKey(c), { hit, busy: false }); } catch (e) { - setRow(c.path, { + setRow(candidateKey(c), { hit: { candidate: c, xpub: '', firstAddress: '', txs: null, totalReceivedSat: null, balanceSat: null, @@ -141,12 +136,12 @@ export function PathSearchPanel(): JSX.Element | null { function promote(c: PathCandidate): void { if (!storageKey) return; - addExtra(storageKey, { path: c.path, script: c.script, label: c.label }); + addExtra(storageKey, { path: c.path, script: c.script, label: c.label, ...(c.hmac ? { hmac: c.hmac } : {}) }); } - function drop(path: string, script: ExtraPathScript): void { + function drop(path: string, script: ExtraPathScript, hmac?: string): void { if (!storageKey) return; - removeExtra(storageKey, path, script); + removeExtra(storageKey, path, script, hmac); } const hasSearchResults = rows.size > 0; @@ -172,7 +167,7 @@ export function PathSearchPanel(): JSX.Element | null {
{extras.map((e) => ( -
+
{e.label ?? e.path} · {SCRIPT_LABEL[e.script]} @@ -180,7 +175,7 @@ export function PathSearchPanel(): JSX.Element | null { @@ -202,15 +197,15 @@ export function PathSearchPanel(): JSX.Element | null { {hasSearchResults && (
{PATH_CANDIDATES.map((c) => { - const row = rows.get(c.path); + const row = rows.get(candidateKey(c)); const hit = row?.hit; const busy = row?.busy ?? false; const isHit = (hit?.txs ?? 0) > 0; const isErr = !!hit?.error; - const isDefault = DEFAULT_SCAN_PATHS.has(c.path); - const isAdded = extrasByPath.has(`${c.path}::${c.script}`); + const isDefault = DEFAULT_SCAN_PATHS.has(candidateKey(c)); + const isAdded = extrasByPath.has(extraKey({ path: c.path, script: c.script, hmac: c.hmac })); return ( -
+
{c.label} @@ -255,7 +250,7 @@ export function PathSearchPanel(): JSX.Element | null { ); })} {!runningAll && PATH_CANDIDATES.every((c) => { - const r = rows.get(c.path)?.hit; + const r = rows.get(candidateKey(c))?.hit; return r && !r.error && (r.txs ?? 0) === 0; }) && (
diff --git a/packages/web-ui/src/SendScreen.tsx b/packages/web-ui/src/SendScreen.tsx index 08f3449..9a7e0a1 100644 --- a/packages/web-ui/src/SendScreen.tsx +++ b/packages/web-ui/src/SendScreen.tsx @@ -4,8 +4,7 @@ import { useSessionAuth } from './session-auth.js'; import { seedFromPhrase, seedFromElectrumPhrase, rootFromSeed, accountNode, addressNode, p2wpkhAddress, addressesForPubkey, digibyte, - importWifWithAddresses, -} from '@dgb-wallet/core'; + importWifWithAddresses, HMAC_DIGIBYTE_SEED } from '@dgb-wallet/core'; import { readExtras, extrasKey, type ExtraPathScript } from './wallet-extra-paths.js'; import type { WalletKind } from './ActiveSeed.js'; import { buildPsbt, finalizeAndExtract, type Utxo } from '@dgb-wallet/psbt'; @@ -734,7 +733,10 @@ async function buildFromSeed( // Legacy mobile path m/0'/{0,1}/i (2018-19 breadwallet-based DigiByte // apps): P2PKH keys, registered as purpose 44 so change policy and // signing treat them exactly like BIP44 legacy coins. - for (const legacy of [root.deriveHardened(0), root.derivePath("m/44'/0'/0'")]) { + // The third entry is the official 2018-19 DigiByte mobile app's tree: + // master key from HMAC "DigiByte seed" (see core/hd.ts), same m/0' layout. + const rootDgb = rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED); + for (const legacy of [root.deriveHardened(0), root.derivePath("m/44'/0'/0'"), rootDgb.deriveHardened(0)]) { for (const change of [0, 1] as const) { for (let i = 0; i < GAP; i++) { const node = addressNode(legacy, change, i); @@ -753,7 +755,8 @@ async function buildFromSeed( 'p2pkh': 44, 'p2sh-p2wpkh': 49, 'p2wpkh': 84, }; for (const extra of extras) { - const acct = extra.path === 'm' ? root : root.derivePath(extra.path); + const base = extra.hmac ? rootFromSeed(seed, undefined, extra.hmac) : root; + const acct = extra.path === 'm' ? base : base.derivePath(extra.path); const purpose = scriptToPurpose[extra.script]; for (const change of [0, 1] as const) { for (let i = 0; i < GAP; i++) { diff --git a/packages/web-ui/src/path-search.ts b/packages/web-ui/src/path-search.ts index 9f61e93..3a2739a 100644 --- a/packages/web-ui/src/path-search.ts +++ b/packages/web-ui/src/path-search.ts @@ -1,4 +1,4 @@ -import { seedFromPhrase, rootFromSeed, accountXpub, addressNode, addressesForPubkey } from '@dgb-wallet/core'; +import { seedFromPhrase, rootFromSeed, accountXpub, addressNode, addressesForPubkey, HMAC_DIGIBYTE_SEED } from '@dgb-wallet/core'; import bs58check from 'bs58check'; import { defaultBlockbookClient } from './blockbook-default.js'; @@ -18,6 +18,13 @@ export interface PathCandidate { script: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh'; label: string; why: string; + /** Master-key HMAC string; absent = standard "Bitcoin seed". */ + hmac?: string; +} + +/** Stable identity for a candidate (two candidates can share a path). */ +export function candidateKey(c: { path: string; hmac?: string }): string { + return `${c.path}|${c.hmac ?? ''}`; } export interface PathHit { @@ -34,7 +41,9 @@ export const PATH_CANDIDATES: PathCandidate[] = [ { path: "m/44'/20'/0'", script: 'p2pkh', label: "BIP44 · DGB (m/44'/20'/0')", why: 'Standard. Coinomi, Exodus, Atomic, DigiByte Go, Edge, Guarda, Trust.' }, { path: "m/49'/20'/0'", script: 'p2sh-p2wpkh', label: "BIP49 · DGB (m/49'/20'/0')", why: 'Standard P2SH-SegWit (S… / 3… addresses).' }, { path: "m/84'/20'/0'", script: 'p2wpkh', label: "BIP84 · DGB (m/84'/20'/0')", why: 'Standard native SegWit (dgb1q…).' }, - { path: "m/0'", script: 'p2pkh', label: "Legacy mobile (m/0')", why: '2018-19 DigiByte Android/iOS (breadwallet fork) and older bitcoinj-based apps.' }, + { path: "m/0'", script: 'p2pkh', label: "Legacy DigiByte mobile (m/0', \"DigiByte seed\")", hmac: HMAC_DIGIBYTE_SEED, + why: 'The official 2018-19 DigiByte Android / iOS app (BreadWallet fork). It derived the master key with the HMAC string "DigiByte seed" instead of the BIP32 standard, so the same words give a different key tree.' }, + { path: "m/0'", script: 'p2pkh', label: "Legacy m/0' (standard key)", why: 'Early non-DigiByte BreadWallet forks and older bitcoinj-based apps.' }, { path: "m/44'/0'/0'", script: 'p2pkh', label: "BIP44 · coin type 0 (m/44'/0'/0')", why: "Wallets that reused the Bitcoin coin type for every coin (Jaxx-era, some early multi-coin apps)." }, { path: "m/49'/0'/0'", script: 'p2sh-p2wpkh', label: "BIP49 · coin type 0 (m/49'/0'/0')", why: 'Same mistake, SegWit variant.' }, { path: "m/84'/0'/0'", script: 'p2wpkh', label: "BIP84 · coin type 0 (m/84'/0'/0')", why: 'Same mistake, native SegWit variant.' }, @@ -98,8 +107,8 @@ export async function searchSingle( candidate: PathCandidate, gap: number, ): Promise { - const root = rootFromSeed(await seedFromPhrase(phrase)); - return probeCandidate(root, candidate, gap); + const seed = await seedFromPhrase(phrase); + return probeCandidate(rootFromSeed(seed, undefined, candidate.hmac), candidate, gap); } /** @@ -118,14 +127,22 @@ export async function searchPaths( candidates: PathCandidate[] = PATH_CANDIDATES, concurrency = 3, ): Promise { - const root = rootFromSeed(await seedFromPhrase(phrase)); + const seed = await seedFromPhrase(phrase); + // One root per HMAC key — the standard tree and the "DigiByte seed" tree. + const roots = new Map>(); + const rootFor = (hmacKey?: string) => { + const k = hmacKey ?? ''; + let r = roots.get(k); + if (!r) { r = rootFromSeed(seed, undefined, hmacKey); roots.set(k, r); } + return r; + }; const results: PathHit[] = new Array(candidates.length); let next = 0; async function worker(): Promise { while (true) { const i = next++; if (i >= candidates.length) return; - const hit = await probeCandidate(root, candidates[i], gap); + const hit = await probeCandidate(rootFor(candidates[i].hmac), candidates[i], gap); results[i] = hit; onRow(hit); } diff --git a/packages/web-ui/src/useWalletBalance.ts b/packages/web-ui/src/useWalletBalance.ts index 3eb2920..9b5f7df 100644 --- a/packages/web-ui/src/useWalletBalance.ts +++ b/packages/web-ui/src/useWalletBalance.ts @@ -12,8 +12,7 @@ import { useEffect, useRef, useState } from 'react'; import { walletIdHash, cacheKey, readCache, writeCache, type CachedScan } from './scan-cache.js'; import { seedFromPhrase, seedFromElectrumPhrase, rootFromSeed, accountNode, accountXpub as toXpub, - importWifWithAddresses, -} from '@dgb-wallet/core'; + importWifWithAddresses, HMAC_DIGIBYTE_SEED } from '@dgb-wallet/core'; import { type BlockbookUtxo } from '@dgb-wallet/blockbook'; import { defaultBlockbookClient } from './blockbook-default.js'; import bs58check from 'bs58check'; @@ -396,7 +395,17 @@ export function useWalletBalance(): { xpub: toXpub(root.deriveHardened(0)), purpose: 44, account: 0, - label: "Legacy mobile m/0' (2018-19 app)", + label: "Legacy m/0' (standard key)", + }); + // The official 2018-19 DigiByte Android / iOS app derived its master + // key with HMAC "DigiByte seed" — a different key tree from the same + // words. Same m/0'/{0,1}/i layout, P2PKH. + const rootDgb = rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED); + descriptors.push({ + xpub: toXpub(rootDgb.deriveHardened(0)), + purpose: 44, + account: 0, + label: "Legacy DigiByte mobile m/0' (\"DigiByte seed\" key)", }); // Coin type 0 (Bitcoin's) under BIP44 — several early multi-coin // apps reused it for every asset. Same P2PKH scripts, different key. @@ -413,7 +422,8 @@ export function useWalletBalance(): { // out in the diagnostics list. const ek = await extrasKey(active!.phrase, active!.kind, active!.passphrase); for (const extra of readExtras(ek)) { - const node = extra.path === 'm' ? root : root.derivePath(extra.path); + const base = extra.hmac ? rootFromSeed(seed, undefined, extra.hmac) : root; + const node = extra.path === 'm' ? base : base.derivePath(extra.path); descriptors.push({ xpub: toXpub(node), purpose: SCRIPT_TO_PURPOSE[extra.script], diff --git a/packages/web-ui/src/useWalletHistory.ts b/packages/web-ui/src/useWalletHistory.ts index 05f4c08..98f32c1 100644 --- a/packages/web-ui/src/useWalletHistory.ts +++ b/packages/web-ui/src/useWalletHistory.ts @@ -16,7 +16,7 @@ import { useEffect, useRef, useState } from 'react'; import { - seedFromPhrase, rootFromSeed, accountNode, accountXpub as toXpub, + seedFromPhrase, rootFromSeed, accountNode, accountXpub as toXpub, HMAC_DIGIBYTE_SEED, addressNode, addressesForPubkey, importWifWithAddresses, } from '@dgb-wallet/core'; import { @@ -156,7 +156,8 @@ export function useWalletHistory(): { const own = new Set(); const accounts = [ ...([44, 49, 84, 86] as const).map((purpose) => accountNode(root, purpose, 0)), - root.deriveHardened(0), // legacy mobile m/0' (breadwallet-style) + root.deriveHardened(0), // legacy m/0' (standard key) + rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED).deriveHardened(0), // official 2018-19 DGB mobile ("DigiByte seed" key) root.derivePath("m/44'/0'/0'"), // BIP44 with Bitcoin's coin type (early multi-coin apps) ]; for (const acct of accounts) { @@ -169,7 +170,8 @@ export function useWalletHistory(): { } const xpubs: Array<{ xpub: string; purpose: 44 | 49 | 84 | 86 }> = [ ...([44, 49, 84, 86] as const).map((purpose) => ({ xpub: toXpub(accountNode(root, purpose, 0)), purpose })), - { xpub: toXpub(root.deriveHardened(0)), purpose: 44 }, // legacy mobile m/0' + { xpub: toXpub(root.deriveHardened(0)), purpose: 44 }, // legacy m/0' (standard key) + { xpub: toXpub(rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED).deriveHardened(0)), purpose: 44 }, // 2018-19 DGB mobile { xpub: toXpub(root.derivePath("m/44'/0'/0'")), purpose: 44 }, // BIP44 coin type 0 ]; const results = await Promise.all(xpubs.map(async ({ xpub, purpose }) => { diff --git a/packages/web-ui/src/wallet-extra-paths.ts b/packages/web-ui/src/wallet-extra-paths.ts index 63035f6..33cf62f 100644 --- a/packages/web-ui/src/wallet-extra-paths.ts +++ b/packages/web-ui/src/wallet-extra-paths.ts @@ -28,6 +28,14 @@ export interface ExtraPath { script: ExtraPathScript; /** Optional display label; UI synthesizes one if absent. */ label?: string; + /** BIP32 master-key HMAC string. Absent = standard "Bitcoin seed". The + * 2018-19 DigiByte mobile wallets used "DigiByte seed". */ + hmac?: string; +} + +/** Identity of an extra path: path + script + hmac. */ +export function extraKey(e: { path: string; script: string; hmac?: string }): string { + return `${e.path}::${e.script}::${e.hmac ?? ''}`; } const KEY_PREFIX = 'dgb-wallet:extra-paths:'; @@ -66,13 +74,14 @@ export function writeExtras(storageKey: string, paths: ExtraPath[]): void { export function addExtra(storageKey: string, entry: ExtraPath): boolean { const existing = readExtras(storageKey); - if (existing.some((e) => e.path === entry.path && e.script === entry.script)) return false; + if (existing.some((e) => extraKey(e) === extraKey(entry))) return false; writeExtras(storageKey, [...existing, entry]); return true; } -export function removeExtra(storageKey: string, path: string, script: ExtraPathScript): void { - writeExtras(storageKey, readExtras(storageKey).filter((e) => !(e.path === path && e.script === script))); +export function removeExtra(storageKey: string, path: string, script: ExtraPathScript, hmac?: string): void { + const k = extraKey({ path, script, hmac }); + writeExtras(storageKey, readExtras(storageKey).filter((e) => extraKey(e) !== k)); } /** Subscribe to any extras-list change (any wallet). Returns unsubscribe. */