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
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/streamingfast/logging v0.0.0-20250404134358-92b15d2fbd2e
github.com/stretchr/testify v1.11.1
github.com/tyler-smith/go-bip39 v1.1.0
go.mongodb.org/mongo-driver/v2 v2.5.0
go.uber.org/ratelimit v0.3.1
go.uber.org/zap v1.27.0
Expand Down
3 changes: 3 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/test-go/testify v1.1.4 h1:Tf9lntrKUMHiXQ07qBScBTSA0dhYQlu83hswqelv1iE=
github.com/test-go/testify v1.1.4/go.mod h1:rH7cfJo/47vWGdi4GPj16x3/t1xGOj2YxzmNQzk2ghU=
github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8=
github.com/tyler-smith/go-bip39 v1.1.0/go.mod h1:gUYDtqQw1JS3ZJ8UWVcGTGqqr6YIN3CWg+kkNaLt55U=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
Expand Down Expand Up @@ -189,6 +191,7 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
Expand Down
145 changes: 145 additions & 0 deletions mnemonic.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
// Copyright 2026 github.com/gagliardetto
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package solana

import (
"crypto/hmac"
"crypto/sha512"
"encoding/binary"
"errors"
"fmt"
"strconv"
"strings"

voied25519 "github.com/oasisprotocol/curve25519-voi/primitives/ed25519"
"github.com/tyler-smith/go-bip39"
)

// SolanaDerivationPath is the default BIP-44 derivation path used by Phantom
// and most Solana wallets when generating a key from a BIP-39 mnemonic:
// m/44'/501'/0'/0'.
const SolanaDerivationPath = "m/44'/501'/0'/0'"

// PrivateKeyFromMnemonic derives a PrivateKey from a BIP-39 mnemonic using
// the default Solana derivation path (m/44'/501'/0'/0'). The passphrase may
// be empty; when set, it must match the passphrase used when the mnemonic
// was generated.
func PrivateKeyFromMnemonic(mnemonic, passphrase string) (PrivateKey, error) {
return PrivateKeyFromMnemonicAtPath(mnemonic, passphrase, SolanaDerivationPath)
}

// PrivateKeyFromMnemonicAtPath derives a PrivateKey from a BIP-39 mnemonic at
// the given SLIP-0010 derivation path. All path segments must be hardened
// (suffixed with ' or h); SLIP-0010 does not define non-hardened derivation
// for ed25519.
func PrivateKeyFromMnemonicAtPath(mnemonic, passphrase, path string) (PrivateKey, error) {
if !bip39.IsMnemonicValid(mnemonic) {
return nil, errors.New("invalid mnemonic")
}
seed := bip39.NewSeed(mnemonic, passphrase)
return PrivateKeyFromSeedAtPath(seed, path)
}

// PrivateKeyFromSeedAtPath derives a PrivateKey from a 16..64 byte seed
// (typically a 64 byte BIP-39 seed) using the given SLIP-0010 derivation
// path. All path segments must be hardened.
func PrivateKeyFromSeedAtPath(seed []byte, path string) (PrivateKey, error) {
indices, err := parseDerivationPath(path)
if err != nil {
return nil, err
}
key, chainCode, err := slip10MasterKey(seed)
if err != nil {
return nil, err
}
for _, index := range indices {
key, chainCode = slip10ChildKey(key, chainCode, index)
}
return PrivateKey(voied25519.NewKeyFromSeed(key)), nil
}

// NewWalletFromMnemonic creates a Wallet whose private key is derived from a
// BIP-39 mnemonic using the default Solana derivation path m/44'/501'/0'/0'.
func NewWalletFromMnemonic(mnemonic, passphrase string) (*Wallet, error) {
pk, err := PrivateKeyFromMnemonic(mnemonic, passphrase)
if err != nil {
return nil, err
}
return &Wallet{PrivateKey: pk}, nil
}

const slip10HardenedOffset = uint32(0x80000000)

