Skip to content

Commit 200a672

Browse files
committed
feat(wallet): derive PrivateKey/Wallet from BIP-39 mnemonic
Closes #297. Two community members have requested an in-tree way to recover a Solana key from a mnemonic; today every consumer rolls their own combination of a BIP-39 library and SLIP-0010 derivation, with the extra friction that BIP-32 is secp256k1-only and does not apply to ed25519. This adds: - PrivateKeyFromMnemonic(mnemonic, passphrase) — Phantom default path m/44'/501'/0'/0' - PrivateKeyFromMnemonicAtPath(mnemonic, passphrase, path) - PrivateKeyFromSeedAtPath(seed, path) — for callers with their own seed - NewWalletFromMnemonic(mnemonic, passphrase) - SolanaDerivationPath constant ("m/44'/501'/0'/0'") Mnemonic -> seed uses tyler-smith/go-bip39 (the de facto Go BIP-39 implementation, also used by btcsuite/btcd/btcutil/hdkeychain and cosmos-sdk forks). Seed -> ed25519 key uses an in-tree SLIP-0010 implementation, since SLIP-0010 ed25519 only defines hardened derivation and is small enough not to warrant pulling in a generic HD-key library. Final seed -> keypair goes through the existing oasisprotocol/curve25519-voi ed25519, so the result is bit-identical to NewRandomPrivateKey output and works with the existing Sign / Validate / PublicKey methods unchanged. Tests cover: - the official SLIP-0010 ed25519 vectors (vector 1: m, m/0', m/0'/1', m/0'/1'/2', m/0'/1'/2'/2', m/0'/1'/2'/2'/1000000000'; vector 2: m, m/0') from satoshilabs/slips - derivation-path parsing (empty, default, h/H/' suffixes, non-hardened rejection, malformed input) - mnemonic validation, determinism, passphrase sensitivity, path sensitivity, and end-to-end Sign/Verify against the derived key
1 parent 75c68a3 commit 200a672

4 files changed

Lines changed: 307 additions & 0 deletions

