Skip to content

Commit 1d8aaa7

Browse files
authored
Merge pull request #736 from 0xPolygon/jihwan/bulk-fund-erc20
feat: polycli fund bulk mint + approve erc20 tokens
2 parents 163f30e + 0be19eb commit 1d8aaa7

File tree

3 files changed

+156
-21
lines changed

3 files changed

+156
-21
lines changed

cmd/fund/cmd.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ type cmdFundParams struct {
3535
Seed string
3636

3737
FunderAddress string
38+
39+
// ERC20 specific parameters
40+
TokenAddress string
41+
TokenAmount *big.Int
42+
ERC20BulkMinterAddress string
43+
ApproveSpender string
44+
ApproveAmount *big.Int
3845
}
3946

4047
var (
@@ -80,6 +87,17 @@ func init() {
8087

8188
f.StringVarP(&params.OutputFile, "file", "f", "wallets.json", "output JSON file path for storing addresses and private keys of funded wallets")
8289

90+
// ERC20 parameters
91+
f.StringVar(&params.TokenAddress, "token-address", "", "address of the ERC20 token contract to mint and fund (if provided, enables ERC20 mode)")
92+
params.TokenAmount = new(big.Int)
93+
params.TokenAmount.SetString("1000000000000000000", 10) // 1 token
94+
f.Var(&flag_loader.BigIntValue{Val: params.TokenAmount}, "token-amount", "amount of ERC20 tokens to mint and transfer to each wallet")
95+
f.StringVar(&params.ERC20BulkMinterAddress, "erc20-bulk-funder-address", "", "address of pre-deployed ERC20BulkFunder contract")
96+
f.StringVar(&params.ApproveSpender, "approve-spender", "", "address to approve for spending tokens from each funded wallet")
97+
params.ApproveAmount = new(big.Int)
98+
params.ApproveAmount.SetString("1000000000000000000000", 10) // 1000 tokens default
99+
f.Var(&flag_loader.BigIntValue{Val: params.ApproveAmount}, "approve-amount", "amount of ERC20 tokens to approve for the spender")
100+
83101
// Marking flags as mutually exclusive
84102
FundCmd.MarkFlagsMutuallyExclusive("addresses", "number")
85103
FundCmd.MarkFlagsMutuallyExclusive("addresses", "hd-derivation")
@@ -148,5 +166,20 @@ func checkFlags() error {
148166
return errors.New("the output file is not specified")
149167
}
150168

169+
// ERC20 specific validations
170+
if params.TokenAddress != "" {
171+
// ERC20 mode - validate token parameters
172+
if params.TokenAmount == nil || params.TokenAmount.Cmp(big.NewInt(0)) <= 0 {
173+
return errors.New("token amount must be greater than 0 when using ERC20 mode")
174+
}
175+
// Validate approve parameters if provided
176+
if params.ApproveSpender != "" {
177+
if params.ApproveAmount == nil || params.ApproveAmount.Cmp(big.NewInt(0)) <= 0 {
178+
return errors.New("approve amount must be greater than 0 when approve spender is specified")
179+
}
180+
}
181+
// In ERC20 mode, ETH funding is still supported alongside token minting
182+
}
183+
151184
return nil
152185
}

cmd/fund/fund.go

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/0xPolygon/polygon-cli/bindings/funder"
1717
"github.com/0xPolygon/polygon-cli/hdwallet"
1818
"github.com/0xPolygon/polygon-cli/util"
19+
"github.com/ethereum/go-ethereum/accounts/abi"
1920
"github.com/ethereum/go-ethereum/accounts/abi/bind"
2021
"github.com/ethereum/go-ethereum/common"
2122
"github.com/ethereum/go-ethereum/crypto"
@@ -77,18 +78,27 @@ func runFunding(ctx context.Context) error {
7778
}()
7879
}
7980

