diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000000..37a41593cc --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +name: Build + +on: + push: + branches: + - master + - copilot/** + pull_request: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + +jobs: + build: + name: Build All + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: false + + - uses: actions/cache@v4 + with: + path: build/cache + key: ${{ runner.os }}-build-tools-cache-${{ hashFiles('build/checksums.txt') }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.24' + cache: false + + - name: Build all commands + run: make all + + - name: Run pushtx tests + run: go test ./cmd/pushtx/ -v diff --git a/.gitignore b/.gitignore index 293359a669..9602bff893 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,4 @@ cmd/geth/geth cmd/rlpdump/rlpdump cmd/workload/workload cmd/keeper/keeper +cmd/pushtx/pushtx diff --git a/Makefile b/Makefile index f3d7f48f2f..424988fefd 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # with Go source code. If you know what GOPATH is then you probably # don't need to bother with make. -.PHONY: geth evm all test lint fmt clean devtools help +.PHONY: geth pushtx evm all test lint fmt clean devtools help GOBIN = ./build/bin GO ?= latest @@ -14,6 +14,12 @@ geth: @echo "Done building." @echo "Run \"$(GOBIN)/geth\" to launch geth." +#? pushtx: Build pushtx. +pushtx: + $(GORUN) build/ci.go install ./cmd/pushtx + @echo "Done building." + @echo "Run \"$(GOBIN)/pushtx\" to launch pushtx." + #? evm: Build evm. evm: $(GORUN) build/ci.go install ./cmd/evm diff --git a/build/ci.go b/build/ci.go index 4d0a1d7e35..c1f2cb3922 100644 --- a/build/ci.go +++ b/build/ci.go @@ -85,6 +85,7 @@ var ( executablePath("abigen"), executablePath("evm"), executablePath("geth"), + executablePath("pushtx"), executablePath("rlpdump"), executablePath("clef"), } @@ -139,6 +140,10 @@ var ( BinaryName: "geth", Description: "Ethereum CLI client.", }, + { + BinaryName: "pushtx", + Description: "CLI tool for submitting raw signed Ethereum transactions to a JSON-RPC endpoint.", + }, { BinaryName: "rlpdump", Description: "Developer utility tool that prints RLP structures.", diff --git a/cmd/pushtx/funnel.go b/cmd/pushtx/funnel.go new file mode 100644 index 0000000000..1444a11913 --- /dev/null +++ b/cmd/pushtx/funnel.go @@ -0,0 +1,188 @@ +// 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 . + +package main + +import ( + "context" + "fmt" + "math/big" + "strings" + + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/rpc" +) + +// Addresses from the Tenderly simulation: +// https://dashboard.tenderly.co/public/tallyxyz/project/simulator/41ec6e27-0532-4efd-8377-ad130b2982cc +// The simulation demonstrates a Gnosis Safe USDC transfer used as a fallback +// when the primary sender lacks sufficient ETH. +var ( + // GnosisSafeProxy that holds the USDC funds. + safeAddr = common.HexToAddress("0x4f2083f5fbede34c2714affb3105539775f7fe64") + // USDC (FiatTokenProxy) contract on mainnet. + usdcAddr = common.HexToAddress("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48") + // Approved Safe owner / recipient of the USDC transfer. + recipientAddr = common.HexToAddress("0xfe89cc7abb2c4183683ab71653c4cdc9b02d44b7") + // Transfer amount: 900 000 USDC (6 decimals). + transferAmt = new(big.Int).SetUint64(900_000_000_000) +) + +// funnelConfig holds parameters for the Gnosis Safe execTransaction fallback. +type funnelConfig struct { + Safe common.Address + To common.Address // Inner call target (e.g. USDC contract). + Value *big.Int + Data []byte // Inner call data (e.g. ERC-20 transfer). + Operation uint8 + SafeTxGas *big.Int + BaseGas *big.Int + GasPrice *big.Int + GasToken common.Address + RefundReceiver common.Address + Signatures []byte +} + +// defaultFunnelConfig returns the funnel configuration matching the +// Tenderly simulation 41ec6e27-0532-4efd-8377-ad130b2982cc. +func defaultFunnelConfig() *funnelConfig { + // Pre-validated owner signature for 0xfe89cc7abb2c4183683ab71653c4cdc9b02d44b7. + // Format: r(32)=padded address, s(32)=0, v(1)=1 (pre-approved). + sig := common.FromHex("000000000000000000000000fe89cc7abb2c4183683ab71653c4cdc9b02d44b7000000000000000000000000000000000000000000000000000000000000000001") + + return &funnelConfig{ + Safe: safeAddr, + To: usdcAddr, + Value: big.NewInt(0), + Data: buildERC20Transfer(recipientAddr, transferAmt), + Operation: 0, + SafeTxGas: big.NewInt(0), + BaseGas: big.NewInt(0), + GasPrice: big.NewInt(0), + GasToken: common.Address{}, + RefundReceiver: common.Address{}, + Signatures: sig, + } +} + +// buildERC20Transfer encodes an ERC-20 transfer(address,uint256) call. +func buildERC20Transfer(to common.Address, amount *big.Int) []byte { + const abiJSON = `[{"name":"transfer","type":"function","inputs":[{"name":"to","type":"address"},{"name":"value","type":"uint256"}]}]` + parsed, err := abi.JSON(strings.NewReader(abiJSON)) + if err != nil { + panic("bad transfer ABI: " + err.Error()) + } + data, err := parsed.Pack("transfer", to, amount) + if err != nil { + panic("packing transfer: " + err.Error()) + } + return data +} + +// buildExecTransaction ABI-encodes a Gnosis Safe execTransaction call. +func buildExecTransaction(cfg *funnelConfig) ([]byte, error) { + const abiJSON = `[{"name":"execTransaction","type":"function","inputs":[{"name":"to","type":"address"},{"name":"value","type":"uint256"},{"name":"data","type":"bytes"},{"name":"operation","type":"uint8"},{"name":"safeTxGas","type":"uint256"},{"name":"baseGas","type":"uint256"},{"name":"gasPrice","type":"uint256"},{"name":"gasToken","type":"address"},{"name":"refundReceiver","type":"address"},{"name":"signatures","type":"bytes"}]}]` + parsed, err := abi.JSON(strings.NewReader(abiJSON)) + if err != nil { + return nil, fmt.Errorf("parsing execTransaction ABI: %w", err) + } + return parsed.Pack("execTransaction", + cfg.To, cfg.Value, cfg.Data, cfg.Operation, + cfg.SafeTxGas, cfg.BaseGas, cfg.GasPrice, + cfg.GasToken, cfg.RefundReceiver, cfg.Signatures, + ) +} + +// printFunnelSummary displays the funnel transaction details to stdout. +func printFunnelSummary(cfg *funnelConfig) { + fmt.Println("Funnel transaction (Gnosis Safe execTransaction):") + fmt.Println(" Safe: ", cfg.Safe.Hex()) + fmt.Println(" Inner call to: ", cfg.To.Hex()) + fmt.Println(" Inner value: ", cfg.Value) + fmt.Println(" Inner data: ", hexutil.Encode(cfg.Data)) + fmt.Println(" Operation: ", cfg.Operation) + fmt.Println(" Signatures: ", hexutil.Encode(cfg.Signatures)) +} + +// sendFunnelTransaction validates and sends the Gnosis Safe execTransaction. +// It first validates the call with eth_call, then submits via eth_sendTransaction. +func sendFunnelTransaction(rpcURL string, cfg *funnelConfig) (common.Hash, error) { + calldata, err := buildExecTransaction(cfg) + if err != nil { + return common.Hash{}, fmt.Errorf("building funnel calldata: %w", err) + } + + client, err := rpc.Dial(rpcURL) + if err != nil { + return common.Hash{}, fmt.Errorf("connecting to %s: %w", rpcURL, err) + } + defer client.Close() + + callMsg := map[string]interface{}{ + "from": recipientAddr.Hex(), + "to": cfg.Safe.Hex(), + "data": hexutil.Encode(calldata), + } + + // Validate with eth_call first. + var callResult hexutil.Bytes + if err := client.CallContext(context.Background(), &callResult, "eth_call", callMsg, "latest"); err != nil { + return common.Hash{}, fmt.Errorf("funnel validation (eth_call) failed: %w", err) + } + fmt.Println("Funnel validation passed (eth_call succeeded)") + + // Submit the transaction. + var hash common.Hash + err = client.CallContext(context.Background(), &hash, "eth_sendTransaction", callMsg) + if err != nil { + return common.Hash{}, fmt.Errorf("sending funnel transaction: %w", err) + } + return hash, nil +} + +// validateTransaction checks the transaction receipt for successful execution. +func validateTransaction(rpcURL string, txHash common.Hash) error { + client, err := rpc.Dial(rpcURL) + if err != nil { + return fmt.Errorf("connecting to %s: %w", rpcURL, err) + } + defer client.Close() + + var receipt map[string]interface{} + err = client.CallContext(context.Background(), &receipt, "eth_getTransactionReceipt", txHash) + if err != nil { + return fmt.Errorf("getting receipt: %w", err) + } + if receipt == nil { + fmt.Println("Transaction not yet mined, check later:", txHash.Hex()) + return nil + } + status, ok := receipt["status"] + if !ok { + return fmt.Errorf("receipt missing status field") + } + statusStr, ok := status.(string) + if !ok { + return fmt.Errorf("unexpected status type in receipt") + } + if statusStr != "0x1" { + return fmt.Errorf("transaction failed (status: %s)", statusStr) + } + fmt.Println("Transaction validated: execution successful") + return nil +} diff --git a/cmd/pushtx/funnel_test.go b/cmd/pushtx/funnel_test.go new file mode 100644 index 0000000000..e3155c42ba --- /dev/null +++ b/cmd/pushtx/funnel_test.go @@ -0,0 +1,215 @@ +// 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 . + +package main + +import ( + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" +) + +func TestBuildERC20Transfer(t *testing.T) { + to := common.HexToAddress("0xfe89cc7abb2c4183683ab71653c4cdc9b02d44b7") + amount := new(big.Int).SetUint64(900_000_000_000) + + data := buildERC20Transfer(to, amount) + + // First 4 bytes must be the transfer(address,uint256) selector. + selector := hexutil.Encode(data[:4]) + if selector != "0xa9059cbb" { + t.Fatalf("wrong selector: got %s, want 0xa9059cbb", selector) + } + + // Expected calldata from the Tenderly simulation. + want := "0xa9059cbb000000000000000000000000fe89cc7abb2c4183683ab71653c4cdc9b02d44b7000000000000000000000000000000000000000000000000000000d18c2e2800" + got := hexutil.Encode(data) + if got != want { + t.Fatalf("calldata mismatch:\n got %s\n want %s", got, want) + } +} + +func TestBuildExecTransaction(t *testing.T) { + cfg := defaultFunnelConfig() + data, err := buildExecTransaction(cfg) + if err != nil { + t.Fatal(err) + } + + // execTransaction selector = 0x6a761202. + selector := hexutil.Encode(data[:4]) + if selector != "0x6a761202" { + t.Fatalf("wrong selector: got %s, want 0x6a761202", selector) + } + + // Encoded data must contain the USDC address. + dataHex := strings.ToLower(hexutil.Encode(data)) + if !strings.Contains(dataHex, "a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48") { + t.Fatal("encoded data does not contain USDC address") + } +} + +func TestDefaultFunnelConfig(t *testing.T) { + cfg := defaultFunnelConfig() + + if cfg.Safe != safeAddr { + t.Errorf("Safe = %s, want %s", cfg.Safe.Hex(), safeAddr.Hex()) + } + if cfg.To != usdcAddr { + t.Errorf("To = %s, want %s", cfg.To.Hex(), usdcAddr.Hex()) + } + if cfg.Value.Sign() != 0 { + t.Errorf("Value = %s, want 0", cfg.Value) + } + if cfg.Operation != 0 { + t.Errorf("Operation = %d, want 0", cfg.Operation) + } + if len(cfg.Signatures) != 65 { + t.Errorf("Signatures length = %d, want 65", len(cfg.Signatures)) + } +} + +func TestIsCalldata(t *testing.T) { + tests := []struct { + name string + data []byte + want bool + }{ + {"ERC20 transfer selector", common.FromHex("a9059cbb0000"), true}, + {"short data", []byte{0xa9}, false}, + {"legacy tx RLP", common.FromHex("f86c0184"), false}, + {"typed tx EIP-1559", common.FromHex("02f86c01"), false}, + } + for _, tt := range tests { + if got := isCalldata(tt.data); got != tt.want { + t.Errorf("isCalldata(%s) = %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestRunCalldataError(t *testing.T) { + // Sending raw calldata (not a signed tx) should produce a helpful error. + calldata := "0xa9059cbb00000000000000000000000099d580d3a7fe7bd183b2464517b2cd7ce5a8f15a0000000000000000000000000000000000000000000000000de0b6b3a7640000" + err := run([]string{calldata}, strings.NewReader("")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "contract calldata") { + t.Fatalf("expected calldata detection message, got: %v", err) + } + if !strings.Contains(err.Error(), "a9059cbb") { + t.Fatalf("expected selector in error, got: %v", err) + } +} + +// fakeRPCFunnel starts an HTTP server that rejects eth_sendRawTransaction +// and accepts the funnel flow (eth_call + eth_sendTransaction + receipt). +func fakeRPCFunnel(t *testing.T) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + ID json.RawMessage `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch req.Method { + case "eth_sendRawTransaction": + // Simulate insufficient funds error. + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]interface{}{"code": -32000, "message": "insufficient funds for gas * price + value"}, + }) + case "eth_call": + // Validation succeeds – return ABI-encoded true. + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": "0x0000000000000000000000000000000000000000000000000000000000000001", + }) + case "eth_sendTransaction": + // Return a fake tx hash. + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890", + }) + case "eth_getTransactionReceipt": + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": map[string]interface{}{ + "status": "0x1", + "blockNumber": "0x178C3C9", + }, + }) + default: + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]interface{}{"code": -32601, "message": "method not found"}, + }) + } + })) +} + +func TestRunFunnelFallback(t *testing.T) { + srv := fakeRPCFunnel(t) + defer srv.Close() + + _, txHex := signedTestTx(t) + err := run([]string{"--rpc", srv.URL, "--funnel", txHex}, strings.NewReader("")) + if err != nil { + t.Fatal("unexpected error:", err) + } +} + +func TestRunFunnelNotEnabledOnError(t *testing.T) { + srv := fakeRPCFunnel(t) + defer srv.Close() + + _, txHex := signedTestTx(t) + // Without --funnel, the insufficient funds error should propagate. + err := run([]string{"--rpc", srv.URL, txHex}, strings.NewReader("")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "insufficient funds") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateTransactionSuccess(t *testing.T) { + srv := fakeRPCFunnel(t) + defer srv.Close() + + hash := common.HexToHash("0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890") + err := validateTransaction(srv.URL, hash) + if err != nil { + t.Fatal("unexpected error:", err) + } +} diff --git a/cmd/pushtx/main.go b/cmd/pushtx/main.go new file mode 100644 index 0000000000..4681cbb6fb --- /dev/null +++ b/cmd/pushtx/main.go @@ -0,0 +1,242 @@ +// 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 . + +// 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 (0x01–0x03). +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] + +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) +} diff --git a/cmd/pushtx/main_test.go b/cmd/pushtx/main_test.go new file mode 100644 index 0000000000..f5b6c0eb78 --- /dev/null +++ b/cmd/pushtx/main_test.go @@ -0,0 +1,339 @@ +// 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 . + +package main + +import ( + "crypto/ecdsa" + "encoding/json" + "io" + "math/big" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "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/crypto" +) + +// signedTestTx returns a signed legacy transaction and its hex encoding. +func signedTestTx(t *testing.T) (*types.Transaction, string) { + t.Helper() + + key, err := crypto.GenerateKey() + if err != nil { + t.Fatal(err) + } + return signedTestTxWithKey(t, key) +} + +func signedTestTxWithKey(t *testing.T, key *ecdsa.PrivateKey) (*types.Transaction, string) { + t.Helper() + + tx := types.NewTx(&types.LegacyTx{ + Nonce: 6, + GasPrice: big.NewInt(1_000_000_000), // 1 Gwei – real networks reject gas price 0 + Gas: 21055, + To: addrPtr(common.HexToAddress("0x78b5290269740033b05bd8d71c97331295eb5918")), + Value: new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)), // 10 ETH + }) + signer := types.NewEIP155Signer(big.NewInt(1)) + signed, err := types.SignTx(tx, signer, key) + if err != nil { + t.Fatal(err) + } + data, err := signed.MarshalBinary() + if err != nil { + t.Fatal(err) + } + return signed, hexutil.Encode(data) +} + +func addrPtr(a common.Address) *common.Address { return &a } + +// fakeRPC starts an HTTP server that responds to eth_sendRawTransaction. +func fakeRPC(t *testing.T, wantErr bool) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + Params []json.RawMessage `json:"params"` + ID json.RawMessage `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if req.Method != "eth_sendRawTransaction" { + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]interface{}{"code": -32601, "message": "method not found"}, + }) + return + } + if wantErr { + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "error": map[string]interface{}{"code": -32000, "message": "already known"}, + }) + return + } + // Decode the raw tx to return its hash as the result. + var hexData string + if err := json.Unmarshal(req.Params[0], &hexData); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + rawBytes, err := hexutil.Decode(hexData) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var tx types.Transaction + if err := tx.UnmarshalBinary(rawBytes); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + json.NewEncoder(w).Encode(map[string]interface{}{ + "jsonrpc": "2.0", + "id": req.ID, + "result": tx.Hash().Hex(), + }) + })) +} + +func TestRunSuccess(t *testing.T) { + srv := fakeRPC(t, false) + defer srv.Close() + + _, txHex := signedTestTx(t) + err := run([]string{"--rpc", srv.URL, txHex}, strings.NewReader("")) + if err != nil { + t.Fatal("unexpected error:", err) + } +} + +func TestRunFromStdin(t *testing.T) { + srv := fakeRPC(t, false) + defer srv.Close() + + _, txHex := signedTestTx(t) + err := run([]string{"--rpc", srv.URL}, strings.NewReader(txHex)) + if err != nil { + t.Fatal("unexpected error:", err) + } +} + +func TestRunRPCError(t *testing.T) { + srv := fakeRPC(t, true) + defer srv.Close() + + _, txHex := signedTestTx(t) + + // Capture stdout – raw hex should still be printed on RPC failure. + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + runErr := run([]string{"--rpc", srv.URL, txHex}, strings.NewReader("")) + + w.Close() + os.Stdout = oldStdout + + if runErr == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(runErr.Error(), "already known") { + t.Fatalf("unexpected error message: %v", runErr) + } + + out, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(out), "Raw tx: 0x") { + t.Fatal("expected raw hex in output even on RPC error") + } +} + +func TestRunNoInput(t *testing.T) { + err := run(nil, strings.NewReader("")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "no transaction data") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunBadHex(t *testing.T) { + err := run([]string{"not-hex-data"}, strings.NewReader("")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "invalid hex") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunBadTx(t *testing.T) { + err := run([]string{"0xdeadbeef"}, strings.NewReader("")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "decoding transaction") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunHelp(t *testing.T) { + err := run([]string{"--help"}, strings.NewReader("")) + if err != nil { + t.Fatal("unexpected error:", err) + } +} + +func TestRunUnknownFlag(t *testing.T) { + err := run([]string{"--unknown"}, strings.NewReader("")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "unknown flag") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRunExtraArgs(t *testing.T) { + err := run([]string{"0xaa", "0xbb"}, strings.NewReader("")) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "unexpected argument") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestFormatWei(t *testing.T) { + tests := []struct { + wei *big.Int + want string + }{ + {nil, "0 wei (0 ETH)"}, + {big.NewInt(0), "0 wei (0 ETH)"}, + {big.NewInt(1e18), "1000000000000000000 wei (1.000000000000000000 ETH)"}, + {new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)), "10000000000000000000 wei (10.000000000000000000 ETH)"}, + } + for _, tt := range tests { + got := formatWei(tt.wei) + if got != tt.want { + t.Errorf("formatWei(%v) = %q, want %q", tt.wei, got, tt.want) + } + } +} + +func TestFormatGwei(t *testing.T) { + tests := []struct { + wei *big.Int + want string + }{ + {nil, "0 wei (0 Gwei)"}, + {big.NewInt(0), "0 wei (0 Gwei)"}, + {big.NewInt(1_000_000_000), "1000000000 wei (1.000000000 Gwei)"}, + {big.NewInt(20_000_000_000), "20000000000 wei (20.000000000 Gwei)"}, + } + for _, tt := range tests { + got := formatGwei(tt.wei) + if got != tt.want { + t.Errorf("formatGwei(%v) = %q, want %q", tt.wei, got, tt.want) + } + } +} + +func TestTxCost(t *testing.T) { + tx := types.NewTx(&types.LegacyTx{ + GasPrice: big.NewInt(1_000_000_000), // 1 Gwei + Gas: 21055, + Value: new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)), // 10 ETH + }) + got := txCost(tx) + // Expected: 10 ETH + 21055 * 1 Gwei = 10000000000000000000 + 21055000000000 = 10000021055000000000 + want, _ := new(big.Int).SetString("10000021055000000000", 10) + if got.Cmp(want) != 0 { + t.Errorf("txCost = %s, want %s", got, want) + } +} + +func TestRunEqualsSyntax(t *testing.T) { + srv := fakeRPC(t, false) + defer srv.Close() + + _, txHex := signedTestTx(t) + err := run([]string{"--rpc=" + srv.URL, txHex}, strings.NewReader("")) + if err != nil { + t.Fatal("unexpected error:", err) + } +} + +func TestRunOutputEndsWithRawHex(t *testing.T) { + srv := fakeRPC(t, false) + defer srv.Close() + + _, txHex := signedTestTx(t) + + // Capture stdout to verify "Raw tx:" appears in output. + oldStdout := os.Stdout + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + os.Stdout = w + + runErr := run([]string{"--rpc", srv.URL, txHex}, strings.NewReader("")) + + w.Close() + os.Stdout = oldStdout + + if runErr != nil { + t.Fatal("unexpected error:", runErr) + } + + out, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(string(out)), "\n") + lastLine := lines[len(lines)-1] + + // The last line must be the raw hex transaction. + if !strings.HasPrefix(lastLine, "Raw tx: 0x") { + t.Fatalf("last output line = %q, want prefix \"Raw tx: 0x\"", lastLine) + } + // Verify the hex payload round-trips back to the input. + rawHex := strings.TrimPrefix(lastLine, "Raw tx: ") + if rawHex != txHex { + t.Fatalf("raw hex mismatch:\n got %s\n want %s", rawHex, txHex) + } +}