latest versions

This commit is contained in:
2026-02-12 12:26:39 -08:00
parent 82b5ed0b3c
commit 78cadee871
97 changed files with 3288 additions and 124 deletions

View File

@@ -0,0 +1,209 @@
package bitcoin
import (
"fmt"
"sort"
"time"
"btcphrasechecker/types"
)
// Analyzer implements the ChainAnalyzer interface for Bitcoin
type Analyzer struct {
addressCount int
}
// NewAnalyzer creates a new Bitcoin analyzer
func NewAnalyzer(addressCount int) *Analyzer {
return &Analyzer{
addressCount: addressCount,
}
}
// DeriveAddresses derives Bitcoin addresses from seed
func (a *Analyzer) DeriveAddresses(seed []byte, count int) ([]types.AddressInfo, error) {
return DeriveAddresses(seed, count)
}
// GetAddressInfo fetches address information
func (a *Analyzer) GetAddressInfo(address string) (balance, txCount uint64, received, sent uint64, err error) {
return GetAddressInfo(address)
}
// GetTransactions fetches transactions for an address
func (a *Analyzer) GetTransactions(address string) ([]types.Transaction, error) {
return GetTransactions(address)
}
// GetChain returns the chain type
func (a *Analyzer) GetChain() types.Chain {
return types.ChainBitcoin
}
// AnalyzeWallet performs comprehensive wallet analysis
func AnalyzeWallet(seed []byte, addressCount int, verbose bool) (*types.WalletSummary, error) {
summary := &types.WalletSummary{
ActiveAddresses: make([]types.AddressInfo, 0),
TransactionHistory: make([]types.TransactionDetail, 0),
}
// Derive all addresses
addresses, err := DeriveAddresses(seed, addressCount)
if err != nil {
return nil, err
}
if verbose {
fmt.Println("Bitcoin Wallet Analysis")
fmt.Println("======================")
fmt.Println()
}
// Check each derivation path
pathStats := make(map[string]struct {
count int
received uint64
sent uint64
})
for _, pathInfo := range StandardPaths {
if verbose {
fmt.Printf("Checking %s: %s - %s\n", pathInfo.Name, pathInfo.Path, pathInfo.Desc)
fmt.Println("-----------------------------------------------------------")
}
pathAddresses := filterByPath(addresses, pathInfo.Path)
for _, addr := range pathAddresses {
balance, txCount, received, sent, err := GetAddressInfo(addr.Address)
if err != nil {
continue
}
addr.Balance = balance
addr.TxCount = int(txCount)
addr.Received = received
addr.Sent = sent
if txCount > 0 {
if verbose {
fmt.Printf(" %s\n", addr.Address)
fmt.Printf(" Path: %s\n", addr.Path)
fmt.Printf(" Balance: %.8f BTC\n", float64(balance)/100000000.0)
fmt.Printf(" Received: %.8f BTC\n", float64(received)/100000000.0)
fmt.Printf(" Sent: %.8f BTC\n", float64(sent)/100000000.0)
fmt.Printf(" Transactions: %d\n", txCount)
}
summary.TotalBalance += balance
summary.TotalReceived += received
summary.TotalSent += sent
summary.TotalTxCount += int(txCount)
summary.ActiveAddresses = append(summary.ActiveAddresses, addr)
// Update path stats
ps := pathStats[pathInfo.Path]
ps.count++
ps.received += received
ps.sent += sent
pathStats[pathInfo.Path] = ps
}
// Rate limiting
time.Sleep(100 * time.Millisecond)
}
if verbose {
fmt.Println()
}
}
return summary, nil
}
// FetchTransactionHistory fetches and processes all transactions for active addresses
func FetchTransactionHistory(summary *types.WalletSummary) error {
var allTransactions []types.TransactionDetail
receiveTxMap := make(map[string]bool)
sendTxMap := make(map[string]bool)
for _, addr := range summary.ActiveAddresses {
if addr.TxCount == 0 {
continue
}
txs, err := GetTransactions(addr.Address)
if err != nil {
continue
}
for _, tx := range txs {
txType := "self"
amount := uint64(0)
if tx.Received > 0 && tx.Sent == 0 {
txType = "received"
amount = tx.Received
receiveTxMap[tx.TxID] = true
} else if tx.Sent > 0 && tx.Received == 0 {
txType = "sent"
amount = tx.Sent
sendTxMap[tx.TxID] = true
} else if tx.Received > 0 && tx.Sent > 0 {
txType = "self"
amount = tx.Received
}
detail := types.TransactionDetail{
TxID: tx.TxID,
Time: tx.Time,
BlockHeight: tx.BlockHeight,
Confirmed: tx.Confirmed,
Type: txType,
Amount: amount,
Address: addr.Address,
}
allTransactions = append(allTransactions, detail)
}
// Rate limiting
time.Sleep(100 * time.Millisecond)
}
// Sort by time (oldest first)
sort.Slice(allTransactions, func(i, j int) bool {
return allTransactions[i].Time.Before(allTransactions[j].Time)
})
// Calculate running balance
balance := uint64(0)
for i := range allTransactions {
tx := &allTransactions[i]
if tx.Type == "received" {
balance += tx.Amount
} else if tx.Type == "sent" {
if balance >= tx.Amount {
balance -= tx.Amount
}
}
tx.Balance = balance
}
summary.TransactionHistory = allTransactions
summary.ReceiveTxCount = len(receiveTxMap)
summary.SendTxCount = len(sendTxMap)
return nil
}
// filterByPath filters addresses by derivation path prefix
func filterByPath(addresses []types.AddressInfo, pathPrefix string) []types.AddressInfo {
var filtered []types.AddressInfo
for _, addr := range addresses {
if len(addr.Path) >= len(pathPrefix) && addr.Path[:len(pathPrefix)] == pathPrefix {
filtered = append(filtered, addr)
}
}
return filtered
}

