Skip to content

feat: generate XE addresses with standard BIP39 recovery phrases (v2) - #294

Merged
adamkdean merged 2 commits into
developfrom
feat-1013-bip39-recovery-phrase
Oct 15, 2026
Merged

adamkdean merged 2 commits into
developfrom
feat-1013-bip39-recovery-phrase

Conversation

@adamkdean

@adamkdean adamkdean commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Moves the XE address generator (added in #293, claim XE flow) from the first XE recovery phrase scheme to XE recovery phrase v2: standard BIP39 + SLIP-0010 ed25519, as specified in xeprotocol/core#1013 (design: standard BIP39 recovery phrase) and implemented in xeprotocol/core#1014.

Blocks xe-claim go-live. Claimed XE will sit at addresses this generator creates. Once real claims exist under the old scheme, both schemes must be supported for good. This PR must reach the production wallet before claims open.

Base is develop. Nothing deploys from this PR: deploy.yml runs on master only, and test.yml runs on PRs to master only, so CI does not run here. Tests and the build were run locally (below).

Companion PRs

Why

The first scheme used the BIP39 word list and checksum but treated the 24 words as the ed25519 seed itself, with no PBKDF2 and no derivation path. Every other ed25519 chain (Solana, Stellar, Cardano, NEAR, Aptos) derives the key from BIP39 words with PBKDF2-HMAC-SHA512 and SLIP-0010. The same words therefore gave one account in XE software and a different one in every standard tool, and ruled out hardware wallet and multi-chain wallet support. v2 adopts the standard derivation; the address rule (sha256("xe/account/v1" || pubkey)) does not change.

Derivation

24 words (BIP39 English, 256-bit entropy)
  -> PBKDF2-HMAC-SHA512(words, "mnemonic" || passphrase, 2048)   (@scure/bip39 mnemonicToSeedSync, NFKD)
  -> SLIP-0010 ed25519, hardened path m/44'/1'/0'                (@noble/hashes hmac + sha512)
  -> 32-byte ed25519 seed -> public key -> sha256("xe/account/v1" || pubkey)

Coin type 1 is provisional (RECOVERY_PHRASE_COIN_TYPE), pending a SLIP-0044 registration for XE.

Changes

src/utils/xe-account.js

Export Behaviour
newXeAccount({ passphrase }) A fresh 24-word phrase (256-bit entropy) at account 0. "New address" always means a new phrase.
xeAccountFromPhrase(phrase, { passphrase, account }) Validates and derives; returns { phrase, words, account, seed, address }.
validatePhrase(phrase) Normalises (lowercase, Unicode whitespace, NFKD) and validates; throws PhraseError with code word_count, unknown_word (with position) or checksum.
slip10Ed25519(seed, path) SLIP-0010 ed25519, hardened children only.
normalizePhrase, publicKeyFromSeed, addressFromPublicKey, pickCheckPositions, PhraseError, RECOVERY_PHRASE_COIN_TYPE Helpers and constants; pickCheckPositions is unchanged.
  • Account indexes outside 0 to 2147483647 (negative, fractional, NaN, Infinity, 2^31 or above) are refused before derivation.
  • phraseFromSeed (the old words-are-the-seed encoding) is gone.
  • No dependency change: the existing @scure/bip39 2.4, @noble/curves 2.4 and @noble/hashes 2.4 cover everything.

src/components/tx/XeAddressModal.vue

  • New address generates a new phrase at account 0, resets the passphrase option and clears both passphrase fields.
  • Three-word check is unchanged and still mandatory before "Use this address".
  • Advanced: BIP39 passphrase (closed by default):
    • a passphrase field and a confirm field ("must match exactly");
    • Apply passphrase stays disabled until both entries are identical, and a direct apply with mismatched entries is ignored;
    • applying re-derives the address under the same phrase, clears both fields, and resets the word check;
    • "I have written them down" stays disabled while either passphrase field holds text, so an un-applied passphrase cannot be skipped by accident;
    • copy: keep it separately, it is never stored, a wrong passphrase opens a different, empty account, avoid a weak passphrase.
  • Nothing is stored: the phrase and passphrase exist only while the modal is open.

Tests and config

  • src/utils/xe-recovery-phrase-vectors.json: byte-identical copy of core's v2 vectors (SHA-256 ef19072c0ead1a53817cd7af926c1a14504f9740ca072abc0ea7df0750e3e61e): 24 official BIP39 vectors with seeds, 12 SLIP-0010 ed25519 chains, 10 XE end-to-end cases, 4 invalid cases.
  • src/utils/xe-account.test.js: pins the vectors and the word list hash; every BIP39 vector, every SLIP-0010 chain, every XE case (seed and address), every invalid case with its code and position; invalid account values; Unicode whitespace and NFKD passphrase normalisation; a new phrase for every account-0 address; check positions.
  • src/components/tx/XeAddressModal.test.js (new): the three-word gate before the address is emitted; identical passphrase entries required and both cleared after apply; a fresh phrase on New address with both passphrase inputs cleared.
  • vitest.config.js: adds @vitejs/plugin-vue so component tests can mount .vue files.

Verification

Check Result
npm test 173 passed, 0 failed, 7 files (117 on develop)
npm run build pass; the existing Browserslist-age and bundle-size warnings are unchanged
Vectors byte-equal with core equal
Cross-client restore a phrase from this generator restores to the same address in the core CLI, the core web wallet and the SDK, and theirs restore here: 16/16 (core scripts/test-1013-recovery/cross.mjs), plus a separate 32/32 run at accounts 0 and 5 against an independent Python reference

Compatibility

  • Addresses made with the old generator restore a different, empty account under v2: the old words also have valid BIP39 checksums, so software cannot tell the schemes apart from the words. Decision 7 in xeprotocol/core#1013 is answered (2026-10-14): no build with the old generator reached users outside the Edge testnet, so no real user holds an old-scheme address and dropping the old scheme is safe.
  • Any standard BIP39 + SLIP-0010 ed25519 implementation now derives the same XE key from the words on m/44'/1'/0'.

Before merge

  1. Coin type 1 is provisional. A SLIP-0044 registration changes RECOVERY_PHRASE_COIN_TYPE and the vectors (copied again from core). Accepted as 1 for the Edge testnet (2026-10-15). The coin type must be final before xe-claim go-live: claimed XE sits at addresses derived on this path.
  2. Merge after xeprotocol/core#1014, which owns the vectors file. xeprotocol/core#1014 merged as 92d83d1 (2026-10-15); the vectors file here is still byte-identical to core's (SHA-256 ef19072c…e61e).
  3. Must ship to the production wallet before xe-claim go-live. This merge is into develop only, for the Edge testnet wallet; the develop to master release waits for the go-live approval.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant