diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a64fd7c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.idea +.vscode + +.env +.env.* + +data/ +*.csv + +*.log diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3ffdfdc --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + go: + name: Go vet/test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Check gofmt + run: | + test -z "$(gofmt -l .)" + + - name: Run go vet + run: | + go vet ./... + + - name: Run go test + run: | + go test ./... + + docker: + name: Docker build (no push) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build + uses: docker/build-push-action@v5 + with: + context: . + push: false diff --git a/README.md b/README.md index 67e9ce5..61a88a3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,34 @@ # transaction-latency +Measures transaction inclusion latency against two RPC endpoints (e.g. Base + Flashblocks) and writes results to CSV. + +## Requirements + +- Docker +- A funded EOA private key for the target network + +## Setup + +1. Copy the env template and fill values: + +```bash +cp .env.example .env +``` + +2. Create an output directory (CSV files are written to `./data`): + +```bash +mkdir -p data +``` + +## Run + +```bash docker build -t transaction-latency . -docker run -v $(pwd)/data:/app/data --env-file .env --rm -it transaction-latency \ No newline at end of file +docker run --rm -it -v "$(pwd)/data:/app/data" --env-file .env transaction-latency +``` + +## Outputs + +- `data/flashblocks-.csv` +- `data/base-.csv` (if `RUN_STANDARD_TRANSACTION_SENDING` is not set to `false`) \ No newline at end of file diff --git a/main.go b/main.go index 289e8a1..841f548 100644 --- a/main.go +++ b/main.go @@ -5,14 +5,17 @@ import ( "crypto/ecdsa" "encoding/csv" "encoding/hex" + "errors" "fmt" "log" "math/big" "math/rand" "os" + "path/filepath" "strconv" "time" + "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core/types" "github.com/ethereum/go-ethereum/crypto" @@ -60,9 +63,13 @@ func main() { log.Fatal("TO_ADDRESS environment variable not set") } + if !common.IsHexAddress(toAddressRaw) { + log.Fatal("TO_ADDRESS environment variable is not a valid hex address") + } + toAddress := common.HexToAddress(toAddressRaw) if toAddress == (common.Address{}) { - log.Fatal("TO_ADDRESS environment variable not set") + log.Fatal("TO_ADDRESS environment variable cannot be the zero address") } flashblocksUrl := os.Getenv("FLASHBLOCKS_URL") @@ -203,9 +210,16 @@ func main() { } func writeToFile(filename string, data []stats) error { + dir := filepath.Dir(filename) + if dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory %s: %w", dir, err) + } + } + file, err := os.Create(filename) if err != nil { - log.Fatalf("Failed to create file: %v", err) + return fmt.Errorf("failed to create file %s: %w", filename, err) } defer file.Close() @@ -214,7 +228,7 @@ func writeToFile(filename string, data []stats) error { header := []string{"sent_at", "txn_hash", "included_in_block", "inclusion_delay_ms"} if err := writer.Write(header); err != nil { - log.Fatalf("Failed to write to file: %v", err) + return fmt.Errorf("failed to write CSV header: %w", err) } for _, d := range data { @@ -225,7 +239,7 @@ func writeToFile(filename string, data []stats) error { strconv.FormatInt(d.InclusionDelay.Milliseconds(), 10), } if err := writer.Write(row); err != nil { - log.Fatalf("Failed to write to file: %v", err) + return fmt.Errorf("failed to write CSV row: %w", err) } } @@ -324,7 +338,12 @@ func sendTransactionAsync(client *ethclient.Client, signedTx *types.Transaction, for i := 0; i < 1000; i++ { receipt, err := client.TransactionReceipt(context.Background(), signedTx.Hash()) if err != nil { - time.Sleep(time.Duration(pollingIntervalMs) * time.Millisecond) + if errors.Is(err, ethereum.NotFound) { + time.Sleep(time.Duration(pollingIntervalMs) * time.Millisecond) + continue + } + + return stats{}, fmt.Errorf("failed to fetch receipt: %w", err) } else { now := time.Now() return stats{