View File

@@ -0,0 +1,161 @@
package bitcoin
import (
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"btcphrasechecker/types"
)
const (
// API base URL (using blockstream.info)
apiBaseURL = "https://blockstream.info/api"
)
// BlockstreamTransaction represents a transaction from Blockstream API
type BlockstreamTransaction struct {
Txid string `json:"txid"`
Status struct {
Confirmed bool `json:"confirmed"`
BlockHeight int `json:"block_height"`
BlockTime int64 `json:"block_time"`
} `json:"status"`
Vin []struct {
Txid string `json:"txid"`
Vout int `json:"vout"`
Prevout struct {
Scriptpubkey string `json:"scriptpubkey"`
ScriptpubkeyAddress string `json:"scriptpubkey_address"`
Value uint64 `json:"value"`
} `json:"prevout"`
} `json:"vin"`
Vout []struct {
Scriptpubkey string `json:"scriptpubkey"`
ScriptpubkeyAddress string `json:"scriptpubkey_address"`
Value uint64 `json:"value"`
} `json:"vout"`
Fee uint64 `json:"fee"`
}
// GetAddressInfo fetches address information from Blockstream API
func GetAddressInfo(address string) (balance, txCount uint64, received, sent uint64, err error) {
url := fmt.Sprintf("%s/address/%s", apiBaseURL, address)
resp, err := http.Get(url)
if err != nil {
return 0, 0, 0, 0, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return 0, 0, 0, 0, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, 0, 0, err
}
var addrData struct {
ChainStats struct {
FundedTxoSum uint64 `json:"funded_txo_sum"`
SpentTxoSum uint64 `json:"spent_txo_sum"`
TxCount int `json:"tx_count"`
} `json:"chain_stats"`
}
if err := json.Unmarshal(body, &addrData); err != nil {
return 0, 0, 0, 0, err
}
balance = addrData.ChainStats.FundedTxoSum - addrData.ChainStats.SpentTxoSum
txCount = uint64(addrData.ChainStats.TxCount)
received = addrData.ChainStats.FundedTxoSum
sent = addrData.ChainStats.SpentTxoSum
return balance, txCount, received, sent, nil
}
// GetTransactions fetches all transactions for an address
func GetTransactions(address string) ([]types.Transaction, error) {
url := fmt.Sprintf("%s/address/%s/txs", apiBaseURL, address)
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var bsTxs []BlockstreamTransaction
if err := json.Unmarshal(body, &bsTxs); err != nil {
return nil, err
}
// Convert to common transaction format
var transactions []types.Transaction
for _, bsTx := range bsTxs {
tx := convertTransaction(bsTx, address)
transactions = append(transactions, tx)
}
return transactions, nil
}
// convertTransaction converts Blockstream transaction to common format
func convertTransaction(bsTx BlockstreamTransaction, forAddress string) types.Transaction {
tx := types.Transaction{
TxID: bsTx.Txid,
BlockHeight: bsTx.Status.BlockHeight,
Confirmed: bsTx.Status.Confirmed,
Fee: bsTx.Fee,
}
if bsTx.Status.BlockTime > 0 {
tx.Time = time.Unix(bsTx.Status.BlockTime, 0)
}
// Calculate received and sent amounts for this address
var received, sent uint64
addressSet := make(map[string]bool)
// Check inputs (sent from address)
for _, vin := range bsTx.Vin {
if vin.Prevout.ScriptpubkeyAddress == forAddress {
sent += vin.Prevout.Value
}
addressSet[vin.Prevout.ScriptpubkeyAddress] = true
}
// Check outputs (received by address)
for _, vout := range bsTx.Vout {
if vout.ScriptpubkeyAddress == forAddress {
received += vout.Value
}
addressSet[vout.ScriptpubkeyAddress] = true
}
tx.Received = received
tx.Sent = sent
tx.NetChange = int64(received) - int64(sent)
// Collect all unique addresses
for addr := range addressSet {
if addr != "" && addr != forAddress {
tx.Addresses = append(tx.Addresses, addr)
}
}
return tx
}

