Skip to content

jmwallet.wallet.deletion

jmwallet.wallet.deletion

Destructive wallet cleanup helpers used by jm-wallet delete.

Attributes

Classes

WalletDeletionResult dataclass

Counts and paths removed by a completed wallet deletion.

Source code in jmwallet/src/jmwallet/wallet/deletion.py
34
35
36
37
38
39
40
@dataclass(frozen=True)
class WalletDeletionResult:
    """Counts and paths removed by a completed wallet deletion."""

    removed_paths: tuple[Path, ...]
    history_entries: int = 0
    bond_entries: int = 0
Attributes
bond_entries: int = 0 class-attribute instance-attribute
history_entries: int = 0 class-attribute instance-attribute
removed_paths: tuple[Path, ...] instance-attribute

Functions:

collect_neutrino_watch_addresses(*, data_dir: Path, mnemonic: str, bip39_passphrase: str, fingerprint: str, network: str, neutrino_url: str, mixdepth_count: int, gap_limit: int, scan_range: int) -> tuple[str, ...]

Derive the complete deterministic Neutrino watch set for wallet deletion.

The normal regular-branch footprint is intentionally bounded to the historical Neutrino backfill size. A matching completed reconstruction checkpoint expands only the branches it actually scanned.

Source code in jmwallet/src/jmwallet/wallet/deletion.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
def collect_neutrino_watch_addresses(
    *,
    data_dir: Path,
    mnemonic: str,
    bip39_passphrase: str,
    fingerprint: str,
    network: str,
    neutrino_url: str,
    mixdepth_count: int,
    gap_limit: int,
    scan_range: int,
) -> tuple[str, ...]:
    """Derive the complete deterministic Neutrino watch set for wallet deletion.

    The normal regular-branch footprint is intentionally bounded to the
    historical Neutrino backfill size. A matching completed reconstruction
    checkpoint expands only the branches it actually scanned.
    """
    baseline_end = (
        max(
            gap_limit,
            min(scan_range, NEUTRINO_HISTORICAL_BACKFILL_BATCH_SIZE),
        )
        - 1
    )
    if baseline_end < 0:
        raise ValueError("Wallet address coverage settings must be positive")

    checkpoint = load_reconstruction_checkpoint_strict(data_dir, wallet_fingerprint=fingerprint)
    matching_checkpoint_ends: dict[tuple[int, int], int] = {}
    if checkpoint is not None:
        if checkpoint.version != 1:
            raise ValueError("Unsupported history reconstruction checkpoint version")
        for branch, end in checkpoint.regular_branch_ends.items():
            try:
                mixdepth_text, change_text = branch.split(":", 1)
                mixdepth = int(mixdepth_text)
                change = int(change_text)
            except (AttributeError, ValueError) as exc:
                raise ValueError(f"Invalid reconstruction checkpoint branch {branch!r}") from exc
            if (
                mixdepth < 0
                or mixdepth >= _MAX_SUPPORTED_MIXDEPTH_COUNT
                or change not in (0, 1)
                or isinstance(end, bool)
                or end < 0
            ):
                raise ValueError(f"Invalid reconstruction checkpoint branch {branch!r}")
        expected_backend_id = "jmwallet.backends.neutrino.NeutrinoBackend|" + neutrino_url.rstrip(
            "/"
        )
        if (
            checkpoint.wallet_fingerprint == fingerprint
            and checkpoint.network == network
            and checkpoint.backend_id == expected_backend_id
        ):
            for branch, end in checkpoint.regular_branch_ends.items():
                mixdepth_text, change_text = branch.split(":", 1)
                mixdepth = int(mixdepth_text)
                change = int(change_text)
                matching_checkpoint_ends[(mixdepth, change)] = end

    derivation_mixdepth_count = max(
        mixdepth_count,
        max((mixdepth + 1 for mixdepth, _change in matching_checkpoint_ends), default=0),
    )
    wallet = WalletService(
        mnemonic=mnemonic,
        backend=OfflineBackend(),
        network=network,
        mixdepth_count=derivation_mixdepth_count,
        gap_limit=gap_limit,
        scan_range=scan_range,
        passphrase=bip39_passphrase,
    )
    if wallet.wallet_fingerprint != fingerprint:
        raise ValueError("Mnemonic-derived wallet fingerprint does not match deletion fingerprint")

    branch_ends = {
        (mixdepth, change): baseline_end
        for mixdepth in range(derivation_mixdepth_count)
        for change in (0, 1)
    }
    for branch, end in matching_checkpoint_ends.items():
        branch_ends[branch] = max(branch_ends[branch], end)

    addresses = _metadata_watch_addresses(data_dir, fingerprint, network)
    for (mixdepth, change), end in branch_ends.items():
        addresses.update(wallet.get_address(mixdepth, change, index) for index in range(end + 1))

    from jmcore.timenumber import TIMENUMBER_COUNT, timenumber_to_timestamp

    addresses.update(
        wallet.get_fidelity_bond_address(timenumber, timenumber_to_timestamp(timenumber))
        for timenumber in range(TIMENUMBER_COUNT)
    )

    per_wallet_path = get_registry_path(data_dir, fingerprint)
    if per_wallet_path.exists():
        _add_registry_addresses(
            addresses,
            _load_bond_registry_for_cleanup(per_wallet_path).bonds,
            network=network,
            owned_only=False,
            wallet=wallet,
        )
    legacy_path = get_legacy_registry_path(data_dir)
    if legacy_path.exists():
        _add_registry_addresses(
            addresses,
            _load_bond_registry_for_cleanup(legacy_path).bonds,
            network=network,
            owned_only=True,
            wallet=wallet,
        )
    return tuple(sorted(addresses))

core_wallet_path(core_wallet_dir: Path, wallet_name: str) -> Path

Build and validate the host-local path for one generated Core wallet.

Source code in jmwallet/src/jmwallet/wallet/deletion.py
287
288
289
290
291
292
293
294
295
296
297
def core_wallet_path(core_wallet_dir: Path, wallet_name: str) -> Path:
    """Build and validate the host-local path for one generated Core wallet."""
    if Path(wallet_name).name != wallet_name or wallet_name in {"", ".", ".."}:
        raise ValueError("unsafe Bitcoin Core wallet name")
    base = core_wallet_dir.expanduser().resolve(strict=True)
    if not base.is_dir():
        raise ValueError(f"Bitcoin Core wallet directory is not a directory: {base}")
    candidate = base / wallet_name
    if candidate.is_symlink():
        raise ValueError(f"Refusing to delete symlinked Bitcoin Core wallet path: {candidate}")
    return candidate

delete_core_descriptor_wallet(backend_settings: ResolvedBackendSettings, wallet_name: str, core_wallet_dir: Path) -> Path async

Unload a Core wallet, disable startup loading, and remove local files.

Source code in jmwallet/src/jmwallet/wallet/deletion.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
async def delete_core_descriptor_wallet(
    backend_settings: ResolvedBackendSettings,
    wallet_name: str,
    core_wallet_dir: Path,
) -> Path:
    """Unload a Core wallet, disable startup loading, and remove local files."""
    path = core_wallet_path(core_wallet_dir, wallet_name)
    backend = DescriptorWalletBackend(
        rpc_url=backend_settings.rpc_url,
        rpc_user=backend_settings.rpc_user,
        rpc_password=backend_settings.rpc_password,
        wallet_name=wallet_name,
    )
    try:
        exists_on_node = await backend.wallet_exists()
        if exists_on_node and not path.exists():
            raise ValueError(
                f"Bitcoin Core knows wallet {wallet_name!r}, but {path} does not exist. "
                "Pass Core's actual -walletdir path."
            )
        if not exists_on_node and path.exists():
            raise ValueError(
                f"Bitcoin Core does not report wallet {wallet_name!r}, but {path} exists. "
                "Refusing to delete files that may belong to a different Core instance."
            )
        if exists_on_node:
            await backend.unload_wallet_for_deletion()
        if path.exists():
            if path.is_dir():
                shutil.rmtree(path)
            else:
                path.unlink()
        return path
    finally:
        await backend.close()

