Skip to content

jmcore.wallet_metadata

jmcore.wallet_metadata

Private mnemonic sidecars and public wallet identity selection.

A legacy fingerprint is a hint written by earlier releases. It is adopted only when an unlocked wallet derives the same fingerprint. Registration never implies recovery is needed, and never replaces an existing selection.

Attributes

IDENTITY_VERSION = 1 module-attribute

UPGRADE_GUIDANCE = 'Wallet identity is unconfirmed: this wallet does not match the fingerprint recorded for its mnemonic file, or none was recorded. If it is the intended wallet, run `jm-wallet identity register` (add --prompt-bip39-passphrase for a passphrase wallet).' module-attribute

Classes

WalletIdentity

Bases: BaseModel

A remembered derivation, not a stored credential or authentication proof.

Source code in jmcore/src/jmcore/wallet_metadata.py
32
33
34
35
36
37
38
39
40
41
42
43
44
class WalletIdentity(BaseModel):
    """A remembered derivation, not a stored credential or authentication proof."""

    fingerprint: str
    bip39: Literal["none", "required", "unknown"]

    @field_validator("fingerprint")
    @classmethod
    def validate_fingerprint(cls, value: str) -> str:
        value = value.strip().lower()
        if len(value) != 8 or any(c not in "0123456789abcdef" for c in value):
            raise ValueError("Wallet fingerprint must be exactly 8 hex characters")
        return value
Attributes
bip39: Literal['none', 'required', 'unknown'] instance-attribute
fingerprint: str instance-attribute
Methods:
validate_fingerprint(value: str) -> str classmethod
Source code in jmcore/src/jmcore/wallet_metadata.py
38
39
40
41
42
43
44
@field_validator("fingerprint")
@classmethod
def validate_fingerprint(cls, value: str) -> str:
    value = value.strip().lower()
    if len(value) != 8 or any(c not in "0123456789abcdef" for c in value):
        raise ValueError("Wallet fingerprint must be exactly 8 hex characters")
    return value

Functions:

adopt_legacy_identity(mnemonic_file: Path, identity: WalletIdentity) -> bool

Register and select an unlocked wallet matching the legacy fingerprint.

Earlier releases recorded the fingerprint of the wallet they derived. A matching derivation is affirmative evidence of the intended wallet, so the upgrade needs no manual steps. Missing or different hints adopt nothing.

Source code in jmcore/src/jmcore/wallet_metadata.py
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
def adopt_legacy_identity(mnemonic_file: Path, identity: WalletIdentity) -> bool:
    """Register and select an unlocked wallet matching the legacy fingerprint.

    Earlier releases recorded the fingerprint of the wallet they derived. A
    matching derivation is affirmative evidence of the intended wallet, so the
    upgrade needs no manual steps. Missing or different hints adopt nothing.
    """
    with metadata_lock(mnemonic_file):
        meta = load_mnemonic_meta(mnemonic_file, strict=True)
        if "identity_version" in meta or not isinstance(meta.get("fingerprint"), str):
            return False
        try:
            recorded = WalletIdentity.validate_fingerprint(meta["fingerprint"])
        except ValueError:
            return False
        if recorded != identity.fingerprint:
            return False
        meta.update(
            identity_version=IDENTITY_VERSION,
            identities={identity.fingerprint: {"bip39": identity.bip39}},
        )
        _select(meta, identity.fingerprint)
        write_mnemonic_meta(mnemonic_file, meta)
        return True

load_mnemonic_meta(mnemonic_file: Path, *, strict: bool = False) -> dict[str, Any]

Keep legacy best-effort reads, but never repair corruption during a write.

Source code in jmcore/src/jmcore/wallet_metadata.py
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def load_mnemonic_meta(mnemonic_file: Path, *, strict: bool = False) -> dict[str, Any]:
    """Keep legacy best-effort reads, but never repair corruption during a write."""
    try:
        data = json.loads(meta_path(mnemonic_file).read_text())
        if not isinstance(data, dict):
            raise ValueError("Mnemonic metadata must be a JSON object")
        if strict:
            _check_version(data)
        return data
    except FileNotFoundError:
        return {}
    except (ValueError, OSError) as exc:
        if strict:
            raise ValueError(
                "Wallet metadata is unreadable or unsupported; restore or repair it explicitly. "
                "It has not been replaced. Use --wallet-fingerprint for offline reads."
            ) from exc
        logger.warning("Failed to read mnemonic metadata")
        return {}

meta_path(mnemonic_file: Path) -> Path

Source code in jmcore/src/jmcore/wallet_metadata.py
47
48
def meta_path(mnemonic_file: Path) -> Path:
    return mnemonic_file.with_name(mnemonic_file.name + ".meta")

metadata_lock(mnemonic_file: Path, *, nonblocking: bool = False) -> Iterator[None]

