Skip to content

Commit 8260063

Browse files
committed
Feat: Add JSON-RPC Bundler Client For ERC-4337
1 parent 56669b7 commit 8260063

3 files changed

Lines changed: 248 additions & 0 deletions

File tree

pkg/userop/client.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package userop
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"net/http"
9+
"time"
10+
11+
"github.com/ethereum/go-ethereum/common"
12+
)
13+
14+
type rpcRequest struct {
15+
JSONRPC string `json:"jsonrpc"`
16+
ID uint64 `json:"id"`
17+
Method string `json:"method"`
18+
Params []any `json:"params"`
19+
}
20+
21+
type rpcError struct {
22+
Code int `json:"code"`
23+
Message string `json:"message"`
24+
Data json.RawMessage `json:"data,omitempty"`
25+
}
26+
27+
type rpcResponse struct {
28+
JSONRPC string `json:"jsonrpc"`
29+
ID uint64 `json:"id"`
30+
Result json.RawMessage `json:"result,omitempty"`
31+
Error *rpcError `json:"error,omitempty"`
32+
}
33+
34+
// BundlerClient is a tiny JSON-RPC client for ERC-4337 calls.
35+
type BundlerClient struct {
36+
endpoint string
37+
httpClient *http.Client
38+
}
39+
40+
// NewBundlerClient creates a client for eth_sendUserOperation requests.
41+
func NewBundlerClient(endpoint string) *BundlerClient {
42+
return &BundlerClient{
43+
endpoint: endpoint,
44+
httpClient: &http.Client{
45+
Timeout: 20 * time.Second,
46+
},
47+
}
48+
}
49+
50+
// NewBundlerClientWithHTTPClient is useful for tests and custom transport wiring.
51+
func NewBundlerClientWithHTTPClient(endpoint string, httpClient *http.Client) *BundlerClient {
52+
if httpClient == nil {
53+
httpClient = &http.Client{Timeout: 20 * time.Second}
54+
}
55+
return &BundlerClient{
56+
endpoint: endpoint,
57+
httpClient: httpClient,
58+
}
59+
}
60+
61+
// BuildSendUserOperationRequest returns JSON payload for eth_sendUserOperation.
62+
func BuildSendUserOperationRequest(op UserOperation, entryPoint common.Address) ([]byte, error) {
63+
if err := op.ValidateBasic(); err != nil {
64+
return nil, err
65+
}
66+
body := rpcRequest{
67+
JSONRPC: "2.0",
68+
ID: 1,
69+
Method: "eth_sendUserOperation",
70+
Params: []any{op, entryPoint},
71+
}
72+
enc, err := json.MarshalIndent(body, "", " ")
73+
if err != nil {
74+
return nil, fmt.Errorf("marshal rpc request: %w", err)
75+
}
76+
return enc, nil
77+
}
78+
79+
// SendUserOperation sends one user operation and returns the userOp hash.
80+
func (c *BundlerClient) SendUserOperation(ctx context.Context, op UserOperation, entryPoint common.Address) (common.Hash, error) {
81+
payload, err := BuildSendUserOperationRequest(op, entryPoint)
82+
if err != nil {
83+
return common.Hash{}, err
84+
}
85+
86+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(payload))
87+
if err != nil {
88+
return common.Hash{}, fmt.Errorf("create request: %w", err)
89+
}
90+
req.Header.Set("Content-Type", "application/json")
91+
92+
resp, err := c.httpClient.Do(req)
93+
if err != nil {
94+
return common.Hash{}, fmt.Errorf("post request: %w", err)
95+
}
96+
defer resp.Body.Close()
97+
98+
var rpcResp rpcResponse
99+
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
100+
return common.Hash{}, fmt.Errorf("decode rpc response: %w", err)
101+
}
102+
if rpcResp.Error != nil {
103+
return common.Hash{}, fmt.Errorf("rpc error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
104+
}
105+
var hash common.Hash
106+
if err := json.Unmarshal(rpcResp.Result, &hash); err != nil {
107+
return common.Hash{}, fmt.Errorf("decode result hash: %w", err)
108+
}
109+
return hash, nil
110+
}

pkg/userop/client_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package userop_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"io"
7+
"math/big"
8+
"net/http"
9+
"strings"
10+
"testing"
11+
12+
"github.com/eipcodelab/eip7702-go/pkg/userop"
13+
"github.com/ethereum/go-ethereum/common"
14+
)
15+
16+
func makeUserOp() userop.UserOperation {
17+
return userop.UserOperation{
18+
Sender: common.HexToAddress("0x000000000000000000000000000000000000dead"),
19+
Nonce: userop.HexBig(big.NewInt(1)),
20+
CallData: []byte{0x01, 0x02},
21+
CallGasLimit: userop.HexUint64(100_000),
22+
VerificationGasLimit: userop.HexUint64(250_000),
23+
PreVerificationGas: userop.HexUint64(50_000),
24+
MaxFeePerGas: userop.HexBig(big.NewInt(30_000_000_000)),
25+
MaxPriorityFeePerGas: userop.HexBig(big.NewInt(2_000_000_000)),
26+
Signature: []byte{0xaa, 0xbb},
27+
}
28+
}
29+
30+
func TestBuildSendUserOperationRequest(t *testing.T) {
31+
op := makeUserOp()
32+
payload, err := userop.BuildSendUserOperationRequest(op, common.HexToAddress("0x0000000000000000000000000000000000000001"))
33+
if err != nil {
34+
t.Fatalf("build request: %v", err)
35+
}
36+
var body map[string]any
37+
if err := json.Unmarshal(payload, &body); err != nil {
38+
t.Fatalf("unmarshal request: %v", err)
39+
}
40+
if body["method"] != "eth_sendUserOperation" {
41+
t.Fatalf("unexpected method: %v", body["method"])
42+
}
43+
}
44+
45+
func TestSendUserOperation(t *testing.T) {
46+
client := userop.NewBundlerClientWithHTTPClient(
47+
"https://bundler.example",
48+
&http.Client{
49+
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
50+
if req.URL.String() != "https://bundler.example" {
51+
t.Fatalf("unexpected endpoint: %s", req.URL.String())
52+
}
53+
body, err := io.ReadAll(req.Body)
54+
if err != nil {
55+
t.Fatalf("read body: %v", err)
56+
}
57+
if !strings.Contains(string(body), "eth_sendUserOperation") {
58+
t.Fatalf("unexpected rpc body: %s", string(body))
59+
}
60+
61+
return &http.Response{
62+
StatusCode: http.StatusOK,
63+
Header: make(http.Header),
64+
Body: io.NopCloser(strings.NewReader(
65+
`{"jsonrpc":"2.0","id":1,"result":"0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`,
66+
)),
67+
}, nil
68+
}),
69+
},
70+
)
71+
hash, err := client.SendUserOperation(context.Background(), makeUserOp(), common.HexToAddress("0x0000000000000000000000000000000000000001"))
72+
if err != nil {
73+
t.Fatalf("send user op: %v", err)
74+
}
75+
if hash.Hex() != "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" {
76+
t.Fatalf("unexpected hash: %s", hash.Hex())
77+
}
78+
}
79+
80+
type roundTripFunc func(*http.Request) (*http.Response, error)
81+
82+
func (fn roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
83+
return fn(r)
84+
}

pkg/userop/types.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package userop
2+
3+
import (
4+
"errors"
5+
"math/big"
6+
7+
"github.com/ethereum/go-ethereum/common"
8+
"github.com/ethereum/go-ethereum/common/hexutil"
9+
)
10+
11+
// UserOperation follows the ERC-4337 v0.6 JSON shape used by eth_sendUserOperation.
12+
type UserOperation struct {
13+
Sender common.Address `json:"sender"`
14+
Nonce *hexutil.Big `json:"nonce"`
15+
InitCode hexutil.Bytes `json:"initCode"`
16+
CallData hexutil.Bytes `json:"callData"`
17+
CallGasLimit hexutil.Uint64 `json:"callGasLimit"`
18+
VerificationGasLimit hexutil.Uint64 `json:"verificationGasLimit"`
19+
PreVerificationGas hexutil.Uint64 `json:"preVerificationGas"`
20+
MaxFeePerGas *hexutil.Big `json:"maxFeePerGas"`
21+
MaxPriorityFeePerGas *hexutil.Big `json:"maxPriorityFeePerGas"`
22+
PaymasterAndData hexutil.Bytes `json:"paymasterAndData"`
23+
Signature hexutil.Bytes `json:"signature"`
24+
}
25+
26+
// ValidateBasic applies local sanity checks before sending to a bundler.
27+
func (u UserOperation) ValidateBasic() error {
28+
if u.Nonce == nil {
29+
return errors.New("nonce is required")
30+
}
31+
if u.MaxFeePerGas == nil || u.MaxPriorityFeePerGas == nil {
32+
return errors.New("max fee fields are required")
33+
}
34+
if len(u.CallData) == 0 {
35+
return errors.New("callData must not be empty")
36+
}
37+
if len(u.Signature) == 0 {
38+
return errors.New("signature must not be empty")
39+
}
40+
return nil
41+
}
42+
43+
// HexBig converts big.Int values into JSON-RPC hex quantities.
44+
func HexBig(v *big.Int) *hexutil.Big {
45+
if v == nil {
46+
return (*hexutil.Big)(big.NewInt(0))
47+
}
48+
return (*hexutil.Big)(new(big.Int).Set(v))
49+
}
50+
51+
// HexUint64 converts uint64 values into JSON-RPC hex quantities.
52+
func HexUint64(v uint64) hexutil.Uint64 {
53+
return hexutil.Uint64(v)
54+
}

0 commit comments

Comments
 (0)