delete_wallet_data(*, data_dir: Path, mnemonic_file: Path, mnemonic: str, bip39_passphrase: str, fingerprint: str, network: str, delete_history: bool, delete_bond_registry: bool, core_path: Path | None = None) -> WalletDeletionResult

Delete local wallet files after backend cleanup has succeeded.

Source code in jmwallet/src/jmwallet/wallet/deletion.py
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
def delete_wallet_data(
    *,
    data_dir: Path,
    mnemonic_file: Path,
    mnemonic: str,
    bip39_passphrase: str,
    fingerprint: str,
    network: str,
    delete_history: bool,
    delete_bond_registry: bool,
    core_path: Path | None = None,
) -> WalletDeletionResult:
    """Delete local wallet files after backend cleanup has succeeded."""
    history_entries = delete_wallet_history_entries(fingerprint, data_dir) if delete_history else 0

    bond_entries = 0
    if delete_bond_registry:
        master_key = HDKey.from_seed(mnemonic_to_seed(mnemonic, bip39_passphrase))
        coin_type = 0 if network == "mainnet" else 1
        predicate = make_wallet_ownership_predicate(master_key, f"m/84'/{coin_type}'")
        bond_entries = delete_wallet_registry_entries(data_dir, fingerprint, predicate)

    removed_paths: list[Path] = []
    for path in local_wallet_artifact_paths(data_dir, mnemonic_file, fingerprint):
        if not path.exists() and not path.is_symlink():
            continue
        if path.is_dir() and not path.is_symlink():
            raise ValueError(f"Refusing to delete unexpected directory: {path}")
        path.unlink()
        removed_paths.append(path)

    if core_path is not None:
        removed_paths.insert(0, core_path)

    return WalletDeletionResult(
        removed_paths=tuple(removed_paths),
        history_entries=history_entries,
        bond_entries=bond_entries,
    )

local_wallet_artifact_paths(data_dir: Path, mnemonic_file: Path, fingerprint: str) -> tuple[Path, ...]

Return wallet-private files removed on every deletion.

Source code in jmwallet/src/jmwallet/wallet/deletion.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
def local_wallet_artifact_paths(
    data_dir: Path,
    mnemonic_file: Path,
    fingerprint: str,
) -> tuple[Path, ...]:
    """Return wallet-private files removed on every deletion."""
    metadata_path = get_wallet_metadata_path(data_dir, fingerprint=fingerprint)
    return (
        metadata_path,
        metadata_path.with_suffix(".lock"),
        data_dir / f"address_history_{fingerprint}.jsonl",
        data_dir / "state" / f"history_reconstruction_{fingerprint}.json",
        mnemonic_file.with_name(mnemonic_file.name + ".meta"),
        mnemonic_file,
    )

remove_neutrino_wallet_watches(backend_settings: ResolvedBackendSettings, addresses: list[str]) -> tuple[int, int] async

Remove one wallet's persisted Neutrino watched addresses and close the client.

Source code in jmwallet/src/jmwallet/wallet/deletion.py
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
async def remove_neutrino_wallet_watches(
    backend_settings: ResolvedBackendSettings, addresses: list[str]
) -> tuple[int, int]:
    """Remove one wallet's persisted Neutrino watched addresses and close the client."""
    from jmwallet.backends.neutrino import NeutrinoBackend

    backend = NeutrinoBackend(
        neutrino_url=backend_settings.neutrino_url,
        network=backend_settings.bitcoin_network,
        add_peers=backend_settings.neutrino_add_peers,
        scan_start_height=backend_settings.scan_start_height,
        tls_cert_path=backend_settings.neutrino_tls_cert,
        auth_token=backend_settings.neutrino_auth_token,
    )
    try:
        return await backend.remove_watch_addresses(addresses)
    finally:
        await backend.close()