recovery: official 2018-19 DigiByte mobile wallets derived the master key with HMAC "DigiByte seed"
The BreadWallet-based DigiByte Android/iOS apps used HMAC-SHA512 key "DigiByte seed" (not BIP32's "Bitcoin seed") when turning the BIP39 seed into the master node, so the same 12 words give a different key tree and a standard m/0' scan finds nothing. rootFromSeed() takes an optional HMAC key; balance scan, history, send and the derivation-path search now cover m/0' under both keys, and user-added extra paths carry the key.
This commit is contained in:
parent
ad3923c18c
commit
989a2696c9
7 changed files with 95 additions and 46 deletions
|
|
@ -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<Purpose, string> = {
|
||||
|
|
@ -16,8 +26,11 @@ export const PURPOSE_LABEL: Record<Purpose, string> = {
|
|||
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'.
|
||||
|
|
|
|||
|
|
@ -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<string, ExtraPath>();
|
||||
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<string, RowState>();
|
||||
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<void> {
|
||||
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 {
|
|||
</div>
|
||||
<div className="path-search-table">
|
||||
{extras.map((e) => (
|
||||
<div key={`${e.path}::${e.script}`} className="path-search-row hit">
|
||||
<div key={extraKey(e)} className="path-search-row hit">
|
||||
<div className="path-search-main">
|
||||
<span className="path-search-label">
|
||||
{e.label ?? e.path} <span className="muted" style={{ fontWeight: 400 }}>· {SCRIPT_LABEL[e.script]}</span>
|
||||
|
|
@ -180,7 +175,7 @@ export function PathSearchPanel(): JSX.Element | null {
|
|||
<span className="path-search-result">
|
||||
<button
|
||||
className="text-btn"
|
||||
onClick={() => drop(e.path, e.script)}
|
||||
onClick={() => drop(e.path, e.script, e.hmac)}
|
||||
style={{ fontSize: 12, color: 'var(--danger, #e05252)' }}
|
||||
title={`Stop scanning ${e.path} for this wallet`}
|
||||
>Remove</button>
|
||||
|
|
@ -202,15 +197,15 @@ export function PathSearchPanel(): JSX.Element | null {
|
|||
{hasSearchResults && (
|
||||
<div className="path-search-table">
|
||||
{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 (
|
||||
<div key={c.path} className={`path-search-row ${isHit ? 'hit' : ''} ${isErr ? 'err' : ''}`}>
|
||||
<div key={candidateKey(c)} className={`path-search-row ${isHit ? 'hit' : ''} ${isErr ? 'err' : ''}`}>
|
||||
<div className="path-search-main">
|
||||
<span className="path-search-label">{c.label}</span>
|
||||
<span className="path-search-result">
|
||||
|
|
@ -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;
|
||||
}) && (
|
||||
<div className="field-help" style={{ marginTop: 8 }}>
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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<PathHit> {
|
||||
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<PathHit[]> {
|
||||
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<string, ReturnType<typeof rootFromSeed>>();
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
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 }) => {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue