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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# SimplexPaymaster and SolverAccount on EntryPoint v0.9, with a bundler allowlist

`SimplexPaymaster` (`evm/src/utils/SimplexPaymaster.sol`) and `SolverAccount`
(`evm/src/apps/intentsv2/SolverAccount.sol`) serve ERC-4337 EntryPoint v0.9
(`0x433709009B8330FDa32311DF1C2AFA402eD8D009`) only. Governance can also restrict which bundler
wallets may submit user operations that the paymaster sponsors.

## SimplexPaymaster on EntryPoint v0.9

`entryPoint()` returns v0.9. A `validatePaymasterUserOp` or `postOp` call from v0.8 reverts
`PaymasterUnauthorized`. The cutover is immediate: once a chain's proxy is upgraded, every v0.8
operation naming the paymaster fails.

v0.9 allows an optional `paymasterSignature` suffix on `paymasterAndData`. The paymaster does not
take one: `paymasterData` must be exactly 150 bytes in PERMIT mode and 182 bytes in PERMIT2 mode,
so a suffix reverts `InvalidPaymasterData`.

`VERSION` is 3. `migrate()` is host-only and runs only on a version-2 proxy. Governance reaches it
through `upgrade_paymaster(state_machine, new_impl, init_data)` with
`init_data = abi.encodeCall(SimplexPaymaster.migrate, ())`, the same bytes on every chain. In the
upgrade transaction it:

1. withdraws the whole v0.8 deposit to the proxy;
2. unlocks the v0.8 stake if it is staked;
3. deposits the proxy's entire native balance into v0.9;
4. emits `EntryPointMigrated(withdrawn, deposited)`.

`migrate` does not stake on v0.9, so the paymaster is unstaked there. Our own bundler runs rundler
with `--unsafe`, which carries sponsored operations without a paymaster stake. If a chain needs one,
the treasury stakes on v0.9 through `addStake`, and governance recovers it with the `UnlockStake`
and `WithdrawStake` requests.

`withdrawStakeV08()` is permissionless. Once the v0.8 unstake delay has passed (86400 seconds on
the live chains), it withdraws the v0.8 stake to the proxy and deposits the proxy's entire native
balance into v0.9. A call before the delay, or after the stake is withdrawn, reverts in v0.8.

The EntryPoint addresses are constants, so the move adds no storage. The pallet needs no change,
because `init_data` is opaque bytes.

## SolverAccount on EntryPoint v0.9

`entryPoint()` returns v0.9 only, which gates `validateUserOp`, `getNonce` and the EntryPoint's
right to execute batches. The account's code is immutable, so v0.9 is a new deployment and solvers
re-delegate to it. The `Budgets` storage slot is unchanged, so limit-order budget tallies survive
the re-delegation. Nonces are per EntryPoint, so they restart, and bids must be signed for v0.9.

## Bundler allowlist

### On-chain check

`_validatePaymasterUserOp` reverts `UnauthorizedBundler(origin)` when the bundler list is non-empty
and `tx.origin` is not on it. The check is its first step, before any permit or prefund logic.

An empty list turns the check off, so removing the last listed wallet turns it off. ERC-7562 bans
ORIGIN during validation, so `tx.origin` is read only while the list is non-empty. On a chain with
the check on, only our own bundler (rundler run with `--unsafe`) can carry sponsored operations.

The list is `EnumerableSet.AddressSet _bundlers` at slots 9 and 10, taken out of `__gap`, which
shrinks to `uint256[46]`. Every other field and the end of the proxy layout keep their slots.
`getBundlers()` returns the listed wallets, and `BundlerUpdated(address indexed bundler, bool allowed)`
is emitted only when a wallet is actually added or removed.

### Governance

`RequestKind.SetBundlers` (8) takes the body `0x08 ++ abi.encode(address[] bundlers, bool allowed)`.
`allowed = true` lists the wallets and `false` delists them. Listing a listed wallet or delisting an
unlisted one is a no-op, and a zero address reverts `ZeroAddress`. The request arrives through
`onAccept`, so the relayer gate applies as for every other kind.

On Hyperbridge, `pallet-intents-coprocessor` exposes `set_paymaster_bundlers(state_machine, bundlers,
allowed)` at call index 21, behind `GovernanceOrigin`. It rejects an empty list or a zero entry with
`InvalidPaymasterBundlers`, builds the body from `RequestKind::PaymasterSetBundlers`, dispatches it to
the paymaster registered for `state_machine`, and emits `PaymasterBundlersUpdateInitiated`. The SDK has
no encoder for paymaster governance requests; the pallet builds every body.

### Turning the check on for a chain

- The first `set_paymaster_bundlers` request for a chain must list, together, every rundler signer
wallet (any signer can bundle for any enabled EntryPoint), a spare wallet, and rundler's
simulation origin `0x0643866dA50efE0b055Cd15aF95191968c8411b5`. Without the simulation origin,
rundler's validation and gas estimation fail for every sponsored op. Rundler only simulates from
that origin, so it still sends bundles from an unlisted signer; they revert on-chain at the
signer's cost. Remove a signer only after it stops bundling.
- Bundler signer wallets must be plain EOAs. EntryPoint v0.9's `handleOps` requires its caller to
be `tx.origin` with no code, so a wallet with an EIP-7702 delegation cannot bundle.

## Rollout constraints

