diff --git a/.github/workflows/test-simplex-e2e.yml b/.github/workflows/test-simplex-e2e.yml index 82160b6a8f..c9e0a5ccb4 100644 --- a/.github/workflows/test-simplex-e2e.yml +++ b/.github/workflows/test-simplex-e2e.yml @@ -95,10 +95,6 @@ jobs: E2E_WORKDIR: ${{ runner.temp }}/simplex-e2e E2E_BSC_TESTNET_RPC_URL: ${{ secrets.E2E_BSC_TESTNET_RPC_URL }} E2E_POLYGON_AMOY_RPC_URL: ${{ secrets.E2E_POLYGON_AMOY_RPC_URL }} - # Hyperbridge's testnet bundlers. The RPC provider, Alchemy, does not serve - # EntryPoint v0.9 as a bundler. - E2E_BSC_TESTNET_BUNDLER_URL: https://bundler.polytope.technology/bsc-chapel - E2E_POLYGON_AMOY_BUNDLER_URL: https://bundler.polytope.technology/polygon-amoy E2E_ORDERBOOK_URL: ${{ secrets.E2E_ORDERBOOK_URL }} E2E_HYPERBRIDGE_WS_URL: ${{ secrets.E2E_HYPERBRIDGE_WS_URL }} E2E_SOLVER1_PRIVATE_KEY: ${{ secrets.E2E_SOLVER1_PRIVATE_KEY }} diff --git a/docs/ai/changelog/2026-10-10-hyperbridge-bundlers-by-default.md b/docs/ai/changelog/2026-10-10-hyperbridge-bundlers-by-default.md new file mode 100644 index 0000000000..123f7f4268 --- /dev/null +++ b/docs/ai/changelog/2026-10-10-hyperbridge-bundlers-by-default.md @@ -0,0 +1,62 @@ +# 2026-10-10 — SDK 3.0.1 and simplex 0.17.1: Hyperbridge's bundlers by default + +Hyperbridge runs ERC-4337 bundlers on Ethereum, BSC, Polygon, Base and Arbitrum, and on BSC Chapel +and Polygon Amoy for testnet. They need no API key and serve EntryPoint v0.8 and v0.9. The SDK and +simplex now use them without being told to. + +## SDK + +`ChainConfigData.bundlerUrl` in `src/configs/chain.ts` holds each chain's Hyperbridge bundler. +`EvmChain.bundlerUrl` returns the bundler the chain was given, or else that one. It is undefined on +a chain Hyperbridge runs no bundler for. + +`EvmChainParams.bundlerUrl` and the second argument to `EvmChain.create()` stay optional, and a URL +passed there still wins. `IntentGateway` reads its bundler from the destination chain, so an +`IntentGateway` on a supported chain now submits fills and estimates fill gas through Hyperbridge's +bundler with no configuration. + +## UserOperation fees + +Fees are priced for rundler, which the Hyperbridge bundlers run. Rundler accepts an op paying at +least half the base fee, but only bundles one whose max fee covers its bundle base fee: the pending +base fee raised by `BUNDLE_BASE_FEE_OVERHEAD_PERCENT` (27% by default), plus the priority fee it +requires. An op priced below that is accepted and then skipped in every bundle until it expires. + +`rundlerUserOperationFees` in the SDK's `rundlerFees.ts` asks the bundler for +`rundler_getUserOperationGasPrice` and uses its `suggested` fees, each raised by its bump +(`maxFeePerGasBumpPercent`, `maxPriorityFeePerGasBumpPercent`). A bundler that does not serve that +method is asked for `rundler_maxPriorityFeePerGas`, and the chain's gas price is raised to it as +before. `GasEstimator.estimateFillOrder` and simplex's `UserOpSender` both price this way. + +The Pimlico and Alchemy pricing paths are gone, along with `BundlerMethod.PIMLICO_GET_USER_OPERATION_GAS_PRICE` +and the `PimlicoGasPriceEstimate` type. + +## Simplex + +Simplex no longer takes a bundler from its config. It submits every fill through Hyperbridge's +bundler for the chain. `HYPERBRIDGE_BUNDLER_URLS` in `src/config/bundlers.ts` lists them, and +`resolveChainConfigs` sets each resolved chain's `bundlerUrl` from it. The list repeats the SDK's +because the dashboard bundles that module without the SDK. A test checks the two match. + +A chain Hyperbridge runs no bundler for can only be watched. Unless it is watch-only, the bundler +preflight refuses it at boot, when it is added, and when watch-only is turned off for it. +`PUT /api/chains` rejects it the same way. + +Older configs still load. A `bundlerUrl` in `[[chains]]` is ignored, and `emitFillerToml` no longer +writes one, so the next save drops it. + +Removed: + +- `ChainInput.bundlerUrl` and `Simplex.chains.setBundlerUrl`. +- `FillerConfigService.setBundlerUrl`. +- The `simplex init` bundler step and its Pimlico helpers. +- `POST /api/setup/validate-bundler`, and `bundlerUrl` from the `validate-alchemy-key` results. + +The wizards and the Chains panel offer only chains with a Hyperbridge bundler (`chainsForNetwork`), +so the testnet catalog is BSC Chapel and Polygon Amoy. `GET /api/chains` and `chains.list()` still +report each chain's `bundlerUrl`, now Hyperbridge's, and empty where there is none. + +The testnet swap E2E no longer takes `E2E_BSC_TESTNET_BUNDLER_URL` or `E2E_POLYGON_AMOY_BUNDLER_URL`. +Its SDK client uses the default bundlers. + +Simplex-desktop moves to 0.17.1 with simplex. diff --git a/docs/content/developers/evm/intent-gateway/placing-orders.mdx b/docs/content/developers/evm/intent-gateway/placing-orders.mdx index 6d5bde4a1c..c1e2887f78 100644 --- a/docs/content/developers/evm/intent-gateway/placing-orders.mdx +++ b/docs/content/developers/evm/intent-gateway/placing-orders.mdx @@ -19,7 +19,7 @@ Use the same flow for other supported token pairs. The only difference between t You need: - An RPC URL for the source chain, and one for the destination chain if this is cross-chain. -- An ERC-4337 bundler URL for the destination chain. This is required in both modes because solvers submit fills as UserOperations. +- A destination chain Hyperbridge runs an ERC-4337 bundler for: Ethereum, BSC, Polygon, Base or Arbitrum. Solvers submit fills as UserOperations, and the SDK sends them through that bundler unless you pass your own. - A source-chain wallet with the USDC input amount and enough native token for transaction gas and the quoted solver fee. - A Hyperbridge-supported source and destination chain. `EvmChain.create()` rejects an RPC for a chain without a known Hyperbridge deployment. @@ -148,9 +148,9 @@ import { createWalletClient, http } from "viem" import { privateKeyToAccount } from "viem/accounts" const RPC_URL = "https://base-mainnet.g.alchemy.com/v2/YOUR_KEY" -const BUNDLER_URL = "https://base-mainnet.g.alchemy.com/v2/YOUR_KEY" -const chain = await EvmChain.create(RPC_URL, BUNDLER_URL) +// Fills go through Hyperbridge's bundler for Base. Pass a second argument to use your own. +const chain = await EvmChain.create(RPC_URL) const sourceChain = chain const destChain = chain @@ -170,11 +170,11 @@ import { privateKeyToAccount } from "viem/accounts" const SOURCE_RPC_URL = "https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY" const DEST_RPC_URL = "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY" -const DEST_BUNDLER_URL = "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY" -// The user signs on the source chain. The solver fills on the destination chain. +// The user signs on the source chain. The solver fills on the destination chain, +// through Hyperbridge's bundler for Arbitrum. const sourceChain = await EvmChain.create(SOURCE_RPC_URL) -const destChain = await EvmChain.create(DEST_RPC_URL, DEST_BUNDLER_URL) +const destChain = await EvmChain.create(DEST_RPC_URL) const coprocessor = await IntentsCoprocessor.connect("wss://nexus.rpc.polytope.technology") const queryClient = createQueryClient({ url: "https://nexus.indexer.polytope.technology" }) diff --git a/docs/content/developers/evm/simplex/api/configuration.mdx b/docs/content/developers/evm/simplex/api/configuration.mdx index 6c203b7cd1..9603d45176 100644 --- a/docs/content/developers/evm/simplex/api/configuration.mdx +++ b/docs/content/developers/evm/simplex/api/configuration.mdx @@ -46,7 +46,7 @@ curl -s $SIMPLEX/api/config | Field | Description | |---|---| | `configPath` | The config file changes are saved to. Absent when the filler was started from a config object. | -| `toml` | The running config as TOML. The signer's key and API credentials, the Substrate key, Binance credentials, and API keys inside RPC and bundler URLs are masked. | +| `toml` | The running config as TOML. The signer's key and API credentials, the Substrate key, Binance credentials, and API keys inside RPC URLs are masked. | | `logLevel` | The current log level. | | `vaultConfigured` | Whether the solver started with a vault treasury. | | `allowlistUsers` | The addresses on the order allowlist. Empty means every address is accepted. | @@ -68,7 +68,7 @@ curl -s $SIMPLEX/api/chains "chains": [ { "chainId": 8453, "stateMachineId": "EVM-8453", "label": "Base", "rpcUrls": ["https://base-mainnet.g.alchemy.com/v2/…", "https://…"], - "bundlerUrl": "https://…", "watchOnly": false, "running": true } + "bundlerUrl": "https://bundler.polytope.technology/base", "watchOnly": false, "running": true } ], "catalog": [ { "chainId": 1, "stateMachineId": "EVM-1", "label": "Ethereum", "network": "mainnet", "…": "…" } @@ -79,20 +79,20 @@ curl -s $SIMPLEX/api/chains ``` - Unlike `/api/config`, this route returns RPC and bundler URLs in full, with any API keys in them, + Unlike `/api/config`, this route returns RPC URLs in full, with any API keys in them, so that the chain editor can send them back unchanged. | Field | Description | |---|---| -| `chains` | One entry per `[[chains]]` block in the config. `running` is `false` for a chain added since the solver started. It starts filling after a restart. | -| `catalog` | Every chain the setup wizard offers on this network, with its label, explorer and default RPC endpoints. | +| `chains` | One entry per `[[chains]]` block in the config. `bundlerUrl` is the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler) the chain fills through, empty where there is none. `running` is `false` for a chain added since the solver started. It starts filling after a restart. | +| `catalog` | Every chain the setup wizard offers on this network, which is every chain with a Hyperbridge bundler, with its label, explorer and default RPC endpoints. | | `network` | `mainnet` or `testnet`. A solver runs on one network. | | `globalWatchOnly` | `true` when `watchOnly` in `[simplex]` is a single on/off value for every chain. Per-chain `watchOnly` changes are then ignored. | ## PUT /api/chains -Replaces the chain set: which chains the solver runs on, their RPC endpoints, their bundlers, and which are watch-only. The body lists every chain you want to keep. +Replaces the chain set: which chains the solver runs on, their RPC endpoints, and which are watch-only. The body lists every chain you want to keep. ```bash lineNumbers curl -s -X PUT $SIMPLEX/api/chains \ @@ -101,7 +101,6 @@ curl -s -X PUT $SIMPLEX/api/chains \ "chains": [ { "chainId": 8453, "rpcUrls": ["https://base-mainnet.g.alchemy.com/v2/KEY", "https://base-rpc.publicnode.com"], - "bundlerUrl": "https://bundler.polytope.technology/base", "watchOnly": false } ] }' @@ -112,7 +111,6 @@ curl -s -X PUT $SIMPLEX/api/chains \ |---|---| | `chainId` | The chain's numeric ID. | | `rpcUrls` | At least one RPC URL, each on a different domain, as the [RPC quorum](/developers/evm/simplex/confirmations#rpc-quorum) needs. | -| `bundlerUrl` | Required. The bundler that submits fill UserOperations. The dashboard always sends the chain's [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler). | | `watchOnly` | Optional. Watch this chain without filling on it. | Chains are wired up when the solver starts, so the new set is saved and takes effect on the next restart. The response always has `applied: false` and `restartNeeded: true`. `removed` lists the chains that were dropped. @@ -120,6 +118,7 @@ Chains are wired up when the solver starts, so the new set is saved and takes ef Every chain is checked the way a restart would check it, and a failure answers `400` with nothing saved: - Each RPC URL that is new for its chain must answer, and must report that chain's ID. +- A chain Hyperbridge runs no [bundler](/developers/evm/simplex/configuration#bundler) for must be watch-only. - Each chain needs a confirmation policy. Mainnet chains have built-in ones, and testnet chains get a default. - Every market's tokens must still resolve on the new chain set. - A chain that still has a vault in the treasury cannot be dropped. Remove the vault first. diff --git a/docs/content/developers/evm/simplex/api/overview.mdx b/docs/content/developers/evm/simplex/api/overview.mdx index b5d483a5a3..2c31768377 100644 --- a/docs/content/developers/evm/simplex/api/overview.mdx +++ b/docs/content/developers/evm/simplex/api/overview.mdx @@ -173,7 +173,6 @@ curl -sN $SIMPLEX/api/events | `GET` | [`/api/setup/orderbook`](/developers/evm/simplex/api/setup#get-apisetuporderbook) | The orderbook a new config posts to, and its books | | `POST` | [`/api/setup/validate-alchemy-key`](/developers/evm/simplex/api/setup#post-apisetupvalidate-alchemy-key) | Check an Alchemy key and derive RPC URLs from it | | `POST` | [`/api/setup/validate-rpc`](/developers/evm/simplex/api/setup#post-apisetupvalidate-rpc) | Check RPC endpoints | -| `POST` | [`/api/setup/validate-bundler`](/developers/evm/simplex/api/setup#post-apisetupvalidate-bundler) | Check a bundler endpoint | | `POST` | [`/api/setup/validate-token`](/developers/evm/simplex/api/setup#post-apisetupvalidate-token) | Read a token's symbol and decimals | | `POST` | [`/api/setup/derive-evm-address`](/developers/evm/simplex/api/setup#post-apisetupderive-evm-address) | The address of an EVM private key | | `POST` | [`/api/setup/generate-substrate-key`](/developers/evm/simplex/api/setup#post-apisetupgenerate-substrate-key) | Generate a Hyperbridge account, or derive one's address | diff --git a/docs/content/developers/evm/simplex/api/setup.mdx b/docs/content/developers/evm/simplex/api/setup.mdx index 0400df21a4..c5ee193a6c 100644 --- a/docs/content/developers/evm/simplex/api/setup.mdx +++ b/docs/content/developers/evm/simplex/api/setup.mdx @@ -66,25 +66,9 @@ curl -s -X POST $SIMPLEX/api/setup/validate-rpc \ Send `urls`, or a single `url`. `expectedChainId` is optional. The URLs must be valid and each on a different domain. When they are not, `results` is empty and `error` says why. Otherwise `ok` is `true` only when every URL answered for the expected chain, and each failing entry in `results` carries an `error`. -### POST /api/setup/validate-bundler - -Checks that a bundler answers `eth_supportedEntryPoints` and, when `chainId` is sent, that it lists the EntryPoint the chain's solver account uses. Every finding is a warning, not a failure, so `ok` is `true` unless the URL is empty. - -```bash lineNumbers -curl -s -X POST $SIMPLEX/api/setup/validate-bundler \ - -H 'Content-Type: application/json' -H 'X-Simplex-UI: 1' \ - -d '{ "url": "https://bundler.polytope.technology/base", "chainId": 8453 }' -# → { "ok": true, "entryPoints": ["0x…"] } -``` - -The response carries a `warning` when: - -- the bundler does not answer, in which case there are no `entryPoints`, or its answer is not a list of addresses. Simplex runs with this bundler and logs a warning. -- it lists addresses without the chain's EntryPoint. Simplex refuses this bundler: the solver does not start, and adding the chain, setting this bundler URL or turning watch-only off for the chain fails. - ### POST /api/setup/validate-alchemy-key -Checks an Alchemy API key, and derives an RPC URL for each mainnet chain Alchemy serves. Each chain also comes back with a `bundlerUrl`, which is the same Alchemy URL. The setup wizard and the dashboard no longer call this route: they use the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler) for every chain. +Checks an Alchemy API key, and derives an RPC URL for each mainnet chain Alchemy serves. The setup wizard and the dashboard no longer call this route. ```bash lineNumbers curl -s -X POST $SIMPLEX/api/setup/validate-alchemy-key \ @@ -97,8 +81,7 @@ curl -s -X POST $SIMPLEX/api/setup/validate-alchemy-key \ "valid": true, "chains": [ { "chainId": 8453, "stateMachineId": "EVM-8453", "label": "Base", - "rpcUrl": "https://base-mainnet.g.alchemy.com/v2/KEY", - "bundlerUrl": "https://base-mainnet.g.alchemy.com/v2/KEY" } + "rpcUrl": "https://base-mainnet.g.alchemy.com/v2/KEY" } ] } ``` diff --git a/docs/content/developers/evm/simplex/configuration.mdx b/docs/content/developers/evm/simplex/configuration.mdx index 0d2babf6a1..a294d296bb 100644 --- a/docs/content/developers/evm/simplex/configuration.mdx +++ b/docs/content/developers/evm/simplex/configuration.mdx @@ -139,31 +139,27 @@ Create an API keypair in the Turnkey Dashboard under user details. The sig ## Bundler -Fills execute as ERC-4337 UserOperations, which a bundler submits on chain, so every `[[chains]]` entry needs a `bundlerUrl`. Hyperbridge runs a bundler for each of these chains. They need no API key and serve EntryPoint v0.8 and v0.9: +Fills execute as ERC-4337 UserOperations, which a bundler submits on chain. Simplex sends them through the bundler Hyperbridge runs for each chain, so there is nothing to configure. These bundlers need no API key and serve EntryPoint v0.8 and v0.9: -| Chain | `bundlerUrl` | +| Chain | Bundler | |---|---| | Ethereum | `https://bundler.polytope.technology/ethereum` | | Base | `https://bundler.polytope.technology/base` | | Arbitrum | `https://bundler.polytope.technology/arbitrum` | | Polygon | `https://bundler.polytope.technology/polygon` | | BNB Chain | `https://bundler.polytope.technology/bsc` | +| BSC Chapel (testnet) | `https://bundler.polytope.technology/bsc-chapel` | +| Polygon Amoy (testnet) | `https://bundler.polytope.technology/polygon-amoy` | -```toml lineNumbers -[[chains]] # Base -rpcUrls = ["https://..."] -bundlerUrl = "https://bundler.polytope.technology/base" -``` - -The setup wizards and the dashboard always use these bundlers and have no bundler field. `bundlerUrl` is edited only in `filler-config.toml`. A chain outside this list needs a bundler that serves the EntryPoint its solver account uses, set there; Simplex [refuses one that does not](/developers/evm/simplex/troubleshooting). +Simplex can only watch any other chain. It [refuses to fill there](/developers/evm/simplex/troubleshooting), so list such a chain under `watchOnly`. An older config's `bundlerUrl` is ignored. ## Delegation & Gas Payment -Simplex always uses the solver selection path: it submits a signed bid to Hyperbridge via `IntentsCoprocessor` rather than calling `fillOrder` directly. This requires `substratePrivateKey` and `hyperbridgeWsUrl` in `[simplex]`, plus a `bundlerUrl` on each `[[chains]]` entry. +Simplex always uses the solver selection path: it submits a signed bid to Hyperbridge via `IntentsCoprocessor` rather than calling `fillOrder` directly. This requires `substratePrivateKey` and `hyperbridgeWsUrl` in `[simplex]`. Solver selection requires each solver's EOA to be delegated to the `SolverAccount` contract. Simplex performs this automatically at startup via EIP-7702: -- **Primary path** — builds a no-op UserOperation with an attached EIP-7702 authorization and submits it through the configured bundler. When a paymaster is deployed on the chain — Circle Paymaster (USDC) preferred, then the `SimplexPaymaster` (USDC or USDT), live on Ethereum, Arbitrum, Base, Polygon and BSC — see [Mainnet Contract Addresses](/developers/evm/contract-addresses/mainnet) — and the solver holds at least one whole token of a supported stablecoin, the paymaster pays gas in that stablecoin so the solver never needs native gas for delegation. Tokens with EIP-2612 are authorized by permit; tokens without it (such as BSC stables) need a one-time funded `approve(Permit2, max)` from the solver EOA, after which every operation carries a per-op Permit2 signature and no native gas is needed again. +- **Primary path** — builds a no-op UserOperation with an attached EIP-7702 authorization and submits it through Hyperbridge's bundler for the chain. When a paymaster is deployed on the chain — Circle Paymaster (USDC) preferred, then the `SimplexPaymaster` (USDC or USDT), live on Ethereum, Arbitrum, Base, Polygon and BSC — see [Mainnet Contract Addresses](/developers/evm/contract-addresses/mainnet) — and the solver holds at least one whole token of a supported stablecoin, the paymaster pays gas in that stablecoin so the solver never needs native gas for delegation. Tokens with EIP-2612 are authorized by permit; tokens without it (such as BSC stables) need a one-time funded `approve(Permit2, max)` from the solver EOA, after which every operation carries a per-op Permit2 signature and no native gas is needed again. - **Fallback** — if the bundler path fails or the chain has no paymaster, Simplex sends a direct type-0x04 delegation tx using the solver's native balance. On paymaster-less chains it also keeps the ERC-4337 EntryPoint deposit topped up to cover `targetGasUnits` (default 3,000,000) at the current gas price. No manual steps are required — delegation is idempotent and skipped if the EOA is already delegated to the correct contract. diff --git a/docs/content/developers/evm/simplex/confirmations.mdx b/docs/content/developers/evm/simplex/confirmations.mdx index 03a1e4a754..9729fde7c4 100644 --- a/docs/content/developers/evm/simplex/confirmations.mdx +++ b/docs/content/developers/evm/simplex/confirmations.mdx @@ -60,7 +60,6 @@ rpcUrls = [ "https://mainnet.infura.io/v3/YOUR_KEY", "https://rpc.ankr.com/eth/YOUR_KEY", ] -bundlerUrl = "https://bundler.polytope.technology/ethereum" ``` When you configure more than one URL, every quorum-checked read (`eth_getLogs`, `eth_blockNumber`, receipt during confirmation counting) is accepted only when a **BFT quorum** of your endpoints — `floor(2N/3) + 1` of them — agree on the result. If the quorum can't be formed — endpoints down, throttled, or disagreeing — the call fails loudly with a `QuorumError` and the scan cursor does not advance. There is no pausing or ejection state: each call independently queries every endpoint and resolves the moment the quorum is met, so a chronically slow or hung endpoint settles out of band and never stalls the confirmation hot path, let alone shrinks the quorum. A provider that *answers with divergent data* is never special-cased away; it simply fails to join the agreeing group, which is what keeps a lying or reorged endpoint detectable rather than authoritative. diff --git a/docs/content/developers/evm/simplex/installation.mdx b/docs/content/developers/evm/simplex/installation.mdx index 5de65aa0e5..08c8d47c44 100644 --- a/docs/content/developers/evm/simplex/installation.mdx +++ b/docs/content/developers/evm/simplex/installation.mdx @@ -179,34 +179,30 @@ url = "https://orderbook.hyperfx.finance/mainnet/graphql" # rpcUrls is always an array. Listing more than one independent endpoint # activates quorum log scanning — see the Confirmations and RPC page. -# bundlerUrl is the Hyperbridge bundler for the chain — see Configuration. +# Fills go through Hyperbridge's bundler for each chain, so there is no bundler to set. [[chains]] # Ethereum rpcUrls = [ "https://eth-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY", "https://mainnet.infura.io/v3/YOUR_INFURA_KEY", ] -bundlerUrl = "https://bundler.polytope.technology/ethereum" [[chains]] # Base rpcUrls = [ "https://base-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY", "https://base-mainnet.infura.io/v3/YOUR_INFURA_KEY", ] -bundlerUrl = "https://bundler.polytope.technology/base" [[chains]] # Arbitrum rpcUrls = [ "https://arb-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY", "https://arbitrum-mainnet.infura.io/v3/YOUR_INFURA_KEY", ] -bundlerUrl = "https://bundler.polytope.technology/arbitrum" [[chains]] # Polygon rpcUrls = [ "https://polygon-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY", "https://polygon-mainnet.infura.io/v3/YOUR_INFURA_KEY", ] -bundlerUrl = "https://bundler.polytope.technology/polygon" ``` diff --git a/docs/content/developers/evm/simplex/troubleshooting.mdx b/docs/content/developers/evm/simplex/troubleshooting.mdx index 7ebced6983..a42e7a3b2b 100644 --- a/docs/content/developers/evm/simplex/troubleshooting.mdx +++ b/docs/content/developers/evm/simplex/troubleshooting.mdx @@ -70,15 +70,21 @@ Switch to a premium RPC provider, reduce `maxConcurrentOrders`, or raise `blockS --- +## **`Hyperbridge runs no bundler for , so simplex cannot fill there`** + +Simplex refuses to start, or to add or switch a chain to filling, when Hyperbridge runs no [bundler](/developers/evm/simplex/configuration#bundler) for it, since every fill is a UserOperation a bundler submits. Mark the chain watch-only, or remove it. + +--- + ## **`Bundler for does not support EntryPoint
`** -Simplex refuses to start, or to add or switch a chain, when a chain's bundler answers `eth_supportedEntryPoints` without the EntryPoint that chain's solver account uses, since every fill UserOperation would be rejected. Point the chain's `bundlerUrl` at a bundler that serves that EntryPoint, such as the [Hyperbridge bundler](/developers/evm/simplex/configuration#bundler), or mark the chain watch-only. A bundler that does not answer, or whose answer cannot be read, is only logged as a warning. +Simplex refuses to start, or to add or switch a chain, when a chain's bundler answers `eth_supportedEntryPoints` without the EntryPoint that chain's solver account uses, since every fill UserOperation would be rejected. That points at a fault in Hyperbridge's bundler for the chain; mark the chain watch-only until it is fixed. A bundler that does not answer, or whose answer cannot be read, is only logged as a warning. --- ## **Bid submission fails** -- Confirm that `substratePrivateKey`, `hyperbridgeWsUrl`, and each chain's `bundlerUrl` are set, and that your Substrate account holds BRIDGE tokens. +- Confirm that `substratePrivateKey` and `hyperbridgeWsUrl` are set, and that your Substrate account holds BRIDGE tokens. - On chains with a paymaster (Circle or Simplex), make sure the solver's smart account holds at least 1 USDC (or 1 USDT for the `SimplexPaymaster`), so the paymaster can cover gas. - On chains without a paymaster, the solver needs a native balance. Simplex automatically tops up the ERC-4337 EntryPoint deposit to cover `targetGasUnits` (3M by default) at the current gas price. - If you use MPC Vault, verify that the client-signer container is running and reachable. diff --git a/docs/content/developers/sdk/api/evm-chain.mdx b/docs/content/developers/sdk/api/evm-chain.mdx index 7ad82f1829..dedbecd687 100644 --- a/docs/content/developers/sdk/api/evm-chain.mdx +++ b/docs/content/developers/sdk/api/evm-chain.mdx @@ -26,7 +26,7 @@ static async create(rpcUrl: string, bundlerUrl?: string): Promise | Name | Type | Description | |------|------|-------------| | `rpcUrl` | `string` | HTTP(S) RPC URL of the EVM node | -| `bundlerUrl` | `string` | Optional. ERC-4337 bundler URL for account abstraction support | +| `bundlerUrl` | `string` | Optional. ERC-4337 bundler URL. Defaults to the bundler Hyperbridge runs for the chain | **Returns:** `Promise` — A fully initialized `EvmChain` instance @@ -37,10 +37,10 @@ static async create(rpcUrl: string, bundlerUrl?: string): Promise ```typescript lineNumbers import { EvmChain } from "@hyperbridge/sdk" -// Auto-detect chain from RPC +// Auto-detect chain from RPC. Fills go through Hyperbridge's bundler for Base. const chain = await EvmChain.create("https://mainnet.base.org") -// With ERC-4337 bundler for IntentGateway +// With your own ERC-4337 bundler instead const destChain = await EvmChain.create( "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY", "https://bundler.example.com" @@ -65,7 +65,7 @@ static fromParams(params: EvmChainParams): EvmChain | `params.rpcUrl` | `string` | RPC URL for the chain (must be an archive node for proof queries) | | `params.host` | `HexString` | Address of the IsmpHost contract on this chain | | `params.consensusStateId` | `string` | Optional. Consensus state identifier on Hyperbridge (auto-detected for known chains) | -| `params.bundlerUrl` | `string` | Optional. ERC-4337 bundler URL for account abstraction support | +| `params.bundlerUrl` | `string` | Optional. ERC-4337 bundler URL. Defaults to the bundler Hyperbridge runs for the chain | **Returns:** `EvmChain` @@ -85,7 +85,7 @@ const arbitrumChain = EvmChain.fromParams({ chainId: 42161, rpcUrl: "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY", host: "0x42fa123...", - bundlerUrl: "https://bundler.example.com" // Optional, for ERC-4337 support + bundlerUrl: "https://bundler.example.com" // Optional, defaults to Hyperbridge's bundler }) ``` @@ -117,14 +117,17 @@ The IsmpHost contract address on this chain. ### bundlerUrl -The optional ERC-4337 bundler URL for account abstraction support. Set via `EvmChainParams.bundlerUrl` or the second argument to `EvmChain.create()`. Used by `IntentGateway` to submit solver `UserOperation`s. +The ERC-4337 bundler `IntentGateway` submits solver `UserOperation`s through. It is the one set via `EvmChainParams.bundlerUrl` or the second argument to `EvmChain.create()`. + +When none is set, it is the bundler Hyperbridge runs for the chain. Hyperbridge runs one on Ethereum, BSC, Polygon, Base and Arbitrum, and on BSC Chapel and Polygon Amoy for testnet. On any other chain it is `undefined`. **Type:** `string | undefined` **Example:** ```typescript lineNumbers -console.log(`Host address: ${evmChain.host}`) +const chain = await EvmChain.create("https://mainnet.base.org") +console.log(chain.bundlerUrl) // https://bundler.polytope.technology/base ``` --- @@ -480,7 +483,7 @@ interface EvmChainParams { | `rpcUrl` | `string` | RPC URL for the chain (must be an archive node) | | `host` | `HexString` | Address of the IsmpHost contract | | `consensusStateId` | `string` | Optional. Consensus state identifier on Hyperbridge | -| `bundlerUrl` | `string` | Optional. ERC-4337 bundler URL for account abstraction support | +| `bundlerUrl` | `string` | Optional. ERC-4337 bundler URL. Defaults to the bundler Hyperbridge runs for the chain | --- diff --git a/docs/content/developers/sdk/api/intent-gateway.mdx b/docs/content/developers/sdk/api/intent-gateway.mdx index 89771a398a..8c28924d15 100644 --- a/docs/content/developers/sdk/api/intent-gateway.mdx +++ b/docs/content/developers/sdk/api/intent-gateway.mdx @@ -43,10 +43,8 @@ static async create( import { EvmChain, IntentsCoprocessor, IntentGateway } from "@hyperbridge/sdk" const source = await EvmChain.create("https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY") -const dest = await EvmChain.create( - "https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY", - "https://bundler.example.com", -) +// Fills go through Hyperbridge's bundler for Arbitrum unless a second argument names another. +const dest = await EvmChain.create("https://arb-mainnet.g.alchemy.com/v2/YOUR_KEY") const coprocessor = await IntentsCoprocessor.connect("wss://coprocessor.hyperbridge.network") const gateway = await IntentGateway.create(source, dest, coprocessor) @@ -664,7 +662,9 @@ async estimateFillOrder(params: EstimateFillOrderParams): Promise` — All gas components, EIP-1559 fee values, total cost in wei, and total cost in fee token -The estimate runs against the destination chain's configured EntryPoint. Fees are refined with `pimlico_getUserOperationGasPrice` for Pimlico bundlers and `rundler_maxPriorityFeePerGas` for Alchemy. Any other bundler is asked for `rundler_maxPriorityFeePerGas` too, and when it answers, the priority fee and max fee are raised to at least what it requires. +The estimate runs against the destination chain's configured EntryPoint. Fees are priced for a rundler bundler, such as the ones Hyperbridge runs. They are the fees the bundler suggests through `rundler_getUserOperationGasPrice`, each raised by its bump. Rundler only bundles an op whose max fee covers its bundle base fee, which runs above the chain's current base fee, so these clear it. + +A bundler that does not serve that method is asked for `rundler_maxPriorityFeePerGas` instead. The chain's gas price, raised by the bumps, is then lifted to at least the priority fee it requires. --- diff --git a/docs/content/developers/sdk/api/simplex.mdx b/docs/content/developers/sdk/api/simplex.mdx index f4029ab3c1..cf55344ce5 100644 --- a/docs/content/developers/sdk/api/simplex.mdx +++ b/docs/content/developers/sdk/api/simplex.mdx @@ -502,7 +502,8 @@ remove(index: number): Promise list(): ChainView[] ``` -**Returns:** `{ chainId, stateMachineId, rpcUrls, bundlerUrl, watchOnly }` per chain. +**Returns:** `{ chainId, stateMachineId, rpcUrls, bundlerUrl, watchOnly }` per chain. `bundlerUrl` is the +[Hyperbridge bundler](/developers/evm/simplex/configuration#bundler) the chain fills through, empty where there is none. ### add(chain) @@ -518,13 +519,13 @@ add(chain: ChainInput): Promise | Name | Type | Description | |------|------|-------------| | `chain.rpcUrls` | `string[]` | One or more endpoints. Multiple enables quorum log scanning | -| `chain.bundlerUrl` | `string` | ERC-4337 bundler, required to submit fill UserOperations | | `chain.watchOnly` | `boolean` | Optional. Monitor without filling | | `chain.confirmationPolicy` | `ChainConfirmationPolicy` | Required for chains with no built-in curve (testnets, and anything outside Ethereum, BSC, Polygon, Base, Arbitrum and Unichain) | **Throws:** If the endpoints are unreachable or answer for a different chain, the chain is already -configured, no confirmation curve covers it, the bundler's `eth_supportedEntryPoints` lacks the -chain's EntryPoint (unless the chain is added watch-only), or delegation fails. The endpoints are +configured, no confirmation curve covers it, or delegation fails. Unless the chain is added watch-only, +it also throws when Hyperbridge runs no bundler for it, or that bundler's `eth_supportedEntryPoints` +lacks the chain's EntryPoint. The endpoints are probed and the chain id read back before anything is mutated, and a failed add rolls itself back. On a scanner you supplied, `add` requires the scanner to already carry the chain — call @@ -554,16 +555,6 @@ setRpcUrls(chainId: number, rpcUrls: string[]): Promise **Throws:** If the endpoints share a hostname, are unreachable, answer for a different chain, or belong to a scanner you supplied — see [Scanner ownership](#scanner-ownership). -### setBundlerUrl(chainId, bundlerUrl) - -```typescript lineNumbers -setBundlerUrl(chainId: number, bundlerUrl: string): Promise -``` - -**Throws:** If the URL is empty, the chain is not configured, or, on a chain that is not -watch-only, the bundler's `eth_supportedEntryPoints` lacks the chain's EntryPoint. A bundler that -does not answer, or whose answer is not a list of addresses, is only logged as a warning. - ### setWatchOnly(chainId, watchOnly) Monitor without filling. Takes effect on the next order. @@ -572,8 +563,8 @@ Monitor without filling. Takes effect on the next order. setWatchOnly(chainId: number, watchOnly: boolean): Promise ``` -`setWatchOnly(chainId, false)` throws when the chain's bundler lists EntryPoints without the -chain's. +`setWatchOnly(chainId, false)` throws when Hyperbridge runs no bundler for the chain, or its bundler +lists EntryPoints without the chain's. On a solver started without a `signer`, `setWatchOnly(chainId, false)` throws — and `add` defaults a new chain to watch-only, rejecting an explicit `watchOnly: false`. A signerless start runs on a diff --git a/docs/content/developers/sdk/simplex.mdx b/docs/content/developers/sdk/simplex.mdx index b16f54264c..eae80ea13c 100644 --- a/docs/content/developers/sdk/simplex.mdx +++ b/docs/content/developers/sdk/simplex.mdx @@ -42,14 +42,8 @@ const config: SimplexConfig = { hyperbridgeWsUrl: "wss://hyperbridge-mainnet-rpc.blockops.network", }, chains: [ - { - rpcUrls: ["https://base.example"], - bundlerUrl: "https://bundler.example/base", - }, - { - rpcUrls: ["https://arbitrum.example"], - bundlerUrl: "https://bundler.example/arb", - }, + { rpcUrls: ["https://base.example"] }, + { rpcUrls: ["https://arbitrum.example"] }, ], // Required. Prices come from the limit orders you post here. orderbook: { @@ -429,7 +423,6 @@ remove from the highest index first if you are closing several. ```ts lineNumbers await simplex.chains.add({ rpcUrls: ["https://polygon.example"], - bundlerUrl: "https://bundler.example/polygon", // Required for chains with no built-in confirmation curve. confirmationPolicy: { points: [ @@ -593,10 +586,7 @@ A shared scanner is edited through the scanner, so every solver reading it sees ```ts lineNumbers // Start scanning a chain. Solvers still opt in individually — see below. -await orders.addChain({ - rpcUrls: ["https://polygon.example"], - bundlerUrl: "https://bundler.example/polygon", -}) +await orders.addChain({ rpcUrls: ["https://polygon.example"] }) // Swap endpoints. The cursor is kept, so no block goes unscanned. await orders.setRpcUrls(8453, ["https://base-new.example"]) @@ -615,8 +605,8 @@ matches incoming events against its own configured chains, so tell it separately supplies the events, the solver decides which it wants: ```ts lineNumbers -await orders.addChain({ rpcUrls, bundlerUrl }) // the scanner sees it -await solverA.chains.add({ rpcUrls, bundlerUrl }) // this solver acts on it +await orders.addChain({ rpcUrls }) // the scanner sees it +await solverA.chains.add({ rpcUrls }) // this solver acts on it ``` `simplex.chains.add` and `.setRpcUrls` only reach a scanner that solver built for itself. On one you diff --git a/sdk/packages/sdk/package.json b/sdk/packages/sdk/package.json index 348be2b39a..a0f690e0da 100644 --- a/sdk/packages/sdk/package.json +++ b/sdk/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbridge/sdk", - "version": "3.0.0", + "version": "3.0.1", "description": "The hyperclient SDK provides utilities for querying proofs and statuses for cross-chain requests from HyperBridge.", "type": "module", "types": "./dist/node/index.d.ts", diff --git a/sdk/packages/sdk/src/chains/evm.ts b/sdk/packages/sdk/src/chains/evm.ts index afebb8b64d..7478f79fa4 100644 --- a/sdk/packages/sdk/src/chains/evm.ts +++ b/sdk/packages/sdk/src/chains/evm.ts @@ -117,7 +117,8 @@ export interface EvmChainParams { */ consensusStateId?: string /** - * Optional ERC-4337 bundler URL for account abstraction support + * ERC-4337 bundler URL for account abstraction support. Defaults to the + * bundler Hyperbridge runs for the chain, where there is one. */ bundlerUrl?: string } @@ -188,7 +189,7 @@ export class EvmChain implements IChain { * This is the only public way to construct an `EvmChain` — the constructor is private. * * @param rpcUrl - HTTP(S) RPC URL of the EVM node - * @param bundlerUrl - Optional ERC-4337 bundler URL for account abstraction support + * @param bundlerUrl - Optional ERC-4337 bundler URL; defaults to Hyperbridge's bundler for the chain * @returns A fully initialised `EvmChain` ready for use * @throws If the chain ID returned by the RPC is not a known Hyperbridge deployment * @@ -220,8 +221,12 @@ export class EvmChain implements IChain { return this.params.host } + /** + * The bundler given to this chain, or else the one Hyperbridge runs for it. + * Undefined on a chain Hyperbridge runs no bundler for. + */ get bundlerUrl(): string | undefined { - return this.params.bundlerUrl + return this.params.bundlerUrl ?? chainConfigs[this.params.chainId]?.bundlerUrl } get config(): IEvmConfig { diff --git a/sdk/packages/sdk/src/configs/chain.ts b/sdk/packages/sdk/src/configs/chain.ts index 32eb34dd1e..3bbf32a28f 100644 --- a/sdk/packages/sdk/src/configs/chain.ts +++ b/sdk/packages/sdk/src/configs/chain.ts @@ -248,6 +248,8 @@ export interface ChainConfigData { } rpcEnvKey?: string defaultRpcUrl?: string + /** ERC-4337 bundler Hyperbridge runs for this chain, which `EvmChain` uses when it is given none */ + bundlerUrl?: string consensusStateId: string coingeckoId: string popularTokens?: string[] @@ -297,6 +299,7 @@ export const chainConfigs: Record = { }, rpcEnvKey: "BSC_CHAPEL", defaultRpcUrl: "https://bnb-testnet.api.onfinality.io/public", + bundlerUrl: "https://bundler.polytope.technology/bsc-chapel", consensusStateId: "BSC0", coingeckoId: "binance-smart-chain", popularTokens: [ @@ -438,6 +441,7 @@ export const chainConfigs: Record = { }, rpcEnvKey: "ETH_MAINNET", defaultRpcUrl: "https://eth-mainnet.g.alchemy.com/v2/demo", + bundlerUrl: "https://bundler.polytope.technology/ethereum", consensusStateId: "ETH0", coingeckoId: "ethereum", layerZeroEid: 30101, @@ -506,6 +510,7 @@ export const chainConfigs: Record = { }, rpcEnvKey: "BSC_MAINNET", defaultRpcUrl: "https://bsc-rpc.publicnode.com", + bundlerUrl: "https://bundler.polytope.technology/bsc", consensusStateId: "BSC0", coingeckoId: "binance-smart-chain", erc4626Vaults: [ @@ -568,6 +573,7 @@ export const chainConfigs: Record = { }, rpcEnvKey: "ARBITRUM_MAINNET", defaultRpcUrl: "https://arbitrum-one.public.blastapi.io", + bundlerUrl: "https://bundler.polytope.technology/arbitrum", consensusStateId: "ETH0", coingeckoId: "arbitrum-one", layerZeroEid: 30110, @@ -641,6 +647,7 @@ export const chainConfigs: Record = { }, rpcEnvKey: "BASE_MAINNET", defaultRpcUrl: "https://base-mainnet.public.blastapi.io", + bundlerUrl: "https://bundler.polytope.technology/base", consensusStateId: "ETH0", coingeckoId: "base", layerZeroEid: 30184, @@ -716,6 +723,7 @@ export const chainConfigs: Record = { }, rpcEnvKey: "POLYGON_MAINNET", defaultRpcUrl: "https://polygon-bor-rpc.publicnode.com", + bundlerUrl: "https://bundler.polytope.technology/polygon", consensusStateId: "POLY", coingeckoId: "polygon-pos", layerZeroEid: 30109, @@ -793,6 +801,7 @@ export const chainConfigs: Record = { }, rpcEnvKey: "POLYGON_AMOY", defaultRpcUrl: "https://rpc-amoy.polygon.technology", + bundlerUrl: "https://bundler.polytope.technology/polygon-amoy", consensusStateId: "POLY", coingeckoId: "polygon-pos", }, diff --git a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts index bf4c276ec4..9b4001d18a 100644 --- a/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts +++ b/sdk/packages/sdk/src/protocols/intents/GasEstimator.ts @@ -23,11 +23,11 @@ import type { import type { HexString } from "@/types" import type { IntentGatewayContext } from "./types" import { BundlerMethod } from "./types" -import type { BundlerGasEstimate, PimlicoGasPriceEstimate } from "./types" +import type { BundlerGasEstimate } from "./types" import { getFeeToken, transformOrderForContract, convertGasToFeeToken, convertFeeTokenToWei } from "./utils" import { CryptoUtils } from "./CryptoUtils" import { readSelectionFormat, selectionOffStateDiff } from "./selection" -import { applyRundlerPriorityFee, fetchRundlerPriorityFee } from "./rundlerFees" +import { rundlerUserOperationFees } from "./rundlerFees" /** * Estimates the gas cost for filling an IntentGatewayV2 order and converts it @@ -39,12 +39,8 @@ import { applyRundlerPriorityFee, fetchRundlerPriorityFee } from "./rundlerFees" * bytecode). Without a bundler, a fixed gas budget * ({@link NO_BUNDLER_FILL_GAS_BASE} plus a per-output increment) is used * instead of a live estimate. - * Bundler-specific gas-price refinement is applied automatically: - * Pimlico (`pimlico_getUserOperationGasPrice`) when the URL contains - * `pimlico.io`, and Alchemy (`rundler_maxPriorityFeePerGas`) when the - * URL contains `alchemy.com`. Any other bundler is asked for rundler's - * required priority fee, which raises the fees when it is above the chain - * estimate. + * Fees are priced for a rundler bundler, such as the ones Hyperbridge runs + * (see {@link rundlerUserOperationFees}). */ /** * Gas budget assumed for delivering and executing the cross-chain RedeemEscrow @@ -171,11 +167,9 @@ export class GasEstimator { * **Bundler path:** constructs a mock `PackedUserOperation` signed by an * ephemeral keypair, applies state overrides, and calls * `eth_estimateUserOperationGas`. Gas limits are bumped by 5-10% for - * headroom. If the bundler is Pimlico, gas prices are refined with - * `pimlico_getUserOperationGasPrice`, and if it is Alchemy, with - * `rundler_maxPriorityFeePerGas`. For any other bundler that answers - * `rundler_maxPriorityFeePerGas`, the fees are raised to at least that - * priority fee (see {@link applyRundlerPriorityFee}). If the bundler rejects the estimate, + * headroom. Fees are the rundler bundler's suggested fees raised by the bumps, + * or the chain's gas price raised to the priority fee it requires where it + * suggests none (see {@link rundlerUserOperationFees}). If the bundler rejects the estimate, * fixed gas limits are returned in its place, or the call throws when * `params.requireBundlerEstimate` is set. * @@ -374,50 +368,20 @@ export class GasEstimator { ]) as HexString const bundlerUserOp = CryptoUtils.prepareBundlerCall(preliminaryUserOp) - const bundlerUrlLower = this.ctx.bundlerUrl.toLowerCase() - const isPimlico = bundlerUrlLower.includes("pimlico.io") - const isAlchemy = bundlerUrlLower.includes("alchemy.com") - const rundlerPriorityFeeRequest = - isPimlico || isAlchemy ? Promise.resolve(null) : fetchRundlerPriorityFee(this.ctx.bundlerUrl) - - const bundlerRequests: { method: BundlerMethod; params: unknown[] }[] = [ + // Started alongside the estimate, which takes longer. + const rundlerFeesRequest = rundlerUserOperationFees( + this.ctx.bundlerUrl, + { maxFeePerGas, maxPriorityFeePerGas }, { - method: BundlerMethod.ETH_ESTIMATE_USER_OPERATION_GAS, - params: [bundlerUserOp, entryPointAddress, bundlerStateOverrides], + baseFeePerGas, + priorityFeeBumpPercent: BigInt(priorityFeeBumpPercent), + maxFeeBumpPercent: BigInt(maxFeeBumpPercent), }, - ] - if (isPimlico) { - bundlerRequests.push({ - method: BundlerMethod.PIMLICO_GET_USER_OPERATION_GAS_PRICE, - params: [], - }) - } - if (isAlchemy) { - bundlerRequests.push({ - method: BundlerMethod.RUNDLER_MAX_PRIORITY_FEE_PER_GAS, - params: [], - }) - } - - let gasEstimate: BundlerGasEstimate - let pimlicoGasPrices: PimlicoGasPriceEstimate | null = null - let alchemyMaxPriorityFee: HexString | null = null - - try { - const batchResults = await this.crypto.sendBundlerBatch(bundlerRequests) - gasEstimate = batchResults[0] as BundlerGasEstimate - if (isPimlico && batchResults.length > 1) { - pimlicoGasPrices = batchResults[1] as PimlicoGasPriceEstimate - } - if (isAlchemy && batchResults.length > 1) { - alchemyMaxPriorityFee = batchResults[1] as HexString - } - } catch { - gasEstimate = await this.crypto.sendBundler( - BundlerMethod.ETH_ESTIMATE_USER_OPERATION_GAS, - [bundlerUserOp, entryPointAddress, bundlerStateOverrides], - ) - } + ) + const gasEstimate = await this.crypto.sendBundler( + BundlerMethod.ETH_ESTIMATE_USER_OPERATION_GAS, + [bundlerUserOp, entryPointAddress, bundlerStateOverrides], + ) callGasLimit = (BigInt(gasEstimate.callGasLimit) * 160n) / 100n verificationGasLimit = (BigInt(gasEstimate.verificationGasLimit) * 105n) / 100n @@ -430,44 +394,9 @@ export class GasEstimator { paymasterPostOpGasLimit = (BigInt(gasEstimate.paymasterPostOpGasLimit) * 105n) / 100n } - if (pimlicoGasPrices) { - const level = pimlicoGasPrices.fast ?? pimlicoGasPrices.standard ?? pimlicoGasPrices.slow ?? null - - if (level) { - const pimMaxFeePerGas = BigInt(level.maxFeePerGas) - const pimMaxPriorityFeePerGas = BigInt(level.maxPriorityFeePerGas) - - maxFeePerGas = pimMaxFeePerGas + (pimMaxFeePerGas * BigInt(maxFeeBumpPercent)) / 100n - maxPriorityFeePerGas = - pimMaxPriorityFeePerGas + (pimMaxPriorityFeePerGas * BigInt(priorityFeeBumpPercent)) / 100n - } - } - - if (alchemyMaxPriorityFee) { - const rundlerPriorityFee = BigInt(alchemyMaxPriorityFee) - // Alchemy requires 25% priority fee buffer (0% for Arbitrum) - const isArbitrum = chainId === 42161n - const alchemyPrioBump = isArbitrum ? 0n : 25n - maxPriorityFeePerGas = rundlerPriorityFee + (rundlerPriorityFee * alchemyPrioBump) / 100n - // Alchemy recommends 50% base fee buffer - const bufferedBaseFee = baseFeePerGas + (baseFeePerGas * 50n) / 100n - maxFeePerGas = bufferedBaseFee + maxPriorityFeePerGas - } - - const rundlerPriorityFee = await rundlerPriorityFeeRequest - if (rundlerPriorityFee !== null) { - const rundlerFees = applyRundlerPriorityFee( - { maxFeePerGas, maxPriorityFeePerGas }, - { - rundlerPriorityFee, - baseFeePerGas, - priorityFeeBumpPercent: BigInt(priorityFeeBumpPercent), - maxFeeBumpPercent: BigInt(maxFeeBumpPercent), - }, - ) - maxFeePerGas = rundlerFees.maxFeePerGas - maxPriorityFeePerGas = rundlerFees.maxPriorityFeePerGas - } + const rundlerFees = await rundlerFeesRequest + maxFeePerGas = rundlerFees.maxFeePerGas + maxPriorityFeePerGas = rundlerFees.maxPriorityFeePerGas } catch (e) { if (params.requireBundlerEstimate) { throw new Error(`Bundler gas estimation failed: ${e instanceof Error ? e.message : String(e)}`, { diff --git a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts index 328225b9b4..96fe60b705 100644 --- a/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts +++ b/sdk/packages/sdk/src/protocols/intents/IntentGateway.ts @@ -185,6 +185,7 @@ export class IntentGateway { * * The ERC-4337 bundler URL is read from `dest.bundlerUrl`, set when constructing * the destination chain via {@link EvmChain.create} or {@link EvmChainParams.bundlerUrl}. + * An `EvmChain` given none uses the bundler Hyperbridge runs for it. * * @param source - Source chain for order placement * @param dest - Destination chain for order fulfillment diff --git a/sdk/packages/sdk/src/protocols/intents/index.ts b/sdk/packages/sdk/src/protocols/intents/index.ts index 028f1efe30..20deecfc93 100644 --- a/sdk/packages/sdk/src/protocols/intents/index.ts +++ b/sdk/packages/sdk/src/protocols/intents/index.ts @@ -46,7 +46,12 @@ export { DOMAIN_TYPEHASH, } from "./CryptoUtils" export { readSelectionFormat, type SelectionFormat } from "./selection" -export { fetchRundlerPriorityFee, applyRundlerPriorityFee } from "./rundlerFees" +export { + fetchRundlerPriorityFee, + fetchRundlerSuggestedFees, + applyRundlerPriorityFee, + rundlerUserOperationFees, +} from "./rundlerFees" export { encodeFillOrder, decodeFillOrder, diff --git a/sdk/packages/sdk/src/protocols/intents/rundlerFees.ts b/sdk/packages/sdk/src/protocols/intents/rundlerFees.ts index 3399059777..ddfdebf15e 100644 --- a/sdk/packages/sdk/src/protocols/intents/rundlerFees.ts +++ b/sdk/packages/sdk/src/protocols/intents/rundlerFees.ts @@ -7,48 +7,121 @@ import { BundlerMethod } from "./types" */ const UNSUPPORTED_REQUEST_CODES = new Set([-32600, -32601, -32602]) +type GasFees = { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } + /** Bundler URLs that answered `rundler_maxPriorityFeePerGas` as unsupported. */ const bundlersWithoutRundlerFees = new Set() +/** Bundler URLs that answered `rundler_getUserOperationGasPrice` as unsupported. */ +const bundlersWithoutRundlerGasPrice = new Set() + /** - * The priority fee a rundler bundler requires of a UserOperation, from - * `rundler_maxPriorityFeePerGas`. Returns `null` when the bundler does not give one, and the - * caller keeps its own estimate. - * - * A bundler that rejects the method as unsupported is not asked again. Any other failure, - * such as a network error or an internal error from rundler, is not remembered, so the next - * call asks again. + * Calls a parameterless rundler method. Returns `null` when the bundler does not answer it. * - * @param bundlerUrl - The bundler's JSON-RPC endpoint. - * @returns The required priority fee in wei, or `null`. + * A bundler that rejects the method as unsupported is added to `unsupported` and not asked + * again. Any other failure, such as a network error or an internal error from rundler, is + * not remembered, so the next call asks again. */ -export async function fetchRundlerPriorityFee(bundlerUrl: string): Promise { - if (bundlersWithoutRundlerFees.has(bundlerUrl)) return null +async function rundlerRequest(bundlerUrl: string, method: string, unsupported: Set): Promise { + if (unsupported.has(bundlerUrl)) return null try { const response = await fetch(bundlerUrl, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - jsonrpc: "2.0", - id: 1, - method: BundlerMethod.RUNDLER_MAX_PRIORITY_FEE_PER_GAS, - params: [], - }), + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params: [] }), }) - const { result, error } = (await response.json()) as { result?: string; error?: { code?: number } } + const { result, error } = (await response.json()) as { result?: unknown; error?: { code?: number } } if (error) { if (error.code !== undefined && UNSUPPORTED_REQUEST_CODES.has(error.code)) { - bundlersWithoutRundlerFees.add(bundlerUrl) + unsupported.add(bundlerUrl) } return null } - return result == null ? null : BigInt(result) + return result ?? null } catch { return null } } +/** + * The priority fee a rundler bundler requires of a UserOperation, from + * `rundler_maxPriorityFeePerGas`. Returns `null` when the bundler does not give one, and the + * caller keeps its own estimate. + * + * @param bundlerUrl - The bundler's JSON-RPC endpoint. + * @returns The required priority fee in wei, or `null`. + */ +export async function fetchRundlerPriorityFee(bundlerUrl: string): Promise { + const result = await rundlerRequest( + bundlerUrl, + BundlerMethod.RUNDLER_MAX_PRIORITY_FEE_PER_GAS, + bundlersWithoutRundlerFees, + ) + try { + return result == null ? null : BigInt(result as string) + } catch { + return null + } +} + +/** + * The fees a rundler bundler suggests for a UserOperation, from + * `rundler_getUserOperationGasPrice`. Rundler only bundles an op whose max fee covers its + * bundle base fee, the pending base fee raised by its overhead (27% by default), plus its + * priority fee. The suggested max fee adds a buffer on top of that. Returns `null` when the + * bundler does not give them. + * + * @param bundlerUrl - The bundler's JSON-RPC endpoint. + * @returns The suggested fees in wei, or `null`. + */ +export async function fetchRundlerSuggestedFees(bundlerUrl: string): Promise { + const result = (await rundlerRequest( + bundlerUrl, + BundlerMethod.RUNDLER_GET_USER_OPERATION_GAS_PRICE, + bundlersWithoutRundlerGasPrice, + )) as { suggested?: { maxFeePerGas?: string; maxPriorityFeePerGas?: string } } | null + const suggested = result?.suggested + if (suggested?.maxFeePerGas == null || suggested.maxPriorityFeePerGas == null) return null + try { + return { + maxFeePerGas: BigInt(suggested.maxFeePerGas), + maxPriorityFeePerGas: BigInt(suggested.maxPriorityFeePerGas), + } + } catch { + return null + } +} + +/** + * UserOperation fees a rundler bundler will bundle. Where the bundler suggests fees, those + * are used, each raised by its bump. Otherwise `fees` are raised to the priority fee it + * requires (see {@link applyRundlerPriorityFee}), or returned unchanged when it gives none. + */ +export async function rundlerUserOperationFees( + bundlerUrl: string, + fees: GasFees, + params: { baseFeePerGas: bigint; priorityFeeBumpPercent: bigint; maxFeeBumpPercent: bigint }, +): Promise { + const { baseFeePerGas, priorityFeeBumpPercent, maxFeeBumpPercent } = params + const suggested = await fetchRundlerSuggestedFees(bundlerUrl) + if (suggested) { + return { + maxFeePerGas: suggested.maxFeePerGas + (suggested.maxFeePerGas * maxFeeBumpPercent) / 100n, + maxPriorityFeePerGas: + suggested.maxPriorityFeePerGas + (suggested.maxPriorityFeePerGas * priorityFeeBumpPercent) / 100n, + } + } + const rundlerPriorityFee = await fetchRundlerPriorityFee(bundlerUrl) + if (rundlerPriorityFee === null) return fees + return applyRundlerPriorityFee(fees, { + rundlerPriorityFee, + baseFeePerGas, + priorityFeeBumpPercent, + maxFeeBumpPercent, + }) +} + /** * Raises UserOperation fees to what a rundler bundler accepts and bundles. The priority fee * becomes at least `rundlerPriorityFee` raised by `priorityFeeBumpPercent`. The max fee @@ -60,14 +133,14 @@ export async function fetchRundlerPriorityFee(bundlerUrl: string): Promise { + it("defaults to the bundler Hyperbridge runs for the chain", () => { + expect(EvmChain.fromParams({ chainId: 8453, rpcUrl, host }).bundlerUrl).toBe( + "https://bundler.polytope.technology/base", + ) + expect(EvmChain.fromParams({ chainId: 97, rpcUrl, host }).bundlerUrl).toBe( + "https://bundler.polytope.technology/bsc-chapel", + ) + }) + + it("keeps a bundler it is given", () => { + const bundlerUrl = "https://bundler.example.com" + expect(EvmChain.fromParams({ chainId: 8453, rpcUrl, host, bundlerUrl }).bundlerUrl).toBe(bundlerUrl) + }) + + it("is undefined on a chain Hyperbridge runs no bundler for", () => { + expect(EvmChain.fromParams({ chainId: 11155111, rpcUrl, host }).bundlerUrl).toBeUndefined() + }) +}) diff --git a/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts b/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts index 0079c76ef7..ecbb8c9060 100644 --- a/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts +++ b/sdk/packages/sdk/src/tests/fillEstimateBundlerFailure.test.ts @@ -45,7 +45,7 @@ const ORDER: Order = { } function estimatorWith( - bundler: { batch: ReturnType; single: ReturnType }, + bundler: { single: ReturnType }, { gateway = { address: GATEWAY, typehash: SELECT_SOLVER_TYPEHASH }, entryPoint = ENTRY_POINT, @@ -72,7 +72,6 @@ function estimatorWith( } as unknown as IntentGatewayContext const crypto = { encodeERC7821Execute: () => "0x" as HexString, - sendBundlerBatch: bundler.batch, sendBundler: bundler.single, } as unknown as CryptoUtils @@ -83,7 +82,6 @@ function estimatorWith( } const rejecting = () => ({ - batch: vi.fn().mockRejectedValue(new Error("batch unsupported")), single: vi.fn().mockRejectedValue(new Error("AA23 reverted")), }) @@ -112,12 +110,13 @@ describe("GasEstimator.estimateFillOrder when the bundler fails to estimate", () it("returns the bundler's figures when the estimate succeeds", async () => { const estimator = estimatorWith({ - batch: vi + single: vi .fn() - .mockResolvedValue([ - { callGasLimit: "0x186a0", verificationGasLimit: "0x186a0", preVerificationGas: "0x186a0" }, - ]), - single: vi.fn(), + .mockResolvedValue({ + callGasLimit: "0x186a0", + verificationGasLimit: "0x186a0", + preVerificationGas: "0x186a0", + }), }) const estimate = await estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) @@ -134,22 +133,20 @@ describe("GasEstimator.estimateFillOrder when the bundler fails to estimate", () await expect(estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true })).rejects.toThrow( "Bundler gas estimation failed: No EntryPoint configured for EVM-97", ) - expect(bundler.batch).not.toHaveBeenCalled() + expect(bundler.single).not.toHaveBeenCalled() }) }) describe("GasEstimator.estimateFillOrder session signature", () => { const succeeding = () => ({ - batch: vi + single: vi .fn() - .mockResolvedValue([{ callGasLimit: "0x1", verificationGasLimit: "0x1", preVerificationGas: "0x1" }]), - single: vi.fn(), + .mockResolvedValue({ callGasLimit: "0x1", verificationGasLimit: "0x1", preVerificationGas: "0x1" }), }) /** The op the estimate sent, packed again, and the session's selection signature at its end. */ - function sentOp(batch: ReturnType) { - const [[{ params }]] = batch.mock.calls[0] as [[{ params: [Record] }]] - const op = params[0] + function sentOp(single: ReturnType) { + const [, [op]] = single.mock.calls[0] as [string, [Record]] return { sender: op.sender, selection: slice(op.signature, 97), @@ -186,7 +183,7 @@ describe("GasEstimator.estimateFillOrder session signature", () => { await estimatorWith(bundler, { gateway }).estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) - const { sender, selection, userOpHash } = sentOp(bundler.batch) + const { sender, selection, userOpHash } = sentOp(bundler.single) const digest = hashTypedData({ domain: domain(gateway.address), types: { @@ -210,7 +207,7 @@ describe("GasEstimator.estimateFillOrder session signature", () => { await estimatorWith(bundler, { gateway }).estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) - const { sender, selection } = sentOp(bundler.batch) + const { sender, selection } = sentOp(bundler.single) const digest = hashTypedData({ domain: domain(gateway.address), types: { diff --git a/sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts b/sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts index c5d641b408..53e6d35f8d 100644 --- a/sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts +++ b/sdk/packages/sdk/src/tests/fillEstimateGasPrice.test.ts @@ -11,11 +11,11 @@ vi.mock("@/protocols/intents/fillOrderCodec", async (importOriginal) => ({ vi.mock("@/protocols/intents/rundlerFees", async (importOriginal) => { const actual = await importOriginal() - return { ...actual, fetchRundlerPriorityFee: vi.fn(actual.fetchRundlerPriorityFee) } + return { ...actual, rundlerUserOperationFees: vi.fn(actual.rundlerUserOperationFees) } }) const { GasEstimator } = await import("@/protocols/intents/GasEstimator") -const { fetchRundlerPriorityFee } = await import("@/protocols/intents/rundlerFees") +const { rundlerUserOperationFees } = await import("@/protocols/intents/rundlerFees") // A testnet chain, so gas is not priced through a swap quote. const CHAIN = "EVM-97" @@ -44,7 +44,7 @@ const ORDER: Order = { const GAS_ESTIMATE = { callGasLimit: "0x1", verificationGasLimit: "0x1", preVerificationGas: "0x1" } -function estimatorWith(bundlerUrl: string, batch: ReturnType) { +function estimatorWith(bundlerUrl: string) { const chain = { config: { stateMachineId: CHAIN }, client: { @@ -61,8 +61,7 @@ function estimatorWith(bundlerUrl: string, batch: ReturnType) { const ctx = { source: chain, dest: chain, bundlerUrl, feeTokenCache: new Map() } as unknown as IntentGatewayContext const crypto = { encodeERC7821Execute: () => "0x" as HexString, - sendBundlerBatch: batch, - sendBundler: vi.fn(), + sendBundler: vi.fn().mockResolvedValue(GAS_ESTIMATE), } as unknown as CryptoUtils const estimator = new GasEstimator(ctx, crypto) @@ -71,75 +70,69 @@ function estimatorWith(bundlerUrl: string, batch: ReturnType) { return estimator } -/** Stubs the bundler's `rundler_maxPriorityFeePerGas`, which is fetched outside the estimate batch. */ -function stubRundler(reply: Record) { - const fetch = vi.fn(async () => ({ json: async () => ({ jsonrpc: "2.0", id: 1, ...reply }) })) +const METHOD_NOT_FOUND = { error: { code: -32601, message: "Method not found" } } + +/** Stubs the bundler's rundler fee methods, which are fetched outside the estimate batch. */ +function stubRundler(replies: Record>) { + const fetch = vi.fn(async (_url: string, init: { body: string }) => { + const { method } = JSON.parse(init.body) as { method: string } + return { json: async () => ({ jsonrpc: "2.0", id: 1, ...(replies[method] ?? METHOD_NOT_FOUND) }) } + }) vi.stubGlobal("fetch", fetch) return fetch } -beforeEach(() => vi.mocked(fetchRundlerPriorityFee).mockClear()) +const methodsCalled = (fetch: ReturnType) => + fetch.mock.calls.map(([, init]) => (JSON.parse(init.body) as { method: string }).method) + +beforeEach(() => { + vi.mocked(rundlerUserOperationFees).mockClear() +}) afterEach(() => vi.unstubAllGlobals()) describe("GasEstimator.estimateFillOrder gas price", () => { - it("raises the fees to a rundler bundler's priority fee when it is above the chain estimate", async () => { - const fetch = stubRundler({ result: "0x5f5e100" }) - const estimator = estimatorWith("https://rundler.example", vi.fn().mockResolvedValue([GAS_ESTIMATE])) + it("prices a rundler bundler from the fees it suggests, raised by the bumps", async () => { + const fetch = stubRundler({ + rundler_getUserOperationGasPrice: { + result: { + priorityFee: "0x5f5e100", + baseFee: "0xdbba0", + blockNumber: "0x1", + suggested: { maxPriorityFeePerGas: "0x7bfa480", maxFeePerGas: "0xbebc200" }, + }, + }, + }) + const estimator = estimatorWith("https://rundler.example") + + const estimate = await estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) + + // 130M and 200M suggested, with the default 8% priority and 10% max fee bumps. + expect(estimate.maxPriorityFeePerGas).toBe(140_400_000n) + expect(estimate.maxFeePerGas).toBe(220_000_000n) + expect(methodsCalled(fetch)).toEqual(["rundler_getUserOperationGasPrice"]) + expect(rundlerUserOperationFees).toHaveBeenCalledWith("https://rundler.example", CHAIN_FEES, expect.anything()) + }) + + it("raises the fees to a rundler bundler's priority fee where it suggests none", async () => { + const fetch = stubRundler({ rundler_maxPriorityFeePerGas: { result: "0x5f5e100" } }) + const estimator = estimatorWith("https://older-rundler.example") const estimate = await estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) expect(estimate.maxPriorityFeePerGas).toBe(108_000_000n) expect(estimate.maxFeePerGas).toBe(BASE_FEE + BASE_FEE / 10n + 108_000_000n) - expect(fetch).toHaveBeenCalledWith("https://rundler.example", expect.anything()) - expect(fetchRundlerPriorityFee).toHaveBeenCalledWith("https://rundler.example") + expect(methodsCalled(fetch)).toEqual(["rundler_getUserOperationGasPrice", "rundler_maxPriorityFeePerGas"]) }) it("keeps the chain estimate and stops asking when the bundler does not serve rundler fees", async () => { - const fetch = stubRundler({ error: { code: -32601, message: "Method not found" } }) - const estimator = estimatorWith("https://other-bundler.example", vi.fn().mockResolvedValue([GAS_ESTIMATE])) + const fetch = stubRundler({}) + const estimator = estimatorWith("https://other-bundler.example") const first = await estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) const second = await estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) expect(first).toMatchObject(CHAIN_FEES) expect(second).toMatchObject(CHAIN_FEES) - expect(fetch).toHaveBeenCalledOnce() - }) - - it("prices a Pimlico bundler from Pimlico's gas price without asking for rundler fees", async () => { - const fetch = stubRundler({ result: "0x5f5e100" }) - const batch = vi - .fn() - .mockResolvedValue([GAS_ESTIMATE, { fast: { maxFeePerGas: "0x2dc6c0", maxPriorityFeePerGas: "0x1e8480" } }]) - const estimator = estimatorWith("https://api.pimlico.io/v2/97/rpc?apikey=k", batch) - - const estimate = await estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) - - expect(estimate.maxFeePerGas).toBe(3_300_000n) - expect(estimate.maxPriorityFeePerGas).toBe(2_160_000n) - expect(batch.mock.calls[0][0].map((request: { method: string }) => request.method)).toEqual([ - "eth_estimateUserOperationGas", - "pimlico_getUserOperationGasPrice", - ]) - expect(fetch).not.toHaveBeenCalled() - expect(fetchRundlerPriorityFee).not.toHaveBeenCalled() - }) - - it("prices an Alchemy bundler with its own buffers without the rundler floor", async () => { - const fetch = stubRundler({ result: "0x5f5e100" }) - const batch = vi.fn().mockResolvedValue([GAS_ESTIMATE, "0x5f5e100"]) - const estimator = estimatorWith("https://bnb-testnet.g.alchemy.com/v2/k", batch) - - const estimate = await estimator.estimateFillOrder({ order: ORDER, requireBundlerEstimate: true }) - - // A 25% priority bump off Arbitrum, and a 50% base fee buffer. - expect(estimate.maxPriorityFeePerGas).toBe(125_000_000n) - expect(estimate.maxFeePerGas).toBe(BASE_FEE + BASE_FEE / 2n + 125_000_000n) - expect(batch.mock.calls[0][0].map((request: { method: string }) => request.method)).toEqual([ - "eth_estimateUserOperationGas", - "rundler_maxPriorityFeePerGas", - ]) - expect(fetch).not.toHaveBeenCalled() - expect(fetchRundlerPriorityFee).not.toHaveBeenCalled() + expect(methodsCalled(fetch)).toEqual(["rundler_getUserOperationGasPrice", "rundler_maxPriorityFeePerGas"]) }) }) diff --git a/sdk/packages/sdk/src/tests/rundlerFees.test.ts b/sdk/packages/sdk/src/tests/rundlerFees.test.ts index 627dbe64b1..1d82265783 100644 --- a/sdk/packages/sdk/src/tests/rundlerFees.test.ts +++ b/sdk/packages/sdk/src/tests/rundlerFees.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest" -import { applyRundlerPriorityFee, fetchRundlerPriorityFee } from "@/protocols/intents/rundlerFees" +import { + applyRundlerPriorityFee, + fetchRundlerPriorityFee, + fetchRundlerSuggestedFees, + rundlerUserOperationFees, +} from "@/protocols/intents/rundlerFees" function stubBundler(reply: () => Promise) { const fetch = vi.fn(async () => ({ json: reply })) @@ -74,3 +79,79 @@ describe("applyRundlerPriorityFee", () => { expect(fees).toEqual({ maxPriorityFeePerGas: 1_080_000n, maxFeePerGas: 1_100_000n }) }) }) + +describe("fetchRundlerSuggestedFees", () => { + it("returns the fees rundler suggests", async () => { + const fetch = stubBundler(async () => ({ + jsonrpc: "2.0", + id: 1, + result: { + priorityFee: "0x2faf080", + baseFee: "0x96e3af8", + blockNumber: "0x18f33c1", + suggested: { maxPriorityFeePerGas: "0x3dfd240", maxFeePerGas: "0x15d6ee1b" }, + }, + })) + + expect(await fetchRundlerSuggestedFees("https://rundler-d.example")).toEqual({ + maxPriorityFeePerGas: 65_000_000n, + maxFeePerGas: 366_407_195n, + }) + const [, init] = fetch.mock.calls[0] as unknown as [string, { body: string }] + expect(JSON.parse(init.body)).toMatchObject({ method: "rundler_getUserOperationGasPrice", params: [] }) + }) + + it("stops asking a bundler that does not serve the method", async () => { + const fetch = stubBundler(async () => ({ + jsonrpc: "2.0", + id: 1, + error: { code: -32601, message: "Method not found" }, + })) + + expect(await fetchRundlerSuggestedFees("https://other-b.example")).toBeNull() + expect(await fetchRundlerSuggestedFees("https://other-b.example")).toBeNull() + expect(fetch).toHaveBeenCalledOnce() + }) +}) + +describe("rundlerUserOperationFees", () => { + const fees = { maxFeePerGas: 1_100_000n, maxPriorityFeePerGas: 1_080_000n } + const params = { baseFeePerGas: 900_000n, priorityFeeBumpPercent: 8n, maxFeeBumpPercent: 10n } + + it("uses the suggested fees raised by the bumps, even below the estimate", async () => { + stubBundler(async () => ({ + jsonrpc: "2.0", + id: 1, + result: { suggested: { maxPriorityFeePerGas: "0x186a0", maxFeePerGas: "0x16e360" } }, + })) + + expect(await rundlerUserOperationFees("https://rundler-e.example", fees, params)).toEqual({ + maxPriorityFeePerGas: 108_000n, + maxFeePerGas: 1_650_000n, + }) + }) + + it("falls back to the required priority fee when the bundler suggests none", async () => { + const fetch = vi.fn(async (_url: string, init: { body: string }) => { + const { method } = JSON.parse(init.body) as { method: string } + return { + json: async () => + method === "rundler_maxPriorityFeePerGas" + ? { jsonrpc: "2.0", id: 1, result: "0x5f5e100" } + : { jsonrpc: "2.0", id: 1, error: { code: -32601, message: "Method not found" } }, + } + }) + vi.stubGlobal("fetch", fetch) + + expect(await rundlerUserOperationFees("https://rundler-f.example", fees, params)).toEqual({ + maxPriorityFeePerGas: 108_000_000n, + maxFeePerGas: 990_000n + 108_000_000n, + }) + }) + + it("keeps the fees when the bundler gives neither", async () => { + stubBundler(async () => ({ jsonrpc: "2.0", id: 1, error: { code: -32601, message: "Method not found" } })) + + expect(await rundlerUserOperationFees("https://other-c.example", fees, params)).toEqual(fees) + }) +}) diff --git a/sdk/packages/simplex-desktop/package.json b/sdk/packages/simplex-desktop/package.json index c80c2c6268..4af1e1706d 100644 --- a/sdk/packages/simplex-desktop/package.json +++ b/sdk/packages/simplex-desktop/package.json @@ -1,7 +1,7 @@ { "name": "@hyperbridge/simplex-desktop", "productName": "Simplex", - "version": "0.17.0", + "version": "0.17.1", "private": true, "description": "Native desktop shell for the Simplex intent solver", "author": "Polytope Labs ", diff --git a/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs b/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs index fc18baceea..04189a06dc 100644 --- a/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs +++ b/sdk/packages/simplex-desktop/scripts/e2e/desktop.e2e.mjs @@ -781,7 +781,6 @@ test("configured startup owns the socket before filling and relaunch attaches wh chains: [ { rpcUrls: [`http://127.0.0.1:${blackhole.port}`], - bundlerUrl: `http://127.0.0.1:${blackhole.port}`, }, ], } @@ -1215,7 +1214,7 @@ test("first run writes a valid private config under Electron userData", async (t ], }, ], - chains: [{ rpcUrls: ["http://127.0.0.1:9"], bundlerUrl: "http://127.0.0.1:9" }], + chains: [{ rpcUrls: ["http://127.0.0.1:9"] }], orderbook: { url: "https://orderbook.example/graphql" }, } const result = await page.evaluate(async (body) => { diff --git a/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs b/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs index 0580be5f42..4efa74f3e9 100644 --- a/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs +++ b/sdk/packages/simplex-desktop/scripts/e2e/packaged-smoke.mjs @@ -187,7 +187,7 @@ async function assertPackagedOnboarding(socketPath, userData) { ], }, ], - chains: [{ rpcUrls: ["http://127.0.0.1:9"], bundlerUrl: "http://127.0.0.1:9" }], + chains: [{ rpcUrls: ["http://127.0.0.1:9"] }], orderbook: { url: "https://orderbook.example/graphql" }, } const response = await socketRequest(socketPath, "/api/setup/save-and-start", "POST", { config }) diff --git a/sdk/packages/simplex/README.md b/sdk/packages/simplex/README.md index e4d53e8736..50635ae0ae 100644 --- a/sdk/packages/simplex/README.md +++ b/sdk/packages/simplex/README.md @@ -100,7 +100,7 @@ still `simplex`. Prefer a container? The same binary ships as Hub page. With no config present, `simplex` opens a local browser wizard that walks through the minimum setup -(chains, RPCs, bundlers, signer, Hyperbridge account), validates every endpoint live, +(chains, RPCs, signer, Hyperbridge account), validates every endpoint live, writes a commented `filler-config.toml` (mode 600) and starts the solver in the same process. `simplex init` is the equivalent terminal wizard. diff --git a/sdk/packages/simplex/e2e/env.mjs b/sdk/packages/simplex/e2e/env.mjs index a71c48777a..975a0d51a1 100644 --- a/sdk/packages/simplex/e2e/env.mjs +++ b/sdk/packages/simplex/e2e/env.mjs @@ -8,10 +8,6 @@ export function readEnv() { const names = { bscRpc: "E2E_BSC_TESTNET_RPC_URL", amoyRpc: "E2E_POLYGON_AMOY_RPC_URL", - // Separate from the RPC: a bundler has to serve the chain's EntryPoint, and an RPC - // provider's own bundler may not. - bscBundler: "E2E_BSC_TESTNET_BUNDLER_URL", - amoyBundler: "E2E_POLYGON_AMOY_BUNDLER_URL", orderbook: "E2E_ORDERBOOK_URL", hyperbridge: "E2E_HYPERBRIDGE_WS_URL", solver1Key: "E2E_SOLVER1_PRIVATE_KEY", @@ -61,8 +57,8 @@ export const TOKENS = { export function chains(env) { return { - "EVM-97": { id: 97, viem: bscTestnet, rpc: env.bscRpc, bundler: env.bscBundler }, - "EVM-80002": { id: 80002, viem: polygonAmoy, rpc: env.amoyRpc, bundler: env.amoyBundler }, + "EVM-97": { id: 97, viem: bscTestnet, rpc: env.bscRpc }, + "EVM-80002": { id: 80002, viem: polygonAmoy, rpc: env.amoyRpc }, } } diff --git a/sdk/packages/simplex/e2e/run.mjs b/sdk/packages/simplex/e2e/run.mjs index cb016787e3..d8a85054b4 100644 --- a/sdk/packages/simplex/e2e/run.mjs +++ b/sdk/packages/simplex/e2e/run.mjs @@ -93,11 +93,9 @@ points = [{ amount = "1", value = 1 }, { amount = "100000", value = 2 }] [[chains]] rpcUrls = [${q(env.bscRpc)}] -bundlerUrl = ${q(env.bscBundler)} [[chains]] rpcUrls = [${q(env.amoyRpc)}] -bundlerUrl = ${q(env.amoyBundler)} ` } diff --git a/sdk/packages/simplex/e2e/swap.mjs b/sdk/packages/simplex/e2e/swap.mjs index 710fb252f2..cf2d93f572 100644 --- a/sdk/packages/simplex/e2e/swap.mjs +++ b/sdk/packages/simplex/e2e/swap.mjs @@ -62,9 +62,8 @@ async function main() { for (const leg of s.legs) await ensureAllowance(srcPub, srcWallet, TOKENS[s.source][leg.tokenIn].address) await ensureAllowance(srcPub, srcWallet, FEE_TOKEN) - const sourceChain = EvmChain.fromParams({ chainId: src.id, host: HOST, rpcUrl: src.rpc, bundlerUrl: src.bundler }) - const destChain = - s.source === s.dest ? sourceChain : EvmChain.fromParams({ chainId: dst.id, host: HOST, rpcUrl: dst.rpc, bundlerUrl: dst.bundler }) + const sourceChain = EvmChain.fromParams({ chainId: src.id, host: HOST, rpcUrl: src.rpc }) + const destChain = s.source === s.dest ? sourceChain : EvmChain.fromParams({ chainId: dst.id, host: HOST, rpcUrl: dst.rpc }) const coprocessor = await IntentsCoprocessor.connect(env.hyperbridge) const gateway = await IntentGateway.create(sourceChain, destChain, coprocessor) diff --git a/sdk/packages/simplex/filler-config-example.toml b/sdk/packages/simplex/filler-config-example.toml index 4be6e2e2d8..56f731fec3 100644 --- a/sdk/packages/simplex/filler-config-example.toml +++ b/sdk/packages/simplex/filler-config-example.toml @@ -101,7 +101,6 @@ hyperbridgeWsUrl = "" # maxOverfillBps = 500 # warn when an offer is 5% above the requested output (default: 500) # maxConsecutiveClamps = 3 # accepted, currently unused (default: 3) - # Logging level: trace, debug, info, warn, error logging = "debug" @@ -240,7 +239,6 @@ url = "https://orderbook.hyperfx.finance/mainnet/graphql" # testnet: https://o # reconcileIntervalSecs = 300 # how often to check the orderbook still matches (default 300) # requestTimeoutMs = 10000 # per orderbook request (default 10000) - # Remote access from a phone. Off by default. When enabled, simplex keeps an outbound # SSH tunnel to a relay so a phone's SSH client can open this UI with a local port # forward; the phone's session terminates inside simplex, the relay only sees ciphertext. @@ -250,10 +248,10 @@ url = "https://orderbook.hyperfx.finance/mainnet/graphql" # testnet: https://o # relay = "simplex.tunnel.polytope.technology:443" # host[:port], port defaults to 443 # relayHostKey = "SHA256:..." # pin for a self-hosted relay; the hosted one is built in -# Chain configuration - RPC and bundler URLs are required -# Hyperbridge runs an ERC-4337 bundler for Ethereum, BSC, Arbitrum, Base and Polygon at -# https://bundler.polytope.technology/. It needs no API key. On any other chain, use a -# bundler that serves the EntryPoint the chain's solver account uses. +# Chain configuration - RPC URLs are required +# Fills go through the ERC-4337 bundler Hyperbridge runs for each chain, so there is no bundler +# to configure. Simplex fills on Ethereum, BSC, Arbitrum, Base and Polygon, and on BSC Chapel +# and Polygon Amoy on testnet. Any other chain can only be watched (see simplex.watchOnly). # Chain IDs are resolved automatically from the RPC endpoints. # All other chain data (addresses, assets, etc.) comes from the SDK automatically. # @@ -264,26 +262,15 @@ url = "https://orderbook.hyperfx.finance/mainnet/graphql" # testnet: https://o # balance reads always use the first healthy endpoint. [[chains]] rpcUrls = [""] # Ethereum Mainnet — add more endpoints to enable quorum -bundlerUrl = "https://bundler.polytope.technology/ethereum" [[chains]] rpcUrls = [""] # BSC Mainnet -bundlerUrl = "https://bundler.polytope.technology/bsc" [[chains]] rpcUrls = [""] # Arbitrum Mainnet -bundlerUrl = "https://bundler.polytope.technology/arbitrum" [[chains]] rpcUrls = [""] # Base Mainnet -bundlerUrl = "https://bundler.polytope.technology/base" [[chains]] rpcUrls = [""] # Polygon Mainnet -bundlerUrl = "https://bundler.polytope.technology/polygon" - -[[chains]] -rpcUrls = [""] # Unichain Mainnet -bundlerUrl = "" - - diff --git a/sdk/packages/simplex/package.json b/sdk/packages/simplex/package.json index 00908ba330..1c9e19fcf2 100644 --- a/sdk/packages/simplex/package.json +++ b/sdk/packages/simplex/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbridge/simplex", - "version": "0.17.0", + "version": "0.17.1", "license": "Apache-2.0", "description": "Automated intent filler for the Hyperbridge IntentGateway \u2014 run it as a binary or embed it as a library", "main": "dist/index.js", diff --git a/sdk/packages/simplex/src/cli/init/chains.ts b/sdk/packages/simplex/src/cli/init/chains.ts index a6c865d49e..25b76bf7b8 100644 --- a/sdk/packages/simplex/src/cli/init/chains.ts +++ b/sdk/packages/simplex/src/cli/init/chains.ts @@ -1,4 +1,5 @@ import externalLinks from "../../config/external-links.json" +import { HYPERBRIDGE_BUNDLER_URLS } from "@/config/bundlers" export type InitNetwork = "mainnet" | "testnet" @@ -19,9 +20,8 @@ export interface InitChainMeta { */ defaultRpcUrls?: string[] /** - * Hyperbridge's ERC-4337 bundler for the chain. Where there is one, the wizards and - * the Chains panel always use it and never ask for a bundler. See - * {@link HYPERBRIDGE_BUNDLER_URLS}. + * Hyperbridge's ERC-4337 bundler for the chain, which simplex fills through. A chain + * without one can only be watched. See {@link HYPERBRIDGE_BUNDLER_URLS}. */ hyperbridgeBundlerUrl?: string } @@ -97,18 +97,6 @@ const FREE_RPC_URLS: Record = { ], } -/** - * The bundlers Hyperbridge runs, per chain. They need no API key and serve EntryPoint - * v0.8 and v0.9. A chain absent here takes its bundler from the config file. - */ -const HYPERBRIDGE_BUNDLER_URLS: Record = { - 1: "https://bundler.polytope.technology/ethereum", - 56: "https://bundler.polytope.technology/bsc", - 137: "https://bundler.polytope.technology/polygon", - 8453: "https://bundler.polytope.technology/base", - 42161: "https://bundler.polytope.technology/arbitrum", -} - const CHAIN_NATIVE_SYMBOLS: Record = { 1: "ETH", 56: "BNB", @@ -214,6 +202,7 @@ export const INIT_CHAINS: InitChainMeta[] = [ network: "testnet", alchemySubdomain: "polygon-amoy", explorerUrl: externalLinks.chainExplorers.polygonAmoy, + hyperbridgeBundlerUrl: HYPERBRIDGE_BUNDLER_URLS[80002], }, { chainId: 97, @@ -222,6 +211,7 @@ export const INIT_CHAINS: InitChainMeta[] = [ network: "testnet", alchemySubdomain: "bnb-testnet", explorerUrl: externalLinks.chainExplorers.bnbChapel, + hyperbridgeBundlerUrl: HYPERBRIDGE_BUNDLER_URLS[97], }, ] @@ -230,8 +220,9 @@ export const HYPERBRIDGE_WS_DEFAULTS: Record = { testnet: "wss://gargantua.rpc.polytope.technology", } +/** The chains the wizards and the Chains panel offer: those Hyperbridge runs a bundler for. */ export function chainsForNetwork(network: InitNetwork): InitChainMeta[] { - return INIT_CHAINS.filter((chain) => chain.network === network) + return INIT_CHAINS.filter((chain) => chain.network === network && chain.hyperbridgeBundlerUrl) } export function chainByChainId(chainId: number): InitChainMeta | undefined { diff --git a/sdk/packages/simplex/src/cli/init/derive-flow.ts b/sdk/packages/simplex/src/cli/init/derive-flow.ts index 77cbe931e7..8f521fcf00 100644 --- a/sdk/packages/simplex/src/cli/init/derive-flow.ts +++ b/sdk/packages/simplex/src/cli/init/derive-flow.ts @@ -2,7 +2,7 @@ import { confirm, select } from "@clack/prompts" import { guard } from "./prompt-utils" /** - * One provider API key can serve every chain (Alchemy RPCs, Pimlico bundlers). + * One provider API key can serve every chain (Alchemy RPCs). * Tracks the key detected in the first manually entered URL and, once the user * confirms, supplies derived URLs for the remaining chains. */ diff --git a/sdk/packages/simplex/src/cli/init/derive/pimlico.ts b/sdk/packages/simplex/src/cli/init/derive/pimlico.ts deleted file mode 100644 index e4ea865d9e..0000000000 --- a/sdk/packages/simplex/src/cli/init/derive/pimlico.ts +++ /dev/null @@ -1,37 +0,0 @@ -export interface ParsedPimlicoUrl { - chainId: number - apiKey: string -} - -/** Matches `https://api.pimlico.io/v2//rpc?apikey=`. */ -export function parsePimlicoUrl(url: string): ParsedPimlicoUrl | null { - let parsed: URL - try { - parsed = new URL(url) - } catch { - return null - } - if (parsed.protocol !== "https:") return null - if (parsed.hostname !== "api.pimlico.io") return null - - const match = parsed.pathname.match(/^\/v2\/([^/]+)\/rpc$/) - if (!match) return null - - const chainId = Number(match[1]) - if (!Number.isInteger(chainId) || chainId <= 0) return null - - const apiKey = parsed.searchParams.get("apikey") - if (!apiKey) return null - - return { chainId, apiKey } -} - -export function isPimlicoUrl(url: string): boolean { - return parsePimlicoUrl(url) !== null -} - -export function derivePimlicoBundler(apiKey: string, chainId: number): string { - // parsePimlicoUrl URL-decodes the key (searchParams.get); re-encode so keys - // containing +/%/space survive the parse -> derive round-trip unchanged. - return `https://api.pimlico.io/v2/${chainId}/rpc?apikey=${encodeURIComponent(apiKey)}` -} diff --git a/sdk/packages/simplex/src/cli/init/emit-toml.ts b/sdk/packages/simplex/src/cli/init/emit-toml.ts index fc61aee49c..3ca1b6c360 100644 --- a/sdk/packages/simplex/src/cli/init/emit-toml.ts +++ b/sdk/packages/simplex/src/cli/init/emit-toml.ts @@ -227,8 +227,6 @@ export function emitFillerToml(config: FillerConfigFile, options: EmitOptions = push("[[chains]]") push("# List 2+ organisationally independent RPC providers to enable quorum log scanning.") push(kv("rpcUrls", chain.rpcUrls)) - push("# ERC-4337 bundler used to submit fill UserOperations on this chain.") - push(kv("bundlerUrl", chain.bundlerUrl)) push() }) diff --git a/sdk/packages/simplex/src/cli/init/help-text.ts b/sdk/packages/simplex/src/cli/init/help-text.ts index 5edc38f7d1..78e04536fb 100644 --- a/sdk/packages/simplex/src/cli/init/help-text.ts +++ b/sdk/packages/simplex/src/cli/init/help-text.ts @@ -3,11 +3,9 @@ * docs/content/developers/evm/simplex/ — keep the two in sync. */ export const WHY = { - chains: "Simplex listens for orders and fills only on the chains you pick. Each chain needs its own RPC, an ERC-4337 bundler, and funded balances (native gas + stablecoins).", + chains: "Simplex listens for orders and fills only on the chains you pick. Each chain needs its own RPC and funded balances (native gas + stablecoins); fills go through Hyperbridge's bundler.", rpc: "The RPC is used to scan order events, read balances and simulate fills. Use a premium endpoint with archive access (Alchemy, Infura, QuickNode) — free tiers rate-limit and break event scanning.", quorum: "Listing a second, organisationally independent RPC enables quorum log scanning: every event batch must match across providers, so one lying or compromised RPC can't feed you fake orders.", - bundler: - "Fills execute as ERC-4337 UserOperations; the bundler submits them on-chain. Hyperbridge runs no bundler for these chains, so use a provider such as Pimlico.", signer: "This wallet signs every fill and holds your stablecoin float on each chain. It's the identity of your filler.", secretPhrase: "A secret phrase is a BIP-39 mnemonic of 12 to 24 English words. The filler signs with the wallet at m/44'/60'/0'/0/0 unless you pick another account index. The phrase is written to the config file in plain text, protected only by the file's permissions, the same as a private key.", diff --git a/sdk/packages/simplex/src/cli/init/index.ts b/sdk/packages/simplex/src/cli/init/index.ts index 340fa24fe4..4b82643f00 100644 --- a/sdk/packages/simplex/src/cli/init/index.ts +++ b/sdk/packages/simplex/src/cli/init/index.ts @@ -9,7 +9,6 @@ import { guard, withTimeout, PROBE_TIMEOUT_MS } from "./prompt-utils" import { migrateLegacyConfig } from "./migrate-legacy" import { newWizardState, type Prefill } from "./state" import { stepChains } from "./steps/chains" -import { stepBundlers } from "./steps/bundlers" import { stepSigner } from "./steps/signer" import { stepHyperbridge } from "./steps/hyperbridge" import { stepFineTune } from "./steps/finetune" @@ -46,7 +45,6 @@ export async function runInit(options: InitOptions): Promise { const state = newWizardState() state.prefillConfig = prefill?.config await stepChains(state, prefill) - await stepBundlers(state, prefill) await stepSigner(state, prefill) await stepHyperbridge(state, prefill) diff --git a/sdk/packages/simplex/src/cli/init/state.ts b/sdk/packages/simplex/src/cli/init/state.ts index a84f07cb8f..d581fcbaf8 100644 --- a/sdk/packages/simplex/src/cli/init/state.ts +++ b/sdk/packages/simplex/src/cli/init/state.ts @@ -6,7 +6,6 @@ import type { InitChainMeta, InitNetwork } from "./chains" export interface WizardChain { meta: InitChainMeta rpcUrls: string[] - bundlerUrl?: string } export interface WizardState { diff --git a/sdk/packages/simplex/src/cli/init/steps/bundlers.ts b/sdk/packages/simplex/src/cli/init/steps/bundlers.ts deleted file mode 100644 index 0b7d0a1488..0000000000 --- a/sdk/packages/simplex/src/cli/init/steps/bundlers.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { confirm } from "@clack/prompts" -import { deriveAlchemyRpc, isAlchemyUrl } from "../derive/alchemy" -import { parsePimlicoUrl, derivePimlicoBundler } from "../derive/pimlico" -import { ProviderDerivation, askDerivedOrCustom } from "../derive-flow" -import { guard, why, askUrl, askSecret } from "../prompt-utils" -import { WHY } from "../help-text" -import type { Prefill, WizardState } from "../state" - -export async function stepBundlers(state: WizardState, prefill?: Prefill): Promise { - // Hyperbridge runs the bundler for these chains, so there is nothing to ask and no other - // bundler is offered. The questions below are for the chains it runs none for. - for (const chain of state.chains) { - if (chain.meta.hyperbridgeBundlerUrl) chain.bundlerUrl = chain.meta.hyperbridgeBundlerUrl - } - if (state.chains.every((chain) => chain.bundlerUrl)) return - - why(WHY.bundler) - - // Alchemy RPCs double as ERC-4337 bundlers — one confirm covers all of them. - // A chain whose prefilled bundler is a dedicated provider (differs from its - // RPC) keeps that as the default instead of the Alchemy shortcut. - const alchemyChains = state.chains.filter((chain) => { - if (chain.bundlerUrl || !isAlchemyUrl(chain.rpcUrls[0])) return false - const existing = prefillBundlerFor(chain.meta.chainId, prefill) - return !existing || existing === chain.rpcUrls[0] - }) - if (alchemyChains.length > 0) { - const reuse = guard( - await confirm({ - message: `Reuse the Alchemy RPC as the ERC-4337 bundler for ${alchemyChains.map((c) => c.meta.label).join(", ")}?`, - initialValue: true, - }), - ) - if (reuse) { - for (const chain of alchemyChains) chain.bundlerUrl = chain.rpcUrls[0] - } - } - - // With public RPC sets the operator may have no Alchemy URL anywhere in the - // config, so there is nothing for the shortcut above to reuse. Alchemy serves - // bundler methods on its ordinary endpoint, so the key alone is enough — and - // with the reads already covered, it is the only account this wizard needs. - const alchemyCapable = state.chains.filter((chain) => !chain.bundlerUrl && chain.meta.alchemySubdomain) - if (alchemyCapable.length > 0) { - const useKey = guard( - await confirm({ - message: `Configure the bundler for ${alchemyCapable.map((c) => c.meta.label).join(", ")} from one Alchemy API key?`, - initialValue: true, - }), - ) - if (useKey) { - const apiKey = await askSecret("Alchemy API key") - for (const chain of alchemyCapable) { - const url = deriveAlchemyRpc(apiKey, chain.meta.chainId) - if (url) chain.bundlerUrl = url - } - } - } - - // One Pimlico key serves every chain — offer to derive the rest. - const pimlico = new ProviderDerivation({ - detect: (url) => parsePimlicoUrl(url)?.apiKey ?? null, - derive: derivePimlicoBundler, - confirmMessage: (remaining) => `Pimlico key detected — derive bundler URLs for ${remaining} from the same key?`, - }) - - for (const chain of state.chains) { - if (chain.bundlerUrl) continue - - const existing = prefillBundlerFor(chain.meta.chainId, prefill) - const derived = pimlico.candidate(chain.meta.chainId) - - const url = derived - ? await askDerivedOrCustom(`Bundler for ${chain.meta.label}`, derived, "Use the derived Pimlico URL", () => - askBundlerUrl(chain.meta.label, existing), - ) - : await askBundlerUrl(chain.meta.label, existing) - chain.bundlerUrl = url - - const remaining = state.chains.filter((c) => !c.bundlerUrl) - await pimlico.offer( - url, - remaining.map((c) => c.meta.label), - ) - } -} - -function prefillBundlerFor(chainId: number, prefill?: Prefill): string | undefined { - if (!prefill) return undefined - const index = prefill.chainIds.indexOf(chainId) - if (index === -1) return undefined - return prefill.config.chains[index]?.bundlerUrl || undefined -} - -function askBundlerUrl(label: string, initial?: string): Promise { - return askUrl(`ERC-4337 bundler URL for ${label}`, { - initial, - placeholder: "https://api.pimlico.io/v2//rpc?apikey=", - required: "Bundler URL is required — fills are submitted through it", - }) -} diff --git a/sdk/packages/simplex/src/cli/init/steps/chains.ts b/sdk/packages/simplex/src/cli/init/steps/chains.ts index e92fe08845..4dc18ec679 100644 --- a/sdk/packages/simplex/src/cli/init/steps/chains.ts +++ b/sdk/packages/simplex/src/cli/init/steps/chains.ts @@ -1,6 +1,12 @@ import { confirm, log, multiselect, select, spinner } from "@clack/prompts" import { fetchChainId } from "@/services/FillerConfigService" -import { chainsForNetwork, chainByAlchemySubdomain, type InitChainMeta, type InitNetwork } from "../chains" +import { + chainsForNetwork, + chainByAlchemySubdomain, + chainByChainId, + type InitChainMeta, + type InitNetwork, +} from "../chains" import { parseAlchemyUrl, deriveAlchemyRpc } from "../derive/alchemy" import { ProviderDerivation, askDerivedOrCustom } from "../derive-flow" import { guard, why, askUrl, withTimeout, PROBE_TIMEOUT_MS } from "../prompt-utils" @@ -51,8 +57,7 @@ function detectPrefillNetwork(prefill?: Prefill): InitNetwork | undefined { if (!prefill) return undefined for (const chainId of prefill.chainIds) { if (chainId === null) continue - const meta = chainsForNetwork("testnet").find((c) => c.chainId === chainId) - if (meta) return "testnet" + if (chainByChainId(chainId)?.network === "testnet") return "testnet" } return "mainnet" } diff --git a/sdk/packages/simplex/src/cli/init/steps/write.ts b/sdk/packages/simplex/src/cli/init/steps/write.ts index d9f74c8973..ef8979da9a 100644 --- a/sdk/packages/simplex/src/cli/init/steps/write.ts +++ b/sdk/packages/simplex/src/cli/init/steps/write.ts @@ -137,7 +137,7 @@ export function assembleConfig(state: WizardState): FillerConfigFile { // would otherwise shadow the fresh entry (last [[chains]] wins at load). chains: [ ...state.passthroughChains, - ...state.chains.map((chain) => ({ rpcUrls: chain.rpcUrls, bundlerUrl: chain.bundlerUrl ?? "" })), + ...state.chains.map((chain) => ({ rpcUrls: chain.rpcUrls })), ], rebalancing, vault: hasVault ? vault : undefined, @@ -167,10 +167,7 @@ function chainComments(state: WizardState): string[] { function showSummary(state: WizardState, outputPath: string): void { const lines: string[] = [] for (const chain of state.chains) { - const bundlerHost = chain.bundlerUrl ? new URL(chain.bundlerUrl).hostname : "?" - lines.push( - `${chain.meta.label}: ${chain.rpcUrls.length} RPC${chain.rpcUrls.length > 1 ? "s (quorum)" : ""}, bundler ${bundlerHost}`, - ) + lines.push(`${chain.meta.label}: ${chain.rpcUrls.length} RPC${chain.rpcUrls.length > 1 ? "s (quorum)" : ""}`) } if (state.passthroughChains.length > 0) { lines.push(`+ ${state.passthroughChains.length} unmanaged chain(s) kept from the previous config`) diff --git a/sdk/packages/simplex/src/config/bundlers.ts b/sdk/packages/simplex/src/config/bundlers.ts new file mode 100644 index 0000000000..c5681128bb --- /dev/null +++ b/sdk/packages/simplex/src/config/bundlers.ts @@ -0,0 +1,22 @@ +/** + * The ERC-4337 bundlers Hyperbridge runs, per chain id. They need no API key and serve + * EntryPoint v0.8 and v0.9. Simplex submits every fill through the one for its chain, and a + * chain absent here can only be watched. + * + * The SDK's chain registry carries the same list as `bundlerUrl`. It is repeated here because + * the dashboard bundles this module and must not pull in the SDK. + */ +export const HYPERBRIDGE_BUNDLER_URLS: Readonly> = { + 1: "https://bundler.polytope.technology/ethereum", + 56: "https://bundler.polytope.technology/bsc", + 97: "https://bundler.polytope.technology/bsc-chapel", + 137: "https://bundler.polytope.technology/polygon", + 8453: "https://bundler.polytope.technology/base", + 42161: "https://bundler.polytope.technology/arbitrum", + 80002: "https://bundler.polytope.technology/polygon-amoy", +} + +/** Hyperbridge's bundler for `chainId`, or undefined where it runs none. */ +export function hyperbridgeBundlerUrl(chainId: number): string | undefined { + return HYPERBRIDGE_BUNDLER_URLS[chainId] +} diff --git a/sdk/packages/simplex/src/config/filler-toml.ts b/sdk/packages/simplex/src/config/filler-toml.ts index b9a1e12db4..f0dbfe5dc4 100644 --- a/sdk/packages/simplex/src/config/filler-toml.ts +++ b/sdk/packages/simplex/src/config/filler-toml.ts @@ -325,9 +325,6 @@ export function validateConfig(config: FillerTomlConfig, cliWatchOnly = false): if (!Array.isArray(chain.rpcUrls) || chain.rpcUrls.length === 0 || chain.rpcUrls.some((u) => !u)) { throw new Error("Each chain configuration must have rpcUrls (a non-empty array of strings)") } - if (!chain.bundlerUrl) { - throw new Error("Each chain configuration must have bundlerUrl") - } } // `|| 5` downstream reads 0 as "unset", and p-queue throws a bare TypeError on diff --git a/sdk/packages/simplex/src/scanner/order-scanner.ts b/sdk/packages/simplex/src/scanner/order-scanner.ts index 7105f4f10e..f0dc2f299d 100644 --- a/sdk/packages/simplex/src/scanner/order-scanner.ts +++ b/sdk/packages/simplex/src/scanner/order-scanner.ts @@ -153,7 +153,7 @@ export class OrderScanner implements OrderScannerContract { const chainId = chain.chainId ?? ( - await resolveChainConfigs([{ rpcUrls, bundlerUrl: chain.bundlerUrl ?? "" }], { + await resolveChainConfigs([{ rpcUrls }], { loggers: this.loggers, tolerateUnreachable: true, }) diff --git a/sdk/packages/simplex/src/services/FillerConfigService.ts b/sdk/packages/simplex/src/services/FillerConfigService.ts index 8cffd5d510..f3b14ded23 100644 --- a/sdk/packages/simplex/src/services/FillerConfigService.ts +++ b/sdk/packages/simplex/src/services/FillerConfigService.ts @@ -2,6 +2,7 @@ import type { ChainConfig, HexString } from "@hyperbridge/sdk" import { defaultLoggerContext, moduleLogger, type LoggerContext } from "./Logger" import { ChainConfigService, bytes32ToBytes20 } from "@hyperbridge/sdk" import type { LogLevel } from "./Logger" +import { hyperbridgeBundlerUrl } from "@/config/bundlers" /** Block-scanner poll period in seconds when `simplex.blockScanIntervalSeconds` is not set. */ export const DEFAULT_BLOCK_SCAN_INTERVAL_SECONDS = 3 @@ -15,13 +16,15 @@ export const DEFAULT_MIN_ORDER_SIZE_USD = 20 export interface UserProvidedChainConfig { /** One or more RPC URLs. When multiple are provided, event scans use quorum consensus. */ rpcUrls: string[] - bundlerUrl: string + /** Accepted and ignored. Simplex fills through Hyperbridge's bundler for the chain. */ + bundlerUrl?: string } export interface ResolvedChainConfig { chainId: number /** One or more RPC URLs for this chain. When multiple are provided, event scans use quorum consensus. */ rpcUrls: string[] + /** Hyperbridge's bundler for the chain, or undefined where it runs none. */ bundlerUrl?: string } @@ -159,7 +162,7 @@ export async function resolveChainConfigs( "RPC endpoint could not report its chainId at startup; keeping it for the quorum to judge per call", ) } - return { chainId: first.chainId, rpcUrls, bundlerUrl: chain.bundlerUrl } + return { chainId: first.chainId, rpcUrls, bundlerUrl: hyperbridgeBundlerUrl(first.chainId) } }), ) } @@ -329,13 +332,6 @@ export class FillerConfigService { this.rpcOverrides.set(chainId, validateRpcUrls(rpcUrls)) } - setBundlerUrl(chainId: number, bundlerUrl: string): void { - if (!this.rpcOverrides.has(chainId)) { - throw new Error(`Chain ${chainId} is not configured`) - } - this.bundlerUrls.set(chainId, bundlerUrl) - } - /** Replaces the rebalancing config at runtime; trigger checks read it live. */ setRebalancing(rebalancing: RebalancingConfig | undefined): void { if (this.fillerConfig) this.fillerConfig.rebalancing = rebalancing diff --git a/sdk/packages/simplex/src/services/UserOpSender.ts b/sdk/packages/simplex/src/services/UserOpSender.ts index ce88cc4a27..c42cc826f1 100644 --- a/sdk/packages/simplex/src/services/UserOpSender.ts +++ b/sdk/packages/simplex/src/services/UserOpSender.ts @@ -1,8 +1,7 @@ import { CryptoUtils, BundlerMethod, - applyRundlerPriorityFee, - fetchRundlerPriorityFee, + rundlerUserOperationFees, type PackedUserOperation, type HexString, } from "@hyperbridge/sdk" @@ -129,7 +128,7 @@ export class UserOpSender { try { // Fetched before paymaster selection so the deposit gate can price the op's // max prefund; a failure here means nothing was submitted, safe to fall back. - fees = await this.getGasPrice(bundlerUrl, publicClient, chainId) + fees = await this.getGasPrice(bundlerUrl, publicClient) // Without explicit limits the fallbacks (~1.9M gas) over-require the deposit; // a false skip degrades to the caller's native fallback, which is safe — @@ -355,53 +354,29 @@ export class UserOpSender { } /** - * Mirrors the gas-price selection used by the delegation and fill paths: Pimlico's and - * Alchemy's own pricing, otherwise the chain's gas price raised to the priority fee a - * rundler bundler requires, when the bundler gives one. + * Prices the op for the rundler bundler, as the fill path does: its suggested fees, or the + * chain's gas price raised to the priority fee it requires where it suggests none. */ private async getGasPrice( bundlerUrl: string, publicClient: PublicClient, - chainId: number, ): Promise<{ maxFeePerGas: bigint; maxPriorityFeePerGas: bigint }> { - const lower = bundlerUrl.toLowerCase() - if (lower.includes("pimlico.io")) { - const res = await this.sendBundlerRpc<{ fast: { maxFeePerGas: string; maxPriorityFeePerGas: string } }>( - bundlerUrl, - BundlerMethod.PIMLICO_GET_USER_OPERATION_GAS_PRICE, - [], - ) - return { maxFeePerGas: BigInt(res.fast.maxFeePerGas), maxPriorityFeePerGas: BigInt(res.fast.maxPriorityFeePerGas) } - } - if (lower.includes("alchemy.com")) { - const [rundlerPriorityFee, latestBlock] = await Promise.all([ - this.sendBundlerRpc(bundlerUrl, BundlerMethod.RUNDLER_MAX_PRIORITY_FEE_PER_GAS, []), - publicClient.getBlock({ blockTag: "latest" }), - ]) - const baseFeePerGas = latestBlock.baseFeePerGas ?? (await publicClient.getGasPrice()) - const isArbitrum = BigInt(chainId) === 42161n - const prioBump = isArbitrum ? 0n : 25n - const maxPriorityFeePerGas = BigInt(rundlerPriorityFee) + (BigInt(rundlerPriorityFee) * prioBump) / 100n - const bufferedBaseFee = baseFeePerGas + (baseFeePerGas * 50n) / 100n - return { maxFeePerGas: bufferedBaseFee + maxPriorityFeePerGas, maxPriorityFeePerGas } - } - const [gasPrice, rundlerPriorityFee] = await Promise.all([ + const [gasPrice, latestBlock] = await Promise.all([ publicClient.getGasPrice(), - fetchRundlerPriorityFee(bundlerUrl), + publicClient.getBlock({ blockTag: "latest" }), ]) - const fees = { - maxFeePerGas: gasPrice + (gasPrice * MAX_FEE_BUMP_PERCENT) / 100n, - maxPriorityFeePerGas: gasPrice + (gasPrice * PRIORITY_FEE_BUMP_PERCENT) / 100n, - } - if (rundlerPriorityFee === null) return fees - - const latestBlock = await publicClient.getBlock({ blockTag: "latest" }) - return applyRundlerPriorityFee(fees, { - rundlerPriorityFee, - baseFeePerGas: latestBlock.baseFeePerGas ?? gasPrice, - priorityFeeBumpPercent: PRIORITY_FEE_BUMP_PERCENT, - maxFeeBumpPercent: MAX_FEE_BUMP_PERCENT, - }) + return rundlerUserOperationFees( + bundlerUrl, + { + maxFeePerGas: gasPrice + (gasPrice * MAX_FEE_BUMP_PERCENT) / 100n, + maxPriorityFeePerGas: gasPrice + (gasPrice * PRIORITY_FEE_BUMP_PERCENT) / 100n, + }, + { + baseFeePerGas: latestBlock.baseFeePerGas ?? gasPrice, + priorityFeeBumpPercent: PRIORITY_FEE_BUMP_PERCENT, + maxFeeBumpPercent: MAX_FEE_BUMP_PERCENT, + }, + ) } private async sendBundlerRpc(bundlerUrl: string, method: string, params: unknown[]): Promise { diff --git a/sdk/packages/simplex/src/services/bundler-preflight.ts b/sdk/packages/simplex/src/services/bundler-preflight.ts index 7a59933311..d676cf1bfa 100644 --- a/sdk/packages/simplex/src/services/bundler-preflight.ts +++ b/sdk/packages/simplex/src/services/bundler-preflight.ts @@ -12,12 +12,13 @@ export interface BundlerPreflightOptions { } /** - * Refuses any chain whose bundler answers eth_supportedEntryPoints without the - * EntryPoint that chain's SolverAccount validates against, since that bundler - * rejects every fill UserOperation. A bundler that cannot be asked, or whose - * answer cannot be read, is only warned about: that says nothing about which - * EntryPoints it serves. Watch-only chains, chains without a bundler, and chains - * without a known EntryPoint are left to the fill path. + * Refuses a chain that is not watch-only when Hyperbridge runs no bundler for it, + * since every fill is a UserOperation a bundler submits. Also refuses any chain + * whose bundler answers eth_supportedEntryPoints without the EntryPoint that + * chain's SolverAccount validates against, since that bundler rejects every fill + * UserOperation. A bundler that cannot be asked, or whose answer cannot be read, + * is only warned about: that says nothing about which EntryPoints it serves. + * Watch-only chains and chains without a known EntryPoint are left to the fill path. */ export async function assertBundlersServeEntryPoint( chains: Pick[], @@ -29,7 +30,9 @@ export async function assertBundlersServeEntryPoint( const failures = await Promise.all( chains.map(async ({ chainId, bundlerUrl }) => { if (options.watchOnly?.[chainId] === true) return undefined - if (!bundlerUrl?.trim()) return undefined + if (!bundlerUrl?.trim()) { + return `Hyperbridge runs no bundler for ${chainName(chainId)}, so simplex cannot fill there; set it watch-only` + } const entryPoint = configService.getEntryPointAddress(formatChainKey(chainId)) if (!entryPoint) return undefined return checkBundler(chainId, bundlerUrl.trim(), entryPoint, timeoutMs, logger) diff --git a/sdk/packages/simplex/src/services/server/UiServer.ts b/sdk/packages/simplex/src/services/server/UiServer.ts index 5246d5b697..326fc749db 100644 --- a/sdk/packages/simplex/src/services/server/UiServer.ts +++ b/sdk/packages/simplex/src/services/server/UiServer.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os" import { resolve as resolvePath } from "node:path" import type { Duplex } from "node:stream" import { formatChainKey, parseChainKey } from "@/config/interpolated-curve" +import { hyperbridgeBundlerUrl } from "@/config/bundlers" import { AssetRegistry, registrySymbols, validateAssetDefinitions, type AssetDefinition } from "@/config/asset-registry" import { assertPairSymbolsResolve, validatePairConfigs, type PairConfig } from "@/config/pairs" import { VaultFundingPlanner, type VaultSweepResult } from "@/funding/vault/VaultFundingPlanner" @@ -58,7 +59,6 @@ import { maskToml, resolveSetupDeps, validateAlchemyKey, - validateBundler, validateRpc, validateToken, type SetupDeps, @@ -272,12 +272,7 @@ export interface SetupContext { export type StartState = "idle" | "starting" | "running" | "failed" /** Setup routes that stay open in operator mode: stateless probes with no wizard state. */ -const OPERATOR_PROBES = [ - "/api/setup/validate-token", - "/api/setup/validate-rpc", - "/api/setup/validate-bundler", - "/api/setup/validate-alchemy-key", -] +const OPERATOR_PROBES = ["/api/setup/validate-token", "/api/setup/validate-rpc", "/api/setup/validate-alchemy-key"] const LOGS_UNAVAILABLE = "Log capture is not enabled for this filler" @@ -923,9 +918,6 @@ export class UiServer { const body = JSON.parse(await readBody(req)) as Record if (path === "/api/setup/validate-rpc") return sendJson(res, 200, await validateRpc(body, this.deps)) - if (path === "/api/setup/validate-bundler") { - return sendJson(res, 200, await validateBundler(body, this.deps)) - } if (path === "/api/setup/validate-alchemy-key") { return sendJson(res, 200, await validateAlchemyKey(body, this.deps)) } @@ -1736,7 +1728,7 @@ export class UiServer { stateMachineId: formatChainKey(chainId), label: chainLabel(chainId), rpcUrls: chain.rpcUrls, - bundlerUrl: chain.bundlerUrl, + bundlerUrl: hyperbridgeBundlerUrl(chainId) ?? "", watchOnly: globalWatchOnly ? (watchOnly as boolean) : Boolean((watchOnly as Record | undefined)?.[String(chainId)]), @@ -1778,7 +1770,7 @@ export class UiServer { }) } - const rows: Array<{ chainId: number; rpcUrls: string[]; bundlerUrl: string; watchOnly: boolean }> = [] + const rows: Array<{ chainId: number; rpcUrls: string[]; watchOnly: boolean }> = [] try { for (const row of body.chains) { const chainId = Number(row.chainId) @@ -1793,11 +1785,12 @@ export class UiServer { .filter(Boolean) // Same non-empty/distinct-host rule boot applies to every chain. validateRpcUrls(rpcUrls) - const bundlerUrl = String(row.bundlerUrl ?? "").trim() - if (!bundlerUrl) { - throw new Error(`${chainLabel(chainId)} needs a bundler URL to submit fill UserOperations`) + const watchOnly = row.watchOnly === true + // Every fill is a UserOperation submitted through Hyperbridge's bundler. + if (!watchOnly && op.config.simplex.watchOnly !== true && !hyperbridgeBundlerUrl(chainId)) { + throw new Error(`Hyperbridge runs no bundler for ${chainLabel(chainId)}, so it can only be watched`) } - rows.push({ chainId, rpcUrls, bundlerUrl, watchOnly: row.watchOnly === true }) + rows.push({ chainId, rpcUrls, watchOnly }) } } catch (err) { return sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) }) @@ -1839,7 +1832,7 @@ export class UiServer { return sendJson(res, 400, { error: err instanceof Error ? err.message : String(err) }) } - op.config.chains = rows.map(({ rpcUrls, bundlerUrl }) => ({ rpcUrls, bundlerUrl })) + op.config.chains = rows.map(({ rpcUrls }) => ({ rpcUrls })) if (Object.keys(confirmationPolicies).length > 0) op.config.confirmationPolicies = confirmationPolicies // A global boolean watchOnly is left as-is: expanding it per chain would // stop validateConfig treating the config as all-watch-only, which is diff --git a/sdk/packages/simplex/src/services/server/dto.ts b/sdk/packages/simplex/src/services/server/dto.ts index 1050ddbba2..e5a9a8aed3 100644 --- a/sdk/packages/simplex/src/services/server/dto.ts +++ b/sdk/packages/simplex/src/services/server/dto.ts @@ -173,6 +173,7 @@ export interface ChainRowDto { stateMachineId: string label: string rpcUrls: string[] + /** Hyperbridge's bundler the chain fills through, empty where it runs none. */ bundlerUrl: string watchOnly: boolean /** False for rows added since boot — they only start filling after a restart. */ diff --git a/sdk/packages/simplex/src/services/server/setup-api.ts b/sdk/packages/simplex/src/services/server/setup-api.ts index 49783b2f3a..b7fd6b19fc 100644 --- a/sdk/packages/simplex/src/services/server/setup-api.ts +++ b/sdk/packages/simplex/src/services/server/setup-api.ts @@ -7,7 +7,6 @@ import { assertPairSymbolsResolve } from "@/config/pairs" import { formatChainKey } from "@/config/interpolated-curve" import { AssetRegistry, registrySymbols, USD_STABLE_SYMBOLS } from "@/config/asset-registry" import { fetchChainId, validateRpcUrls } from "@/services/FillerConfigService" -import { describeEntryPoints, servesEntryPoint } from "@/services/bundler-preflight" import { normaliseSecretPhrase, secretPhraseSigner, SignerType, validateSignerConfig } from "@/services/wallet" import { deriveSubstrateKeyPair, generateSubstrateKey } from "@/services/substrate-key" import { ERC20_ABI } from "@/config/abis/ERC20" @@ -139,8 +138,6 @@ export async function handleSetupRequest( return sendJson(res, 200, await validateAlchemyKey(body, deps)) case "validate-rpc": return sendJson(res, 200, await validateRpc(body, deps)) - case "validate-bundler": - return sendJson(res, 200, await validateBundler(body, deps)) case "validate-token": return sendJson(res, 200, await validateToken(body)) case "derive-evm-address": @@ -176,8 +173,6 @@ export async function validateAlchemyKey(body: Record, deps: Re label: meta.label, note: meta.note, rpcUrl, - // Alchemy serves ERC-4337 bundler methods on the same endpoint. - bundlerUrl: rpcUrl, } }) @@ -219,50 +214,6 @@ export async function validateRpc(body: Record, deps: Required< return { ok: results.every((r) => !r.error), results } } -/** Warning-only: bundler probes never block the wizard. */ -export async function validateBundler(body: Record, deps: Required) { - const url = String(body.url ?? "").trim() - if (!url) return { ok: false, warning: "Bundler URL is empty" } - try { - const entryPoints = await withTimeout( - deps.rpcRequest(url, "eth_supportedEntryPoints", []), - PROBE_TIMEOUT_MS, - "Bundler probe", - ) - const chainId = Number(body.chainId) - const required = Number.isInteger(chainId) - ? new ChainConfigService({}).getEntryPointAddress(formatChainKey(chainId)) - : undefined - if (!required) return { ok: true, entryPoints } - // Mirrors the boot preflight: only a list of addresses settles which EntryPoints a bundler serves. - if (!Array.isArray(entryPoints) || !entryPoints.every((address) => typeof address === "string")) { - return { - ok: true, - entryPoints, - warning: - `Bundler's eth_supportedEntryPoints answer is not a list of addresses, so it could not be ` + - `confirmed to support EntryPoint ${required}, which this chain's solver account uses. ` + - `Simplex will use it and warn.`, - } - } - if (!servesEntryPoint(entryPoints, required)) { - return { - ok: true, - entryPoints, - warning: - `Bundler does not support EntryPoint ${required}, which this chain's solver account uses; ` + - `it lists ${describeEntryPoints(entryPoints)}. Simplex will refuse to use it.`, - } - } - return { ok: true, entryPoints } - } catch (err) { - return { - ok: true, - warning: `Bundler did not answer eth_supportedEntryPoints: ${err instanceof Error ? err.message : err}`, - } - } -} - export async function validateToken(body: Record) { const rpcUrl = String(body.rpcUrl ?? "").trim() const address = String(body.address ?? "").trim() @@ -424,7 +375,6 @@ export function maskToml(config: FillerConfigFile, chainLabels?: string[]): stri } for (const chain of masked.chains) { chain.rpcUrls = chain.rpcUrls.map(maskUrlKey) - chain.bundlerUrl = maskUrlKey(chain.bundlerUrl) } return emitFillerToml(masked, { chainComments: chainLabels }) } diff --git a/sdk/packages/simplex/src/simplex.ts b/sdk/packages/simplex/src/simplex.ts index a21db5c1e0..212dbdb69d 100644 --- a/sdk/packages/simplex/src/simplex.ts +++ b/sdk/packages/simplex/src/simplex.ts @@ -185,13 +185,13 @@ export interface ChainView { chainId: number stateMachineId: string rpcUrls: string[] + /** Hyperbridge's bundler the chain fills through, empty where it runs none. */ bundlerUrl: string watchOnly: boolean } export interface ChainInput { rpcUrls: string[] - bundlerUrl: string watchOnly?: boolean /** * Required for chains with no built-in confirmation curve (testnets and @@ -525,11 +525,8 @@ export class ChainController { async add(chain: ChainInput): Promise { return this.serialise(async () => { validateRpcUrls(chain.rpcUrls) - if (!chain.bundlerUrl?.trim()) { - throw new Error("A bundler URL is required to submit fill UserOperations") - } - const [resolved] = await resolveChainConfigs([{ rpcUrls: chain.rpcUrls, bundlerUrl: chain.bundlerUrl }]) + const [resolved] = await resolveChainConfigs([{ rpcUrls: chain.rpcUrls }]) const { chainId } = resolved const { configService, config, intentFiller } = this.runtime @@ -580,7 +577,7 @@ export class ChainController { try { if (!inScanner) { - await this.scanner.addChain({ rpcUrls: chain.rpcUrls, bundlerUrl: chain.bundlerUrl, chainId }) + await this.scanner.addChain({ rpcUrls: chain.rpcUrls, chainId }) } // Drop anything cached from a previous life of this chain id, or the // clients keep pointing at whatever endpoints it had before. @@ -608,7 +605,7 @@ export class ChainController { } this.runtime.resolvedChains.push(resolved) - config.chains = [...config.chains, { rpcUrls: chain.rpcUrls, bundlerUrl: chain.bundlerUrl }] + config.chains = [...config.chains, { rpcUrls: chain.rpcUrls }] if (Object.keys(confirmationPolicies).length > 0) config.confirmationPolicies = confirmationPolicies this.syncWatchOnlyToConfig() @@ -663,9 +660,7 @@ export class ChainController { // Probe before mutating: an endpoint answering for another chain would // otherwise silently feed this scanner the wrong chain's logs. - const [probed] = await resolveChainConfigs([ - { rpcUrls, bundlerUrl: this.runtime.resolvedChains[index].bundlerUrl ?? "" }, - ]) + const [probed] = await resolveChainConfigs([{ rpcUrls }]) if (probed.chainId !== chainId) { throw new Error(`Those endpoints answer for chain ${probed.chainId}, not ${chainId}`) } @@ -692,22 +687,6 @@ export class ChainController { }) } - async setBundlerUrl(chainId: number, bundlerUrl: string): Promise { - return this.serialise(async () => { - if (!bundlerUrl?.trim()) throw new Error("A bundler URL is required") - const index = this.runtime.resolvedChains.findIndex((chain) => chain.chainId === chainId) - if (index < 0) throw new Error(`Chain ${chainId} is not configured`) - await assertBundlersServeEntryPoint([{ chainId, bundlerUrl }], this.runtime.configService, { - watchOnly: this.runtime.intentFiller.getWatchOnly(), - }) - - this.runtime.configService.setBundlerUrl(chainId, bundlerUrl) - this.runtime.resolvedChains[index].bundlerUrl = bundlerUrl - this.runtime.config.chains[index].bundlerUrl = bundlerUrl - await this.persist() - }) - } - /** Monitor without filling. Takes effect on the next order. */ async setWatchOnly(chainId: number, watchOnly: boolean): Promise { return this.serialise(async () => { diff --git a/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts b/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts index b51f344bc5..c88f495a1d 100644 --- a/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts +++ b/sdk/packages/simplex/src/tests/cli/emit-toml.test.ts @@ -27,14 +27,8 @@ const minimalSameAsset: FillerConfigFile = { }, ], chains: [ - { - rpcUrls: ["https://eth-mainnet.g.alchemy.com/v2/someKey"], - bundlerUrl: "https://eth-mainnet.g.alchemy.com/v2/someKey", - }, - { - rpcUrls: ["https://base-mainnet.g.alchemy.com/v2/someKey"], - bundlerUrl: "https://api.pimlico.io/v2/8453/rpc?apikey=pim_key", - }, + { rpcUrls: ["https://eth-mainnet.g.alchemy.com/v2/someKey"] }, + { rpcUrls: ["https://base-mainnet.g.alchemy.com/v2/someKey"] }, ], } @@ -72,10 +66,7 @@ const crossAssetWithCurves: FillerConfigFile = { ], }, }, - chains: [ - { rpcUrls: ["https://bsc.example/rpc"], bundlerUrl: "https://api.pimlico.io/v2/56/rpc?apikey=k" }, - { rpcUrls: ["https://polygon.example/rpc"], bundlerUrl: "https://api.pimlico.io/v2/137/rpc?apikey=k" }, - ], + chains: [{ rpcUrls: ["https://bsc.example/rpc"] }, { rpcUrls: ["https://polygon.example/rpc"] }], } // `side` requires pool pricing with no static curves, so this pair is curve-less @@ -118,12 +109,7 @@ const kitchenSink: FillerConfigFile = { ], }, }, - chains: [ - { - rpcUrls: ["https://eth.example/rpc", "https://eth-two.example/rpc"], - bundlerUrl: "https://api.pimlico.io/v2/1/rpc?apikey=k", - }, - ], + chains: [{ rpcUrls: ["https://eth.example/rpc", "https://eth-two.example/rpc"] }], rebalancing: { triggerPercentage: 0.5, baseBalances: { @@ -215,6 +201,16 @@ describe("emitFillerToml", () => { expect(signer?.address).toBe("0x70997970C51812dc3A010C7d01b50e0d17dc79C8") }) + it("drops a bundlerUrl an older config still carries", () => { + const legacy = { + ...minimalSameAsset, + chains: [{ rpcUrls: ["https://eth.example/rpc"], bundlerUrl: "https://bundler.example/rpc?apikey=k" }], + } + const toml = emitFillerToml(legacy) + expect(toml).not.toContain("bundlerUrl") + expect(parse(toml).chains).toEqual([{ rpcUrls: ["https://eth.example/rpc"] }]) + }) + it("renders chain comments above each [[chains]] entry", () => { const emitted = emitFillerToml(minimalSameAsset, { chainComments: ["Ethereum (1)", "Base (8453)"] }) expect(emitted).toContain("# Ethereum (1)\n[[chains]]") diff --git a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts index cb4188ba1a..e734a671c3 100644 --- a/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts +++ b/sdk/packages/simplex/src/tests/cli/filler-toml-validate.test.ts @@ -24,7 +24,7 @@ const minimalConfig = (): FillerTomlConfig => ({ token1: "USDC", }, ], - chains: [{ rpcUrls: ["https://eth-mainnet.g.alchemy.com/v2/key"], bundlerUrl: "https://bundler.example" }], + chains: [{ rpcUrls: ["https://eth-mainnet.g.alchemy.com/v2/key"] }], }) describe("validateConfig", () => { @@ -39,7 +39,6 @@ describe("validateConfig", () => { config.simplex.hyperbridgeWsUrl = "wss://example" for (const chain of config.chains) { chain.rpcUrls = ["https://rpc.example"] - chain.bundlerUrl = "https://bundler.example" } expect(() => validateConfig(config)).not.toThrow() }) @@ -78,14 +77,19 @@ describe("validateConfig", () => { expect(() => validateConfig(config)).not.toThrow() }) - it("rejects chains without rpcUrls or bundlerUrl", () => { + it("rejects chains without rpcUrls and needs no bundlerUrl", () => { const noRpc = minimalConfig() - noRpc.chains = [{ rpcUrls: [""], bundlerUrl: "https://bundler.example" }] + noRpc.chains = [{ rpcUrls: [""] }] expect(() => validateConfig(noRpc)).toThrow(/rpcUrls/) const noBundler = minimalConfig() - noBundler.chains = [{ rpcUrls: ["https://rpc.example"], bundlerUrl: "" }] - expect(() => validateConfig(noBundler)).toThrow(/bundlerUrl/) + noBundler.chains = [{ rpcUrls: ["https://rpc.example"] }] + expect(() => validateConfig(noBundler)).not.toThrow() + + // An older config still naming one loads; the value is not used. + const legacy = minimalConfig() + legacy.chains = [{ rpcUrls: ["https://rpc.example"], bundlerUrl: "https://bundler.example" }] + expect(() => validateConfig(legacy)).not.toThrow() }) // Pair rules (same-token invariants, crossed books, anchoring, curve diff --git a/sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts b/sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts index a1ef296312..018ee54836 100644 --- a/sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts +++ b/sdk/packages/simplex/src/tests/cli/hyperbridge-bundlers.test.ts @@ -1,57 +1,48 @@ -import { describe, expect, it, vi } from "vitest" +import { chainConfigs } from "@hyperbridge/sdk" +import { describe, expect, it } from "vitest" import { chainsForNetwork, INIT_CHAINS } from "@/cli/init/chains" -import type { WizardState } from "@/cli/init/state" -import { stepBundlers } from "@/cli/init/steps/bundlers" - -vi.mock("@clack/prompts", () => { - const asked = () => { - throw new Error("the wizard asked a question") - } - return { - confirm: asked, - text: asked, - password: asked, - select: asked, - note: asked, - log: { info: asked, message: asked }, - } -}) +import { HYPERBRIDGE_BUNDLER_URLS, hyperbridgeBundlerUrl } from "@/config/bundlers" /** - * The bundler the wizards and the Chains panel use for a chain, never asking the operator for - * one. Every chain they offer on mainnet has to carry it, or the config they write names no - * bundler for that chain and does not validate. + * Simplex fills through Hyperbridge's bundler for each chain and takes none from its config, so + * the wizards and the Chains panel only offer chains that have one. */ describe("Hyperbridge bundlers", () => { - const mainnet = chainsForNetwork("mainnet") - - it("covers every mainnet chain the wizards offer", () => { - expect(Object.fromEntries(mainnet.map((meta) => [meta.chainId, meta.hyperbridgeBundlerUrl]))).toEqual({ + it("are the bundlers Hyperbridge runs", () => { + expect(HYPERBRIDGE_BUNDLER_URLS).toEqual({ 1: "https://bundler.polytope.technology/ethereum", 56: "https://bundler.polytope.technology/bsc", + 97: "https://bundler.polytope.technology/bsc-chapel", 137: "https://bundler.polytope.technology/polygon", 8453: "https://bundler.polytope.technology/base", 42161: "https://bundler.polytope.technology/arbitrum", + 80002: "https://bundler.polytope.technology/polygon-amoy", }) + expect(hyperbridgeBundlerUrl(11155111)).toBeUndefined() }) - it("names none for testnets, which take their bundler from the config", () => { - for (const meta of INIT_CHAINS.filter((chain) => chain.network === "testnet")) { - expect(meta.hyperbridgeBundlerUrl, meta.label).toBeUndefined() - } + it("are the ones the SDK defaults to", () => { + const sdk = Object.fromEntries( + Object.values(chainConfigs) + .filter((config) => config.bundlerUrl) + .map((config) => [config.chainId, config.bundlerUrl]), + ) + expect(sdk).toEqual(HYPERBRIDGE_BUNDLER_URLS) }) - it("is what the terminal wizard writes, without a question and over an earlier choice", async () => { - const state = { - chains: mainnet.map((meta, index) => ({ - meta, - rpcUrls: ["https://rpc.example"], - bundlerUrl: index === 0 ? "https://another-bundler.example" : undefined, - })), - } as unknown as WizardState - - await stepBundlers(state) + it("cover every chain the wizards offer, on both networks", () => { + for (const network of ["mainnet", "testnet"] as const) { + for (const meta of chainsForNetwork(network)) { + expect(meta.hyperbridgeBundlerUrl, meta.label).toBe(HYPERBRIDGE_BUNDLER_URLS[meta.chainId]) + } + } + expect(chainsForNetwork("testnet").map((meta) => meta.chainId)).toEqual([80002, 97]) + }) - expect(state.chains.map((chain) => chain.bundlerUrl)).toEqual(mainnet.map((meta) => meta.hyperbridgeBundlerUrl)) + it("leave chains without one out of the wizards", () => { + const offered = new Set([...chainsForNetwork("mainnet"), ...chainsForNetwork("testnet")].map((m) => m.chainId)) + for (const meta of INIT_CHAINS.filter((chain) => !chain.hyperbridgeBundlerUrl)) { + expect(offered.has(meta.chainId), meta.label).toBe(false) + } }) }) diff --git a/sdk/packages/simplex/src/tests/cli/pimlico-derive.test.ts b/sdk/packages/simplex/src/tests/cli/pimlico-derive.test.ts deleted file mode 100644 index 3fc72bfa33..0000000000 --- a/sdk/packages/simplex/src/tests/cli/pimlico-derive.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, it, expect } from "vitest" -import { parsePimlicoUrl, isPimlicoUrl, derivePimlicoBundler } from "@/cli/init/derive/pimlico" - -describe("parsePimlicoUrl", () => { - it("parses a valid bundler URL", () => { - expect(parsePimlicoUrl("https://api.pimlico.io/v2/1/rpc?apikey=pim_abc")).toEqual({ - chainId: 1, - apiKey: "pim_abc", - }) - }) - - it("tolerates extra query params", () => { - expect(parsePimlicoUrl("https://api.pimlico.io/v2/8453/rpc?foo=bar&apikey=key")).toEqual({ - chainId: 8453, - apiKey: "key", - }) - }) - - it("rejects missing apikey", () => { - expect(parsePimlicoUrl("https://api.pimlico.io/v2/1/rpc")).toBeNull() - }) - - it("rejects non-numeric chain segments", () => { - expect(parsePimlicoUrl("https://api.pimlico.io/v2/ethereum/rpc?apikey=key")).toBeNull() - }) - - it("rejects other hosts and schemes", () => { - expect(parsePimlicoUrl("https://api.pimlico.io.evil.com/v2/1/rpc?apikey=key")).toBeNull() - expect(parsePimlicoUrl("http://api.pimlico.io/v2/1/rpc?apikey=key")).toBeNull() - expect(parsePimlicoUrl("not a url")).toBeNull() - }) -}) - -describe("isPimlicoUrl", () => { - it("detects pimlico URLs", () => { - expect(isPimlicoUrl("https://api.pimlico.io/v2/137/rpc?apikey=key")).toBe(true) - expect(isPimlicoUrl("https://eth-mainnet.g.alchemy.com/v2/key")).toBe(false) - }) -}) - -describe("derivePimlicoBundler", () => { - it("builds per-chain bundler URLs", () => { - expect(derivePimlicoBundler("key", 42161)).toBe("https://api.pimlico.io/v2/42161/rpc?apikey=key") - }) - - it("round-trips with parsePimlicoUrl", () => { - expect(parsePimlicoUrl(derivePimlicoBundler("k", 56))).toEqual({ chainId: 56, apiKey: "k" }) - }) - - it("round-trips keys containing URL-special characters", () => { - for (const apiKey of ["pim+key", "pim%2Bkey", "pim key", "pim=key&x"]) { - const derived = derivePimlicoBundler(apiKey, 1) - expect(parsePimlicoUrl(derived)).toEqual({ chainId: 1, apiKey }) - // deriving from the parsed key again must be stable - expect(derivePimlicoBundler(parsePimlicoUrl(derived)!.apiKey, 1)).toBe(derived) - } - }) -}) diff --git a/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts b/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts index bdbbd8650d..855c4b95c1 100644 --- a/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts +++ b/sdk/packages/simplex/src/tests/cli/update-run-preservation.test.ts @@ -42,7 +42,7 @@ describe("CLI wizard update run", () => { token1: "USDC", }, ], - chains: [{ rpcUrls: ["https://eth.example/rpc"], bundlerUrl: "https://bundler.example" }], + chains: [{ rpcUrls: ["https://eth.example/rpc"] }], binance: { apiKey: "bk", apiSecret: "bs", timeout: 9000 }, keeper: { intervalMinutes: 45, minSwapUsd: 10 }, allowlist: { users: ["0x1111111111111111111111111111111111111111"] }, @@ -70,7 +70,6 @@ describe("CLI wizard update run", () => { { meta: INIT_CHAINS.find((c) => c.chainId === 1)!, rpcUrls: ["https://eth.example/rpc"], - bundlerUrl: "https://bundler.example", }, ] state.signer = config.simplex.signer @@ -130,7 +129,6 @@ describe("CLI wizard update run", () => { { meta: INIT_CHAINS.find((c) => c.chainId === 1)!, rpcUrls: ["https://eth.example/rpc"], - bundlerUrl: "https://bundler.example", }, ] state.assets = wizardAssets @@ -177,7 +175,6 @@ describe("CLI wizard update run", () => { { meta: INIT_CHAINS.find((c) => c.chainId === 1)!, rpcUrls: ["https://eth.example/rpc"], - bundlerUrl: "https://bundler.example", }, ] state.signer = existing.simplex.signer @@ -198,7 +195,6 @@ describe("CLI wizard update run", () => { { meta: INIT_CHAINS.find((c) => c.chainId === 1)!, rpcUrls: ["https://eth.example/rpc"], - bundlerUrl: "https://bundler.example", }, ] state.signer = existing.simplex.signer @@ -222,7 +218,6 @@ describe("CLI wizard update run", () => { { meta: INIT_CHAINS.find((c) => c.chainId === 97)!, rpcUrls: ["https://bsc.example/rpc"], - bundlerUrl: "https://bundler.example", }, ] state.signer = existing.simplex.signer diff --git a/sdk/packages/simplex/src/tests/core/boot-bundler-preflight.test.ts b/sdk/packages/simplex/src/tests/core/boot-bundler-preflight.test.ts index 8dfe1884af..45dd246e8a 100644 --- a/sdk/packages/simplex/src/tests/core/boot-bundler-preflight.test.ts +++ b/sdk/packages/simplex/src/tests/core/boot-bundler-preflight.test.ts @@ -1,4 +1,4 @@ -import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest" import { bootFiller } from "@/core/boot" import type { FillerTomlConfig } from "@/config/filler-toml" import { LoggerContext } from "@/services/Logger" @@ -11,13 +11,23 @@ const CHAIN_ID = 8453 const SUBSTRATE_SEED = `0x${"11".repeat(32)}` // The mock answers eth_supportedEntryPoints with EntryPoint v0.7 alone, which no -// SolverAccount validates against. +// SolverAccount validates against. It stands in for Hyperbridge's bundler on Base. let rpc: MockRpc +const bundler = vi.hoisted(() => ({ url: "" })) + +vi.mock("@/config/bundlers", () => ({ + HYPERBRIDGE_BUNDLER_URLS: {}, + hyperbridgeBundlerUrl: (chainId: number) => (chainId === 8453 && bundler.url ? bundler.url : undefined), +})) beforeAll(async () => { rpc = await startMockRpc({ chainId: CHAIN_ID }) }) +afterEach(() => { + bundler.url = rpc.url +}) + afterAll(() => { rpc.close() }) @@ -26,23 +36,31 @@ function config(): FillerTomlConfig { return { simplex: { substratePrivateKey: SUBSTRATE_SEED, hyperbridgeWsUrl: "ws://127.0.0.1:1" }, pairs: [{ token0: "USDC", token1: "USDC" }], - chains: [{ rpcUrls: [rpc.url], bundlerUrl: rpc.url }], + chains: [{ rpcUrls: [rpc.url] }], orderbook: { url: "https://orderbook.example/graphql" }, } } +function boot() { + return bootFiller(config(), { + loggers: new LoggerContext({}), + scanners: { orders: stubOrderScanner([CHAIN_ID]) }, + data: new MemoryDataStore(), + ownsData: true, + signer: privateKeySigner(`0x${"22".repeat(32)}`), + }) +} + describe("boot bundler EntryPoint check", () => { it("refuses to start when a filling chain's bundler does not list its EntryPoint", async () => { - const boot = bootFiller(config(), { - loggers: new LoggerContext({}), - scanners: { orders: stubOrderScanner([CHAIN_ID]) }, - data: new MemoryDataStore(), - ownsData: true, - signer: privateKeySigner(`0x${"22".repeat(32)}`), - }) - - await expect(boot).rejects.toThrow( + bundler.url = rpc.url + await expect(boot()).rejects.toThrow( /Bundler 127\.0\.0\.1:\d+ for Base \(EVM-8453\) does not support EntryPoint 0x[0-9a-fA-F]{40}; it lists 0x0000000071727De22E5E9d8BAf0edAc6f37da032/, ) }) + + it("refuses to start when Hyperbridge runs no bundler for a filling chain", async () => { + bundler.url = "" + await expect(boot()).rejects.toThrow(/Hyperbridge runs no bundler for Base \(EVM-8453\).*set it watch-only/) + }) }) diff --git a/sdk/packages/simplex/src/tests/core/boot-signer.test.ts b/sdk/packages/simplex/src/tests/core/boot-signer.test.ts index c4def174ea..e74a698968 100644 --- a/sdk/packages/simplex/src/tests/core/boot-signer.test.ts +++ b/sdk/packages/simplex/src/tests/core/boot-signer.test.ts @@ -39,7 +39,7 @@ function config(overrides: Partial = {}): FillerTom pairs: [ { token0: "USDC", token1: "USDC" }, ], - chains: [{ rpcUrls: [rpc.url], bundlerUrl: "https://bundler.example" }], + chains: [{ rpcUrls: [rpc.url] }], orderbook: { url: "https://orderbook.example/graphql" }, } } @@ -123,7 +123,6 @@ describe("signerless runtime is watch-only for good", () => { await expect( chains.add({ rpcUrls: [rpc.url], - bundlerUrl: "https://bundler.example", watchOnly: false, confirmationPolicy: { points: [ diff --git a/sdk/packages/simplex/src/tests/core/chain-lifecycle.test.ts b/sdk/packages/simplex/src/tests/core/chain-lifecycle.test.ts index 590890dd67..8f0bfe8103 100644 --- a/sdk/packages/simplex/src/tests/core/chain-lifecycle.test.ts +++ b/sdk/packages/simplex/src/tests/core/chain-lifecycle.test.ts @@ -11,8 +11,9 @@ import type { FillerRuntime } from "@/core/boot" // `add` probes the endpoints for real; these are not endpoints. vi.mock("@/services/FillerConfigService", async (importOriginal) => ({ ...(await importOriginal()), - resolveChainConfigs: async (entries: { rpcUrls: string[]; bundlerUrl?: string }[]) => - entries.map((entry) => ({ chainId: 8453, rpcUrls: entry.rpcUrls, bundlerUrl: entry.bundlerUrl })), + // Resolution fills in the chain's bundler from its id; this stands in for Base's. + resolveChainConfigs: async (entries: { rpcUrls: string[] }[]) => + entries.map((entry) => ({ chainId: 8453, rpcUrls: entry.rpcUrls, bundlerUrl: "https://bundler.example" })), })) /** @@ -67,7 +68,6 @@ describe("FillerConfigService chain set", () => { it("rejects endpoint edits for chains it does not know", () => { const service = configService() expect(() => service.setRpcUrls(999, RPC_B)).toThrow(/not configured/) - expect(() => service.setBundlerUrl(999, "https://x.example")).toThrow(/not configured/) }) it("rejects duplicate hosts, on add as well as at construction", () => { @@ -343,7 +343,7 @@ describe("ChainController.add rollback", () => { const controller = new ChainController(runtime, async () => {}, supplied, false) await expect( - controller.add({ rpcUrls: ["https://base.example"], bundlerUrl: "https://bundler.example" }), + controller.add({ rpcUrls: ["https://base.example"] }), ).rejects.toThrow(/not in the order scanner/) expect(service.getConfiguredChainIds()).toEqual(before) @@ -388,7 +388,7 @@ describe("ChainController bundler EntryPoint check", () => { configService: service, config: { simplex: {}, - chains: resolvedChains.map(({ rpcUrls, bundlerUrl }) => ({ rpcUrls, bundlerUrl })), + chains: resolvedChains.map(({ rpcUrls }) => ({ rpcUrls })), confirmationPolicies: {}, }, intentFiller: { @@ -412,60 +412,42 @@ describe("ChainController bundler EntryPoint check", () => { const controller = new ChainController(runtimeFor(service), async () => {}, scanner, true) await expect( - controller.add({ rpcUrls: ["https://base.example"], bundlerUrl: "https://bundler.example" }), + controller.add({ rpcUrls: ["https://base.example"] }), ).rejects.toThrow(/does not support EntryPoint/) expect(service.getConfiguredChainIds()).toEqual([1]) expect(scanner.addChain).not.toHaveBeenCalled() }) - it("refuses to swap in a bundler that lacks the chain's EntryPoint", async () => { + it("refuses to take a chain out of watch-only while its bundler lacks the EntryPoint", async () => { stubV07Bundler() const service = new FillerConfigService([ { chainId: 8453, rpcUrls: RPC_A, bundlerUrl: "https://bundler.example" }, ]) - const runtime = runtimeFor(service, [{ chainId: 8453, rpcUrls: RPC_A, bundlerUrl: "https://bundler.example" }]) + const chains = [{ chainId: 8453, rpcUrls: RPC_A, bundlerUrl: "https://bundler.example" }] + const runtime = runtimeFor(service, chains, { 8453: true }) const persist = vi.fn() const controller = new ChainController(runtime, persist, emptyScanner(), true) - await expect(controller.setBundlerUrl(8453, "https://v07-only.example")).rejects.toThrow( - /does not support EntryPoint/, - ) - expect(service.getBundlerUrl("EVM-8453")).toBe("https://bundler.example") - expect(runtime.resolvedChains[0].bundlerUrl).toBe("https://bundler.example") + await expect(controller.setWatchOnly(8453, false)).rejects.toThrow(/does not support EntryPoint/) + expect(runtime.intentFiller.setWatchOnly).not.toHaveBeenCalled() expect(persist).not.toHaveBeenCalled() + + await expect(controller.setWatchOnly(8453, true)).resolves.toBeUndefined() + expect(runtime.intentFiller.setWatchOnly).toHaveBeenCalledWith(8453, true) }) - it("swaps the bundler on a watch-only chain without asking it", async () => { + it("keeps a chain Hyperbridge runs no bundler for in watch-only", async () => { const fetchSpy = vi.fn() vi.stubGlobal("fetch", fetchSpy) - const service = new FillerConfigService([ - { chainId: 8453, rpcUrls: RPC_A, bundlerUrl: "https://bundler.example" }, - ]) - const chains = [{ chainId: 8453, rpcUrls: RPC_A, bundlerUrl: "https://bundler.example" }] - const runtime = runtimeFor(service, chains, { 8453: true }) - const controller = new ChainController(runtime, vi.fn(), emptyScanner(), true) - - await controller.setBundlerUrl(8453, "https://v07-only.example") - expect(fetchSpy).not.toHaveBeenCalled() - expect(service.getBundlerUrl("EVM-8453")).toBe("https://v07-only.example") - }) - - it("refuses to take a chain out of watch-only while its bundler lacks the EntryPoint", async () => { - stubV07Bundler() - const service = new FillerConfigService([ - { chainId: 8453, rpcUrls: RPC_A, bundlerUrl: "https://bundler.example" }, - ]) - const chains = [{ chainId: 8453, rpcUrls: RPC_A, bundlerUrl: "https://bundler.example" }] - const runtime = runtimeFor(service, chains, { 8453: true }) + const service = new FillerConfigService([{ chainId: 8453, rpcUrls: RPC_A }]) + const runtime = runtimeFor(service, [{ chainId: 8453, rpcUrls: RPC_A }], { 8453: true }) const persist = vi.fn() const controller = new ChainController(runtime, persist, emptyScanner(), true) - await expect(controller.setWatchOnly(8453, false)).rejects.toThrow(/does not support EntryPoint/) + await expect(controller.setWatchOnly(8453, false)).rejects.toThrow(/runs no bundler/) + expect(fetchSpy).not.toHaveBeenCalled() expect(runtime.intentFiller.setWatchOnly).not.toHaveBeenCalled() expect(persist).not.toHaveBeenCalled() - - await expect(controller.setWatchOnly(8453, true)).resolves.toBeUndefined() - expect(runtime.intentFiller.setWatchOnly).toHaveBeenCalledWith(8453, true) }) it("takes a chain out of watch-only when its bundler cannot be asked", async () => { @@ -488,11 +470,7 @@ describe("ChainController bundler EntryPoint check", () => { const runtime = runtimeFor(service) const controller = new ChainController(runtime, vi.fn(), emptyScanner(), true) - await controller.add({ - rpcUrls: ["https://base.example"], - bundlerUrl: "https://bundler.example", - watchOnly: true, - }) + await controller.add({ rpcUrls: ["https://base.example"], watchOnly: true }) expect(fetchSpy).not.toHaveBeenCalled() expect(service.getConfiguredChainIds()).toEqual([1, 8453]) expect(runtime.intentFiller.setWatchOnly).toHaveBeenCalledWith(8453, true) diff --git a/sdk/packages/simplex/src/tests/services/DelegationService.test.ts b/sdk/packages/simplex/src/tests/services/DelegationService.test.ts index 408676ea67..16b2bab544 100644 --- a/sdk/packages/simplex/src/tests/services/DelegationService.test.ts +++ b/sdk/packages/simplex/src/tests/services/DelegationService.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest" import { erc20Abi } from "viem" import type { HexString } from "@hyperbridge/sdk" +import { hyperbridgeBundlerUrl } from "@/config/bundlers" import { DelegationService } from "@/services/DelegationService" import { ChainClientManager } from "@/services/ChainClientManager" import { FillerConfigService, type ResolvedChainConfig } from "@/services/FillerConfigService" @@ -15,8 +16,7 @@ import { privateKeySigner } from "@/services/wallet/accounts/privatekey" * - The bundler reconstructs the EIP-7702 authorization hash and recovers the signer; if * our local hash computation drifts from canonical RLP (e.g. encoding integer 0 as * `0x00` instead of empty bytes), the recovered address won't match the UserOp sender - * and the bundler rejects the op. The Alchemy and Pimlico variants exercise the two - * gas-pricing branches in the service. + * and the bundler rejects the op. * * Required env (suite skips when missing): * BASE_MAINNET — Base mainnet RPC URL @@ -24,14 +24,9 @@ import { privateKeySigner } from "@/services/wallet/accounts/privatekey" * first run also expects EOA nonce = 0 (the path most * vulnerable to non-canonical RLP edges); subsequent * runs short-circuit via `isDelegated`. - * BASE_PIMLICO_BUNDLER_URL — Pimlico v2 bundler URL for Base - * (https://api.pimlico.io/v2/8453/rpc?apikey=...) * - * Notes: - * - The Alchemy variant reuses `BASE_MAINNET` because Alchemy serves bundler RPC at the - * same endpoint as the chain RPC. Override with a dedicated URL if needed. - * - Each variant `skipIf`s independently on its bundler URL. - * - A successful run spends a small amount of USDC via the Simplex paymaster. + * The op goes through Hyperbridge's bundler for Base. A successful run spends a small + * amount of USDC via the Simplex paymaster. */ const BASE_MAINNET = "EVM-8453" @@ -39,9 +34,6 @@ const BASE_CHAIN_ID = 8453 const RPC_URL = process.env.BASE_MAINNET const PRIVATE_KEY = process.env.PRIVATE_KEY as HexString | undefined -const PIMLICO_BUNDLER_URL = process.env.BASE_PIMLICO_BUNDLER_URL -// Alchemy's bundler shares its RPC endpoint; reuse BASE_MAINNET unless a separate URL is needed. -const ALCHEMY_BUNDLER_URL = RPC_URL interface SendBundlerRpc { sendBundlerRpc: (bundlerUrl: string, method: string, params: unknown[]) => Promise @@ -110,25 +102,10 @@ async function logPreconditions(label: string, ctx: ReturnType) { const skipSuite = !(RPC_URL && PRIVATE_KEY) describe.skipIf(skipSuite)("DelegationService — Base mainnet EIP-7702 bundler (live integration)", () => { - it.skipIf(!PIMLICO_BUNDLER_URL)( - "delegates via Pimlico bundler with the Simplex paymaster", - async () => { - const ctx = build(PIMLICO_BUNDLER_URL!) - await logPreconditions("pimlico", ctx) - const success = await ctx.runBundlerDelegation() - expect(success).toBe(true) - }, - 120_000, - ) - - it.skipIf(!ALCHEMY_BUNDLER_URL)( - "delegates via Alchemy bundler with the Simplex paymaster", - async () => { - const ctx = build(ALCHEMY_BUNDLER_URL!) - await logPreconditions("alchemy", ctx) - const success = await ctx.runBundlerDelegation() - expect(success).toBe(true) - }, - 120_000, - ) + it("delegates via Hyperbridge's bundler with the Simplex paymaster", async () => { + const ctx = build(hyperbridgeBundlerUrl(BASE_CHAIN_ID)!) + await logPreconditions("hyperbridge", ctx) + const success = await ctx.runBundlerDelegation() + expect(success).toBe(true) + }, 120_000) }) diff --git a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts index 3e82d3c49c..fcaedb0008 100644 --- a/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts +++ b/sdk/packages/simplex/src/tests/services/UserOpSender.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { toHex } from "viem" -import { fetchRundlerPriorityFee, type HexString } from "@hyperbridge/sdk" +import { rundlerUserOperationFees, type HexString } from "@hyperbridge/sdk" import { UserOpSender, type Eip7702Authorization } from "@/services/UserOpSender" import { buildPaymasterAndData } from "@/services/paymaster" @@ -25,7 +25,7 @@ vi.mock("@/services/paymaster", () => ({ vi.mock("@hyperbridge/sdk", async (importOriginal) => { const actual = await importOriginal() - return { ...actual, fetchRundlerPriorityFee: vi.fn(actual.fetchRundlerPriorityFee) } + return { ...actual, rundlerUserOperationFees: vi.fn(actual.rundlerUserOperationFees) } }) const CHAIN = "EVM-56" @@ -79,7 +79,7 @@ beforeEach(() => { bundlerCalls = [] bundlerReplies = {} vi.mocked(buildPaymasterAndData).mockReset() - vi.mocked(fetchRundlerPriorityFee).mockClear() + vi.mocked(rundlerUserOperationFees).mockClear() vi.stubGlobal("fetch", async (_url: unknown, init?: { body?: string }) => { const { method, params } = JSON.parse(init?.body ?? "{}") as { method: string; params: unknown[] } bundlerCalls.push({ method, params }) @@ -216,53 +216,43 @@ describe("UserOpSender gas price", () => { return { maxFeePerGas: BigInt(op.maxFeePerGas), maxPriorityFeePerGas: BigInt(op.maxPriorityFeePerGas) } }) - const rundlerCalls = () => bundlerCalls.filter((c) => c.method === "rundler_maxPriorityFeePerGas") + const rundlerCalls = () => bundlerCalls.filter((c) => c.method.startsWith("rundler_")).map((c) => c.method) + const METHOD_NOT_FOUND = { error: { code: -32601, message: "Method not found" } } - it("raises the fees to a rundler bundler's priority fee when it is above the chain estimate", async () => { + it("prices a rundler bundler from the fees it suggests, raised by the bumps", async () => { + bundlerReplies.rundler_getUserOperationGasPrice = { + result: { suggested: { maxPriorityFeePerGas: toHex(2_000_000_000n), maxFeePerGas: toHex(5_000_000_000n) } }, + } + + await expect(sponsorWith("http://rundler.test/bundler")).resolves.not.toBeNull() + + const fees = { maxPriorityFeePerGas: 2_160_000_000n, maxFeePerGas: 5_500_000_000n } + expect(sentFees()).toEqual([fees]) + expect(vi.mocked(buildPaymasterAndData).mock.calls[0][0].prefund?.maxFeePerGas).toBe(fees.maxFeePerGas) + expect(rundlerCalls()).toEqual(["rundler_getUserOperationGasPrice"]) + expect(rundlerUserOperationFees).toHaveBeenCalledWith("http://rundler.test/bundler", expect.anything(), expect.anything()) + }) + + it("raises the fees to a rundler bundler's priority fee where it suggests none", async () => { + bundlerReplies.rundler_getUserOperationGasPrice = METHOD_NOT_FOUND // 30 gwei, the Polygon Amoy floor, against a 1 gwei chain gas price. bundlerReplies.rundler_maxPriorityFeePerGas = { result: "0x6fc23ac00" } - await expect(sponsorWith("http://rundler.test/bundler")).resolves.not.toBeNull() + await expect(sponsorWith("http://older-rundler.test/bundler")).resolves.not.toBeNull() const fees = { maxPriorityFeePerGas: 32_400_000_000n, maxFeePerGas: 990_000_000n + 32_400_000_000n } expect(sentFees()).toEqual([fees]) - expect(vi.mocked(buildPaymasterAndData).mock.calls[0][0].prefund?.maxFeePerGas).toBe(fees.maxFeePerGas) - expect(fetchRundlerPriorityFee).toHaveBeenCalledWith("http://rundler.test/bundler") }) it("keeps the chain estimate and stops asking when the bundler does not serve rundler fees", async () => { - bundlerReplies.rundler_maxPriorityFeePerGas = { error: { code: -32601, message: "Method not found" } } + bundlerReplies.rundler_getUserOperationGasPrice = METHOD_NOT_FOUND + bundlerReplies.rundler_maxPriorityFeePerGas = METHOD_NOT_FOUND await sponsorWith("http://other-bundler.test/bundler") await sponsorWith("http://other-bundler.test/bundler") const chainFees = { maxPriorityFeePerGas: 1_080_000_000n, maxFeePerGas: 1_100_000_000n } expect(sentFees()).toEqual([chainFees, chainFees]) - expect(rundlerCalls()).toHaveLength(1) - }) - - it("prices a Pimlico bundler from Pimlico's gas price without asking for rundler fees", async () => { - bundlerReplies.pimlico_getUserOperationGasPrice = { - result: { fast: { maxFeePerGas: toHex(3_000_000_000n), maxPriorityFeePerGas: toHex(2_000_000_000n) } }, - } - bundlerReplies.rundler_maxPriorityFeePerGas = { result: "0x6fc23ac00" } - - await sponsorWith("https://api.pimlico.io/v2/56/rpc?apikey=k") - - expect(sentFees()).toEqual([{ maxFeePerGas: 3_000_000_000n, maxPriorityFeePerGas: 2_000_000_000n }]) - expect(rundlerCalls()).toHaveLength(0) - expect(fetchRundlerPriorityFee).not.toHaveBeenCalled() - }) - - it("prices an Alchemy bundler with its own buffers without the rundler floor", async () => { - bundlerReplies.rundler_maxPriorityFeePerGas = { result: "0x6fc23ac00" } - - await sponsorWith("https://bnb-mainnet.g.alchemy.com/v2/k") - - // A 25% priority bump off Arbitrum, and a 50% base fee buffer. - const fees = { maxPriorityFeePerGas: 37_500_000_000n, maxFeePerGas: 1_350_000_000n + 37_500_000_000n } - expect(sentFees()).toEqual([fees]) - expect(rundlerCalls()).toHaveLength(1) - expect(fetchRundlerPriorityFee).not.toHaveBeenCalled() + expect(rundlerCalls()).toEqual(["rundler_getUserOperationGasPrice", "rundler_maxPriorityFeePerGas"]) }) }) diff --git a/sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts b/sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts index d9d2ffc2d0..17bde0fa20 100644 --- a/sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts +++ b/sdk/packages/simplex/src/tests/services/bundler-preflight.test.ts @@ -201,17 +201,26 @@ describe("assertBundlersServeEntryPoint", () => { expect(calls.map((call) => call.url)).toEqual(["https://arb.example"]) }) - it("skips chains with no bundler or no known EntryPoint", async () => { + it("refuses a filling chain Hyperbridge runs no bundler for, and lets it be watched", async () => { const calls = stubBundlers({}) await expect( - assertBundlersServeEntryPoint( - [ - { chainId: 8453, bundlerUrl: undefined }, - { chainId: 8453, bundlerUrl: " " }, - { chainId: 31337, bundlerUrl: BUNDLER }, - ], - configService, - ), + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: undefined }], configService), + ).rejects.toThrow(/Hyperbridge runs no bundler for .*EVM-8453.*set it watch-only/) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: " " }], configService), + ).rejects.toThrow(/runs no bundler/) + await expect( + assertBundlersServeEntryPoint([{ chainId: 8453, bundlerUrl: undefined }], configService, { + watchOnly: { 8453: true }, + }), + ).resolves.toBeUndefined() + expect(calls).toEqual([]) + }) + + it("skips chains with no known EntryPoint", async () => { + const calls = stubBundlers({}) + await expect( + assertBundlersServeEntryPoint([{ chainId: 31337, bundlerUrl: BUNDLER }], configService), ).resolves.toBeUndefined() expect(calls).toEqual([]) }) diff --git a/sdk/packages/simplex/src/tests/setup-api.test.ts b/sdk/packages/simplex/src/tests/setup-api.test.ts index b9aaf636b2..dffce5dff2 100644 --- a/sdk/packages/simplex/src/tests/setup-api.test.ts +++ b/sdk/packages/simplex/src/tests/setup-api.test.ts @@ -7,7 +7,6 @@ import { UiServer, type SetupContext } from "@/services/server/UiServer" import { validateConfig, type FillerConfigFile } from "@/config/filler-toml" import { SignerType, signerFromToml } from "@/services/wallet" import { SECRET_PHRASE_MASK } from "@/services/server/setup-api" -import { FillerConfigService } from "@/services/FillerConfigService" import { deriveSubstrateKeyPair } from "@/services/substrate-key" import { startMockRpc, type MockRpc } from "./helpers/mock-rpc" import { encryptedConfigStore, isEncryptedConfig } from "@/config/storage" @@ -72,6 +71,7 @@ describe("setup API", () => { token1: "USDC", }, ], + // A bundler an older config named, with a provider key in it: never written back or shown. chains: [{ rpcUrls: [rpcUrl], bundlerUrl: "https://api.pimlico.io/v2/1/rpc?apikey=secretpimlicokey" }], orderbook: { url: "https://orderbook.example/graphql" }, } @@ -118,56 +118,6 @@ describe("setup API", () => { expect(res.error).toContain("different domains") }) - it("probes bundlers as a warning-only check", async () => { - rpc = await startMockRpc({}) - const { base } = await startInitServer() - - const ok = await (await post(base, "validate-bundler", { url: rpc.url })).json() - expect(ok.ok).toBe(true) - expect(ok.entryPoints).toHaveLength(1) - - const dead = await (await post(base, "validate-bundler", { url: "http://127.0.0.1:1/rpc" })).json() - expect(dead.ok).toBe(true) - expect(dead.warning).toBeDefined() - }) - - it("warns when the bundler does not list the chain's EntryPoint", async () => { - rpc = await startMockRpc({}) - const { base } = await startInitServer() - const required = new FillerConfigService([]).getEntryPointAddress("EVM-8453")! - - const res = await (await post(base, "validate-bundler", { url: rpc.url, chainId: 8453 })).json() - expect(res.ok).toBe(true) - expect(res.warning).toContain(required) - expect(res.warning).toContain("0x0000000071727De22E5E9d8BAf0edAc6f37da032") - }) - - it("accepts a bundler listing the chain's EntryPoint in any case, and skips chains without one", async () => { - const required = new FillerConfigService([]).getEntryPointAddress("EVM-8453")! - const rpcRequest = vi.fn(async () => [required.toLowerCase()]) - const { base } = await startInitServer({ deps: { rpcRequest } }) - - const url = "https://bundler.example" - const listed = await (await post(base, "validate-bundler", { url, chainId: 8453 })).json() - expect(listed).toEqual({ ok: true, entryPoints: [required.toLowerCase()] }) - expect(rpcRequest).toHaveBeenCalledWith(url, "eth_supportedEntryPoints", []) - - const unknown = await (await post(base, "validate-bundler", { url, chainId: 31337 })).json() - expect(unknown.warning).toBeUndefined() - }) - - it("warns without promising a refusal when the bundler's answer is not a list of addresses", async () => { - const required = new FillerConfigService([]).getEntryPointAddress("EVM-8453")! - const rpcRequest = vi.fn(async () => ({ entryPoints: [required] })) - const { base } = await startInitServer({ deps: { rpcRequest } }) - - const res = await (await post(base, "validate-bundler", { url: "https://bundler.example", chainId: 8453 })).json() - expect(res.ok).toBe(true) - expect(res.warning).toContain("not a list of addresses") - expect(res.warning).toContain(required) - expect(res.warning).not.toContain("refuse") - }) - it("validates ERC-20 tokens on-chain", async () => { rpc = await startMockRpc({ symbol: "cNGN", decimals: 6 }) const { base } = await startInitServer() @@ -403,7 +353,8 @@ describe("setup API", () => { const written = parse(readFileSync(configPath, "utf-8")) as FillerConfigFile expect(() => validateConfig(written)).not.toThrow() expect(written.simplex.signer?.type).toBe("privateKey") - expect(JSON.parse(JSON.stringify(written))).toEqual(JSON.parse(JSON.stringify(config))) + const expected = { ...config, chains: config.chains.map(({ rpcUrls }) => ({ rpcUrls })) } + expect(JSON.parse(JSON.stringify(written))).toEqual(JSON.parse(JSON.stringify(expected))) await vi.waitFor(() => expect(onSaveAndStart).toHaveBeenCalledTimes(1)) const [bootedConfig, toml, path] = onSaveAndStart.mock.calls[0] diff --git a/sdk/packages/simplex/src/tests/ui-server.test.ts b/sdk/packages/simplex/src/tests/ui-server.test.ts index b9c1969aed..473cf35bab 100644 --- a/sdk/packages/simplex/src/tests/ui-server.test.ts +++ b/sdk/packages/simplex/src/tests/ui-server.test.ts @@ -117,7 +117,7 @@ function fakeConfig(): FillerConfigFile { { token0: "USDC", token1: "CNGN" }, { token0: "USDC", token1: "ZARP" }, ], - chains: [{ rpcUrls: ["https://rpc.example"], bundlerUrl: "https://bundler.example" }], + chains: [{ rpcUrls: ["https://rpc.example"] }], } } @@ -1264,8 +1264,8 @@ describe("UiServer (operator mode)", () => { function chainsConfig(): FillerConfigFile { const config = marketConfig() config.chains = [ - { rpcUrls: ["https://base.example"], bundlerUrl: "https://base-bundler.example" }, - { rpcUrls: ["https://bsc.example"], bundlerUrl: "https://bsc-bundler.example" }, + { rpcUrls: ["https://base.example"] }, + { rpcUrls: ["https://bsc.example"] }, ] return config } @@ -1279,7 +1279,7 @@ describe("UiServer (operator mode)", () => { stateMachineId: "EVM-8453", label: "Base", rpcUrls: ["https://base.example"], - bundlerUrl: "https://base-bundler.example", + bundlerUrl: "https://bundler.polytope.technology/base", watchOnly: false, running: true, }, @@ -1288,7 +1288,7 @@ describe("UiServer (operator mode)", () => { stateMachineId: "EVM-56", label: "BNB Chain", rpcUrls: ["https://bsc.example"], - bundlerUrl: "https://bsc-bundler.example", + bundlerUrl: "https://bundler.polytope.technology/bsc", watchOnly: false, running: true, }, @@ -1307,10 +1307,8 @@ describe("UiServer (operator mode)", () => { const { base } = await startServer({ config, chains: [84532, 11155111] }) const dto = await (await fetch(`${base}/api/chains`)).json() expect(dto.network).toBe("testnet") - expect(dto.catalog.map((c: { chainId: number }) => c.chainId)).toEqual( - expect.arrayContaining([84532, 11155111]), - ) - expect(dto.catalog.map((c: { chainId: number }) => c.chainId)).not.toContain(1) + // Only the testnets Hyperbridge runs a bundler for can be enabled. + expect(dto.catalog.map((c: { chainId: number }) => c.chainId)).toEqual([80002, 97]) }) it("replaces the chain set: probes only new endpoints, persists, and asks for a restart", async () => { @@ -1319,17 +1317,8 @@ describe("UiServer (operator mode)", () => { // Drop BNB Chain, add Arbitrum, and give Base a second quorum provider. const res = await put(base, "/api/chains", { chains: [ - { - chainId: 8453, - rpcUrls: ["https://base.example", "https://base-two.example"], - bundlerUrl: "https://base-bundler.example", - }, - { - chainId: 42161, - rpcUrls: ["https://arb.example"], - bundlerUrl: "https://arb-bundler.example", - watchOnly: true, - }, + { chainId: 8453, rpcUrls: ["https://base.example", "https://base-two.example"] }, + { chainId: 42161, rpcUrls: ["https://arb.example"], watchOnly: true }, ], }) expect(res.status).toBe(200) @@ -1343,7 +1332,7 @@ describe("UiServer (operator mode)", () => { expect(operator.config.simplex.watchOnly).toEqual({ "42161": true }) const written = parse(readFileSync(operator.configPath!, "utf-8")) as FillerConfigFile - expect(written.chains[1].bundlerUrl).toBe("https://arb-bundler.example") + expect(written.chains[1]).toEqual({ rpcUrls: ["https://arb.example"] }) // The rewritten file keeps the chain rows identifiable — the TOML has no chain id. expect(readFileSync(operator.configPath!, "utf-8")).toContain("# Arbitrum") @@ -1358,11 +1347,7 @@ describe("UiServer (operator mode)", () => { it("rejects chain edits that boot would reject, with nothing persisted", async () => { const fetchChainId = vi.fn(async () => 999) const { base, operator } = await startServer({ config: chainsConfig() }, { fetchChainId }) - const base8453 = { - chainId: 8453, - rpcUrls: ["https://base.example"], - bundlerUrl: "https://base-bundler.example", - } + const base8453 = { chainId: 8453, rpcUrls: ["https://base.example"] } const empty = await put(base, "/api/chains", { chains: [] }) expect((await empty.json()).error).toContain("at least one chain") @@ -1372,11 +1357,13 @@ describe("UiServer (operator mode)", () => { }) expect((await sameHost.json()).error).toContain("different domains") - const noBundler = await put(base, "/api/chains", { chains: [{ ...base8453, bundlerUrl: " " }] }) - expect((await noBundler.json()).error).toContain("bundler URL") + const noBundler = await put(base, "/api/chains", { + chains: [base8453, { chainId: 11155111, rpcUrls: ["https://sepolia.example"] }], + }) + expect((await noBundler.json()).error).toContain("Hyperbridge runs no bundler for Sepolia") const uncovered = await put(base, "/api/chains", { - chains: [base8453, { chainId: 999, rpcUrls: ["https://odd.example"], bundlerUrl: "https://odd.example" }], + chains: [base8453, { chainId: 999, rpcUrls: ["https://odd.example"], watchOnly: true }], }) expect((await uncovered.json()).error).toContain("No confirmation policy") @@ -1394,7 +1381,7 @@ describe("UiServer (operator mode)", () => { config.vault = { vaults: [{ chain: "EVM-56", vault: "0x1111111111111111111111111111111111111111" }] } const { base, operator } = await startServer({ config }, { fetchChainId: vi.fn(async () => 8453) }) const res = await put(base, "/api/chains", { - chains: [{ chainId: 8453, rpcUrls: ["https://base.example"], bundlerUrl: "https://base-bundler.example" }], + chains: [{ chainId: 8453, rpcUrls: ["https://base.example"] }], }) expect(res.status).toBe(400) expect((await res.json()).error).toContain("vault treasury") @@ -1408,8 +1395,9 @@ describe("UiServer (operator mode)", () => { ) const res = await put(base, "/api/chains", { chains: [ - { chainId: 8453, rpcUrls: ["https://base.example"], bundlerUrl: "https://base-bundler.example" }, - { chainId: 84532, rpcUrls: ["https://base-sepolia.example"], bundlerUrl: "https://bundler.example" }, + { chainId: 8453, rpcUrls: ["https://base.example"] }, + // Hyperbridge runs no bundler on Base Sepolia, so it can only be watched. + { chainId: 84532, rpcUrls: ["https://base-sepolia.example"], watchOnly: true }, ], }) expect(res.status).toBe(200) @@ -1425,7 +1413,7 @@ describe("UiServer (operator mode)", () => { expect(dto.chains.every((c: { watchOnly: boolean }) => c.watchOnly)).toBe(true) await put(base, "/api/chains", { - chains: [{ chainId: 8453, rpcUrls: ["https://base.example"], bundlerUrl: "https://base-bundler.example" }], + chains: [{ chainId: 8453, rpcUrls: ["https://base.example"] }], }) // Expanding it per chain would stop validateConfig treating this as an // all-watch-only (signer-less) config. @@ -1453,8 +1441,7 @@ describe("UiServer (operator mode)", () => { expect(prefill.valid).toBe(true) const base8453 = prefill.chains.find((c: { chainId: number }) => c.chainId === 8453) expect(base8453.rpcUrl).toContain("test-key") - // Alchemy serves ERC-4337 bundler methods on the same endpoint. - expect(base8453.bundlerUrl).toBe(base8453.rpcUrl) + expect(base8453.bundlerUrl).toBeUndefined() expect((await fetch(`${base}/api/setup/defaults`)).status).toBe(410) expect( diff --git a/sdk/packages/simplex/ui/src/operator/Chains.tsx b/sdk/packages/simplex/ui/src/operator/Chains.tsx index 81c5316076..a20217ce8b 100644 --- a/sdk/packages/simplex/ui/src/operator/Chains.tsx +++ b/sdk/packages/simplex/ui/src/operator/Chains.tsx @@ -41,7 +41,7 @@ export function Chains() { {chain.enabled && !chain.running ? ( Applies after restart ) : null} - {chain.bundlerUrl ? null : Add in the config file} + {chain.bundlerUrl ? null : Watch only}
@@ -50,7 +50,7 @@ export function Chains() { title={ chain.bundlerUrl ? undefined - : "Hyperbridge runs no bundler for this chain. Add the chain to the config file with its bundlerUrl." + : "Hyperbridge runs no bundler for this chain, so it can only be watched." } > > } | undefined +let savedChains: { chains: Array> } | undefined const requests = vi.fn() function Harness() { @@ -48,7 +48,7 @@ beforeEach(() => { stateMachineId: "EVM-8453", label: "Base", rpcUrls: ["https://saved.example", "https://backup.example"], - bundlerUrl: "https://saved-bundler.example", + bundlerUrl: "https://bundler.polytope.technology/base", watchOnly: true, running: true, }, @@ -70,6 +70,8 @@ beforeEach(() => { const meta = dto.catalog.find((chain) => chain.chainId === row.chainId) return { ...row, + // The server reports the chain's Hyperbridge bundler; the panel never sends one. + bundlerUrl: meta?.hyperbridgeBundlerUrl ?? "", stateMachineId: meta?.stateMachineId ?? previous?.stateMachineId ?? `EVM-${row.chainId}`, label: meta?.label ?? previous?.label ?? String(row.chainId), running: previous?.running ?? false, @@ -125,7 +127,6 @@ describe("operator chain settings", () => { { chainId: 1, rpcUrls: ["https://edited.example", ...(dto.catalog[0].defaultRpcUrls?.slice(1) ?? [])], - bundlerUrl: HYPERBRIDGE_BUNDLER[1], watchOnly: false, }, ]), @@ -155,15 +156,12 @@ describe("operator chain settings", () => { expect(chain(1)).toMatchObject({ enabled: true, rpcUrls: dto.catalog[0].defaultRpcUrls }) }) - it("uses the Hyperbridge bundler for every catalog chain, whatever the config held", async () => { + it("shows the Hyperbridge bundler for every catalog chain", async () => { await mount() for (const meta of dto.catalog) { expect(meta.hyperbridgeBundlerUrl, meta.label).toMatch(/^https:\/\/bundler\.polytope\.technology\//) expect(chain(meta.chainId).bundlerUrl).toBe(meta.hyperbridgeBundlerUrl) } - // Base was saved with another bundler. - expect(dto.chains[0].bundlerUrl).toBe("https://saved-bundler.example") - expect(chain(8453).bundlerUrl).toBe(HYPERBRIDGE_BUNDLER[8453]) }) it("keeps one empty RPC field when bundled defaults are missing or empty", async () => { @@ -176,21 +174,27 @@ describe("operator chain settings", () => { for (const draft of model.chains) expect(draft.rpcUrls).toEqual([""]) }) - it("preserves configured chains outside the catalog, with the bundler the config names", async () => { - const custom = { ...dto.chains[0], chainId: 12345, stateMachineId: "EVM-12345", label: "Custom" } + it("preserves configured chains outside the catalog, with no bundler", async () => { + const custom = { + ...dto.chains[0], + chainId: 12345, + stateMachineId: "EVM-12345", + label: "Custom", + bundlerUrl: "", + } dto.chains.push(custom) await mount() - expect(chain(12345)).toMatchObject({ enabled: true, rpcUrls: custom.rpcUrls, bundlerUrl: custom.bundlerUrl }) + expect(chain(12345)).toMatchObject({ enabled: true, rpcUrls: custom.rpcUrls, bundlerUrl: "" }) }) - it("will not enable a chain that has neither a Hyperbridge bundler nor one in the config", async () => { + it("will not enable a chain Hyperbridge runs no bundler for", async () => { dto.catalog = [{ ...dto.catalog[0], hyperbridgeBundlerUrl: undefined }] dto.chains = [] await act(async () => root.render(createElement(Chains))) const toggle = container.querySelector('.chain-enable-toggle input[type="checkbox"]') if (!toggle) throw new Error("Missing enable switch") expect(toggle.disabled).toBe(true) - expect(container.querySelector(".chain-configuration")?.textContent).toContain("Add in the config file") + expect(container.querySelector(".chain-configuration")?.textContent).toContain("Watch only") }) it("verifies the RPC endpoints alone", async () => { @@ -210,7 +214,7 @@ describe("operator chain settings", () => { }) }) - it("saves a new chain and a configured one on the Hyperbridge bundler, then reseeds from the saved config", async () => { + it("saves a new chain and a configured one without a bundler, then reseeds from the saved config", async () => { await mount() const rpcUrls = [...(dto.catalog[0].defaultRpcUrls ?? [])] const savedBaseRpcUrls = [...chain(8453).rpcUrls] @@ -218,8 +222,8 @@ describe("operator chain settings", () => { await act(async () => model.save()) expect(savedChains).toEqual({ chains: [ - { chainId: 1, rpcUrls, bundlerUrl: HYPERBRIDGE_BUNDLER[1], watchOnly: false }, - { chainId: 8453, rpcUrls: savedBaseRpcUrls, bundlerUrl: HYPERBRIDGE_BUNDLER[8453], watchOnly: true }, + { chainId: 1, rpcUrls, watchOnly: false }, + { chainId: 8453, rpcUrls: savedBaseRpcUrls, watchOnly: true }, ], }) expect(model.saved).toBe(true) diff --git a/sdk/packages/simplex/ui/src/operator/chains/useChainSettings.ts b/sdk/packages/simplex/ui/src/operator/chains/useChainSettings.ts index 2973b80870..38f99a9942 100644 --- a/sdk/packages/simplex/ui/src/operator/chains/useChainSettings.ts +++ b/sdk/packages/simplex/ui/src/operator/chains/useChainSettings.ts @@ -8,10 +8,7 @@ export interface ChainDraft { meta: ChainDefault enabled: boolean rpcUrls: string[] - /** - * Not editable here. Hyperbridge's bundler where the catalog has one, whatever the config - * held before; otherwise the bundler the config file names, or none for a chain not yet in it. - */ + /** Not editable. Hyperbridge's bundler the chain fills through, empty where it runs none. */ bundlerUrl: string watchOnly: boolean running: boolean @@ -27,7 +24,7 @@ function seedDrafts(dto: ChainsDto): ChainDraft[] { meta, enabled: Boolean(row), rpcUrls: row ? [...row.rpcUrls] : meta.defaultRpcUrls?.length ? [...meta.defaultRpcUrls] : [""], - bundlerUrl: meta.hyperbridgeBundlerUrl ?? row?.bundlerUrl ?? "", + bundlerUrl: meta.hyperbridgeBundlerUrl ?? "", watchOnly: row?.watchOnly ?? false, running: row?.running ?? false, } @@ -125,7 +122,6 @@ export function useChainSettings() { .map((chain) => ({ chainId: chain.meta.chainId, rpcUrls: chain.rpcUrls.map((url) => url.trim()).filter(Boolean), - bundlerUrl: chain.bundlerUrl.trim(), watchOnly: chain.watchOnly, })), }) diff --git a/sdk/packages/simplex/ui/src/wizard/bundler.test.ts b/sdk/packages/simplex/ui/src/wizard/bundler.test.ts index 979e152069..fb678afc39 100644 --- a/sdk/packages/simplex/ui/src/wizard/bundler.test.ts +++ b/sdk/packages/simplex/ui/src/wizard/bundler.test.ts @@ -3,7 +3,7 @@ import { chainsForNetwork } from "@/cli/init/chains" import type { SetupDefaults } from "../types" import { assembleConfig, initialState } from "./state" -/** The wizard has no bundler field: a chain's bundler is Hyperbridge's, written without asking. */ +/** The wizard has no bundler field: simplex fills through Hyperbridge's bundler for each chain. */ describe("wizard bundlers", () => { const defaults = { chains: chainsForNetwork("mainnet"), @@ -19,17 +19,14 @@ describe("wizard bundlers", () => { for (const chain of initialState(defaults).chains) expect(chain).not.toHaveProperty("bundlerUrl") }) - it("writes Hyperbridge's bundler for every enabled chain", () => { + it("writes no bundler for any enabled chain", () => { const state = initialState(defaults) const enabled = new Set([1, 8453, 56]) const config = assembleConfig( { ...state, chains: state.chains.map((chain) => ({ ...chain, enabled: enabled.has(chain.meta.chainId) })) }, defaults, ) - expect(config.chains.map((chain) => chain.bundlerUrl).sort()).toEqual([ - "https://bundler.polytope.technology/base", - "https://bundler.polytope.technology/bsc", - "https://bundler.polytope.technology/ethereum", - ]) + expect(config.chains).toHaveLength(3) + for (const chain of config.chains) expect(chain).not.toHaveProperty("bundlerUrl") }) }) diff --git a/sdk/packages/simplex/ui/src/wizard/state.ts b/sdk/packages/simplex/ui/src/wizard/state.ts index 5e4d375a3d..c64de04ef0 100644 --- a/sdk/packages/simplex/ui/src/wizard/state.ts +++ b/sdk/packages/simplex/ui/src/wizard/state.ts @@ -228,11 +228,7 @@ export function assembleConfig(state: WizardState, defaults: SetupDefaults): Fil }, pairs, orderbook: { url: state.orderbook?.url ?? DEFAULT_ORDERBOOK_URLS.mainnet }, - chains: chains.map((c) => ({ - rpcUrls: c.rpcUrls.map((u) => u.trim()).filter(Boolean), - // Never asked for: every chain the wizard offers fills through Hyperbridge's bundler. - bundlerUrl: c.meta.hyperbridgeBundlerUrl ?? "", - })), + chains: chains.map((c) => ({ rpcUrls: c.rpcUrls.map((u) => u.trim()).filter(Boolean) })), ...(vaults ? { vault: { vaults } } : {}), ...(allowlistUsers.length > 0 ? { allowlist: { users: allowlistUsers } } : {}), }