View File

@@ -0,0 +1,72 @@
package bitcoin
import (
"testing"
)
func TestGetAddressInfo(t *testing.T) {
// Test with a known address from the test mnemonic
testAddress := "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA"
balance, txCount, received, sent, err := GetAddressInfo(testAddress)
if err != nil {
t.Fatalf("GetAddressInfo failed: %v", err)
}
// This address has been used in tests, so it should have transactions
if txCount == 0 {
t.Log("Warning: Test address has no transactions. This might be expected if blockchain state changed.")
}
// Balance should equal received - sent
expectedBalance := received - sent
if balance != expectedBalance {
t.Errorf("Balance mismatch: balance=%d, received=%d, sent=%d, expected=%d",
balance, received, sent, expectedBalance)
}
t.Logf("Address: %s", testAddress)
t.Logf("Balance: %d satoshis (%.8f BTC)", balance, float64(balance)/100000000.0)
t.Logf("Received: %d satoshis", received)
t.Logf("Sent: %d satoshis", sent)
t.Logf("Transactions: %d", txCount)
}
func TestGetTransactions(t *testing.T) {
// Test with a known address from the test mnemonic
testAddress := "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu"
txs, err := GetTransactions(testAddress)
if err != nil {
t.Fatalf("GetTransactions failed: %v", err)
}
t.Logf("Found %d transactions for address %s", len(txs), testAddress)
// Verify transaction structure
for i, tx := range txs {
if tx.TxID == "" {
t.Errorf("Transaction %d has empty TxID", i)
}
if tx.Time.IsZero() && tx.Confirmed {
t.Errorf("Transaction %d is confirmed but has zero time", i)
}
// At least one of Received or Sent should be non-zero
if tx.Received == 0 && tx.Sent == 0 {
t.Logf("Warning: Transaction %d has zero received and sent amounts", i)
}
t.Logf(" Tx %d: %s, Received: %d, Sent: %d, NetChange: %d, Time: %s",
i, tx.TxID[:16], tx.Received, tx.Sent, tx.NetChange, tx.Time.Format("2006-01-02"))
}
}
func TestGetAddressInfoInvalidAddress(t *testing.T) {
// Test with an invalid address
_, _, _, _, err := GetAddressInfo("invalid_address")
if err == nil {
t.Error("Expected error for invalid address, got nil")
}
}

