From e9d93dde1133d3d0d1ff205aeda5bde355ac3af2 Mon Sep 17 00:00:00 2001 From: jeffyanta Date: Fri, 24 Jul 2026 16:09:31 -0400 Subject: [PATCH] Add NewAccountFromMnemonic common utility --- ocp/common/account.go | 27 +++++++++++++++++++++++-- ocp/common/account_test.go | 40 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/ocp/common/account.go b/ocp/common/account.go index 545f488..406c637 100644 --- a/ocp/common/account.go +++ b/ocp/common/account.go @@ -5,6 +5,7 @@ import ( "context" "crypto/ed25519" "fmt" + "strings" "filippo.io/edwards25519" slip10 "github.com/anyproto/go-slip10" @@ -164,7 +165,29 @@ func NewAccountFromEntropy(entropy []byte) (*Account, error) { return nil, errors.Wrap(err, "error deriving mnemonic from entropy") } - seed := bip39.NewSeed(mnemonic, "") + return NewAccountFromMnemonic(mnemonic) +} + +// NewAccountFromMnemonic deterministically derives an account from a 12 word +// BIP-39 mnemonic phrase following the standard Solana key derivation used by +// Code and other wallets: +// +// mnemonic -> BIP-39 seed (PBKDF2) -> SLIP-0010 ed25519 key at m/44'/501'/0'/0' +// +// Words are expected to be space delimited and the checksum must be valid. An +// empty passphrase is assumed. +func NewAccountFromMnemonic(mnemonic string) (*Account, error) { + words := strings.Fields(mnemonic) + if len(words) != 12 { + return nil, errors.New("mnemonic must be 12 words") + } + normalized := strings.Join(words, " ") + + if !bip39.IsMnemonicValid(normalized) { + return nil, errors.New("mnemonic is invalid") + } + + seed := bip39.NewSeed(normalized, "") node, err := slip10.DeriveForPath(solanaDerivationPath, seed) if err != nil { @@ -174,7 +197,7 @@ func NewAccountFromEntropy(entropy []byte) (*Account, error) { derivedKey, err := NewKeyFromBytes(privateKey) if err != nil { - return nil, errors.Wrap(err, "error creating private key from entropy") + return nil, errors.Wrap(err, "error creating private key from mnemonic") } return NewAccountFromPrivateKey(derivedKey) diff --git a/ocp/common/account_test.go b/ocp/common/account_test.go index f024a60..67f2f8b 100644 --- a/ocp/common/account_test.go +++ b/ocp/common/account_test.go @@ -112,6 +112,46 @@ func TestNewAccountFromEntropy(t *testing.T) { } } +func TestNewAccountFromMnemonic(t *testing.T) { + // The canonical "abandon ... about" mnemonic, whose Solana account at + // m/44'/501'/0'/0' (empty passphrase) is a well-known address shared with + // standard wallets (Phantom, Solana CLI). + mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + + account, err := NewAccountFromMnemonic(mnemonic) + require.NoError(t, err) + require.NotNil(t, account.PrivateKey()) + assert.Equal(t, "HAgk14JpMQLgt6rVgv7cBQFJWFto5Dqxi472uT3DKpqk", account.PublicKey().ToBase58()) + + // The mnemonic derivation is equivalent to the entropy it encodes. + fromEntropy, err := NewAccountFromEntropy(make([]byte, 16)) + require.NoError(t, err) + assert.Equal(t, fromEntropy.PublicKey().ToBase58(), account.PublicKey().ToBase58()) + assert.Equal(t, fromEntropy.PrivateKey().ToBase58(), account.PrivateKey().ToBase58()) + + // Surrounding and repeated whitespace is tolerated. + untidy, err := NewAccountFromMnemonic(" abandon\tabandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about\n") + require.NoError(t, err) + assert.Equal(t, account.PublicKey().ToBase58(), untidy.PublicKey().ToBase58()) + + // A different mnemonic is unrelated. + other, err := NewAccountFromMnemonic("legal winner thank year wave sausage worth useful legal winner thank yellow") + require.NoError(t, err) + assert.NotEqual(t, account.PublicKey().ToBase58(), other.PublicKey().ToBase58()) + + for _, invalid := range []string{ + "", + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", // 11 words + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", // 13 words + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon", // Bad checksum + "notaword abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about", // Word outside the wordlist + "legal winner thank year wave sausage worth useful legal winner thank yellow legal winner thank year wave sausage", // 18 words + } { + _, err := NewAccountFromMnemonic(invalid) + assert.Error(t, err) + } +} + // TestNewAccountFromEntropy_SpecVectors anchors each stage of the derivation // pipeline to its canonical specification test vector, guarding against a // dependency behaving non-standardly.