Source code in jmcore/src/jmcore/wallet_metadata.py
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
@contextmanager
def metadata_lock(mnemonic_file: Path, *, nonblocking: bool = False) -> Iterator[None]:
    path = meta_path(mnemonic_file)
    lock_path = path.with_name(path.name + (".recovery.lock" if nonblocking else ".lock"))
    lock_path.parent.mkdir(parents=True, exist_ok=True)
    fd = os.open(
        lock_path,
        os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0),
        0o600,
    )
    try:
        if not stat.S_ISREG(os.fstat(fd).st_mode):
            raise OSError("Mnemonic metadata lock must be a regular file")
        os.fchmod(fd, 0o600)
        if sys.platform == "win32":  # pragma: no cover - Windows
            import msvcrt

            if os.fstat(fd).st_size == 0:
                os.write(fd, b"\0")
            os.lseek(fd, 0, os.SEEK_SET)
            msvcrt.locking(fd, msvcrt.LK_NBLCK if nonblocking else msvcrt.LK_LOCK, 1)
        else:
            import fcntl

            fcntl.flock(fd, fcntl.LOCK_EX | (fcntl.LOCK_NB if nonblocking else 0))
        yield
    finally:
        os.close(fd)

register_identity(mnemonic_file: Path, identity: WalletIdentity) -> bool

Register, selecting only when nothing is selected yet.

Preserves an existing selection, unknown fields, and recovery state. Returns whether the identity became the selection.

Source code in jmcore/src/jmcore/wallet_metadata.py
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
def register_identity(mnemonic_file: Path, identity: WalletIdentity) -> bool:
    """Register, selecting only when nothing is selected yet.

    Preserves an existing selection, unknown fields, and recovery state.
    Returns whether the identity became the selection.
    """
    with metadata_lock(mnemonic_file):
        meta = load_mnemonic_meta(mnemonic_file, strict=True)
        entries = meta.get("identities", {})
        if not isinstance(entries, dict):
            raise ValueError("Invalid wallet identity collection; repair metadata explicitly")
        old = entries.get(identity.fingerprint)
        if isinstance(old, dict) and old.get("bip39") not in (identity.bip39, "unknown"):
            raise ValueError("Identity already registered with a different passphrase requirement")
        entries[identity.fingerprint] = {"bip39": identity.bip39}
        meta.update(identity_version=IDENTITY_VERSION, identities=entries)
        selected = meta.get("selected_identity") is None
        if selected:
            _select(meta, identity.fingerprint)
        write_mnemonic_meta(mnemonic_file, meta)
        return selected

registered_identities(mnemonic_file: Path) -> dict[str, WalletIdentity]

Source code in jmcore/src/jmcore/wallet_metadata.py
117
118
119
def registered_identities(mnemonic_file: Path) -> dict[str, WalletIdentity]:
    meta = load_mnemonic_meta(mnemonic_file, strict=True)
    return _registered_identities(meta)

select_identity(mnemonic_file: Path, fingerprint: str) -> None

Source code in jmcore/src/jmcore/wallet_metadata.py
219
220
221
222
223
224
225
226
def select_identity(mnemonic_file: Path, fingerprint: str) -> None:
    with metadata_lock(mnemonic_file):
        meta = load_mnemonic_meta(mnemonic_file, strict=True)
        identity = _registered_identities(meta).get(fingerprint.strip().lower())
        if identity is None:
            raise ValueError("Wallet identity is not registered")
        _select(meta, identity.fingerprint)
        write_mnemonic_meta(mnemonic_file, meta)

selected_identity(mnemonic_file: Path) -> WalletIdentity | None

Source code in jmcore/src/jmcore/wallet_metadata.py
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
def selected_identity(mnemonic_file: Path) -> WalletIdentity | None:
    meta = load_mnemonic_meta(mnemonic_file, strict=True)
    selection = meta.get("selected_identity")
    if selection is None:
        if meta.get("identity_version") == IDENTITY_VERSION:
            raise ValueError(
                "No wallet identity selected. Run `jm-wallet identity list`, then "
                "`jm-wallet identity select <fingerprint>`."
            )
        return None
    if not isinstance(selection, str):
        raise ValueError("Invalid selected wallet identity; repair metadata explicitly")
    identity = _registered_identities(meta).get(selection)
    if identity is None:
        raise ValueError(
            "Selected wallet identity is unavailable; select a valid identity explicitly"
        )
    return identity

write_mnemonic_meta(mnemonic_file: Path, meta: dict[str, Any]) -> None

Source code in jmcore/src/jmcore/wallet_metadata.py
112
113
114
def write_mnemonic_meta(mnemonic_file: Path, meta: dict[str, Any]) -> None:
    _check_version(meta)
    atomic_write_private(meta_path(mnemonic_file), (json.dumps(meta, indent=2) + "\n").encode())