Skip to content

Commit 38b946f

Browse files
authored
Merge pull request #132 from synonymdev/feat/seedkit
feat: add seedkit - regtest wallet scenario generator
2 parents 9885ba7 + 5c8063f commit 38b946f

23 files changed

Lines changed: 1467 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ node_modules
55
docker/lnd
66
artifacts
77
WARP.md
8-
.ai
8+
.ai
9+
tools/seedkit/seedkit

tools/seedkit/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
seedkit

tools/seedkit/README.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# seedkit
2+
3+
Generate realistic Bitcoin wallets on regtest for Bitkit.
4+
5+
seedkit creates regtest chain state matching predefined scenarios, outputting a BIP39 mnemonic that restores cleanly in Bitkit. Useful for demos, QA, marketing screenshots, and support.
6+
7+
## Quick Start
8+
9+
```bash
10+
go build -o seedkit .
11+
12+
./seedkit list
13+
14+
./seedkit run first-time
15+
```
16+
17+
## Prerequisites
18+
19+
- **Go 1.22+**
20+
- **Local backend**: [bitkit-docker](https://github.com/synonymdev/bitkit-docker) running (Bitcoin Core RPC on localhost:43782)
21+
- **Staging backend**: Access to Synonym staging network (api.stag0.blocktank.to)
22+
23+
## Usage
24+
25+
```bash
26+
# List all scenarios
27+
seedkit list
28+
29+
# Run a scenario (local backend, default)
30+
seedkit run first-time
31+
32+
# Run against staging
33+
seedkit run merchant --backend staging
34+
35+
# Custom Bitcoin Core RPC
36+
seedkit run fragmented --rpc-url http://user:pass@host:port
37+
38+
# Custom Blocktank URL
39+
seedkit run merchant --backend staging --blocktank-url https://custom.api/v2
40+
41+
# Use existing mnemonic
42+
seedkit run dust --mnemonic "word1 word2 word3 word4 word5 word6 word7 word8 word9 word10 word11 word12"
43+
44+
# JSON output (for E2E test integration)
45+
seedkit run first-time --output json
46+
47+
# Preview wallet state (reads mnemonic from clipboard)
48+
seedkit preview
49+
50+
# Preview against staging
51+
seedkit preview --backend staging
52+
```
53+
54+
## Scenarios
55+
56+
| Scenario | Description |
57+
|----------|-------------|
58+
| `first-time` | Clean wallet with one confirmed receive (50,000 sat) |
59+
| `fragmented` | 18 small UTXOs (2,000-9,100 sat) for coin selection testing |
60+
| `dust` | Tiny UTXOs at spendability edge cases (330-1,000 sat) |
61+
| `merchant` | 12 inbound payments across multiple blocks |
62+
| `savings` | Single large UTXO (1,000,000 sat) |
63+
64+
## Backends
65+
66+
### Local (default)
67+
68+
Connects to Bitcoin Core via JSON-RPC. Expects [bitkit-docker](https://github.com/synonymdev/bitkit-docker) or the [bitkit-e2e-tests](https://github.com/synonymdev/bitkit-e2e-tests) docker stack running.
69+
70+
- Default URL: `http://polaruser:polarpass@127.0.0.1:43782`
71+
- Override with `--rpc-url`
72+
- Automatically mines 101 blocks if the node wallet has insufficient funds
73+
74+
### Staging
75+
76+
Uses Synonym's Blocktank API for regtest operations.
77+
78+
- Default URL: `https://api.stag0.blocktank.to/blocktank/api/v2`
79+
- Override with `--blocktank-url`
80+
81+
## JSON Output
82+
83+
When used with `--output json`, the `run` command outputs structured JSON for programmatic use:
84+
85+
```json
86+
{
87+
"scenario": "first-time",
88+
"mnemonic": "word1 word2 ...",
89+
"addresses": [
90+
{"index": 0, "address": "bcrt1q...", "amountSat": 50000, "confirmed": true}
91+
],
92+
"totalSat": 50000,
93+
"utxoCount": 1,
94+
"blocksMined": 1
95+
}
96+
```
97+
98+
## Wallet Derivation
99+
100+
- BIP39 mnemonic (12 words, 128-bit entropy)
101+
- BIP84 derivation paths: `m/84'/1'/0'/0/i` (receive) and `m/84'/1'/0'/1/i` (change)
102+
- P2WPKH addresses (`bcrt1...` prefix)
103+
104+
## Architecture
105+
106+
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for design details.

tools/seedkit/cmd/list.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
"github.com/synonymdev/bitkit-e2e-tests/tools/seedkit/internal/scenario"
8+
)
9+
10+
func init() {
11+
rootCmd.AddCommand(listCmd)
12+
}
13+
14+
var listCmd = &cobra.Command{
15+
Use: "list",
16+
Short: "List available scenarios",
17+
Run: func(cmd *cobra.Command, args []string) {
18+
fmt.Println("Available scenarios:")
19+
fmt.Println()
20+
for _, s := range scenario.All {
21+
fmt.Printf(" %-15s %s\n", s.Name, s.Description)
22+
}
23+
fmt.Println()
24+
},
25+
}

tools/seedkit/cmd/preview.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
"os/exec"
6+
"runtime"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
"github.com/synonymdev/bitkit-e2e-tests/tools/seedkit/internal/electrum"
11+
"github.com/synonymdev/bitkit-e2e-tests/tools/seedkit/internal/output"
12+
"github.com/synonymdev/bitkit-e2e-tests/tools/seedkit/internal/wallet"
13+
)
14+
15+
const (
16+
defaultLocalElectrum = "127.0.0.1:60001"
17+
defaultStagingElectrum = "electrs.bitkit.stag0.blocktank.to:9999"
18+
addressGapLimit = 5
19+
maxAddresses = 30
20+
)
21+
22+
var (
23+
previewBackendFlag string
24+
previewMnemonicFlag string
25+
electrumFlag string
26+
)
27+
28+
func init() {
29+
previewCmd.Flags().StringVarP(&previewBackendFlag, "backend", "b", "local", "Backend: local or staging")
30+
previewCmd.Flags().StringVarP(&previewMnemonicFlag, "mnemonic", "m", "", "Mnemonic (reads from clipboard if omitted)")
31+
previewCmd.Flags().StringVar(&electrumFlag, "electrum", "", "Custom Electrum server (host:port)")
32+
rootCmd.AddCommand(previewCmd)
33+
}
34+
35+
var previewCmd = &cobra.Command{
36+
Use: "preview",
37+
Short: "Preview wallet state for a mnemonic",
38+
Long: "Connects to an Electrum server to show UTXOs and address history.\nReads mnemonic from clipboard by default (since 'run' copies it there).",
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
mnemonic, err := resolveMnemonic()
41+
if err != nil {
42+
return err
43+
}
44+
45+
w, err := wallet.FromMnemonic(mnemonic)
46+
if err != nil {
47+
return fmt.Errorf("invalid mnemonic: %w", err)
48+
}
49+
50+
electrumAddr, useTLS, err := resolveElectrumAddr()
51+
if err != nil {
52+
return err
53+
}
54+
55+
proto := "tcp"
56+
if useTLS {
57+
proto = "tls"
58+
}
59+
fmt.Printf("Connecting to Electrum at %s (%s)...\n", electrumAddr, proto)
60+
client, err := electrum.NewClient(electrumAddr, useTLS)
61+
if err != nil {
62+
return fmt.Errorf("electrum: %w", err)
63+
}
64+
defer client.Close()
65+
66+
fmt.Println()
67+
fmt.Println("--- seedkit preview ---")
68+
fmt.Printf("Electrum: %s\n", electrumAddr)
69+
fmt.Printf("Mnemonic: %s\n", mnemonic)
70+
fmt.Println()
71+
72+
type chainDef struct {
73+
label string
74+
derive func(uint32) (string, error)
75+
}
76+
chains := []chainDef{
77+
{"Receive (m/84'/1'/0'/0)", w.DeriveAddress},
78+
{"Change (m/84'/1'/0'/1)", w.DeriveChangeAddress},
79+
}
80+
81+
var totalSat int64
82+
var utxoCount int
83+
var activeAddrs int
84+
var usedAddrs int
85+
86+
for _, chain := range chains {
87+
fmt.Printf("%s:\n", chain.label)
88+
gap := 0
89+
found := false
90+
91+
for i := uint32(0); i < maxAddresses && gap < addressGapLimit; i++ {
92+
addr, err := chain.derive(i)
93+
if err != nil {
94+
return fmt.Errorf("derive address: %w", err)
95+
}
96+
97+
scripthash, err := electrum.AddressToScripthash(addr)
98+
if err != nil {
99+
return fmt.Errorf("scripthash for %s: %w", addr, err)
100+
}
101+
102+
utxos, err := client.ListUnspent(scripthash)
103+
if err != nil {
104+
return fmt.Errorf("listunspent: %w", err)
105+
}
106+
107+
history, err := client.GetHistory(scripthash)
108+
if err != nil {
109+
return fmt.Errorf("get_history: %w", err)
110+
}
111+
112+
if len(history) == 0 && len(utxos) == 0 {
113+
gap++
114+
continue
115+
}
116+
gap = 0
117+
found = true
118+
119+
if len(utxos) > 0 {
120+
var addrTotal int64
121+
confirmed := 0
122+
unconfirmed := 0
123+
for _, u := range utxos {
124+
addrTotal += u.Value
125+
if u.Height > 0 {
126+
confirmed++
127+
} else {
128+
unconfirmed++
129+
}
130+
}
131+
totalSat += addrTotal
132+
utxoCount += len(utxos)
133+
activeAddrs++
134+
135+
status := "confirmed"
136+
if unconfirmed > 0 && confirmed == 0 {
137+
status = "unconfirmed"
138+
} else if unconfirmed > 0 {
139+
status = "mixed"
140+
}
141+
142+
fmt.Printf(" [%d] %s %s (%d UTXO, %s)\n",
143+
i, addr, output.FormatSats(addrTotal), len(utxos), status)
144+
} else {
145+
usedAddrs++
146+
fmt.Printf(" [%d] %s (used, now empty)\n", i, addr)
147+
}
148+
}
149+
150+
if !found {
151+
fmt.Println(" (none)")
152+
}
153+
fmt.Println()
154+
}
155+
156+
fmt.Printf(" Total: %s (%d UTXOs)\n", output.FormatSats(totalSat), utxoCount)
157+
fmt.Printf(" Active: %d addresses\n", activeAddrs)
158+
if usedAddrs > 0 {
159+
fmt.Printf(" Used (empty): %d addresses\n", usedAddrs)
160+
}
161+
fmt.Println()
162+
163+
return nil
164+
},
165+
}
166+
167+
func resolveMnemonic() (string, error) {
168+
if previewMnemonicFlag != "" {
169+
return previewMnemonicFlag, nil
170+
}
171+
clip, err := readClipboard()
172+
if err != nil || strings.TrimSpace(clip) == "" {
173+
return "", fmt.Errorf("no mnemonic provided and clipboard is empty (use --mnemonic)")
174+
}
175+
mnemonic := strings.TrimSpace(clip)
176+
fmt.Printf("Read mnemonic from clipboard: %s...\n", truncateWords(mnemonic, 3))
177+
return mnemonic, nil
178+
}
179+
180+
func resolveElectrumAddr() (string, bool, error) {
181+
if electrumFlag != "" {
182+
useTLS := previewBackendFlag == "staging"
183+
return electrumFlag, useTLS, nil
184+
}
185+
switch previewBackendFlag {
186+
case "local":
187+
return defaultLocalElectrum, false, nil
188+
case "staging":
189+
return defaultStagingElectrum, true, nil
190+
default:
191+
return "", false, fmt.Errorf("unknown backend %q (use 'local' or 'staging')", previewBackendFlag)
192+
}
193+
}
194+
195+
func readClipboard() (string, error) {
196+
var cmd *exec.Cmd
197+
switch runtime.GOOS {
198+
case "darwin":
199+
cmd = exec.Command("pbpaste")
200+
case "linux":
201+
cmd = exec.Command("xclip", "-selection", "clipboard", "-o")
202+
default:
203+
return "", fmt.Errorf("clipboard not supported on %s", runtime.GOOS)
204+
}
205+
out, err := cmd.Output()
206+
if err != nil {
207+
return "", err
208+
}
209+
return string(out), nil
210+
}
211+
212+
func truncateWords(s string, n int) string {
213+
words := strings.SplitN(s, " ", n+1)
214+
if len(words) <= n {
215+
return s
216+
}
217+
return strings.Join(words[:n], " ") + " ..."
218+
}

tools/seedkit/cmd/root.go

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package cmd
2+
3+
import "github.com/spf13/cobra"
4+
5+
var rootCmd = &cobra.Command{
6+
Use: "seedkit",
7+
Short: "Generate realistic Bitcoin wallets on regtest for Bitkit",
8+
Long: `seedkit creates regtest chain state matching predefined scenarios,
9+
outputting a BIP39 mnemonic that restores cleanly in Bitkit.
10+
11+
Useful for demos, QA, marketing screenshots, and support.`,
12+
}
13+
14+
func Execute() error {
15+
return rootCmd.Execute()
16+
}

0 commit comments

Comments
 (0)