// parseDerivationPath parses a SLIP-0010 derivation path of the form
// m/idx'/idx'/... Each index must be hardened (suffixed with ' or h). An
// empty path or "m" returns no indices, meaning the master key is used.
func parseDerivationPath(path string) ([]uint32, error) {
path = strings.TrimSpace(path)
if path == "" || path == "m" || path == "/" {
return nil, nil
}
path = strings.TrimPrefix(path, "m")
path = strings.TrimPrefix(path, "/")
segments := strings.Split(path, "/")
indices := make([]uint32, 0, len(segments))
for _, seg := range segments {
seg = strings.TrimSpace(seg)
if seg == "" {
return nil, fmt.Errorf("invalid derivation path %q: empty segment", path)
}
hardened := false
switch seg[len(seg)-1] {
case '\'', 'h', 'H':
hardened = true
seg = seg[:len(seg)-1]
}
n, err := strconv.ParseUint(seg, 10, 32)
if err != nil {
return nil, fmt.Errorf("invalid derivation path %q: %w", path, err)
}
if n >= uint64(slip10HardenedOffset) {
return nil, fmt.Errorf("invalid derivation path %q: index %d out of range", path, n)
}
if !hardened {
return nil, fmt.Errorf("invalid derivation path %q: SLIP-0010 ed25519 requires all segments to be hardened", path)
}
indices = append(indices, uint32(n)+slip10HardenedOffset)
}
return indices, nil
}

// slip10MasterKey derives the SLIP-0010 master key for ed25519 from a seed,
// per https://github.com/satoshilabs/slips/blob/master/slip-0010.md.
func slip10MasterKey(seed []byte) (key, chainCode []byte, err error) {
if l := len(seed); l < 16 || l > 64 {
return nil, nil, fmt.Errorf("invalid seed length %d (want 16..64 bytes)", l)
}
mac := hmac.New(sha512.New, []byte("ed25519 seed"))
mac.Write(seed)
sum := mac.Sum(nil)
return sum[:32], sum[32:], nil
}

// slip10ChildKey derives a hardened SLIP-0010 child key for ed25519. The
// caller must ensure index >= slip10HardenedOffset; non-hardened derivation
// is not defined for ed25519.
func slip10ChildKey(parentKey, parentChainCode []byte, index uint32) (key, chainCode []byte) {
mac := hmac.New(sha512.New, parentChainCode)
mac.Write([]byte{0x00})
mac.Write(parentKey)
_ = binary.Write(mac, binary.BigEndian, index)
sum := mac.Sum(nil)
return sum[:32], sum[32:]
}
158 changes: 158 additions & 0 deletions mnemonic_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright 2026 github.com/gagliardetto
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package solana

import (
"encoding/hex"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestSlip10Vectors covers the official SLIP-0010 ed25519 test vectors from
// https://github.com/satoshilabs/slips/blob/master/slip-0010.md, exercising
// the master + hardened child derivation in isolation from BIP-39.
func TestSlip10Vectors(t *testing.T) {
t.Run("vector 1", func(t *testing.T) {
seed, _ := hex.DecodeString("000102030405060708090a0b0c0d0e0f")
cases := []struct {
path string
key string
}{
{"m", "2b4be7f19ee27bbf30c667b642d5f4aa69fd169872f8fc3059c08ebae2eb19e7"},
{"m/0'", "68e0fe46dfb67e368c75379acec591dad19df3cde26e63b93a8e704f1dade7a3"},
{"m/0'/1'", "b1d0bad404bf35da785a64ca1ac54b2617211d2777696fbffaf208f746ae84f2"},
{"m/0'/1'/2'", "92a5b23c0b8a99e37d07df3fb9966917f5d06e02ddbd909c7e184371463e9fc9"},
{"m/0'/1'/2'/2'", "30d1dc7e5fc04c31219ab25a27ae00b50f6fd66622f6e9c913253d6511d1e662"},
{"m/0'/1'/2'/2'/1000000000'", "8f94d394a8e8fd6b1bc2f3f49f5c47e385281d5c17e65324b0f62483e37e8793"},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
pk, err := PrivateKeyFromSeedAtPath(seed, tc.path)
require.NoError(t, err)
// PrivateKey is seed(32) || pubkey(32); compare the seed half.
assert.Equal(t, tc.key, hex.EncodeToString(pk[:32]))
})
}
})

