Skip to content

Commit 15a87d6

Browse files
authored
Merge pull request #196 from ethpandaops/pk910/wallet-key-util
add `wallet-key` subcommand to `spamoor-utils`
2 parents bd995de + 8edb4fa commit 15a87d6

File tree

2 files changed

+160
-0
lines changed

2 files changed

+160
-0
lines changed

cmd/spamoor-utils/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ func main() {
2525
}
2626

2727
rootCmd.AddCommand(NewConvertEESTCmd(logger))
28+
rootCmd.AddCommand(NewWalletKeyCmd(logger))
2829

2930
rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Verbose output")
3031
rootCmd.PersistentFlags().Bool("trace", false, "Trace output")

cmd/spamoor-utils/wallet_key.go

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package main
2+
3+
import (
4+
"crypto/ecdsa"
5+
"crypto/sha256"
6+
"encoding/binary"
7+
"fmt"
8+
"strings"
9+
10+
"github.com/ethereum/go-ethereum/common"
11+
"github.com/ethereum/go-ethereum/crypto"
12+
"github.com/sirupsen/logrus"
13+
"github.com/spf13/cobra"
14+
)
15+
16+
// NewWalletKeyCmd creates the wallet-key subcommand for deriving child wallet
17+
// private keys from a root key, seed, and wallet index or well-known name.
18+
func NewWalletKeyCmd(logger logrus.FieldLogger) *cobra.Command {
19+
var rootKey string
20+
var seed string
21+
var walletIndex int64
22+
var walletName string
23+
var veryWellKnown bool
24+
var count uint64
25+
26+
cmd := &cobra.Command{
27+
Use: "wallet-key",
28+
Short: "Derive a child wallet private key",
29+
Long: `Derive the private key and address of a spamoor child wallet.
30+
31+
The derivation uses the same logic as spamoor's WalletPool:
32+
SHA256(root_private_key || identifier || seed)
33+
34+
For indexed wallets, the identifier is the 8-byte big-endian wallet index.
35+
For well-known wallets, the identifier is the wallet name as bytes.
36+
37+
The seed is appended to the identifier unless --very-well-known is set.
38+
39+
Use --count to generate multiple consecutive indexed wallets starting from --index.`,
40+
RunE: func(cmd *cobra.Command, args []string) error {
41+
return runWalletKey(rootKey, seed, walletIndex, walletName, veryWellKnown, count)
42+
},
43+
}
44+
45+
cmd.Flags().StringVar(&rootKey, "root-key", "", "Root wallet private key (hex, with or without 0x prefix)")
46+
cmd.Flags().StringVar(&seed, "seed", "", "Wallet seed (scenario seed)")
47+
cmd.Flags().Int64Var(&walletIndex, "index", -1, "Child wallet index (0-based)")
48+
cmd.Flags().StringVar(&walletName, "name", "", "Well-known wallet name")
49+
cmd.Flags().BoolVar(&veryWellKnown, "very-well-known", false, "Skip seed for well-known wallet derivation")
50+
cmd.Flags().Uint64Var(&count, "count", 0, "Number of consecutive indexed wallets to derive (starting from --index)")
51+
52+
if err := cmd.MarkFlagRequired("root-key"); err != nil {
53+
logger.WithError(err).Error("failed to mark root-key flag as required")
54+
}
55+
56+
return cmd
57+
}
58+
59+
// deriveChildKey derives a child private key and address from a parent key and identifier bytes.
60+
func deriveChildKey(parentKeyBytes, idxBytes []byte) (string, common.Address, error) {
61+
childKeyHash := sha256.Sum256(append(parentKeyBytes, idxBytes...))
62+
childKeyHex := fmt.Sprintf("%x", childKeyHash)
63+
64+
childPrivKey, err := crypto.HexToECDSA(childKeyHex)
65+
if err != nil {
66+
return "", common.Address{}, fmt.Errorf("failed to derive child key: %w", err)
67+
}
68+
69+
publicKey := childPrivKey.Public()
70+
71+
publicKeyECDSA, ok := publicKey.(*ecdsa.PublicKey)
72+
if !ok {
73+
return "", common.Address{}, fmt.Errorf("failed to cast public key to ECDSA")
74+
}
75+
76+
address := crypto.PubkeyToAddress(*publicKeyECDSA)
77+
78+
return childKeyHex, address, nil
79+
}
80+
81+
func runWalletKey(rootKey, seed string, walletIndex int64, walletName string, veryWellKnown bool, count uint64) error {
82+
if walletIndex < 0 && walletName == "" {
83+
return fmt.Errorf("either --index or --name must be specified")
84+
}
85+
86+
if walletIndex >= 0 && walletName != "" {
87+
return fmt.Errorf("--index and --name are mutually exclusive")
88+
}
89+
90+
if count > 0 && walletName != "" {
91+
return fmt.Errorf("--count can only be used with --index")
92+
}
93+
94+
// Parse root private key.
95+
rootKey = strings.TrimPrefix(rootKey, "0x")
96+
97+
rootPrivKey, err := crypto.HexToECDSA(rootKey)
98+
if err != nil {
99+
return fmt.Errorf("invalid root key: %w", err)
100+
}
101+
102+
parentKeyBytes := crypto.FromECDSA(rootPrivKey)
103+
104+
// Batch mode: generate multiple consecutive indexed wallets.
105+
if count > 0 {
106+
seedBytes := []byte(seed)
107+
108+
for i := range count {
109+
idx := uint64(walletIndex) + i
110+
111+
idxBytes := make([]byte, 8)
112+
binary.BigEndian.PutUint64(idxBytes, idx)
113+
114+
if seed != "" {
115+
idxBytes = append(idxBytes, seedBytes...)
116+
}
117+
118+
keyHex, address, err := deriveChildKey(parentKeyBytes, idxBytes)
119+
if err != nil {
120+
return fmt.Errorf("failed to derive wallet %d: %w", idx, err)
121+
}
122+
123+
fmt.Printf("%s 0x%s\n", address.Hex(), keyHex)
124+
}
125+
126+
return nil
127+
}
128+
129+
// Single wallet mode.
130+
var idxBytes []byte
131+
132+
if walletName != "" {
133+
// Well-known wallet: identifier is the name bytes.
134+
idxBytes = make([]byte, len(walletName))
135+
copy(idxBytes, walletName)
136+
137+
if seed != "" && !veryWellKnown {
138+
idxBytes = append(idxBytes, []byte(seed)...)
139+
}
140+
} else {
141+
// Indexed wallet: identifier is 8-byte big-endian index.
142+
idxBytes = make([]byte, 8)
143+
binary.BigEndian.PutUint64(idxBytes, uint64(walletIndex))
144+
145+
if seed != "" {
146+
idxBytes = append(idxBytes, []byte(seed)...)
147+
}
148+
}
149+
150+
keyHex, address, err := deriveChildKey(parentKeyBytes, idxBytes)
151+
if err != nil {
152+
return err
153+
}
154+
155+
fmt.Printf("Private Key: 0x%s\n", keyHex)
156+
fmt.Printf("Address: %s\n", address.Hex())
157+
158+
return nil
159+
}

0 commit comments

Comments
 (0)