View File

@@ -0,0 +1,148 @@
package bitcoin
import (
"fmt"
"btcphrasechecker/types"
"github.com/btcsuite/btcd/btcutil"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/btcsuite/btcd/txscript"
)
// DerivationPath represents a BIP derivation path configuration
type DerivationPath struct {
Name string
Path string
Purpose uint32
Desc string
}
// Standard Bitcoin derivation paths
var StandardPaths = []DerivationPath{
{
Name: "BIP44 (Legacy)",
Path: "m/44'/0'/0'/0",
Purpose: 44,
Desc: "P2PKH addresses (1...)",
},
{
Name: "BIP49 (SegWit)",
Path: "m/49'/0'/0'/0",
Purpose: 49,
Desc: "P2SH-wrapped SegWit (3...)",
},
{
Name: "BIP84 (Native SegWit)",
Path: "m/84'/0'/0'/0",
Purpose: 84,
Desc: "Bech32 addresses (bc1...)",
},
}
// DeriveAddresses derives Bitcoin addresses from a seed for all standard paths
func DeriveAddresses(seed []byte, addressCount int) ([]types.AddressInfo, error) {
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
if err != nil {
return nil, fmt.Errorf("failed to create master key: %w", err)
}
var allAddresses []types.AddressInfo
for _, pathInfo := range StandardPaths {
addresses, err := derivePathAddresses(masterKey, pathInfo, addressCount)
if err != nil {
return nil, err
}
allAddresses = append(allAddresses, addresses...)
}
return allAddresses, nil
}
// derivePathAddresses derives addresses for a specific derivation path
func derivePathAddresses(masterKey *hdkeychain.ExtendedKey, pathInfo DerivationPath, count int) ([]types.AddressInfo, error) {
var addresses []types.AddressInfo
// Derive base path: m/purpose'/coin_type'/account'/change
// For Bitcoin: coin_type = 0, account = 0, change = 0 (external addresses)
key := masterKey
key, _ = key.Derive(hdkeychain.HardenedKeyStart + pathInfo.Purpose)
key, _ = key.Derive(hdkeychain.HardenedKeyStart + 0) // coin_type = 0 for Bitcoin
key, _ = key.Derive(hdkeychain.HardenedKeyStart + 0) // account = 0
key, _ = key.Derive(0) // change = 0 (external)
// Derive individual addresses
for i := uint32(0); i < uint32(count); i++ {
childKey, err := key.Derive(i)
if err != nil {
continue
}
pubKey, err := childKey.ECPubKey()
if err != nil {
continue
}
pubKeyBytes := pubKey.SerializeCompressed()
address, err := deriveAddress(pubKeyBytes, pathInfo.Purpose)
if err != nil {
continue
}
addresses = append(addresses, types.AddressInfo{
Address: address,
Path: fmt.Sprintf("%s/%d", pathInfo.Path, i),
Chain: types.ChainBitcoin,
})
}
return addresses, nil
}
// deriveAddress creates an address from a public key based on the purpose
func deriveAddress(pubKeyBytes []byte, purpose uint32) (string, error) {
switch purpose {
case 44:
// Legacy P2PKH
addr, err := btcutil.NewAddressPubKey(pubKeyBytes, &chaincfg.MainNetParams)
if err != nil {
return "", err
}
return addr.EncodeAddress(), nil
case 49:
// P2SH-wrapped SegWit (BIP49)
// Create witness program for P2WPKH
pubKeyHash := btcutil.Hash160(pubKeyBytes)
witnessProgram, err := txscript.NewScriptBuilder().
AddOp(txscript.OP_0).
AddData(pubKeyHash).
Script()
if err != nil {
return "", err
}
// Wrap witness program in P2SH
scriptAddr, err := btcutil.NewAddressScriptHash(witnessProgram, &chaincfg.MainNetParams)
if err != nil {
return "", err
}
return scriptAddr.EncodeAddress(), nil
case 84:
// Native SegWit (bech32)
addr, err := btcutil.NewAddressWitnessPubKeyHash(
btcutil.Hash160(pubKeyBytes),
&chaincfg.MainNetParams,
)
if err != nil {
return "", err
}
return addr.EncodeAddress(), nil
default:
return "", fmt.Errorf("unsupported purpose: %d", purpose)
}
}