80-
// Deploy or instantiate the Funder contract.
81-
var contract *funder.Funder
82-
contract, err = deployOrInstantiateFunderContract(ctx, c, tops, privateKey, len(addresses))
83-
if err != nil {
84-
return err
85-
}
81+
// If ERC20 mode is enabled, fund with tokens instead of ETH
82+
if params.TokenAddress != "" {
83+
log.Info().Str("tokenAddress", params.TokenAddress).Msg("Starting ERC20 token funding (ETH funding disabled)")
84+
if err = fundWalletsWithERC20(ctx, c, tops, privateKey, addresses, privateKeys); err != nil {
85+
return err
86+
}
87+
log.Info().Msg("Wallet(s) funded with ERC20 tokens! 🪙")
88+
} else {
89+
// Deploy or instantiate the Funder contract.
90+
var contract *funder.Funder
91+
contract, err = deployOrInstantiateFunderContract(ctx, c, tops, privateKey, len(addresses))
92+
if err != nil {
93+
return err
94+
}
8695

87-
// Fund wallets.
88-
if err = fundWallets(ctx, c, tops, contract, addresses); err != nil {
89-
return err
96+
// Fund wallets with ETH.
97+
if err = fundWallets(ctx, c, tops, contract, addresses); err != nil {
98+
return err
99+
}
100+
log.Info().Msg("Wallet(s) funded with ETH! 💸")
90101
}
91-
log.Info().Msg("Wallet(s) funded! 💸")
92102

93103
log.Info().Msgf("Total execution time: %s", time.Since(startTime))
94104
return nil
@@ -367,3 +377,90 @@ func getAddressesAndKeysFromSeed(seed string, numWallets int) ([]common.Address,
367377
log.Info().Int("count", numWallets).Msg("Wallet(s) generated from seed")
368378
return addresses, privateKeys, nil
369379
}
380+
381+
// fundWalletsWithERC20 funds multiple wallets with ERC20 tokens by minting directly to each wallet and optionally approving a spender.
382+
func fundWalletsWithERC20(ctx context.Context, c *ethclient.Client, tops *bind.TransactOpts, privateKey *ecdsa.PrivateKey, wallets []common.Address, walletsPrivateKeys []*ecdsa.PrivateKey) error {
383+
if len(wallets) == 0 {
384+
return errors.New("no wallet to fund with ERC20 tokens")
385+
}
386+
387+
// Get the token contract instance
388+
tokenAddress := common.HexToAddress(params.TokenAddress)
389+
390+
log.Info().Int("wallets", len(wallets)).Str("amountPerWallet", params.TokenAmount.String()).Msg("Minting tokens directly to each wallet")
391+
392+
// Create ABI for mint(address, uint256) function since the generated binding has wrong signature
393+
mintABI, err := abi.JSON(strings.NewReader(`[{"type":"function","name":"mint","inputs":[{"name":"to","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[],"stateMutability":"nonpayable"}]`))
394+
if err != nil {
395+
log.Error().Err(err).Msg("Unable to parse mint ABI")
396+
return err
397+
}
398+
399+
// Create bound contract with the correct ABI
400+
mintContract := bind.NewBoundContract(tokenAddress, mintABI, c, c, c)
401+
402+
// Mint tokens directly to each wallet
403+
for i, wallet := range wallets {
404+
log.Debug().Int("wallet", i+1).Int("total", len(wallets)).Str("address", wallet.String()).Str("amount", params.TokenAmount.String()).Msg("Minting tokens directly to wallet")
405+
406+
// Call mint(address, uint256) function directly
407+
_, err = mintContract.Transact(tops, "mint", wallet, params.TokenAmount)
408+
if err != nil {
409+
log.Error().Err(err).Str("wallet", wallet.String()).Msg("Unable to mint ERC20 tokens directly to wallet")
410+
return err
411+
}
412+
}
413+
414+
log.Info().Int("count", len(wallets)).Str("amount", params.TokenAmount.String()).Msg("Successfully minted tokens to all wallets")
415+
416+
// If approve spender is specified, approve tokens from each wallet
417+
if params.ApproveSpender != "" && len(walletsPrivateKeys) > 0 {
418+
spenderAddress := common.HexToAddress(params.ApproveSpender)
419+
log.Info().Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Starting bulk approve for all wallets")
420+
421+
// Create ABI for approve(address, uint256) function
422+
approveABI, err := abi.JSON(strings.NewReader(`[{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable"}]`))
423+
if err != nil {
424+
log.Error().Err(err).Msg("Unable to parse approve ABI")
425+
return err
426+
}
427+
428+
// Get chain ID for signing transactions
429+
chainID, err := c.ChainID(ctx)
430+
if err != nil {
431+
log.Error().Err(err).Msg("Unable to get chain ID for approve transactions")
432+
return err
433+
}
434+
435+
// Approve from each wallet
436+
for i, walletPrivateKey := range walletsPrivateKeys {
437+
if i >= len(wallets) {
438+
break // Safety check
439+
}
440+
441+
wallet := wallets[i]
442+
log.Debug().Int("wallet", i+1).Int("total", len(wallets)).Str("address", wallet.String()).Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Approving spender from wallet")
443+
444+
// Create transaction options for this wallet
445+
walletTops, err := bind.NewKeyedTransactorWithChainID(walletPrivateKey, chainID)
446+
if err != nil {
447+
log.Error().Err(err).Str("wallet", wallet.String()).Msg("Unable to create transaction signer for wallet")
448+
return err
449+
}
450+
451+
// Create bound contract for approve call
452+
approveContract := bind.NewBoundContract(tokenAddress, approveABI, c, c, c)
453+
454+
// Call approve(address, uint256) function from this wallet
455+
_, err = approveContract.Transact(walletTops, "approve", spenderAddress, params.ApproveAmount)
456+
if err != nil {
457+
log.Error().Err(err).Str("wallet", wallet.String()).Str("spender", spenderAddress.String()).Msg("Unable to approve spender from wallet")
458+
return err
459+
}
460+
}
461+
462+
log.Info().Int("count", len(wallets)).Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Successfully approved spender for all wallets")
463+
}
464+
465+
return nil
466+
}

doc/polycli_fund.md

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -84,17 +84,22 @@ $ cast balance 0x5D8121cf716B70d3e345adB58157752304eED5C3
8484
## Flags
8585
8686
```bash
87-
--addresses strings comma-separated list of wallet addresses to fund
88-
--contract-address string address of pre-deployed Funder contract
89-
--eth-amount big.Int amount of wei to send to each wallet (default 50000000000000000)
90-
-f, --file string output JSON file path for storing addresses and private keys of funded wallets (default "wallets.json")
91-
--hd-derivation derive wallets to fund from private key in deterministic way (default true)
92-
-h, --help help for fund
93-
--key-file string file containing accounts private keys, one per line
94-
-n, --number uint number of wallets to fund (default 10)
95-
--private-key string hex encoded private key to use for sending transactions (default "0x42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa")
96-
-r, --rpc-url string RPC endpoint URL (default "http://localhost:8545")
97-
--seed string seed string for deterministic wallet generation (e.g., 'ephemeral_test')
87+
--addresses strings comma-separated list of wallet addresses to fund
88+
--approve-amount big.Int amount of ERC20 tokens to approve for the spender (default 1000000000000000000000)
89+
--approve-spender string address to approve for spending tokens from each funded wallet
90+
--contract-address string address of pre-deployed Funder contract
91+
--erc20-bulk-funder-address string address of pre-deployed ERC20BulkFunder contract
92+
--eth-amount big.Int amount of wei to send to each wallet (default 50000000000000000)
93+
-f, --file string output JSON file path for storing addresses and private keys of funded wallets (default "wallets.json")
94+
--hd-derivation derive wallets to fund from private key in deterministic way (default true)
95+
-h, --help help for fund
96+
--key-file string file containing accounts private keys, one per line
97+
-n, --number uint number of wallets to fund (default 10)
98+
--private-key string hex encoded private key to use for sending transactions (default "0x42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa")
99+
-r, --rpc-url string RPC endpoint URL (default "http://localhost:8545")
100+
--seed string seed string for deterministic wallet generation (e.g., 'ephemeral_test')
101+
--token-address string address of the ERC20 token contract to mint and fund (if provided, enables ERC20 mode)
102+
--token-amount big.Int amount of ERC20 tokens to mint and transfer to each wallet (default 1000000000000000000)
98103
```
99104
100105
The command also inherits flags from parent commands.

0 commit comments

Comments
 (0)