Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions config/gateway.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,14 @@ 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 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}
controller_private_key: <testnet-controller-private-key-hex>
paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92
Expand All @@ -68,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: <testnet-controller-private-key-hex>
paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92
max_wallets_per_owner: 2000
Expand All @@ -79,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: <testnet-controller-private-key-hex>
paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92
max_wallets_per_owner: 3
Expand Down
8 changes: 8 additions & 0 deletions config/test.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: <testnet-controller-private-key-hex>
paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92

Expand All @@ -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: <testnet-controller-private-key-hex>
paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92
max_wallets_per_owner: 2000
Expand All @@ -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: <testnet-controller-private-key-hex>
paymaster_address: 0xd856f532F7C032e6b30d76F19187F25A068D6d92
max_wallets_per_owner: 3
Expand Down
8 changes: 6 additions & 2 deletions config/worker-base-sepolia.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions config/worker-sepolia.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
87 changes: 87 additions & 0 deletions core/config/bundler_provider_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
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: "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"},
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")
}
}
96 changes: 88 additions & 8 deletions core/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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://<subdomain>.g.alchemy.com/v2/<AlchemyAPIKey>.
AlchemyAPIKey string
FactoryAddress common.Address
EntrypointAddress common.Address
// ChainID of the connected network (derived at runtime from RPC)
Expand All @@ -206,6 +216,68 @@ 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://<subdomain>.g.alchemy.com/v2/<key>). 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",
56: "bnb-mainnet",
}

// 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
Expand All @@ -218,6 +290,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"`
Expand Down Expand Up @@ -546,6 +620,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)
Expand Down Expand Up @@ -937,6 +1013,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,
Expand All @@ -950,11 +1028,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",
Expand Down Expand Up @@ -1007,6 +1086,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())

Expand Down
10 changes: 7 additions & 3 deletions core/taskengine/userops_withdraw_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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{}{
Expand Down
6 changes: 5 additions & 1 deletion core/taskengine/vm_contract_write_waiting.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 4 additions & 1 deletion core/taskengine/vm_runner_contract_write.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions core/testutil/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()),
Expand Down
Loading
Loading