- The SDK must resolve the EntryPoint for each bid and be released before any chain's paymaster is
upgraded. An SDK that hard-codes v0.8 cannot execute v0.9 bids.
- On each chain, the SDK config must change the `SolverAccount` address and the EntryPoint
together.
- Each chain's bundler must have v0.9 enabled.
8 changes: 5 additions & 3 deletions evm/script/DeploySimplexPaymaster.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,14 @@ contract DeployScript is BaseScript {

vm.stopBroadcast();
require(paymaster.relayer() == relayer, "relayer not armed");
require(paymaster.version() == 2, "unexpected paymaster version");
require(paymaster.version() == 3, "unexpected paymaster version");
config.set("SIMPLEX_PAYMASTER", address(paymaster));

console.log("");
console.log("=== IMPORTANT: Post-deployment steps ===");
console.log("1. Fund the EntryPoint deposit for the paymaster:");
console.log(" cast send <ENTRY_POINT> \"depositTo(address)\" ", address(paymaster), " --value 0.01ether");
console.log("1. Fund the paymaster's EntryPoint v0.9 deposit (any sender):");
console.log(" cast send", address(paymaster), "\"deposit()\" --value 0.01ether");
console.log("2. Stake the paymaster on EntryPoint v0.9 from the treasury", treasury);
console.log(" cast send", address(paymaster), "\"addStake(uint32)\" 86400 --value <STAKE>");
}
}
6 changes: 4 additions & 2 deletions evm/script/DeploySimplexPaymasterImpl.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import {BaseScript} from "./BaseScript.sol";

/// @notice Deploys a new SimplexPaymaster implementation only. The live ERC-1967 proxy keeps its
/// address; Hyperbridge governance points it at this implementation through the
/// intents-coprocessor pallet's `upgrade_paymaster`, with `migrate(relayer)` as the init data so
/// the relayer gate is armed in the same transaction.
/// intents-coprocessor pallet's `upgrade_paymaster`, with `migrate()` as the init data so the
/// proxy's EntryPoint deposit moves to v0.9 and its v0.8 stake unlocks in the same transaction.
contract DeployScript is BaseScript {
using strings for *;

Expand All @@ -20,6 +20,8 @@ contract DeployScript is BaseScript {
vm.stopBroadcast();

console.log("SimplexPaymaster implementation deployed at:", address(implementation));
console.log("upgrade_paymaster init_data:", vm.toString(abi.encodeCall(SimplexPaymaster.migrate, ())));
console.log("After the old stake's unstake delay, call withdrawStakeV08() to move it into the v0.9 deposit.");

config.set("SIMPLEX_PAYMASTER_IMPL", address(implementation));
}
Expand Down
3 changes: 1 addition & 2 deletions evm/script/SimplexPaymasterPermit2Probe.s.sol
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ pragma solidity ^0.8.24;
import "forge-std/Script.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";

import {SimplexPaymaster, AggregatorV3Interface} from "../src/utils/SimplexPaymaster.sol";
import {SolverAccount} from "../src/apps/intentsv2/SolverAccount.sol";
Expand Down Expand Up @@ -69,7 +68,7 @@ contract SimplexPaymasterPermit2ProbeScript is Script {
SimplexPaymaster paymaster =
SimplexPaymaster(payable(address(new ERC1967Proxy(address(implementation), initData))));

ERC4337Utils.ENTRYPOINT_V08.depositTo{value: 0.05 ether}(address(paymaster));
paymaster.deposit{value: 0.05 ether}();
paymaster.addStake{value: 0.1 ether}(86_400);

vm.stopBroadcast();
Expand Down
15 changes: 14 additions & 1 deletion evm/src/apps/intentsv2/SolverAccount.sol
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ pragma solidity ^0.8.17;
import {Account} from "@openzeppelin/contracts/account/Account.sol";
import {ERC4337Utils} from "@openzeppelin/contracts/account/utils/draft-ERC4337Utils.sol";
import {ERC7821} from "@openzeppelin/contracts/account/extensions/draft-ERC7821.sol";
import {PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
import {IEntryPoint, PackedUserOperation} from "@openzeppelin/contracts/interfaces/draft-IERC4337.sol";
import {Execution} from "@openzeppelin/contracts/interfaces/draft-IERC7579.sol";
import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {IERC1271} from "@openzeppelin/contracts/interfaces/IERC1271.sol";
Expand Down Expand Up @@ -58,6 +58,11 @@ contract SolverAccount is Account, ERC7821, IERC1271 {
*/
bytes32 private constant BUDGETS_STORAGE_SLOT = 0xef37eedb8cd243d7bb1074a6cb5a4fad8c39bd328408761135c4a5a7d5c29900;

/**
* @dev ERC-4337 EntryPoint v0.9, the only one this account accepts.
*/
IEntryPoint private constant ENTRYPOINT_V09 = IEntryPoint(0x433709009B8330FDa32311DF1C2AFA402eD8D009);

/**
* @dev A plain ECDSA signature: r, s, v.
*/
Expand Down Expand Up @@ -97,6 +102,14 @@ contract SolverAccount is Account, ERC7821, IERC1271 {
_intentGateway = gateway;
}

/**
* @dev EntryPoint v0.9 in place of OpenZeppelin's v0.8. It gates `validateUserOp`, `getNonce`
* and the EntryPoint's right to execute batches.
*/
function entryPoint() public pure override returns (IEntryPoint) {
return ENTRYPOINT_V09;
}

/**
* @dev A 65-byte signature is plain ECDSA over `userOpHash`. It is refused for ops that call
* `fillOrder`: bids are public, so a bid's solver signature could be replayed here to burn its
Expand Down
Loading
Loading