Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,6 @@ build-tools/docker/example-mainnet-dns-server/mintlayer-data/*

# This directory will contain some generated files.
build-tools/block-data-plots/output

# Cloudflare wrangler local cache (created by local wrangler tooling)
.wrangler/
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions wasm-wrappers/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,17 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/

## [Unreleased]

### Added
- `make_default_account_privkey` now accepts an optional BIP39 passphrase as its third
argument: `make_default_account_privkey(mnemonic, network, passphrase?)`. The passphrase
is used as the BIP39 passphrase when converting the mnemonic to a seed
(salt = "mnemonic" + passphrase).
**Note:** passing `None`/`undefined`/`null` (or an empty string) preserves the legacy
behavior of deriving without a passphrase, byte-for-byte. Wallets derived with a
non-empty passphrase produce completely different keys; consumers must keep deriving
legacy wallets with no passphrase and pass the user's passphrase only for wallets
created with one.

## [1.4.0] - 2026-07-09

No changes
Expand Down
2 changes: 2 additions & 0 deletions wasm-wrappers/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ fixed-hash.workspace = true
itertools.workspace = true
serde.workspace = true
thiserror.workspace = true
zeroize.workspace = true

# This is required for `rand` to work with wasm. See: https://docs.rs/getrandom/latest/getrandom/#webassembly-support
# Note that technically we use 2 differeent versions of `rand` (0.8 and 0.10) which depend on different versions of
Expand All @@ -43,3 +44,4 @@ serde-wasm-bindgen = "0.6"
hex.workspace = true
rstest.workspace = true
test-utils = { path = "../test-utils" }
wallet = { path = "../wallet", default-features = false }
25 changes: 25 additions & 0 deletions wasm-wrappers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

This module has different basic functionalities of mintlayer compiled into wasm for various purposes, primarily interfacing with other systems and languages without having to rewrite code.

## BIP39 passphrase support

`make_default_account_privkey` accepts an optional BIP39 passphrase as its third argument:

```js
// Legacy wallets (no passphrase) - all of these are equivalent:
make_default_account_privkey(mnemonic, Network.Mainnet);
make_default_account_privkey(mnemonic, Network.Mainnet, undefined);
make_default_account_privkey(mnemonic, Network.Mainnet, null);
make_default_account_privkey(mnemonic, Network.Mainnet, "");

// Wallets created with a passphrase:
make_default_account_privkey(mnemonic, Network.Mainnet, "my secret passphrase");
```

The passphrase is used as the BIP39 passphrase when converting the mnemonic to a seed
(the salt is `"mnemonic" + passphrase`). A non-empty passphrase produces completely
different keys, so it must be remembered together with the mnemonic. Deriving keys
without a passphrase keeps the legacy behavior byte-for-byte, so existing wallets are
unaffected.

The downstream functions (`make_receiving_address`, `make_change_address`,
`encode_witness`, `sign_challenge`, etc.) take the extended private key produced by
`make_default_account_privkey` and need no changes.

##### Note: This was tested on x86_64 Linux, and may not work on other platforms. It didn't work on M1 Mac directly (particularly the build. A pre-built wasm binary works fine on a browser, see below for more information).

## Running the tests
Expand Down
8 changes: 8 additions & 0 deletions wasm-wrappers/WASM-API.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ Generates a new, random private key from entropy
Create the default account's extended private key for a given mnemonic
derivation path: 44'/mintlayer_coin_type'/0'

The optional `passphrase` is used as the BIP39 passphrase when converting the mnemonic
to a seed (the seed is derived as PBKDF2-HMAC-SHA512 over the mnemonic with the salt
"mnemonic" + passphrase). Passing `None` (or `undefined`/`null` from JS, or an empty
string) preserves the legacy behavior of deriving without a passphrase.

Note: wallets derived with a non-empty passphrase produce completely different keys,
so a passphrase must be remembered together with the mnemonic.

### Function: `make_receiving_address`

From an extended private key create a receiving private key for a given key index
Expand Down
45 changes: 45 additions & 0 deletions wasm-wrappers/js-bindings-test/tests/test_address_generation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const ADDRESS = "tmt1q9dn5m4svn8sds3fcy09kpxrefnu75xekgr5wa3n";
export function test_address_generation() {
run_one_test(predefined_address_test);
run_one_test(general_test);
run_one_test(passphrase_test);
}

export function predefined_address_test() {
Expand Down Expand Up @@ -154,3 +155,47 @@ export function general_test() {
}
}
}

// Tests the optional BIP39 passphrase parameter of `make_default_account_privkey`:
// - the legacy call forms (2 arguments, `undefined`, `null`, "") must all be equivalent;
// - a non-empty passphrase must produce different keys and a different address;
// - derivations with a passphrase must be reproducible.
export function passphrase_test() {
const PASSPHRASE = "test passphrase";

const legacy_key = make_default_account_privkey(MNEMONIC, Network.Testnet);
for (const passphrase of [undefined, null, ""]) {
const key = make_default_account_privkey(MNEMONIC, Network.Testnet, passphrase);
assert_eq_arrays(legacy_key, key);
}

const passphrase_key = make_default_account_privkey(MNEMONIC, Network.Testnet, PASSPHRASE);
const passphrase_key2 = make_default_account_privkey(MNEMONIC, Network.Testnet, PASSPHRASE);
assert_eq_arrays(passphrase_key, passphrase_key2);
if (passphrase_key.length !== legacy_key.length) {
throw new Error("Passphrase-derived key has an unexpected length");
}
assert_eq_arrays_is_different(legacy_key, passphrase_key);

const legacy_address = pubkey_to_pubkeyhash_address(
public_key_from_private_key(make_receiving_address(legacy_key, 0)),
Network.Testnet
);
const passphrase_address = pubkey_to_pubkeyhash_address(
public_key_from_private_key(make_receiving_address(passphrase_key, 0)),
Network.Testnet
);
if (legacy_address === passphrase_address) {
throw new Error("Passphrase-derived address unexpectedly equals the legacy address");
}

console.log("Tested BIP39 passphrase support successfully");
}

function assert_eq_arrays_is_different(arr1: Uint8Array, arr2: Uint8Array) {
if (arr1.length !== arr2.length) return;
for (let i = 0; i < arr1.length; i++) {
if (arr1[i] !== arr2[i]) return;
}
throw new Error("Arrays are unexpectedly equal");
}
24 changes: 21 additions & 3 deletions wasm-wrappers/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -123,13 +123,31 @@ pub fn make_private_key() -> Vec<u8> {

/// Create the default account's extended private key for a given mnemonic
/// derivation path: 44'/mintlayer_coin_type'/0'
///
/// The optional `passphrase` is used as the BIP39 passphrase when converting the mnemonic
/// to a seed (the seed is derived as PBKDF2-HMAC-SHA512 over the mnemonic with the salt
/// "mnemonic" + passphrase). Passing `None` (or `undefined`/`null` from JS, or an empty
/// string) preserves the legacy behavior of deriving without a passphrase.
///
/// Note: wallets derived with a non-empty passphrase produce completely different keys,
/// so a passphrase must be remembered together with the mnemonic.
#[wasm_bindgen]
pub fn make_default_account_privkey(mnemonic: &str, network: Network) -> Result<Vec<u8>, Error> {
pub fn make_default_account_privkey(
mnemonic: &str,
network: Network,
passphrase: Option<String>,
) -> Result<Vec<u8>, Error> {
let mnemonic =
bip39::Mnemonic::parse_in(Language::English, mnemonic).map_err(Error::InvalidMnemonic)?;
let seed = mnemonic.to_seed("");

let root_key = ExtendedPrivateKey::new_master(&seed, ExtendedKeyKind::Secp256k1Schnorr)
// Best-effort mitigation: zeroize the passphrase and the derived seed when they are
// dropped. The derived keys and the encoded result returned over the JS boundary
// cannot be fully protected, but this limits the lifetime of the raw secret material.
let passphrase = passphrase.map(zeroize::Zeroizing::new);
let passphrase: &str = passphrase.as_deref().map_or("", |p| p.as_str());
let seed = zeroize::Zeroizing::new(mnemonic.to_seed(passphrase));

let root_key = ExtendedPrivateKey::new_master(seed.as_ref(), ExtendedKeyKind::Secp256k1Schnorr)
.expect("Should not fail to create a master key");

let chain_config = Builder::new(network.into()).build();
Expand Down
Loading
Loading