diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bdd3fa86f..a9e5c770e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,9 @@ - Exposed `StateGetNetworkParams` in the Lotus Gateway API ([filecoin-project/lotus#12881](https://github.com/filecoin-project/lotus/pull/12881)) - **BREAKING**: Removed `SupportedProofTypes` from `StateGetNetworkParams` response as it was unreliable and didn't match FVM's actual supported proofs ([filecoin-project/lotus#12881](https://github.com/filecoin-project/lotus/pull/12881)) - refactor(eth): attach ToFilecoinMessage converter to EthCall method for improved package/module import structure. This change also exports the converter as a public method, enhancing usability for developers utilizing Lotus as a library. ([filecoin-project/lotus#12844](https://github.com/filecoin-project/lotus/pull/12844)) - - chore: switch to pure-go zstd decoder for snapshot imports. ([filecoin-project/lotus#12857](https://github.com/filecoin-project/lotus/pull/12857)) - - feat: automatically detect if the genesis is zstd compressed. ([filecoin-project/lotus#12885](https://github.com/filecoin-project/lotus/pull/12885) +- `lotus send` now supports `--csv` option for sending multiple transactions. ([filecoin-project/lotus#12892](https://github.com/filecoin-project/lotus/pull/12892)) # UNRELEASED v.1.32.0 diff --git a/cli/send.go b/cli/send.go index bdf9549bd4c..86288778e4c 100644 --- a/cli/send.go +++ b/cli/send.go @@ -2,8 +2,11 @@ package cli import ( "bytes" + "encoding/csv" "encoding/hex" "fmt" + "os" + "strconv" "strings" "github.com/urfave/cli/v2" @@ -69,8 +72,16 @@ var SendCmd = &cli.Command{ Name: "force", Usage: "Deprecated: use global 'force-send'", }, + &cli.StringFlag{ + Name: "csv", + Usage: "send multiple transactions from a CSV file (format: Recipient,FIL,Method,Params)", + }, }, Action: func(cctx *cli.Context) error { + if csvFile := cctx.String("csv"); csvFile != "" { + return handleCSVSend(cctx, csvFile) + } + if cctx.IsSet("force") { fmt.Println("'force' flag is deprecated, use global flag 'force-send'") } @@ -244,3 +255,130 @@ var SendCmd = &cli.Command{ return nil }, } + +func handleCSVSend(cctx *cli.Context, csvFile string) error { + srv, err := GetFullNodeServices(cctx) + if err != nil { + return err + } + defer srv.Close() //nolint:errcheck + + ctx := ReqContext(cctx) + + var fromAddr address.Address + if from := cctx.String("from"); from != "" { + addr, err := address.NewFromString(from) + if err != nil { + return err + } + fromAddr = addr + } else { + defaddr, err := srv.FullNodeAPI().WalletDefaultAddress(ctx) + if err != nil { + return fmt.Errorf("failed to get default address: %w", err) + } + fromAddr = defaddr + } + + // Print sending address + _, _ = fmt.Fprintf(cctx.App.Writer, "Sending messages from: %s\n", fromAddr.String()) + + fileReader, err := os.Open(csvFile) + if err != nil { + return xerrors.Errorf("read csv: %w", err) + } + defer func() { + if err := fileReader.Close(); err != nil { + log.Errorf("failed to close csv file: %v", err) + } + }() + + r := csv.NewReader(fileReader) + records, err := r.ReadAll() + if err != nil { + return xerrors.Errorf("read csv: %w", err) + } + + // Validate header + if len(records) == 0 || + len(records[0]) != 4 || + strings.TrimSpace(records[0][0]) != "Recipient" || + strings.TrimSpace(records[0][1]) != "FIL" || + strings.TrimSpace(records[0][2]) != "Method" || + strings.TrimSpace(records[0][3]) != "Params" { + return xerrors.Errorf("expected header row to be \"Recipient,FIL,Method,Params\"") + } + + // First pass: validate and build params + var sendParams []SendParams + totalAmount := abi.NewTokenAmount(0) + + for i, e := range records[1:] { + if len(e) != 4 { + return xerrors.Errorf("row %d has %d fields, expected 4", i, len(e)) + } + + var params SendParams + params.From = fromAddr + + // Parse recipient + var err error + params.To, err = address.NewFromString(e[0]) + if err != nil { + return xerrors.Errorf("failed to parse address in row %d: %w", i, err) + } + + // Parse value + val, err := types.ParseFIL(e[1]) + if err != nil { + return xerrors.Errorf("failed to parse amount in row %d: %w", i, err) + } + params.Val = abi.TokenAmount(val) + totalAmount = types.BigAdd(totalAmount, params.Val) + + // Parse method + method, err := strconv.Atoi(strings.TrimSpace(e[2])) + if err != nil { + return xerrors.Errorf("failed to parse method number in row %d: %w", i, err) + } + params.Method = abi.MethodNum(method) + + // Parse params + if strings.TrimSpace(e[3]) != "nil" { + params.Params, err = hex.DecodeString(strings.TrimSpace(e[3])) + if err != nil { + return xerrors.Errorf("failed to parse hex params in row %d: %w", i, err) + } + } + + sendParams = append(sendParams, params) + } + + // Check sender balance + senderBalance, err := srv.FullNodeAPI().WalletBalance(ctx, fromAddr) + if err != nil { + return xerrors.Errorf("failed to get sender balance: %w", err) + } + + if senderBalance.LessThan(totalAmount) { + return xerrors.Errorf("insufficient funds: need %s FIL, have %s FIL", + types.FIL(totalAmount), types.FIL(senderBalance)) + } + + // Second pass: perform sends + for i, params := range sendParams { + proto, err := srv.MessageForSend(ctx, params) + if err != nil { + return xerrors.Errorf("creating message prototype for row %d: %w", i, err) + } + + sm, err := InteractiveSend(ctx, cctx, srv, proto) + if err != nil { + return xerrors.Errorf("sending message for row %d: %w", i, err) + } + + fmt.Printf("Sent message %d: %s\n", i, sm.Cid()) + } + + return nil +} diff --git a/cmd/lotus-shed/main.go b/cmd/lotus-shed/main.go index 615c94338b3..00c477c20c1 100644 --- a/cmd/lotus-shed/main.go +++ b/cmd/lotus-shed/main.go @@ -82,7 +82,6 @@ func main() { fr32Cmd, chainCmd, balancerCmd, - sendCsvCmd, terminationsCmd, migrationsCmd, diffCmd, diff --git a/cmd/lotus-shed/send-csv.go b/cmd/lotus-shed/send-csv.go deleted file mode 100644 index 17b62150fc5..00000000000 --- a/cmd/lotus-shed/send-csv.go +++ /dev/null @@ -1,152 +0,0 @@ -package main - -import ( - "encoding/csv" - "encoding/hex" - "fmt" - "os" - "strconv" - "strings" - - "github.com/ipfs/go-cid" - "github.com/urfave/cli/v2" - "golang.org/x/xerrors" - - "github.com/filecoin-project/go-address" - "github.com/filecoin-project/go-state-types/abi" - "github.com/filecoin-project/go-state-types/exitcode" - - lapi "github.com/filecoin-project/lotus/api" - "github.com/filecoin-project/lotus/chain/types" - lcli "github.com/filecoin-project/lotus/cli" -) - -var sendCsvCmd = &cli.Command{ - Name: "send-csv", - Usage: "Utility for sending a batch of balance transfers", - Flags: []cli.Flag{ - &cli.StringFlag{ - Name: "from", - Usage: "specify the account to send funds from", - Required: true, - }, - }, - ArgsUsage: "[csvfile]", - Action: func(cctx *cli.Context) error { - if cctx.NArg() != 1 { - return lcli.IncorrectNumArgs(cctx) - } - - api, closer, err := lcli.GetFullNodeAPIV1(cctx) - if err != nil { - return err - } - - defer closer() - ctx := lcli.ReqContext(cctx) - - srv, err := lcli.GetFullNodeServices(cctx) - if err != nil { - return err - } - defer srv.Close() //nolint:errcheck - - sender, err := address.NewFromString(cctx.String("from")) - if err != nil { - return err - } - - fileReader, err := os.Open(cctx.Args().First()) - if err != nil { - return xerrors.Errorf("read csv: %w", err) - } - - defer fileReader.Close() //nolint:errcheck - r := csv.NewReader(fileReader) - records, err := r.ReadAll() - if err != nil { - return xerrors.Errorf("read csv: %w", err) - } - - if strings.TrimSpace(records[0][0]) != "Recipient" || - strings.TrimSpace(records[0][1]) != "FIL" || - strings.TrimSpace(records[0][2]) != "Method" || - strings.TrimSpace(records[0][3]) != "Params" { - return xerrors.Errorf("expected header row to be \"Recipient, FIL, Method, Params\"") - } - - var msgs []*types.Message - for i, e := range records[1:] { - addr, err := address.NewFromString(e[0]) - if err != nil { - return xerrors.Errorf("failed to parse address in row %d: %w", i, err) - } - - value, err := types.ParseFIL(strings.TrimSpace(e[1])) - if err != nil { - return xerrors.Errorf("failed to parse value balance: %w", err) - } - - method, err := strconv.Atoi(strings.TrimSpace(e[2])) - if err != nil { - return xerrors.Errorf("failed to parse method number: %w", err) - } - - var params []byte - if strings.TrimSpace(e[3]) != "nil" { - params, err = hex.DecodeString(strings.TrimSpace(e[3])) - if err != nil { - return xerrors.Errorf("failed to parse hexparams: %w", err) - } - } - - msgs = append(msgs, &types.Message{ - To: addr, - From: sender, - Value: abi.TokenAmount(value), - Method: abi.MethodNum(method), - Params: params, - }) - } - - if len(msgs) == 0 { - return nil - } - - var msgCids []cid.Cid - for i, msg := range msgs { - smsg, err := api.MpoolPushMessage(ctx, msg, nil) - if err != nil { - fmt.Printf("%d, ERROR %s\n", i, err) - continue - } - - fmt.Printf("%d, %s\n", i, smsg.Cid()) - - if i > 0 && i%100 == 0 { - fmt.Printf("catching up until latest message lands") - _, err := api.StateWaitMsg(ctx, smsg.Cid(), 1, lapi.LookbackNoLimit, true) - if err != nil { - return err - } - } - - msgCids = append(msgCids, smsg.Cid()) - } - - fmt.Println("waiting on messages...") - - for _, msgCid := range msgCids { - ml, err := api.StateWaitMsg(ctx, msgCid, 5, lapi.LookbackNoLimit, true) - if err != nil { - return err - } - if ml.Receipt.ExitCode != exitcode.Ok { - fmt.Printf("MSG %s NON-ZERO EXITCODE: %s\n", msgCid, ml.Receipt.ExitCode) - } - } - - fmt.Println("all sent messages succeeded") - return nil - }, -} diff --git a/documentation/en/cli-lotus.md b/documentation/en/cli-lotus.md index 0d760766b2b..6cf9833089d 100644 --- a/documentation/en/cli-lotus.md +++ b/documentation/en/cli-lotus.md @@ -198,6 +198,7 @@ OPTIONS: --params-json value specify invocation parameters in json --params-hex value specify invocation parameters in hex --force Deprecated: use global 'force-send' (default: false) + --csv value send multiple transactions from a CSV file (format: Recipient,FIL,Method,Params) --help, -h show help ```