Skip to content
This repository was archived by the owner on Oct 20, 2024. It is now read-only.

Commit 75d86dd

Browse files
authored
Default to sender balance override in gas estimation if no paymaster (#350)
1 parent b6ed720 commit 75d86dd

File tree

13 files changed

+259
-76
lines changed

13 files changed

+259
-76
lines changed

e2e/test/verification.test.ts

Lines changed: 114 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
import { ethers } from "ethers";
2-
import { Client, Presets } from "userop";
2+
import {
3+
BundlerJsonRpcProvider,
4+
Client,
5+
Presets,
6+
UserOperationBuilder,
7+
} from "userop";
38
import { errorCodes } from "../src/errors";
49
import { TestAccount } from "../src/testAccount";
510
import config from "../config";
611

712
describe("During the verification phase", () => {
13+
const provider = new BundlerJsonRpcProvider(config.nodeUrl).setBundlerRpc(
14+
config.bundlerUrl
15+
);
816
let client: Client;
917
let acc: TestAccount;
1018
beforeAll(async () => {
@@ -59,49 +67,126 @@ describe("During the verification phase", () => {
5967
});
6068
});
6169

62-
describe("With state overrides", () => {
63-
test("Sender with zero funds can successfully estimate UserOperation gas", async () => {
64-
expect.assertions(4);
70+
describe("With no gas fees", () => {
71+
test("Sender with funds can estimate gas and send", async () => {
72+
const signer = new ethers.Wallet(config.signingKey);
73+
const fundedAcc = await Presets.Builder.SimpleAccount.init(
74+
signer,
75+
config.nodeUrl,
76+
{
77+
overrideBundlerRpc: config.bundlerUrl,
78+
}
79+
);
80+
const op = await client.buildUserOperation(
81+
fundedAcc.execute(
82+
ethers.constants.AddressZero,
83+
ethers.constants.Zero,
84+
"0x"
85+
)
86+
);
87+
88+
const builderWithEstimate = new UserOperationBuilder()
89+
.useDefaults({
90+
...op,
91+
maxFeePerGas: 0,
92+
maxPriorityFeePerGas: 0,
93+
})
94+
.useMiddleware(Presets.Middleware.estimateUserOperationGas(provider));
95+
const opWithEstimate = await client.buildUserOperation(
96+
builderWithEstimate
97+
);
98+
expect(ethers.BigNumber.from(opWithEstimate.maxFeePerGas).isZero()).toBe(
99+
true
100+
);
101+
expect(
102+
ethers.BigNumber.from(opWithEstimate.maxPriorityFeePerGas).isZero()
103+
).toBe(true);
104+
105+
const builderWithGasPrice = new UserOperationBuilder()
106+
.useDefaults(opWithEstimate)
107+
.useMiddleware(Presets.Middleware.getGasPrice(provider))
108+
.useMiddleware(Presets.Middleware.signUserOpHash(signer));
109+
const response = await client.sendUserOperation(builderWithGasPrice);
110+
const event = await response.wait();
111+
expect(event?.args.success).toBe(true);
112+
});
113+
114+
test("Sender with zero funds can estimate gas but cannot send", async () => {
115+
expect.assertions(3);
116+
const signer = new ethers.Wallet(ethers.utils.randomBytes(32));
65117
const randAcc = await Presets.Builder.SimpleAccount.init(
66-
new ethers.Wallet(ethers.utils.randomBytes(32)),
118+
signer,
67119
config.nodeUrl,
68120
{
69121
overrideBundlerRpc: config.bundlerUrl,
70122
}
71123
);
72-
randAcc
73-
.setPreVerificationGas(0)
74-
.setVerificationGasLimit(0)
75-
.setCallGasLimit(0);
124+
const op = await client.buildUserOperation(
125+
randAcc.execute(
126+
ethers.constants.AddressZero,
127+
ethers.constants.Zero,
128+
"0x"
129+
)
130+
);
131+
132+
const builderWithEstimate = new UserOperationBuilder()
133+
.useDefaults({
134+
...op,
135+
maxFeePerGas: 0,
136+
maxPriorityFeePerGas: 0,
137+
})
138+
.useMiddleware(Presets.Middleware.estimateUserOperationGas(provider));
139+
const opWithEstimate = await client.buildUserOperation(
140+
builderWithEstimate
141+
);
142+
expect(ethers.BigNumber.from(opWithEstimate.maxFeePerGas).isZero()).toBe(
143+
true
144+
);
145+
expect(
146+
ethers.BigNumber.from(opWithEstimate.maxPriorityFeePerGas).isZero()
147+
).toBe(true);
76148

77149
try {
78-
await client.sendUserOperation(
79-
randAcc.execute(randAcc.getSender(), ethers.constants.Zero, "0x")
80-
);
150+
const builderWithGasPrice = new UserOperationBuilder()
151+
.useDefaults(opWithEstimate)
152+
.useMiddleware(Presets.Middleware.getGasPrice(provider))
153+
.useMiddleware(Presets.Middleware.signUserOpHash(signer));
154+
await client.sendUserOperation(builderWithGasPrice);
81155
} catch (error: any) {
82156
expect(error?.error.code).toBe(errorCodes.rejectedByEpOrAccount);
83157
}
158+
});
159+
});
84160

85-
await client.sendUserOperation(
86-
randAcc.execute(randAcc.getSender(), ethers.constants.Zero, "0x"),
161+
describe("With state overrides", () => {
162+
test("New sender will fail estimation if it uses its actual balance", async () => {
163+
expect.assertions(1);
164+
const randAcc = await Presets.Builder.SimpleAccount.init(
165+
new ethers.Wallet(ethers.utils.randomBytes(32)),
166+
config.nodeUrl,
87167
{
88-
dryRun: true,
89-
stateOverrides: {
90-
[randAcc.getSender()]: {
91-
balance: ethers.constants.MaxUint256.toHexString(),
92-
},
93-
},
94-
onBuild(op) {
95-
expect(ethers.BigNumber.from(op.preVerificationGas).gte(0)).toBe(
96-
true
97-
);
98-
expect(ethers.BigNumber.from(op.verificationGasLimit).gte(0)).toBe(
99-
true
100-
);
101-
expect(ethers.BigNumber.from(op.callGasLimit).gte(0)).toBe(true);
102-
},
168+
overrideBundlerRpc: config.bundlerUrl,
103169
}
104170
);
171+
172+
try {
173+
await client.buildUserOperation(
174+
randAcc.execute(
175+
ethers.constants.AddressZero,
176+
ethers.constants.Zero,
177+
"0x"
178+
),
179+
{
180+
[randAcc.getSender()]: {
181+
balance: ethers.utils.hexValue(
182+
await provider.getBalance(randAcc.getSender())
183+
),
184+
},
185+
}
186+
);
187+
} catch (error: any) {
188+
expect(error?.error.code).toBe(errorCodes.rejectedByEpOrAccount);
189+
}
105190
});
106191
});
107192
});

internal/start/private.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ func PrivateMode() {
130130
// Init Client
131131
c := client.New(mem, ov, chain, conf.SupportedEntryPoints)
132132
c.SetGetUserOpReceiptFunc(client.GetUserOpReceiptWithEthClient(eth))
133+
c.SetGetGasPricesFunc(client.GetGasPricesWithEthClient(eth))
133134
c.SetGetGasEstimateFunc(
134135
client.GetGasEstimateWithEthClient(rpc, ov, chain, conf.MaxBatchGasLimit),
135136
)

internal/start/searcher.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ func SearcherMode() {
122122
// Init Client
123123
c := client.New(mem, ov, chain, conf.SupportedEntryPoints)
124124
c.SetGetUserOpReceiptFunc(client.GetUserOpReceiptWithEthClient(eth))
125+
c.SetGetGasPricesFunc(client.GetGasPricesWithEthClient(eth))
125126
c.SetGetGasEstimateFunc(
126127
client.GetGasEstimateWithEthClient(rpc, ov, chain, conf.MaxBatchGasLimit),
127128
)

pkg/client/client.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ type Client struct {
2828
userOpHandler modules.UserOpHandlerFunc
2929
logger logr.Logger
3030
getUserOpReceipt GetUserOpReceiptFunc
31+
getGasPrices GetGasPricesFunc
3132
getGasEstimate GetGasEstimateFunc
3233
getUserOpByHash GetUserOpByHashFunc
3334
}
@@ -48,6 +49,7 @@ func New(
4849
userOpHandler: noop.UserOpHandler,
4950
logger: logger.NewZeroLogr().WithName("client"),
5051
getUserOpReceipt: getUserOpReceiptNoop(),
52+
getGasPrices: getGasPricesNoop(),
5153
getGasEstimate: getGasEstimateNoop(),
5254
getUserOpByHash: getUserOpByHashNoop(),
5355
}
@@ -79,6 +81,13 @@ func (i *Client) SetGetUserOpReceiptFunc(fn GetUserOpReceiptFunc) {
7981
i.getUserOpReceipt = fn
8082
}
8183

84+
// SetGetGasPricesFunc defines a general function for fetching values for maxFeePerGas and
85+
// maxPriorityFeePerGas. This function is called in *Client.EstimateUserOperationGas if given fee values are
86+
// 0.
87+
func (i *Client) SetGetGasPricesFunc(fn GetGasPricesFunc) {
88+
i.getGasPrices = fn
89+
}
90+
8291
// SetGetGasEstimateFunc defines a general function for fetching an estimate for verificationGasLimit and
8392
// callGasLimit given a userOp and EntryPoint address. This function is called in
8493
// *Client.EstimateUserOperationGas.
@@ -170,11 +179,30 @@ func (i *Client) EstimateUserOperationGas(
170179
hash := userOp.GetUserOpHash(epAddr, i.chainID)
171180
l = l.WithValues("userop_hash", hash)
172181

182+
// Parse state override set. If paymaster is not included and sender overrides are not set, default to
183+
// overriding sender balance to max uint96. This ensures gas estimation is not blocked by insufficient
184+
// funds.
173185
sos, err := state.ParseOverrideData(os)
174186
if err != nil {
175187
l.Error(err, "eth_estimateUserOperationGas error")
176188
return nil, err
177189
}
190+
if userOp.GetPaymaster() == common.HexToAddress("0x") {
191+
sos = state.WithMaxBalanceOverride(userOp.Sender, sos)
192+
}
193+
194+
// Override op with suggested gas prices if maxFeePerGas is 0. This allows for more reliable gas
195+
// estimations upstream. The default balance override also ensures simulations won't revert on
196+
// insufficient funds.
197+
if userOp.MaxFeePerGas.Cmp(common.Big0) != 1 {
198+
gp, err := i.getGasPrices()
199+
if err != nil {
200+
l.Error(err, "eth_estimateUserOperationGas error")
201+
return nil, err
202+
}
203+
userOp.MaxFeePerGas = gp.MaxFeePerGas
204+
userOp.MaxPriorityFeePerGas = gp.MaxPriorityFeePerGas
205+
}
178206

179207
// Estimate gas limits
180208
vg, cg, err := i.getGasEstimate(epAddr, userOp, sos)

pkg/client/utils.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/ethereum/go-ethereum/ethclient"
99
"github.com/ethereum/go-ethereum/rpc"
1010
"github.com/stackup-wallet/stackup-bundler/pkg/entrypoint/filter"
11+
"github.com/stackup-wallet/stackup-bundler/pkg/fees"
1112
"github.com/stackup-wallet/stackup-bundler/pkg/gas"
1213
"github.com/stackup-wallet/stackup-bundler/pkg/state"
1314
"github.com/stackup-wallet/stackup-bundler/pkg/userop"
@@ -32,6 +33,26 @@ func GetUserOpReceiptWithEthClient(eth *ethclient.Client) GetUserOpReceiptFunc {
3233
}
3334
}
3435

36+
// GetGasPricesFunc is a general interface for fetching values for maxFeePerGas and maxPriorityFeePerGas.
37+
type GetGasPricesFunc = func() (*fees.GasPrices, error)
38+
39+
func getGasPricesNoop() GetGasPricesFunc {
40+
return func() (*fees.GasPrices, error) {
41+
return &fees.GasPrices{
42+
MaxFeePerGas: big.NewInt(0),
43+
MaxPriorityFeePerGas: big.NewInt(0),
44+
}, nil
45+
}
46+
}
47+
48+
// GetGasPricesWithEthClient returns an implementation of GetGasPricesFunc that relies on an eth client to
49+
// fetch values for maxFeePerGas and maxPriorityFeePerGas.
50+
func GetGasPricesWithEthClient(eth *ethclient.Client) GetGasPricesFunc {
51+
return func() (*fees.GasPrices, error) {
52+
return fees.NewGasPrices(eth)
53+
}
54+
}
55+
3556
// GetGasEstimateFunc is a general interface for fetching an estimate for verificationGasLimit and
3657
// callGasLimit given a userOp and EntryPoint address.
3758
type GetGasEstimateFunc = func(

pkg/entrypoint/execution/trace.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func TraceSimulateHandleOp(in *TraceInput) (*TraceOutput, error) {
100100
}
101101
opts := utils.TraceCallOpts{
102102
Tracer: tracer.Loaded.BundlerExecutionTracer,
103-
StateOverrides: state.WithZeroAddressOverride(in.Sos),
103+
StateOverrides: state.WithMaxBalanceOverride(common.HexToAddress("0x"), in.Sos),
104104
}
105105
if err := in.Rpc.CallContext(context.Background(), &res, "debug_traceCall", &req, "latest", &opts); err != nil {
106106
return nil, err

pkg/entrypoint/simulation/tracevalidation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ func TraceSimulateValidation(in *TraceInput) (*TraceOutput, error) {
6363
}
6464
opts := utils.TraceCallOpts{
6565
Tracer: tracer.Loaded.BundlerCollectorTracer,
66-
StateOverrides: state.WithZeroAddressOverride(nil),
66+
StateOverrides: state.WithMaxBalanceOverride(common.HexToAddress("0x"), nil),
6767
}
6868
if err := in.Rpc.CallContext(context.Background(), &res, "debug_traceCall", &req, "latest", &opts); err != nil {
6969
return nil, err

pkg/fees/gasprices.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package fees
2+
3+
import (
4+
"context"
5+
"math/big"
6+
7+
"github.com/ethereum/go-ethereum/common"
8+
"github.com/ethereum/go-ethereum/ethclient"
9+
)
10+
11+
// GasPrices contains recommended gas fees for a UserOperation to be included in a timely manner.
12+
type GasPrices struct {
13+
MaxFeePerGas *big.Int
14+
MaxPriorityFeePerGas *big.Int
15+
}
16+
17+
// NewGasPrices returns an instance of GasPrices with the latest suggested fees derived from an Eth Client.
18+
func NewGasPrices(eth *ethclient.Client) (*GasPrices, error) {
19+
gp := GasPrices{}
20+
if head, err := eth.HeaderByNumber(context.Background(), nil); err != nil {
21+
return nil, err
22+
} else if head.BaseFee != nil {
23+
tip, err := eth.SuggestGasTipCap(context.Background())
24+
if err != nil {
25+
return nil, err
26+
}
27+
gp.MaxFeePerGas = big.NewInt(0).Add(tip, big.NewInt(0).Mul(head.BaseFee, common.Big2))
28+
gp.MaxPriorityFeePerGas = tip
29+
} else {
30+
sgp, err := eth.SuggestGasPrice(context.Background())
31+
if err != nil {
32+
return nil, err
33+
}
34+
gp.MaxFeePerGas = sgp
35+
gp.MaxPriorityFeePerGas = sgp
36+
}
37+
38+
return &gp, nil
39+
}

pkg/gas/estimate.go

Lines changed: 3 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"github.com/ethereum/go-ethereum/common/hexutil"
99
"github.com/ethereum/go-ethereum/rpc"
1010
"github.com/stackup-wallet/stackup-bundler/pkg/entrypoint/execution"
11-
"github.com/stackup-wallet/stackup-bundler/pkg/errors"
1211
"github.com/stackup-wallet/stackup-bundler/pkg/state"
1312
"github.com/stackup-wallet/stackup-bundler/pkg/userop"
1413
)
@@ -79,15 +78,6 @@ func retryEstimateGas(err error, vgl int64, in *EstimateInput) (uint64, uint64,
7978
// EstimateGas uses the simulateHandleOp method on the EntryPoint to derive an estimate for
8079
// verificationGasLimit and callGasLimit.
8180
func EstimateGas(in *EstimateInput) (verificationGas uint64, callGas uint64, err error) {
82-
// Skip if maxFeePerGas is zero.
83-
if in.Op.MaxFeePerGas.Cmp(big.NewInt(0)) != 1 {
84-
return 0, 0, errors.NewRPCError(
85-
errors.INVALID_FIELDS,
86-
"maxFeePerGas must be more than 0",
87-
nil,
88-
)
89-
}
90-
9181
// Set the initial conditions.
9282
data, err := in.Op.ToMap()
9383
if err != nil {
@@ -97,9 +87,9 @@ func EstimateGas(in *EstimateInput) (verificationGas uint64, callGas uint64, err
9787
data["verificationGasLimit"] = hexutil.EncodeBig(big.NewInt(0))
9888
data["callGasLimit"] = hexutil.EncodeBig(big.NewInt(0))
9989

100-
// Find the optimal verificationGasLimit with binary search. Setting gas price to 0 and maxing out the gas
101-
// limit here would result in certain code paths not being executed which results in an inaccurate gas
102-
// estimate.
90+
// Find the optimal verificationGasLimit with binary search. A gas price of 0 may result in certain
91+
// upstream code paths in the EVM to not be executed which can affect the reliability of gas estimates. In
92+
// this case, consider calling the EstimateGas function after setting the gas price on the UserOperation.
10393
l := int64(0)
10494
r := in.MaxGasLimit.Int64()
10595
f := in.lastVGL

0 commit comments

Comments
 (0)