File tree

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ require (
2828
github.com/spf13/viper v1.21.0
2929
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e
3030
github.com/stretchr/testify v1.11.1
31+
github.com/tyler-smith/go-bip39 v1.1.0
3132
go.mongodb.org/mongo-driver/v2 v2.5.0
3233
go.uber.org/ratelimit v0.3.1
3334
go.uber.org/zap v1.27.0

go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
151151
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
152152
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
153153
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
154+
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
155+
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
154156
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
155157
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
156158
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
@@ -189,6 +191,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
189191
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
190192
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
191193
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
194+
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
192195
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
193196
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
194197
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=

mnemonic.go

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
// Copyright 2026 github.com/gagliardetto
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package solana
16+
17+
import (
18+
"crypto/hmac"
19+
"crypto/sha512"
20+
"encoding/binary"
21+
"errors"
22+
"fmt"
23+
"strconv"
24+
"strings"
25+
26+
voied25519 "github.com/oasisprotocol/curve25519-voi/primitives/ed25519"
27+
"github.com/tyler-smith/go-bip39"
28+
)
29+
30+
// SolanaDerivationPath is the default BIP-44 derivation path used by Phantom
31+
// and most Solana wallets when generating a key from a BIP-39 mnemonic:
32+
// m/44'/501'/0'/0'.
33+
const SolanaDerivationPath = "m/44'/501'/0'/0'"
34+
35+
// PrivateKeyFromMnemonic derives a PrivateKey from a BIP-39 mnemonic using
36+
// the default Solana derivation path (m/44'/501'/0'/0'). The passphrase may
37+
// be empty; when set, it must match the passphrase used when the mnemonic
38+
// was generated.
39+
func PrivateKeyFromMnemonic(mnemonic, passphrase string) (PrivateKey, error) {
40+
return PrivateKeyFromMnemonicAtPath(mnemonic, passphrase, SolanaDerivationPath)
41+
}
42+
43+
// PrivateKeyFromMnemonicAtPath derives a PrivateKey from a BIP-39 mnemonic at
44+
// the given SLIP-0010 derivation path. All path segments must be hardened
45+
// (suffixed with ' or h); SLIP-0010 does not define non-hardened derivation
46+
// for ed25519.
47+
func PrivateKeyFromMnemonicAtPath(mnemonic, passphrase, path string) (PrivateKey, error) {
48+
if !bip39.IsMnemonicValid(mnemonic) {
49+
return nil, errors.New("invalid mnemonic")
50+
}
51+
seed := bip39.NewSeed(mnemonic, passphrase)
52+
return PrivateKeyFromSeedAtPath(seed, path)
53+
}
54+
55+
// PrivateKeyFromSeedAtPath derives a PrivateKey from a 16..64 byte seed
56+
// (typically a 64 byte BIP-39 seed) using the given SLIP-0010 derivation
57+
// path. All path segments must be hardened.
58+
func PrivateKeyFromSeedAtPath(seed []byte, path string) (PrivateKey, error) {
59+
indices, err := parseDerivationPath(path)
60+
if err != nil {
61+
return nil, err
62+
}
63+
key, chainCode, err := slip10MasterKey(seed)
64+
if err != nil {
65+
return nil, err
66+
}
67+
for _, index := range indices {
68+
key, chainCode = slip10ChildKey(key, chainCode, index)
69+
}
70+
return PrivateKey(voied25519.NewKeyFromSeed(key)), nil
71+
}
72+
73+
// NewWalletFromMnemonic creates a Wallet whose private key is derived from a
74+
// BIP-39 mnemonic using the default Solana derivation path m/44'/501'/0'/0'.
75+
func NewWalletFromMnemonic(mnemonic, passphrase string) (*Wallet, error) {
76+
pk, err := PrivateKeyFromMnemonic(mnemonic, passphrase)
77+
if err != nil {
78+
return nil, err
79+
}
80+
return &Wallet{PrivateKey: pk}, nil
81+
}
82+
83+
const slip10HardenedOffset = uint32(0x80000000)
84+
85+
// parseDerivationPath parses a SLIP-0010 derivation path of the form
86+
// m/idx'/idx'/... Each index must be hardened (suffixed with ' or h). An
87+
// empty path or "m" returns no indices, meaning the master key is used.
88+
func parseDerivationPath(path string) ([]uint32, error) {
89+
path = strings.TrimSpace(path)
90+
if path == "" || path == "m" || path == "/" {
91+
return nil, nil
92+
}
93+
path = strings.TrimPrefix(path, "m")
94+
path = strings.TrimPrefix(path, "/")
95+
segments := strings.Split(path, "/")
96+
indices := make([]uint32, 0, len(segments))
97+
for _, seg := range segments {
98+
seg = strings.TrimSpace(seg)
99+
if seg == "" {
100+
return nil, fmt.Errorf("invalid derivation path %q: empty segment", path)
101+
}
102+
hardened := false
103+
switch seg[len(seg)-1] {
104+
case '\'', 'h', 'H':
105+
hardened = true
106+
seg = seg[:len(seg)-1]
107+
}
108+
n, err := strconv.ParseUint(seg, 10, 32)
109+
if err != nil {
110+
return nil, fmt.Errorf("invalid derivation path %q: %w", path, err)
111+
}
112+
if n >= uint64(slip10HardenedOffset) {
113+
return nil, fmt.Errorf("invalid derivation path %q: index %d out of range", path, n)
114+
}
115+
if !hardened {
116+
return nil, fmt.Errorf("invalid derivation path %q: SLIP-0010 ed25519 requires all segments to be hardened", path)
117+
}
118+
indices = append(indices, uint32(n)+slip10HardenedOffset)
119+
}
120+
return indices, nil
121+
}
122+
123+
// slip10MasterKey derives the SLIP-0010 master key for ed25519 from a seed,
124+
// per https://github.com/satoshilabs/slips/blob/master/slip-0010.md.
125+
func slip10MasterKey(seed []byte) (key, chainCode []byte, err error) {
126+
if l := len(seed); l < 16 || l > 64 {
127+
return nil, nil, fmt.Errorf("invalid seed length %d (want 16..64 bytes)", l)
128+
}
129+
mac := hmac.New(sha512.New, []byte("ed25519 seed"))
130+
mac.Write(seed)
131+
sum := mac.Sum(nil)
132+
return sum[:32], sum[32:], nil
133+
}
134+
135+
// slip10ChildKey derives a hardened SLIP-0010 child key for ed25519. The
136+
// caller must ensure index >= slip10HardenedOffset; non-hardened derivation
137+
// is not defined for ed25519.
138+
func slip10ChildKey(parentKey, parentChainCode []byte, index uint32) (key, chainCode []byte) {
139+
mac := hmac.New(sha512.New, parentChainCode)
140+
mac.Write([]byte{0x00})
141+
mac.Write(parentKey)
142+
_ = binary.Write(mac, binary.BigEndian, index)
143+
sum := mac.Sum(nil)
144+
return sum[:32], sum[32:]
145+
}

mnemonic_test.go

Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright 2026 github.com/gagliardetto
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package solana
16+
17+
import (
18+
"encoding/hex"
19+
"testing"
20+
21+
"github.com/stretchr/testify/assert"
22+
"github.com/stretchr/testify/require"
23+
)
24+
25+
// TestSlip10Vectors covers the official SLIP-0010 ed25519 test vectors from
26+
// https://github.com/satoshilabs/slips/blob/master/slip-0010.md, exercising
27+
// the master + hardened child derivation in isolation from BIP-39.
28+
func TestSlip10Vectors(t *testing.T) {
29+
t.Run("vector 1", func(t *testing.T) {
30+
seed, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f")
31+
cases := []struct {
32+
path string
33+
key string
34+
}{
35+
{"m", "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7"},
36+
{"m/0'", "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3"},
37+
{"m/0'/1'", "b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2"},
38+
{"m/0'/1'/2'", "92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9"},
39+
{"m/0'/1'/2'/2'", "30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662"},
40+
{"m/0'/1'/2'/2'/1000000000'", "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793"},
41+
}
42+
for _, tc := range cases {
43+
t.Run(tc.path, func(t *testing.T) {
44+
pk, err := PrivateKeyFromSeedAtPath(seed, tc.path)
45+
require.NoError(t, err)
46+
// PrivateKey is seed(32) || pubkey(32); compare the seed half.
47+
assert.Equal(t, tc.key, hex.EncodeToString(pk[:32]))
48+
})
49+
}
50+
})
51+
52+
t.Run("vector 2", func(t *testing.T) {
53+
seed, _ := hex.DecodeString("fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542")
54+
cases := []struct {
55+
path string
56+
key string
57+
}{
58+
{"m", "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012"},
59+
{"m/0'", "1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635"},
60+
}
61+
for _, tc := range cases {
62+
t.Run(tc.path, func(t *testing.T) {
63+
pk, err := PrivateKeyFromSeedAtPath(seed, tc.path)
64+
require.NoError(t, err)
65+
assert.Equal(t, tc.key, hex.EncodeToString(pk[:32]))
66+
})
67+
}
68+
})
69+
}
70+
71+
func TestParseDerivationPath(t *testing.T) {
72+
t.Run("empty path returns no indices", func(t *testing.T) {
73+
for _, p := range []string{"", "m", "/"} {
74+
indices, err := parseDerivationPath(p)
75+
require.NoError(t, err)
76+
assert.Empty(t, indices)
77+
}
78+
})
79+
80+
t.Run("solana default path", func(t *testing.T) {
81+
indices, err := parseDerivationPath(SolanaDerivationPath)
82+
require.NoError(t, err)
83+
require.Len(t, indices, 4)
84+
assert.Equal(t, slip10HardenedOffset+44, indices[0])
85+
assert.Equal(t, slip10HardenedOffset+501, indices[1])
86+
assert.Equal(t, slip10HardenedOffset+0, indices[2])
87+
assert.Equal(t, slip10HardenedOffset+0, indices[3])
88+
})
89+
90+
t.Run("h and H suffixes are accepted", func(t *testing.T) {
91+
a, err := parseDerivationPath("m/44'/501'/0'/0'")
92+
require.NoError(t, err)
93+
b, err := parseDerivationPath("m/44h/501h/0h/0h")
94+
require.NoError(t, err)
95+
c, err := parseDerivationPath("m/44H/501H/0H/0H")
96+
require.NoError(t, err)
97+
assert.Equal(t, a, b)
98+
assert.Equal(t, a, c)
99+
})
100+
101+
t.Run("non-hardened segments are rejected", func(t *testing.T) {
102+
_, err := parseDerivationPath("m/44'/501'/0'/0")
103+
assert.Error(t, err)
104+
})
105+
106+
t.Run("malformed paths are rejected", func(t *testing.T) {
107+
for _, p := range []string{"m//0'", "m/abc'", "m/4294967296'"} {
108+
_, err := parseDerivationPath(p)
109+
assert.Errorf(t, err, "expected error parsing %q", p)
110+
}
111+
})
112+
}
113+
114+
func TestPrivateKeyFromMnemonic(t *testing.T) {
115+
t.Run("invalid mnemonic returns error", func(t *testing.T) {
116+
_, err := PrivateKeyFromMnemonic("not a real mnemonic", "")
117+
require.Error(t, err)
118+
})
119+
120+
t.Run("valid mnemonic derives a usable signing key", func(t *testing.T) {
121+
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
122+
pk, err := PrivateKeyFromMnemonic(mnemonic, "")
123+
require.NoError(t, err)
124+
require.NoError(t, pk.Validate())
125+
126+
// Same inputs must always produce the same key.
127+
pk2, err := PrivateKeyFromMnemonic(mnemonic, "")
128+
require.NoError(t, err)
129+
assert.Equal(t, pk, pk2)
130+
131+
// A different passphrase must produce a different key.
132+
pk3, err := PrivateKeyFromMnemonic(mnemonic, "different")
133+
require.NoError(t, err)
134+
assert.NotEqual(t, pk, pk3)
135+
136+
// A different path must produce a different key.
137+
pk4, err := PrivateKeyFromMnemonicAtPath(mnemonic, "", "m/44'/501'/1'/0'")
138+
require.NoError(t, err)
139+
assert.NotEqual(t, pk, pk4)
140+
141+
// The derived key must produce verifiable signatures.
142+
sig, err := pk.Sign([]byte("test payload"))
143+
require.NoError(t, err)
144+
assert.True(t, pk.PublicKey().Verify([]byte("test payload"), sig))
145+
})
146+
}
147+
148+
func TestNewWalletFromMnemonic(t *testing.T) {
149+
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
150+
w, err := NewWalletFromMnemonic(mnemonic, "")
151+
require.NoError(t, err)
152+
require.NotNil(t, w)
153+
154+
pk, err := PrivateKeyFromMnemonic(mnemonic, "")
155+
require.NoError(t, err)
156+
assert.Equal(t, pk, w.PrivateKey)
157+
assert.Equal(t, pk.PublicKey(), w.PublicKey())
158+
}

0 commit comments

Comments
 (0)