diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 8c88c37..c2b7ace 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -1,6 +1,10 @@ name: Checks -on: [push, pull_request] +on: + push: + branches: + - main + pull_request: jobs: test: @@ -10,7 +14,7 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v2 with: - go-version: ^1.20 + go-version: ^1.24 id: go - name: Check out code into the Go module directory @@ -26,7 +30,7 @@ jobs: - name: Set up Go 1.x uses: actions/setup-go@v2 with: - go-version: ^1.20 + go-version: ^1.24 id: go - name: Check out code into the Go module directory diff --git a/.gitignore b/.gitignore index aeb8638..0c1b484 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,5 @@ # IDE .idea/ .vscode/ +cert.pem +results.bin diff --git a/Makefile b/Makefile index e60cfcc..cd27793 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,15 @@ v: test: go test ./... +bench: + go test -bench=. -run=Bench ./... + +fmt: + gofmt -s -w . + gci write . + gofumpt -w -extra . + go mod tidy + lint: gofmt -d ./ go vet ./... diff --git a/blocksub/blocksub.go b/blocksub/blocksub.go index c69aeaf..cf62c93 100644 --- a/blocksub/blocksub.go +++ b/blocksub/blocksub.go @@ -15,6 +15,10 @@ import ( ) var ErrStopped = errors.New("already stopped") +var ( + defaultPollTimeout = 10 * time.Second + defaultSubTimeout = 60 * time.Second +) type BlockSubscriber interface { IsRunning() bool @@ -24,9 +28,10 @@ type BlockSubscriber interface { } type BlockSub struct { - PollTimeout time.Duration // 10 seconds by default (8,640 requests per day) - SubTimeout time.Duration // 60 seconds by default, after this timeout the subscriber will reconnect - DebugOutput bool + PollTimeout time.Duration // 10 seconds by default (8,640 requests per day) + SubTimeout time.Duration // 60 seconds by default, after this timeout the subscriber will reconnect + DebugOutput bool + EnableMetrics bool ethNodeHTTPURI string // usually port 8545 ethNodeWebsocketURI string // usually port 8546 @@ -51,11 +56,15 @@ type BlockSub struct { wsConnectingCond *sync.Cond } -func NewBlockSub(ctx context.Context, ethNodeHTTPURI string, ethNodeWebsocketURI string) *BlockSub { +func NewBlockSub(ctx context.Context, ethNodeHTTPURI, ethNodeWebsocketURI string) *BlockSub { + return NewBlockSubWithTimeout(ctx, ethNodeHTTPURI, ethNodeWebsocketURI, defaultPollTimeout, defaultSubTimeout) +} + +func NewBlockSubWithTimeout(ctx context.Context, ethNodeHTTPURI, ethNodeWebsocketURI string, pollTimeout, subTimeout time.Duration) *BlockSub { ctx, cancel := context.WithCancel(ctx) sub := &BlockSub{ - PollTimeout: 10 * time.Second, - SubTimeout: 60 * time.Second, + PollTimeout: pollTimeout, + SubTimeout: subTimeout, ethNodeHTTPURI: ethNodeHTTPURI, ethNodeWebsocketURI: ethNodeWebsocketURI, ctx: ctx, @@ -141,6 +150,10 @@ func (s *BlockSub) runListener() { case header := <-s.internalHeaderC: // use the new header if it's later or has a different hash than the previous known one if header.Number.Uint64() >= s.CurrentBlockNumber && header.Hash().Hex() != s.CurrentBlockHash { + + if s.EnableMetrics { + setBlockNumber(header.Number.Uint64()) + } s.CurrentHeader = header s.CurrentBlockNumber = header.Number.Uint64() s.CurrentBlockHash = header.Hash().Hex() @@ -242,7 +255,7 @@ func (s *BlockSub) _startWebsocket() (err error) { // Listen for headers and errors, and reconnect if needed go func() { - var timer = time.NewTimer(s.SubTimeout) + timer := time.NewTimer(s.SubTimeout) for { select { diff --git a/blocksub/metrics.go b/blocksub/metrics.go new file mode 100644 index 0000000..38f830c --- /dev/null +++ b/blocksub/metrics.go @@ -0,0 +1,11 @@ +package blocksub + +import ( + "github.com/VictoriaMetrics/metrics" +) + +var blockNumberGauge = metrics.NewGauge(`goutils_blocksub_latest_block_number`, nil) + +func setBlockNumber(blockNumber uint64) { + blockNumberGauge.Set(float64(blockNumber)) +} diff --git a/cli/cli.go b/cli/cli.go index 3ee0d00..47f97c2 100644 --- a/cli/cli.go +++ b/cli/cli.go @@ -14,7 +14,7 @@ func CheckErr(err error) { } // GetEnv returns the value of the environment variable named by key, or defaultValue if the environment variable doesn't exist -func GetEnv(key string, defaultValue string) string { +func GetEnv(key, defaultValue string) string { if value, ok := os.LookupEnv(key); ok { return value } diff --git a/examples/blocksub/main.go b/examples/blocksub/main.go index 9c75341..c64f89e 100644 --- a/examples/blocksub/main.go +++ b/examples/blocksub/main.go @@ -3,10 +3,9 @@ package main import ( "context" "io" + "log/slog" "os" - "golang.org/x/exp/slog" - "github.com/ethereum/go-ethereum/log" "github.com/flashbots/go-utils/blocksub" ) diff --git a/examples/httplogger/main.go b/examples/httplogger/main.go index 8f67a9f..6aa40f9 100644 --- a/examples/httplogger/main.go +++ b/examples/httplogger/main.go @@ -12,9 +12,7 @@ import ( "go.uber.org/zap" ) -var ( - listenAddr = "localhost:8124" -) +var listenAddr = "localhost:8124" func HelloHandler(w http.ResponseWriter, r *http.Request) { w.Write([]byte("Hello, World!")) diff --git a/examples/rpcserver/README.md b/examples/rpcserver/README.md new file mode 100644 index 0000000..99d61b8 --- /dev/null +++ b/examples/rpcserver/README.md @@ -0,0 +1,29 @@ +Example RPC Server usage. + +- Implement a simple RPC server +- Handle requests with different processing times and gc-heavy operations +- Use pprof for profiling +- Use [Vegeta](https://github.com/tsenart/vegeta) for load testing + +Getting started: + +```bash +cd examples/rpcserver + +# Run the RPC server +go run main.go + +# Example requests +curl 'http://localhost:8080' --header 'Content-Type: application/json' --data '{"jsonrpc":"2.0","method":"slow","params":[],"id":2}' + +# Using packaged payloads +curl 'http://localhost:8080' --header 'Content-Type: application/json' --data "@rpc-payload-fast.json" +curl 'http://localhost:8080' --header 'Content-Type: application/json' --data "@rpc-payload-slow.json" + +# Load testing with Vegeta +vegeta attack -rate=10000 -duration=60s -targets=targets.txt | tee results.bin | vegeta report + +# Grab pprof profiles +go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30 +go tool pprof http://localhost:6060/debug/pprof/heap +``` diff --git a/examples/rpcserver/main.go b/examples/rpcserver/main.go new file mode 100644 index 0000000..18dcb7a --- /dev/null +++ b/examples/rpcserver/main.go @@ -0,0 +1,116 @@ +package main + +// +// This example demonstrates how to use the rpcserver package to create a simple JSON-RPC server. +// +// It includes profiling test handlers, inspired by https://goperf.dev/02-networking/bench-and-load +// + +import ( + "context" + "flag" + "fmt" + "log/slog" + "math/rand/v2" + "net/http" + "os" + "time" + + _ "net/http/pprof" + + "github.com/flashbots/go-utils/rpcserver" +) + +var ( + // Servers + listenAddr = "localhost:8080" + pprofAddr = "localhost:6060" + + // Logger for the server + log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) + + // Profiling utilities + fastDelay = flag.Duration("fast-delay", 0, "Fixed delay for fast handler (if any)") + slowMin = flag.Duration("slow-min", 1*time.Millisecond, "Minimum delay for slow handler") + slowMax = flag.Duration("slow-max", 300*time.Millisecond, "Maximum delay for slow handler") + gcMinAlloc = flag.Int("gc-min-alloc", 50, "Minimum number of allocations in GC heavy handler") + gcMaxAlloc = flag.Int("gc-max-alloc", 1000, "Maximum number of allocations in GC heavy handler") + longLivedData [][]byte +) + +func main() { + handler, err := rpcserver.NewJSONRPCHandler( + rpcserver.Methods{ + "test_foo": rpcHandlerTestFoo, + "fast": rpcHandlerFast, + "slow": rpcHandlerSlow, + "gc": rpcHandlerGCHeavy, + }, + rpcserver.JSONRPCHandlerOpts{ + Log: log, + ServerName: "public_server", + GetResponseContent: []byte("static GET content hurray \\o/\n"), + }, + ) + if err != nil { + panic(err) + } + + // Start separate pprof server + go startPprofServer() + + // API server + server := &http.Server{ + Addr: listenAddr, + Handler: handler, + } + fmt.Println("Starting server.", "listenAddr:", listenAddr) + if err := server.ListenAndServe(); err != nil { + panic(err) + } +} + +func startPprofServer() { + fmt.Println("Starting pprof server.", "pprofAddr:", pprofAddr) + if err := http.ListenAndServe(pprofAddr, nil); err != nil { + fmt.Println("Error starting pprof server:", err) + } +} + +func randRange(min, max int) int { + return rand.IntN(max-min) + min +} + +func rpcHandlerTestFoo(ctx context.Context) (string, error) { + return "foo", nil +} + +func rpcHandlerFast(ctx context.Context) (string, error) { + if *fastDelay > 0 { + time.Sleep(*fastDelay) + } + + return "fast response", nil +} + +func rpcHandlerSlow(ctx context.Context) (string, error) { + delayRange := int((*slowMax - *slowMin) / time.Millisecond) + delay := time.Duration(randRange(1, delayRange)) * time.Millisecond + time.Sleep(delay) + + return fmt.Sprintf("slow response with delay %d ms", delay.Milliseconds()), nil +} + +func rpcHandlerGCHeavy(ctx context.Context) (string, error) { + numAllocs := randRange(*gcMinAlloc, *gcMaxAlloc) + var data [][]byte + for i := 0; i < numAllocs; i++ { + // Allocate 10KB slices. Occasionally retain a reference to simulate long-lived objects. + b := make([]byte, 1024*10) + data = append(data, b) + if i%100 == 0 { // every 100 allocations, keep the data alive + longLivedData = append(longLivedData, b) + } + } + return fmt.Sprintf("allocated %d KB\n", len(data)*10), nil +} diff --git a/examples/rpcserver/rpc-payload-fast.json b/examples/rpcserver/rpc-payload-fast.json new file mode 100644 index 0000000..dde103f --- /dev/null +++ b/examples/rpcserver/rpc-payload-fast.json @@ -0,0 +1,6 @@ +{ + "jsonrpc": "2.0", + "method": "fast", + "params": [], + "id": 83 +} \ No newline at end of file diff --git a/examples/rpcserver/rpc-payload-slow.json b/examples/rpcserver/rpc-payload-slow.json new file mode 100644 index 0000000..24ab9fc --- /dev/null +++ b/examples/rpcserver/rpc-payload-slow.json @@ -0,0 +1,6 @@ +{ + "jsonrpc": "2.0", + "method": "slow", + "params": [], + "id": 83 +} \ No newline at end of file diff --git a/examples/rpcserver/targets.txt b/examples/rpcserver/targets.txt new file mode 100644 index 0000000..3aa5089 --- /dev/null +++ b/examples/rpcserver/targets.txt @@ -0,0 +1,3 @@ +POST http://localhost:8080 +Content-Type: application/json +@rpc-payload-slow.json \ No newline at end of file diff --git a/examples/send-multioperator-orderflow/main.go b/examples/send-multioperator-orderflow/main.go new file mode 100644 index 0000000..c495944 --- /dev/null +++ b/examples/send-multioperator-orderflow/main.go @@ -0,0 +1,89 @@ +package main + +// This example demonstrates sending a signed eth_sendRawTransaction request to a +// multioperator builder node with a specific server certificate. + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net/http" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/flashbots/go-utils/rpcclient" + "github.com/flashbots/go-utils/rpctypes" + "github.com/flashbots/go-utils/signature" +) + +var ( + // Builder node nodeEndpoint and certificate + nodeEndpoint = "https://127.0.0.1:443" + nodeCertPEM = []byte("-----BEGIN CERTIFICATE-----\nMIIBlTCCATugAwIBAgIQeUQhWmrcFUOKnA/HpBPdODAKBggqhkjOPQQDAjAPMQ0w\nCwYDVQQKEwRBY21lMB4XDTI0MTExNDEyMTExM1oXDTI1MTExNDEyMTExM1owDzEN\nMAsGA1UEChMEQWNtZTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJCl4R+DtNqu\nyPYd8a+Ppd4lSIEgKcyGz3Q6HOnZV3D96oxW03e92FBdKUkl5DLxTYo+837u44XL\n11OWmajjKzGjeTB3MA4GA1UdDwEB/wQEAwIChDATBgNVHSUEDDAKBggrBgEFBQcD\nATAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTjt0S4lYkceJnonMJBEvwjezh3\nvDAgBgNVHREEGTAXgglsb2NhbGhvc3SHBDSuK4SHBH8AAAEwCgYIKoZIzj0EAwID\nSAAwRQIgOzm8ghnR4cKiE76siQ43Q4H2RzoJUmww3NyRVFkcp6oCIQDFZmuI+2tK\n1WlX3whjllaqr33K7kAa9ntihWfo+VB9zg==\n-----END CERTIFICATE-----\n") + ignoreNodeCert = false // if set to true, the client will ignore the server certificate and connect to the endpoint without verifying it + + // Transaction and signing key + rawTxHex = "0x02f8710183195414808503a1e38a30825208947804a60641a89c9c3a31ab5abea2a18c2b6b48408788c225841b2a9f80c080a0df68a9664190a59005ab6d6cc6b8e5a1e25604f546c36da0fd26ddd44d8f7d50a05b1bcfab22a3017cabb305884d081171e0f23340ae2a13c04eb3b0dd720a0552" + signerPrivateKey = "0xaccc869c5c3cb397e4833d41b138d3528af6cc5ff4808bb85a1c2ce1c8f04007" +) + +func createTransportForSelfSignedCert(certPEM []byte) (*http.Transport, error) { + certPool := x509.NewCertPool() + if ok := certPool.AppendCertsFromPEM(certPEM); !ok { + return nil, errors.New("failed to add certifcate to pool") + } + return &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: certPool, + MinVersion: tls.VersionTLS12, + }, + }, nil +} + +func exampleSendRawTx() (err error) { + // Create a transport that verifies (or ignores) the server certificate + var transport *http.Transport + if ignoreNodeCert { + transport = &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + } else { + transport, err = createTransportForSelfSignedCert(nodeCertPEM) + if err != nil { + return err + } + } + + // Prepare the request signer + requestSigner, err := signature.NewSignerFromHexPrivateKey(signerPrivateKey) + if err != nil { + return err + } + + // Setup the RPC client + client := rpcclient.NewClientWithOpts(nodeEndpoint, &rpcclient.RPCClientOpts{ + HTTPClient: &http.Client{ + Transport: transport, + }, + Signer: requestSigner, + }) + + // Execute the eth_sendRawTransaction request + rawTransaction := hexutil.MustDecode(rawTxHex) + resp, err := client.Call(context.Background(), "eth_sendRawTransaction", rpctypes.EthSendRawTransactionArgs(rawTransaction)) + if err != nil { + return err + } + if resp != nil && resp.Error != nil { + return fmt.Errorf("rpc error: %s", resp.Error.Error()) + } + return nil +} + +func main() { + err := exampleSendRawTx() + if err != nil { + panic(err) + } +} diff --git a/examples/tls-server/main.go b/examples/tls-server/main.go new file mode 100644 index 0000000..7f48800 --- /dev/null +++ b/examples/tls-server/main.go @@ -0,0 +1,66 @@ +package main + +// +// This example demonstrates how to create a TLS certificate and key and serve it on a port. +// +// The certificate can be required by curl like this: +// +// curl --cacert cert.pem https://localhost:4433 +// + +import ( + "crypto/tls" + "fmt" + "net/http" + "os" + "time" + + utils_tls "github.com/flashbots/go-utils/tls" +) + +// Configuration +const listenAddr = ":4433" +const certPath = "cert.pem" + +func main() { + cert, key, err := utils_tls.GenerateTLS(time.Hour*24*265, []string{"localhost"}) + if err != nil { + panic(err) + } + fmt.Println("Generated TLS certificate and key:") + fmt.Println(string(cert)) + + // write cert to file + err = os.WriteFile(certPath, cert, 0644) + if err != nil { + panic(err) + } + fmt.Println("Wrote certificate to", certPath) + + certificate, err := tls.X509KeyPair(cert, key) + if err != nil { + panic(err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + // write certificate to response + _, _ = w.Write(cert) + }) + + srv := &http.Server{ + Addr: listenAddr, + Handler: mux, + ReadHeaderTimeout: time.Second, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS13, + PreferServerCipherSuites: true, + }, + } + + fmt.Println("Starting HTTPS server", "addr", listenAddr) + if err := srv.ListenAndServeTLS("", ""); err != nil { + panic(err) + } +} diff --git a/go.mod b/go.mod index 34f7a53..479aee9 100644 --- a/go.mod +++ b/go.mod @@ -1,44 +1,47 @@ module github.com/flashbots/go-utils -go 1.20 +go 1.24.0 require ( - github.com/ethereum/go-ethereum v1.13.14 + github.com/VictoriaMetrics/metrics v1.35.1 + github.com/ethereum/go-ethereum v1.15.5 + github.com/goccy/go-json v0.10.5 github.com/google/uuid v1.3.1 github.com/sirupsen/logrus v1.9.3 - github.com/stretchr/testify v1.8.4 + github.com/stretchr/testify v1.10.0 go.uber.org/atomic v1.11.0 go.uber.org/zap v1.25.0 + golang.org/x/crypto v0.32.0 ) require ( - github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/StackExchange/wmi v1.2.1 // indirect - github.com/bits-and-blooms/bitset v1.10.0 // indirect - github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect - github.com/consensys/bavard v0.1.13 // indirect - github.com/consensys/gnark-crypto v0.12.1 // indirect - github.com/crate-crypto/go-kzg-4844 v0.7.0 // indirect + github.com/bits-and-blooms/bitset v1.17.0 // indirect + github.com/consensys/bavard v0.1.22 // indirect + github.com/consensys/gnark-crypto v0.14.0 // indirect + github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect + github.com/crate-crypto/go-kzg-4844 v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/deckarep/golang-set/v2 v2.1.0 // indirect + github.com/deckarep/golang-set/v2 v2.6.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect - github.com/ethereum/c-kzg-4844 v0.4.0 // indirect + github.com/ethereum/c-kzg-4844 v1.0.0 // indirect + github.com/ethereum/go-verkle v0.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/gorilla/websocket v1.4.2 // indirect - github.com/holiman/uint256 v1.2.4 // indirect + github.com/holiman/uint256 v1.3.2 // indirect github.com/mmcloughlin/addchain v0.4.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible // indirect - github.com/supranational/blst v0.3.11 // indirect + github.com/supranational/blst v0.3.14 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect + github.com/valyala/fastrand v1.1.0 // indirect + github.com/valyala/histogram v1.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.17.0 // indirect golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect - golang.org/x/mod v0.14.0 // indirect - golang.org/x/sync v0.5.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/tools v0.15.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect rsc.io/tmplfunc v0.0.3 // indirect ) diff --git a/go.sum b/go.sum index a9916b7..4dbd046 100644 --- a/go.sum +++ b/go.sum @@ -1,143 +1,207 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= -github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= -github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA= github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8= -github.com/VictoriaMetrics/fastcache v1.12.1 h1:i0mICQuojGDL3KblA7wUNlY5lOK6a4bwt3uRKnkZU40= +github.com/VictoriaMetrics/fastcache v1.12.2 h1:N0y9ASrJ0F6h0QaC3o6uJb3NIZ9VKLjCM7NQbSmF7WI= +github.com/VictoriaMetrics/fastcache v1.12.2/go.mod h1:AmC+Nzz1+3G2eCPapF6UcsnkThDcMsQicp4xDukwJYI= +github.com/VictoriaMetrics/metrics v1.35.1 h1:o84wtBKQbzLdDy14XeskkCZih6anG+veZ1SwJHFGwrU= +github.com/VictoriaMetrics/metrics v1.35.1/go.mod h1:r7hveu6xMdUACXvB8TYdAj8WEsKzWB0EkpJN+RDtOf8= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= +github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= -github.com/bits-and-blooms/bitset v1.10.0 h1:ePXTeiPEazB5+opbv5fr8umg2R/1NlzgDsyepwsSr88= -github.com/bits-and-blooms/bitset v1.10.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= -github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= -github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= -github.com/btcsuite/btcd/chaincfg/chainhash v1.0.1 h1:q0rUy8C/TYNBQS1+CGKw68tLOFYSNEs0TFnxxnS9+4U= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cockroachdb/errors v1.8.1 h1:A5+txlVZfOqFBDa4mGz2bUWSp0aHElvHX2bKkdbQu+Y= -github.com/cockroachdb/logtags v0.0.0-20190617123548-eb05cc24525f h1:o/kfcElHqOiXqcou5a3rIlMc7oJbMQkeLk0VQJ7zgqY= -github.com/cockroachdb/pebble v0.0.0-20230928194634-aa077af62593 h1:aPEJyR4rPBvDmeyi+l/FS/VtA00IWvjeFvjen1m1l1A= -github.com/cockroachdb/redact v1.0.8 h1:8QG/764wK+vmEYoOlfobpe12EQcS81ukx/a4hdVMxNw= -github.com/cockroachdb/sentry-go v0.6.1-cockroachdb.2 h1:IKgmqgMQlVJIZj19CdocBeSfSaiCbEBZGKODaixqtHM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bits-and-blooms/bitset v1.17.0 h1:1X2TS7aHz1ELcC0yU1y2stUs/0ig5oMU6STFZGrhvHI= +github.com/bits-and-blooms/bitset v1.17.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/errors v1.11.3 h1:5bA+k2Y6r+oz/6Z/RFlNeVCesGARKuC6YymtcDrbC/I= +github.com/cockroachdb/errors v1.11.3/go.mod h1:m4UIW4CDjx+R5cybPsNrRbreomiFqt8o1h1wUVazSd8= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce h1:giXvy4KSc/6g/esnpM7Geqxka4WSqI1SZc7sMJFd3y4= +github.com/cockroachdb/fifo v0.0.0-20240606204812-0bbfbd93a7ce/go.mod h1:9/y3cnZ5GKakj/H4y9r9GTjCvAFta7KLgSHPJJYc52M= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b h1:r6VH0faHjZeQy818SGhaone5OnYfxFR/+AzdY3sf5aE= +github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b/go.mod h1:Vz9DsVWQQhf3vs21MhPMZpMGSht7O/2vFW2xusFUVOs= +github.com/cockroachdb/pebble v1.1.2 h1:CUh2IPtR4swHlEj48Rhfzw6l/d0qA31fItcIszQVIsA= +github.com/cockroachdb/pebble v1.1.2/go.mod h1:4exszw1r40423ZsmkG/09AFEG83I0uDgfujJdbL6kYU= +github.com/cockroachdb/redact v1.1.5 h1:u1PMllDkdFfPWaNGMyLD1+so+aq3uUItthCFqzwPJ30= +github.com/cockroachdb/redact v1.1.5/go.mod h1:BVNblN9mBWFyMyqK1k3AAiSxhvhfK2oOZZ2lK+dpvRg= github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06 h1:zuQyyAKVxetITBuuhv3BI9cMrmStnpT18zmgmTxunpo= -github.com/consensys/bavard v0.1.13 h1:oLhMLOFGTLdlda/kma4VOJazblc7IM5y5QPd2A/YjhQ= -github.com/consensys/bavard v0.1.13/go.mod h1:9ItSMtA/dXMAiL7BG6bqW2m3NdSEObYWoH223nGHukI= -github.com/consensys/gnark-crypto v0.12.1 h1:lHH39WuuFgVHONRl3J0LRBtuYdQTumFSDtJF7HpyG8M= -github.com/consensys/gnark-crypto v0.12.1/go.mod h1:v2Gy7L/4ZRosZ7Ivs+9SfUDr0f5UlG+EM5t7MPHiLuY= -github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= -github.com/crate-crypto/go-ipa v0.0.0-20231025140028-3c0104f4b233 h1:d28BXYi+wUpz1KBmiF9bWrjEMacUEREV6MBi2ODnrfQ= -github.com/crate-crypto/go-kzg-4844 v0.7.0 h1:C0vgZRk4q4EZ/JgPfzuSoxdCq3C3mOZMBShovmncxvA= -github.com/crate-crypto/go-kzg-4844 v0.7.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc= +github.com/cockroachdb/tokenbucket v0.0.0-20230807174530-cc333fc44b06/go.mod h1:7nc4anLGjupUW/PeY5qiNYsdNXj7zopG+eqsS7To5IQ= +github.com/consensys/bavard v0.1.22 h1:Uw2CGvbXSZWhqK59X0VG/zOjpTFuOMcPLStrp1ihI0A= +github.com/consensys/bavard v0.1.22/go.mod h1:k/zVjHHC4B+PQy1Pg7fgvG3ALicQw540Crag8qx+dZs= +github.com/consensys/gnark-crypto v0.14.0 h1:DDBdl4HaBtdQsq/wfMwJvZNE80sHidrK3Nfrefatm0E= +github.com/consensys/gnark-crypto v0.14.0/go.mod h1:CU4UijNPsHawiVGNxe9co07FkzCeWHHrb1li/n1XoU0= +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a h1:W8mUrRp6NOVl3J+MYp5kPMoUZPp7aOYHtaua31lwRHg= +github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a/go.mod h1:sTwzHBvIzm2RfVCGNEBZgRyjwK40bVoun3ZnGOCafNM= +github.com/crate-crypto/go-kzg-4844 v1.1.0 h1:EN/u9k2TF6OWSHrCCDBBU6GLNMq88OspHHlMnHfoyU4= +github.com/crate-crypto/go-kzg-4844 v1.1.0/go.mod h1:JolLjpSff1tCCJKaJx4psrlEdlXuJEC996PL3tTAFks= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/deckarep/golang-set/v2 v2.1.0 h1:g47V4Or+DUdzbs8FxCCmgb6VYd+ptPAngjM6dtGktsI= -github.com/deckarep/golang-set/v2 v2.1.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= +github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= +github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/ethereum/c-kzg-4844 v0.4.0 h1:3MS1s4JtA868KpJxroZoepdV0ZKBp3u/O5HcZ7R3nlY= -github.com/ethereum/c-kzg-4844 v0.4.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= -github.com/ethereum/go-ethereum v1.13.14 h1:EwiY3FZP94derMCIam1iW4HFVrSgIcpsu0HwTQtm6CQ= -github.com/ethereum/go-ethereum v1.13.14/go.mod h1:TN8ZiHrdJwSe8Cb6x+p0hs5CxhJZPbqB7hHkaUXcmIU= -github.com/fjl/memsize v0.0.2 h1:27txuSD9or+NZlnOWdKUxeBzTAUkWCVh+4Gf2dWFOzA= -github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= -github.com/gballet/go-libpcsclite v0.0.0-20190607065134-2772fd86a8ff h1:tY80oXqGNY4FhTFhk+o9oFHGINQ/+vhlm8HFzi6znCI= -github.com/gballet/go-verkle v0.1.1-0.20231031103413-a67434b50f46 h1:BAIP2GihuqhwdILrV+7GJel5lyPV3u1+PgzrWLc0TkE= +github.com/ethereum/c-kzg-4844 v1.0.0 h1:0X1LBXxaEtYD9xsyj9B9ctQEZIpnvVDeoBx8aHEwTNA= +github.com/ethereum/c-kzg-4844 v1.0.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= +github.com/ethereum/go-ethereum v1.15.5 h1:Fo2TbBWC61lWVkFw9tsMoHCNX1ndpuaQBRJ8H6xLUPo= +github.com/ethereum/go-ethereum v1.15.5/go.mod h1:1LG2LnMOx2yPRHR/S+xuipXH29vPr6BIH6GElD8N/fo= +github.com/ethereum/go-verkle v0.2.2 h1:I2W0WjnrFUIzzVPwm8ykY+7pL2d4VhlsePn4j7cnFk8= +github.com/ethereum/go-verkle v0.2.2/go.mod h1:M3b90YRnzqKyyzBEWJGqj8Qff4IDeXnzFw0P9bFw3uk= +github.com/getsentry/sentry-go v0.27.0 h1:Pv98CIbtB3LkMWmXi4Joa5OOcwbmnX88sF5qbK3r3Ps= +github.com/getsentry/sentry-go v0.27.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo= +github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= +github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/go-bexpr v0.1.10 h1:9kuI5PFotCboP3dkDYFr/wi0gg0QVbSNz5oFRpxn4uE= +github.com/hashicorp/go-bexpr v0.1.10/go.mod h1:oxlubA2vC/gFVfX1A6JGp7ls7uCDlfJn732ehYYg+g0= github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4 h1:X4egAf/gcS1zATw6wn4Ej8vjuVGxeHdan+bRb2ebyv4= +github.com/holiman/billy v0.0.0-20240216141850-2abb0c79d3c4/go.mod h1:5GuXa7vkL8u9FkFuWdVvfR5ix8hRB7DbOAaYULamFpc= github.com/holiman/bloomfilter/v2 v2.0.3 h1:73e0e/V0tCydx14a0SCYS/EWCxgwLZ18CZcZKVu0fao= -github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU= -github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/holiman/bloomfilter/v2 v2.0.3/go.mod h1:zpoh+gs7qcpqrHr3dB55AMiJwo0iURXE7ZOP9L9hSkA= +github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= +github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= +github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= -github.com/klauspost/compress v1.15.15 h1:EF27CXIuDsYJ6mmvtBRlEuB2UVOqHG1tAXgZ7yIO+lw= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= +github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4= +github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= -github.com/leanovate/gopter v0.2.9 h1:fQjYxZaynp97ozCzfOyOuAGOU4aU/z37zf/tOujFk7c= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leanovate/gopter v0.2.11 h1:vRjThO1EKPb/1NsDXuDrzldR28RLkBflWYcU9CvzWu4= +github.com/leanovate/gopter v0.2.11/go.mod h1:aK3tzZP/C+p1m3SPRE4SYZFGP7jjkuSI4f7Xvpt0S9c= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 h1:I0XW9+e1XWDxdcEniV4rQAIOPUGDq67JSCiRCgGCZLI= +github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/pointerstructure v1.2.0 h1:O+i9nHnXS3l/9Wu7r4NrEdwA2VFTicjUEN1uBnDo34A= +github.com/mitchellh/pointerstructure v1.2.0/go.mod h1:BRAsLI5zgXmw97Lf6s25bs8ohIXc3tViBH44KcwB2g4= github.com/mmcloughlin/addchain v0.4.0 h1:SobOdjm2xLj1KkXN5/n0xTIWyZA2+s99UCY1iPfkHRY= github.com/mmcloughlin/addchain v0.4.0/go.mod h1:A86O+tHqZLMNO4w6ZZ4FlVQEadcoqkyU72HC5wJ4RlU= github.com/mmcloughlin/profile v0.1.1/go.mod h1:IhHD7q1ooxgwTgjxQYkACGA77oFTDdFVejUS1/tS/qU= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/pion/dtls/v2 v2.2.7 h1:cSUBsETxepsCSFSxC3mc/aDo14qQLMSL+O6IjG28yV8= +github.com/pion/dtls/v2 v2.2.7/go.mod h1:8WiMkebSHFD0T+dIU+UeBaoV7kDhOW5oDCzZ7WZ/F9s= +github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= +github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= +github.com/pion/stun/v2 v2.0.0 h1:A5+wXKLAypxQri59+tmQKVs7+l6mMM+3d+eER9ifRU0= +github.com/pion/stun/v2 v2.0.0/go.mod h1:22qRSh08fSEttYUmJZGlriq9+03jtVmXNODgLccj8GQ= +github.com/pion/transport/v2 v2.2.1 h1:7qYnCBlpgSJNYMbLCKuSY9KbQdBFoETvPNETv0y4N7c= +github.com/pion/transport/v2 v2.2.1/go.mod h1:cXXWavvCnFF6McHTft3DWS9iic2Mftcz1Aq29pGcU5g= +github.com/pion/transport/v3 v3.0.1 h1:gDTlPJwROfSfz6QfSi0ZmeCSkFcnWWiiR9ES0ouANiM= +github.com/pion/transport/v3 v3.0.1/go.mod h1:UY7kiITrlMv7/IKgd5eTUcaahZx5oUN3l9SzK5f5xE0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_golang v1.12.0 h1:C+UIj/QWtmqY13Arb8kwMt5j34/0Z2iKamrJ+ryC0Gg= +github.com/prometheus/client_golang v1.12.0/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a h1:CmF68hwI0XsOQ5UwlBopMi2Ow4Pbg32akc4KIVCOm+Y= +github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= github.com/prometheus/common v0.32.1 h1:hWIdL3N2HoUx3B8j3YN9mWor0qhY/NlEKZEaXxuIRh4= +github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= +github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= github.com/rs/cors v1.7.0 h1:+88SsELBHx5r+hZ8TCkggzSstaWNbDvThkVK8H6f9ik= +github.com/rs/cors v1.7.0/go.mod h1:gFx+x8UowdsKA9AchylcLynDq+nNFfI8FkUZdN/jGCU= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible h1:Bn1aCHHRnjv4Bl16T8rcaFjYSrGrIZvpiGO6P3Q4GpU= github.com/shirou/gopsutil v3.21.4-0.20210419000835-c7a38de76ee5+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/status-im/keycard-go v0.2.0 h1:QDLFswOQu1r5jsycloeQh3bVU8n/NatHHaZobtDnDzA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/supranational/blst v0.3.11 h1:LyU6FolezeWAhvQk0k6O/d49jqgO52MSDDfYgbeoEm4= -github.com/supranational/blst v0.3.11/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/supranational/blst v0.3.14 h1:xNMoHRJOTwMn63ip6qoWJ2Ymgvj7E2b9jY2FAwY+qRo= +github.com/supranational/blst v0.3.14/go.mod h1:jZJtfjgudtNl4en1tzwPIV3KjUnQUvG3/j+w+fVonLw= github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7 h1:epCh84lMvA70Z7CTTCmYQn2CKbY8j86K7/FAIr141uY= +github.com/syndtr/goleveldb v1.0.1-0.20210819022825-2ae1ddf74ef7/go.mod h1:q4W45IWZaF22tdD+VEXcAWRA037jwmWEB5VWYORlTpc= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= -github.com/tyler-smith/go-bip39 v1.1.0 h1:5eUemwrMargf3BSLRRCalXT93Ns6pQJIjYQN2nyfOP8= -github.com/urfave/cli/v2 v2.25.7 h1:VAzn5oq403l5pHjc4OhD54+XGO9cdKVL/7lDjF+iKUs= -github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 h1:bAn7/zixMGCfxrRTfdpNzjtPYqr8smhKouy9mxVdGPU= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8= +github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ= +github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ= +github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.25.0 h1:4Hvk6GtkucQ790dqmj7l1eEnRdKm3k3ZUrUMS2d5+5c= go.uber.org/zap v1.25.0/go.mod h1:JIAUzQIH94IC4fOJQm7gMmBJP5k7wQfdcnYdPoEXJYk= -golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= -golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/sync v0.5.0 h1:60k92dhOjHxJkrqnwsfl8KuaHbn/5dl0lUPUklKo3qE= -golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/tools v0.15.0 h1:zdAyfUGbYmuVokhzVmghFl2ZJh5QhcfebBgmVPFYA+8= -golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= -google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= -gopkg.in/natefinch/lumberjack.v2 v2.0.0 h1:1Lc07Kr7qY4U2YPouBjpCLxpiyxIVoxqXgkXLknAOE8= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/httplogger/httplogger.go b/httplogger/httplogger.go index ca4e4c8..593678b 100644 --- a/httplogger/httplogger.go +++ b/httplogger/httplogger.go @@ -106,6 +106,7 @@ func LoggingMiddlewareSlog(logger *slog.Logger, next http.Handler) http.Handler "method", r.Method, "path", r.URL.EscapedPath(), "duration", fmt.Sprintf("%f", time.Since(start).Seconds()), + "durationUs", fmt.Sprint(time.Since(start).Microseconds()), ) }, ) diff --git a/jsonrpc/mockserver.go b/jsonrpc/mockserver.go index b195dee..2cfae4a 100644 --- a/jsonrpc/mockserver.go +++ b/jsonrpc/mockserver.go @@ -1,12 +1,13 @@ package jsonrpc import ( - "encoding/json" "fmt" "net/http" "net/http/httptest" "sync" + "github.com/goccy/go-json" + "github.com/ethereum/go-ethereum/log" ) @@ -84,7 +85,7 @@ func (s *MockJSONRPCServer) handleHTTPRequest(w http.ResponseWriter, req *http.R } func (s *MockJSONRPCServer) IncrementRequestCounter(method string) { - var newCount = 0 + newCount := 0 currentCount, ok := s.RequestCounter.Load(method) if ok { newCount = currentCount.(int) diff --git a/jsonrpc/request.go b/jsonrpc/request.go index 01bfa14..4ce08f8 100644 --- a/jsonrpc/request.go +++ b/jsonrpc/request.go @@ -3,9 +3,10 @@ package jsonrpc import ( "bytes" - "encoding/json" "errors" "net/http" + + "github.com/goccy/go-json" ) type JSONRPCRequest struct { diff --git a/jsonrpc/request_test.go b/jsonrpc/request_test.go index cc3de8e..f5d827d 100644 --- a/jsonrpc/request_test.go +++ b/jsonrpc/request_test.go @@ -1,9 +1,10 @@ package jsonrpc import ( - "encoding/json" "testing" + "github.com/goccy/go-json" + "github.com/stretchr/testify/assert" ) diff --git a/jsonrpc/response.go b/jsonrpc/response.go index ce40006..c3c58b8 100644 --- a/jsonrpc/response.go +++ b/jsonrpc/response.go @@ -2,8 +2,9 @@ package jsonrpc import ( - "encoding/json" "fmt" + + "github.com/goccy/go-json" ) // As per JSON-RPC 2.0 Specification diff --git a/rpcclient/client.go b/rpcclient/client.go new file mode 100644 index 0000000..1a86ab8 --- /dev/null +++ b/rpcclient/client.go @@ -0,0 +1,659 @@ +// Package rpcclient is used to do jsonrpc calls with Flashbots request signatures (X-Flashbots-Signature header) +// +// Implemenation and interface is a slightly modified copy of https://github.com/ybbus/jsonrpc +// The differences are: +// * we handle case when Flashbots API returns errors incorrectly according to jsonrpc protocol (backwards compatibility) +// * we don't support object params in the Call API. When you do Call with one object we set params to be [object] instead of object +// * we can sign request body with ecdsa +package rpcclient + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "strconv" + + "github.com/goccy/go-json" + + "github.com/flashbots/go-utils/signature" +) + +const ( + jsonrpcVersion = "2.0" +) + +// RPCClient sends JSON-RPC requests over HTTP to the provided JSON-RPC backend. +// +// RPCClient is created using the factory function NewClient(). +type RPCClient interface { + // Call is used to send a JSON-RPC request to the server endpoint. + // + // The spec states, that params can only be an array or an object, no primitive values. + // We don't support object params in call interface and we always wrap params into array. + // Use NewRequestWithObjectParam to create a request with object param. + // + // 1. no params: params field is omitted. e.g. Call(ctx, "getinfo") + // + // 2. single params primitive value: value is wrapped in array. e.g. Call(ctx, "getByID", 1423) + // + // 3. single params value array: array value is wrapped into array (use CallRaw to pass array to params without wrapping). e.g. Call(ctx, "storePerson", []*Person{&Person{Name: "Alex"}}) + // + // 4. single object or multiple params values: always wrapped in array. e.g. Call(ctx, "setDetails", "Alex, 35, "Germany", true) + // + // Examples: + // Call(ctx, "getinfo") -> {"method": "getinfo"} + // Call(ctx, "getPersonId", 123) -> {"method": "getPersonId", "params": [123]} + // Call(ctx, "setName", "Alex") -> {"method": "setName", "params": ["Alex"]} + // Call(ctx, "setMale", true) -> {"method": "setMale", "params": [true]} + // Call(ctx, "setNumbers", []int{1, 2, 3}) -> {"method": "setNumbers", "params": [[1, 2, 3]]} + // Call(ctx, "setNumbers", []int{1, 2, 3}...) -> {"method": "setNumbers", "params": [1, 2, 3]} + // Call(ctx, "setNumbers", 1, 2, 3) -> {"method": "setNumbers", "params": [1, 2, 3]} + // Call(ctx, "savePerson", &Person{Name: "Alex", Age: 35}) -> {"method": "savePerson", "params": [{"name": "Alex", "age": 35}]} + // Call(ctx, "setPersonDetails", "Alex", 35, "Germany") -> {"method": "setPersonDetails", "params": ["Alex", 35, "Germany"}} + // + // for more information, see the examples or the unit tests + Call(ctx context.Context, method string, params ...any) (*RPCResponse, error) + + // CallRaw is like Call() but without magic in the requests.Params field. + // The RPCRequest object is sent exactly as you provide it. + // See docs: NewRequest, RPCRequest + // + // It is recommended to first consider Call() and CallFor() + CallRaw(ctx context.Context, request *RPCRequest) (*RPCResponse, error) + + // CallFor is a very handy function to send a JSON-RPC request to the server endpoint + // and directly specify an object to store the response. + // + // out: will store the unmarshaled object, if request was successful. + // should always be provided by references. can be nil even on success. + // the behaviour is the same as expected from json.Unmarshal() + // + // method and params: see Call() function + // + // if the request was not successful (network, http error) or the rpc response returns an error, + // an error is returned. if it was an JSON-RPC error it can be casted + // to *RPCError. + // + CallFor(ctx context.Context, out any, method string, params ...any) error + + // CallBatch invokes a list of RPCRequests in a single batch request. + // + // Most convenient is to use the following form: + // CallBatch(ctx, RPCRequests{ + // NewRequest("myMethod1", 1, 2, 3), + // NewRequest("myMethod2", "Test"), + // }) + // + // You can create the []*RPCRequest array yourself, but it is not recommended and you should notice the following: + // - field Params is sent as provided, so Params: 2 forms an invalid json (correct would be Params: []int{2}) + // - you can use the helper function Params(1, 2, 3) to use the same format as in Call() + // - field JSONRPC is overwritten and set to value: "2.0" + // - field ID is overwritten and set incrementally and maps to the array position (e.g. requests[5].ID == 5) + // + // + // Returns RPCResponses that is of type []*RPCResponse + // - note that a list of RPCResponses can be received unordered so it can happen that: responses[i] != responses[i].ID + // - RPCPersponses is enriched with helper functions e.g.: responses.HasError() returns true if one of the responses holds an RPCError + CallBatch(ctx context.Context, requests RPCRequests) (RPCResponses, error) + + // CallBatchRaw invokes a list of RPCRequests in a single batch request. + // It sends the RPCRequests parameter is it passed (no magic, no id autoincrement). + // + // Consider to use CallBatch() instead except you have some good reason not to. + // + // CallBatchRaw(ctx, RPCRequests{ + // &RPCRequest{ + // ID: 123, // this won't be replaced in CallBatchRaw + // JSONRPC: "wrong", // this won't be replaced in CallBatchRaw + // Method: "myMethod1", + // Params: []int{1}, // there is no magic, be sure to only use array or object + // }, + // }) + // + // Returns RPCResponses that is of type []*RPCResponse + // - note that a list of RPCResponses can be received unordered + // - the id's must be mapped against the id's you provided + // - RPCPersponses is enriched with helper functions e.g.: responses.HasError() returns true if one of the responses holds an RPCError + CallBatchRaw(ctx context.Context, requests RPCRequests) (RPCResponses, error) +} + +type dynamicHeadersCtxKey struct{} + +func CtxWithHeaders(ctx context.Context, headers map[string]string) context.Context { + ctx = context.WithValue(ctx, dynamicHeadersCtxKey{}, headers) + return ctx +} + +func DynamicHeadersFromCtx(ctx context.Context) map[string]string { + val, ok := ctx.Value(dynamicHeadersCtxKey{}).(map[string]string) + if !ok { + return nil + } + + return val +} + +// RPCRequest represents a JSON-RPC request object. +// +// Method: string containing the method to be invoked +// +// Params: can be nil. if not must be an json array or object +// +// ID: may always be set to 0 (default can be changed) for single requests. Should be unique for every request in one batch request. +// +// JSONRPC: must always be set to "2.0" for JSON-RPC version 2.0 +// +// See: http://www.jsonrpc.org/specification#request_object +// +// Most of the time you shouldn't create the RPCRequest object yourself. +// The following functions do that for you: +// Call(), CallFor(), NewRequest() +// +// If you want to create it yourself (e.g. in batch or CallRaw()) +// you can potentially create incorrect rpc requests: +// +// request := &RPCRequest{ +// Method: "myMethod", +// Params: 2, <-- invalid since a single primitive value must be wrapped in an array +// } +// +// correct: +// +// request := &RPCRequest{ +// Method: "myMethod", +// Params: []int{2}, +// } +type RPCRequest struct { + Method string `json:"method"` + Params any `json:"params,omitempty"` + ID int `json:"id"` + JSONRPC string `json:"jsonrpc"` +} + +// NewRequest returns a new RPCRequest that can be created using the same convenient parameter syntax as Call() +// +// Default RPCRequest id is 0. If you want to use an id other than 0, use NewRequestWithID() or set the ID field of the returned RPCRequest manually. +// +// e.g. NewRequest("myMethod", "Alex", 35, true) +func NewRequest(method string, params ...any) *RPCRequest { + return NewRequestWithID(0, method, params...) +} + +// NewRequestWithID returns a new RPCRequest that can be created using the same convenient parameter syntax as Call() +// +// e.g. NewRequestWithID(123, "myMethod", "Alex", 35, true) +func NewRequestWithID(id int, method string, params ...any) *RPCRequest { + // this code will omit "params" from the json output instead of having "params": null + var newParams any + if params != nil { + newParams = params + } + return NewRequestWithObjectParam(id, method, newParams) +} + +// NewRequestWithObjectParam returns a new RPCRequest that uses param object without wrapping it into array +// +// e.g. NewRequestWithID(struct{}{}) -> {"params": {}} +func NewRequestWithObjectParam(id int, method string, params any) *RPCRequest { + request := &RPCRequest{ + ID: id, + Method: method, + Params: params, + JSONRPC: jsonrpcVersion, + } + + return request +} + +// RPCResponse represents a JSON-RPC response object. +// +// Result: holds the result of the rpc call if no error occurred, nil otherwise. can be nil even on success. +// +// Error: holds an RPCError object if an error occurred. must be nil on success. +// +// ID: may always be 0 for single requests. is unique for each request in a batch call (see CallBatch()) +// +// JSONRPC: must always be set to "2.0" for JSON-RPC version 2.0 +// +// See: http://www.jsonrpc.org/specification#response_object +type RPCResponse struct { + JSONRPC string `json:"jsonrpc"` + Result any `json:"result,omitempty"` + Error *RPCError `json:"error,omitempty"` + ID int `json:"id"` +} + +// RPCError represents a JSON-RPC error object if an RPC error occurred. +// +// Code holds the error code. +// +// Message holds a short error message. +// +// Data holds additional error data, may be nil. +// +// See: http://www.jsonrpc.org/specification#error_object +type RPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data any `json:"data,omitempty"` +} + +// Error function is provided to be used as error object. +func (e *RPCError) Error() string { + return strconv.Itoa(e.Code) + ": " + e.Message +} + +// HTTPError represents a error that occurred on HTTP level. +// +// An error of type HTTPError is returned when a HTTP error occurred (status code) +// and the body could not be parsed to a valid RPCResponse object that holds a RPCError. +// +// Otherwise a RPCResponse object is returned with a RPCError field that is not nil. +type HTTPError struct { + Code int + err error +} + +// Error function is provided to be used as error object. +func (e *HTTPError) Error() string { + return e.err.Error() +} + +type rpcClient struct { + log *slog.Logger + endpoint string + httpClient *http.Client + customHeaders map[string]string + allowUnknownFields bool + defaultRequestID int + signer *signature.Signer + rejectBrokenFlashbotsErrors bool +} + +// RPCClientOpts can be provided to NewClientWithOpts() to change configuration of RPCClient. +// +// HTTPClient: provide a custom http.Client (e.g. to set a proxy, or tls options) +// +// CustomHeaders: provide custom headers, e.g. to set BasicAuth +// +// AllowUnknownFields: allows the rpc response to contain fields that are not defined in the rpc response specification. +type RPCClientOpts struct { + Log *slog.Logger + HTTPClient *http.Client + CustomHeaders map[string]string + AllowUnknownFields bool + DefaultRequestID int + + // If Signer is set requset body will be signed and signature will be set in the X-Flashbots-Signature header + Signer *signature.Signer + // if true client will return error when server responds with errors like {"error": "text"} + // otherwise this response will be converted to equivalent {"error": {"message": "text", "code": FlashbotsBrokenErrorResponseCode}} + // Bad errors are always rejected for batch requests + RejectBrokenFlashbotsErrors bool +} + +// RPCResponses is of type []*RPCResponse. +// This type is used to provide helper functions on the result list. +type RPCResponses []*RPCResponse + +// AsMap returns the responses as map with response id as key. +func (res RPCResponses) AsMap() map[int]*RPCResponse { + resMap := make(map[int]*RPCResponse, 0) + for _, r := range res { + resMap[r.ID] = r + } + + return resMap +} + +// GetByID returns the response object of the given id, nil if it does not exist. +func (res RPCResponses) GetByID(id int) *RPCResponse { + for _, r := range res { + if r.ID == id { + return r + } + } + + return nil +} + +// HasError returns true if one of the response objects has Error field != nil. +func (res RPCResponses) HasError() bool { + for _, res := range res { + if res.Error != nil { + return true + } + } + return false +} + +// RPCRequests is of type []*RPCRequest. +// This type is used to provide helper functions on the request list. +type RPCRequests []*RPCRequest + +// NewClient returns a new RPCClient instance with default configuration. +// +// endpoint: JSON-RPC service URL to which JSON-RPC requests are sent. +func NewClient(endpoint string) RPCClient { + return NewClientWithOpts(endpoint, nil) +} + +// NewClientWithOpts returns a new RPCClient instance with custom configuration. +// +// endpoint: JSON-RPC service URL to which JSON-RPC requests are sent. +// +// opts: RPCClientOpts is used to provide custom configuration. +func NewClientWithOpts(endpoint string, opts *RPCClientOpts) RPCClient { + rpcClient := &rpcClient{ + endpoint: endpoint, + httpClient: &http.Client{}, + customHeaders: make(map[string]string), + } + + rpcClient.log = slog.Default() + if opts != nil && opts.Log != nil { + rpcClient.log = opts.Log + } + + if opts == nil { + return rpcClient + } + + if opts.HTTPClient != nil { + rpcClient.httpClient = opts.HTTPClient + } + + if opts.CustomHeaders != nil { + for k, v := range opts.CustomHeaders { + rpcClient.customHeaders[k] = v + } + } + + if opts.AllowUnknownFields { + rpcClient.allowUnknownFields = true + } + + rpcClient.defaultRequestID = opts.DefaultRequestID + rpcClient.signer = opts.Signer + rpcClient.rejectBrokenFlashbotsErrors = opts.RejectBrokenFlashbotsErrors + + return rpcClient +} + +func (client *rpcClient) Call(ctx context.Context, method string, params ...any) (*RPCResponse, error) { + request := NewRequestWithID(client.defaultRequestID, method, params...) + return client.doCall(ctx, request) +} + +func (client *rpcClient) CallRaw(ctx context.Context, request *RPCRequest) (*RPCResponse, error) { + return client.doCall(ctx, request) +} + +func (client *rpcClient) CallFor(ctx context.Context, out any, method string, params ...any) error { + rpcResponse, err := client.Call(ctx, method, params...) + if err != nil { + return err + } + + if rpcResponse.Error != nil { + return rpcResponse.Error + } + + return rpcResponse.GetObject(out) +} + +func (client *rpcClient) CallBatch(ctx context.Context, requests RPCRequests) (RPCResponses, error) { + if len(requests) == 0 { + return nil, errors.New("empty request list") + } + + for i, req := range requests { + req.ID = i + req.JSONRPC = jsonrpcVersion + } + + return client.doBatchCall(ctx, requests) +} + +func (client *rpcClient) CallBatchRaw(ctx context.Context, requests RPCRequests) (RPCResponses, error) { + if len(requests) == 0 { + return nil, errors.New("empty request list") + } + + return client.doBatchCall(ctx, requests) +} + +func (client *rpcClient) newRequest(ctx context.Context, req any) (*http.Request, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + + client.log.Debug("RPC Client debug mode newRequest: request body", slog.Any("body", string(body))) + + request, err := http.NewRequestWithContext(ctx, "POST", client.endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + + dynamicHeaders := DynamicHeadersFromCtx(ctx) + for k, v := range dynamicHeaders { + request.Header.Set(k, v) + } + + if client.signer != nil { + signatureHeader, err := client.signer.Create(body) + if err != nil { + return nil, err + } + request.Header.Set(signature.HTTPHeader, signatureHeader) + } + + // set default headers first, so that even content type and accept can be overwritten + for k, v := range client.customHeaders { + // check if header is "Host" since this will be set on the request struct itself + if k == "Host" { + request.Host = v + } else { + request.Header.Set(k, v) + } + } + + return request, nil +} + +func (client *rpcClient) doCall(ctx context.Context, RPCRequest *RPCRequest) (*RPCResponse, error) { + httpRequest, err := client.newRequest(ctx, RPCRequest) + if err != nil { + return nil, fmt.Errorf("rpc call %v() on %v: %w", RPCRequest.Method, client.endpoint, err) + } + + httpResponse, err := client.httpClient.Do(httpRequest) + if err != nil { + return nil, fmt.Errorf("rpc call %v() on %v: %w", RPCRequest.Method, httpRequest.URL.Redacted(), err) + } + defer httpResponse.Body.Close() + + body, err := io.ReadAll(httpResponse.Body) + if err != nil { + return nil, fmt.Errorf("rpc call %v() on %v: %w", RPCRequest.Method, httpRequest.URL.Redacted(), err) + } + + client.log.Debug("RPC Client debug mode doCall: response", slog.Any("responseBody", string(body)), slog.Any("respCode", httpResponse.StatusCode)) + + decodeJSONBody := func(v any) error { + decoder := json.NewDecoder(bytes.NewReader(body)) + if !client.allowUnknownFields { + decoder.DisallowUnknownFields() + } + decoder.UseNumber() + return decoder.Decode(v) + } + + var ( + rpcResponse *RPCResponse + ) + err = decodeJSONBody(&rpcResponse) + + // parsing error + if err != nil { + // if we have some http error, return it + if httpResponse.StatusCode >= 400 { + return nil, &HTTPError{ + Code: httpResponse.StatusCode, + err: fmt.Errorf("rpc call %v() on %v status code: %v. could not decode body to rpc response: %w", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode, err), + } + } + return nil, fmt.Errorf("rpc call %v() on %v status code: %v. could not decode body to rpc response: %w", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode, err) + } + + // response body empty + if rpcResponse == nil { + // if we have some http error, return it + if httpResponse.StatusCode >= 400 { + return nil, &HTTPError{ + Code: httpResponse.StatusCode, + err: fmt.Errorf("rpc call %v() on %v status code: %v. rpc response missing", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode), + } + } + return nil, fmt.Errorf("rpc call %v() on %v status code: %v. rpc response missing", RPCRequest.Method, httpRequest.URL.Redacted(), httpResponse.StatusCode) + } + + return rpcResponse, nil +} + +func (client *rpcClient) doBatchCall(ctx context.Context, rpcRequest []*RPCRequest) ([]*RPCResponse, error) { + httpRequest, err := client.newRequest(ctx, rpcRequest) + if err != nil { + return nil, fmt.Errorf("rpc batch call on %v: %w", client.endpoint, err) + } + httpResponse, err := client.httpClient.Do(httpRequest) + if err != nil { + return nil, fmt.Errorf("rpc batch call on %v: %w", httpRequest.URL.Redacted(), err) + } + defer httpResponse.Body.Close() + + var rpcResponses RPCResponses + decoder := json.NewDecoder(httpResponse.Body) + if !client.allowUnknownFields { + decoder.DisallowUnknownFields() + } + decoder.UseNumber() + err = decoder.Decode(&rpcResponses) + + // parsing error + if err != nil { + // if we have some http error, return it + if httpResponse.StatusCode >= 400 { + return nil, &HTTPError{ + Code: httpResponse.StatusCode, + err: fmt.Errorf("rpc batch call on %v status code: %v. could not decode body to rpc response: %w", httpRequest.URL.Redacted(), httpResponse.StatusCode, err), + } + } + return nil, fmt.Errorf("rpc batch call on %v status code: %v. could not decode body to rpc response: %w", httpRequest.URL.Redacted(), httpResponse.StatusCode, err) + } + + // response body empty + if len(rpcResponses) == 0 { + // if we have some http error, return it + if httpResponse.StatusCode >= 400 { + return nil, &HTTPError{ + Code: httpResponse.StatusCode, + err: fmt.Errorf("rpc batch call on %v status code: %v. rpc response missing", httpRequest.URL.Redacted(), httpResponse.StatusCode), + } + } + return nil, fmt.Errorf("rpc batch call on %v status code: %v. rpc response missing", httpRequest.URL.Redacted(), httpResponse.StatusCode) + } + + // if we have a response body, but also a http error, return both + if httpResponse.StatusCode >= 400 { + return rpcResponses, &HTTPError{ + Code: httpResponse.StatusCode, + err: fmt.Errorf("rpc batch call on %v status code: %v. check rpc responses for potential rpc error", httpRequest.URL.Redacted(), httpResponse.StatusCode), + } + } + + return rpcResponses, nil +} + +// GetInt converts the rpc response to an int64 and returns it. +// +// If result was not an integer an error is returned. +func (RPCResponse *RPCResponse) GetInt() (int64, error) { + val, ok := RPCResponse.Result.(json.Number) + if !ok { + return 0, fmt.Errorf("could not parse int64 from %s", RPCResponse.Result) + } + + i, err := val.Int64() + if err != nil { + return 0, err + } + + return i, nil +} + +// GetFloat converts the rpc response to float64 and returns it. +// +// If result was not an float64 an error is returned. +func (RPCResponse *RPCResponse) GetFloat() (float64, error) { + val, ok := RPCResponse.Result.(json.Number) + if !ok { + return 0, fmt.Errorf("could not parse float64 from %s", RPCResponse.Result) + } + + f, err := val.Float64() + if err != nil { + return 0, err + } + + return f, nil +} + +// GetBool converts the rpc response to a bool and returns it. +// +// If result was not a bool an error is returned. +func (RPCResponse *RPCResponse) GetBool() (bool, error) { + val, ok := RPCResponse.Result.(bool) + if !ok { + return false, fmt.Errorf("could not parse bool from %s", RPCResponse.Result) + } + + return val, nil +} + +// GetString converts the rpc response to a string and returns it. +// +// If result was not a string an error is returned. +func (RPCResponse *RPCResponse) GetString() (string, error) { + val, ok := RPCResponse.Result.(string) + if !ok { + return "", fmt.Errorf("could not parse string from %s", RPCResponse.Result) + } + + return val, nil +} + +// GetObject converts the rpc response to an arbitrary type. +// +// The function works as you would expect it from json.Unmarshal() +func (RPCResponse *RPCResponse) GetObject(toType any) error { + js, err := json.Marshal(RPCResponse.Result) + if err != nil { + return err + } + + err = json.Unmarshal(js, toType) + if err != nil { + return err + } + + return nil +} diff --git a/rpcclient/client_test.go b/rpcclient/client_test.go new file mode 100644 index 0000000..11252c2 --- /dev/null +++ b/rpcclient/client_test.go @@ -0,0 +1,1145 @@ +package rpcclient + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/flashbots/go-utils/signature" +) + +// needed to retrieve requests that arrived at httpServer for further investigation +var requestChan = make(chan *RequestData, 1) + +// the request datastructure that can be retrieved for test assertions +type RequestData struct { + request *http.Request + body string +} + +// set the response body the httpServer should return for the next request +var responseBody = "" + +var ( + httpStatusCode = http.StatusOK + httpServer *httptest.Server +) + +// start the test-http server and stop it when tests are finished +func TestMain(m *testing.M) { + httpServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + data, _ := io.ReadAll(r.Body) + defer r.Body.Close() + // put request and body to channel for the client to investigate them + requestChan <- &RequestData{r, string(data)} + + w.WriteHeader(httpStatusCode) + fmt.Fprint(w, responseBody) + })) + defer httpServer.Close() + + os.Exit(m.Run()) +} + +func TestSimpleRpcCallHeaderCorrect(t *testing.T) { + check := assert.New(t) + + responseBody = `{"result": null}` + rpcClient := NewClient(httpServer.URL) + _, err := rpcClient.Call(context.Background(), "add", 1, 2) + check.Nil(err) + + req := (<-requestChan).request + + check.Equal("POST", req.Method) + check.Equal("application/json", req.Header.Get("Content-Type")) + check.Equal("application/json", req.Header.Get("Accept")) +} + +// test if the structure of a rpc request is built correctly by validating the data that arrived at the test server +func TestRpcClient_Call(t *testing.T) { + check := assert.New(t) + + rpcClient := NewClient(httpServer.URL) + + person := Person{ + Name: "Alex", + Age: 35, + Country: "Germany", + } + + drink := Drink{ + Name: "Cuba Libre", + Ingredients: []string{"rum", "cola"}, + } + + _, err := rpcClient.Call(context.Background(), "missingParam") + check.Nil(err) + check.Equal(`{"method":"missingParam","id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "nullParam", nil) + check.Nil(err) + check.Equal(`{"method":"nullParam","params":[null],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "nullParams", nil, nil) + check.Nil(err) + check.Equal(`{"method":"nullParams","params":[null,null],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "emptyParams", []interface{}{}) + check.Nil(err) + check.Equal(`{"method":"emptyParams","params":[[]],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "emptyAnyParams", []string{}) + check.Nil(err) + check.Equal(`{"method":"emptyAnyParams","params":[[]],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "emptyObject", struct{}{}) + check.Nil(err) + check.Equal(`{"method":"emptyObject","params":[{}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "emptyObjectList", []struct{}{{}, {}}) + check.Nil(err) + check.Equal(`{"method":"emptyObjectList","params":[[{},{}]],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "boolParam", true) + check.Nil(err) + check.Equal(`{"method":"boolParam","params":[true],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "boolParams", true, false, true) + check.Nil(err) + check.Equal(`{"method":"boolParams","params":[true,false,true],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "stringParam", "Alex") + check.Nil(err) + check.Equal(`{"method":"stringParam","params":["Alex"],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "stringParams", "JSON", "RPC") + check.Nil(err) + check.Equal(`{"method":"stringParams","params":["JSON","RPC"],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "numberParam", 123) + check.Nil(err) + check.Equal(`{"method":"numberParam","params":[123],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "numberParams", 123, 321) + check.Nil(err) + check.Equal(`{"method":"numberParams","params":[123,321],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "floatParam", 1.23) + check.Nil(err) + check.Equal(`{"method":"floatParam","params":[1.23],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "floatParams", 1.23, 3.21) + check.Nil(err) + check.Equal(`{"method":"floatParams","params":[1.23,3.21],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "manyParams", "Alex", 35, true, nil, 2.34) + check.Nil(err) + check.Equal(`{"method":"manyParams","params":["Alex",35,true,null,2.34],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "emptyMissingPublicFieldObject", struct{ name string }{name: "Alex"}) + check.Nil(err) + check.Equal(`{"method":"emptyMissingPublicFieldObject","params":[{}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "singleStruct", person) + check.Nil(err) + check.Equal(`{"method":"singleStruct","params":[{"name":"Alex","age":35,"country":"Germany"}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "singlePointerToStruct", &person) + check.Nil(err) + check.Equal(`{"method":"singlePointerToStruct","params":[{"name":"Alex","age":35,"country":"Germany"}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + pp := &person + _, err = rpcClient.Call(context.Background(), "doublePointerStruct", &pp) + check.Nil(err) + check.Equal(`{"method":"doublePointerStruct","params":[{"name":"Alex","age":35,"country":"Germany"}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "multipleStructs", person, &drink) + check.Nil(err) + check.Equal(`{"method":"multipleStructs","params":[{"name":"Alex","age":35,"country":"Germany"},{"name":"Cuba Libre","ingredients":["rum","cola"]}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "singleStructInArray", []interface{}{person}) + check.Nil(err) + check.Equal(`{"method":"singleStructInArray","params":[[{"name":"Alex","age":35,"country":"Germany"}]],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "namedParameters", map[string]interface{}{ + "name": "Alex", + "age": 35, + }) + check.Nil(err) + check.Equal(`{"method":"namedParameters","params":[{"age":35,"name":"Alex"}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "anonymousStructNoTags", struct { + Name string + Age int + }{"Alex", 33}) + check.Nil(err) + check.Equal(`{"method":"anonymousStructNoTags","params":[{"Name":"Alex","Age":33}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "anonymousStructWithTags", struct { + Name string `json:"name"` + Age int `json:"age"` + }{"Alex", 33}) + check.Nil(err) + check.Equal(`{"method":"anonymousStructWithTags","params":[{"name":"Alex","age":33}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "structWithNullField", struct { + Name string `json:"name"` + Address *string `json:"address"` + }{"Alex", nil}) + check.Nil(err) + check.Equal(`{"method":"structWithNullField","params":[{"name":"Alex","address":null}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + _, err = rpcClient.Call(context.Background(), "nestedStruct", + Planet{ + Name: "Mars", + Properties: Properties{ + Distance: 54600000, + Color: "red", + }, + }) + + check.Nil(err) + check.Equal(`{"method":"nestedStruct","params":[{"name":"Mars","properties":{"distance":54600000,"color":"red"}}],"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) + + request := NewRequestWithObjectParam(0, "singleStructRawObjectRequest", person) + _, err = rpcClient.CallRaw(context.Background(), request) + check.Nil(err) + check.Equal(`{"method":"singleStructRawObjectRequest","params":{"name":"Alex","age":35,"country":"Germany"},"id":0,"jsonrpc":"2.0"}`, (<-requestChan).body) +} + +func TestRpcClient_CallBatch(t *testing.T) { + responseBody = `[{"result": null}]` + check := assert.New(t) + + rpcClient := NewClient(httpServer.URL) + + person := Person{ + Name: "Alex", + Age: 35, + Country: "Germany", + } + + drink := Drink{ + Name: "Cuba Libre", + Ingredients: []string{"rum", "cola"}, + } + + // invalid parameters are possible by manually defining *RPCRequest + _, err := rpcClient.CallBatch(context.Background(), RPCRequests{ + { + Method: "singleRequest", + Params: 3, // invalid, should be []int{3} + }, + }) + check.Nil(err) + check.Equal(`[{"method":"singleRequest","params":3,"id":0,"jsonrpc":"2.0"}]`, (<-requestChan).body) + + // better use Params() unless you know what you are doing + _, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + { + Method: "singleRequest", + Params: []int{3}, // always valid json rpc + }, + }) + check.Nil(err) + check.Equal(`[{"method":"singleRequest","params":[3],"id":0,"jsonrpc":"2.0"}]`, (<-requestChan).body) + + // even better, use NewRequest() + _, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("multipleRequests1", 1), + NewRequest("multipleRequests2", 2), + NewRequest("multipleRequests3", 3), + }) + check.Nil(err) + check.Equal(`[{"method":"multipleRequests1","params":[1],"id":0,"jsonrpc":"2.0"},{"method":"multipleRequests2","params":[2],"id":1,"jsonrpc":"2.0"},{"method":"multipleRequests3","params":[3],"id":2,"jsonrpc":"2.0"}]`, (<-requestChan).body) + + // test a huge batch request + requests := RPCRequests{ + NewRequest("nullParam", nil), + NewRequest("nullParams", nil, nil), + NewRequest("emptyParams", []interface{}{}), + NewRequest("emptyAnyParams", []string{}), + NewRequest("emptyObject", struct{}{}), + NewRequest("emptyObjectList", []struct{}{{}, {}}), + NewRequest("boolParam", true), + NewRequest("boolParams", true, false, true), + NewRequest("stringParam", "Alex"), + NewRequest("stringParams", "JSON", "RPC"), + NewRequest("numberParam", 123), + NewRequest("numberParams", 123, 321), + NewRequest("floatParam", 1.23), + NewRequest("floatParams", 1.23, 3.21), + NewRequest("manyParams", "Alex", 35, true, nil, 2.34), + NewRequest("emptyMissingPublicFieldObject", struct{ name string }{name: "Alex"}), + NewRequest("singleStruct", person), + NewRequest("singlePointerToStruct", &person), + NewRequest("multipleStructs", person, &drink), + NewRequest("singleStructInArray", []interface{}{person}), + NewRequest("namedParameters", map[string]interface{}{ + "name": "Alex", + "age": 35, + }), + NewRequest("anonymousStructNoTags", struct { + Name string + Age int + }{"Alex", 33}), + NewRequest("anonymousStructWithTags", struct { + Name string `json:"name"` + Age int `json:"age"` + }{"Alex", 33}), + NewRequest("structWithNullField", struct { + Name string `json:"name"` + Address *string `json:"address"` + }{"Alex", nil}), + } + _, err = rpcClient.CallBatch(context.Background(), requests) + check.Nil(err) + + check.Equal(`[{"method":"nullParam","params":[null],"id":0,"jsonrpc":"2.0"},`+ + `{"method":"nullParams","params":[null,null],"id":1,"jsonrpc":"2.0"},`+ + `{"method":"emptyParams","params":[[]],"id":2,"jsonrpc":"2.0"},`+ + `{"method":"emptyAnyParams","params":[[]],"id":3,"jsonrpc":"2.0"},`+ + `{"method":"emptyObject","params":[{}],"id":4,"jsonrpc":"2.0"},`+ + `{"method":"emptyObjectList","params":[[{},{}]],"id":5,"jsonrpc":"2.0"},`+ + `{"method":"boolParam","params":[true],"id":6,"jsonrpc":"2.0"},`+ + `{"method":"boolParams","params":[true,false,true],"id":7,"jsonrpc":"2.0"},`+ + `{"method":"stringParam","params":["Alex"],"id":8,"jsonrpc":"2.0"},`+ + `{"method":"stringParams","params":["JSON","RPC"],"id":9,"jsonrpc":"2.0"},`+ + `{"method":"numberParam","params":[123],"id":10,"jsonrpc":"2.0"},`+ + `{"method":"numberParams","params":[123,321],"id":11,"jsonrpc":"2.0"},`+ + `{"method":"floatParam","params":[1.23],"id":12,"jsonrpc":"2.0"},`+ + `{"method":"floatParams","params":[1.23,3.21],"id":13,"jsonrpc":"2.0"},`+ + `{"method":"manyParams","params":["Alex",35,true,null,2.34],"id":14,"jsonrpc":"2.0"},`+ + `{"method":"emptyMissingPublicFieldObject","params":[{}],"id":15,"jsonrpc":"2.0"},`+ + `{"method":"singleStruct","params":[{"name":"Alex","age":35,"country":"Germany"}],"id":16,"jsonrpc":"2.0"},`+ + `{"method":"singlePointerToStruct","params":[{"name":"Alex","age":35,"country":"Germany"}],"id":17,"jsonrpc":"2.0"},`+ + `{"method":"multipleStructs","params":[{"name":"Alex","age":35,"country":"Germany"},{"name":"Cuba Libre","ingredients":["rum","cola"]}],"id":18,"jsonrpc":"2.0"},`+ + `{"method":"singleStructInArray","params":[[{"name":"Alex","age":35,"country":"Germany"}]],"id":19,"jsonrpc":"2.0"},`+ + `{"method":"namedParameters","params":[{"age":35,"name":"Alex"}],"id":20,"jsonrpc":"2.0"},`+ + `{"method":"anonymousStructNoTags","params":[{"Name":"Alex","Age":33}],"id":21,"jsonrpc":"2.0"},`+ + `{"method":"anonymousStructWithTags","params":[{"name":"Alex","age":33}],"id":22,"jsonrpc":"2.0"},`+ + `{"method":"structWithNullField","params":[{"name":"Alex","address":null}],"id":23,"jsonrpc":"2.0"}]`, (<-requestChan).body) + + // create batch manually + requests = []*RPCRequest{ + { + Method: "myMethod1", + Params: []int{1}, + ID: 123, // will be forced to requests[i].ID == i unless you use CallBatchRaw + JSONRPC: "7.0", // will be forced to "2.0" unless you use CallBatchRaw + }, + { + Method: "myMethod2", + Params: &person, + ID: 321, // will be forced to requests[i].ID == i unless you use CallBatchRaw + JSONRPC: "wrong", // will be forced to "2.0" unless you use CallBatchRaw + }, + } + _, err = rpcClient.CallBatch(context.Background(), requests) + check.Nil(err) + + check.Equal(`[{"method":"myMethod1","params":[1],"id":0,"jsonrpc":"2.0"},`+ + `{"method":"myMethod2","params":{"name":"Alex","age":35,"country":"Germany"},"id":1,"jsonrpc":"2.0"}]`, (<-requestChan).body) + + // use raw batch + requests = []*RPCRequest{ + { + Method: "myMethod1", + Params: []int{1}, + ID: 123, + JSONRPC: "7.0", + }, + { + Method: "myMethod2", + Params: &person, + ID: 321, + JSONRPC: "wrong", + }, + } + _, err = rpcClient.CallBatchRaw(context.Background(), requests) + check.Nil(err) + + check.Equal(`[{"method":"myMethod1","params":[1],"id":123,"jsonrpc":"7.0"},`+ + `{"method":"myMethod2","params":{"name":"Alex","age":35,"country":"Germany"},"id":321,"jsonrpc":"wrong"}]`, (<-requestChan).body) +} + +// test if the result of a rpc request is parsed correctly and if errors are thrown correctly +func TestRpcJsonResponseStruct(t *testing.T) { + check := assert.New(t) + + rpcClient := NewClient(httpServer.URL) + + // empty return body is an error + responseBody = `` + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.NotNil(err) + check.Nil(res) + + // not a json body is an error + responseBody = `{ "not": "a", "json": "object"` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.NotNil(err) + check.Nil(res) + + // field "anotherField" not allowed in rpc response is an error + responseBody = `{ "anotherField": "norpc"}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.NotNil(err) + check.Nil(res) + + // result null is ok + responseBody = `{"result": null}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Result) + check.Nil(res.Error) + + // error null is ok + responseBody = `{"error": null}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Result) + check.Nil(res.Error) + + // result and error null is ok + responseBody = `{"result": null, "error": null}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Result) + check.Nil(res.Error) + + // result string is ok + responseBody = `{"result": "ok"}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Equal("ok", res.Result) + + // result with error null is ok + responseBody = `{"result": "ok", "error": null}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Equal("ok", res.Result) + + // error with result null is ok + responseBody = `{"error": {"code": 123, "message": "something wrong"}, "result": null}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Result) + check.Equal(123, res.Error.Code) + check.Equal("something wrong", res.Error.Message) + + // error with code and message is ok + responseBody = `{ "error": {"code": 123, "message": "something wrong"}}` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Result) + check.Equal(123, res.Error.Code) + check.Equal("something wrong", res.Error.Message) + + // check results + + // should return int correctly + responseBody = `{ "result": 1 }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + i, err := res.GetInt() + check.Nil(err) + check.Equal(int64(1), i) + + // error on not int + responseBody = `{ "result": "notAnInt" }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + i, err = res.GetInt() + check.NotNil(err) + check.Equal(int64(0), i) + + // error on not int but float + responseBody = `{ "result": 1.234 }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + i, err = res.GetInt() + check.NotNil(err) + check.Equal(int64(0), i) + + // error on result null + responseBody = `{ "result": null }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + i, err = res.GetInt() + check.NotNil(err) + check.Equal(int64(0), i) + + responseBody = `{ "result": true }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + b, err := res.GetBool() + check.Nil(err) + check.Equal(true, b) + + responseBody = `{ "result": 123 }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + b, err = res.GetBool() + check.NotNil(err) + check.Equal(false, b) + + responseBody = `{ "result": "string" }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + str, err := res.GetString() + check.Nil(err) + check.Equal("string", str) + + responseBody = `{ "result": 1.234 }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + str, err = res.GetString() + check.NotNil(err) + check.Equal("", str) + + responseBody = `{ "result": 1.234 }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + f, err := res.GetFloat() + check.Nil(err) + check.Equal(1.234, f) + + responseBody = `{ "result": "notfloat" }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + f, err = res.GetFloat() + check.NotNil(err) + check.Equal(0.0, f) + + var p *Person + responseBody = `{ "result": {"name": "Alex", "age": 35, "anotherField": "something"} }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(&p) + check.Nil(err) + check.Equal("Alex", p.Name) + check.Equal(35, p.Age) + check.Equal("", p.Country) + + // TODO: How to check if result could be parsed or if it is default? + p = nil + responseBody = `{ "result": {"anotherField": "something"} }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(&p) + check.Nil(err) + check.NotNil(p) + + var pp *PointerFieldPerson + responseBody = `{ "result": {"anotherField": "something", "country": "Germany"} }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(&pp) + check.Nil(err) + check.Nil(pp.Name) + check.Nil(pp.Age) + check.Equal("Germany", *pp.Country) + + p = nil + responseBody = `{ "result": null }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(&p) + check.Nil(err) + check.Nil(p) + + // passing nil is an error + p = nil + responseBody = `{ "result": null }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(p) + check.NotNil(err) + check.Nil(p) + + p2 := &Person{ + Name: "Alex", + } + responseBody = `{ "result": null }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(&p2) + check.Nil(err) + check.Nil(p2) + + p2 = &Person{ + Name: "Alex", + } + responseBody = `{ "result": {"age": 35} }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(p2) + check.Nil(err) + check.Equal("Alex", p2.Name) + check.Equal(35, p2.Age) + + // prefilled struct is kept on no result + p3 := Person{ + Name: "Alex", + } + responseBody = `{ "result": null }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(&p3) + check.Nil(err) + check.Equal("Alex", p3.Name) + + // prefilled struct is extended / overwritten + p3 = Person{ + Name: "Alex", + Age: 123, + } + responseBody = `{ "result": {"age": 35, "country": "Germany"} }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(&p3) + check.Nil(err) + check.Equal("Alex", p3.Name) + check.Equal(35, p3.Age) + check.Equal("Germany", p3.Country) + + // nil is an error + responseBody = `{ "result": {"age": 35} }` + res, err = rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Nil(res.Error) + err = res.GetObject(nil) + check.NotNil(err) +} + +func TestRpcClientOptions(t *testing.T) { + check := assert.New(t) + + t.Run("allowUnknownFields false should return error on unknown field", func(t *testing.T) { + rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{AllowUnknownFields: false}) + + // unknown field should cause error + responseBody = `{ "result": 1, "unknown_field": 2 }` + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.NotNil(err) + check.Nil(res) + }) + + t.Run("allowUnknownFields true should not return error on unknown field", func(t *testing.T) { + rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{AllowUnknownFields: true}) + + // unknown field should not cause error now + responseBody = `{ "result": 1, "unknown_field": 2 }` + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.NotNil(res) + }) + + t.Run("customheaders should be added to request", func(t *testing.T) { + rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{ + CustomHeaders: map[string]string{ + "X-Custom-Header": "custom-value", + "X-Custom-Header2": "custom-value2", + }, + }) + + responseBody = `{"result": 1}` + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + reqObject := <-requestChan + check.Nil(err) + check.NotNil(res) + check.Equal("custom-value", reqObject.request.Header.Get("X-Custom-Header")) + check.Equal("custom-value2", reqObject.request.Header.Get("X-Custom-Header2")) + }) + + t.Run("host header should be added to request", func(t *testing.T) { + rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{ + CustomHeaders: map[string]string{ + "X-Custom-Header1": "custom-value1", + "Host": "my-host.com", + "X-Custom-Header2": "custom-value2", + }, + }) + + responseBody = `{"result": 1}` + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + reqObject := <-requestChan + check.Nil(err) + check.NotNil(res) + check.Equal("custom-value1", reqObject.request.Header.Get("X-Custom-Header1")) + check.Equal("my-host.com", reqObject.request.Host) + check.Equal("custom-value2", reqObject.request.Header.Get("X-Custom-Header2")) + }) + + t.Run("default rpcrequest id should be customized", func(t *testing.T) { + rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{ + DefaultRequestID: 123, + }) + + _, err := rpcClient.Call(context.Background(), "myMethod", 1, 2, 3) + check.Nil(err) + check.Equal(`{"method":"myMethod","params":[1,2,3],"id":123,"jsonrpc":"2.0"}`, (<-requestChan).body) + }) +} + +func TestRpcBatchJsonResponseStruct(t *testing.T) { + check := assert.New(t) + + rpcClient := NewClient(httpServer.URL) + + // empty return body is an error + responseBody = `` + res, err := rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.NotNil(err) + check.Nil(res) + + // not a json body is an error + responseBody = `{ "not": "a", "json": "object"` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.NotNil(err) + check.Nil(res) + + // field "anotherField" not allowed in rpc response is an error + responseBody = `{ "anotherField": "norpc"}` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.NotNil(err) + check.Nil(res) + + // result must be wrapped in array on batch request + responseBody = `{"result": null}` + _, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.NotNil(err.Error()) + + // result ok since in array + responseBody = `[{"result": null}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Equal(1, len(res)) + check.Nil(res[0].Result) + + // error null is ok + responseBody = `[{"error": null}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Nil(res[0].Result) + check.Nil(res[0].Error) + + // result and error null is ok + responseBody = `[{"result": null, "error": null}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Nil(res[0].Result) + check.Nil(res[0].Error) + + // result string is ok + responseBody = `[{"result": "ok","id":0}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Equal("ok", res[0].Result) + check.Equal(0, res[0].ID) + + // result with error null is ok + responseBody = `[{"result": "ok", "error": null}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Equal("ok", res[0].Result) + + // error with result null is ok + responseBody = `[{"error": {"code": 123, "message": "something wrong"}, "result": null}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Nil(res[0].Result) + check.Equal(123, res[0].Error.Code) + check.Equal("something wrong", res[0].Error.Message) + + // error with code and message is ok + responseBody = `[{ "error": {"code": 123, "message": "something wrong"}}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Nil(res[0].Result) + check.Equal(123, res[0].Error.Code) + check.Equal("something wrong", res[0].Error.Message) + + // check results + + // should return int correctly + responseBody = `[{ "result": 1 }]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Nil(res[0].Error) + i, err := res[0].GetInt() + check.Nil(err) + check.Equal(int64(1), i) + + // error on wrong type + responseBody = `[{ "result": "notAnInt" }]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.Nil(res[0].Error) + i, err = res[0].GetInt() + check.NotNil(err) + check.Equal(int64(0), i) + + var p *Person + responseBody = `[{"id":0, "result": {"name": "Alex", "age": 35}}, {"id":2, "result": {"name": "Lena", "age": 2}}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + + <-requestChan + check.Nil(err) + + check.Nil(res[0].Error) + check.Equal(0, res[0].ID) + + check.Nil(res[1].Error) + check.Equal(2, res[1].ID) + + err = res[0].GetObject(&p) + check.Nil(err) + check.Equal("Alex", p.Name) + check.Equal(35, p.Age) + + err = res[1].GetObject(&p) + check.Nil(err) + check.Equal("Lena", p.Name) + check.Equal(2, p.Age) + + // check if error occurred + responseBody = `[{ "result": "someresult", "error": null}, { "result": null, "error": {"code": 123, "message": "something wrong"}}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.True(res.HasError()) + + // check if error occurred + responseBody = `[{ "result": null, "error": {"code": 123, "message": "something wrong"}}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.True(res.HasError()) + // check if error occurred + responseBody = `[{ "result": null, "error": {"code": 123, "message": "something wrong"}}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.True(res.HasError()) + + // check if response mapping works + responseBody = `[{ "id":123,"result": 123},{ "id":1,"result": 1}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.False(res.HasError()) + resMap := res.AsMap() + + int1, _ := resMap[1].GetInt() + int123, _ := resMap[123].GetInt() + check.Equal(int64(1), int1) + check.Equal(int64(123), int123) + + // check if getByID works + int123, _ = res.GetByID(123).GetInt() + check.Equal(int64(123), int123) + + // check if missing id returns nil + missingIDRes := res.GetByID(124) + check.Nil(missingIDRes) + + // check if error occurred + responseBody = `[{ "result": null, "error": {"code": 123, "message": "something wrong"}}]` + res, err = rpcClient.CallBatch(context.Background(), RPCRequests{ + NewRequest("something", 1, 2, 3), + }) + <-requestChan + check.Nil(err) + check.True(res.HasError()) +} + +func TestRpcClient_CallFor(t *testing.T) { + check := assert.New(t) + + rpcClient := NewClient(httpServer.URL) + + i := 0 + responseBody = `{"result":3,"id":0,"jsonrpc":"2.0"}` + err := rpcClient.CallFor(context.Background(), &i, "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.Equal(3, i) +} + +func TestErrorHandling(t *testing.T) { + check := assert.New(t) + rpcClient := NewClient(httpServer.URL) + + oldStatusCode := httpStatusCode + oldResponseBody := responseBody + defer func() { + httpStatusCode = oldStatusCode + responseBody = oldResponseBody + }() + + t.Run("check returned rpcerror", func(t *testing.T) { + responseBody = `{"error":{"code":123,"message":"something wrong"}}` + call, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.NotNil(call.Error) + check.Equal("123: something wrong", call.Error.Error()) + }) + + t.Run("check returned httperror", func(t *testing.T) { + responseBody = `{"error":{"code":123,"message":"something wrong"}}` + httpStatusCode = http.StatusInternalServerError + call, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.Nil(err) + check.NotNil(call) + check.NotNil(call.Error) + check.Equal("123: something wrong", call.Error.Error()) + }) +} + +func TestSignedRequest(t *testing.T) { + check := assert.New(t) + signer, _ := signature.NewRandomSigner() + rpcClient := NewClientWithOpts(httpServer.URL, &RPCClientOpts{ + Signer: signer, + }) + + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + reqObject := <-requestChan + check.Nil(err) + check.NotNil(res) + header := reqObject.request.Header.Get(signature.HTTPHeader) + recoveredAddress, err := signature.Verify(header, []byte(reqObject.body)) + check.Nil(err) + check.Equal(signer.Address(), recoveredAddress) +} + +func TestUnsignedRequest(t *testing.T) { + check := assert.New(t) + rpcClient := NewClient(httpServer.URL) + + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + reqObject := <-requestChan + check.Nil(err) + check.NotNil(res) + header := reqObject.request.Header.Get(signature.HTTPHeader) + check.Equal("", header) +} + +func TestCallFlashbots(t *testing.T) { + check := assert.New(t) + signer, _ := signature.NewRandomSigner() + rpcClient := NewClientWithOpts("https://relay.flashbots.net", &RPCClientOpts{ + Signer: signer, + }) + + res, _ := rpcClient.Call(context.Background(), "eth_sendBundle", struct{}{}) + // Disabled the following two lines because they work locally, but reliably fail in Github CI! + // See also https://github.com/flashbots/go-utils/actions/runs/13905273154/job/38919059341?pr=37 + // check.NotNil(err, res) + // check.Contains(err.Error(), "rpc response error") + check.NotNil(res) + check.NotNil(res.Error) + check.Equal("missing blockNumber param", res.Error.Message) + check.Equal(-32602, res.Error.Code) +} + +func TestBrokenFlashbotsErrorResponse(t *testing.T) { + oldStatusCode := httpStatusCode + oldResponseBody := responseBody + defer func() { + httpStatusCode = oldStatusCode + responseBody = oldResponseBody + }() + + check := assert.New(t) + rpcClient := NewClient(httpServer.URL) + + responseBody = `{"error":"unknown method: something"}` + httpStatusCode = 400 + res, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + <-requestChan + check.NotNil(err) + check.Nil(res) +} + +type Person struct { + Name string `json:"name"` + Age int `json:"age"` + Country string `json:"country"` +} + +type PointerFieldPerson struct { + Name *string `json:"name"` + Age *int `json:"age"` + Country *string `json:"country"` +} + +type Drink struct { + Name string `json:"name"` + Ingredients []string `json:"ingredients"` +} + +type Planet struct { + Name string `json:"name"` + Properties Properties `json:"properties"` +} + +type Properties struct { + Distance int `json:"distance"` + Color string `json:"color"` +} + +// benchmarks + +func BenchmarkJSONRPCClientNoSignatures(b *testing.B) { + benchServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + defer r.Body.Close() + w.WriteHeader(httpStatusCode) + _, _ = w.Write([]byte(`{"result": null}`)) + })) + defer benchServer.Close() + + rpcClient := NewClient(benchServer.URL) + responseBody = `{"result": null}` + for i := 0; i < b.N; i++ { + _, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + if err != nil { + panic(err) + } + } +} + +func BenchmarkJSONRPCClientWithSignatures(b *testing.B) { + benchServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.Copy(io.Discard, r.Body) + defer r.Body.Close() + w.WriteHeader(httpStatusCode) + _, _ = w.Write([]byte(`{"result": null}`)) + })) + defer benchServer.Close() + + signer, _ := signature.NewRandomSigner() + rpcClient := NewClientWithOpts(benchServer.URL, &RPCClientOpts{ + Signer: signer, + }) + + responseBody = `{"result": null}` + for i := 0; i < b.N; i++ { + _, err := rpcClient.Call(context.Background(), "something", 1, 2, 3) + if err != nil { + panic(err) + } + } +} diff --git a/rpcserver/jsonrpc_server.go b/rpcserver/jsonrpc_server.go new file mode 100644 index 0000000..21d597a --- /dev/null +++ b/rpcserver/jsonrpc_server.go @@ -0,0 +1,387 @@ +// Package rpcserver allows exposing functions like: +// func Foo(context, int) (int, error) +// as a JSON RPC methods +// +// This implementation is similar to the one in go-ethereum, but the idea is to eventually replace it as a default +// JSON RPC server implementation in Flasbhots projects and for this we need to reimplement some of the quirks of existing API. +package rpcserver + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + "github.com/goccy/go-json" + + "github.com/ethereum/go-ethereum/common" + "github.com/flashbots/go-utils/signature" +) + +var ( + // this are the only errors that are returned as http errors with http error codes + errMethodNotAllowed = "only POST method is allowed" + errWrongContentType = "header Content-Type must be application/json" + errMarshalResponse = "failed to marshal response" + + CodeParseError = -32700 + CodeInvalidRequest = -32600 + CodeMethodNotFound = -32601 + CodeInvalidParams = -32602 + CodeInternalError = -32603 + CodeCustomError = -32000 + + DefaultMaxRequestBodySizeBytes = 30 * 1024 * 1024 // 30mb +) + +const ( + maxOriginIDLength = 255 + requestSizeThreshold = 50_000 +) + +type ( + highPriorityKey struct{} + signerKey struct{} + originKey struct{} + sizeKey struct{} +) + +type jsonRPCRequest struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Method string `json:"method"` + Params []json.RawMessage `json:"params"` +} + +type JSONRPCResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id"` + Result *json.RawMessage `json:"result,omitempty"` + Error *JSONRPCError `json:"error,omitempty"` +} + +type JSONRPCError struct { + Code int `json:"code"` + Message string `json:"message"` + Data *any `json:"data,omitempty"` +} + +func (e *JSONRPCError) Error() string { + return e.Message +} + +type JSONRPCHandler struct { + JSONRPCHandlerOpts + methods map[string]methodHandler +} + +type Methods map[string]any + +type JSONRPCHandlerOpts struct { + // Logger, can be nil + Log *slog.Logger + // Server name. Used to separate logs and metrics when having multiple servers in one binary. + ServerName string + // Max size of the request payload + MaxRequestBodySizeBytes int64 + // If true payload signature from X-Flashbots-Signature will be verified + // Result can be extracted from the context using GetSigner + VerifyRequestSignatureFromHeader bool + // If true signer from X-Flashbots-Signature will be extracted without verifying signature + // Result can be extracted from the context using GetSigner + ExtractUnverifiedRequestSignatureFromHeader bool + // If true high_prio header value will be extracted (true or false) + // Result can be extracted from the context using GetHighPriority + ExtractPriorityFromHeader bool + // If true extract value from x-flashbots-origin header + // Result can be extracted from the context using GetOrigin + ExtractOriginFromHeader bool + // GET response content + GetResponseContent []byte + // Custom handler for /readyz endpoint. If not nil then it is expected to write the response to the provided ResponseWriter. + // If the custom handler returns an error, the error message is written to the ResponseWriter with a 500 status code. + ReadyHandler func(w http.ResponseWriter, r *http.Request) error + + ForbidEmptySigner bool +} + +// NewJSONRPCHandler creates JSONRPC http.Handler from the map that maps method names to method functions +// each method function must: +// - have context as a first argument +// - return error as a last argument +// - have argument types that can be unmarshalled from JSON +// - have return types that can be marshalled to JSON +func NewJSONRPCHandler(methods Methods, opts JSONRPCHandlerOpts) (*JSONRPCHandler, error) { + if opts.MaxRequestBodySizeBytes == 0 { + opts.MaxRequestBodySizeBytes = int64(DefaultMaxRequestBodySizeBytes) + } + + m := make(map[string]methodHandler) + for name, fn := range methods { + method, err := getMethodTypes(fn) + if err != nil { + return nil, err + } + m[name] = method + } + return &JSONRPCHandler{ + JSONRPCHandlerOpts: opts, + methods: m, + }, nil +} + +func (h *JSONRPCHandler) writeJSONRPCResponse(w http.ResponseWriter, response JSONRPCResponse) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + if h.Log != nil { + h.Log.Error("failed to marshall response", slog.Any("error", err), slog.String("serverName", h.ServerName)) + } + http.Error(w, errMarshalResponse, http.StatusInternalServerError) + incInternalErrors(h.ServerName) + return + } +} + +func (h *JSONRPCHandler) writeJSONRPCError(w http.ResponseWriter, id any, code int, msg string) { + res := JSONRPCResponse{ + JSONRPC: "2.0", + ID: id, + Result: nil, + Error: &JSONRPCError{ + Code: code, + Message: msg, + Data: nil, + }, + } + h.writeJSONRPCResponse(w, res) +} + +func (h *JSONRPCHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + startAt := time.Now() + methodForMetrics := unknownMethodLabel + bigRequest := false + ctx := r.Context() + + defer func() { + incRequestCount(methodForMetrics, h.ServerName, bigRequest) + incRequestDuration(time.Since(startAt), methodForMetrics, h.ServerName, bigRequest) + }() + + stepStartAt := time.Now() + + // Some GET requests are allowed + if r.Method == http.MethodGet { + if r.URL.Path == "/livez" { + w.WriteHeader(http.StatusOK) + return + } else if r.URL.Path == "/readyz" { + if h.JSONRPCHandlerOpts.ReadyHandler == nil { + http.Error(w, "ready handler is not set", http.StatusNotFound) + incIncorrectRequest(h.ServerName) + return + } else { + // Handler is expected to write the Response + err := h.JSONRPCHandlerOpts.ReadyHandler(w, r) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + incInternalErrors(h.ServerName) + } + return + } + } else if len(h.GetResponseContent) > 0 { + // Static response for all other GET requests + w.WriteHeader(http.StatusOK) + _, err := w.Write(h.GetResponseContent) + if err != nil { + http.Error(w, errMarshalResponse, http.StatusInternalServerError) + incInternalErrors(h.ServerName) + return + } + return + } + } + + // From here we only accept POST requests with JSON body + if r.Method != http.MethodPost { + http.Error(w, errMethodNotAllowed, http.StatusMethodNotAllowed) + incIncorrectRequest(h.ServerName) + return + } + + if r.Header.Get("Content-Type") != "application/json" { + http.Error(w, errWrongContentType, http.StatusUnsupportedMediaType) + incIncorrectRequest(h.ServerName) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, h.MaxRequestBodySizeBytes) + body, err := io.ReadAll(r.Body) + if err != nil { + msg := fmt.Sprintf("request body is too big, max size: %d", h.MaxRequestBodySizeBytes) + h.writeJSONRPCError(w, nil, CodeInvalidRequest, msg) + incIncorrectRequest(h.ServerName) + return + } + bodySize := len(body) + ctx = context.WithValue(ctx, sizeKey{}, bodySize) + + bigRequest = bodySize > requestSizeThreshold + defer func(size int) { + incRequestSizeBytes(size, methodForMetrics, h.ServerName) + }(bodySize) + + stepTime := time.Since(stepStartAt) + defer func(stepTime time.Duration) { + incRequestDurationStep(stepTime, methodForMetrics, h.ServerName, "io", bigRequest) + }(stepTime) + stepStartAt = time.Now() + + if h.ForbidEmptySigner { + signatureHeader := r.Header.Get("x-flashbots-signature") + if signatureHeader == "" { + h.writeJSONRPCError(w, nil, CodeInvalidRequest, "signature is required") + incIncorrectRequest(h.ServerName) + return + } + } + + if h.VerifyRequestSignatureFromHeader { + signatureHeader := r.Header.Get("x-flashbots-signature") + signer, err := signature.Verify(signatureHeader, body) + if err != nil { + h.writeJSONRPCError(w, nil, CodeInvalidRequest, err.Error()) + incIncorrectRequest(h.ServerName) + return + } + ctx = context.WithValue(ctx, signerKey{}, signer) + } + + // read request + var req jsonRPCRequest + if err := json.Unmarshal(body, &req); err != nil { + h.writeJSONRPCError(w, nil, CodeParseError, err.Error()) + incIncorrectRequest(h.ServerName) + return + } + + if req.JSONRPC != "2.0" { + h.writeJSONRPCError(w, req.ID, CodeParseError, "invalid jsonrpc version") + incIncorrectRequest(h.ServerName) + return + } + if req.ID != nil { + // id must be string or number + switch req.ID.(type) { + case string, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + default: + h.writeJSONRPCError(w, req.ID, CodeParseError, "invalid id type") + incIncorrectRequest(h.ServerName) + return + } + } + + if h.ExtractPriorityFromHeader { + highPriority := r.Header.Get("high_prio") == "true" + ctx = context.WithValue(ctx, highPriorityKey{}, highPriority) + } + + if h.ExtractUnverifiedRequestSignatureFromHeader { + signature := r.Header.Get("x-flashbots-signature") + if split := strings.Split(signature, ":"); len(split) > 0 { + signer := common.HexToAddress(split[0]) + ctx = context.WithValue(ctx, signerKey{}, signer) + } + } + + if h.ExtractOriginFromHeader { + origin := r.Header.Get("x-flashbots-origin") + if origin != "" { + if len(origin) > maxOriginIDLength { + h.writeJSONRPCError(w, req.ID, CodeInvalidRequest, "x-flashbots-origin header is too long") + incIncorrectRequest(h.ServerName) + return + } + ctx = context.WithValue(ctx, originKey{}, origin) + } + } + + // get method + method, ok := h.methods[req.Method] + if !ok { + h.writeJSONRPCError(w, req.ID, CodeMethodNotFound, "method not found") + incIncorrectRequest(h.ServerName) + return + } + methodForMetrics = req.Method + + incRequestDurationStep(time.Since(stepStartAt), methodForMetrics, h.ServerName, "parse", bigRequest) + stepStartAt = time.Now() + + // call method + result, err := method.call(ctx, req.Params) + if err != nil { + if jsonRPCErr, ok := err.(*JSONRPCError); ok { + h.writeJSONRPCError(w, req.ID, jsonRPCErr.Code, jsonRPCErr.Message) + } else { + h.writeJSONRPCError(w, req.ID, CodeCustomError, err.Error()) + } + incRequestErrorCount(methodForMetrics, h.ServerName) + incRequestDurationStep(time.Since(stepStartAt), methodForMetrics, h.ServerName, "call", bigRequest) + return + } + + incRequestDurationStep(time.Since(stepStartAt), methodForMetrics, h.ServerName, "call", bigRequest) + stepStartAt = time.Now() + + marshaledResult, err := json.Marshal(result) + if err != nil { + h.writeJSONRPCError(w, req.ID, CodeInternalError, err.Error()) + incInternalErrors(h.ServerName) + + incRequestDurationStep(time.Since(stepStartAt), methodForMetrics, h.ServerName, "response", bigRequest) + return + } + + // write response + rawMessageResult := json.RawMessage(marshaledResult) + res := JSONRPCResponse{ + JSONRPC: "2.0", + ID: req.ID, + Result: &rawMessageResult, + Error: nil, + } + h.writeJSONRPCResponse(w, res) + + incRequestDurationStep(time.Since(stepStartAt), methodForMetrics, h.ServerName, "response", bigRequest) +} + +func GetHighPriority(ctx context.Context) bool { + value, ok := ctx.Value(highPriorityKey{}).(bool) + if !ok { + return false + } + return value +} + +func GetSigner(ctx context.Context) common.Address { + value, ok := ctx.Value(signerKey{}).(common.Address) + if !ok { + return common.Address{} + } + return value +} + +func GetOrigin(ctx context.Context) string { + value, ok := ctx.Value(originKey{}).(string) + if !ok { + return "" + } + return value +} + +func GetRequestSize(ctx context.Context) int { + return ctx.Value(sizeKey{}).(int) +} diff --git a/rpcserver/jsonrpc_server_test.go b/rpcserver/jsonrpc_server_test.go new file mode 100644 index 0000000..ac9b5e7 --- /dev/null +++ b/rpcserver/jsonrpc_server_test.go @@ -0,0 +1,183 @@ +package rpcserver + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/flashbots/go-utils/rpcclient" + "github.com/flashbots/go-utils/signature" + "github.com/stretchr/testify/require" +) + +func testHandler(opts JSONRPCHandlerOpts) *JSONRPCHandler { + var ( + errorArg = -1 + errorOut = errors.New("custom error") //nolint:goerr113 + ) + handlerMethod := func(ctx context.Context, arg1 int) (dummyStruct, error) { + if arg1 == errorArg { + return dummyStruct{}, errorOut + } + return dummyStruct{arg1}, nil + } + + handler, err := NewJSONRPCHandler(map[string]interface{}{ + "function": handlerMethod, + }, opts) + if err != nil { + panic(err) + } + return handler +} + +func TestHandler_ServeHTTP(t *testing.T) { + handler := testHandler(JSONRPCHandlerOpts{}) + + testCases := map[string]struct { + requestBody string + expectedResponse string + }{ + "success": { + requestBody: `{"jsonrpc":"2.0","id":1,"method":"function","params":[1]}`, + expectedResponse: `{"jsonrpc":"2.0","id":1,"result":{"field":1}}`, + }, + "error": { + requestBody: `{"jsonrpc":"2.0","id":1,"method":"function","params":[-1]}`, + expectedResponse: `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"custom error"}}`, + }, + "invalid json": { + requestBody: `{"jsonrpc":"2.0","id":1,"method":"function","params":[1]`, + expectedResponse: `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"expected comma after object element"}}`, + }, + "method not found": { + requestBody: `{"jsonrpc":"2.0","id":1,"method":"not_found","params":[1]}`, + expectedResponse: `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}`, + }, + "invalid params": { + requestBody: `{"jsonrpc":"2.0","id":1,"method":"function","params":[1,2]}`, + expectedResponse: `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"too much arguments"}}`, // TODO: return correct code here + }, + "invalid params type": { + requestBody: `{"jsonrpc":"2.0","id":1,"method":"function","params":["1"]}`, + expectedResponse: `{"jsonrpc":"2.0","id":1,"error":{"code":-32000,"message":"json: cannot unmarshal number \" into Go value of type int"}}`, + }, + } + + for name, testCase := range testCases { + t.Run(name, func(t *testing.T) { + body := bytes.NewReader([]byte(testCase.requestBody)) + request, err := http.NewRequest(http.MethodPost, "/", body) + require.NoError(t, err) + request.Header.Add("Content-Type", "application/json") + + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, request) + require.Equal(t, http.StatusOK, rr.Code) + + require.JSONEq(t, testCase.expectedResponse, rr.Body.String()) + }) + } +} + +func TestJSONRPCServerWithClient(t *testing.T) { + handler := testHandler(JSONRPCHandlerOpts{}) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + client := rpcclient.NewClient(httpServer.URL) + + var resp dummyStruct + err := client.CallFor(context.Background(), &resp, "function", 123) + require.NoError(t, err) + require.Equal(t, 123, resp.Field) +} + +func TestJSONRPCServerWithSignatureWithClient(t *testing.T) { + handler := testHandler(JSONRPCHandlerOpts{VerifyRequestSignatureFromHeader: true}) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // first we do request without signature + client := rpcclient.NewClient(httpServer.URL) + resp, err := client.Call(context.Background(), "function", 123) + require.NoError(t, err) + require.Equal(t, "no signature provided", resp.Error.Message) + + // call with signature + signer, err := signature.NewRandomSigner() + require.NoError(t, err) + client = rpcclient.NewClientWithOpts(httpServer.URL, &rpcclient.RPCClientOpts{ + Signer: signer, + }) + + var structResp dummyStruct + err = client.CallFor(context.Background(), &structResp, "function", 123) + require.NoError(t, err) + require.Equal(t, 123, structResp.Field) +} + +func TestJSONRPCServerDefaultLiveAndReady(t *testing.T) { + handler := testHandler(JSONRPCHandlerOpts{}) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + // /livez (200 by default) + request, err := http.NewRequest(http.MethodGet, "/livez", nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, request) + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "", rr.Body.String()) + + // /readyz (404 by default) + request, err = http.NewRequest(http.MethodGet, "/readyz", nil) + require.NoError(t, err) + rr = httptest.NewRecorder() + handler.ServeHTTP(rr, request) + require.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestJSONRPCServerReadyzOK(t *testing.T) { + handler := testHandler(JSONRPCHandlerOpts{ + ReadyHandler: func(w http.ResponseWriter, r *http.Request) error { + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte("ready")) + return err + }, + }) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + request, err := http.NewRequest(http.MethodGet, "/readyz", nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, request) + require.Equal(t, http.StatusOK, rr.Code) + require.Equal(t, "ready", rr.Body.String()) +} + +func TestJSONRPCServerReadyzError(t *testing.T) { + handler := testHandler(JSONRPCHandlerOpts{ + ReadyHandler: func(w http.ResponseWriter, r *http.Request) error { + return fmt.Errorf("not ready") + }, + }) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + request, err := http.NewRequest(http.MethodGet, "/readyz", nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, request) + require.Equal(t, http.StatusInternalServerError, rr.Code) + fmt.Println(rr.Body.String()) + require.Equal(t, "not ready\n", rr.Body.String()) +} diff --git a/rpcserver/metrics.go b/rpcserver/metrics.go new file mode 100644 index 0000000..37e7eaf --- /dev/null +++ b/rpcserver/metrics.go @@ -0,0 +1,69 @@ +package rpcserver + +import ( + "fmt" + "time" + + "github.com/VictoriaMetrics/metrics" +) + +const ( + // we use unknown method label for methods that server does not support because otherwise + // users can create arbitrary number of metrics + unknownMethodLabel = "unknown" + + // incremented when user made incorrect request + incorrectRequestCounter = `goutils_rpcserver_incorrect_request_total{server_name="%s"}` + + // incremented when server has a bug (e.g. can't marshall response) + internalErrorsCounter = `goutils_rpcserver_internal_errors_total{server_name="%s"}` + + // incremented when request comes in + requestCountLabel = `goutils_rpcserver_request_count{method="%s",server_name="%s",is_big="%t"}` + // incremented when handler method returns JSONRPC error + errorCountLabel = `goutils_rpcserver_error_count{method="%s",server_name="%s"}` + // total duration of the request + requestDurationLabel = `goutils_rpcserver_request_duration_milliseconds{method="%s",server_name="%s",is_big="%t"}` + // partial duration of the request + requestDurationStepLabel = `goutils_rpcserver_request_step_duration_milliseconds{method="%s",server_name="%s",step="%s",is_big="%t"}` + + // request size in bytes + requestSizeBytes = `goutils_rpcserver_request_size_bytes{method="%s",server_name="%s"}` +) + +func incRequestCount(method, serverName string, isBig bool) { + l := fmt.Sprintf(requestCountLabel, method, serverName, isBig) + metrics.GetOrCreateCounter(l).Inc() +} + +func incIncorrectRequest(serverName string) { + l := fmt.Sprintf(incorrectRequestCounter, serverName) + metrics.GetOrCreateCounter(l).Inc() +} + +func incRequestErrorCount(method, serverName string) { + l := fmt.Sprintf(errorCountLabel, method, serverName) + metrics.GetOrCreateCounter(l).Inc() +} + +func incRequestDuration(duration time.Duration, method string, serverName string, isBig bool) { + millis := float64(duration.Microseconds()) / 1000.0 + l := fmt.Sprintf(requestDurationLabel, method, serverName, isBig) + metrics.GetOrCreateSummary(l).Update(millis) +} + +func incInternalErrors(serverName string) { + l := fmt.Sprintf(internalErrorsCounter, serverName) + metrics.GetOrCreateCounter(l).Inc() +} + +func incRequestDurationStep(duration time.Duration, method, serverName, step string, isBig bool) { + millis := float64(duration.Microseconds()) / 1000.0 + l := fmt.Sprintf(requestDurationStepLabel, method, serverName, step, isBig) + metrics.GetOrCreateSummary(l).Update(millis) +} + +func incRequestSizeBytes(size int, method string, serverName string) { + l := fmt.Sprintf(requestSizeBytes, method, serverName) + metrics.GetOrCreateSummary(l).Update(float64(size)) +} diff --git a/rpcserver/reflect.go b/rpcserver/reflect.go new file mode 100644 index 0000000..ca70695 --- /dev/null +++ b/rpcserver/reflect.go @@ -0,0 +1,120 @@ +package rpcserver + +import ( + "context" + "errors" + "reflect" + + "github.com/goccy/go-json" +) + +var ( + ErrNotFunction = errors.New("not a function") + ErrMustReturnError = errors.New("function must return error as a last return value") + ErrMustHaveContext = errors.New("function must have context.Context as a first argument") + ErrTooManyReturnValues = errors.New("too many return values") + ErrVariadicArgument = errors.New("unsupported function signature: functions with variadic arguments support one and only variadic argument") + + ErrTooMuchArguments = errors.New("too much arguments") +) + +type methodHandler struct { + in []reflect.Type + out []reflect.Type + fn any + isVariadic bool +} + +func getMethodTypes(fn interface{}) (methodHandler, error) { + fnType := reflect.TypeOf(fn) + if fnType.Kind() != reflect.Func { + return methodHandler{}, ErrNotFunction + } + isVariadic := fnType.IsVariadic() + numIn := fnType.NumIn() + in := make([]reflect.Type, numIn) + for i := 0; i < numIn; i++ { + in[i] = fnType.In(i) + } + // first input argument must be context.Context + if numIn == 0 || in[0] != reflect.TypeOf((*context.Context)(nil)).Elem() { + return methodHandler{}, ErrMustHaveContext + } + if numIn != 2 && isVariadic { + return methodHandler{}, ErrVariadicArgument + } + + numOut := fnType.NumOut() + out := make([]reflect.Type, numOut) + for i := 0; i < numOut; i++ { + out[i] = fnType.Out(i) + } + + // function must contain error as a last return value + if numOut == 0 || !out[numOut-1].Implements(reflect.TypeOf((*error)(nil)).Elem()) { + return methodHandler{}, ErrMustReturnError + } + + // function can return only one value + if numOut > 2 { + return methodHandler{}, ErrTooManyReturnValues + } + + return methodHandler{in, out, fn, isVariadic}, nil +} + +func (h methodHandler) call(ctx context.Context, params []json.RawMessage) (any, error) { + var funcArgTypes []reflect.Type + if len(h.in) == 2 && h.isVariadic { + funcArgTypes = make([]reflect.Type, len(params)) + for i := range funcArgTypes { + funcArgTypes[i] = h.in[1].Elem() + } + } else { + funcArgTypes = h.in[1:] + } + args, err := extractArgumentsFromJSONparamsArray(funcArgTypes, params) + if err != nil { + return nil, err + } + + // prepend context.Context + args = append([]reflect.Value{reflect.ValueOf(ctx)}, args...) + + // call function + results := reflect.ValueOf(h.fn).Call(args) + + // check error + var outError error + if !results[len(results)-1].IsNil() { + errVal, ok := results[len(results)-1].Interface().(error) + if !ok { + return nil, ErrMustReturnError + } + outError = errVal + } + + if len(results) == 1 { + return nil, outError + } else { + return results[0].Interface(), outError + } +} + +func extractArgumentsFromJSONparamsArray(in []reflect.Type, params []json.RawMessage) ([]reflect.Value, error) { + if len(params) > len(in) { + return nil, ErrTooMuchArguments + } + + args := make([]reflect.Value, len(in)) + for i, argType := range in { + arg := reflect.New(argType) + if i < len(params) { + if err := json.Unmarshal(params[i], arg.Interface()); err != nil { + return nil, err + } + } + args[i] = arg.Elem() + } + return args, nil +} diff --git a/rpcserver/reflect_test.go b/rpcserver/reflect_test.go new file mode 100644 index 0000000..8609dd7 --- /dev/null +++ b/rpcserver/reflect_test.go @@ -0,0 +1,233 @@ +package rpcserver + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/goccy/go-json" + + "github.com/stretchr/testify/require" +) + +type ctxKey string + +func rawParams(raw string) []json.RawMessage { + var params []json.RawMessage + err := json.Unmarshal([]byte(raw), ¶ms) + if err != nil { + panic(err) + } + return params +} + +func TestGetMethodTypes(t *testing.T) { + funcWithTypes := func(ctx context.Context, arg1 int, arg2 float32) error { + return nil + } + methodTypes, err := getMethodTypes(funcWithTypes) + require.NoError(t, err) + require.Equal(t, 3, len(methodTypes.in)) + require.Equal(t, 1, len(methodTypes.out)) + + funcWithoutArgs := func(ctx context.Context) error { + return nil + } + methodTypes, err = getMethodTypes(funcWithoutArgs) + require.NoError(t, err) + + funcWithouCtx := func(arg1 int, arg2 float32) error { + return nil + } + methodTypes, err = getMethodTypes(funcWithouCtx) + require.ErrorIs(t, err, ErrMustHaveContext) + + funcWithouError := func(ctx context.Context, arg1 int, arg2 float32) (int, float32) { + return 0, 0 + } + methodTypes, err = getMethodTypes(funcWithouError) + require.ErrorIs(t, err, ErrMustReturnError) + + funcWithTooManyReturnValues := func(ctx context.Context, arg1 int, arg2 float32) (int, float32, error) { + return 0, 0, nil + } + methodTypes, err = getMethodTypes(funcWithTooManyReturnValues) + require.ErrorIs(t, err, ErrTooManyReturnValues) +} + +type dummyStruct struct { + Field int `json:"field"` +} + +func TestExtractArgumentsFromJSON(t *testing.T) { + funcWithTypes := func(context.Context, int, float32, []int, dummyStruct) error { + return nil + } + methodTypes, err := getMethodTypes(funcWithTypes) + require.NoError(t, err) + + jsonArgs := rawParams(`[1, 2.0, [2, 3, 5], {"field": 11}]`) + args, err := extractArgumentsFromJSONparamsArray(methodTypes.in[1:], jsonArgs) + require.NoError(t, err) + require.Equal(t, 4, len(args)) + require.Equal(t, int(1), args[0].Interface()) + require.Equal(t, float32(2.0), args[1].Interface()) + require.Equal(t, []int{2, 3, 5}, args[2].Interface()) + require.Equal(t, dummyStruct{Field: 11}, args[3].Interface()) + + funcWithoutArgs := func(context.Context) error { + return nil + } + methodTypes, err = getMethodTypes(funcWithoutArgs) + require.NoError(t, err) + jsonArgs = rawParams(`[]`) + args, err = extractArgumentsFromJSONparamsArray(methodTypes.in[1:], jsonArgs) + require.NoError(t, err) + require.Equal(t, 0, len(args)) +} + +func TestCall_old(t *testing.T) { + var ( + errorArg = 0 + errorOut = errors.New("function error") //nolint:goerr113 + ) + funcWithTypes := func(ctx context.Context, arg int) (dummyStruct, error) { + value := ctx.Value(ctxKey("key")).(string) //nolint:forcetypeassert + require.Equal(t, "value", value) + + if arg == errorArg { + return dummyStruct{}, errorOut + } + return dummyStruct{arg}, nil + } + methodTypes, err := getMethodTypes(funcWithTypes) + require.NoError(t, err) + + ctx := context.WithValue(context.Background(), ctxKey("key"), "value") + + jsonArgs := rawParams(`[1]`) + result, err := methodTypes.call(ctx, jsonArgs) + require.NoError(t, err) + require.Equal(t, dummyStruct{1}, result) + + jsonArgs = rawParams(fmt.Sprintf(`[%d]`, errorArg)) + result, err = methodTypes.call(ctx, jsonArgs) + require.ErrorIs(t, err, errorOut) + require.Equal(t, dummyStruct{}, result) +} + +func TestCall(t *testing.T) { + // for testing error return + var ( + errorArg = 0 + errorOut = errors.New("function error") //nolint:goerr113 + ) + functionWithTypes := func(ctx context.Context, arg int) (dummyStruct, error) { + // test context + value := ctx.Value(ctxKey("key")).(string) //nolint:forcetypeassert + require.Equal(t, "value", value) + + if arg == errorArg { + return dummyStruct{}, errorOut + } + return dummyStruct{arg}, nil + } + functionNoArgs := func(ctx context.Context) (dummyStruct, error) { + // test context + value := ctx.Value(ctxKey("key")).(string) //nolint:forcetypeassert + require.Equal(t, "value", value) + + return dummyStruct{1}, nil + } + functionNoArgsError := func(ctx context.Context) (dummyStruct, error) { + // test context + value := ctx.Value(ctxKey("key")).(string) //nolint:forcetypeassert + require.Equal(t, "value", value) + + return dummyStruct{}, errorOut + } + functionNoReturn := func(ctx context.Context, arg int) error { + // test context + value := ctx.Value(ctxKey("key")).(string) //nolint:forcetypeassert + require.Equal(t, "value", value) + return nil + } + functonNoReturnError := func(ctx context.Context, arg int) error { + // test context + value := ctx.Value(ctxKey("key")).(string) //nolint:forcetypeassert + require.Equal(t, "value", value) + + return errorOut + } + functionVariadic := func(ctx context.Context, args ...int) error { + return nil + } + + testCases := map[string]struct { + function interface{} + args string + expectedValue interface{} + expectedError error + }{ + "functionWithTypes": { + function: functionWithTypes, + args: `[1]`, + expectedValue: dummyStruct{1}, + expectedError: nil, + }, + "functionWithTypesError": { + function: functionWithTypes, + args: fmt.Sprintf(`[%d]`, errorArg), + expectedValue: dummyStruct{}, + expectedError: errorOut, + }, + "functionNoArgs": { + function: functionNoArgs, + args: `[]`, + expectedValue: dummyStruct{1}, + expectedError: nil, + }, + "functionNoArgsError": { + function: functionNoArgsError, + args: `[]`, + expectedValue: dummyStruct{}, + expectedError: errorOut, + }, + "functionNoReturn": { + function: functionNoReturn, + args: `[1]`, + expectedValue: nil, + expectedError: nil, + }, + "functionNoReturnError": { + function: functonNoReturnError, + args: `[1]`, + expectedValue: nil, + expectedError: errorOut, + }, + "functionVariadic": { + function: functionVariadic, + args: "[1,2,3]", + expectedValue: nil, + expectedError: nil, + }, + } + + for testName, testCase := range testCases { + t.Run(testName, func(t *testing.T) { + methodTypes, err := getMethodTypes(testCase.function) + require.NoError(t, err) + + ctx := context.WithValue(context.Background(), ctxKey("key"), "value") + + result, err := methodTypes.call(ctx, rawParams(testCase.args)) + if testCase.expectedError == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, testCase.expectedError) + } + require.Equal(t, testCase.expectedValue, result) + }) + } +} diff --git a/rpctypes/types.go b/rpctypes/types.go new file mode 100644 index 0000000..55df014 --- /dev/null +++ b/rpctypes/types.go @@ -0,0 +1,445 @@ +// Package rpctypes implement types commonly used in the Flashbots codebase for receiving and senging requests +package rpctypes + +import ( + "bytes" + "crypto/sha256" + "encoding/binary" + "errors" + "hash" + "math/big" + "sort" + + "github.com/goccy/go-json" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core/types" + "github.com/google/uuid" + "golang.org/x/crypto/sha3" +) + +// Note on optional Signer field: +// * when receiving from Flashbots or other builders this field should be set +// * otherwise its set from the request signature by orderflow proxy +// in this case it can be empty! @should we prohibit that? + +// eth_SendBundle + +const ( + BundleTxLimit = 100 + MevBundleTxLimit = 50 + MevBundleMaxDepth = 1 + BundleVersionV1 = "v1" + BundleVersionV2 = "v2" +) + +var ( + ErrBundleNoTxs = errors.New("bundle with no txs") + ErrBundleTooManyTxs = errors.New("too many txs in bundle") + ErrMevBundleUnmatchedTx = errors.New("mev bundle with unmatched tx") + ErrMevBundleTooDeep = errors.New("mev bundle too deep") + ErrUnsupportedBundleVersion = errors.New("unsupported bundle version") +) + +type EthSendBundleArgs struct { + Txs []hexutil.Bytes `json:"txs"` + BlockNumber *hexutil.Uint64 `json:"blockNumber"` + MinTimestamp *uint64 `json:"minTimestamp,omitempty"` + MaxTimestamp *uint64 `json:"maxTimestamp,omitempty"` + RevertingTxHashes []common.Hash `json:"revertingTxHashes,omitempty"` + ReplacementUUID *string `json:"replacementUuid,omitempty"` + Version *string `json:"version,omitempty"` + + ReplacementNonce *uint64 `json:"replacementNonce,omitempty"` + SigningAddress *common.Address `json:"signingAddress,omitempty"` // may or may not be respected depending on the context + RefundIdentity *common.Address `json:"refundIdentity,omitempty"` // metadata field to improve redistribution ux + + DroppingTxHashes []common.Hash `json:"droppingTxHashes,omitempty"` + UUID *string `json:"uuid,omitempty"` + RefundPercent *uint64 `json:"refundPercent,omitempty"` + RefundRecipient *common.Address `json:"refundRecipient,omitempty"` + RefundTxHashes []string `json:"refundTxHashes,omitempty"` +} + +const ( + RevertModeAllow = "allow" + RevertModeDrop = "drop" + RevertModeFail = "fail" +) + +type MevBundleInclusion struct { + BlockNumber hexutil.Uint64 `json:"block"` + MaxBlock hexutil.Uint64 `json:"maxBlock"` +} + +type MevBundleBody struct { + Hash *common.Hash `json:"hash,omitempty"` + Tx *hexutil.Bytes `json:"tx,omitempty"` + Bundle *MevSendBundleArgs `json:"bundle,omitempty"` + CanRevert bool `json:"canRevert,omitempty"` + RevertMode string `json:"revertMode,omitempty"` +} + +type MevBundleValidity struct { + Refund []RefundConstraint `json:"refund,omitempty"` + RefundConfig []RefundConfig `json:"refundConfig,omitempty"` +} + +type RefundConstraint struct { + BodyIdx int `json:"bodyIdx"` + Percent int `json:"percent"` +} + +type RefundConfig struct { + Address common.Address `json:"address"` + Percent int `json:"percent"` +} + +type MevBundleMetadata struct { + // Signer should be set by infra that verifies user signatures and not user + Signer *common.Address `json:"signer,omitempty"` + ReplacementNonce *int `json:"replacementNonce,omitempty"` + // Used for cancelling. When true the only thing we care about is signer,replacement_nonce and RawShareBundle::replacement_uuid + Cancelled *bool `json:"cancelled,omitempty"` +} + +type MevSendBundleArgs struct { + Version string `json:"version"` + ReplacementUUID string `json:"replacementUuid,omitempty"` + Inclusion MevBundleInclusion `json:"inclusion"` + // when empty its considered cancel + Body []MevBundleBody `json:"body"` + Validity MevBundleValidity `json:"validity"` + Metadata *MevBundleMetadata `json:"metadata,omitempty"` + + // must be empty + Privacy *json.RawMessage `json:"privacy,omitempty"` +} + +// eth_sendRawTransaction + +type EthSendRawTransactionArgs hexutil.Bytes + +func (tx EthSendRawTransactionArgs) MarshalText() ([]byte, error) { + return hexutil.Bytes(tx).MarshalText() +} + +func (tx *EthSendRawTransactionArgs) UnmarshalJSON(input []byte) error { + return (*hexutil.Bytes)(tx).UnmarshalJSON(input) +} + +func (tx *EthSendRawTransactionArgs) UnmarshalText(input []byte) error { + return (*hexutil.Bytes)(tx).UnmarshalText(input) +} + +// eth_cancelBundle + +type EthCancelBundleArgs struct { + ReplacementUUID string `json:"replacementUuid"` + SigningAddress *common.Address `json:"signingAddress"` +} + +// bid_subsidiseBlock + +type BidSubsisideBlockArgs uint64 + +/// unique key +/// unique key is used to deduplicate requests, its will give different results then bundle uuid + +func newHash() hash.Hash { + return sha256.New() +} + +func uuidFromHash(h hash.Hash) uuid.UUID { + version := 5 + s := h.Sum(nil) + var uuid uuid.UUID + copy(uuid[:], s) + uuid[6] = (uuid[6] & 0x0f) | uint8((version&0xf)<<4) + uuid[8] = (uuid[8] & 0x3f) | 0x80 // RFC 4122 variant + return uuid +} + +func (b *EthSendBundleArgs) UniqueKey() uuid.UUID { + blockNumber := uint64(0) + if b.BlockNumber != nil { + blockNumber = uint64(*b.BlockNumber) + } + hash := newHash() + _ = binary.Write(hash, binary.LittleEndian, blockNumber) + for _, tx := range b.Txs { + _, _ = hash.Write(tx) + } + if b.MinTimestamp != nil { + _ = binary.Write(hash, binary.LittleEndian, b.MinTimestamp) + } + if b.MaxTimestamp != nil { + _ = binary.Write(hash, binary.LittleEndian, b.MaxTimestamp) + } + sort.Slice(b.RevertingTxHashes, func(i, j int) bool { + return bytes.Compare(b.RevertingTxHashes[i][:], b.RevertingTxHashes[j][:]) <= 0 + }) + for _, txHash := range b.RevertingTxHashes { + _, _ = hash.Write(txHash.Bytes()) + } + if b.ReplacementUUID != nil { + _, _ = hash.Write([]byte(*b.ReplacementUUID)) + } + if b.ReplacementNonce != nil { + _ = binary.Write(hash, binary.LittleEndian, *b.ReplacementNonce) + } + + sort.Slice(b.DroppingTxHashes, func(i, j int) bool { + return bytes.Compare(b.DroppingTxHashes[i][:], b.DroppingTxHashes[j][:]) <= 0 + }) + for _, txHash := range b.DroppingTxHashes { + _, _ = hash.Write(txHash.Bytes()) + } + if b.RefundPercent != nil { + _ = binary.Write(hash, binary.LittleEndian, *b.RefundPercent) + } + + if b.RefundRecipient != nil { + _, _ = hash.Write(b.RefundRecipient.Bytes()) + } + for _, txHash := range b.RefundTxHashes { + _, _ = hash.Write([]byte(txHash)) + } + + if b.SigningAddress != nil { + _, _ = hash.Write(b.SigningAddress.Bytes()) + } + return uuidFromHash(hash) +} + +func (b *EthSendBundleArgs) Validate() (common.Hash, uuid.UUID, error) { + blockNumber := uint64(0) + if b.BlockNumber != nil { + blockNumber = uint64(*b.BlockNumber) + } + if len(b.Txs) > BundleTxLimit { + return common.Hash{}, uuid.Nil, ErrBundleTooManyTxs + } + // first compute keccak hash over the txs + hasher := sha3.NewLegacyKeccak256() + for _, rawTx := range b.Txs { + var tx types.Transaction + if err := tx.UnmarshalBinary(rawTx); err != nil { + return common.Hash{}, uuid.Nil, err + } + hasher.Write(tx.Hash().Bytes()) + } + hashBytes := hasher.Sum(nil) + + if b.Version == nil || *b.Version == BundleVersionV1 { + // then compute the uuid + var buf []byte + buf = binary.AppendVarint(buf, int64(blockNumber)) + buf = append(buf, hashBytes...) + sort.Slice(b.RevertingTxHashes, func(i, j int) bool { + return bytes.Compare(b.RevertingTxHashes[i][:], b.RevertingTxHashes[j][:]) <= 0 + }) + for _, txHash := range b.RevertingTxHashes { + buf = append(buf, txHash[:]...) + } + return common.BytesToHash(hashBytes), + uuid.NewHash(sha256.New(), uuid.Nil, buf, 5), + nil + } + + if *b.Version == BundleVersionV2 { + // blockNumber, default 0 + blockNumber := uint64(0) + if b.BlockNumber != nil { + blockNumber = uint64(*b.BlockNumber) + } + + // minTimestamp, default 0 + minTimestamp := uint64(0) + if b.MinTimestamp != nil { + minTimestamp = *b.MinTimestamp + } + + // maxTimestamp, default ^uint64(0) (i.e. 0xFFFFFFFFFFFFFFFF in Rust) + maxTimestamp := ^uint64(0) + if b.MaxTimestamp != nil { + maxTimestamp = *b.MaxTimestamp + } + + // Build up our buffer using variable-length encoding of the block + // number, minTimestamp, maxTimestamp, #revertingTxHashes, #droppingTxHashes. + var buf []byte + buf = binary.AppendUvarint(buf, blockNumber) + buf = binary.AppendUvarint(buf, minTimestamp) + buf = binary.AppendUvarint(buf, maxTimestamp) + buf = binary.AppendUvarint(buf, uint64(len(b.RevertingTxHashes))) + buf = binary.AppendUvarint(buf, uint64(len(b.DroppingTxHashes))) + + // Append the main txs keccak hash (already computed in hashBytes). + buf = append(buf, hashBytes...) + + // Sort revertingTxHashes and append them. + sort.Slice(b.RevertingTxHashes, func(i, j int) bool { + return bytes.Compare(b.RevertingTxHashes[i][:], b.RevertingTxHashes[j][:]) < 0 + }) + for _, h := range b.RevertingTxHashes { + buf = append(buf, h[:]...) + } + + // Sort droppingTxHashes and append them. + sort.Slice(b.DroppingTxHashes, func(i, j int) bool { + return bytes.Compare(b.DroppingTxHashes[i][:], b.DroppingTxHashes[j][:]) < 0 + }) + for _, h := range b.DroppingTxHashes { + buf = append(buf, h[:]...) + } + + // If a "refund" is present (analogous to the Rust code), we push: + // refundPercent (1 byte) + // refundRecipient (20 bytes, if an Ethereum address) + // #refundTxHashes (varint) + // each refundTxHash (32 bytes) + // NOTE: The Rust code uses a single byte for `refund.percent`, + // so we do the same here + if b.RefundPercent != nil && *b.RefundPercent != 0 { + if len(b.Txs) == 0 { + // Bundle with not txs can't be refund-recipient + return common.Hash{}, uuid.Nil, ErrBundleNoTxs + } + + // We only keep the low 8 bits of RefundPercent (mimicking Rust's `buff.push(u8)`). + buf = append(buf, byte(*b.RefundPercent)) + + refundRecipient := b.RefundRecipient + if refundRecipient == nil { + var tx types.Transaction + if err := tx.UnmarshalBinary(b.Txs[0]); err != nil { + return common.Hash{}, uuid.Nil, err + } + from, err := types.Sender(types.LatestSignerForChainID(big.NewInt(1)), &tx) + if err != nil { + return common.Hash{}, uuid.Nil, err + } + refundRecipient = &from + } + bts := [20]byte(*refundRecipient) + // RefundRecipient is a common.Address, which is 20 bytes in geth. + buf = append(buf, bts[:]...) + + var refundTxHashes []common.Hash + for _, rth := range b.RefundTxHashes { + // decode from hex + refundTxHashes = append(refundTxHashes, common.HexToHash(rth)) + } + + if len(refundTxHashes) == 0 { + var lastTx types.Transaction + if err := lastTx.UnmarshalBinary(b.Txs[len(b.Txs)-1]); err != nil { + return common.Hash{}, uuid.Nil, err + } + refundTxHashes = []common.Hash{lastTx.Hash()} + } + + // #refundTxHashes + buf = binary.AppendUvarint(buf, uint64(len(refundTxHashes))) + + sort.Slice(refundTxHashes, func(i, j int) bool { + return bytes.Compare(refundTxHashes[i][:], refundTxHashes[j][:]) < 0 + }) + for _, h := range refundTxHashes { + buf = append(buf, h[:]...) + } + } + + // Now produce a UUID from `buf` using SHA-256 in the same way the Rust code calls + // `Self::uuid_from_buffer(buff)` (which is effectively a UUIDv5 but with SHA-256). + finalUUID := uuid.NewHash(sha256.New(), uuid.Nil, buf, 5) + + // Return the main txs keccak hash as well as the computed UUID + return common.BytesToHash(hashBytes), finalUUID, nil + } + + return common.Hash{}, uuid.Nil, ErrUnsupportedBundleVersion + +} + +func (b *MevSendBundleArgs) UniqueKey() uuid.UUID { + hash := newHash() + uniqueKeyMevSendBundle(b, hash) + return uuidFromHash(hash) +} + +func uniqueKeyMevSendBundle(b *MevSendBundleArgs, hash hash.Hash) { + hash.Write([]byte(b.ReplacementUUID)) + _ = binary.Write(hash, binary.LittleEndian, b.Inclusion.BlockNumber) + _ = binary.Write(hash, binary.LittleEndian, b.Inclusion.MaxBlock) + for _, body := range b.Body { + if body.Bundle != nil { + uniqueKeyMevSendBundle(body.Bundle, hash) + } else if body.Tx != nil { + hash.Write(*body.Tx) + } + // body.Hash should not occur at this point + if body.CanRevert { + hash.Write([]byte{1}) + } else { + hash.Write([]byte{0}) + } + hash.Write([]byte(body.RevertMode)) + } + _, _ = hash.Write(b.Metadata.Signer.Bytes()) +} + +func (b *MevSendBundleArgs) Validate() (common.Hash, error) { + // only cancell call can be without txs + // cancell call must have ReplacementUUID set + if len(b.Body) == 0 && b.ReplacementUUID == "" { + return common.Hash{}, ErrBundleNoTxs + } + return hashMevSendBundle(0, b) +} + +func hashMevSendBundle(level int, b *MevSendBundleArgs) (common.Hash, error) { + if level > MevBundleMaxDepth { + return common.Hash{}, ErrMevBundleTooDeep + } + hasher := sha3.NewLegacyKeccak256() + for _, body := range b.Body { + if body.Hash != nil { + return common.Hash{}, ErrMevBundleUnmatchedTx + } else if body.Bundle != nil { + innerHash, err := hashMevSendBundle(level+1, body.Bundle) + if err != nil { + return common.Hash{}, err + } + hasher.Write(innerHash.Bytes()) + } else if body.Tx != nil { + tx := new(types.Transaction) + if err := tx.UnmarshalBinary(*body.Tx); err != nil { + return common.Hash{}, err + } + hasher.Write(tx.Hash().Bytes()) + } + } + return common.BytesToHash(hasher.Sum(nil)), nil +} + +func (tx *EthSendRawTransactionArgs) UniqueKey() uuid.UUID { + hash := newHash() + _, _ = hash.Write(*tx) + return uuidFromHash(hash) +} + +func (b *EthCancelBundleArgs) UniqueKey() uuid.UUID { + hash := newHash() + _, _ = hash.Write([]byte(b.ReplacementUUID)) + _, _ = hash.Write(b.SigningAddress.Bytes()) + return uuidFromHash(hash) +} + +func (b *BidSubsisideBlockArgs) UniqueKey() uuid.UUID { + hash := newHash() + _ = binary.Write(hash, binary.LittleEndian, uint64(*b)) + return uuidFromHash(hash) +} diff --git a/rpctypes/types_test.go b/rpctypes/types_test.go new file mode 100644 index 0000000..cfc403e --- /dev/null +++ b/rpctypes/types_test.go @@ -0,0 +1,267 @@ +package rpctypes + +import ( + "fmt" + "testing" + + "github.com/goccy/go-json" + + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/stretchr/testify/require" +) + +func TestEthSendBundleArgsValidate(t *testing.T) { + // from https://github.com/flashbots/rbuilder/blob/develop/crates/rbuilder/src/primitives/serialize.rs#L607 + inputs := []struct { + Payload json.RawMessage + ExpectedHash string + ExpectedUUID string + ExpectedUniqueKey string + }{ + { + Payload: []byte(`{ + "blockNumber": "0x1136F1F", + "txs": ["0x02f9037b018203cd8405f5e1008503692da370830388ba943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad8780e531581b77c4b903043593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000064f390d300000000000000000000000000000000000000000000000000000000000000030b090c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000009184e72a0000000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b5ea574dd8f2b735424dfc8c4e16760fc44a931b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000c001a0a9ea84ad107d335afd5e5d2ddcc576f183be37386a9ac6c9d4469d0329c22e87a06a51ea5a0809f43bf72d0156f1db956da3a9f3da24b590b7eed01128ff84a2c1"], + "revertingTxHashes": ["0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"] + }`), + ExpectedHash: "0xcf3c567aede099e5455207ed81c4884f72a4c0c24ddca331163a335525cd22cc", + ExpectedUUID: "d9a3ae52-79a2-5ce9-a687-e2aa4183d5c6", + }, + { + Payload: []byte(`{ + "blockNumber": "0x1136F1F", + "txs": ["0x02f9037b018203cd8405f5e1008503692da370830388ba943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad8780e531581b77c4b903043593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000064f390d300000000000000000000000000000000000000000000000000000000000000030b090c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000009184e72a0000000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b5ea574dd8f2b735424dfc8c4e16760fc44a931b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000c001a0a9ea84ad107d335afd5e5d2ddcc576f183be37386a9ac6c9d4469d0329c22e87a06a51ea5a0809f43bf72d0156f1db956da3a9f3da24b590b7eed01128ff84a2c1"], + "revertingTxHashes": ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"] + }`), + ExpectedHash: "0xcf3c567aede099e5455207ed81c4884f72a4c0c24ddca331163a335525cd22cc", + ExpectedUUID: "d9a3ae52-79a2-5ce9-a687-e2aa4183d5c6", + }, + { + Payload: []byte(`{ + "blockNumber": "0xA136F1F", + "txs": ["0x02f9037b018203cd8405f5e1008503692da370830388ba943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad8780e531581b77c4b903043593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000064f390d300000000000000000000000000000000000000000000000000000000000000030b090c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000009184e72a0000000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b5ea574dd8f2b735424dfc8c4e16760fc44a931b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000c001a0a9ea84ad107d335afd5e5d2ddcc576f183be37386a9ac6c9d4469d0329c22e87a06a51ea5a0809f43bf72d0156f1db956da3a9f3da24b590b7eed01128ff84a2c1"], + "revertingTxHashes": [] + }`), + ExpectedHash: "0xcf3c567aede099e5455207ed81c4884f72a4c0c24ddca331163a335525cd22cc", + ExpectedUUID: "5d5bf52c-ac3f-57eb-a3e9-fc01b18ca516", + }, + { + Payload: []byte(`{ + "txs": ["0x02f9037b018203cd8405f5e1008503692da370830388ba943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad8780e531581b77c4b903043593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000064f390d300000000000000000000000000000000000000000000000000000000000000030b090c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000009184e72a0000000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b5ea574dd8f2b735424dfc8c4e16760fc44a931b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000c001a0a9ea84ad107d335afd5e5d2ddcc576f183be37386a9ac6c9d4469d0329c22e87a06a51ea5a0809f43bf72d0156f1db956da3a9f3da24b590b7eed01128ff84a2c1"], + "revertingTxHashes": [] + }`), + ExpectedHash: "0xcf3c567aede099e5455207ed81c4884f72a4c0c24ddca331163a335525cd22cc", + ExpectedUUID: "e9ced844-16d5-5884-8507-db9338950c5c", + }, + { + Payload: []byte(`{ + "blockNumber": "0x0", + "txs": ["0x02f9037b018203cd8405f5e1008503692da370830388ba943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad8780e531581b77c4b903043593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000064f390d300000000000000000000000000000000000000000000000000000000000000030b090c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000009184e72a0000000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b5ea574dd8f2b735424dfc8c4e16760fc44a931b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000c001a0a9ea84ad107d335afd5e5d2ddcc576f183be37386a9ac6c9d4469d0329c22e87a06a51ea5a0809f43bf72d0156f1db956da3a9f3da24b590b7eed01128ff84a2c1"], + "revertingTxHashes": [] + }`), + ExpectedHash: "0xcf3c567aede099e5455207ed81c4884f72a4c0c24ddca331163a335525cd22cc", + ExpectedUUID: "e9ced844-16d5-5884-8507-db9338950c5c", + }, + { + Payload: []byte(`{ + "blockNumber": null, + "txs": ["0x02f9037b018203cd8405f5e1008503692da370830388ba943fc91a3afd70395cd496c647d5a6cc9d4b2b7fad8780e531581b77c4b903043593564c000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000064f390d300000000000000000000000000000000000000000000000000000000000000030b090c00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000003000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000009184e72a0000000000000000000000000000000000000000000000000000080e531581b77c400000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2000000000000000000000000b5ea574dd8f2b735424dfc8c4e16760fc44a931b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000000000000c001a0a9ea84ad107d335afd5e5d2ddcc576f183be37386a9ac6c9d4469d0329c22e87a06a51ea5a0809f43bf72d0156f1db956da3a9f3da24b590b7eed01128ff84a2c1"], + "revertingTxHashes": [] + }`), + ExpectedHash: "0xcf3c567aede099e5455207ed81c4884f72a4c0c24ddca331163a335525cd22cc", + ExpectedUUID: "e9ced844-16d5-5884-8507-db9338950c5c", + }, + // different empty bundles have the same uuid, they must have different unique key + { + Payload: []byte(`{ + "replacementUuid": "e9ced844-16d5-5884-8507-db9338950c5c" + }`), + ExpectedHash: "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + ExpectedUUID: "35718fe4-5d24-51c8-93bf-9c961d7c3ea3", + ExpectedUniqueKey: "1655edd0-29a6-5372-a19b-1ddedda14b20", + }, + { + Payload: []byte(`{ + "replacementUuid": "35718fe4-5d24-51c8-93bf-9c961d7c3ea3" + }`), + ExpectedHash: "0xc5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470", + ExpectedUUID: "35718fe4-5d24-51c8-93bf-9c961d7c3ea3", + ExpectedUniqueKey: "3c718cb9-3f6c-5dc0-9d99-264dafc0b4e9", + }, + { + Payload: []byte(` { + "version": "v2", + "txs": [ + "0x02f86b83aa36a780800982520894f24a01ae29dec4629dfb4170647c4ed4efc392cd861ca62a4c95b880c080a07d37bb5a4da153a6fbe24cf1f346ef35748003d1d0fc59cf6c17fb22d49e42cea02c231ac233220b494b1ad501c440c8b1a34535cdb8ca633992d6f35b14428672" + ], + "blockNumber": "0x0", + "minTimestamp": 123, + "maxTimestamp": 1234, + "revertingTxHashes": ["0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"], + "droppingTxHashes": ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + "refundPercent": 1, + "refundRecipient": "0x95222290dd7278aa3ddd389cc1e1d165cc4bafe5", + "refundTxHashes": ["0x75662ab9cb6d1be7334723db5587435616352c7e581a52867959ac24006ac1fe"] + }`), + ExpectedHash: "0xee3996920364173b0990f92cf6fbeb8a4ab832fe5549c1b728ac44aee0160f02", + ExpectedUUID: "e2bdb8cd-9473-5a1b-b425-57fa7ecfe2c1", + ExpectedUniqueKey: "a54c1e8f-936f-5868-bded-f5138c60b34a", + }, + { + Payload: []byte(` { + "version": "v2", + "txs": [ + "0x02f90408018303f1d4808483ab318e8304485c94a69babef1ca67a37ffaf7a485dfff3382056e78c8302be00b9014478e111f60000000000000000000000007f0f35bbf44c8343d14260372c469b331491567b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c4f4ff52950000000000000000000000000000000000000000000000000be75df44ebec5390000000000000000000000000000000000000000000036404c073ad050000000000000000000000000000000000000000000003e91fd871e8a6021ca93d911920000000000000000000000000000000000000000000000000000e91615b961030000000000000000000000000000000000000000000000000000000067eaa0b7ff8000000000000000000000000000000000000000000000000000000001229300000000000000000000000000000000000000000000000000000000f90253f9018394919fa96e88d67499339577fa202345436bcdaf79f9016ba0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513782a000000000000000000000000000000000000000000000000000000000000000a1a0bfd358e93f18da3ed276c3afdbdba00b8f0b6008a03476a6a86bd6320ee6938ba0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513785a00000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000000a0a00000000000000000000000000000000000000000000000000000000000000002a0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513783a0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513784a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000004f85994c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2f842a060802b93a9ac49b8c74d6ade12cf6235f4ac8c52c84fd39da757d0b2d720d76fa075245230289a9f0bf73a6c59aef6651b98b3833a62a3c0bd9ab6b0dec8ed4d8fd6947f0f35bbf44c8343d14260372c469b331491567bc0f85994d533a949740bb3306d119cc777fa900ba034cd52f842a07a7ff188ddb962db42160fb3fb573f4af0ebe1a1d6b701f1f1464b5ea43f7638a03d4653d86fe510221a71cfd2b1168b2e9af3e71339c63be5f905dabce97ee61f01a0c9d68ec80949077b6c28d45a6bf92727bc49d705d201bff8c62956201f5d3a81a036b7b953d7385d8fab8834722b7c66eea4a02a66434fc4f38ebfe8f5218a87b0" + ], + "blockNumber": "0x0", + "minTimestamp": 123, + "maxTimestamp": 1234, + "refundPercent": 20, + "refundRecipient": "0xFF82BF5238637B7E5E345888BaB9cd99F5Ebe331", + "refundTxHashes": ["0xffd9f02004350c16b312fd14ccc828f587c3c49ad3e9293391a398cc98c1a373"] + }`), + ExpectedHash: "0x90551b655a8a5b424064e802c0ec2daae864d8b786a788c2c6f9d7902feb42d2", + ExpectedUUID: "e785c7c0-8bfa-508e-9c3f-cb24f1638de3", + ExpectedUniqueKey: "fb7bff94-6f0d-5030-ab69-33adf953b742", + }, + { + Payload: []byte(` { + "version": "v2", + "txs": [ + "0x02f90408018303f1d4808483ab318e8304485c94a69babef1ca67a37ffaf7a485dfff3382056e78c8302be00b9014478e111f60000000000000000000000007f0f35bbf44c8343d14260372c469b331491567b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c4f4ff52950000000000000000000000000000000000000000000000000be75df44ebec5390000000000000000000000000000000000000000000036404c073ad050000000000000000000000000000000000000000000003e91fd871e8a6021ca93d911920000000000000000000000000000000000000000000000000000e91615b961030000000000000000000000000000000000000000000000000000000067eaa0b7ff8000000000000000000000000000000000000000000000000000000001229300000000000000000000000000000000000000000000000000000000f90253f9018394919fa96e88d67499339577fa202345436bcdaf79f9016ba0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513782a000000000000000000000000000000000000000000000000000000000000000a1a0bfd358e93f18da3ed276c3afdbdba00b8f0b6008a03476a6a86bd6320ee6938ba0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513785a00000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000000a0a00000000000000000000000000000000000000000000000000000000000000002a0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513783a0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513784a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000004f85994c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2f842a060802b93a9ac49b8c74d6ade12cf6235f4ac8c52c84fd39da757d0b2d720d76fa075245230289a9f0bf73a6c59aef6651b98b3833a62a3c0bd9ab6b0dec8ed4d8fd6947f0f35bbf44c8343d14260372c469b331491567bc0f85994d533a949740bb3306d119cc777fa900ba034cd52f842a07a7ff188ddb962db42160fb3fb573f4af0ebe1a1d6b701f1f1464b5ea43f7638a03d4653d86fe510221a71cfd2b1168b2e9af3e71339c63be5f905dabce97ee61f01a0c9d68ec80949077b6c28d45a6bf92727bc49d705d201bff8c62956201f5d3a81a036b7b953d7385d8fab8834722b7c66eea4a02a66434fc4f38ebfe8f5218a87b0" + ], + "blockNumber": "0x0", + "minTimestamp": 123, + "maxTimestamp": 1234, + "refundPercent": 20, + "refundRecipient": "0xFF82BF5238637B7E5E345888BaB9cd99F5Ebe331" + }`), + ExpectedHash: "0x90551b655a8a5b424064e802c0ec2daae864d8b786a788c2c6f9d7902feb42d2", + ExpectedUUID: "e785c7c0-8bfa-508e-9c3f-cb24f1638de3", + ExpectedUniqueKey: "", + }, + { + Payload: []byte(` { + "version": "v2", + "txs": [ + "0x02f90408018303f1d4808483ab318e8304485c94a69babef1ca67a37ffaf7a485dfff3382056e78c8302be00b9014478e111f60000000000000000000000007f0f35bbf44c8343d14260372c469b331491567b000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c4f4ff52950000000000000000000000000000000000000000000000000be75df44ebec5390000000000000000000000000000000000000000000036404c073ad050000000000000000000000000000000000000000000003e91fd871e8a6021ca93d911920000000000000000000000000000000000000000000000000000e91615b961030000000000000000000000000000000000000000000000000000000067eaa0b7ff8000000000000000000000000000000000000000000000000000000001229300000000000000000000000000000000000000000000000000000000f90253f9018394919fa96e88d67499339577fa202345436bcdaf79f9016ba0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513782a000000000000000000000000000000000000000000000000000000000000000a1a0bfd358e93f18da3ed276c3afdbdba00b8f0b6008a03476a6a86bd6320ee6938ba0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513785a00000000000000000000000000000000000000000000000000000000000000001a000000000000000000000000000000000000000000000000000000000000000a0a00000000000000000000000000000000000000000000000000000000000000002a0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513783a0bf1c200cc6dee22da7e010c51ff8e5210da52f1c78d2171dbb5d4f739e513784a00000000000000000000000000000000000000000000000000000000000000000a00000000000000000000000000000000000000000000000000000000000000004f85994c02aaa39b223fe8d0a0e5c4f27ead9083c756cc2f842a060802b93a9ac49b8c74d6ade12cf6235f4ac8c52c84fd39da757d0b2d720d76fa075245230289a9f0bf73a6c59aef6651b98b3833a62a3c0bd9ab6b0dec8ed4d8fd6947f0f35bbf44c8343d14260372c469b331491567bc0f85994d533a949740bb3306d119cc777fa900ba034cd52f842a07a7ff188ddb962db42160fb3fb573f4af0ebe1a1d6b701f1f1464b5ea43f7638a03d4653d86fe510221a71cfd2b1168b2e9af3e71339c63be5f905dabce97ee61f01a0c9d68ec80949077b6c28d45a6bf92727bc49d705d201bff8c62956201f5d3a81a036b7b953d7385d8fab8834722b7c66eea4a02a66434fc4f38ebfe8f5218a87b0" + ], + "blockNumber": "0x0", + "minTimestamp": 123, + "maxTimestamp": 1234, + "refundPercent": 20 + }`), + ExpectedHash: "0x90551b655a8a5b424064e802c0ec2daae864d8b786a788c2c6f9d7902feb42d2", + ExpectedUUID: "e785c7c0-8bfa-508e-9c3f-cb24f1638de3", + ExpectedUniqueKey: "", + }, + { + Payload: []byte(`{ + "version": "v2", + "txs": [ + "0x02f86b83aa36a780800982520894f24a01ae29dec4629dfb4170647c4ed4efc392cd861ca62a4c95b880c080a07d37bb5a4da153a6fbe24cf1f346ef35748003d1d0fc59cf6c17fb22d49e42cea02c231ac233220b494b1ad501c440c8b1a34535cdb8ca633992d6f35b14428672" + ], + "blockNumber": "0x0", + "revertingTxHashes": [] + }`), + ExpectedHash: "0xee3996920364173b0990f92cf6fbeb8a4ab832fe5549c1b728ac44aee0160f02", + ExpectedUUID: "22dc6bf0-9a12-5a76-9bbd-98ab77423415", + ExpectedUniqueKey: "", + }, + } + + for i, input := range inputs { + t.Run(fmt.Sprintf("inout-%d", i), func(t *testing.T) { + bundle := &EthSendBundleArgs{} + require.NoError(t, json.Unmarshal(input.Payload, bundle)) + hash, uuid, err := bundle.Validate() + uniqueKey := bundle.UniqueKey() + require.NoError(t, err) + require.Equal(t, input.ExpectedHash, hash.Hex()) + require.Equal(t, input.ExpectedUUID, uuid.String()) + if input.ExpectedUniqueKey != "" { + require.Equal(t, input.ExpectedUniqueKey, uniqueKey.String()) + } + }) + } +} + +func TestMevSendBundleArgsValidate(t *testing.T) { + // From: https://github.com/flashbots/rbuilder/blob/91f7a2c22eaeaf6c44e28c0bda98a2a0d566a6cb/crates/rbuilder/src/primitives/serialize.rs#L700 + // NOTE: I had to dump the hash in a debugger to get the expected hash since the test above uses a computed hash + raw := []byte(`{ + "version": "v0.1", + "inclusion": { + "block": "0x1" + }, + "body": [ + { + "bundle": { + "version": "v0.1", + "inclusion": { + "block": "0x1" + }, + "body": [ + { + "tx": "0x02f86b0180843b9aca00852ecc889a0082520894c87037874aed04e51c29f582394217a0a2b89d808080c080a0a463985c616dd8ee17d7ef9112af4e6e06a27b071525b42182fe7b0b5c8b4925a00af5ca177ffef2ff28449292505d41be578bebb77110dfc09361d2fb56998260", + "canRevert": true + }, + { + "tx": "0x02f8730180843b9aca00852ecc889a008288b894c10000000000000000000000000000000000000088016345785d8a000080c001a07c8890151fed9a826f241d5a37c84062ebc55ca7f5caef4683dcda6ac99dbffba069108de72e4051a764f69c51a6b718afeff4299107963a5d84d5207b2d6932a4" + } + ], + "validity": { + "refund": [ + { + "bodyIdx": 0, + "percent": 90 + } + ], + "refundConfig": [ + { + "address": "0x3e7dfb3e26a16e3dbf6dfeeff8a5ae7a04f73aad", + "percent": 100 + } + ] + } + } + }, + { + "tx": "0x02f8730101843b9aca00852ecc889a008288b894c10000000000000000000000000000000000000088016345785d8a000080c001a0650c394d77981e46be3d8cf766ecc435ec3706375baed06eb9bef21f9da2828da064965fdf88b91575cd74f20301649c9d011b234cefb6c1761cc5dd579e4750b1" + } + ], + "validity": { + "refund": [ + { + "bodyIdx": 0, + "percent": 80 + } + ] + }, + "metadata": { + "signer": "0x4696595f68034b47BbEc82dB62852B49a8EE7105" + } + }`) + + bundle := &MevSendBundleArgs{} + require.NoError(t, json.Unmarshal(raw, bundle)) + hash, err := bundle.Validate() + require.NoError(t, err) + require.Equal(t, "0x3b1994ad123d089f978074cfa197811b644e43b2b44b4c4710614f3a30ee0744", hash.Hex()) +} + +func TestEthsendRawTransactionArgsJSON(t *testing.T) { + data := hexutil.MustDecode("0x1234") + + rawTransaction := EthSendRawTransactionArgs(data) + + out, err := json.Marshal(rawTransaction) + require.NoError(t, err) + + require.Equal(t, `"0x1234"`, string(out)) + + var roundtripRawTransaction EthSendRawTransactionArgs + err = json.Unmarshal(out, &roundtripRawTransaction) + require.NoError(t, err) + require.Equal(t, rawTransaction, roundtripRawTransaction) +} diff --git a/signature/signature.go b/signature/signature.go new file mode 100644 index 0000000..24154d4 --- /dev/null +++ b/signature/signature.go @@ -0,0 +1,143 @@ +// Package signature provides functionality for interacting with the X-Flashbots-Signature header. +package signature + +import ( + "crypto/ecdsa" + "errors" + "fmt" + "strings" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" +) + +// HTTPHeader is the name of the X-Flashbots-Signature header. +const HTTPHeader = "X-Flashbots-Signature" + +var ( + ErrNoSignature = errors.New("no signature provided") + ErrInvalidSignature = errors.New("invalid signature provided") +) + +// Verify takes a X-Flashbots-Signature header and a body and verifies that the signature is valid for the body. +// It returns the signing address if the signature is valid or an error if the signature is invalid. +func Verify(header string, body []byte) (common.Address, error) { + if header == "" { + return common.Address{}, ErrNoSignature + } + + parsedSignerStr, parsedSignatureStr, found := strings.Cut(header, ":") + if !found { + return common.Address{}, fmt.Errorf("%w: missing separator", ErrInvalidSignature) + } + + parsedSignature, err := hexutil.Decode(parsedSignatureStr) + if err != nil || len(parsedSignature) == 0 { + return common.Address{}, fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + + if parsedSignature[len(parsedSignature)-1] >= 27 { + parsedSignature[len(parsedSignature)-1] -= 27 + } + if parsedSignature[len(parsedSignature)-1] > 1 { + return common.Address{}, fmt.Errorf("%w: invalid recovery id", ErrInvalidSignature) + } + + hashedBody := crypto.Keccak256Hash(body).Hex() + messageHash := accounts.TextHash([]byte(hashedBody)) + recoveredPublicKeyBytes, err := crypto.Ecrecover(messageHash, parsedSignature) + if err != nil { + return common.Address{}, fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + + recoveredPublicKey, err := crypto.UnmarshalPubkey(recoveredPublicKeyBytes) + if err != nil { + return common.Address{}, fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + recoveredSigner := crypto.PubkeyToAddress(*recoveredPublicKey) + + // case-insensitive equality check + parsedSigner := common.HexToAddress(parsedSignerStr) + if recoveredSigner.Cmp(parsedSigner) != 0 { + return common.Address{}, fmt.Errorf("%w: signing address mismatch", ErrInvalidSignature) + } + + signatureNoRecoverID := parsedSignature[:len(parsedSignature)-1] // remove recovery id + if !crypto.VerifySignature(recoveredPublicKeyBytes, messageHash, signatureNoRecoverID) { + return common.Address{}, fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + + return recoveredSigner, nil +} + +type Signer struct { + privateKey *ecdsa.PrivateKey + address common.Address + hexAddress string +} + +func NewSigner(privateKey *ecdsa.PrivateKey) Signer { + address := crypto.PubkeyToAddress(privateKey.PublicKey) + return Signer{ + privateKey: privateKey, + hexAddress: address.Hex(), + address: address, + } +} + +// NewSignerFromHexPrivateKey creates new signer from 0x-prefixed hex-encoded private key +func NewSignerFromHexPrivateKey(hexPrivateKey string) (*Signer, error) { + privateKeyBytes, err := hexutil.Decode(hexPrivateKey) + if err != nil { + return nil, err + } + + privateKey, err := crypto.ToECDSA(privateKeyBytes) + if err != nil { + return nil, err + } + + signer := NewSigner(privateKey) + return &signer, nil +} + +func NewRandomSigner() (*Signer, error) { + privateKey, err := crypto.GenerateKey() + if err != nil { + return nil, err + } + signer := NewSigner(privateKey) + return &signer, nil +} + +func (s *Signer) Address() common.Address { + return s.address +} + +// Create takes a body and a private key and returns a X-Flashbots-Signature header value. +// The header value can be included in a HTTP request to sign the body. +func (s *Signer) Create(body []byte) (string, error) { + signature, err := crypto.Sign( + accounts.TextHash([]byte(hexutil.Encode(crypto.Keccak256(body)))), + s.privateKey, + ) + if err != nil { + return "", err + } + // To maintain compatibility with the EVM `ecrecover` precompile, the recovery ID in the last + // byte is encoded as v = 27/28 instead of 0/1. This also ensures we generate the same signatures as other + // popular libraries like ethers.js, and tooling like `cast wallet sign` and MetaMask. + // + // See: + // - Yellow Paper, Appendix E & F. https://ethereum.github.io/yellowpaper/paper.pdf + // - https://www.evm.codes/precompiled (ecrecover is the 1st precompile at 0x01) + // + if signature[len(signature)-1] < 27 { + signature[len(signature)-1] += 27 + } + + header := fmt.Sprintf("%s:%s", s.hexAddress, hexutil.Encode(signature)) + return header, nil +} diff --git a/signature/signature_test.go b/signature/signature_test.go new file mode 100644 index 0000000..97e64ad --- /dev/null +++ b/signature/signature_test.go @@ -0,0 +1,212 @@ +package signature_test + +import ( + "fmt" + "testing" + + "github.com/ethereum/go-ethereum/accounts" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/crypto" + "github.com/flashbots/go-utils/signature" + "github.com/stretchr/testify/require" +) + +// TestSignatureVerify tests the signature verification function. +func TestSignatureVerify(t *testing.T) { + // For most of these test cases, we first need to generate a signature + privateKey, err := crypto.GenerateKey() + require.NoError(t, err) + + signerAddress := crypto.PubkeyToAddress(privateKey.PublicKey) + body := fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_getTransactionCount","params":["%s","pending"],"id":1}`, + signerAddress, + ) + + sig, err := crypto.Sign( + accounts.TextHash([]byte(hexutil.Encode(crypto.Keccak256([]byte(body))))), + privateKey, + ) + require.NoError(t, err) + + header := fmt.Sprintf("%s:%s", signerAddress, hexutil.Encode(sig)) + + t.Run("header is empty", func(t *testing.T) { + _, err := signature.Verify("", []byte{}) + require.ErrorIs(t, err, signature.ErrNoSignature) + }) + + t.Run("header is valid", func(t *testing.T) { + verifiedAddress, err := signature.Verify(header, []byte(body)) + require.NoError(t, err) + require.Equal(t, signerAddress, verifiedAddress) + }) + + t.Run("header is invalid", func(t *testing.T) { + _, err := signature.Verify("invalid", []byte(body)) + require.ErrorIs(t, err, signature.ErrInvalidSignature) + }) + + t.Run("header has extra bytes", func(t *testing.T) { + _, err := signature.Verify(header+"deadbeef", []byte(body)) + require.ErrorIs(t, err, signature.ErrInvalidSignature) + }) + + t.Run("header has missing bytes", func(t *testing.T) { + _, err := signature.Verify(header[:len(header)-8], []byte(body)) + require.ErrorIs(t, err, signature.ErrInvalidSignature) + }) + + t.Run("body is empty", func(t *testing.T) { + _, err := signature.Verify(header, []byte{}) + require.ErrorIs(t, err, signature.ErrInvalidSignature) + }) + + t.Run("body is invalid", func(t *testing.T) { + _, err := signature.Verify(header, []byte(`{}`)) + require.ErrorIs(t, err, signature.ErrInvalidSignature) + }) + + t.Run("body has extra bytes", func(t *testing.T) { + _, err := signature.Verify(header, []byte(body+"...")) + require.ErrorIs(t, err, signature.ErrInvalidSignature) + }) + + t.Run("body has missing bytes", func(t *testing.T) { + _, err := signature.Verify(header, []byte(body[:len(body)-8])) + require.ErrorIs(t, err, signature.ErrInvalidSignature) + }) +} + +// TestVerifySignatureFromMetaMask ensures that a signature generated by MetaMask +// can be verified by this package. +func TestVerifySignatureFromMetaMask(t *testing.T) { + // Source: use the "Sign Message" feature in Etherscan + // to sign the keccak256 hash of `Hello` + // Published to https://etherscan.io/verifySig/255560 + messageHash := crypto.Keccak256Hash([]byte("Hello")).Hex() + require.Equal(t, `0x06b3dfaec148fb1bb2b066f10ec285e7c9bf402ab32aa78a5d38e34566810cd2`, messageHash) + signerAddress := common.HexToAddress(`0x4bE0Cd2553356b4ABb8b6a1882325dAbC8d3013D`) + signatureHash := `0xbf36915334f8fa93894cd54d491c31a89dbf917e9a4402b2779b73d21ecf46e36ff07db2bef6d10e92c99a02c1c5ea700b0b674dfa5d3ce9220822a7ebcc17101b` + header := signerAddress.Hex() + ":" + signatureHash + verifiedAddress, err := signature.Verify( + header, + []byte(`Hello`), + ) + require.NoError(t, err) + require.Equal(t, signerAddress, verifiedAddress) +} + +// TestVerifySignatureFromCast ensures that the signature generated by the `cast` CLI +// can be verified by this package. +func TestVerifySignatureFromCast(t *testing.T) { + // Source: use `cast wallet sign` in the `cast` CLI + // to sign the keccak256 hash of `Hello`: + // `cast wallet sign --interactive $(cast from-utf8 $(cast keccak Hello))` + // NOTE: The call to from-utf8 is required as cast wallet sign + // interprets inputs with a leading 0x as a byte array, not a string. + // Published to https://etherscan.io/verifySig/255562 + messageHash := crypto.Keccak256Hash([]byte("Hello")).Hex() + require.Equal(t, `0x06b3dfaec148fb1bb2b066f10ec285e7c9bf402ab32aa78a5d38e34566810cd2`, messageHash) + signerAddress := common.HexToAddress(`0x2485Aaa7C5453e04658378358f5E028150Dc7606`) + signatureHash := `0xff2aa92eb8d8c2ca04f1755a4ddbff4bda6a5c9cefc8b706d5d8a21d3aa6fe7a20d3ec062fb5a4c1656fd2c14a8b33ca378b830d9b6168589bfee658e83745cc1b` + header := signerAddress.Hex() + ":" + signatureHash + verifiedAddress, err := signature.Verify( + header, + []byte(`Hello`), + ) + require.NoError(t, err) + require.Equal(t, signerAddress, verifiedAddress) +} + +// TestSignatureCreateAndVerify uses a randomly generated private key +// to create a signature and then verifies it. +func TestSignatureCreateAndVerify(t *testing.T) { + signer, err := signature.NewRandomSigner() + require.NoError(t, err) + + signerAddress := signer.Address() + body := fmt.Sprintf( + `{"jsonrpc":"2.0","method":"eth_getTransactionCount","params":["%s","pending"],"id":1}`, + signerAddress, + ) + + header, err := signer.Create([]byte(body)) + require.NoError(t, err) + + verifiedAddress, err := signature.Verify(header, []byte(body)) + require.NoError(t, err) + require.Equal(t, signerAddress, verifiedAddress) +} + +// TestSignatureCreateCompareToCastAndEthers uses a static private key +// and compares the signature generated by this package to the signatures +// generated by the `cast` CLI and ethers.js. +func TestSignatureCreateCompareToCastAndEthers(t *testing.T) { + // This purposefully uses the already highly compromised keypair from the go-ethereum book: + // https://goethereumbook.org/transfer-eth/ + // privateKey = fad9c8855b740a0b7ed4c221dbad0f33a83a49cad6b3fe8d5817ac83d38b6a19 + signer, err := signature.NewSignerFromHexPrivateKey("0xfad9c8855b740a0b7ed4c221dbad0f33a83a49cad6b3fe8d5817ac83d38b6a19") + require.NoError(t, err) + + address := signer.Address() + body := []byte("Hello") + + // I generated the signature using the cast CLI: + // + // cast wallet sign --private-key fad9c8855b740a0b7ed4c221dbad0f33a83a49cad6b3fe8d5817ac83d38b6a19 $(cast from-utf8 $(cast keccak Hello)) + // + // (As mentioned above, the call to from-utf8 is required as cast wallet + // sign interprets inputs with a leading 0x as a byte array, not a string.) + // + // As well as the following ethers script: + // + // import { Wallet } from "ethers"; + // import { id } from 'ethers/lib/utils' + // var w = new Wallet("0xfad9c8855b740a0b7ed4c221dbad0f33a83a49cad6b3fe8d5817ac83d38b6a19") + // `${ await w.getAddress() }:${ await w.signMessage(id("Hello")) }` + //'0x96216849c49358B10257cb55b28eA603c874b05E:0x1446053488f02d460c012c84c4091cd5054d98c6cfca01b65f6c1a72773e80e60b8a4931aeee7ed18ce3adb45b2107e8c59e25556c1f871a8334e30e5bddbed21c' + + expectedSignature := "0x1446053488f02d460c012c84c4091cd5054d98c6cfca01b65f6c1a72773e80e60b8a4931aeee7ed18ce3adb45b2107e8c59e25556c1f871a8334e30e5bddbed21c" + expectedAddress := common.HexToAddress("0x96216849c49358B10257cb55b28eA603c874b05E") + expectedHeader := fmt.Sprintf("%s:%s", expectedAddress, expectedSignature) + require.Equal(t, expectedAddress, address) + + header, err := signer.Create(body) + require.NoError(t, err) + require.Equal(t, expectedHeader, header) + + verifiedAddress, err := signature.Verify(header, body) + require.NoError(t, err) + require.Equal(t, expectedAddress, verifiedAddress) +} + +func BenchmarkSignatureCreate(b *testing.B) { + signer, err := signature.NewRandomSigner() + require.NoError(b, err) + + body := []byte("Hello") + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := signer.Create(body) + require.NoError(b, err) + } +} + +// benchmark signature verification +func BenchmarkSignatureVerify(b *testing.B) { + signer, err := signature.NewRandomSigner() + require.NoError(b, err) + + body := "body" + header, err := signer.Create([]byte(body)) + require.NoError(b, err) + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := signature.Verify(header, []byte(body)) + require.NoError(b, err) + } +} diff --git a/tls/tls_generate.go b/tls/tls_generate.go new file mode 100644 index 0000000..098a9be --- /dev/null +++ b/tls/tls_generate.go @@ -0,0 +1,122 @@ +// Package tls provides utilities for generating self-signed TLS certificates. +package tls + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net" + "os" + "time" +) + +// GetOrGenerateTLS tries to load a TLS certificate and key from the given paths, and if that fails, +// it generates a new self-signed certificate and key and saves it. +func GetOrGenerateTLS(certPath, certKeyPath string, validFor time.Duration, hosts []string) (cert, key []byte, err error) { + // Check if the certificate and key files exist + _, err1 := os.Stat(certPath) + _, err2 := os.Stat(certKeyPath) + if os.IsNotExist(err1) || os.IsNotExist(err2) { + // If either file does not exist, generate a new certificate and key + cert, key, err = GenerateTLS(validFor, hosts) + if err != nil { + return nil, nil, err + } + // Save the generated certificate and key to the specified paths + err = os.WriteFile(certPath, cert, 0644) + if err != nil { + return nil, nil, err + } + err = os.WriteFile(certKeyPath, key, 0600) + if err != nil { + return nil, nil, err + } + return cert, key, nil + } + + // The files exist, read them + cert, err = os.ReadFile(certPath) + if err != nil { + return nil, nil, err + } + key, err = os.ReadFile(certKeyPath) + if err != nil { + return nil, nil, err + } + + return cert, key, nil +} + +// GenerateTLS generated a TLS certificate and key. +// based on https://go.dev/src/crypto/tls/generate_cert.go +// - `hosts`: a list of ip / dns names to include in the certificate +func GenerateTLS(validFor time.Duration, hosts []string) (cert, key []byte, err error) { + priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, nil, err + } + keyUsage := x509.KeyUsageDigitalSignature + + notBefore := time.Now() + notAfter := notBefore.Add(validFor) + + serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) + serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) + if err != nil { + return nil, nil, err + } + + template := x509.Certificate{ + SerialNumber: serialNumber, + Subject: pkix.Name{ + Organization: []string{"Acme"}, + }, + NotBefore: notBefore, + NotAfter: notAfter, + + KeyUsage: keyUsage, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + for _, h := range hosts { + if ip := net.ParseIP(h); ip != nil { + template.IPAddresses = append(template.IPAddresses, ip) + } else { + template.DNSNames = append(template.DNSNames, h) + } + } + + // certificate is its own CA + template.IsCA = true + template.KeyUsage |= x509.KeyUsageCertSign + + derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv) + if err != nil { + return nil, nil, err + } + + var certOut bytes.Buffer + if err = pem.Encode(&certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}); err != nil { + return nil, nil, err + } + cert = certOut.Bytes() + + privBytes, err := x509.MarshalPKCS8PrivateKey(priv) + if err != nil { + return nil, nil, err + } + + var keyOut bytes.Buffer + err = pem.Encode(&keyOut, &pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}) + if err != nil { + return nil, nil, err + } + key = keyOut.Bytes() + return cert, key, nil +}