git / moonbite-network / tests/test_address_derivation.py · raw

"""The browser and the chain must derive the same address from a seed phrase.The wallet PWA derives its address client-side so the user's seed nevercrosses the network, which means the derivation exists twice: once inwallet.py and once in static/moonbite-address.js. If those two ever disagree,a user's coins land at an address their other device cannot see and the walletsilently reports a zero balance on real funds.These tests run the JavaScript under node and compare it against the Pythonreference for a spread of phrases, so a change to either side fails hereinstead of in someone's wallet."""import jsonimport shutilimport subprocessimport sysfrom pathlib import Pathimport pytestsys.path.insert(0, str(Path(__file__).resolve().parent.parent))from wallet import (derive_from_seed_phrase, is_valid_seed_address,  # noqa: E402                    normalize_seed_phrase)REPO = Path(__file__).resolve().parent.parent# Ordinary phrases, plus the formatting variants a user actually produces when# retyping a seed on a second device.PHRASES = [    "mineral swift option stale harbor cinder volt marrow flint",    "alpha bravo charlie delta echo foxtrot golf hotel india",    "  MINERAL   Swift Option Stale Harbor Cinder Volt Marrow Flint  ",    "zebra zebra zebra zebra zebra zebra zebra zebra zebra",    "one",    "trailing space at the end ",    "TABS\tAND\nNEWLINES between words",    # A valid BIP39 mnemonic (checksum OK) so the BIP32/BIP84 branch is    # exercised, not just the legacy-scalar fallback the free-form phrases hit.    "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",]node = shutil.which("node")requires_node = pytest.mark.skipif(node is None, reason="node is not installed")def _derive_in_js(phrases: list[str]) -> list[dict]:    """Run the browser derivation under node and return its results."""    script = f"""    import {{ deriveFromSeedPhrase }} from './static/moonbite-address.js';    const phrases = {json.dumps(phrases)};    const out = [];    for (const p of phrases) out.push(await deriveFromSeedPhrase(p));    console.log(JSON.stringify(out));    """    # .mjs so node treats the bare file as an ES module without a package.json.    tmp = REPO / "_derivation_check.mjs"    tmp.write_text(script, encoding="utf-8")    try:        proc = subprocess.run(            [node, str(tmp)],            cwd=REPO,            capture_output=True,            text=True,            timeout=120,        )        if proc.returncode != 0:            raise AssertionError(f"node failed: {proc.stderr.strip()}")        return json.loads(proc.stdout)    finally:        tmp.unlink(missing_ok=True)@requires_nodedef test_js_and_python_agree_on_every_field():    js_results = _derive_in_js(PHRASES)    assert len(js_results) == len(PHRASES)    for phrase, js in zip(PHRASES, js_results):        py = derive_from_seed_phrase(phrase)        for field in ("private_key", "public_key", "pubkey_hash", "address"):            assert js[field] == py[field], (                f"{field} differs for {phrase!r}:\n"                f"  js={js[field]}\n  py={py[field]}"            )@requires_nodedef test_js_addresses_are_valid_on_chain():    for js in _derive_in_js(PHRASES):        assert is_valid_seed_address(js["address"])def test_addresses_are_unique_per_phrase():    # The bug this replaced handed every user the same address, so assert    # distinctness explicitly rather than trusting the hash.    distinct = {        derive_from_seed_phrase(p)["address"]        for p in PHRASES        if normalize_seed_phrase(p) != normalize_seed_phrase(PHRASES[0])    }    assert len(distinct) == len(        {normalize_seed_phrase(p) for p in PHRASES if normalize_seed_phrase(p) != normalize_seed_phrase(PHRASES[0])}    )def test_normalization_is_stable_across_retyping():    base = derive_from_seed_phrase(PHRASES[0])["address"]    for variant in (        PHRASES[0].upper(),        f"   {PHRASES[0]}   ",        PHRASES[0].replace(" ", "  "),        PHRASES[0].replace(" ", "\t"),    ):        assert derive_from_seed_phrase(variant)["address"] == basedef test_empty_phrase_is_rejected():    for empty in ("", "   ", "\t\n"):        with pytest.raises(ValueError):            derive_from_seed_phrase(empty)def test_single_word_change_changes_the_address():    a = derive_from_seed_phrase(PHRASES[0])["address"]    b = derive_from_seed_phrase(PHRASES[0].replace("flint", "flame"))["address"]    assert a != b# ---- optional extra password (BIP39 passphrase) ----------------------------# Same agreement guarantee with a password set. The browser can receive the# password either as an argument or inside its packed in-memory secret; both# must land on the Python reference's key._ABOUT = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"PASS_CASES = [    (_ABOUT, ""),    (_ABOUT, "TREZOR"),    (_ABOUT, "trezor"),            # case is significant    (_ABOUT, " TREZOR"),           # spaces are significant    (_ABOUT, "pässwörd ü"),        # NFKD-normalized, per BIP39    (_ABOUT, "correct horse battery staple 123!"),]def _derive_pass_in_js(cases):    script = f"""    import {{ deriveFromSeedPhrase, packSecret }} from './static/moonbite-address.js';    import {{ mnemonicToSeed }} from './static/moonbite-bip39.js';    const cases = {json.dumps(cases)};    const out = [];    for (const [m, p] of cases) {{      const byArg = await deriveFromSeedPhrase(m, p);      const byPack = await deriveFromSeedPhrase(packSecret(m, p));      const seed = Buffer.from(await mnemonicToSeed(m, p)).toString('hex');      out.push({{ byArg, byPack, seed }});    }}    console.log(JSON.stringify(out));    """    tmp = REPO / "_passphrase_check.mjs"    tmp.write_text(script, encoding="utf-8")    try:        proc = subprocess.run([node, str(tmp)], cwd=REPO, capture_output=True, text=True, timeout=120)        if proc.returncode != 0:            raise AssertionError(f"node failed: {proc.stderr.strip()}")        return json.loads(proc.stdout)    finally:        tmp.unlink(missing_ok=True)@requires_nodedef test_js_and_python_agree_with_extra_password():    for (m, p), js in zip(PASS_CASES, _derive_pass_in_js(PASS_CASES)):        py = derive_from_seed_phrase(m, p)        for field in ("private_key", "public_key", "pubkey_hash", "address"):            assert js["byArg"][field] == py[field], f"{field} differs for password {p!r}"            assert js["byPack"][field] == py[field], f"packed {field} differs for password {p!r}"@requires_nodedef test_official_bip39_vector_with_password():    # Trezor's published BIP39 vector: "abandon x11 about" + passphrase "TREZOR".    want = ("c55257c360c07c72029aebc1b53c05ed0362ada38ead3e3e9efa3708e53495531f"            "09a6987599d18264c1e1c92f2cf141630c7a3c4ab7c81b2f001698e7463b04")    from wallet import _bip39_seed    assert _bip39_seed(_ABOUT, "TREZOR").hex() == want    js = _derive_pass_in_js([(_ABOUT, "TREZOR")])[0]    assert js["seed"] == wantdef test_empty_password_is_todays_wallet():    assert derive_from_seed_phrase(_ABOUT, "") == derive_from_seed_phrase(_ABOUT)def test_every_password_is_its_own_wallet():    addrs = {derive_from_seed_phrase(m, p)["address"] for m, p in PASS_CASES}    assert len(addrs) == len(PASS_CASES)def test_password_refused_for_non_bip39_phrase():    with pytest.raises(ValueError):        derive_from_seed_phrase(PHRASES[0], "secret")