go-ethereum/cmd/pushtx/main.go
Dr Q. Josef Kurk Edwards f606360f30
Add raw transaction broadcast tool with Gnosis Safe fallback (#4)
* Initial plan

* Initial plan

* cmd/pushtx, .github/workflows: add raw transaction broadcast tool with funnel fallback

Add a standalone CLI tool (pushtx) for decoding and submitting raw signed
Ethereum transactions to any JSON-RPC endpoint. Includes Gnosis Safe funnel
fallback for insufficient-funds scenarios and calldata detection for the
common RLP broadcast error.

Files added:
- cmd/pushtx/main.go: core tool with tx decode, summary, and broadcast
- cmd/pushtx/funnel.go: Gnosis Safe execTransaction fallback
- cmd/pushtx/main_test.go: tests for core functionality
- cmd/pushtx/funnel_test.go: tests for funnel and calldata detection
- .github/workflows/build.yml: CI workflow for build and test

Files modified:
- Makefile: add pushtx target
- .gitignore: add cmd/pushtx/pushtx binary

Co-authored-by: drqsatoshi <240532885+drqsatoshi@users.noreply.github.com>

* cmd/pushtx: add Tenderly simulation URL to funnel comment

Co-authored-by: drqsatoshi <240532885+drqsatoshi@users.noreply.github.com>

* Initial plan

* Initial plan

Co-authored-by: drqsatoshi <240532885+drqsatoshi@users.noreply.github.com>

* Makefile, build/ci.go: add pushtx to .PHONY and distribution archives

Co-authored-by: drqsatoshi <240532885+drqsatoshi@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: drqsatoshi <240532885+drqsatoshi@users.noreply.github.com>
2026-03-12 14:48:54 -04:00

242 lines
7.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Copyright 2025 The go-ethereum Authors
// This file is part of go-ethereum.
//
// go-ethereum is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// go-ethereum is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with go-ethereum. If not, see <http://www.gnu.org/licenses/>.
// pushtx submits a raw signed transaction to an Ethereum JSON-RPC endpoint.
package main
import (
"context"
"encoding/hex"
"fmt"
"io"
"math/big"
"os"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/common/hexutil"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/rpc"
)
const defaultRPCURL = "http://127.0.0.1:8545"
func main() {
if err := run(os.Args[1:], os.Stdin); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(args []string, stdin io.Reader) error {
var (
rpcURL string
txHex string
funnel bool
)
// Parse flags manually so the tool stays minimal.
for i := 0; i < len(args); i++ {
switch {
case args[i] == "--rpc" || args[i] == "-rpc":
i++
if i >= len(args) {
return fmt.Errorf("missing value for %s", args[i-1])
}
rpcURL = args[i]
case strings.HasPrefix(args[i], "--rpc="):
rpcURL = strings.TrimPrefix(args[i], "--rpc=")
case strings.HasPrefix(args[i], "-rpc="):
rpcURL = strings.TrimPrefix(args[i], "-rpc=")
case args[i] == "--funnel":
funnel = true
case args[i] == "-h" || args[i] == "--help":
printUsage()
return nil
case strings.HasPrefix(args[i], "-"):
return fmt.Errorf("unknown flag: %s", args[i])
default:
if txHex != "" {
return fmt.Errorf("unexpected argument: %s", args[i])
}
txHex = args[i]
}
}
if rpcURL == "" {
rpcURL = defaultRPCURL
}
// Read transaction hex from stdin when no positional argument is given.
if txHex == "" {
data, err := io.ReadAll(stdin)
if err != nil {
return fmt.Errorf("reading stdin: %w", err)
}
txHex = strings.TrimSpace(string(data))
}
if txHex == "" {
return fmt.Errorf("no transaction data provided (see --help for usage)")
}
rawTx, err := hex.DecodeString(strings.TrimPrefix(txHex, "0x"))
if err != nil {
return fmt.Errorf("invalid hex data: %w", err)
}
// Normalize to 0x-prefixed form for consistent output.
txHex = "0x" + hex.EncodeToString(rawTx)
// Decode the transaction so we can display a summary.
var tx types.Transaction
if err := tx.UnmarshalBinary(rawTx); err != nil {
if isCalldata(rawTx) {
return fmt.Errorf("decoding transaction: data appears to be contract calldata (selector 0x%x), not a signed transaction; sign the transaction before broadcasting", rawTx[:4])
}
return fmt.Errorf("decoding transaction: %w", err)
}
printTxSummary(&tx)
// Send to the RPC endpoint.
hash, err := sendRawTransaction(rpcURL, rawTx)
if err != nil {
if funnel {
fmt.Printf("\nPrimary transaction failed: %v\n", err)
fmt.Println("Attempting funnel fallback...")
cfg := defaultFunnelConfig()
printFunnelSummary(cfg)
funnelHash, fErr := sendFunnelTransaction(rpcURL, cfg)
if fErr != nil {
fmt.Println("Raw tx:", txHex)
return fmt.Errorf("funnel fallback failed: %w", fErr)
}
fmt.Println("Funnel transaction submitted successfully")
fmt.Println("Hash:", funnelHash.Hex())
if vErr := validateTransaction(rpcURL, funnelHash); vErr != nil {
fmt.Println("Validation pending:", vErr)
}
fmt.Println("Raw tx:", txHex)
return nil
}
// Still print the raw hex so the user can submit it elsewhere
// (e.g. etherscan.io/pushTx).
fmt.Println("Raw tx:", txHex)
return fmt.Errorf("sending transaction: %w", err)
}
fmt.Println("Transaction submitted successfully")
fmt.Println("Hash:", hash.Hex())
// Print the raw hex transaction as the last output for easy
// copy-paste into block explorers like etherscan.io/pushTx.
fmt.Println("Raw tx:", txHex)
return nil
}
// sendRawTransaction dials the given RPC endpoint and calls
// eth_sendRawTransaction with the provided raw bytes.
func sendRawTransaction(rpcURL string, rawTx []byte) (common.Hash, error) {
client, err := rpc.Dial(rpcURL)
if err != nil {
return common.Hash{}, fmt.Errorf("connecting to %s: %w", rpcURL, err)
}
defer client.Close()
var hash common.Hash
err = client.CallContext(context.Background(), &hash, "eth_sendRawTransaction", hexutil.Encode(rawTx))
if err != nil {
return common.Hash{}, err
}
return hash, nil
}
// printTxSummary displays the decoded transaction details to stdout.
func printTxSummary(tx *types.Transaction) {
signer := types.LatestSignerForChainID(tx.ChainId())
from, err := types.Sender(signer, tx)
if err != nil {
from = common.Address{}
}
fmt.Println("Transaction details:")
fmt.Println(" Type: ", tx.Type())
fmt.Println(" From: ", from.Hex())
if tx.To() != nil {
fmt.Println(" To: ", tx.To().Hex())
} else {
fmt.Println(" To: (contract creation)")
}
fmt.Println(" Nonce: ", tx.Nonce())
fmt.Println(" Value: ", formatWei(tx.Value()))
fmt.Println(" Gas limit:", tx.Gas())
fmt.Println(" Gas price:", formatGwei(tx.GasPrice()))
fmt.Println(" Tx cost: ", formatWei(txCost(tx)))
fmt.Println(" Chain ID: ", tx.ChainId())
}
// txCost returns value + gas * gasPrice, i.e. the total ETH the sender
// must hold for the transaction to be accepted by the network.
func txCost(tx *types.Transaction) *big.Int {
gasCost := new(big.Int).Mul(new(big.Int).SetUint64(tx.Gas()), tx.GasPrice())
return new(big.Int).Add(tx.Value(), gasCost)
}
// formatWei converts a wei amount to a human-readable string showing
// both the wei value and the ETH equivalent.
func formatWei(wei *big.Int) string {
if wei == nil || wei.Sign() == 0 {
return "0 wei (0 ETH)"
}
ether := new(big.Float).Quo(new(big.Float).SetInt(wei), new(big.Float).SetFloat64(1e18))
return fmt.Sprintf("%s wei (%s ETH)", wei.String(), ether.Text('f', 18))
}
// formatGwei converts a wei gas price to a human-readable string in Gwei.
func formatGwei(wei *big.Int) string {
if wei == nil || wei.Sign() == 0 {
return "0 wei (0 Gwei)"
}
gwei := new(big.Float).Quo(new(big.Float).SetInt(wei), new(big.Float).SetFloat64(1e9))
return fmt.Sprintf("%s wei (%s Gwei)", wei.String(), gwei.Text('f', 9))
}
// isCalldata returns true if the data looks like ABI-encoded contract
// calldata rather than an RLP-encoded signed transaction. Legacy
// transactions start with an RLP list header (>= 0xc0) and typed
// transactions (EIP-2718) start with a type byte (0x010x03).
func isCalldata(data []byte) bool {
if len(data) < 4 {
return false
}
first := data[0]
return first >= 0x04 && first < 0xc0
}
func printUsage() {
fmt.Fprintf(os.Stderr, `Usage: pushtx [--rpc URL] [--funnel] <tx-hex>
Submit a raw signed Ethereum transaction to a JSON-RPC endpoint.
The transaction data can be provided as a positional argument or via stdin.
Options:
--rpc URL JSON-RPC endpoint (default: %s)
--funnel Enable Gnosis Safe funnel fallback on failure
-h, --help Show this help message
Examples:
pushtx --rpc http://localhost:8545 0xf86c...
echo 0xf86c... | pushtx --rpc http://localhost:8545
pushtx --rpc http://localhost:8545 --funnel 0xf86c...
`, defaultRPCURL)
}