feat(wallet): mnemonic seed encode/decode with Electrum wordlist

24-word Electrum encoding (4 bytes → 3 words × 8 groups) plus CRC32
checksum word. 1626-word dictionary extracted from C++ source.

Co-Authored-By: Charon <charon@lethean.io>
This commit is contained in:
Claude 2026-02-20 22:59:16 +00:00
parent b506c9c0a1
commit 5b677d1f36
No known key found for this signature in database
GPG key ID: AF404715446AEB41
3 changed files with 1841 additions and 0 deletions

85
wallet/mnemonic.go Normal file
View file

@ -0,0 +1,85 @@
package wallet
import (
"encoding/binary"
"fmt"
"hash/crc32"
"strings"
)
const numWords = 1626
// MnemonicEncode converts a 32-byte secret key to a 25-word mnemonic phrase.
func MnemonicEncode(key []byte) (string, error) {
if len(key) != 32 {
return "", fmt.Errorf("wallet: mnemonic encode requires 32 bytes, got %d", len(key))
}
words := make([]string, 0, 25)
n := uint32(numWords)
for i := 0; i < 32; i += 4 {
val := binary.LittleEndian.Uint32(key[i : i+4])
w1 := val % n
w2 := ((val / n) + w1) % n
w3 := (((val / n) / n) + w2) % n
words = append(words, wordlist[w1], wordlist[w2], wordlist[w3])
}
checkIdx := checksumIndex(words)
words = append(words, words[checkIdx])
return strings.Join(words, " "), nil
}
// MnemonicDecode converts a 25-word mnemonic phrase to a 32-byte secret key.
func MnemonicDecode(phrase string) ([32]byte, error) {
var key [32]byte
words := strings.Fields(phrase)
if len(words) != 25 {
return key, fmt.Errorf("wallet: mnemonic requires 25 words, got %d", len(words))
}
expected := checksumIndex(words[:24])
if words[24] != words[expected] {
return key, fmt.Errorf("wallet: mnemonic checksum failed")
}
n := uint32(numWords)
for i := 0; i < 8; i++ {
w1, ok1 := wordIndex[words[i*3]]
w2, ok2 := wordIndex[words[i*3+1]]
w3, ok3 := wordIndex[words[i*3+2]]
if !ok1 || !ok2 || !ok3 {
word := words[i*3]
if !ok2 {
word = words[i*3+1]
}
if !ok3 {
word = words[i*3+2]
}
return key, fmt.Errorf("wallet: unknown mnemonic word %q", word)
}
val := uint32(w1) +
n*(((n-uint32(w1))+uint32(w2))%n) +
n*n*(((n-uint32(w2))+uint32(w3))%n)
binary.LittleEndian.PutUint32(key[i*4:i*4+4], val)
}
return key, nil
}
func checksumIndex(words []string) int {
var prefixes string
for _, w := range words {
if len(w) >= 3 {
prefixes += w[:3]
} else {
prefixes += w
}
}
return int(crc32.ChecksumIEEE([]byte(prefixes))) % len(words)
}

113
wallet/mnemonic_test.go Normal file
View file

@ -0,0 +1,113 @@
package wallet
import (
"strings"
"testing"
)
func TestWordlistLength(t *testing.T) {
if len(wordlist) != 1626 {
t.Fatalf("wordlist length = %d, want 1626", len(wordlist))
}
}
func TestWordlistFirstLast(t *testing.T) {
if wordlist[0] != "like" {
t.Errorf("wordlist[0] = %q, want %q", wordlist[0], "like")
}
if wordlist[1625] != "weary" {
t.Errorf("wordlist[1625] = %q, want %q", wordlist[1625], "weary")
}
}
func TestWordIndexConsistency(t *testing.T) {
for i, w := range wordlist {
idx, ok := wordIndex[w]
if !ok {
t.Fatalf("word %q not in index", w)
}
if idx != i {
t.Fatalf("wordIndex[%q] = %d, want %d", w, idx, i)
}
}
}
func TestMnemonicRoundTrip(t *testing.T) {
var key [32]byte
phrase, err := MnemonicEncode(key[:])
if err != nil {
t.Fatal(err)
}
words := strings.Fields(phrase)
if len(words) != 25 {
t.Fatalf("got %d words, want 25", len(words))
}
decoded, err := MnemonicDecode(phrase)
if err != nil {
t.Fatal(err)
}
if decoded != key {
t.Fatalf("round-trip failed: got %x, want %x", decoded, key)
}
}
func TestMnemonicRoundTripNonZero(t *testing.T) {
var key [32]byte
for i := range key {
key[i] = byte(i * 7)
}
phrase, err := MnemonicEncode(key[:])
if err != nil {
t.Fatal(err)
}
decoded, err := MnemonicDecode(phrase)
if err != nil {
t.Fatal(err)
}
if decoded != key {
t.Fatalf("round-trip failed: got %x, want %x", decoded, key)
}
}
func TestMnemonicInvalidWordCount(t *testing.T) {
_, err := MnemonicDecode("like just love")
if err == nil {
t.Fatal("expected error for 3 words")
}
}
func TestMnemonicInvalidWord(t *testing.T) {
phrase := "like just love know never want time out there make look eye down only think call hand high keep last long make new zzzznotaword"
_, err := MnemonicDecode(phrase)
if err == nil {
t.Fatal("expected error for invalid word")
}
}
func TestMnemonicBadChecksum(t *testing.T) {
var key [32]byte
phrase, _ := MnemonicEncode(key[:])
words := strings.Fields(phrase)
words[24] = "never"
if words[24] == words[0] {
words[24] = "want"
}
_, err := MnemonicDecode(strings.Join(words, " "))
if err == nil {
t.Fatal("expected checksum error")
}
}
func TestMnemonicInvalidLength(t *testing.T) {
_, err := MnemonicEncode([]byte{1, 2, 3})
if err == nil {
t.Fatal("expected error for non-32-byte input")
}
}

1643
wallet/wordlist.go Normal file

File diff suppressed because it is too large Load diff