From 4147bd38711bf14014158c3ec302bb9efb1f1c27 Mon Sep 17 00:00:00 2001 From: Chris Li Date: Mon, 20 Jul 2026 13:15:41 -0700 Subject: [PATCH 1/6] fix: make erc4337 send path portable across bundlers (Alchemy Rundler) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing UserOps at a third-party bundler (Alchemy Rundler) instead of our self-hosted Voltaire surfaced two client-side incompatibilities. Neither is a chain/account/paymaster issue — a real Sepolia UserOp (SimpleAccount + our VerifyingPaymaster, v0.6 EntryPoint) now lands through both Voltaire and Alchemy. 1. eth_simulateUserOperation is a Voltaire/Candide extension. Rundler returns -32600/-32601 "Unsupported method". The call was an early convenience check; make it best-effort (skip on unsupported-method, keep the hard failure for a genuine sim error on bundlers that do implement it). sendUserOpCore still runs eth_estimateUserOperationGas, which simulates against the EntryPoint. 2. The wrapped reimbursement op (executeBatchWithValues) skipped gas estimation and shipped the 1M DEFAULT_VERIFICATION_GAS_LIMIT. Rundler enforces a verification-gas-limit-efficiency floor (>=20%); 1M vs ~30K actual ≈ 3% → reject. Verification gas is independent of the execution calldata, so reuse the accurate value from the unwrapped estimate (+15% buffer) instead of the blind default. Call gas keeps the padded default (wrapped exec can't be simulated). This also tightens the Voltaire path (accurate ~30K vs a blind 1M). Also fix TestUserOpETHWithdrawal_Sepolia, which had rotted after chain-decoupling (G5): chain_id must be on the node config (camelCase chainId), not settings, for a direct RunNodeImmediately call. Shrink the fixed withdrawal to 0.00001 ETH to conserve testnet ETH across runs. --- core/taskengine/userops_withdraw_test.go | 10 +++++-- pkg/erc4337/preset/builder.go | 37 +++++++++++++++++++----- 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/core/taskengine/userops_withdraw_test.go b/core/taskengine/userops_withdraw_test.go index e308aaff..f7edb3bf 100644 --- a/core/taskengine/userops_withdraw_test.go +++ b/core/taskengine/userops_withdraw_test.go @@ -520,8 +520,8 @@ func TestUserOpETHWithdrawal_Sepolia(t *testing.T) { require.NoError(t, err, "Failed to get smart wallet balance") t.Logf("💰 Smart Wallet ETH Balance: %s wei (%.9f ETH)", smartWalletBalance.String(), float64(smartWalletBalance.Int64())/1e18) - // Fixed withdrawal amount: 0.001 ETH - withdrawalAmount := big.NewInt(1000000000000000) // 0.001 ETH in wei + // Fixed withdrawal amount: 0.00001 ETH (small, to conserve testnet ETH across runs) + withdrawalAmount := big.NewInt(10000000000000) // 0.00001 ETH in wei // Skip if balance is insufficient if smartWalletBalance.Cmp(withdrawalAmount) < 0 { @@ -558,10 +558,14 @@ func TestUserOpETHWithdrawal_Sepolia(t *testing.T) { }) require.NoError(t, err, "Failed to store wallet in database") - // ETHTransfer node configuration + // ETHTransfer node configuration. + // chainId lives on the node config (camelCase) — post chain-decoupling (G5) + // the strict resolver reads node.Config.chain_id, and a direct + // RunNodeImmediately call does not stamp it from settings. ethTransferConfig := map[string]interface{}{ "destination": destinationAddress.Hex(), "amount": withdrawalAmount.String(), + "chainId": int64(11155111), // Sepolia } settings := map[string]interface{}{ diff --git a/pkg/erc4337/preset/builder.go b/pkg/erc4337/preset/builder.go index d31835bd..7218cb8f 100644 --- a/pkg/erc4337/preset/builder.go +++ b/pkg/erc4337/preset/builder.go @@ -682,12 +682,25 @@ func sendUserOpShared( isWrappedOperation := len(callData) >= 4 && hexutil.Encode(callData[:4]) == "0xc3ff72fc" if isWrappedOperation { - // SKIP gas estimation for wrapped operations - bundler simulation always fails with -32521 - // Use fallback defaults directly since estimation will fail anyway - l.Debug("Skipping gas estimation for wrapped operation (executeBatchWithValues)") + // The wrapped op (executeBatchWithValues) reverts under eth_estimateUserOperationGas + // (-32521), so its CALL gas can't be bundler-simulated — keep the padded default there. + // But VERIFICATION gas (validateUserOp + paymaster validation) is independent of the + // execution calldata, so reuse the accurate value from the unwrapped estimate instead + // of the 1M conservative default. The default trips Alchemy Rundler's verification-gas- + // limit-efficiency floor (needs >=20%; 1M declared vs ~30K actual ≈ 3%). Voltaire has no + // such floor, which is why the default worked there. A small buffer hedges paymaster + // validation variance while staying well above the 20% floor. estimatedCallGas = new(big.Int).Mul(DEFAULT_CALL_GAS_LIMIT, big.NewInt(ETH_TRANSFER_GAS_MULTIPLIER)) - estimatedVerificationGas = DEFAULT_VERIFICATION_GAS_LIMIT estimatedPreVerificationGas = DEFAULT_PREVERIFICATION_GAS + if unwrappedGasEstimate != nil && unwrappedGasEstimate.VerificationGasLimit != nil && unwrappedGasEstimate.VerificationGasLimit.Sign() > 0 { + vg := new(big.Int).Mul(unwrappedGasEstimate.VerificationGasLimit, big.NewInt(115)) + vg.Div(vg, big.NewInt(100)) // +15% buffer + estimatedVerificationGas = vg + l.Debug("Wrapped operation: verification gas from unwrapped estimate (+15%)", "verificationGas", estimatedVerificationGas, "callGas", estimatedCallGas) + } else { + l.Debug("Wrapped operation: no unwrapped estimate, using conservative verification default") + estimatedVerificationGas = DEFAULT_VERIFICATION_GAS_LIMIT + } } else { // Attempt bundler gas estimation on unwrapped calldata // IMPORTANT: Pass 0 for callGasLimit and verificationGasLimit to force bundler to actually estimate @@ -781,11 +794,21 @@ func sendUserOpShared( return nil, nil, err } - // Run full validation on the final, fully-built userOp to catch signature/paymaster issues early + // Run full validation on the final, fully-built userOp to catch signature/paymaster issues early. + // eth_simulateUserOperation is a Voltaire/Candide extension; third-party bundlers (e.g. Alchemy + // Rundler) don't implement it and return JSON-RPC -32600/-32601 "Unsupported method". Treat that + // as non-fatal and skip the early sim — sendUserOpCore still runs eth_estimateUserOperationGas, + // which simulates against the EntryPoint and rejects reverting ops. A genuine sim failure on a + // bundler that DOES support the method is still fatal. if paymasterReq != nil { if simErr := bundlerClient.SimulateUserOperation(context.Background(), *userOp, entrypoint); simErr != nil { - l.Warn("SimulateUserOperation failed", "error", simErr) - return userOp, nil, fmt.Errorf("simulate validation failed: %w", simErr) + msg := strings.ToLower(simErr.Error()) + if strings.Contains(msg, "unsupported method") || strings.Contains(msg, "method not found") || strings.Contains(msg, "-32601") { + l.Warn("bundler does not support eth_simulateUserOperation; skipping early sim (gas estimation still validates)", "error", simErr) + } else { + l.Warn("SimulateUserOperation failed", "error", simErr) + return userOp, nil, fmt.Errorf("simulate validation failed: %w", simErr) + } } } From a6f3002a25b5f8562f8e6b458f7ccaa8e0e88c9f Mon Sep 17 00:00:00 2001 From: Chris Li Date: Mon, 20 Jul 2026 13:40:43 -0700 Subject: [PATCH 2/6] feat: bundler_provider flag (alchemy default) with dedicated Alchemy endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an explicit, per-chain switch between the Alchemy and self-hosted (Voltaire) bundlers, defaulting to Alchemy. The Alchemy path derives its endpoint from a dedicated alchemy_api_key + a chainId→subdomain map and NEVER reads bundler_url; the self_hosted path uses bundler_url. A chain configured for alchemy with no key (or an unmapped chain) is a hard error at send time — fail closed, no silent fallback. - SmartWalletConfig gains BundlerProvider + AlchemyAPIKey; ActiveBundlerURL() resolves the endpoint (default alchemy), BundlerConfigured() reports whether a bundler resolves (replaces the old `bundler_url != ""` connectivity-only guard). - All 3 bundler-client construction sites (builder.go, vm_runner_contract_write.go, vm_contract_write_waiting.go) resolve via ActiveBundlerURL(). - Plumbed through config conversions (top-level + per-chain) and worker config. - test.example.yaml / gateway.example.yaml document the fields; testutil pins self_hosted so the suite keeps using Voltaire deterministically. - Unit tests cover URL derivation, default-to-alchemy, and every hard-error path. Validated end-to-end on Sepolia: a real UserOp lands via both providers (self_hosted through bundler_url, alchemy purely from alchemy_api_key). ROLLOUT (guardian_ruleset gotcha — config ships separately from code): before this reaches an environment, every wallet-op chain in avs-infra must set alchemy_api_key OR bundler_provider: self_hosted, else the gateway hard-errors on send. --- config/gateway.example.yaml | 10 ++- config/test.example.yaml | 8 ++ core/config/bundler_provider_test.go | 82 +++++++++++++++++ core/config/config.go | 94 ++++++++++++++++++-- core/taskengine/vm_contract_write_waiting.go | 6 +- core/taskengine/vm_runner_contract_write.go | 5 +- core/testutil/utils.go | 4 + pkg/erc4337/preset/builder.go | 6 +- worker/config.go | 10 ++- 9 files changed, 210 insertions(+), 15 deletions(-) create mode 100644 core/config/bundler_provider_test.go diff --git a/config/gateway.example.yaml b/config/gateway.example.yaml index 354ff689..05ec6b79 100644 --- a/config/gateway.example.yaml +++ b/config/gateway.example.yaml @@ -53,7 +53,15 @@ tenderly_access_key: "" # AVS chain above. Per-workflow execution still routes via chains[]. smart_wallet: eth_rpc_url: https://ethereum-sepolia-rpc.publicnode.com - bundler_url: ${SEPOLIA_BUNDLER_URL} + # bundler_provider selects the bundler. It DEFAULTS TO 'alchemy' when omitted: + # the URL is derived as https://.g.alchemy.com/v2/ + # and NEVER falls back to bundler_url. Set alchemy_api_key, or set + # bundler_provider: self_hosted to use bundler_url (our Voltaire bundler). + # A chain with neither a valid alchemy config nor (self_hosted + bundler_url) + # hard-errors at send time — so every wallet-op chain must set one. + bundler_provider: alchemy + alchemy_api_key: ${ALCHEMY_API_KEY} + bundler_url: ${SEPOLIA_BUNDLER_URL} # used only when bundler_provider: self_hosted controller_private_key: paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92 diff --git a/config/test.example.yaml b/config/test.example.yaml index 8ad39ae8..7ba58a45 100644 --- a/config/test.example.yaml +++ b/config/test.example.yaml @@ -56,6 +56,10 @@ tenderly_access_key: "" smart_wallet: eth_rpc_url: https://ethereum-sepolia-rpc.publicnode.com bundler_url: ${SEPOLIA_BUNDLER_URL} + # bundler_provider defaults to 'alchemy' (URL derived from alchemy_api_key + + # chain subdomain). Set to 'self_hosted' to use bundler_url (Voltaire). + bundler_provider: self_hosted + alchemy_api_key: ${ALCHEMY_API_KEY} controller_private_key: paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92 @@ -69,6 +73,8 @@ chains: eth_rpc_url: https://ethereum-sepolia-rpc.publicnode.com eth_ws_url: wss://ethereum-sepolia-rpc.publicnode.com bundler_url: ${SEPOLIA_BUNDLER_URL} + bundler_provider: self_hosted + alchemy_api_key: ${ALCHEMY_API_KEY} controller_private_key: paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92 max_wallets_per_owner: 2000 @@ -80,6 +86,8 @@ chains: eth_rpc_url: https://sepolia.base.org eth_ws_url: wss://base-sepolia-rpc.publicnode.com bundler_url: ${BASE_SEPOLIA_BUNDLER_URL} + bundler_provider: self_hosted + alchemy_api_key: ${ALCHEMY_API_KEY} controller_private_key: paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92 max_wallets_per_owner: 3 diff --git a/core/config/bundler_provider_test.go b/core/config/bundler_provider_test.go new file mode 100644 index 00000000..8b314dd7 --- /dev/null +++ b/core/config/bundler_provider_test.go @@ -0,0 +1,82 @@ +package config + +import "testing" + +func TestActiveBundlerURL(t *testing.T) { + cases := []struct { + name string + cfg SmartWalletConfig + want string + wantErr bool + }{ + { + name: "empty provider defaults to alchemy and derives URL", + cfg: SmartWalletConfig{ChainID: 11155111, AlchemyAPIKey: "KEY"}, + want: "https://eth-sepolia.g.alchemy.com/v2/KEY", + }, + { + name: "explicit alchemy on base mainnet", + cfg: SmartWalletConfig{BundlerProvider: "alchemy", ChainID: 8453, AlchemyAPIKey: "K2"}, + want: "https://base-mainnet.g.alchemy.com/v2/K2", + }, + { + name: "alchemy path never falls back to bundler_url", + cfg: SmartWalletConfig{ChainID: 11155111, AlchemyAPIKey: "K", BundlerURL: "https://voltaire.example/rpc"}, + want: "https://eth-sepolia.g.alchemy.com/v2/K", + }, + { + name: "alchemy without api key is a hard error", + cfg: SmartWalletConfig{ChainID: 1}, + wantErr: true, + }, + { + name: "alchemy on an unmapped chain is a hard error", + cfg: SmartWalletConfig{ChainID: 999999, AlchemyAPIKey: "K"}, + wantErr: true, + }, + { + name: "self_hosted uses bundler_url", + cfg: SmartWalletConfig{BundlerProvider: "self_hosted", BundlerURL: "https://voltaire.example/rpc"}, + want: "https://voltaire.example/rpc", + }, + { + name: "self_hosted without bundler_url is a hard error", + cfg: SmartWalletConfig{BundlerProvider: "self_hosted"}, + wantErr: true, + }, + { + name: "unknown provider is a hard error", + cfg: SmartWalletConfig{BundlerProvider: "pimlico", ChainID: 1, AlchemyAPIKey: "K"}, + wantErr: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := tc.cfg.ActiveBundlerURL() + if tc.wantErr { + if err == nil { + t.Fatalf("expected error, got url %q", got) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} + +func TestBundlerConfigured(t *testing.T) { + if !(&SmartWalletConfig{ChainID: 11155111, AlchemyAPIKey: "K"}).BundlerConfigured() { + t.Fatal("alchemy-configured chain should report configured") + } + if (&SmartWalletConfig{ChainID: 11155111}).BundlerConfigured() { + t.Fatal("alchemy default without key should report not configured (connectivity-only)") + } + if (&SmartWalletConfig{BundlerProvider: "self_hosted"}).BundlerConfigured() { + t.Fatal("self_hosted without url should report not configured") + } +} diff --git a/core/config/config.go b/core/config/config.go index a76531ab..08f00d5b 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -187,9 +187,19 @@ type NotificationsSummaryConfig struct { } type SmartWalletConfig struct { - EthRpcUrl string - EthWsUrl string - BundlerURL string + EthRpcUrl string + EthWsUrl string + // BundlerURL is the self-hosted (Voltaire) bundler endpoint. Used only when + // BundlerProvider is "self_hosted". + BundlerURL string + // BundlerProvider selects the bundler endpoint. "alchemy" (the default when + // empty) derives the URL from AlchemyAPIKey + the chain's Alchemy subdomain; + // "self_hosted" uses BundlerURL (our Voltaire bundler). The alchemy path never + // falls back to BundlerURL — a missing key or unmapped chain is a hard error. + BundlerProvider string + // AlchemyAPIKey is the Alchemy app key for the alchemy provider. The bundler + // URL is derived as https://.g.alchemy.com/v2/. + AlchemyAPIKey string FactoryAddress common.Address EntrypointAddress common.Address // ChainID of the connected network (derived at runtime from RPC) @@ -206,6 +216,67 @@ type SmartWalletConfig struct { MaxWalletsPerOwner int } +// Bundler provider identifiers for SmartWalletConfig.BundlerProvider. +const ( + BundlerProviderAlchemy = "alchemy" + BundlerProviderSelfHosted = "self_hosted" +) + +// alchemyNetworkSubdomain maps a chain ID to its Alchemy JSON-RPC/bundler +// network subdomain (https://.g.alchemy.com/v2/). Extend this +// when onboarding a new chain to the Alchemy bundler path — e.g. Unichain +// ("unichain-mainnet") or Robinhood Chain ("robinhood-mainnet"). +var alchemyNetworkSubdomain = map[int64]string{ + 1: "eth-mainnet", + 11155111: "eth-sepolia", + 8453: "base-mainnet", + 84532: "base-sepolia", +} + +// ProviderName returns the effective bundler provider, defaulting to alchemy +// when BundlerProvider is empty. Safe to log (no secret). +func (c *SmartWalletConfig) ProviderName() string { + p := strings.ToLower(strings.TrimSpace(c.BundlerProvider)) + if p == "" { + return BundlerProviderAlchemy + } + return p +} + +// ActiveBundlerURL returns the bundler endpoint for the configured provider. +// The provider defaults to alchemy. The alchemy path derives its URL from +// AlchemyAPIKey + the chain's Alchemy subdomain and NEVER falls back to +// BundlerURL; a missing key or an unmapped chain is a hard error (fail closed). +// The self_hosted path uses BundlerURL (our Voltaire bundler). +func (c *SmartWalletConfig) ActiveBundlerURL() (string, error) { + switch c.ProviderName() { + case BundlerProviderSelfHosted, "voltaire": + if c.BundlerURL == "" { + return "", fmt.Errorf("bundler_provider=self_hosted but bundler_url is empty (chain_id=%d)", c.ChainID) + } + return c.BundlerURL, nil + case BundlerProviderAlchemy: + if c.AlchemyAPIKey == "" { + return "", fmt.Errorf("bundler_provider=alchemy but alchemy_api_key is empty (chain_id=%d); set alchemy_api_key or bundler_provider: self_hosted", c.ChainID) + } + subdomain, ok := alchemyNetworkSubdomain[c.ChainID] + if !ok { + return "", fmt.Errorf("bundler_provider=alchemy but chain_id=%d has no known Alchemy network subdomain; add it to alchemyNetworkSubdomain or set bundler_provider: self_hosted", c.ChainID) + } + return fmt.Sprintf("https://%s.g.alchemy.com/v2/%s", subdomain, c.AlchemyAPIKey), nil + default: + return "", fmt.Errorf("unknown bundler_provider %q (chain_id=%d); expected %q or %q", c.BundlerProvider, c.ChainID, BundlerProviderAlchemy, BundlerProviderSelfHosted) + } +} + +// BundlerConfigured reports whether a bundler endpoint resolves for this chain. +// Distinguishes wallet-op chains from connectivity-only rollouts (where no +// bundler is configured for either provider). +func (c *SmartWalletConfig) BundlerConfigured() bool { + _, err := c.ActiveBundlerURL() + return err == nil +} + type BackupConfig struct { Enabled bool // Whether periodic backups are enabled IntervalMinutes int // Interval between backups in minutes @@ -218,6 +289,8 @@ type SmartWalletConfigRaw struct { EthRpcUrl string `yaml:"eth_rpc_url"` EthWsUrl string `yaml:"eth_ws_url"` BundlerURL string `yaml:"bundler_url"` + BundlerProvider string `yaml:"bundler_provider"` + AlchemyAPIKey string `yaml:"alchemy_api_key"` FactoryAddress string `yaml:"factory_address"` EntrypointAddress string `yaml:"entrypoint_address"` ControllerPrivateKey string `yaml:"controller_private_key"` @@ -546,6 +619,8 @@ func NewConfig(configFilePath string) (*Config, error) { EthRpcUrl: configRaw.SmartWallet.EthRpcUrl, EthWsUrl: configRaw.SmartWallet.EthWsUrl, BundlerURL: configRaw.SmartWallet.BundlerURL, + BundlerProvider: configRaw.SmartWallet.BundlerProvider, + AlchemyAPIKey: configRaw.SmartWallet.AlchemyAPIKey, FactoryAddress: common.HexToAddress(firstNonEmpty(configRaw.SmartWallet.FactoryAddress, DefaultFactoryProxyAddressHex)), EntrypointAddress: common.HexToAddress(firstNonEmpty(configRaw.SmartWallet.EntrypointAddress, DefaultEntrypointAddressHex)), ChainID: smartWalletChainId.Int64(), // Use smart wallet chain ID, not EigenLayer chain ID (prevents cross-chain configuration errors for Base aggregator) @@ -937,6 +1012,8 @@ func parseChainConfig(raw ChainConfigRaw, logger sdklogging.Logger) (*ChainConfi EthRpcUrl: sw.EthRpcUrl, EthWsUrl: wsURL, BundlerURL: sw.BundlerURL, + BundlerProvider: sw.BundlerProvider, + AlchemyAPIKey: sw.AlchemyAPIKey, FactoryAddress: common.HexToAddress(firstNonEmpty(sw.FactoryAddress, DefaultFactoryProxyAddressHex)), EntrypointAddress: common.HexToAddress(firstNonEmpty(sw.EntrypointAddress, DefaultEntrypointAddressHex)), ChainID: raw.ChainID, @@ -950,11 +1027,12 @@ func parseChainConfig(raw ChainConfigRaw, logger sdklogging.Logger) (*ChainConfi // Probe paymaster on this chain's RPC. Catches mismatched // paymaster/RPC pairings at startup instead of waiting for the - // first UserOp to fail (Sentry EIGENLAYER-AVS-1N/1M). Skip when - // bundler_url is empty: that signals a connectivity-only rollout - // (e.g. BNB Phase 0.5 in avs-infra/chains/), where wallet ops are - // intentionally disabled and the paymaster_address is a placeholder. - if chainCfg.SmartWallet.BundlerURL != "" && chainCfg.SmartWallet.EthRpcUrl != "" { + // first UserOp to fail (Sentry EIGENLAYER-AVS-1N/1M). Skip when no + // bundler resolves for the configured provider: that signals a + // connectivity-only rollout (e.g. BNB Phase 0.5 in avs-infra/chains/), + // where wallet ops are intentionally disabled and the paymaster_address + // is a placeholder. + if chainCfg.SmartWallet.BundlerConfigured() && chainCfg.SmartWallet.EthRpcUrl != "" { rpcClient, err := ethclient.Dial(chainCfg.SmartWallet.EthRpcUrl) if err != nil { return nil, fmt.Errorf("dial RPC %s for chain %s (chain_id=%d): %w", diff --git a/core/taskengine/vm_contract_write_waiting.go b/core/taskengine/vm_contract_write_waiting.go index 792200d6..da797b49 100644 --- a/core/taskengine/vm_contract_write_waiting.go +++ b/core/taskengine/vm_contract_write_waiting.go @@ -112,7 +112,11 @@ func (v *VM) waitForUserOpConfirmation(userOpHash string) (*waitForUserOpConfirm } // Create bundler client - bundlerClient, err := bundler.NewBundlerClient(v.smartWalletConfig.BundlerURL) + activeBundlerURL, err := v.smartWalletConfig.ActiveBundlerURL() + if err != nil { + return nil, fmt.Errorf("resolve bundler endpoint: %w", err) + } + bundlerClient, err := bundler.NewBundlerClient(activeBundlerURL) if err != nil { return nil, fmt.Errorf("failed to create bundler client: %w", err) } diff --git a/core/taskengine/vm_runner_contract_write.go b/core/taskengine/vm_runner_contract_write.go index b63dec8f..d98876a7 100644 --- a/core/taskengine/vm_runner_contract_write.go +++ b/core/taskengine/vm_runner_contract_write.go @@ -801,7 +801,10 @@ func (r *ContractWriteProcessor) executeRealUserOpTransaction(ctx context.Contex // bundler does the authoritative gas pricing at send time. executionLogBuilder.WriteString("Collecting pre-send diagnostics...\n") if r.client != nil { - if bundlerClient, bundlerErr := bundler.NewBundlerClient(r.smartWalletConfig.BundlerURL); bundlerErr != nil { + activeBundlerURL, bundlerURLErr := r.smartWalletConfig.ActiveBundlerURL() + if bundlerURLErr != nil { + executionLogBuilder.WriteString(fmt.Sprintf("Failed to resolve bundler endpoint: %v\n", bundlerURLErr)) + } else if bundlerClient, bundlerErr := bundler.NewBundlerClient(activeBundlerURL); bundlerErr != nil { executionLogBuilder.WriteString(fmt.Sprintf("Failed to create bundler client: %v\n", bundlerErr)) } else { bundlerClient.Close() diff --git a/core/testutil/utils.go b/core/testutil/utils.go index cedfe107..bb99198c 100644 --- a/core/testutil/utils.go +++ b/core/testutil/utils.go @@ -764,6 +764,9 @@ func GetTestSmartWalletConfig() *config.SmartWalletConfig { PaymasterAddress: paymasterAddress, WhitelistAddresses: []common.Address{}, } + // Tests target our self-hosted (Voltaire) bundler at GetTestBundlerRPC(); + // the provider now defaults to alchemy, so opt in explicitly. + smartWalletConfig.BundlerProvider = config.BundlerProviderSelfHosted // Fetch paymaster owner address by calling owner() on the paymaster contract. // Reimbursement sends ETH to the owner EOA, not the paymaster contract itself. @@ -815,6 +818,7 @@ func GetBaseTestSmartWalletConfig() *config.SmartWalletConfig { return &config.SmartWalletConfig{ EthRpcUrl: GetTestRPC(), BundlerURL: GetTestBundlerRPC(), + BundlerProvider: config.BundlerProviderSelfHosted, EthWsUrl: GetTestWsRPC(), FactoryAddress: common.HexToAddress(GetTestFactoryAddress()), EntrypointAddress: common.HexToAddress(GetTestEntrypointAddress()), diff --git a/pkg/erc4337/preset/builder.go b/pkg/erc4337/preset/builder.go index 7218cb8f..f4556e63 100644 --- a/pkg/erc4337/preset/builder.go +++ b/pkg/erc4337/preset/builder.go @@ -562,7 +562,11 @@ func sendUserOpShared( copy(originalCallData, callData) // Initialize clients once and reuse them - bundlerClient, err := bundler.NewBundlerClient(smartWalletConfig.BundlerURL) + activeBundlerURL, err := smartWalletConfig.ActiveBundlerURL() + if err != nil { + return nil, nil, err + } + bundlerClient, err := bundler.NewBundlerClient(activeBundlerURL) if err != nil { return nil, nil, err } diff --git a/worker/config.go b/worker/config.go index e4ef2b0e..04177f81 100644 --- a/worker/config.go +++ b/worker/config.go @@ -17,9 +17,11 @@ type WorkerConfig struct { ListenAddress string `yaml:"listen_address"` HealthAddress string `yaml:"health_address"` - EthRpcUrl string `yaml:"eth_rpc_url"` - EthWsUrl string `yaml:"eth_ws_url"` - BundlerURL string `yaml:"bundler_url"` + EthRpcUrl string `yaml:"eth_rpc_url"` + EthWsUrl string `yaml:"eth_ws_url"` + BundlerURL string `yaml:"bundler_url"` + BundlerProvider string `yaml:"bundler_provider"` + AlchemyAPIKey string `yaml:"alchemy_api_key"` SmartWallet SmartWalletRaw `yaml:"smart_wallet"` } @@ -84,6 +86,8 @@ func (c *WorkerConfig) ToSmartWalletConfig() (*config.SmartWalletConfig, error) EthRpcUrl: c.EthRpcUrl, EthWsUrl: c.EthWsUrl, BundlerURL: c.BundlerURL, + BundlerProvider: c.BundlerProvider, + AlchemyAPIKey: c.AlchemyAPIKey, FactoryAddress: factoryAddr, EntrypointAddress: entrypointAddr, ChainID: c.ChainID, From 967af032304b4dce116892d5d9d6db298b7ad667 Mon Sep 17 00:00:00 2001 From: Chris Li Date: Mon, 20 Jul 2026 14:30:16 -0700 Subject: [PATCH 3/6] fix: add bundler_provider/alchemy_api_key to all example config blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The provider defaults to alchemy and hard-errors without a key, so example templates that only set bundler_url would fail when copied. Add bundler_provider + alchemy_api_key to every wallet-op block, pinned to self_hosted (works out-of-box with the existing Voltaire ${*_BUNDLER_URL}) with a comment noting the code default is alchemy: - gateway.example.yaml: the two per-chain smart_wallet blocks (top-level was already migrated; also switched it from alchemy→self_hosted for consistency). - worker-sepolia.example.yaml / worker-base-sepolia.example.yaml: top-level. --- config/gateway.example.yaml | 20 ++++++++++++-------- config/worker-base-sepolia.example.yaml | 8 ++++++-- config/worker-sepolia.example.yaml | 8 ++++++-- 3 files changed, 24 insertions(+), 12 deletions(-) diff --git a/config/gateway.example.yaml b/config/gateway.example.yaml index 05ec6b79..2951559e 100644 --- a/config/gateway.example.yaml +++ b/config/gateway.example.yaml @@ -53,15 +53,15 @@ tenderly_access_key: "" # AVS chain above. Per-workflow execution still routes via chains[]. smart_wallet: eth_rpc_url: https://ethereum-sepolia-rpc.publicnode.com - # bundler_provider selects the bundler. It DEFAULTS TO 'alchemy' when omitted: - # the URL is derived as https://.g.alchemy.com/v2/ - # and NEVER falls back to bundler_url. Set alchemy_api_key, or set - # bundler_provider: self_hosted to use bundler_url (our Voltaire bundler). - # A chain with neither a valid alchemy config nor (self_hosted + bundler_url) - # hard-errors at send time — so every wallet-op chain must set one. - bundler_provider: alchemy + # bundler_provider selects the bundler and DEFAULTS TO 'alchemy' in code when + # omitted: the URL is derived from alchemy_api_key + the chain's Alchemy subdomain + # and NEVER falls back to bundler_url. This example pins 'self_hosted' so a copied + # config works out-of-box with your Voltaire ${*_BUNDLER_URL}; switch to 'alchemy' + # (and set ALCHEMY_API_KEY) to route through Alchemy. A wallet-op chain configured + # for alchemy with no key — or an alchemy-unmapped chain — hard-errors at send time. + bundler_provider: self_hosted alchemy_api_key: ${ALCHEMY_API_KEY} - bundler_url: ${SEPOLIA_BUNDLER_URL} # used only when bundler_provider: self_hosted + bundler_url: ${SEPOLIA_BUNDLER_URL} controller_private_key: paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92 @@ -76,6 +76,8 @@ chains: eth_rpc_url: https://ethereum-sepolia-rpc.publicnode.com eth_ws_url: wss://ethereum-sepolia-rpc.publicnode.com bundler_url: ${SEPOLIA_BUNDLER_URL} + bundler_provider: self_hosted + alchemy_api_key: ${ALCHEMY_API_KEY} controller_private_key: paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92 max_wallets_per_owner: 2000 @@ -87,6 +89,8 @@ chains: eth_rpc_url: https://sepolia.base.org eth_ws_url: wss://base-sepolia-rpc.publicnode.com bundler_url: ${BASE_SEPOLIA_BUNDLER_URL} + bundler_provider: self_hosted + alchemy_api_key: ${ALCHEMY_API_KEY} controller_private_key: paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92 max_wallets_per_owner: 3 diff --git a/config/worker-base-sepolia.example.yaml b/config/worker-base-sepolia.example.yaml index 1782b83e..a37126c9 100644 --- a/config/worker-base-sepolia.example.yaml +++ b/config/worker-base-sepolia.example.yaml @@ -27,9 +27,13 @@ gateway_address: "127.0.0.1:2206" eth_rpc_url: https://base-sepolia-rpc.publicnode.com eth_ws_url: wss://base-sepolia-rpc.publicnode.com -# Third-party bundler endpoint (no self-hosted bundler). +# Bundler endpoint. bundler_provider defaults to 'alchemy' in code (URL derived +# from alchemy_api_key); pinned to self_hosted here so this worker uses bundler_url. +# Set 'alchemy' + ALCHEMY_API_KEY to route via Alchemy instead. # Set the BASE_SEPOLIA_BUNDLER_URL env var via .env.local before launching. -bundler_url: ${BASE_SEPOLIA_BUNDLER_URL} +bundler_url: ${BASE_SEPOLIA_BUNDLER_URL} +bundler_provider: self_hosted +alchemy_api_key: ${ALCHEMY_API_KEY} # Smart wallet / account abstraction config. smart_wallet: diff --git a/config/worker-sepolia.example.yaml b/config/worker-sepolia.example.yaml index ef571459..c5886559 100644 --- a/config/worker-sepolia.example.yaml +++ b/config/worker-sepolia.example.yaml @@ -27,9 +27,13 @@ gateway_address: "127.0.0.1:2206" eth_rpc_url: https://ethereum-sepolia-rpc.publicnode.com eth_ws_url: wss://ethereum-sepolia-rpc.publicnode.com -# Third-party bundler endpoint (no self-hosted bundler). +# Bundler endpoint. bundler_provider defaults to 'alchemy' in code (URL derived +# from alchemy_api_key); pinned to self_hosted here so this worker uses bundler_url. +# Set 'alchemy' + ALCHEMY_API_KEY to route via Alchemy instead. # Set the SEPOLIA_BUNDLER_URL env var via .env.local before launching. -bundler_url: ${SEPOLIA_BUNDLER_URL} +bundler_url: ${SEPOLIA_BUNDLER_URL} +bundler_provider: self_hosted +alchemy_api_key: ${ALCHEMY_API_KEY} # Smart wallet / account abstraction config. smart_wallet: From 1430cacd679ec341e93c1bee00d1b73d69f124c1 Mon Sep 17 00:00:00 2001 From: Chris Li Date: Mon, 20 Jul 2026 17:30:57 -0700 Subject: [PATCH 4/6] chore: log bundler_provider per chain at startup So operators can confirm which bundler each chain resolved to (alchemy vs self_hosted) in the 'Parsed chain config' startup line. --- core/config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/core/config/config.go b/core/config/config.go index 08f00d5b..889ec6c9 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -1085,6 +1085,7 @@ func parseChainConfig(raw ChainConfigRaw, logger sdklogging.Logger) (*ChainConfi "chain_id", chainCfg.ChainID, "name", chainCfg.Name, "worker_addr", chainCfg.WorkerAddr, + "bundler_provider", chainCfg.SmartWallet.ProviderName(), "controller", chainCfg.SmartWallet.ControllerAddress.Hex(), "paymaster_owner", chainCfg.SmartWallet.PaymasterOwnerAddress.Hex()) From f69f91f233cb74d756779da2d6dae2b4d3e4ac64 Mon Sep 17 00:00:00 2001 From: Chris Li Date: Mon, 20 Jul 2026 18:17:31 -0700 Subject: [PATCH 5/6] fix: reuse unwrapped estimate for verification gas when estimation fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining Alchemy-Rundler portability gap: the unwrapped send path, on gas-estimation failure, fell back to the 1M DEFAULT_VERIFICATION_GAS_LIMIT, which trips Rundler's verification-gas-limit-efficiency floor (>=20%). A transient estimation failure on a funded wallet would then be rejected by Alchemy where Voltaire (no floor) would have accepted it. Verification/preVerification gas are execution-independent, so prefer the earlier unwrappedGasEstimate (+15%) before the conservative default. The 1M default is used only when no estimate exists at all — e.g. the op genuinely reverts, in which case it fails on-chain regardless of provider. Mirrors the wrapped-op fix. --- pkg/erc4337/preset/builder.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/pkg/erc4337/preset/builder.go b/pkg/erc4337/preset/builder.go index f4556e63..91167e1b 100644 --- a/pkg/erc4337/preset/builder.go +++ b/pkg/erc4337/preset/builder.go @@ -755,11 +755,27 @@ func sendUserOpShared( } } if gasErr != nil || gas == nil { - l.Warn("Gas estimation failed, using defaults", "attempts", maxRetries, "error", gasErr) - // Fallback to conservative defaults when estimation fails + // Live estimation failed. Verification/preVerification gas are + // execution-independent, so prefer the earlier unwrapped estimate + // (if it succeeded) rather than the 1M DEFAULT_VERIFICATION_GAS_LIMIT + // — the blind default trips Alchemy Rundler's verification-gas-limit + // efficiency floor (>=20%). Fall back to the conservative default only + // when no estimate is available at all (e.g. the op genuinely reverts, + // in which case it will fail on-chain regardless of provider). estimatedCallGas = new(big.Int).Mul(DEFAULT_CALL_GAS_LIMIT, big.NewInt(ETH_TRANSFER_GAS_MULTIPLIER)) - estimatedVerificationGas = DEFAULT_VERIFICATION_GAS_LIMIT estimatedPreVerificationGas = DEFAULT_PREVERIFICATION_GAS + if unwrappedGasEstimate != nil && unwrappedGasEstimate.VerificationGasLimit != nil && unwrappedGasEstimate.VerificationGasLimit.Sign() > 0 { + vg := new(big.Int).Mul(unwrappedGasEstimate.VerificationGasLimit, big.NewInt(115)) + vg.Div(vg, big.NewInt(100)) // +15% buffer + estimatedVerificationGas = vg + if unwrappedGasEstimate.PreVerificationGas != nil && unwrappedGasEstimate.PreVerificationGas.Sign() > 0 { + estimatedPreVerificationGas = unwrappedGasEstimate.PreVerificationGas + } + l.Warn("Gas estimation failed; reusing unwrapped estimate for verification gas", "attempts", maxRetries, "verificationGas", estimatedVerificationGas, "error", gasErr) + } else { + estimatedVerificationGas = DEFAULT_VERIFICATION_GAS_LIMIT + l.Warn("Gas estimation failed, using conservative defaults", "attempts", maxRetries, "error", gasErr) + } } else { estimatedCallGas = gas.CallGasLimit estimatedVerificationGas = gas.VerificationGasLimit From 921b4e39a69939b2c712ce6b750bb29c5b4421b5 Mon Sep 17 00:00:00 2001 From: Chris Li Date: Mon, 20 Jul 2026 21:28:21 -0700 Subject: [PATCH 6/6] feat: add BNB mainnet (56) to Alchemy subdomain map MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Alchemy's bnb-mainnet bundler exposes v0.6, so the code can route BNB to Alchemy once BNB becomes a wallet-op chain. NOTE: BNB stays bundler_provider: self_hosted in avs-infra for now — it's connectivity-only with a placeholder testnet paymaster on BNB mainnet, so making BundlerConfigured() true would fail the fatal startup paymaster probe. Flip BNB's config to alchemy only after a real BNB paymaster is deployed + funded. --- core/config/bundler_provider_test.go | 5 +++++ core/config/config.go | 1 + 2 files changed, 6 insertions(+) diff --git a/core/config/bundler_provider_test.go b/core/config/bundler_provider_test.go index 8b314dd7..5bb05f09 100644 --- a/core/config/bundler_provider_test.go +++ b/core/config/bundler_provider_test.go @@ -19,6 +19,11 @@ func TestActiveBundlerURL(t *testing.T) { cfg: SmartWalletConfig{BundlerProvider: "alchemy", ChainID: 8453, AlchemyAPIKey: "K2"}, want: "https://base-mainnet.g.alchemy.com/v2/K2", }, + { + name: "explicit alchemy on bnb mainnet", + cfg: SmartWalletConfig{BundlerProvider: "alchemy", ChainID: 56, AlchemyAPIKey: "K3"}, + want: "https://bnb-mainnet.g.alchemy.com/v2/K3", + }, { name: "alchemy path never falls back to bundler_url", cfg: SmartWalletConfig{ChainID: 11155111, AlchemyAPIKey: "K", BundlerURL: "https://voltaire.example/rpc"}, diff --git a/core/config/config.go b/core/config/config.go index 889ec6c9..f338b61b 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -231,6 +231,7 @@ var alchemyNetworkSubdomain = map[int64]string{ 11155111: "eth-sepolia", 8453: "base-mainnet", 84532: "base-sepolia", + 56: "bnb-mainnet", } // ProviderName returns the effective bundler provider, defaulting to alchemy