Skip to content

Commit

Permalink
feature: bulk fund crypto wallets (#159)
Browse files Browse the repository at this point in the history
* feature: bulk fund crypto wallets

* gofmt

* fix: errant print statements

* fix: add gen docs

* fix: unnecessary pause

* fix: pr feedback

* fix: lint issues

* fix verbosity flag and default gas value

* fix docs

* fix: auto detect chain id

---------

Co-authored-by: dan moore <dmoore@Polygon-R726LQF020.local>
  • Loading branch information
rebelArtists and dan moore committed Nov 20, 2023
1 parent 96089ed commit 48044b8
Show file tree
Hide file tree
Showing 8 changed files with 432 additions and 0 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ Note: Do not modify this section! It is auto-generated by `cobra` using `make ge

- [polycli fork](doc/polycli_fork.md) - Take a forked block and walk up the chain to do analysis.

- [polycli fund](doc/polycli_fund.md) - Bulk fund many crypto wallets automatically.

- [polycli hash](doc/polycli_hash.md) - Provide common crypto hashing functions.

- [polycli loadtest](doc/polycli_loadtest.md) - Run a generic load test against an Eth/EVM style JSON-RPC endpoint.
Expand Down
317 changes: 317 additions & 0 deletions cmd/fund/fund.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,317 @@
package fund

import (
"crypto/ecdsa"
"encoding/hex"
"encoding/json"
"fmt"
"io/ioutil"
"math/big"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"

_ "embed"

"github.com/chenzhijie/go-web3"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)

var (
//go:embed usage.md
usage string
walletCount int
fundingWalletPK string
fundingWalletPublicKey *ecdsa.PublicKey
chainRPC string
concurrencyLevel int
walletFundingAmt float64
walletFundingGas uint64
nonceMutex sync.Mutex
globalNonce uint64
nonceInitialized bool
outputFileFlag string
)

// Wallet struct to hold public key, private key, and address
type Wallet struct {
PublicKey *ecdsa.PublicKey
PrivateKey *ecdsa.PrivateKey
Address common.Address
}

func getChainIDFromNode(chainRPC string) (int64, error) {
// Create an HTTP client
client := &http.Client{}

// Prepare the JSON-RPC request payload
payload := `{"jsonrpc":"2.0","method":"eth_chainId","params":[],"id":1}`

// Create the HTTP request
req, err := http.NewRequest("POST", chainRPC, strings.NewReader(payload))
if err != nil {
return 0, err
}

// Set the required headers
req.Header.Set("Content-Type", "application/json")

// Send the request
resp, err := client.Do(req)
if err != nil {
return 0, err
}
defer resp.Body.Close()

// Read the response body
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return 0, err
}

// Parse the JSON response
var result map[string]interface{}
if err := json.Unmarshal(body, &result); err != nil {

Check failure on line 81 in cmd/fund/fund.go

View workflow job for this annotation

GitHub Actions / Lint

declaration of "err" shadows declaration at line 58

Check failure on line 81 in cmd/fund/fund.go

View workflow job for this annotation

GitHub Actions / Lint

declaration of "err" shadows declaration at line 58
return 0, err
}

// Extract the chain ID from the response
chainIDHex, ok := result["result"].(string)
if !ok {
return 0, fmt.Errorf("unable to extract chain ID from response")
}

// Convert the chain ID from hex to int64
int64ChainID, err := strconv.ParseInt(chainIDHex, 0, 64)
if err != nil {
return 0, err
}

return int64ChainID, nil
}

func generateNonce(web3Client *web3.Web3) (uint64, error) {
nonceMutex.Lock()
defer nonceMutex.Unlock()

if nonceInitialized {
globalNonce++
} else {
// Derive the public key from the funding wallet's private key
fundingWalletECDSA, ecdsaErr := crypto.HexToECDSA(fundingWalletPK)
if ecdsaErr != nil {
log.Error().Err(ecdsaErr).Msg("Error getting ECDSA from funding wallet private key")
return 0, ecdsaErr
}

fundingWalletPublicKey = &fundingWalletECDSA.PublicKey
// Convert ecdsa.PublicKey to common.Address
fundingAddress := crypto.PubkeyToAddress(*fundingWalletPublicKey)

nonce, err := web3Client.Eth.GetNonce(fundingAddress, nil)
if err != nil {
log.Error().Err(err).Msg("Error getting nonce")
return 0, err
}
globalNonce = nonce
nonceInitialized = true
}

return globalNonce, nil
}

func generateWallets(numWallets int) ([]Wallet, error) {
wallets := make([]Wallet, numWallets)

for i := 0; i < numWallets; i++ {
account, err := crypto.GenerateKey()
if err != nil {
log.Error().Err(err).Msg("Error generating key")
return nil, err
}

addr := crypto.PubkeyToAddress(account.PublicKey)
wallet := Wallet{
PublicKey: &account.PublicKey,
PrivateKey: account,
Address: addr,
}

wallets[i] = wallet
}
return wallets, nil
}

func fundWallets(web3Client *web3.Web3, wallets []Wallet, amountWei *big.Int, walletFundingGas uint64, concurrency int) error {
// Create a channel to control concurrency
walletChan := make(chan Wallet, len(wallets))
for _, wallet := range wallets {
walletChan <- wallet
}
close(walletChan)

// Wait group to ensure all goroutines finish before returning
var wg sync.WaitGroup
wg.Add(concurrency)

// Function to fund wallets
fundWallet := func() {
defer wg.Done()
for wallet := range walletChan {
nonce, err := generateNonce(web3Client)
if err != nil {
log.Error().Err(err).Msg("Error getting nonce")
return
}

// Fund the wallet using the obtained nonce
_, err = web3Client.Eth.SyncSendRawTransaction(
wallet.Address,
amountWei,
nonce,
walletFundingGas,
web3Client.Utils.ToGWei(1),
nil,
)
if err != nil {
log.Error().Err(err).Str("wallet", wallet.Address.Hex()).Msg("Error funding wallet")
return
}

log.Info().Str("wallet", wallet.Address.Hex()).Msgf("Funded with %s wei", amountWei.String())
}
}

// Start funding the wallets concurrently
for i := 0; i < concurrency; i++ {
go fundWallet()
}

// Wait for all goroutines to finish
wg.Wait()
return nil
}

// fundCmd represents the fund command
var FundCmd = &cobra.Command{
Use: "fund",
Short: "Bulk fund many crypto wallets automatically.",
Long: usage,
Run: func(cmd *cobra.Command, args []string) {
if err := runFunding(cmd); err != nil {
log.Error().Err(err).Msg("Error funding wallets")
}
},
}

func runFunding(cmd *cobra.Command) error {
// Capture the start time
startTime := time.Now()

// Remove '0x' prefix from fundingWalletPK if present
fundingWalletPK = strings.TrimPrefix(fundingWalletPK, "0x")

// setup new web3 session with remote rpc node
web3Client, clientErr := web3.NewWeb3(chainRPC)
if clientErr != nil {
cmd.PrintErrf("There was an error creating web3 client: %s", clientErr.Error())
return clientErr
}

// add pk to session for sending signed transactions
if setAcctErr := web3Client.Eth.SetAccount(fundingWalletPK); setAcctErr != nil {
cmd.PrintErrf("There was an error setting account with pk: %s", setAcctErr.Error())
return setAcctErr
}

// Query the chain ID from the rpc node
chainID, chainIDErr := getChainIDFromNode(chainRPC)
if chainIDErr != nil {
log.Error().Err(chainIDErr).Msg("Error getting chain ID")
return chainIDErr
}

// Set proper chainId for corresponding chainRPC
web3Client.Eth.SetChainId(chainID)

// generate set of new wallet objects
wallets, genWalletErr := generateWallets(walletCount)
if genWalletErr != nil {
cmd.PrintErrf("There was an error generating wallet objects: %s", genWalletErr.Error())
return genWalletErr
}

// fund all crypto wallets
log.Info().Msg("Starting to fund loadtest wallets...")
fundWalletErr := fundWallets(web3Client, wallets, big.NewInt(int64(walletFundingAmt*1e18)), uint64(walletFundingGas), concurrencyLevel)
if fundWalletErr != nil {
log.Error().Err(fundWalletErr).Msg("Error funding wallets")
return fundWalletErr
}

// Save wallet details to a file
outputFile := outputFileFlag // You can modify the file format or name as needed

type WalletDetails struct {
Address string `json:"Address"`
PrivateKey string `json:"PrivateKey"`
}

walletDetails := make([]WalletDetails, len(wallets))
for i, w := range wallets {
privateKey := hex.EncodeToString(w.PrivateKey.D.Bytes()) // Convert private key to hex
walletDetails[i] = WalletDetails{
Address: w.Address.Hex(),
PrivateKey: privateKey,
}
}

// Convert walletDetails to JSON
walletsJSON, jsonErr := json.MarshalIndent(walletDetails, "", " ")
if jsonErr != nil {
log.Error().Err(jsonErr).Msg("Error converting wallet details to JSON")
return jsonErr
}

// Write JSON data to a file
file, createErr := os.Create(outputFile)
if createErr != nil {
log.Error().Err(createErr).Msg("Error creating file")
return createErr
}
defer file.Close()

_, writeErr := file.Write(walletsJSON)
if writeErr != nil {
log.Error().Err(writeErr).Msg("Error writing wallet details to file")
return writeErr
}

log.Info().Msgf("Wallet details have been saved to %s", outputFile)

// Calculate the duration
duration := time.Since(startTime)
log.Info().Msgf("Total execution time: %s", duration)

return nil
}

func init() {
// Configure zerolog to output to os.Stdout
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout})

FundCmd.Flags().IntVar(&walletCount, "wallet-count", 2, "Number of wallets to fund")
FundCmd.Flags().StringVar(&fundingWalletPK, "funding-wallet-pk", "", "Corresponding private key for funding wallet address, ensure you remove leading 0x")
FundCmd.Flags().StringVar(&chainRPC, "rpc-url", "http://localhost:8545", "The RPC endpoint url")
FundCmd.Flags().IntVar(&concurrencyLevel, "concurrency", 2, "Concurrency level for speeding up funding wallets")
FundCmd.Flags().Float64Var(&walletFundingAmt, "wallet-funding-amt", 0.05, "Amount to fund each wallet with")
FundCmd.Flags().Uint64Var(&walletFundingGas, "wallet-funding-gas", 100000, "Gas for each wallet funding transaction")
FundCmd.Flags().StringVar(&outputFileFlag, "output-file", "funded_wallets.json", "Specify the output JSON file name")
}
10 changes: 10 additions & 0 deletions cmd/fund/usage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
```bash
$ polycli fund \
--rpc-url="https://rootchain-devnetsub.zkevmdev.net" \
--funding-wallet-pk="REPLACE" \
--wallet-count=5 \
--wallet-funding-amt=0.00015 \
--wallet-funding-gas=50000 \
--concurrency=5 \
--output-file="/opt/funded_wallets.json"
```
2 changes: 2 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import (
"github.com/maticnetwork/polygon-cli/cmd/abi"
"github.com/maticnetwork/polygon-cli/cmd/dumpblocks"
"github.com/maticnetwork/polygon-cli/cmd/enr"
"github.com/maticnetwork/polygon-cli/cmd/fund"
"github.com/maticnetwork/polygon-cli/cmd/hash"
"github.com/maticnetwork/polygon-cli/cmd/loadtest"
"github.com/maticnetwork/polygon-cli/cmd/metricsToDash"
Expand Down Expand Up @@ -106,6 +107,7 @@ func NewPolycliCommand() *cobra.Command {
abi.ABICmd,
dumpblocks.DumpblocksCmd,
fork.ForkCmd,
fund.FundCmd,
hash.HashCmd,
enr.ENRCmd,
dbbench.DBBenchCmd,
Expand Down
2 changes: 2 additions & 0 deletions doc/polycli.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ Polycli is a collection of tools that are meant to be useful while building, tes

- [polycli fork](polycli_fork.md) - Take a forked block and walk up the chain to do analysis.

- [polycli fund](polycli_fund.md) - Bulk fund many crypto wallets automatically.

- [polycli hash](polycli_hash.md) - Provide common crypto hashing functions.

- [polycli loadtest](polycli_loadtest.md) - Run a generic load test against an Eth/EVM style JSON-RPC endpoint.
Expand Down

0 comments on commit 48044b8

Please sign in to comment.