View File

@@ -0,0 +1,122 @@
package bitcoin
import (
"testing"
"github.com/btcsuite/btcd/btcutil/hdkeychain"
"github.com/btcsuite/btcd/chaincfg"
"github.com/tyler-smith/go-bip39"
)
const (
testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
)
func TestDeriveAddresses(t *testing.T) {
seed := bip39.NewSeed(testMnemonic, "")
addresses, err := DeriveAddresses(seed, 5)
if err != nil {
t.Fatalf("DeriveAddresses failed: %v", err)
}
// We expect 5 addresses per path * 3 paths = 15 addresses
expectedCount := 5 * len(StandardPaths)
if len(addresses) != expectedCount {
t.Errorf("Expected %d addresses, got %d", expectedCount, len(addresses))
}
// Test known addresses for the test mnemonic
expectedAddresses := map[string]string{
"m/44'/0'/0'/0/0": "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA",
"m/84'/0'/0'/0/0": "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
}
addressMap := make(map[string]string)
for _, addr := range addresses {
addressMap[addr.Path] = addr.Address
}
for path, expectedAddr := range expectedAddresses {
if actualAddr, exists := addressMap[path]; !exists {
t.Errorf("Address for path %s not found", path)
} else if actualAddr != expectedAddr {
t.Errorf("Address mismatch for path %s: expected %s, got %s", path, expectedAddr, actualAddr)
}
}
}
func TestDerivePathAddresses(t *testing.T) {
seed := bip39.NewSeed(testMnemonic, "")
masterKey, err := hdkeychain.NewMaster(seed, &chaincfg.MainNetParams)
if err != nil {
t.Fatalf("Failed to create master key: %v", err)
}
tests := []struct {
path DerivationPath
addressIndex int
expectedAddress string
}{
{
path: StandardPaths[0], // BIP44
addressIndex: 0,
expectedAddress: "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA",
},
{
path: StandardPaths[1], // BIP49
addressIndex: 0,
expectedAddress: "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf",
},
{
path: StandardPaths[2], // BIP84
addressIndex: 0,
expectedAddress: "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu",
},
}
for _, tt := range tests {
t.Run(tt.path.Name, func(t *testing.T) {
addresses, err := derivePathAddresses(masterKey, tt.path, 5)
if err != nil {
t.Fatalf("derivePathAddresses failed: %v", err)
}
if len(addresses) != 5 {
t.Errorf("Expected 5 addresses, got %d", len(addresses))
}
if addresses[tt.addressIndex].Address != tt.expectedAddress {
t.Errorf("Address mismatch: expected %s, got %s",
tt.expectedAddress, addresses[tt.addressIndex].Address)
}
})
}
}
func TestDeriveAddressesWithPassphrase(t *testing.T) {
seed := bip39.NewSeed(testMnemonic, "TREZOR")
addresses, err := DeriveAddresses(seed, 1)
if err != nil {
t.Fatalf("DeriveAddresses failed: %v", err)
}
// With passphrase, addresses should be different
if len(addresses) == 0 {
t.Error("Expected addresses to be generated")
}
// The first BIP44 address with "TREZOR" passphrase should be different
addressMap := make(map[string]string)
for _, addr := range addresses {
addressMap[addr.Path] = addr.Address
}
// Should NOT match the address without passphrase
if addr, exists := addressMap["m/44'/0'/0'/0/0"]; exists {
if addr == "1LqBGSKuX5yYUonjxT5qGfpUsXKYYWeabA" {
t.Error("Address should be different with passphrase")
}
}
}