t.Run("vector 2", func(t *testing.T) {
seed, _ := hex.DecodeString("fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542")
cases := []struct {
path string
key string
}{
{"m", "171cb88b1b3c1db25add599712e36245d75bc65a1a5c9e18d76f9f2b1eab4012"},
{"m/0'", "1559eb2bbec5790b0c65d8693e4d0875b1747f4970ae8b650486ed7470845635"},
}
for _, tc := range cases {
t.Run(tc.path, func(t *testing.T) {
pk, err := PrivateKeyFromSeedAtPath(seed, tc.path)
require.NoError(t, err)
assert.Equal(t, tc.key, hex.EncodeToString(pk[:32]))
})
}
})
}

func TestParseDerivationPath(t *testing.T) {
t.Run("empty path returns no indices", func(t *testing.T) {
for _, p := range []string{"", "m", "/"} {
indices, err := parseDerivationPath(p)
require.NoError(t, err)
assert.Empty(t, indices)
}
})

t.Run("solana default path", func(t *testing.T) {
indices, err := parseDerivationPath(SolanaDerivationPath)
require.NoError(t, err)
require.Len(t, indices, 4)
assert.Equal(t, slip10HardenedOffset+44, indices[0])
assert.Equal(t, slip10HardenedOffset+501, indices[1])
assert.Equal(t, slip10HardenedOffset+0, indices[2])
assert.Equal(t, slip10HardenedOffset+0, indices[3])
})

t.Run("h and H suffixes are accepted", func(t *testing.T) {
a, err := parseDerivationPath("m/44'/501'/0'/0'")
require.NoError(t, err)
b, err := parseDerivationPath("m/44h/501h/0h/0h")
require.NoError(t, err)
c, err := parseDerivationPath("m/44H/501H/0H/0H")
require.NoError(t, err)
assert.Equal(t, a, b)
assert.Equal(t, a, c)
})

t.Run("non-hardened segments are rejected", func(t *testing.T) {
_, err := parseDerivationPath("m/44'/501'/0'/0")
assert.Error(t, err)
})

t.Run("malformed paths are rejected", func(t *testing.T) {
for _, p := range []string{"m//0'", "m/abc'", "m/4294967296'"} {
_, err := parseDerivationPath(p)
assert.Errorf(t, err, "expected error parsing %q", p)
}
})
}

func TestPrivateKeyFromMnemonic(t *testing.T) {
t.Run("invalid mnemonic returns error", func(t *testing.T) {
_, err := PrivateKeyFromMnemonic("not a real mnemonic", "")
require.Error(t, err)
})

t.Run("valid mnemonic derives a usable signing key", func(t *testing.T) {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
pk, err := PrivateKeyFromMnemonic(mnemonic, "")
require.NoError(t, err)
require.NoError(t, pk.Validate())

// Same inputs must always produce the same key.
pk2, err := PrivateKeyFromMnemonic(mnemonic, "")
require.NoError(t, err)
assert.Equal(t, pk, pk2)

// A different passphrase must produce a different key.
pk3, err := PrivateKeyFromMnemonic(mnemonic, "different")
require.NoError(t, err)
assert.NotEqual(t, pk, pk3)

// A different path must produce a different key.
pk4, err := PrivateKeyFromMnemonicAtPath(mnemonic, "", "m/44'/501'/1'/0'")
require.NoError(t, err)
assert.NotEqual(t, pk, pk4)

// The derived key must produce verifiable signatures.
sig, err := pk.Sign([]byte("test payload"))
require.NoError(t, err)
assert.True(t, pk.PublicKey().Verify([]byte("test payload"), sig))
})
}

func TestNewWalletFromMnemonic(t *testing.T) {
mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
w, err := NewWalletFromMnemonic(mnemonic, "")
require.NoError(t, err)
require.NotNil(t, w)

pk, err := PrivateKeyFromMnemonic(mnemonic, "")
require.NoError(t, err)
assert.Equal(t, pk, w.PrivateKey)
assert.Equal(t, pk.PublicKey(), w.PublicKey())
}